From a065e9fa1d852f74a1f466e319d12ef21cb4de34 Mon Sep 17 00:00:00 2001 From: shishengliang Date: Tue, 13 Oct 2026 13:20:01 +0800 Subject: [PATCH] fix(mileage): skip missing daily mileage in interval totals --- src/modules/mileage/VehicleDetailModal.tsx | 29 ++-- src/modules/mileage/mileage-display.test.ts | 23 +++ src/modules/mileage/mileage-display.ts | 24 +++ .../components/FullscreenMonitoring.tsx | 5 +- .../monitoring/components/VehicleList.tsx | 9 +- src/modules/mileage/types.ts | 2 +- src/modules/mileage/xlsx-export.ts | 10 +- src/server/routes/mileage/cache.ts | 150 +----------------- src/server/routes/mileage/oneos-model.ts | 6 + src/server/routes/mileage/range-model.test.ts | 71 +++++++++ src/server/routes/mileage/range-model.ts | 148 +++++++++++++++++ src/server/routes/mileage/types.ts | 2 +- src/server/routes/mileage/vehicle-recent.ts | 5 +- 13 files changed, 309 insertions(+), 175 deletions(-) create mode 100644 src/modules/mileage/mileage-display.test.ts create mode 100644 src/modules/mileage/mileage-display.ts create mode 100644 src/server/routes/mileage/range-model.test.ts create mode 100644 src/server/routes/mileage/range-model.ts 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 }:
区间合计
{loading ? - : <>{Math.round(stats.totalKm).toLocaleString()}km} + : stats.synced === 0 ? '—' : <>{Math.round(stats.totalKm).toLocaleString()}km}
日均
{loading ? - : <>{Math.round(stats.avg).toLocaleString()}km} + : stats.synced === 0 ? '—' : <>{Math.round(stats.avg).toLocaleString()}km}
@@ -237,7 +235,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: ) : ( - + formatLabel(d, range)} @@ -249,7 +247,8 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: /> [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']} + filterNull={false} + formatter={(v) => [v == null ? '—' : `${Math.round(Number(v)).toLocaleString()} km`, '行驶里程']} labelFormatter={(d) => `日期 ${d}`} contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }} cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }} @@ -306,15 +305,15 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
- - {d.isDataSynced + + {hasDailyMileage(d) ? <>{Math.round(d.dailyKm).toLocaleString()} km - : 未对接} + : —}
diff --git a/src/modules/mileage/mileage-display.test.ts b/src/modules/mileage/mileage-display.test.ts new file mode 100644 index 0000000..13f6adf --- /dev/null +++ b/src/modules/mileage/mileage-display.test.ts @@ -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); +}); diff --git a/src/modules/mileage/mileage-display.ts b/src/modules/mileage/mileage-display.ts new file mode 100644 index 0000000..aa16014 --- /dev/null +++ b/src/modules/mileage/mileage-display.ts @@ -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, + }; +} diff --git a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx index b183e93..71c9e85 100644 --- a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx +++ b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx @@ -1,3 +1,4 @@ +import { monitoringMileageValue } from '../../mileage-display'; import type { Dispatch, SetStateAction } from 'react'; import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react'; import { motion } from 'motion/react'; @@ -283,6 +284,8 @@ export default function FullscreenMonitoring({ {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), });