fix(mileage): preserve anomalous odometer readings and show data quality
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -310,7 +310,7 @@ export default function FullscreenMonitoring({
|
|||||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
|
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
|
||||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
{(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>{v.mileageAnomaly ? '' : 'km'}</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
|
|||||||
@@ -69,13 +69,13 @@ export default function VehicleList({
|
|||||||
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-slate-50 flex items-center justify-center">
|
||||||
<Truck size={14} className="text-slate-400" />
|
<Truck size={14} className="text-slate-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${v.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} title={v.isOnline ? '在线' : '离线'}></div>
|
<div className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white ${v.isOnline ? 'bg-green-500' : 'bg-slate-300'}`} title={v.mileageAnomaly ? '数据异常' : v.isOnline ? '在线' : '离线'}></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden flex-1">
|
<div className="overflow-hidden flex-1">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plate}</Blur></span>
|
<span className="text-xs font-black text-slate-900 font-mono"><Blur>{v.plate}</Blur></span>
|
||||||
<span className={`text-[8px] px-1 rounded ${v.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
<span className={`text-[8px] px-1 rounded ${v.isOnline ? 'bg-green-50 text-green-600' : 'bg-slate-100 text-slate-400'} font-bold`}>
|
||||||
{v.isOnline ? '在线' : '离线'}
|
{v.mileageAnomaly ? '数据异常' : v.isOnline ? '在线' : '离线'}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
className={`inline-flex shrink-0 rounded px-1.5 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
||||||
@@ -84,6 +84,7 @@ export default function VehicleList({
|
|||||||
{sourceDisplay.label}
|
{sourceDisplay.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{v.mileageAnomaly && <div className="text-[9px] font-bold text-amber-600" title={v.mileageAnomaly}>{v.mileageAnomaly === 'ODOMETER_SOURCE_CHANGED' ? '里程来源变化' : '上游里程异常'}</div>}
|
||||||
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
||||||
{statisticTime.label}
|
{statisticTime.label}
|
||||||
</div>
|
</div>
|
||||||
@@ -101,8 +102,8 @@ export default function VehicleList({
|
|||||||
</div>
|
</div>
|
||||||
<div className="hidden text-right md:block">
|
<div className="hidden text-right md:block">
|
||||||
<div className={`text-sm font-black tabular-nums ${highMileageAlert ? 'text-red-600' : 'text-blue-600'}`}>
|
<div className={`text-sm font-black tabular-nums ${highMileageAlert ? 'text-red-600' : 'text-blue-600'}`}>
|
||||||
{Math.max(0, v.dailyKm || 0).toLocaleString()}
|
{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()}
|
||||||
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span>
|
<span className="ml-1 text-[9px] font-bold text-slate-400">{v.mileageAnomaly ? '' : 'km'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden text-right md:block">
|
<div className="hidden text-right md:block">
|
||||||
@@ -118,7 +119,7 @@ export default function VehicleList({
|
|||||||
)}
|
)}
|
||||||
<span className="text-[7px] font-black text-blue-600/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}>区</span>
|
<span className="text-[7px] font-black text-blue-600/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}>区</span>
|
||||||
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
|
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
|
||||||
{(v.isDataSynced || v.totalKm != null) ? <>{Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>km</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
{(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>{v.mileageAnomaly ? '' : 'km'}</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export interface MonitoringVehicle {
|
|||||||
plate: string;
|
plate: string;
|
||||||
vin: string;
|
vin: string;
|
||||||
dailyKm: number;
|
dailyKm: number;
|
||||||
|
mileageAnomaly?: string | null;
|
||||||
dailyMileage?: Record<string, number>;
|
dailyMileage?: Record<string, number>;
|
||||||
totalKm: number | null;
|
totalKm: number | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface MileageRow {
|
|||||||
vin: string;
|
vin: string;
|
||||||
daily_km: string;
|
daily_km: string;
|
||||||
total_km: string | null;
|
total_km: string | null;
|
||||||
|
mileage_anomaly?: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
source_protocol: OneOsProtocol | null;
|
source_protocol: OneOsProtocol | null;
|
||||||
data_time: string | null;
|
data_time: string | null;
|
||||||
@@ -27,6 +28,7 @@ export interface DailyMileageRow {
|
|||||||
vin: string | null;
|
vin: string | null;
|
||||||
date: string;
|
date: string;
|
||||||
daily_km: string | number | null;
|
daily_km: string | number | null;
|
||||||
|
mileage_anomaly?: string | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
source_protocol: OneOsProtocol | null;
|
source_protocol: OneOsProtocol | null;
|
||||||
data_time: string | null;
|
data_time: string | null;
|
||||||
@@ -105,7 +107,8 @@ export function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] {
|
|||||||
vin: row.vin,
|
vin: row.vin,
|
||||||
daily_km: String(row.dailyMileageKm ?? 0),
|
daily_km: String(row.dailyMileageKm ?? 0),
|
||||||
total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm),
|
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,
|
source_protocol: row.sourceProtocol,
|
||||||
data_time: row.dataTime,
|
data_time: row.dataTime,
|
||||||
calculated_at: row.calculatedAt,
|
calculated_at: row.calculatedAt,
|
||||||
@@ -174,6 +177,7 @@ export function mergeMonitoringVehicles(
|
|||||||
plate: info.plate,
|
plate: info.plate,
|
||||||
vin: mileage?.vin || info.vin || '',
|
vin: mileage?.vin || info.vin || '',
|
||||||
dailyKm,
|
dailyKm,
|
||||||
|
mileageAnomaly: mileage?.mileage_anomaly || null,
|
||||||
// Cumulative mileage is never backfilled from a different source here.
|
// Cumulative mileage is never backfilled from a different source here.
|
||||||
totalKm: mileage?.total_km != null ? Number(mileage.total_km) : null,
|
totalKm: mileage?.total_km != null ? Number(mileage.total_km) : null,
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -164,7 +164,8 @@ export async function queryRangeMileage(
|
|||||||
vin: row.vin,
|
vin: row.vin,
|
||||||
date,
|
date,
|
||||||
daily_km: row.dailyMileageKm,
|
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,
|
source_protocol: row.sourceProtocol,
|
||||||
data_time: row.dataTime,
|
data_time: row.dataTime,
|
||||||
calculated_at: row.calculatedAt,
|
calculated_at: row.calculatedAt,
|
||||||
@@ -216,6 +217,7 @@ export async function queryRangeMileage(
|
|||||||
vin: existing?.vin || row.vin || '',
|
vin: existing?.vin || row.vin || '',
|
||||||
daily_km: String((Number(existing?.daily_km) || 0) + km),
|
daily_km: String((Number(existing?.daily_km) || 0) + km),
|
||||||
total_km: null,
|
total_km: null,
|
||||||
|
mileage_anomaly: existing?.mileage_anomaly || row.mileage_anomaly || null,
|
||||||
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
|
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
|
||||||
source_protocol: row.source_protocol || existing?.source_protocol || null,
|
source_protocol: row.source_protocol || existing?.source_protocol || null,
|
||||||
data_time: row.data_time || existing?.data_time || null,
|
data_time: row.data_time || existing?.data_time || null,
|
||||||
|
|||||||
@@ -86,3 +86,15 @@ test('连续日期分组、车牌规范化和请求键保持稳定', () => {
|
|||||||
);
|
);
|
||||||
assert.throws(() => assertMileageDate('2026/08/12'), /Invalid mileage date/);
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ export interface OneOsDailyMileage {
|
|||||||
date: string;
|
date: string;
|
||||||
dailyMileageKm: number | null;
|
dailyMileageKm: number | null;
|
||||||
totalMileageKm: number | null;
|
totalMileageKm: number | null;
|
||||||
status: 'NORMAL' | 'NO_DATA';
|
status: 'NORMAL' | 'NO_DATA' | 'DATA_ANOMALY';
|
||||||
|
dataQuality?: string;
|
||||||
dataTime: string | null;
|
dataTime: string | null;
|
||||||
calculatedAt: string | null;
|
calculatedAt: string | null;
|
||||||
updatedAt: 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 vin = typeof row.vin === 'string' ? row.vin.trim() : '';
|
||||||
const plateNumber = typeof row.plateNumber === 'string' ? row.plateNumber.trim() : '';
|
const plateNumber = typeof row.plateNumber === 'string' ? row.plateNumber.trim() : '';
|
||||||
const date = typeof row.date === 'string' ? row.date : requestedDate;
|
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.
|
// The aliases keep the BI compatible while the Open API formalizes its fields.
|
||||||
const dataTime = stringField(row, 'dataTime', 'statisticTime', 'recordTime');
|
const dataTime = stringField(row, 'dataTime', 'statisticTime', 'recordTime');
|
||||||
@@ -60,8 +61,9 @@ export function normalizeOneOsRows(value: unknown, requestedDate: string): OneOs
|
|||||||
plateNumber,
|
plateNumber,
|
||||||
date,
|
date,
|
||||||
dailyMileageKm: nonNegativeMileage(row.dailyMileageKm, status),
|
dailyMileageKm: nonNegativeMileage(row.dailyMileageKm, status),
|
||||||
totalMileageKm: nonNegativeMileage(row.totalMileageKm, status),
|
totalMileageKm: nonNegativeMileage(row.totalMileageKm, status === 'DATA_ANOMALY' ? 'NORMAL' : status),
|
||||||
status,
|
status,
|
||||||
|
...(status === 'DATA_ANOMALY' ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}),
|
||||||
dataTime,
|
dataTime,
|
||||||
calculatedAt,
|
calculatedAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface CachedVehicle {
|
|||||||
plate: string;
|
plate: string;
|
||||||
vin: string;
|
vin: string;
|
||||||
dailyKm: number;
|
dailyKm: number;
|
||||||
|
mileageAnomaly?: string | null;
|
||||||
dailyMileage?: Record<string, number>;
|
dailyMileage?: Record<string, number>;
|
||||||
totalKm: number | null;
|
totalKm: number | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user