fix(mileage): skip missing daily mileage in interval totals
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { hasDailyMileage, recentMileageStats } from './mileage-display';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
import { motion, AnimatePresence, useDragControls } from 'motion/react';
|
||||||
import { X, Truck } from 'lucide-react';
|
import { X, Truck } from 'lucide-react';
|
||||||
@@ -97,13 +98,10 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
|
|
||||||
// 排除"今日"列(数据未到位时易引起误读)
|
// 排除"今日"列(数据未到位时易引起误读)
|
||||||
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
|
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => recentMileageStats(historyDays), [historyDays]);
|
||||||
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
|
const chartDays = useMemo(() => historyDays.map(day => ({
|
||||||
const synced = historyDays.filter(d => d.isDataSynced).length;
|
...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null,
|
||||||
const avg = synced > 0 ? totalKm / synced : 0;
|
})), [historyDays]);
|
||||||
const max = Math.max(1, ...historyDays.map(d => d.dailyKm));
|
|
||||||
return { totalKm, synced, avg, max, totalDays: historyDays.length };
|
|
||||||
}, [historyDays]);
|
|
||||||
|
|
||||||
// 骨架天数:根据区间预估
|
// 骨架天数:根据区间预估
|
||||||
const skeletonCount = useMemo(() => {
|
const skeletonCount = useMemo(() => {
|
||||||
@@ -206,14 +204,14 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
|
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
|
||||||
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
: stats.synced === 0 ? '—' : <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
|
||||||
<div className="text-base font-black text-slate-900 leading-tight">
|
<div className="text-base font-black text-slate-900 leading-tight">
|
||||||
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
|
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
|
||||||
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
: stats.synced === 0 ? '—' : <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-50 rounded-xl p-2.5">
|
<div className="bg-slate-50 rounded-xl p-2.5">
|
||||||
@@ -237,7 +235,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
<SkeletonBars count={Math.min(skeletonCount, 30)} />
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
<BarChart data={chartDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
tickFormatter={(d) => formatLabel(d, range)}
|
tickFormatter={(d) => formatLabel(d, range)}
|
||||||
@@ -249,7 +247,8 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
/>
|
/>
|
||||||
<YAxis hide />
|
<YAxis hide />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
|
filterNull={false}
|
||||||
|
formatter={(v) => [v == null ? '—' : `${Math.round(Number(v)).toLocaleString()} km`, '行驶里程']}
|
||||||
labelFormatter={(d) => `日期 ${d}`}
|
labelFormatter={(d) => `日期 ${d}`}
|
||||||
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
|
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
|
||||||
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
|
||||||
@@ -306,15 +305,15 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
|
|||||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ width: 0 }}
|
initial={{ width: 0 }}
|
||||||
animate={{ width: `${(d.dailyKm / stats.max) * 100}%` }}
|
animate={{ width: `${hasDailyMileage(d) ? (d.dailyKm / stats.max) * 100 : 0}%` }}
|
||||||
transition={{ delay: Math.min(i * 0.012, 0.4) + 0.1, duration: 0.4 }}
|
transition={{ delay: Math.min(i * 0.012, 0.4) + 0.1, duration: 0.4 }}
|
||||||
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
|
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
|
<span className={`text-[11px] font-mono font-bold w-20 text-right ${hasDailyMileage(d) ? 'text-slate-700' : 'text-slate-400'}`}>
|
||||||
{d.isDataSynced
|
{hasDailyMileage(d)
|
||||||
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
|
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
|
||||||
: <span className="text-[9px]">未对接</span>}
|
: <span>—</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { monitoringMileageValue, recentMileageStats } from './mileage-display';
|
||||||
|
import type { VehicleRecentDay } from './api';
|
||||||
|
import type { MonitoringVehicle } from './types';
|
||||||
|
|
||||||
|
const day = (dailyKm: number, isDataSynced = true): VehicleRecentDay => ({
|
||||||
|
date: '2026-10-12', dailyKm, isDataSynced, sourceProtocol: null, sourceCategory: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('详情合计、平均值和有数据天排除缺失日,保留真实零里程', () => {
|
||||||
|
assert.deepEqual(recentMileageStats([day(100), day(900, false), day(200), day(0)]), {
|
||||||
|
totalKm: 300, synced: 3, avg: 100, max: 200, totalDays: 4,
|
||||||
|
});
|
||||||
|
assert.equal(recentMileageStats([day(0, false)]).synced, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('列表全区间缺失时显示横杠,即使存在累计里程', () => {
|
||||||
|
const vehicle = { dailyKm: 0, isDataSynced: false, totalKm: 64413.6 } as MonitoringVehicle;
|
||||||
|
assert.equal(monitoringMileageValue(vehicle), '—');
|
||||||
|
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true }), 0);
|
||||||
|
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true, dailyKm: 5417 }), 5417);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { MonitoringVehicle } from './types';
|
||||||
|
import type { VehicleRecentDay } from './api';
|
||||||
|
|
||||||
|
export function monitoringMileageValue(vehicle: MonitoringVehicle): number | string {
|
||||||
|
if (vehicle.mileageAnomaly) return '数据异常';
|
||||||
|
if (!vehicle.isDataSynced) return '—';
|
||||||
|
return Math.max(0, vehicle.dailyKm || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasDailyMileage(day: VehicleRecentDay): boolean {
|
||||||
|
return day.isDataSynced && Number.isFinite(day.dailyKm) && day.dailyKm >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recentMileageStats(days: VehicleRecentDay[]) {
|
||||||
|
const validDays = days.filter(hasDailyMileage);
|
||||||
|
const totalKm = validDays.reduce((sum, day) => sum + day.dailyKm, 0);
|
||||||
|
return {
|
||||||
|
totalKm,
|
||||||
|
synced: validDays.length,
|
||||||
|
avg: validDays.length ? totalKm / validDays.length : 0,
|
||||||
|
max: Math.max(1, ...validDays.map(day => day.dailyKm)),
|
||||||
|
totalDays: days.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { monitoringMileageValue } from '../../mileage-display';
|
||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
|
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
@@ -283,6 +284,8 @@ export default function FullscreenMonitoring({
|
|||||||
<tbody className="divide-y divide-slate-800/30">
|
<tbody className="divide-y divide-slate-800/30">
|
||||||
{fullscreenVehicles.map((v) => {
|
{fullscreenVehicles.map((v) => {
|
||||||
const highMileageAlert = isHighMileageAlert(v);
|
const highMileageAlert = isHighMileageAlert(v);
|
||||||
|
const mileageValue = monitoringMileageValue(v);
|
||||||
|
const hasMileage = typeof mileageValue === 'number';
|
||||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||||
const sourceDisplay = vehicleSourceDisplay(v);
|
const sourceDisplay = vehicleSourceDisplay(v);
|
||||||
return (
|
return (
|
||||||
@@ -310,7 +313,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) ? <>{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>}
|
<>{mileageValue.toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>{hasMileage ? 'km' : ''}</span></>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { monitoringMileageValue } from '../../mileage-display';
|
||||||
import type { RefObject } from 'react';
|
import type { RefObject } from 'react';
|
||||||
import { Truck } from 'lucide-react';
|
import { Truck } from 'lucide-react';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
@@ -54,6 +55,8 @@ export default function VehicleList({
|
|||||||
<div className="grid grid-cols-1 gap-1.5">
|
<div className="grid grid-cols-1 gap-1.5">
|
||||||
{vehicles.map((v) => {
|
{vehicles.map((v) => {
|
||||||
const highMileageAlert = isHighMileageAlert(v);
|
const highMileageAlert = isHighMileageAlert(v);
|
||||||
|
const mileageValue = monitoringMileageValue(v);
|
||||||
|
const hasMileage = typeof mileageValue === 'number';
|
||||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||||
const sourceDisplay = vehicleSourceDisplay(v);
|
const sourceDisplay = vehicleSourceDisplay(v);
|
||||||
return (
|
return (
|
||||||
@@ -102,8 +105,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'}`}>
|
||||||
{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()}
|
{mileageValue.toLocaleString()}
|
||||||
<span className="ml-1 text-[9px] font-bold text-slate-400">{v.mileageAnomaly ? '' : 'km'}</span>
|
<span className="ml-1 text-[9px] font-bold text-slate-400">{hasMileage ? 'km' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden text-right md:block">
|
<div className="hidden text-right md:block">
|
||||||
@@ -119,7 +122,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) ? <>{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>}
|
<>{mileageValue.toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>{hasMileage ? 'km' : ''}</span></>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface MonitoringVehicle {
|
|||||||
vin: string;
|
vin: string;
|
||||||
dailyKm: number;
|
dailyKm: number;
|
||||||
mileageAnomaly?: string | null;
|
mileageAnomaly?: string | null;
|
||||||
dailyMileage?: Record<string, number>;
|
dailyMileage?: Record<string, number | null>;
|
||||||
totalKm: number | null;
|
totalKm: number | null;
|
||||||
source: string;
|
source: string;
|
||||||
sourceProtocol: MileageSourceProtocol | null;
|
sourceProtocol: MileageSourceProtocol | null;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { monitoringMileageValue } from './mileage-display';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import type { MonitoringVehicle } from './types';
|
import type { MonitoringVehicle } from './types';
|
||||||
|
|
||||||
@@ -27,8 +28,7 @@ function statusLabel(v: MonitoringVehicle): string {
|
|||||||
|
|
||||||
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
|
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
|
||||||
if (kind === 'today') {
|
if (kind === 'today') {
|
||||||
if (!v.isDataSynced && v.totalKm == null) return '未对接';
|
return monitoringMileageValue(v);
|
||||||
return Math.max(0, v.dailyKm || 0);
|
|
||||||
}
|
}
|
||||||
return v.totalKm != null ? v.totalKm : '未对接';
|
return v.totalKm != null ? v.totalKm : '未对接';
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
v.rentStatus || '',
|
v.rentStatus || '',
|
||||||
v.region || '',
|
v.region || '',
|
||||||
];
|
];
|
||||||
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? 0);
|
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—');
|
||||||
return [
|
return [
|
||||||
...baseRow,
|
...baseRow,
|
||||||
...dailyCols,
|
...dailyCols,
|
||||||
@@ -135,8 +135,8 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
v.project || '',
|
v.project || '',
|
||||||
v.rentStatus || '',
|
v.rentStatus || '',
|
||||||
v.region || '',
|
v.region || '',
|
||||||
...dayKeys.map(day => v.dailyMileage?.[day] || 0),
|
...dayKeys.map(day => v.dailyMileage?.[day] ?? '—'),
|
||||||
Math.max(0, v.dailyKm || 0),
|
mileageCell(v, 'today'),
|
||||||
v.totalKm != null ? v.totalKm : '',
|
v.totalKm != null ? v.totalKm : '',
|
||||||
]),
|
]),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -4,11 +4,8 @@ import { dirname, join } from 'node:path';
|
|||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||||||
import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js';
|
import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js';
|
||||||
import {
|
import type { OneOsProtocol } from './source-policy.js';
|
||||||
sourceCategoryFromProtocol,
|
import { buildRangeMileage, type RangeMileageResult } from './range-model.js';
|
||||||
type MileageSourceCategory,
|
|
||||||
type OneOsProtocol,
|
|
||||||
} from './source-policy.js';
|
|
||||||
import {
|
import {
|
||||||
buildMonitoringFilters,
|
buildMonitoringFilters,
|
||||||
buildPlateTargetNamesMap,
|
buildPlateTargetNamesMap,
|
||||||
@@ -18,8 +15,6 @@ import {
|
|||||||
mileageDatesBetween,
|
mileageDatesBetween,
|
||||||
previousMileageDate,
|
previousMileageDate,
|
||||||
toMileageRows,
|
toMileageRows,
|
||||||
type DailyMileageRow,
|
|
||||||
type MileageRow,
|
|
||||||
type TargetRow,
|
type TargetRow,
|
||||||
} from './cache-model.js';
|
} from './cache-model.js';
|
||||||
import type { CachedVehicle, MonitoringCache, MonitoringFilters } from './types.js';
|
import type { CachedVehicle, MonitoringCache, MonitoringFilters } from './types.js';
|
||||||
@@ -43,13 +38,6 @@ function shanghaiDate(): string {
|
|||||||
}).format(new Date());
|
}).format(new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RangeMileageResult {
|
|
||||||
vehicles: CachedVehicle[];
|
|
||||||
dailyTotals: { date: string; totalKm: number }[];
|
|
||||||
start: string;
|
|
||||||
end: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchTargetRows(): Promise<TargetRow[]> {
|
async function fetchTargetRows(): Promise<TargetRow[]> {
|
||||||
return pool.execute(
|
return pool.execute(
|
||||||
`SELECT t.id, t.target_name, v.plate_number
|
`SELECT t.id, t.target_name, v.plate_number
|
||||||
@@ -131,23 +119,6 @@ export async function queryRangeMileage(
|
|||||||
endDate: string,
|
endDate: string,
|
||||||
protocolPriority?: OneOsProtocol[],
|
protocolPriority?: OneOsProtocol[],
|
||||||
): Promise<RangeMileageResult> {
|
): Promise<RangeMileageResult> {
|
||||||
if (startDate === endDate) {
|
|
||||||
const vehicles = (await queryDateMileage(startDate, protocolPriority)).map(vehicle => ({
|
|
||||||
...vehicle,
|
|
||||||
dailyMileage: { [startDate]: vehicle.dailyKm },
|
|
||||||
dailySourceProtocols: { [startDate]: vehicle.sourceProtocol },
|
|
||||||
}));
|
|
||||||
return {
|
|
||||||
vehicles,
|
|
||||||
dailyTotals: [{
|
|
||||||
date: startDate,
|
|
||||||
totalKm: Math.round(vehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)),
|
|
||||||
}],
|
|
||||||
start: startDate,
|
|
||||||
end: endDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const days = mileageDatesBetween(startDate, endDate);
|
const days = mileageDatesBetween(startDate, endDate);
|
||||||
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||||
fetchOneOsMileageDates(days, undefined, protocolPriority),
|
fetchOneOsMileageDates(days, undefined, protocolPriority),
|
||||||
@@ -156,122 +127,7 @@ export async function queryRangeMileage(
|
|||||||
fetchVehicleInfoMap(),
|
fetchVehicleInfoMap(),
|
||||||
fetchTargetRows(),
|
fetchTargetRows(),
|
||||||
]);
|
]);
|
||||||
const dailyRows: DailyMileageRow[] = [];
|
return buildRangeMileage(startDate, endDate, apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows, regionMap);
|
||||||
for (const [date, apiRows] of apiRowsByDate) {
|
|
||||||
for (const row of apiRows) {
|
|
||||||
dailyRows.push({
|
|
||||||
plate: row.plateNumber,
|
|
||||||
vin: row.vin,
|
|
||||||
date,
|
|
||||||
daily_km: row.dailyMileageKm,
|
|
||||||
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,
|
|
||||||
updated_at: row.updatedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const perVehicleDaily = new Map<string, Record<string, number>>();
|
|
||||||
const perVehicleDailySources = new Map<string, Record<string, OneOsProtocol | null>>();
|
|
||||||
const perVehicleSourceCategories = new Map<string, Set<MileageSourceCategory>>();
|
|
||||||
const perVehicleSum = new Map<string, MileageRow>();
|
|
||||||
const dailyTotals = new Map<string, number>();
|
|
||||||
const bestDailyRows = new Map<string, DailyMileageRow>();
|
|
||||||
|
|
||||||
for (const day of days) dailyTotals.set(day, 0);
|
|
||||||
|
|
||||||
for (const row of dailyRows) {
|
|
||||||
const key = `${row.plate}\u0000${row.date}`;
|
|
||||||
const km = Math.max(0, Number(row.daily_km) || 0);
|
|
||||||
const existing = bestDailyRows.get(key);
|
|
||||||
if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) {
|
|
||||||
bestDailyRows.set(key, row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of bestDailyRows.values()) {
|
|
||||||
const km = Math.max(0, Number(row.daily_km) || 0);
|
|
||||||
const date = row.date;
|
|
||||||
const plate = row.plate;
|
|
||||||
dailyTotals.set(date, (dailyTotals.get(date) || 0) + km);
|
|
||||||
|
|
||||||
const daily = perVehicleDaily.get(plate) || {};
|
|
||||||
daily[date] = km;
|
|
||||||
perVehicleDaily.set(plate, daily);
|
|
||||||
const dailySources = perVehicleDailySources.get(plate) || {};
|
|
||||||
dailySources[date] = row.source_protocol;
|
|
||||||
perVehicleDailySources.set(plate, dailySources);
|
|
||||||
const category = sourceCategoryFromProtocol(row.source_protocol);
|
|
||||||
if (category) {
|
|
||||||
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
|
|
||||||
categories.add(category);
|
|
||||||
perVehicleSourceCategories.set(plate, categories);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = perVehicleSum.get(plate);
|
|
||||||
perVehicleSum.set(plate, {
|
|
||||||
plate,
|
|
||||||
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,
|
|
||||||
calculated_at: row.calculated_at || existing?.calculated_at || null,
|
|
||||||
updated_at: row.updated_at || existing?.updated_at || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row]));
|
|
||||||
for (const [plate, aggregate] of perVehicleSum) {
|
|
||||||
const endDateRow = endDateMap.get(plate);
|
|
||||||
if (!endDateRow) continue;
|
|
||||||
perVehicleSum.set(plate, {
|
|
||||||
...aggregate,
|
|
||||||
vin: endDateRow.vin || aggregate.vin,
|
|
||||||
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
|
|
||||||
source_protocol: endDateRow.sourceProtocol || aggregate.source_protocol,
|
|
||||||
data_time: endDateRow.dataTime || aggregate.data_time,
|
|
||||||
updated_at: endDateRow.updatedAt || aggregate.updated_at,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const yesterdayMap = dailyMileageMap(yesterdayRows);
|
|
||||||
|
|
||||||
const vehicles = mergeMonitoringVehicles(
|
|
||||||
Array.from(perVehicleSum.values()),
|
|
||||||
infoMap,
|
|
||||||
yesterdayMap,
|
|
||||||
buildPlateTargetNamesMap(targetRows),
|
|
||||||
regionMap,
|
|
||||||
).map(vehicle => {
|
|
||||||
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
|
|
||||||
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
|
|
||||||
const categories = perVehicleSourceCategories.get(vehicle.plate);
|
|
||||||
const completedDailyMileage: Record<string, number> = {};
|
|
||||||
const completedDailySources: Record<string, OneOsProtocol | null> = {};
|
|
||||||
for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0;
|
|
||||||
for (const day of days) completedDailySources[day] = dailySources[day] || null;
|
|
||||||
return {
|
|
||||||
...vehicle,
|
|
||||||
dailyMileage: completedDailyMileage,
|
|
||||||
dailySourceProtocols: completedDailySources,
|
|
||||||
sourceCategory: categories && categories.size > 1
|
|
||||||
? 'MIXED' as const
|
|
||||||
: categories?.values().next().value || vehicle.sourceCategory,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
vehicles,
|
|
||||||
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
|
|
||||||
start: startDate,
|
|
||||||
end: endDate,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ export interface OneOsDailyMileage {
|
|||||||
sourceProtocol: OneOsProtocol | null;
|
sourceProtocol: OneOsProtocol | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Missing or unusable daily mileage must not contribute to interval statistics. */
|
||||||
|
export function validDailyMileage(row: OneOsDailyMileage | undefined): number | null {
|
||||||
|
const km = row?.dailyMileageKm;
|
||||||
|
return row?.status === 'NORMAL' && km != null && Number.isFinite(km) && km >= 0 ? km : null;
|
||||||
|
}
|
||||||
|
|
||||||
const MILEAGE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
const MILEAGE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
export function assertMileageDate(date: string): void {
|
export function assertMileageDate(date: string): void {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { buildRangeMileage } from './range-model.js';
|
||||||
|
import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js';
|
||||||
|
import type { VehicleInfoRow } from './types.js';
|
||||||
|
|
||||||
|
const info: VehicleInfoRow = {
|
||||||
|
plate: '粤A06619F', vin: 'test', customer: null, department: null, manager: null,
|
||||||
|
manager_id: null, rent_status: null, entity: null, project: null,
|
||||||
|
inventory_location: null, brand_label: null,
|
||||||
|
};
|
||||||
|
const row = (date: string, km: number | null, status: OneOsDailyMileage['status'] = 'NORMAL'): OneOsDailyMileage => ({
|
||||||
|
plateNumber: info.plate, vin: 'test', date, dailyMileageKm: km, status,
|
||||||
|
totalMileageKm: 64413.6, dataTime: null, calculatedAt: null, updatedAt: null,
|
||||||
|
sourceProtocol: 'GB32960',
|
||||||
|
});
|
||||||
|
function aggregate(rows: OneOsDailyMileage[], start = '2026-10-09', end = '2026-10-12') {
|
||||||
|
const byDate = new Map<string, OneOsDailyMileage[]>();
|
||||||
|
for (const item of rows) byDate.set(item.date, [...(byDate.get(item.date) || []), item]);
|
||||||
|
return buildRangeMileage(start, end, byDate, rows.filter(item => item.date === end), [], new Map([[info.plate, info]]), [], {});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('缺失或异常的一天不覆盖其余日期合计,明细保留空值', () => {
|
||||||
|
for (const status of ['NO_DATA', 'DATA_ANOMALY', 'NORMAL'] as const) {
|
||||||
|
const result = aggregate([
|
||||||
|
row('2026-10-09', 1067), row('2026-10-10', null, status),
|
||||||
|
row('2026-10-11', 2212), row('2026-10-12', 2138),
|
||||||
|
]);
|
||||||
|
const vehicle = result.vehicles[0];
|
||||||
|
assert.equal(vehicle.dailyKm, 5417);
|
||||||
|
assert.equal(vehicle.mileageAnomaly, null);
|
||||||
|
assert.equal(vehicle.isDataSynced, true);
|
||||||
|
assert.equal(vehicle.totalKm, 64413.6);
|
||||||
|
assert.deepEqual(vehicle.dailyMileage, {
|
||||||
|
'2026-10-09': 1067, '2026-10-10': null, '2026-10-11': 2212, '2026-10-12': 2138,
|
||||||
|
});
|
||||||
|
assert.equal(result.dailyTotals.reduce((sum, day) => sum + day.totalKm, 0), 5417);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('整段无日里程保留缺失状态及累计里程,不能伪装为有效零里程', () => {
|
||||||
|
const vehicle = aggregate([row('2026-10-12', null, 'DATA_ANOMALY')]).vehicles[0];
|
||||||
|
assert.equal(vehicle.dailyKm, 0);
|
||||||
|
assert.equal(vehicle.isDataSynced, false);
|
||||||
|
assert.equal(vehicle.mileageAnomaly, null);
|
||||||
|
assert.equal(vehicle.totalKm, 64413.6);
|
||||||
|
assert.deepEqual(Object.values(vehicle.dailyMileage!), [null, null, null, null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('真实零里程是有效数据,缺失日期及重复无效记录不影响它', () => {
|
||||||
|
const vehicle = aggregate([row('2026-10-09', null, 'NO_DATA'), row('2026-10-09', 0)]).vehicles[0];
|
||||||
|
assert.equal(vehicle.isDataSynced, true);
|
||||||
|
assert.equal(vehicle.dailyMileage?.['2026-10-09'], 0);
|
||||||
|
assert.equal(vehicle.dailyMileage?.['2026-10-10'], null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('单日区间同样跳过缺失数据', () => {
|
||||||
|
const vehicle = aggregate([row('2026-10-12', null, 'DATA_ANOMALY')], '2026-10-12').vehicles[0];
|
||||||
|
assert.equal(vehicle.mileageAnomaly, null);
|
||||||
|
assert.equal(vehicle.isDataSynced, false);
|
||||||
|
assert.deepEqual(vehicle.dailyMileage, { '2026-10-12': null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('区间汇总和单车明细共用有效里程口径', () => {
|
||||||
|
assert.equal(validDailyMileage(undefined), null);
|
||||||
|
assert.equal(validDailyMileage(row('2026-10-12', null)), null);
|
||||||
|
assert.equal(validDailyMileage(row('2026-10-12', 123, 'DATA_ANOMALY')), null);
|
||||||
|
assert.equal(validDailyMileage(row('2026-10-12', NaN)), null);
|
||||||
|
assert.equal(validDailyMileage(row('2026-10-12', 0)), 0);
|
||||||
|
assert.equal(validDailyMileage(row('2026-10-12', 12.3)), 12.3);
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { sourceCategoryFromProtocol, type MileageSourceCategory, type OneOsProtocol } from './source-policy.js';
|
||||||
|
import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js';
|
||||||
|
import {
|
||||||
|
buildPlateTargetNamesMap, dailyMileageMap, mergeMonitoringVehicles, mileageDatesBetween,
|
||||||
|
type DailyMileageRow, type MileageRow, type TargetRow,
|
||||||
|
} from './cache-model.js';
|
||||||
|
import type { CachedVehicle, VehicleInfoRow } from './types.js';
|
||||||
|
|
||||||
|
export interface RangeMileageResult {
|
||||||
|
vehicles: CachedVehicle[];
|
||||||
|
dailyTotals: { date: string; totalKm: number }[];
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRangeMileage(
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
apiRowsByDate: Map<string, OneOsDailyMileage[]>,
|
||||||
|
endDateRows: OneOsDailyMileage[],
|
||||||
|
yesterdayRows: OneOsDailyMileage[],
|
||||||
|
infoMap: Map<string, VehicleInfoRow>,
|
||||||
|
targetRows: TargetRow[],
|
||||||
|
regionMap: Record<string, string>,
|
||||||
|
): RangeMileageResult {
|
||||||
|
const days = mileageDatesBetween(startDate, endDate);
|
||||||
|
const dailyRows: DailyMileageRow[] = [];
|
||||||
|
for (const [date, apiRows] of apiRowsByDate) {
|
||||||
|
for (const row of apiRows) {
|
||||||
|
dailyRows.push({
|
||||||
|
plate: row.plateNumber,
|
||||||
|
vin: row.vin,
|
||||||
|
date,
|
||||||
|
daily_km: validDailyMileage(row),
|
||||||
|
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,
|
||||||
|
updated_at: row.updatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const perVehicleDaily = new Map<string, Record<string, number>>();
|
||||||
|
const perVehicleDailySources = new Map<string, Record<string, OneOsProtocol | null>>();
|
||||||
|
const perVehicleSourceCategories = new Map<string, Set<MileageSourceCategory>>();
|
||||||
|
const perVehicleSum = new Map<string, MileageRow>();
|
||||||
|
const dailyTotals = new Map<string, number>();
|
||||||
|
const bestDailyRows = new Map<string, DailyMileageRow>();
|
||||||
|
|
||||||
|
for (const day of days) dailyTotals.set(day, 0);
|
||||||
|
|
||||||
|
for (const row of dailyRows) {
|
||||||
|
if (row.daily_km == null) continue;
|
||||||
|
const key = `${row.plate}\u0000${row.date}`;
|
||||||
|
const km = Math.max(0, Number(row.daily_km) || 0);
|
||||||
|
const existing = bestDailyRows.get(key);
|
||||||
|
if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) {
|
||||||
|
bestDailyRows.set(key, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of bestDailyRows.values()) {
|
||||||
|
const km = Math.max(0, Number(row.daily_km) || 0);
|
||||||
|
const date = row.date;
|
||||||
|
const plate = row.plate;
|
||||||
|
dailyTotals.set(date, (dailyTotals.get(date) || 0) + km);
|
||||||
|
|
||||||
|
const daily = perVehicleDaily.get(plate) || {};
|
||||||
|
daily[date] = km;
|
||||||
|
perVehicleDaily.set(plate, daily);
|
||||||
|
const dailySources = perVehicleDailySources.get(plate) || {};
|
||||||
|
dailySources[date] = row.source_protocol;
|
||||||
|
perVehicleDailySources.set(plate, dailySources);
|
||||||
|
const category = sourceCategoryFromProtocol(row.source_protocol);
|
||||||
|
if (category) {
|
||||||
|
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
|
||||||
|
categories.add(category);
|
||||||
|
perVehicleSourceCategories.set(plate, categories);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = perVehicleSum.get(plate);
|
||||||
|
perVehicleSum.set(plate, {
|
||||||
|
plate,
|
||||||
|
vin: existing?.vin || row.vin || '',
|
||||||
|
daily_km: String((Number(existing?.daily_km) || 0) + km),
|
||||||
|
total_km: null,
|
||||||
|
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,
|
||||||
|
calculated_at: row.calculated_at || existing?.calculated_at || null,
|
||||||
|
updated_at: row.updated_at || existing?.updated_at || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row]));
|
||||||
|
// Preserve end-of-range odometer data even when every daily mileage is missing.
|
||||||
|
for (const [plate, endDateRow] of endDateMap) {
|
||||||
|
const aggregate = perVehicleSum.get(plate);
|
||||||
|
perVehicleSum.set(plate, {
|
||||||
|
plate,
|
||||||
|
daily_km: aggregate?.daily_km || '0',
|
||||||
|
source: aggregate?.source || 'NONE',
|
||||||
|
mileage_anomaly: null,
|
||||||
|
vin: endDateRow.vin || aggregate?.vin || '',
|
||||||
|
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
|
||||||
|
source_protocol: endDateRow.sourceProtocol || aggregate?.source_protocol || null,
|
||||||
|
data_time: endDateRow.dataTime || aggregate?.data_time || null,
|
||||||
|
calculated_at: endDateRow.calculatedAt || aggregate?.calculated_at || null,
|
||||||
|
updated_at: endDateRow.updatedAt || aggregate?.updated_at || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const yesterdayMap = dailyMileageMap(yesterdayRows);
|
||||||
|
|
||||||
|
const vehicles = mergeMonitoringVehicles(
|
||||||
|
Array.from(perVehicleSum.values()),
|
||||||
|
infoMap,
|
||||||
|
yesterdayMap,
|
||||||
|
buildPlateTargetNamesMap(targetRows),
|
||||||
|
regionMap,
|
||||||
|
).map(vehicle => {
|
||||||
|
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
|
||||||
|
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
|
||||||
|
const categories = perVehicleSourceCategories.get(vehicle.plate);
|
||||||
|
const completedDailyMileage: Record<string, number | null> = {};
|
||||||
|
const completedDailySources: Record<string, OneOsProtocol | null> = {};
|
||||||
|
for (const day of days) completedDailyMileage[day] = dailyMileage[day] ?? null;
|
||||||
|
for (const day of days) completedDailySources[day] = dailySources[day] || null;
|
||||||
|
return {
|
||||||
|
...vehicle,
|
||||||
|
dailyMileage: completedDailyMileage,
|
||||||
|
dailySourceProtocols: completedDailySources,
|
||||||
|
sourceCategory: categories && categories.size > 1
|
||||||
|
? 'MIXED' as const
|
||||||
|
: categories?.values().next().value || vehicle.sourceCategory,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
vehicles,
|
||||||
|
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
|
||||||
|
start: startDate,
|
||||||
|
end: endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ export interface CachedVehicle {
|
|||||||
vin: string;
|
vin: string;
|
||||||
dailyKm: number;
|
dailyKm: number;
|
||||||
mileageAnomaly?: string | null;
|
mileageAnomaly?: string | null;
|
||||||
dailyMileage?: Record<string, number>;
|
dailyMileage?: Record<string, number | null>;
|
||||||
totalKm: number | null;
|
totalKm: number | null;
|
||||||
source: string;
|
source: string;
|
||||||
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
|
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { validDailyMileage } from './oneos-model.js';
|
||||||
import { fetchOneOsMileageDates } from './oneos-api.js';
|
import { fetchOneOsMileageDates } from './oneos-api.js';
|
||||||
import {
|
import {
|
||||||
parseMileageSourcePriority,
|
parseMileageSourcePriority,
|
||||||
@@ -81,8 +82,8 @@ app.get('/:plate/recent', async (c) => {
|
|||||||
const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate);
|
const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate);
|
||||||
result.push({
|
result.push({
|
||||||
date: key,
|
date: key,
|
||||||
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
|
dailyKm: validDailyMileage(hit) ?? 0,
|
||||||
isDataSynced: hit?.status === 'NORMAL',
|
isDataSynced: validDailyMileage(hit) != null,
|
||||||
sourceProtocol: hit?.sourceProtocol || null,
|
sourceProtocol: hit?.sourceProtocol || null,
|
||||||
sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null),
|
sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user