diff --git a/src/modules/mileage/VehicleDetailModal.tsx b/src/modules/mileage/VehicleDetailModal.tsx
index 5ef2a0a..8326285 100644
--- a/src/modules/mileage/VehicleDetailModal.tsx
+++ b/src/modules/mileage/VehicleDetailModal.tsx
@@ -1,3 +1,4 @@
+import { hasDailyMileage, recentMileageStats } from './mileage-display';
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence, useDragControls } from 'motion/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 stats = useMemo(() => {
- const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
- const synced = historyDays.filter(d => d.isDataSynced).length;
- const avg = synced > 0 ? totalKm / synced : 0;
- const max = Math.max(1, ...historyDays.map(d => d.dailyKm));
- return { totalKm, synced, avg, max, totalDays: historyDays.length };
- }, [historyDays]);
+ const stats = useMemo(() => recentMileageStats(historyDays), [historyDays]);
+ const chartDays = useMemo(() => historyDays.map(day => ({
+ ...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null,
+ })), [historyDays]);
// 骨架天数:根据区间预估
const skeletonCount = useMemo(() => {
@@ -206,14 +204,14 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
{fullscreenVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
+ const mileageValue = monitoringMileageValue(v);
+ const hasMileage = typeof mileageValue === 'number';
const statisticTime = vehicleStatisticTime(v, relativeNow);
const sourceDisplay = vehicleSourceDisplay(v);
return (
@@ -310,7 +313,7 @@ export default function FullscreenMonitoring({
{v.department || '-'} |
- {(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} {v.mileageAnomaly ? '' : 'km'}> : 未对接}
+ <>{mileageValue.toLocaleString()} {hasMileage ? 'km' : ''}>
|
diff --git a/src/modules/mileage/monitoring/components/VehicleList.tsx b/src/modules/mileage/monitoring/components/VehicleList.tsx
index 38ab994..9477303 100644
--- a/src/modules/mileage/monitoring/components/VehicleList.tsx
+++ b/src/modules/mileage/monitoring/components/VehicleList.tsx
@@ -1,3 +1,4 @@
+import { monitoringMileageValue } from '../../mileage-display';
import type { RefObject } from 'react';
import { Truck } from 'lucide-react';
import { motion } from 'motion/react';
@@ -54,6 +55,8 @@ export default function VehicleList({
{vehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
+ const mileageValue = monitoringMileageValue(v);
+ const hasMileage = typeof mileageValue === 'number';
const statisticTime = vehicleStatisticTime(v, relativeNow);
const sourceDisplay = vehicleSourceDisplay(v);
return (
@@ -102,8 +105,8 @@ export default function VehicleList({
- {v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()}
- {v.mileageAnomaly ? '' : 'km'}
+ {mileageValue.toLocaleString()}
+ {hasMileage ? 'km' : ''}
@@ -119,7 +122,7 @@ export default function VehicleList({
)}
区
- {(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} {v.mileageAnomaly ? '' : 'km'}> : 未对接}
+ <>{mileageValue.toLocaleString()} {hasMileage ? 'km' : ''}>
diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts
index 258739f..c17f39c 100644
--- a/src/modules/mileage/types.ts
+++ b/src/modules/mileage/types.ts
@@ -7,7 +7,7 @@ export interface MonitoringVehicle {
vin: string;
dailyKm: number;
mileageAnomaly?: string | null;
- dailyMileage?: Record;
+ dailyMileage?: Record;
totalKm: number | null;
source: string;
sourceProtocol: MileageSourceProtocol | null;
diff --git a/src/modules/mileage/xlsx-export.ts b/src/modules/mileage/xlsx-export.ts
index c77ffd7..43fc2da 100644
--- a/src/modules/mileage/xlsx-export.ts
+++ b/src/modules/mileage/xlsx-export.ts
@@ -1,3 +1,4 @@
+import { monitoringMileageValue } from './mileage-display';
import * as XLSX from 'xlsx';
import type { MonitoringVehicle } from './types';
@@ -27,8 +28,7 @@ function statusLabel(v: MonitoringVehicle): string {
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
if (kind === 'today') {
- if (!v.isDataSynced && v.totalKm == null) return '未对接';
- return Math.max(0, v.dailyKm || 0);
+ return monitoringMileageValue(v);
}
return v.totalKm != null ? v.totalKm : '未对接';
}
@@ -64,7 +64,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
v.rentStatus || '',
v.region || '',
];
- const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? 0);
+ const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—');
return [
...baseRow,
...dailyCols,
@@ -135,8 +135,8 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
v.project || '',
v.rentStatus || '',
v.region || '',
- ...dayKeys.map(day => v.dailyMileage?.[day] || 0),
- Math.max(0, v.dailyKm || 0),
+ ...dayKeys.map(day => v.dailyMileage?.[day] ?? '—'),
+ mileageCell(v, 'today'),
v.totalKm != null ? v.totalKm : '',
]),
];
diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts
index 2bf302a..0021f8f 100644
--- a/src/server/routes/mileage/cache.ts
+++ b/src/server/routes/mileage/cache.ts
@@ -4,11 +4,8 @@ import { dirname, join } from 'node:path';
import pool from '../../db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js';
-import {
- sourceCategoryFromProtocol,
- type MileageSourceCategory,
- type OneOsProtocol,
-} from './source-policy.js';
+import type { OneOsProtocol } from './source-policy.js';
+import { buildRangeMileage, type RangeMileageResult } from './range-model.js';
import {
buildMonitoringFilters,
buildPlateTargetNamesMap,
@@ -18,8 +15,6 @@ import {
mileageDatesBetween,
previousMileageDate,
toMileageRows,
- type DailyMileageRow,
- type MileageRow,
type TargetRow,
} from './cache-model.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters } from './types.js';
@@ -43,13 +38,6 @@ function shanghaiDate(): string {
}).format(new Date());
}
-export interface RangeMileageResult {
- vehicles: CachedVehicle[];
- dailyTotals: { date: string; totalKm: number }[];
- start: string;
- end: string;
-}
-
async function fetchTargetRows(): Promise {
return pool.execute(
`SELECT t.id, t.target_name, v.plate_number
@@ -131,23 +119,6 @@ export async function queryRangeMileage(
endDate: string,
protocolPriority?: OneOsProtocol[],
): Promise {
- 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 [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsMileageDates(days, undefined, protocolPriority),
@@ -156,122 +127,7 @@ export async function queryRangeMileage(
fetchVehicleInfoMap(),
fetchTargetRows(),
]);
- 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: 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>();
- const perVehicleDailySources = new Map>();
- const perVehicleSourceCategories = new Map>();
- const perVehicleSum = new Map();
- const dailyTotals = new Map();
- const bestDailyRows = new Map();
-
- 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();
- 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 = {};
- const completedDailySources: Record = {};
- 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,
- };
+ return buildRangeMileage(startDate, endDate, apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows, regionMap);
}
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
diff --git a/src/server/routes/mileage/oneos-model.ts b/src/server/routes/mileage/oneos-model.ts
index 53a12f6..e92e3ed 100644
--- a/src/server/routes/mileage/oneos-model.ts
+++ b/src/server/routes/mileage/oneos-model.ts
@@ -14,6 +14,12 @@ export interface OneOsDailyMileage {
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}$/;
export function assertMileageDate(date: string): void {
diff --git a/src/server/routes/mileage/range-model.test.ts b/src/server/routes/mileage/range-model.test.ts
new file mode 100644
index 0000000..0628081
--- /dev/null
+++ b/src/server/routes/mileage/range-model.test.ts
@@ -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();
+ 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);
+});
diff --git a/src/server/routes/mileage/range-model.ts b/src/server/routes/mileage/range-model.ts
new file mode 100644
index 0000000..19e8e5e
--- /dev/null
+++ b/src/server/routes/mileage/range-model.ts
@@ -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,
+ endDateRows: OneOsDailyMileage[],
+ yesterdayRows: OneOsDailyMileage[],
+ infoMap: Map,
+ targetRows: TargetRow[],
+ regionMap: Record,
+): 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>();
+ const perVehicleDailySources = new Map>();
+ const perVehicleSourceCategories = new Map>();
+ const perVehicleSum = new Map();
+ const dailyTotals = new Map();
+ const bestDailyRows = new Map();
+
+ 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();
+ 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 = {};
+ const completedDailySources: Record = {};
+ 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,
+ };
+}
diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts
index 4cb17dd..823a22d 100644
--- a/src/server/routes/mileage/types.ts
+++ b/src/server/routes/mileage/types.ts
@@ -4,7 +4,7 @@ export interface CachedVehicle {
vin: string;
dailyKm: number;
mileageAnomaly?: string | null;
- dailyMileage?: Record;
+ dailyMileage?: Record;
totalKm: number | null;
source: string;
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
diff --git a/src/server/routes/mileage/vehicle-recent.ts b/src/server/routes/mileage/vehicle-recent.ts
index 623867a..184b74f 100644
--- a/src/server/routes/mileage/vehicle-recent.ts
+++ b/src/server/routes/mileage/vehicle-recent.ts
@@ -1,4 +1,5 @@
import { Hono } from 'hono';
+import { validDailyMileage } from './oneos-model.js';
import { fetchOneOsMileageDates } from './oneos-api.js';
import {
parseMileageSourcePriority,
@@ -81,8 +82,8 @@ app.get('/:plate/recent', async (c) => {
const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate);
result.push({
date: key,
- dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
- isDataSynced: hit?.status === 'NORMAL',
+ dailyKm: validDailyMileage(hit) ?? 0,
+ isDataSynced: validDailyMileage(hit) != null,
sourceProtocol: hit?.sourceProtocol || null,
sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null),
});
|