diff --git a/package-lock.json b/package-lock.json index b443096..1652476 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ln-bi", - "version": "1.1.22", + "version": "1.1.23", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ln-bi", - "version": "1.1.22", + "version": "1.1.23", "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", "@hono/node-server": "^1.13.0", diff --git a/package.json b/package.json index 5cfd399..269359b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ln-bi", "private": true, - "version": "1.1.22", + "version": "1.1.23", "type": "module", "scripts": { "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index 9ec2574..2c35af4 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -391,7 +391,6 @@ export default function MonitoringView() { rangeEnd={rangeEnd} onRangeStartChange={setRangeStart} onRangeEndChange={setRangeEnd} - onApplyRangePreset={applyRangePreset} departments={departments} filterDept={filterDept} onFilterDeptChange={setFilterDept} diff --git a/src/modules/mileage/monitoring/components/FilterPanel.tsx b/src/modules/mileage/monitoring/components/FilterPanel.tsx index faf840e..b2acc98 100644 --- a/src/modules/mileage/monitoring/components/FilterPanel.tsx +++ b/src/modules/mileage/monitoring/components/FilterPanel.tsx @@ -1,7 +1,8 @@ import type { Dispatch, SetStateAction } from 'react'; import { motion } from 'motion/react'; import type { MonitoringFilters } from '../../types'; -import { defaultMileageDate, type RangePreset } from '../date-range'; +import { DateRangePicker } from '../../../../components/ui/DateRangePicker'; +import { defaultMileageDate, getRangePreset, type RangePreset } from '../date-range'; import BatchMultiSelect from './BatchMultiSelect'; import SearchableSelect from './SearchableSelect'; @@ -12,7 +13,6 @@ export default function FilterPanel({ rangeEnd, onRangeStartChange, onRangeEndChange, - onApplyRangePreset, departments, filterDept, onFilterDeptChange, @@ -42,7 +42,6 @@ export default function FilterPanel({ rangeEnd: string; onRangeStartChange: (value: string) => void; onRangeEndChange: (value: string) => void; - onApplyRangePreset: (preset: RangePreset) => void; departments: string[]; filterDept: string; onFilterDeptChange: (value: string) => void; @@ -76,47 +75,25 @@ export default function FilterPanel({ className="overflow-hidden" >
- {/* Date range */} -
- -
- onRangeStartChange(e.target.value)} - /> - 至 - onRangeEndChange(e.target.value)} - /> -
- {rangeStart && rangeEnd && rangeStart > rangeEnd &&

开始日期不能晚于结束日期

} -
- {([ - ['today', '今天'], - ['yesterday', '昨天'], - ['thisWeek', '本周'], - ['thisMonth', '本月'], - ['last7', '近7天'], - ['last15', '近15天'], - ] as Array<[RangePreset, string]>).map(([preset, label]) => ( - - ))} -
-
+ { + onRangeStartChange(start); + onRangeEndChange(end); + }} + quickPicks={([ + ['today', '今天'], + ['yesterday', '昨天'], + ['thisWeek', '本周'], + ['thisMonth', '本月'], + ['last7', '近7天'], + ['last15', '近15天'], + ] satisfies Array<[RangePreset, string]>).map(([preset, label]) => ({ + label, + range: getRangePreset(preset), + }))} + />
{/* Department */} diff --git a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx index 99b0454..bdc749c 100644 --- a/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx +++ b/src/modules/mileage/monitoring/components/FullscreenMonitoring.tsx @@ -331,7 +331,8 @@ export default function FullscreenMonitoring({ - {v.totalKm != null ? <>{v.totalKm.toLocaleString()} km : 未对接} + {v.totalKm != null ? <>{v.totalKm.toLocaleString()} km : 暂无读数} + {v.totalKmFallbackDate && 截至 {v.totalKmFallbackDate}} diff --git a/src/modules/mileage/monitoring/components/VehicleList.tsx b/src/modules/mileage/monitoring/components/VehicleList.tsx index b983eef..b88b83a 100644 --- a/src/modules/mileage/monitoring/components/VehicleList.tsx +++ b/src/modules/mileage/monitoring/components/VehicleList.tsx @@ -113,7 +113,8 @@ export default function VehicleList({
{v.totalKm != null ? v.totalKm.toLocaleString() : '-'} - km + {v.totalKm != null && km} + {v.totalKmFallbackDate &&
截至 {v.totalKmFallbackDate}
}
@@ -129,7 +130,8 @@ export default function VehicleList({
总 - {v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '未对接'} + {v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '暂无读数'} + {v.totalKmFallbackDate && 截至 {v.totalKmFallbackDate}}
diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index 135af6c..4ee6733 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -16,6 +16,7 @@ export interface MonitoringVehicle { mileageAnomaly?: string | null; dailyMileage?: Record; totalKm: number | null; + totalKmFallbackDate?: string; source: string; sourceProtocol: MileageSourceProtocol | null; sourceCategory: MileageSourceCategory | null; diff --git a/src/modules/mileage/xlsx-export.ts b/src/modules/mileage/xlsx-export.ts index 1d1ebda..dd37714 100644 --- a/src/modules/mileage/xlsx-export.ts +++ b/src/modules/mileage/xlsx-export.ts @@ -50,6 +50,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont ...(dayKeys.length > 0 ? dailyHeaders : []), isRange ? '区间合计(km)' : '当日里程(km)', '累计里程(km)', + ...(isRange ? ['累计读数日期'] : []), ]; const summaryData: (string | number)[][] = [ @@ -71,6 +72,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont ...dailyCols, mileageCell(v, 'today'), mileageCell(v, 'total'), + ...(isRange ? [v.totalKm != null ? v.totalKmFallbackDate || end : ''] : []), ]; }), ]; @@ -92,6 +94,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 }); wsCols.push({ wch: 14 }); // 区间合计 wsCols.push({ wch: 14 }); // 累计里程 + if (isRange) wsCols.push({ wch: 16 }); ws['!cols'] = wsCols; // 冻结首行 + 固定列 @@ -126,6 +129,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont ...dayKeys.map(day => `${day}里程(km)`), '区间合计(km)', '累计里程(km)', + ...(isRange ? ['累计读数日期'] : []), ]; const detailData: (string | number)[][] = [ detailHeaders, @@ -140,6 +144,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont ...dayKeys.map(day => v.dailyMileage?.[day] ?? '—'), mileageCell(v, 'today'), v.totalKm != null ? v.totalKm : '', + ...(isRange ? [v.totalKm != null ? v.totalKmFallbackDate || end : ''] : []), ]), ]; const detailWs = XLSX.utils.aoa_to_sheet(detailData); @@ -154,6 +159,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont ...dayKeys.map(() => ({ wch: 14 })), { wch: 14 }, { wch: 14 }, + ...(isRange ? [{ wch: 16 }] : []), ]; detailWs['!freeze'] = { xSplit: 7, ySplit: 1 } as never; for (let r = 1; r < detailData.length; r++) { diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts index b8a9d34..40137c1 100644 --- a/src/server/routes/mileage/cache.ts +++ b/src/server/routes/mileage/cache.ts @@ -127,7 +127,7 @@ export async function queryRangeMileage( ]); const { summary, yesterdayRows } = cached.result; return { - vehicles: buildSummaryMileage(summary, yesterdayRows, infoMap, targetRows, regionMap), + vehicles: buildSummaryMileage(summary, yesterdayRows, infoMap, targetRows, regionMap, cached.result.priorOdometers), summary, start: startDate, end: endDate, queriedAt: cached.queriedAt, diff --git a/src/server/routes/mileage/monitoring-cache.test.ts b/src/server/routes/mileage/monitoring-cache.test.ts index cb9f363..5628a71 100644 --- a/src/server/routes/mileage/monitoring-cache.test.ts +++ b/src/server/routes/mileage/monitoring-cache.test.ts @@ -54,11 +54,14 @@ test('summary integration preserves scope, valid partial mileage, trend nulls, r assert.equal(first.vehicles[0].plate,'TEST1');assert.equal(first.stats.totalToday,50); assert.equal(first.rangeDailyTotals?.[1].totalKm,null); assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1); - assert.equal(requests.filter(r=>r.url.endsWith('/range/query')).length,0); + assert.deepEqual(requests.filter(r=>r.url.endsWith('/range/query')).map(r=>r.body.plateNumbers), [['TEST0']]); const second=await read('&page=2&limit=1&includeDailyTrend=0');assert.equal(second.vehicles[0].periodMileageKm,20); const sorted=await read('&sortOrder=asc&includeDailyTrend=0');assert.equal(sorted.vehicles[0].periodMileageKm,0); const exported=await read('&limit=9999&includeDailyTrend=0'); assert.equal(exported.vehicles.length,4);assert.equal(exported.vehicles[3].periodMileageKm,null); + const fallback = exported.vehicles.find(v => v.plate === 'TEST0')!; + assert.equal(fallback.totalKm,100); + assert.equal(fallback.totalKmFallbackDate,'2026-10-10'); assert.equal(exported.queriedAt,first.queriedAt);assert.ok(!exported.vehicles[0].dailyMileage); assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1); user={...user,permissionLevel:'department'}; diff --git a/src/server/routes/mileage/query-cache.ts b/src/server/routes/mileage/query-cache.ts index 2ad6413..f963ae8 100644 --- a/src/server/routes/mileage/query-cache.ts +++ b/src/server/routes/mileage/query-cache.ts @@ -1,5 +1,5 @@ -import { fetchOneOsDailyMileage, type OneOsDailyMileage } from './oneos-api.js'; -import { previousMileageDate } from './cache-model.js'; +import { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js'; +import { mileageDatesBetween, previousMileageDate } from './cache-model.js'; import { fetchMileageSummary, type MileageSummary } from './summary-api.js'; import type { OneOsProtocol } from './source-policy.js'; @@ -10,6 +10,7 @@ const MAX_ENTRIES = 4; export interface RangeMileageSource { summary: MileageSummary; yesterdayRows: OneOsDailyMileage[]; + priorOdometers?: Map; } type Entry = { result: RangeMileageSource; queriedAt: string; expiresAt: number }; @@ -19,7 +20,23 @@ async function loadSource(start: string, end: string, protocols: OneOsProtocol[] fetchMileageSummary(start, end, protocols, plates), plates ? Promise.resolve([]) : fetchOneOsDailyMileage(previousMileageDate(start), undefined, protocols, refresh), ]); - return { summary, yesterdayRows }; + // Plate-scoped summary requests are used only for filtered trends. + if (plates || start === end) return { summary, yesterdayRows }; + const missing = summary.vehicles.filter(v => v.endDateTotalMileageKm === null && v.validDays + v.anomalyDays > 0); + if (!missing.length) return { summary, yesterdayRows }; + const dates = mileageDatesBetween(start, previousMileageDate(end)); + const history = await fetchOneOsMileageDates(dates, missing.map(v => v.plateNumber), protocols); + const priorOdometers = new Map(); + for (const date of dates.reverse()) { + for (const row of history.get(date) || []) { + const plate = row.plateNumber.toUpperCase(); + const trusted = row.status === 'NORMAL' || row.dataQuality === 'BASELINE_MISSING' || row.dataQuality === 'ODOMETER_SOURCE_CHANGED'; + if (!priorOdometers.has(plate) && trusted && row.dataQuality !== 'CARRIED_FORWARD' && row.totalMileageKm != null) { + priorOdometers.set(plate, { date, km: row.totalMileageKm }); + } + } + } + return { summary, yesterdayRows, priorOdometers }; } export function createMonitoringQueryCache( diff --git a/src/server/routes/mileage/summary-model.ts b/src/server/routes/mileage/summary-model.ts index e26529b..b7312a8 100644 --- a/src/server/routes/mileage/summary-model.ts +++ b/src/server/routes/mileage/summary-model.ts @@ -4,15 +4,17 @@ import { buildPlateTargetNamesMap, dailyMileageMap, mergeMonitoringVehicles, typ import type { CachedVehicle, VehicleInfoRow } from './types.js'; import { sourceCategoryFromProtocol } from './source-policy.js'; -export function buildSummaryMileage(summary: MileageSummary, yesterdayRows: OneOsDailyMileage[], infoMap: Map, targets: TargetRow[], regions: Record): CachedVehicle[] { +export function buildSummaryMileage(summary: MileageSummary, yesterdayRows: OneOsDailyMileage[], infoMap: Map, targets: TargetRow[], regions: Record, priorOdometers?: Map): CachedVehicle[] { const byPlate = new Map(summary.vehicles.map(v => [v.plateNumber, v])); return mergeMonitoringVehicles([], infoMap, dailyMileageMap(yesterdayRows), buildPlateTargetNamesMap(targets), regions).map(vehicle => { const row = byPlate.get(vehicle.plate.trim().toUpperCase()); if (!row) return { ...vehicle, periodMileageKm: null }; + const prior = priorOdometers?.get(row.plateNumber); const categories = new Set(row.sourceProtocols.map(sourceCategoryFromProtocol)); return { ...vehicle, vin: row.vin || vehicle.vin, dailyKm: row.periodMileageKm ?? 0, periodMileageKm: row.periodMileageKm, - totalKm: row.endDateTotalMileageKm, + totalKm: row.endDateTotalMileageKm ?? prior?.km ?? null, + totalKmFallbackDate: prior?.date, validDays: row.validDays, missingDays: row.missingDays, anomalyDays: row.anomalyDays, qualityStatus: row.status, qualityReasons: row.qualityReasons, sourceProtocols: row.sourceProtocols, mileageAnomaly: row.anomalyDays ? row.qualityReasons.join(', ') || 'DATA_ANOMALY' : null, diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts index d4421b6..1223304 100644 --- a/src/server/routes/mileage/types.ts +++ b/src/server/routes/mileage/types.ts @@ -13,6 +13,7 @@ export interface CachedVehicle { mileageAnomaly?: string | null; dailyMileage?: Record; totalKm: number | null; + totalKmFallbackDate?: string; source: string; sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null; sourceCategory: 'INSTRUMENT' | 'GPS' | 'MIXED' | null;