diff --git a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx index 38ea06e..b183e93 100644 --- a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx +++ b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx @@ -310,7 +310,7 @@ export default function FullscreenMonitoring({ {v.department || '-'} - {(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} km : 未对接} + {(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} {v.mileageAnomaly ? '' : 'km'} : 未对接} diff --git a/src/modules/mileage/monitoring/components/VehicleList.tsx b/src/modules/mileage/monitoring/components/VehicleList.tsx index 922523c..1bbb7c8 100644 --- a/src/modules/mileage/monitoring/components/VehicleList.tsx +++ b/src/modules/mileage/monitoring/components/VehicleList.tsx @@ -69,13 +69,13 @@ export default function VehicleList({
-
+
{v.plate} - {v.isOnline ? '在线' : '离线'} + {v.mileageAnomaly ? '数据异常' : v.isOnline ? '在线' : '离线'}
+ {v.mileageAnomaly &&
{v.mileageAnomaly === 'ODOMETER_SOURCE_CHANGED' ? '里程来源变化' : '上游里程异常'}
}
{statisticTime.label}
@@ -101,8 +102,8 @@ export default function VehicleList({
- {Math.max(0, v.dailyKm || 0).toLocaleString()} - km + {v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} + {v.mileageAnomaly ? '' : 'km'}
@@ -118,7 +119,7 @@ export default function VehicleList({ )}
- {(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} km : 未对接} + {(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} {v.mileageAnomaly ? '' : 'km'} : 未对接}
diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index 3ca395f..258739f 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -6,6 +6,7 @@ export interface MonitoringVehicle { plate: string; vin: string; dailyKm: number; + mileageAnomaly?: string | null; dailyMileage?: Record; totalKm: number | null; source: string; diff --git a/src/server/routes/mileage/cache-model.ts b/src/server/routes/mileage/cache-model.ts index 92fe861..25eacfc 100644 --- a/src/server/routes/mileage/cache-model.ts +++ b/src/server/routes/mileage/cache-model.ts @@ -15,6 +15,7 @@ export interface MileageRow { vin: string; daily_km: string; total_km: string | null; + mileage_anomaly?: string | null; source: string; source_protocol: OneOsProtocol | null; data_time: string | null; @@ -27,6 +28,7 @@ export interface DailyMileageRow { vin: string | null; date: string; daily_km: string | number | null; + mileage_anomaly?: string | null; source: string | null; source_protocol: OneOsProtocol | null; data_time: string | null; @@ -105,7 +107,8 @@ export function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] { vin: row.vin, daily_km: String(row.dailyMileageKm ?? 0), total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm), - source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE', + source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE', + mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null, source_protocol: row.sourceProtocol, data_time: row.dataTime, calculated_at: row.calculatedAt, @@ -174,6 +177,7 @@ export function mergeMonitoringVehicles( plate: info.plate, vin: mileage?.vin || info.vin || '', dailyKm, + mileageAnomaly: mileage?.mileage_anomaly || null, // Cumulative mileage is never backfilled from a different source here. totalKm: mileage?.total_km != null ? Number(mileage.total_km) : null, source, diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts index fe3315c..2bf302a 100644 --- a/src/server/routes/mileage/cache.ts +++ b/src/server/routes/mileage/cache.ts @@ -164,7 +164,8 @@ export async function queryRangeMileage( vin: row.vin, date, daily_km: row.dailyMileageKm, - source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE', + source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE', + mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null, source_protocol: row.sourceProtocol, data_time: row.dataTime, calculated_at: row.calculatedAt, @@ -216,6 +217,7 @@ export async function queryRangeMileage( vin: existing?.vin || row.vin || '', daily_km: String((Number(existing?.daily_km) || 0) + km), total_km: null, + mileage_anomaly: existing?.mileage_anomaly || row.mileage_anomaly || null, source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'), source_protocol: row.source_protocol || existing?.source_protocol || null, data_time: row.data_time || existing?.data_time || null, diff --git a/src/server/routes/mileage/oneos-model.test.ts b/src/server/routes/mileage/oneos-model.test.ts index 7fa80de..11574cb 100644 --- a/src/server/routes/mileage/oneos-model.test.ts +++ b/src/server/routes/mileage/oneos-model.test.ts @@ -86,3 +86,15 @@ test('连续日期分组、车牌规范化和请求键保持稳定', () => { ); assert.throws(() => assertMileageDate('2026/08/12'), /Invalid mileage date/); }); + +test('异常日里程不伪造为有效值,保留仪表累计和异常原因', () => { + const [row] = normalizeOneOsRows([{ + plateNumber: '粤AGE4862', date: '2026-09-17', status: 'DATA_ANOMALY', + dataQuality: 'ODOMETER_SOURCE_CHANGED', dailyMileageKm: null, + totalMileageKm: 66852.4, sourceProtocol: 'GB32960', + }], '2026-09-17'); + assert.equal(row.status, 'DATA_ANOMALY'); + assert.equal(row.dailyMileageKm, null); + assert.equal(row.totalMileageKm, 66852.4); + assert.equal(row.dataQuality, 'ODOMETER_SOURCE_CHANGED'); +}); diff --git a/src/server/routes/mileage/oneos-model.ts b/src/server/routes/mileage/oneos-model.ts index b4d28bc..53a12f6 100644 --- a/src/server/routes/mileage/oneos-model.ts +++ b/src/server/routes/mileage/oneos-model.ts @@ -6,7 +6,8 @@ export interface OneOsDailyMileage { date: string; dailyMileageKm: number | null; totalMileageKm: number | null; - status: 'NORMAL' | 'NO_DATA'; + status: 'NORMAL' | 'NO_DATA' | 'DATA_ANOMALY'; + dataQuality?: string; dataTime: string | null; calculatedAt: string | null; updatedAt: string | null; @@ -44,7 +45,7 @@ export function normalizeOneOsRows(value: unknown, requestedDate: string): OneOs const vin = typeof row.vin === 'string' ? row.vin.trim() : ''; const plateNumber = typeof row.plateNumber === 'string' ? row.plateNumber.trim() : ''; const date = typeof row.date === 'string' ? row.date : requestedDate; - const status = row.status === 'NORMAL' ? 'NORMAL' : 'NO_DATA'; + const status = row.status === 'NORMAL' || row.status === 'DATA_ANOMALY' ? row.status : 'NO_DATA'; // The aliases keep the BI compatible while the Open API formalizes its fields. const dataTime = stringField(row, 'dataTime', 'statisticTime', 'recordTime'); @@ -60,8 +61,9 @@ export function normalizeOneOsRows(value: unknown, requestedDate: string): OneOs plateNumber, date, dailyMileageKm: nonNegativeMileage(row.dailyMileageKm, status), - totalMileageKm: nonNegativeMileage(row.totalMileageKm, status), + totalMileageKm: nonNegativeMileage(row.totalMileageKm, status === 'DATA_ANOMALY' ? 'NORMAL' : status), status, + ...(status === 'DATA_ANOMALY' ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}), dataTime, calculatedAt, updatedAt, diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts index 4344325..4cb17dd 100644 --- a/src/server/routes/mileage/types.ts +++ b/src/server/routes/mileage/types.ts @@ -3,6 +3,7 @@ export interface CachedVehicle { plate: string; vin: string; dailyKm: number; + mileageAnomaly?: string | null; dailyMileage?: Record; totalKm: number | null; source: string;