From 686fc4e2e2ea13373e353a43f612a87234a706c7 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 15:56:39 +0800 Subject: [PATCH] refactor: stabilize cumulative mileage metrics --- src/server/routes/analytics/index.test.ts | 6 +++--- src/server/routes/mileage/monitoring.ts | 5 +++-- src/server/routes/mileage/precision.test.ts | 13 +++++++++++++ src/server/routes/mileage/precision.ts | 13 +++++++++++++ src/shared/analytics/catalog.test.ts | 4 ++++ src/shared/analytics/catalog.ts | 15 ++++++++++++++- 6 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 src/server/routes/mileage/precision.test.ts create mode 100644 src/server/routes/mileage/precision.ts diff --git a/src/server/routes/analytics/index.test.ts b/src/server/routes/analytics/index.test.ts index b336bcb..83d9ab9 100644 --- a/src/server/routes/analytics/index.test.ts +++ b/src/server/routes/analytics/index.test.ts @@ -7,7 +7,7 @@ test('returns the published mileage metric contract', async () => { const payload = await response.json(); assert.equal(response.status, 200); - assert.equal(payload.catalogVersion, 5); + assert.equal(payload.catalogVersion, 6); assert.deepEqual(payload.domains, ['mileage']); 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(); assert.equal(response.status, 200); - assert.equal(payload.catalogVersion, 5); + assert.equal(payload.catalogVersion, 6); 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.blended_cost_intensity')); @@ -39,7 +39,7 @@ test('returns the published ETC metric contract', async () => { const payload = await response.json(); assert.equal(response.status, 200); - assert.equal(payload.catalogVersion, 5); + assert.equal(payload.catalogVersion, 6); 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.bill_receivable')); diff --git a/src/server/routes/mileage/monitoring.ts b/src/server/routes/mileage/monitoring.ts index f01ddbe..a2b5d73 100644 --- a/src/server/routes/mileage/monitoring.ts +++ b/src/server/routes/mileage/monitoring.ts @@ -17,6 +17,7 @@ import { getManualMonitoringSnapshot, storeManualMonitoringSnapshot, } from './manual-snapshot.js'; +import { sumMileageKm } from './precision.js'; const app = new Hono(); @@ -245,9 +246,9 @@ app.get('/', async (c) => { const stats = { 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, - yesterdayTotal: filtered.reduce((sum, v) => sum + v.yesterdayKm, 0), + yesterdayTotal: sumMileageKm(filtered.map(v => v.yesterdayKm)), }; const sorted = [...filtered].sort((a, b) => { diff --git a/src/server/routes/mileage/precision.test.ts b/src/server/routes/mileage/precision.test.ts new file mode 100644 index 0000000..7024735 --- /dev/null +++ b/src/server/routes/mileage/precision.test.ts @@ -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); +}); diff --git a/src/server/routes/mileage/precision.ts b/src/server/routes/mileage/precision.ts new file mode 100644 index 0000000..c9b6dbe --- /dev/null +++ b/src/server/routes/mileage/precision.ts @@ -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, decimals = 1): number { + let total = 0; + for (const value of values) { + if (Number.isFinite(value)) total += value; + } + return roundMileageKm(total, decimals); +} diff --git a/src/shared/analytics/catalog.test.ts b/src/shared/analytics/catalog.test.ts index 661139b..90bd810 100644 --- a/src/shared/analytics/catalog.test.ts +++ b/src/shared/analytics/catalog.test.ts @@ -19,6 +19,10 @@ test('lists mileage metrics without exposing the catalog array', () => { assert.ok(metrics.length >= 5); assert.notEqual(metrics, METRIC_CATALOG); 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', () => { diff --git a/src/shared/analytics/catalog.ts b/src/shared/analytics/catalog.ts index c0c4e14..40460f1 100644 --- a/src/shared/analytics/catalog.ts +++ b/src/shared/analytics/catalog.ts @@ -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'; } -export const METRIC_CATALOG_VERSION = 5; +export const METRIC_CATALOG_VERSION = 6; const MILEAGE_METRICS: readonly MetricDefinition[] = [ { @@ -72,6 +72,19 @@ const MILEAGE_METRICS: readonly MetricDefinition[] = [ dimensions: ['assessment-year', 'assessment-target', 'department', 'customer', 'plate'], 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', domain: 'mileage',