fix: decouple mileage KPIs from sorting

This commit is contained in:
kkfluous
2026-08-07 18:44:49 +08:00
parent c0d504cb7b
commit 559094cdc5
7 changed files with 123 additions and 18 deletions
+1
View File
@@ -179,6 +179,7 @@ SUM(fee) / NULLIF(SUM(kwh), 0)
- 里程考核车辆日期侧栏仅展示带目标和日期标识的成功快照;切换日期失败时保留并标注上一成功日期,首次失败不再回退为当前日数据或伪造“0 辆”。
- 里程实时监控的首页、分页、全屏和导出已共用同一组筛选参数;全屏首次故障不再显示零值,刷新故障会保留并标注上一成功快照,导出成功或失败均提供可见结果和重试入口。
- 里程实时监控主列表快照已绑定完整筛选与排序口径;新口径请求失败时隐藏旧 KPI、趋势和车辆,原口径刷新失败时保留并标注旧快照,快速筛选与分页的过期响应不会覆盖当前结果。
- 里程实时监控已将排序维度与 KPI 公式解耦:主值和平均单车始终使用当日/区间流量里程,累计仪表合计保持独立快照语义;平均单车里程已纳入版本 7 指标目录。
- 已完成电能数据截至时间和实际趋势月展示。
- 电能日期下钻自动核对日汇总与订单全量合计的日期、车辆范围、电量和费用,并显式展示通过或差异状态。
- 已完成氢能结构化故障状态与重试体验。
+22 -14
View File
@@ -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<string | null>(null);
const [fullscreenSnapshotKey, setFullscreenSnapshotKey] = useState<string | null>(null);
const [fullscreenUpdatedAt, setFullscreenUpdatedAt] = useState<string | null>(null);
const [fullscreenDateRange, setFullscreenDateRange] = useState<{ start: string; end: string } | undefined>();
// New filters from image
const [filterPlates, setFilterPlates] = useState<string[]>(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() {
<h2 className="text-white font-bold text-xs"></h2>
</div>
<div className="flex items-center gap-3 text-[10px]">
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenStats.totalToday).toLocaleString() : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-500">{fullscreenKpis.distanceShortLabel} <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenKpis.distanceKm).toLocaleString() : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenStats.totalAll).toLocaleString() : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? Math.round(fullscreenKpis.cumulativeOdometerKm).toLocaleString() : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? fullscreenStats.vehicleCount : '—'}</span> </span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? fullscreenKpis.vehicleCount : '—'}</span> </span>
<span className="text-slate-700">|</span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? (fullscreenStats.vehicleCount > 0 ? (sortBy === 'total' ? fullscreenStats.totalAll : fullscreenStats.totalToday) / fullscreenStats.vehicleCount : 0).toFixed(0) : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-500"> <span className="text-white font-black">{fullscreenHasSnapshot ? fullscreenKpis.averagePerVehicleKm.toFixed(0) : '—'}</span> <span className="text-blue-400">km</span></span>
<span className="text-slate-700">|</span>
<span className="text-slate-500">
<span className="text-white font-black">
@@ -1275,9 +1283,9 @@ export default function MonitoringView() {
<span>{manualRefreshing ? '刷新中' : '刷新数据'}</span>
</button>
<div className="flex items-center gap-1 rounded-lg bg-slate-100 p-0.5">
<button onClick={() => setSortBy('today')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button onClick={() => setSortBy('total')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button onClick={() => setSortBy('statisticTime')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'statisticTime' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button aria-pressed={sortBy === 'today'} title="按所选日期区间里程排序" onClick={() => setSortBy('today')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'today' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button aria-pressed={sortBy === 'total'} title="按最新累计仪表里程排序" onClick={() => setSortBy('total')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'total' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button aria-pressed={sortBy === 'statisticTime'} title="按最后有效数据时间排序" onClick={() => setSortBy('statisticTime')} className={`rounded-md px-2 py-1 text-[9px] font-bold transition-all ${sortBy === 'statisticTime' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400'}`}></button>
<button
onClick={() => setSortOrder(sortOrder === 'desc' ? 'asc' : 'desc')}
className="rounded-md p-1 text-blue-600 transition-all hover:bg-white"
@@ -1650,11 +1658,11 @@ export default function MonitoringView() {
<div className="sticky top-[44px] z-20 bg-[var(--app-bg)] pt-1 pb-1 space-y-2">
<div className={`grid grid-cols-4 gap-2 transition-opacity ${pageLoading && pageHasSnapshot ? 'opacity-60' : ''}`}>
<div className="relative col-span-2 flex min-h-[68px] flex-col justify-center overflow-hidden rounded-xl bg-slate-900 p-2.5 text-white">
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{sortBy === 'total' ? '累计' : (isRangeMode ? '区间' : '当日')}</div>
<div className="text-[7px] font-bold text-slate-500 uppercase tracking-wider">{pageKpis.distanceLabel}</div>
<div className="text-lg font-black tracking-tighter leading-tight flex items-baseline gap-1">
{pageLoading && !pageHasSnapshot
? <div className="h-5 w-20 bg-slate-700 rounded animate-pulse"></div>
: <>{pageHasSnapshot ? Math.round(sortBy === 'total' ? stats.totalAll : stats.totalToday).toLocaleString() : '—'} <span className="text-[8px] text-slate-400">km</span></>}
: <>{pageHasSnapshot ? Math.round(pageKpis.distanceKm).toLocaleString() : '—'} <span className="text-[8px] text-slate-400">km</span></>}
</div>
<div className="mt-0.5 truncate text-[8px] font-bold text-slate-500">{rangeLabel}</div>
</div>
@@ -1663,7 +1671,7 @@ export default function MonitoringView() {
<div className="text-sm font-black text-slate-800 leading-tight">
{pageLoading && !pageHasSnapshot
? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div>
: pageHasSnapshot ? (stats.vehicleCount > 0 ? (sortBy === 'total' ? stats.totalAll : stats.totalToday) / stats.vehicleCount : 0).toFixed(0) : '—'}
: pageHasSnapshot ? pageKpis.averagePerVehicleKm.toFixed(0) : '—'}
</div>
<div className="text-[7px] text-slate-400">km/</div>
</div>
@@ -1672,7 +1680,7 @@ export default function MonitoringView() {
<div className="text-sm font-black text-slate-800 leading-tight">
{pageLoading && !pageHasSnapshot
? <div className="h-4 w-8 bg-slate-100 rounded animate-pulse"></div>
: pageHasSnapshot ? stats.vehicleCount : '—'}
: pageHasSnapshot ? pageKpis.vehicleCount : '—'}
</div>
<div className="text-[7px] text-slate-400"></div>
</div>
@@ -1729,11 +1737,11 @@ export default function MonitoringView() {
</div>
<div className="flex flex-col justify-center rounded-lg bg-blue-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-blue-400"></div>
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(stats.totalToday).toLocaleString()}</div>
<div className="mt-1 text-xs font-black tabular-nums text-blue-700">{Math.round(pageKpis.distanceKm).toLocaleString()}</div>
</div>
<div className="flex flex-col justify-center rounded-lg bg-slate-50 px-2 py-1.5 text-right">
<div className="text-[9px] font-black text-slate-400"></div>
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{stats.vehicleCount > 0 ? Math.round(stats.totalToday / stats.vehicleCount).toLocaleString() : 0}</div>
<div className="mt-1 text-xs font-black tabular-nums text-slate-800">{Math.round(pageKpis.averagePerVehicleKm).toLocaleString()}</div>
</div>
</div>
)}
@@ -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);
});
+31
View File
@@ -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,
};
}
+4 -3
View File
@@ -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'));
+4
View File
@@ -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', () => {
+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';
}
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',