From 559094cdc5957591c2adff1bc535ac506df0af57 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Fri, 7 Aug 2026 18:44:49 +0800 Subject: [PATCH] fix: decouple mileage KPIs from sorting --- docs/bi-refactor-roadmap.md | 1 + src/modules/mileage/MonitoringView.tsx | 36 ++++++++++------ src/modules/mileage/monitoring-kpis.test.ts | 47 +++++++++++++++++++++ src/modules/mileage/monitoring-kpis.ts | 31 ++++++++++++++ src/server/routes/analytics/index.test.ts | 7 +-- src/shared/analytics/catalog.test.ts | 4 ++ src/shared/analytics/catalog.ts | 15 ++++++- 7 files changed, 123 insertions(+), 18 deletions(-) create mode 100644 src/modules/mileage/monitoring-kpis.test.ts create mode 100644 src/modules/mileage/monitoring-kpis.ts diff --git a/docs/bi-refactor-roadmap.md b/docs/bi-refactor-roadmap.md index a00944e..573282c 100644 --- a/docs/bi-refactor-roadmap.md +++ b/docs/bi-refactor-roadmap.md @@ -179,6 +179,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0) - 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。 - 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。 - 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。 +- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。 - 已完成电能数据截至时间和实际趋势月展示。 - 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。 - 已完成氢能结构化故障状态与重试体验。 diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index d224490..d5511b3 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -29,6 +29,7 @@ import { parseMileageMonitoringContext, type MileageMonitoringContext, } from './monitoring-context'; +import { buildMonitoringKpis } from './monitoring-kpis'; const HIGH_MILEAGE_ALERT_TARGETS = new Set([ '交投40辆4.5T普货', @@ -473,6 +474,7 @@ export default function MonitoringView() { const [fullscreenError, setFullscreenError] = useState(null); const [fullscreenSnapshotKey, setFullscreenSnapshotKey] = useState(null); const [fullscreenUpdatedAt, setFullscreenUpdatedAt] = useState(null); + const [fullscreenDateRange, setFullscreenDateRange] = useState<{ start: string; end: string } | undefined>(); // New filters from image const [filterPlates, setFilterPlates] = useState(initialMonitoringContext.plates); @@ -704,6 +706,11 @@ export default function MonitoringView() { const averageDailyKm = pageHasSnapshot && rangeDailyTotals.length > 0 ? rangeDailyTotals.reduce((sum, item) => sum + item.totalKm, 0) / rangeDailyTotals.length : 0; + const pageKpis = buildMonitoringKpis(stats, displayedRange); + const fullscreenKpis = buildMonitoringKpis( + fullscreenStats, + fullscreenHasSnapshot ? fullscreenDateRange : { start: rangeStart, end: rangeEnd }, + ); // 加载首页数据 const loadFirstPage = useCallback((showPageLoading = true, force = false) => { @@ -871,6 +878,7 @@ export default function MonitoringView() { setFullscreenStats(d.stats); setFilterOptions(d.filters); setFullscreenUpdatedAt(d.updatedAt); + setFullscreenDateRange(d.dateRange || { start: rangeStart, end: rangeEnd }); setFullscreenSnapshotKey(monitoringRequestKey); }).catch(error => { if (cancelled) return; @@ -879,7 +887,7 @@ export default function MonitoringView() { if (!cancelled) setFullscreenLoading(false); }); return () => { cancelled = true; }; - }, [fullscreenRefresh, isFullscreen, monitoringFilters, monitoringRequestKey, sortBy, sortOrder]); + }, [fullscreenRefresh, isFullscreen, monitoringFilters, monitoringRequestKey, rangeEnd, rangeStart, sortBy, sortOrder]); // 全屏时禁止背景滚动 useEffect(() => { @@ -943,13 +951,13 @@ export default function MonitoringView() {

全屏监控

- 区间 {fullscreenHasSnapshot ? Math.round(fullscreenStats.totalToday).toLocaleString() : '—'} km + {fullscreenKpis.distanceShortLabel} {fullscreenHasSnapshot ? Math.round(fullscreenKpis.distanceKm).toLocaleString() : '—'} km | - 累计 {fullscreenHasSnapshot ? Math.round(fullscreenStats.totalAll).toLocaleString() : '—'} km + 累计 {fullscreenHasSnapshot ? Math.round(fullscreenKpis.cumulativeOdometerKm).toLocaleString() : '—'} km | - 车辆 {fullscreenHasSnapshot ? fullscreenStats.vehicleCount : '—'} + 车辆 {fullscreenHasSnapshot ? fullscreenKpis.vehicleCount : '—'} | - {fullscreenHasSnapshot ? (fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0) : '—'} km + 单车均 {fullscreenHasSnapshot ? fullscreenKpis.averagePerVehicleKm.toFixed(0) : '—'} km | 来源 @@ -1275,9 +1283,9 @@ export default function MonitoringView() { {manualRefreshing ? '刷新中' : '刷新数据'}
- - - + + +
当日总计
-
{Math.round(stats.totalToday).toLocaleString()}
+
{Math.round(pageKpis.distanceKm).toLocaleString()}
日均单车
-
{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}
+
{Math.round(pageKpis.averagePerVehicleKm).toLocaleString()}
)} diff --git a/src/modules/mileage/monitoring-kpis.test.ts b/src/modules/mileage/monitoring-kpis.test.ts new file mode 100644 index 0000000..40e4ccf --- /dev/null +++ b/src/modules/mileage/monitoring-kpis.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildMonitoringKpis } from './monitoring-kpis.js'; + +test('builds daily monitoring KPIs from the selected-day flow metric', () => { + const model = buildMonitoringKpis( + { totalToday: 120, totalAll: 3_000, vehicleCount: 3, yesterdayTotal: 100 }, + { start: '2026-08-07', end: '2026-08-07' }, + ); + + assert.deepEqual(model, { + distanceMetricId: 'mileage.daily_total', + distanceLabel: '当日总里程', + distanceShortLabel: '当日', + distanceKm: 120, + averagePerVehicleKm: 40, + vehicleCount: 3, + cumulativeOdometerKm: 3_000, + }); +}); + +test('builds range KPIs without substituting cumulative odometer values', () => { + const model = buildMonitoringKpis( + { totalToday: 900, totalAll: 30_000, vehicleCount: 6, yesterdayTotal: 0 }, + { start: '2026-08-01', end: '2026-08-07' }, + ); + + assert.equal(model.distanceMetricId, 'mileage.period_total'); + assert.equal(model.distanceLabel, '区间总里程'); + assert.equal(model.distanceKm, 900); + assert.equal(model.averagePerVehicleKm, 150); + assert.equal(model.cumulativeOdometerKm, 30_000); +}); + +test('normalizes invalid monitoring KPI inputs', () => { + const model = buildMonitoringKpis({ + totalToday: Number.NaN, + totalAll: -10, + vehicleCount: Number.POSITIVE_INFINITY, + yesterdayTotal: 0, + }); + + assert.equal(model.distanceKm, 0); + assert.equal(model.averagePerVehicleKm, 0); + assert.equal(model.vehicleCount, 0); + assert.equal(model.cumulativeOdometerKm, 0); +}); diff --git a/src/modules/mileage/monitoring-kpis.ts b/src/modules/mileage/monitoring-kpis.ts new file mode 100644 index 0000000..5bfa8fd --- /dev/null +++ b/src/modules/mileage/monitoring-kpis.ts @@ -0,0 +1,31 @@ +import type { MonitoringStats } from './types'; + +export interface MonitoringKpiModel { + distanceMetricId: 'mileage.daily_total' | 'mileage.period_total'; + distanceLabel: '当日总里程' | '区间总里程'; + distanceShortLabel: '当日' | '区间'; + distanceKm: number; + averagePerVehicleKm: number; + vehicleCount: number; + cumulativeOdometerKm: number; +} + +export function buildMonitoringKpis( + stats: MonitoringStats, + dateRange?: { start: string; end: string }, +): MonitoringKpiModel { + const isRange = !!dateRange?.start && !!dateRange?.end && dateRange.start !== dateRange.end; + const vehicleCount = Number.isFinite(stats.vehicleCount) ? Math.max(0, stats.vehicleCount) : 0; + const distanceKm = Number.isFinite(stats.totalToday) ? Math.max(0, stats.totalToday) : 0; + const cumulativeOdometerKm = Number.isFinite(stats.totalAll) ? Math.max(0, stats.totalAll) : 0; + + return { + distanceMetricId: isRange ? 'mileage.period_total' : 'mileage.daily_total', + distanceLabel: isRange ? '区间总里程' : '当日总里程', + distanceShortLabel: isRange ? '区间' : '当日', + distanceKm, + averagePerVehicleKm: vehicleCount > 0 ? distanceKm / vehicleCount : 0, + vehicleCount, + cumulativeOdometerKm, + }; +} diff --git a/src/server/routes/analytics/index.test.ts b/src/server/routes/analytics/index.test.ts index ff994ac..6f3b9e1 100644 --- a/src/server/routes/analytics/index.test.ts +++ b/src/server/routes/analytics/index.test.ts @@ -7,9 +7,10 @@ test('returns the published mileage metric contract', async () => { const payload = await response.json(); assert.equal(response.status, 200); - assert.equal(payload.catalogVersion, 6); + assert.equal(payload.catalogVersion, 7); 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.average_per_vehicle')); }); test('returns the published hydrogen metric contract', async () => { @@ -27,7 +28,7 @@ test('returns the published electric metric contract', async () => { const payload = await response.json(); assert.equal(response.status, 200); - assert.equal(payload.catalogVersion, 6); + assert.equal(payload.catalogVersion, 7); 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 +40,7 @@ test('returns the published ETC metric contract', async () => { const payload = await response.json(); assert.equal(response.status, 200); - assert.equal(payload.catalogVersion, 6); + assert.equal(payload.catalogVersion, 7); 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/shared/analytics/catalog.test.ts b/src/shared/analytics/catalog.test.ts index 90bd810..ddf6804 100644 --- a/src/shared/analytics/catalog.test.ts +++ b/src/shared/analytics/catalog.test.ts @@ -23,6 +23,10 @@ test('lists mileage metrics without exposing the catalog array', () => { metrics.find(metric => metric.id === 'mileage.cumulative_odometer_sum')?.timeSemantics, 'snapshot', ); + assert.equal( + metrics.find(metric => metric.id === 'mileage.average_per_vehicle')?.formula, + 'mileage.period_total / NULLIF(mileage.vehicle_count, 0)', + ); }); 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 40460f1..4377b91 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 = 6; +export const METRIC_CATALOG_VERSION = 7; const MILEAGE_METRICS: readonly MetricDefinition[] = [ { @@ -59,6 +59,19 @@ const MILEAGE_METRICS: readonly MetricDefinition[] = [ dimensions: ['department', 'region', 'customer', 'vehicle-model'], drillEntity: 'vehicle', }, + { + id: 'mileage.average_per_vehicle', + domain: 'mileage', + label: '平均单车里程', + description: '所选自然日或日期区间总里程除以当前筛选范围内的去重监控车辆数,不使用累计仪表值。', + unit: 'km', + aggregation: 'derived', + timeSemantics: 'flow', + formula: 'mileage.period_total / NULLIF(mileage.vehicle_count, 0)', + sources: ['OneOS mileage API', 'Asset database'], + dimensions: ['date', 'department', 'region', 'customer', 'vehicle-model'], + drillEntity: 'vehicle', + }, { id: 'mileage.assessment_completion_rate', domain: 'mileage',