refactor: stabilize cumulative mileage metrics

This commit is contained in:
kkfluous
2026-08-07 15:56:39 +08:00
parent 8c6a3ad831
commit 686fc4e2e2
6 changed files with 50 additions and 6 deletions
+3 -3
View File
@@ -7,7 +7,7 @@ test('returns the published mileage metric contract', async () => {
const payload = await response.json(); const payload = await response.json();
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 5); assert.equal(payload.catalogVersion, 6);
assert.deepEqual(payload.domains, ['mileage']); assert.deepEqual(payload.domains, ['mileage']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate')); assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'mileage.assessment_completion_rate'));
}); });
@@ -27,7 +27,7 @@ test('returns the published electric metric contract', async () => {
const payload = await response.json(); const payload = await response.json();
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 5); assert.equal(payload.catalogVersion, 6);
assert.deepEqual(payload.domains, ['electric']); assert.deepEqual(payload.domains, ['electric']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.charge_total_fee')); assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.charge_total_fee'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.blended_cost_intensity')); assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'electric.blended_cost_intensity'));
@@ -39,7 +39,7 @@ test('returns the published ETC metric contract', async () => {
const payload = await response.json(); const payload = await response.json();
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.equal(payload.catalogVersion, 5); assert.equal(payload.catalogVersion, 6);
assert.deepEqual(payload.domains, ['etc']); assert.deepEqual(payload.domains, ['etc']);
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.total_amount')); assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.total_amount'));
assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.bill_receivable')); assert.ok(payload.metrics.some((metric: { id: string }) => metric.id === 'etc.bill_receivable'));
+3 -2
View File
@@ -17,6 +17,7 @@ import {
getManualMonitoringSnapshot, getManualMonitoringSnapshot,
storeManualMonitoringSnapshot, storeManualMonitoringSnapshot,
} from './manual-snapshot.js'; } from './manual-snapshot.js';
import { sumMileageKm } from './precision.js';
const app = new Hono(); const app = new Hono();
@@ -245,9 +246,9 @@ app.get('/', async (c) => {
const stats = { const stats = {
totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)), totalToday: Math.round(filtered.reduce((sum, v) => sum + v.dailyKm, 0)),
totalAll: filtered.reduce((sum, v) => sum + (v.totalKm || 0), 0), totalAll: sumMileageKm(filtered.map(v => v.totalKm || 0)),
vehicleCount: filtered.length, vehicleCount: filtered.length,
yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0), yesterdayTotal: sumMileageKm(filtered.map(v => v.yesterdayKm)),
}; };
const sorted = [...filtered].sort((a, b) => { const sorted = [...filtered].sort((a, b) => {
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { roundMileageKm, sumMileageKm } from './precision.js';
test('removes binary floating point tails at the published precision', () => {
assert.equal(sumMileageKm([0.1, 0.2]), 0.3);
assert.equal(roundMileageKm(44_488_200.300000004), 44_488_200.3);
});
test('rounds after aggregation and ignores invalid values', () => {
assert.equal(sumMileageKm([0.04, 0.04]), 0.1);
assert.equal(sumMileageKm([1, Number.NaN, Number.POSITIVE_INFINITY]), 1);
});
+13
View File
@@ -0,0 +1,13 @@
export function roundMileageKm(value: number, decimals = 1): number {
if (!Number.isFinite(value)) return 0;
const scale = 10 ** decimals;
return Math.round((value + Number.EPSILON) * scale) / scale;
}
export function sumMileageKm(values: Iterable<number>, decimals = 1): number {
let total = 0;
for (const value of values) {
if (Number.isFinite(value)) total += value;
}
return roundMileageKm(total, decimals);
}
+4
View File
@@ -19,6 +19,10 @@ test('lists mileage metrics without exposing the catalog array', () => {
assert.ok(metrics.length >= 5); assert.ok(metrics.length >= 5);
assert.notEqual(metrics, METRIC_CATALOG); assert.notEqual(metrics, METRIC_CATALOG);
assert.ok(metrics.every(metric => metric.domain === 'mileage')); assert.ok(metrics.every(metric => metric.domain === 'mileage'));
assert.equal(
metrics.find(metric => metric.id === 'mileage.cumulative_odometer_sum')?.timeSemantics,
'snapshot',
);
}); });
test('publishes hydrogen cost, revenue, and gross profit as separate metrics', () => { test('publishes hydrogen cost, revenue, and gross profit as separate metrics', () => {
+14 -1
View File
@@ -17,7 +17,7 @@ export interface MetricDefinition {
drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order' | 'electric-charge-order' | 'etc-toll-record' | 'etc-bill'; drillEntity: 'vehicle' | 'assessment-target' | 'hydrogen-station' | 'hydrogen-customer' | 'hydrogen-order' | 'electric-charge-order' | 'etc-toll-record' | 'etc-bill';
} }
export const METRIC_CATALOG_VERSION = 5; export const METRIC_CATALOG_VERSION = 6;
const MILEAGE_METRICS: readonly MetricDefinition[] = [ const MILEAGE_METRICS: readonly MetricDefinition[] = [
{ {
@@ -72,6 +72,19 @@ const MILEAGE_METRICS: readonly MetricDefinition[] = [
dimensions: ['assessment-year', 'assessment-target', 'department', 'customer', 'plate'], dimensions: ['assessment-year', 'assessment-target', 'department', 'customer', 'plate'],
drillEntity: 'assessment-target', drillEntity: 'assessment-target',
}, },
{
id: 'mileage.cumulative_odometer_sum',
domain: 'mileage',
label: '累计里程合计',
description: '当前筛选范围内每辆车最新累计里程表值之和,是快照指标,不能跨日期累加。',
unit: 'km',
aggregation: 'sum',
timeSemantics: 'snapshot',
formula: 'ROUND(SUM(latest_vehicle_total_mileage_km), 1)',
sources: ['OneOS mileage API'],
dimensions: ['department', 'region', 'customer', 'vehicle-model', 'plate'],
drillEntity: 'vehicle',
},
{ {
id: 'mileage.data_freshness', id: 'mileage.data_freshness',
domain: 'mileage', domain: 'mileage',