From d8773ff0a0f26a20dbd192b7292227e91aa28671 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Thu, 23 Jul 2026 14:11:52 +0800 Subject: [PATCH] feat(mileage): add OneOS source priority controls --- docs/oneos-mileage-api-requirements.md | 23 ++- src/modules/mileage/MonitoringView.tsx | 160 +++++++++++++++++++- src/modules/mileage/VehicleDetailModal.tsx | 31 +++- src/modules/mileage/api.ts | 23 ++- src/modules/mileage/types.ts | 8 + src/modules/mileage/xlsx-export.ts | 19 ++- src/server/routes/mileage/cache.ts | 62 ++++++-- src/server/routes/mileage/monitoring.ts | 13 +- src/server/routes/mileage/oneos-api.ts | 84 ++++++++-- src/server/routes/mileage/source-policy.ts | 38 +++++ src/server/routes/mileage/types.ts | 4 + src/server/routes/mileage/vehicle-recent.ts | 27 +++- 12 files changed, 448 insertions(+), 44 deletions(-) create mode 100644 src/server/routes/mileage/source-policy.ts diff --git a/docs/oneos-mileage-api-requirements.md b/docs/oneos-mileage-api-requirements.md index f3909b9..6fcbcfc 100644 --- a/docs/oneos-mileage-api-requirements.md +++ b/docs/oneos-mileage-api-requirements.md @@ -22,6 +22,7 @@ POST /api/v1/vehicles/mileage/range/query "startDate": "2026-07-01", "endDate": "2026-07-23", "plateNumbers": ["沪A00001", "沪A00002"], + "protocolPriority": ["GB32960", "MQTT", "JT808"], "cursor": null, "pageSize": 5000 } @@ -31,6 +32,14 @@ POST /api/v1/vehicles/mileage/range/query - `startDate`、`endDate` 必填,最长支持 366 天; - `plateNumbers` 可省略,省略时返回授权范围内全部车辆; +- `protocolPriority` 可省略;提供时只允许 `GB32960`、`MQTT`、`JT808`, + 数组顺序表示逐车逐日的数据源优先级,未列出的协议视为禁用; +- BI 只会使用以下四种组合: + - 仪表数据优先:`["GB32960", "MQTT", "JT808"]`; + - GPS 数据优先:`["JT808", "GB32960", "MQTT"]`; + - 仅仪表数据:`["GB32960", "MQTT"]`; + - 仅 GPS 数据:`["JT808"]`; +- 仪表数据内部优先级固定为 `GB32960 > MQTT`,不提供反向切换; - 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`; - 每辆车每天返回一条记录; - 无数据返回 `status: "NO_DATA"` 且 `dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"` 且 `dailyMileageKm: 0`。 @@ -47,6 +56,7 @@ POST /api/v1/vehicles/mileage/range/query "plateNumber": "沪A00001", "date": "2026-07-01", "dailyMileageKm": 182.437, + "sourceProtocol": "GB32960", "dataTime": "2026-07-01T23:58:45+08:00", "updatedAt": "2026-07-02T05:10:00+08:00", "status": "NORMAL" @@ -71,13 +81,15 @@ POST /api/v1/vehicles/mileage/query ```json { "date": "2026-07-23", - "plateNumbers": ["沪A00001", "沪A00002"] + "plateNumbers": ["沪A00001", "沪A00002"], + "protocolPriority": ["GB32960", "MQTT", "JT808"] } ``` 规则: - `date` 和 `plateNumbers` 沿用现有接口规则; +- `protocolPriority` 的枚举、顺序和禁用规则与区间接口完全一致; - `plateNumbers` 省略时返回授权范围内全部车辆; - 接口默认在现有车辆结果中返回 `totalMileageKm`、`dataTime` 和 `updatedAt`; - 只增加响应字段,不删除或修改任何现有字段及其语义; @@ -100,6 +112,7 @@ POST /api/v1/vehicles/mileage/query "date": "2026-07-23", "dailyMileageKm": 182.437, "totalMileageKm": 12345.678, + "sourceProtocol": "GB32960", "dataTime": "2026-07-23T10:35:42+08:00", "updatedAt": "2026-07-23T10:35:46+08:00", "status": "NORMAL" @@ -114,6 +127,11 @@ POST /api/v1/vehicles/mileage/query ## 通用要求 - `dataTime`、`updatedAt` 使用带 `+08:00` 的 ISO 8601 格式; +- `sourceProtocol` 必须返回本条结果实际采用的协议: + `GB32960`、`MQTT` 或 `JT808`;无数据时返回 `null`; +- OneOS 必须按 `protocolPriority` 选择第一份有效数据,不得返回已禁用协议的数据; +- `sourceProtocol=GB32960/MQTT` 在 BI 显示为“仪表数据”, + `sourceProtocol=JT808` 显示为“GPS数据”; - 每个响应返回 `traceId`; - 接口返回值保留原始精度; - 负里程不得作为正常数据返回; @@ -127,3 +145,6 @@ POST /api/v1/vehicles/mileage/query 3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。 4. 正确区分无数据与真实零里程。 5. `dataTime` 能反映每辆车实际数据的新鲜度。 +6. 四种 `protocolPriority` 组合均能按顺序选源,且响应中的 + `sourceProtocol` 与实际采用协议一致。 +7. 禁用某类协议后,响应不得回退到该协议。 diff --git a/src/modules/mileage/MonitoringView.tsx b/src/modules/mileage/MonitoringView.tsx index af4402c..b034ec2 100644 --- a/src/modules/mileage/MonitoringView.tsx +++ b/src/modules/mileage/MonitoringView.tsx @@ -4,10 +4,15 @@ import { Truck, Filter, ChevronDown, Maximize2, Minimize2, RotateCcw, ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays, - RefreshCw, + RefreshCw, ArrowLeftRight, Power, } from 'lucide-react'; import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine, XAxis } from 'recharts'; -import type { MonitoringVehicle, MonitoringStats, MonitoringFilters } from './types'; +import type { + MileageSourceGroup, + MonitoringVehicle, + MonitoringStats, + MonitoringFilters, +} from './types'; import { fetchMonitoring } from './api'; import Blur from '../../components/Blur'; import PlateMultiSelect from './PlateMultiSelect'; @@ -19,6 +24,116 @@ const HIGH_MILEAGE_ALERT_TARGETS = new Set([ '交投190辆4.5T冷链车', ]); const HIGH_MILEAGE_ALERT_KM = 800; +const ALL_MILEAGE_SOURCE_GROUPS: MileageSourceGroup[] = ['instrument', 'gps']; + +const MILEAGE_SOURCE_META = { + instrument: { label: '仪表数据', protocol: '32960 > MQTT' }, + gps: { label: 'GPS数据', protocol: 'JT808' }, +} as const; + +function vehicleSourceDisplay(vehicle: MonitoringVehicle): { + label: string; + title: string; + className: string; +} { + if (vehicle.sourceCategory === 'INSTRUMENT') { + return { + label: '仪表数据', + title: vehicle.sourceProtocol || 'GB32960 / MQTT', + className: 'bg-violet-50 text-violet-600', + }; + } + if (vehicle.sourceCategory === 'GPS') { + return { + label: 'GPS数据', + title: vehicle.sourceProtocol || 'JT808', + className: 'bg-emerald-50 text-emerald-600', + }; + } + if (vehicle.sourceCategory === 'MIXED') { + return { + label: '仪表+GPS', + title: '所选区间内包含仪表数据和 GPS 数据', + className: 'bg-blue-50 text-blue-600', + }; + } + return { + label: vehicle.isDataSynced ? '来源待接口' : '无数据', + title: vehicle.isDataSynced + ? 'OneOS 当前响应未返回 sourceProtocol' + : '所选来源没有有效数据', + className: vehicle.isDataSynced + ? 'bg-amber-50 text-amber-600' + : 'bg-slate-100 text-slate-400', + }; +} + +function SourcePriorityControl({ + priority, + onChange, +}: { + priority: MileageSourceGroup[]; + onChange: (next: MileageSourceGroup[]) => void; +}) { + const ordered = [ + ...priority, + ...ALL_MILEAGE_SOURCE_GROUPS.filter(group => !priority.includes(group)), + ]; + const toggle = (group: MileageSourceGroup) => { + if (priority.includes(group)) { + if (priority.length === 1) return; + onChange(priority.filter(item => item !== group)); + return; + } + onChange([...priority, group]); + }; + const swap = () => { + if (priority.length === 2) onChange([priority[1], priority[0]]); + }; + + return ( +
+ 数据来源 + {ordered.map(group => { + const enabledIndex = priority.indexOf(group); + const enabled = enabledIndex >= 0; + const meta = MILEAGE_SOURCE_META[group]; + return ( + + ); + })} + +
+ ); +} function defaultMileageDate(): string { const now = new Date(); @@ -354,6 +469,7 @@ export default function MonitoringView() { const [detailVehicle, setDetailVehicle] = useState(null); const [rangeStart, setRangeStart] = useState(defaultMileageDate); const [rangeEnd, setRangeEnd] = useState(defaultMileageDate); + const [sourcePriority, setSourcePriority] = useState(['instrument', 'gps']); const [vehicles, setVehicles] = useState([]); const [stats, setStats] = useState({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 }); @@ -418,6 +534,7 @@ export default function MonitoringView() { startDate: rangeStart || undefined, endDate: rangeEnd || undefined, brands: filterBrands.length > 0 ? filterBrands : undefined, + sourcePriority, }).then(d => { setVehicles(d.vehicles); setStats(d.stats); @@ -431,7 +548,7 @@ export default function MonitoringView() { }).catch(() => {}).finally(() => { if (showPageLoading) setPageLoading(false); }); - }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]); + }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]); const handleManualRefresh = useCallback(async () => { if (manualRefreshing) return; @@ -468,12 +585,13 @@ export default function MonitoringView() { startDate: rangeStart || undefined, endDate: rangeEnd || undefined, brands: filterBrands.length > 0 ? filterBrands : undefined, + sourcePriority, }).then(d => { setVehicles(prev => [...prev, ...d.vehicles]); setPage(nextPage); setHasMore(nextPage < d.totalPages); }).catch(() => {}).finally(() => setLoadingMore(false)); - }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands]); + }, [sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, page, loadingMore, hasMore, filterBrands, sourcePriority]); // 筛选/排序变化时重新加载 useEffect(() => { @@ -513,6 +631,7 @@ export default function MonitoringView() { startDate: rangeStart || undefined, endDate: rangeEnd || undefined, brands: filterBrands.length > 0 ? filterBrands : undefined, + sourcePriority, }); exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy }); } catch (err) { @@ -520,7 +639,7 @@ export default function MonitoringView() { } finally { setExporting(false); } - }, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands]); + }, [exporting, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterProject, filterEntity, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, appliedMileageRange, rangeStart, rangeEnd, filterBrands, sourcePriority]); // 每分钟自动刷新 useEffect(() => { @@ -592,12 +711,13 @@ export default function MonitoringView() { startDate: rangeStart || undefined, endDate: rangeEnd || undefined, brands: filterBrands.length > 0 ? filterBrands : undefined, + sourcePriority, }).then(d => { setFullscreenVehicles(d.vehicles); setFullscreenStats(d.stats); setFilterOptions(d.filters); }).catch(() => {}).finally(() => setFullscreenLoading(false)); - }, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands]); + }, [isFullscreen, sortBy, sortOrder, searchTerm, filterDept, filterCustomer, filterRentStatus, filterPlatePrefix, filterTargetNames, filterRegion, filterPlates, rangeStart, rangeEnd, fullscreenRefresh, filterBrands, sourcePriority]); // 全屏时禁止背景滚动 useEffect(() => { @@ -665,6 +785,12 @@ export default function MonitoringView() { 车辆 {fullscreenStats.vehicleCount} | {(fullscreenStats.vehicleCount > 0 ? (sortBy === 'today' ? fullscreenStats.totalToday : fullscreenStats.totalAll) / fullscreenStats.vehicleCount : 0).toFixed(0)} km + | + + 来源 + {sourcePriority.map(group => MILEAGE_SOURCE_META[group].label).join(' > ')} + +
@@ -845,6 +971,7 @@ export default function MonitoringView() { {fullscreenVehicles.map((v) => { const highMileageAlert = isHighMileageAlert(v); const statisticTime = vehicleStatisticTime(v, relativeNow); + const sourceDisplay = vehicleSourceDisplay(v); return ( @@ -855,6 +982,12 @@ export default function MonitoringView() {
{statisticTime.label}
+ + {sourceDisplay.label} + {v.customer || '-'} {v.brand || '-'} @@ -926,6 +1059,8 @@ export default function MonitoringView() {
+ +
@@ -1343,6 +1478,7 @@ export default function MonitoringView() { {filteredVehicles.map((v) => { const highMileageAlert = isHighMileageAlert(v); const statisticTime = vehicleStatisticTime(v, relativeNow); + const sourceDisplay = vehicleSourceDisplay(v); return ( {statisticTime.label}
+ + {sourceDisplay.label} +
{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''} {v.customer || '-'} @@ -1435,7 +1577,11 @@ export default function MonitoringView() {
- setDetailVehicle(null)} /> + setDetailVehicle(null)} + sourcePriority={sourcePriority} + /> {/* 回到顶部按钮 */} diff --git a/src/modules/mileage/VehicleDetailModal.tsx b/src/modules/mileage/VehicleDetailModal.tsx index abb5a59..5ef2a0a 100644 --- a/src/modules/mileage/VehicleDetailModal.tsx +++ b/src/modules/mileage/VehicleDetailModal.tsx @@ -4,13 +4,14 @@ import { X, Truck } from 'lucide-react'; import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell, } from 'recharts'; -import type { MonitoringVehicle } from './types'; +import type { MileageSourceGroup, MonitoringVehicle } from './types'; import { fetchVehicleRecent, type VehicleRecentDay } from './api'; import Blur from '../../components/Blur'; interface Props { vehicle: MonitoringVehicle | null; onClose: () => void; + sourcePriority: MileageSourceGroup[]; } type RangeKey = 'last15' | 'month' | 'quarter'; @@ -56,7 +57,13 @@ function formatLabel(date: string, key: RangeKey): string { return date.slice(5); } -export default function VehicleDetailModal({ vehicle, onClose }: Props) { +function daySourceLabel(day: VehicleRecentDay): string { + if (day.sourceCategory === 'INSTRUMENT') return '仪表数据'; + if (day.sourceCategory === 'GPS') return 'GPS数据'; + return day.isDataSynced ? '来源待接口' : '无数据'; +} + +export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }: Props) { const [days, setDays] = useState([]); const [loading, setLoading] = useState(false); const [range, setRange] = useState('last15'); @@ -74,12 +81,12 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) { setLoading(true); setDays([]); let cancelled = false; - fetchVehicleRecent(vehicle.plate, { start, end }) + fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority }) .then(d => { if (!cancelled) setDays(d.days); }) .catch(() => { if (!cancelled) setDays([]); }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; - }, [vehicle?.plate, range]); // eslint-disable-line react-hooks/exhaustive-deps + }, [vehicle?.plate, range, sourcePriority]); // eslint-disable-line react-hooks/exhaustive-deps // 锁滚动 useEffect(() => { @@ -280,7 +287,21 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) { transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }} className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50" > - {d.date} +
+
{d.date}
+
+ {daySourceLabel(d)} +
+
{ const query = new URLSearchParams(); if (params?.sortBy) query.set('sortBy', params.sortBy); @@ -56,6 +65,7 @@ export async function fetchMonitoring(params?: { if (params?.date) query.set('date', params.date); if (params?.startDate) query.set('startDate', params.startDate); if (params?.endDate) query.set('endDate', params.endDate); + if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(',')); const qs = query.toString(); return fetchJson(`${BASE}/monitoring${qs ? `?${qs}` : ''}`); } @@ -82,6 +92,8 @@ export interface VehicleRecentDay { date: string; dailyKm: number; isDataSynced: boolean; + sourceProtocol: MileageSourceProtocol | null; + sourceCategory: Exclude | null; } export interface VehicleRecentResponse { @@ -89,16 +101,23 @@ export interface VehicleRecentResponse { start?: string; end?: string; days: VehicleRecentDay[]; + sourcePriority?: MileageSourceGroup[]; } export async function fetchVehicleRecent( plate: string, - range: { days?: number; start?: string; end?: string } = { days: 15 }, + range: { + days?: number; + start?: string; + end?: string; + sourcePriority?: MileageSourceGroup[]; + } = { days: 15 }, ): Promise { const params = new URLSearchParams(); if (range.start) params.set('start', range.start); if (range.end) params.set('end', range.end); if (range.days != null) params.set('days', String(range.days)); + if (range.sourcePriority?.length) params.set('sourcePriority', range.sourcePriority.join(',')); return fetchJson( `${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}` ); diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index 38eb9b3..a31894c 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -1,3 +1,7 @@ +export type MileageSourceGroup = 'instrument' | 'gps'; +export type MileageSourceCategory = 'INSTRUMENT' | 'GPS' | 'MIXED'; +export type MileageSourceProtocol = 'GB32960' | 'MQTT' | 'JT808'; + export interface MonitoringVehicle { plate: string; vin: string; @@ -5,6 +9,9 @@ export interface MonitoringVehicle { dailyMileage?: Record; totalKm: number | null; source: string; + sourceProtocol: MileageSourceProtocol | null; + sourceCategory: MileageSourceCategory | null; + dailySourceProtocols?: Record; dataTime: string | null; calculatedAt: string | null; updatedAt: string | null; @@ -51,6 +58,7 @@ export interface MonitoringData { page: number; totalPages: number; updatedAt: string; + sourcePriority: MileageSourceGroup[]; } export interface TargetSummary { diff --git a/src/modules/mileage/xlsx-export.ts b/src/modules/mileage/xlsx-export.ts index f33f2b4..7e026eb 100644 --- a/src/modules/mileage/xlsx-export.ts +++ b/src/modules/mileage/xlsx-export.ts @@ -9,10 +9,17 @@ interface ExportContext { } const BASE_HEADERS = [ - '状态', '车牌号', '客户', '业务部门', '项目', '租赁状态', + '状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态', '运营区域', ] as const; +function sourceLabel(v: MonitoringVehicle): string { + if (v.sourceCategory === 'INSTRUMENT') return '仪表数据'; + if (v.sourceCategory === 'GPS') return 'GPS数据'; + if (v.sourceCategory === 'MIXED') return '仪表数据+GPS数据'; + return v.isDataSynced ? '来源待接口' : '无数据'; +} + function statusLabel(v: MonitoringVehicle): string { if (!v.isDataSynced) return '未对接'; return v.isOnline ? '在线' : '离线'; @@ -49,6 +56,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont ...vehicles.map(v => { const baseRow = [ statusLabel(v), + sourceLabel(v), v.plate, v.customer || '', v.department || '', @@ -71,6 +79,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont const numFixedCols = BASE_HEADERS.length; const wsCols: { wch: number }[] = [ { wch: 8 }, // 状态 + { wch: 16 }, // 数据来源 { wch: 12 }, // 车牌号 { wch: 28 }, // 客户 { wch: 14 }, // 业务部门 @@ -111,7 +120,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont // 每日明细 sheet:保留原有格式 if (dayKeys.length > 0) { const detailHeaders = [ - '车牌号', '客户', '业务部门', '项目', '租赁状态', '运营区域', + '车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域', ...dayKeys.map(day => `${day}里程(km)`), '区间合计(km)', '累计里程(km)', @@ -120,6 +129,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont detailHeaders, ...vehicles.map(v => [ v.plate, + sourceLabel(v), v.customer || '', v.department || '', v.project || '', @@ -133,6 +143,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont const detailWs = XLSX.utils.aoa_to_sheet(detailData); detailWs['!cols'] = [ { wch: 12 }, + { wch: 16 }, { wch: 28 }, { wch: 14 }, { wch: 16 }, @@ -142,9 +153,9 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont { wch: 14 }, { wch: 14 }, ]; - detailWs['!freeze'] = { xSplit: 6, ySplit: 1 } as never; + detailWs['!freeze'] = { xSplit: 7, ySplit: 1 } as never; for (let r = 1; r < detailData.length; r++) { - for (let c = 6; c < detailHeaders.length; c++) { + for (let c = 7; c < detailHeaders.length; c++) { const ref = XLSX.utils.encode_cell({ r, c }); if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########'; } diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts index eb337af..962b8c9 100644 --- a/src/server/routes/mileage/cache.ts +++ b/src/server/routes/mileage/cache.ts @@ -4,6 +4,11 @@ import { dirname, join } from 'node:path'; import pool from '../../db.js'; import { fetchVehicleInfoMap } from './vehicle-info.js'; import { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js'; +import { + sourceCategoryFromProtocol, + type MileageSourceCategory, + type OneOsProtocol, +} from './source-policy.js'; import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -64,6 +69,7 @@ interface MileageRow { daily_km: string; total_km: string | null; source: string; + source_protocol: OneOsProtocol | null; data_time: string | null; calculated_at: string | null; updated_at: string | null; @@ -75,6 +81,7 @@ interface DailyMileageRow { date: string; daily_km: string | number | null; source: string | null; + source_protocol: OneOsProtocol | null; data_time: string | null; calculated_at: string | null; updated_at: string | null; @@ -87,6 +94,7 @@ function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] { daily_km: String(row.dailyMileageKm ?? 0), total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm), source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE', + source_protocol: row.sourceProtocol, data_time: row.dataTime, calculated_at: row.calculatedAt, updated_at: row.updatedAt, @@ -190,6 +198,8 @@ function mergeVehicles( // Never backfill it from another mileage source outside the assessment page. totalKm: gpsTotal, source, + sourceProtocol: m?.source_protocol || null, + sourceCategory: sourceCategoryFromProtocol(m?.source_protocol || null), dataTime: m?.data_time || null, calculatedAt: m?.calculated_at || null, updatedAt: m?.updated_at || null, @@ -248,10 +258,13 @@ export async function refreshMonitoringCache(): Promise { } } -export async function queryDateMileage(dateStr: string): Promise { +export async function queryDateMileage( + dateStr: string, + protocolPriority?: OneOsProtocol[], +): Promise { const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([ - fetchOneOsDailyMileage(dateStr), - fetchOneOsDailyMileage(previousDate(dateStr)), + fetchOneOsDailyMileage(dateStr, undefined, protocolPriority), + fetchOneOsDailyMileage(previousDate(dateStr), undefined, protocolPriority), fetchVehicleInfoMap(), fetchTargetRows(), ]); @@ -282,11 +295,16 @@ function datesBetween(start: string, end: string): string[] { return result; } -export async function queryRangeMileage(startDate: string, endDate: string): Promise { +export async function queryRangeMileage( + startDate: string, + endDate: string, + protocolPriority?: OneOsProtocol[], +): Promise { if (startDate === endDate) { - const vehicles = (await queryDateMileage(startDate)).map(vehicle => ({ + const vehicles = (await queryDateMileage(startDate, protocolPriority)).map(vehicle => ({ ...vehicle, dailyMileage: { [startDate]: vehicle.dailyKm }, + dailySourceProtocols: { [startDate]: vehicle.sourceProtocol }, })); return { vehicles, @@ -301,9 +319,9 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro const days = datesBetween(startDate, endDate); const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([ - fetchOneOsMileageDates(days), - fetchOneOsDailyMileage(endDate), - fetchOneOsDailyMileage(previousDate(startDate)), + fetchOneOsMileageDates(days, undefined, protocolPriority), + fetchOneOsDailyMileage(endDate, undefined, protocolPriority), + fetchOneOsDailyMileage(previousDate(startDate), undefined, protocolPriority), fetchVehicleInfoMap(), fetchTargetRows(), ]); @@ -316,6 +334,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro date, daily_km: row.dailyMileageKm, source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE', + source_protocol: row.sourceProtocol, data_time: row.dataTime, calculated_at: row.calculatedAt, updated_at: row.updatedAt, @@ -324,12 +343,15 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro } const perVehicleDaily = new Map>(); + const perVehicleDailySources = new Map>(); + const perVehicleSourceCategories = new Map>(); const perVehicleSum = new Map(); + categories.add(category); + perVehicleSourceCategories.set(plate, categories); + } const existing = perVehicleSum.get(plate); perVehicleSum.set(plate, { @@ -365,6 +396,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro daily_km: String((Number(existing?.daily_km) || 0) + km), total_km: 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, @@ -379,6 +411,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro ...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, }); @@ -393,9 +426,20 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro buildPlateTargetNamesMap(targetRows), ).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; - return { ...vehicle, dailyMileage: completedDailyMileage }; + 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 { diff --git a/src/server/routes/mileage/monitoring.ts b/src/server/routes/mileage/monitoring.ts index 085dc3c..f2c30f1 100644 --- a/src/server/routes/mileage/monitoring.ts +++ b/src/server/routes/mileage/monitoring.ts @@ -3,6 +3,11 @@ import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js'; import type { AuthUser } from '../../auth/types.js'; import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './types.js'; +import { + DEFAULT_MILEAGE_SOURCE_PRIORITY, + parseMileageSourcePriority, + protocolsForSourcePriority, +} from './source-policy.js'; const app = new Hono(); @@ -14,6 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = { page: 1, totalPages: 1, updatedAt: new Date().toISOString(), + sourcePriority: [...DEFAULT_MILEAGE_SOURCE_PRIORITY], }; function applyFilters(vehicles: CachedVehicle[], params: { @@ -104,6 +110,8 @@ app.get('/', async (c) => { const page = Number(c.req.query('page')) || 1; const date = c.req.query('date') || ''; const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || ''); + const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority')); + const protocolPriority = protocolsForSourcePriority(sourcePriority); const filterParams = { search: c.req.query('search') || '', @@ -128,7 +136,7 @@ app.get('/', async (c) => { if (range) { try { - const result = await queryRangeMileage(range.start, range.end); + const result = await queryRangeMileage(range.start, range.end, protocolPriority); allVehicles = result.vehicles; rangeDailyTotals = result.dailyTotals; dateRange = { start: result.start, end: result.end }; @@ -139,7 +147,7 @@ app.get('/', async (c) => { } } else if (date) { try { - allVehicles = await queryDateMileage(date); + allVehicles = await queryDateMileage(date, protocolPriority); filters = buildDateFilters(allVehicles); } catch (e: unknown) { console.error('monitoring date query error:', e); @@ -200,6 +208,7 @@ app.get('/', async (c) => { page, totalPages: Math.ceil(total / limit), updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(), + sourcePriority, }); }); diff --git a/src/server/routes/mileage/oneos-api.ts b/src/server/routes/mileage/oneos-api.ts index c607899..916eb19 100644 --- a/src/server/routes/mileage/oneos-api.ts +++ b/src/server/routes/mileage/oneos-api.ts @@ -1,4 +1,9 @@ import dotenv from 'dotenv'; +import { + DEFAULT_ONEOS_PROTOCOL_PRIORITY, + normalizeOneOsProtocol, + type OneOsProtocol, +} from './source-policy.js'; dotenv.config(); @@ -20,6 +25,7 @@ export interface OneOsDailyMileage { dataTime: string | null; calculatedAt: string | null; updatedAt: string | null; + sourceProtocol: OneOsProtocol | null; } interface OneOsMileageResponse { @@ -42,6 +48,17 @@ interface CacheEntry { const cache = new Map(); const inflight = new Map>(); const rangeInflight = new Map>>(); +let protocolPrioritySupported: boolean | null = null; +let protocolFallbackWarned = false; + +function markProtocolPriorityUnsupported(): void { + protocolPrioritySupported = false; + if (protocolFallbackWarned) return; + protocolFallbackWarned = true; + console.warn( + '[mileage] OneOS does not support protocolPriority/sourceProtocol yet; using the legacy request contract', + ); +} function apiConfig(): { baseUrl: string; apiKey: string; timeoutMs: number } { const baseUrl = (process.env.ONEOS_MILEAGE_API_BASE_URL || '').replace(/\/+$/, ''); @@ -94,6 +111,9 @@ function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage const dataTime = stringField('dataTime', 'statisticTime', 'recordTime'); const calculatedAt = stringField('calculatedAt', 'calculationTime'); const updatedAt = stringField('updatedAt'); + const sourceProtocol = normalizeOneOsProtocol( + stringField('sourceProtocol', 'protocol', 'dataSource'), + ); if (!plateNumber || date !== requestedDate) continue; const normalized: OneOsDailyMileage = { @@ -106,6 +126,7 @@ function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage dataTime, calculatedAt, updatedAt, + sourceProtocol, }; const existing = best.get(plateNumber); if (!existing || (dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) { @@ -131,12 +152,18 @@ function normalizeRangeRows(value: unknown, startDate: string, endDate: string): return result; } -async function requestDate(date: string): Promise { +async function requestDate( + date: string, + protocolPriority: OneOsProtocol[], +): Promise { const { baseUrl, apiKey, timeoutMs } = apiConfig(); let lastError: unknown; for (let attempt = 0; attempt < 3; attempt += 1) { try { + const includeProtocolPriority = protocolPrioritySupported !== false; + const body: Record = { date }; + if (includeProtocolPriority) body.protocolPriority = protocolPriority; const response = await fetch(`${baseUrl}${ENDPOINT}`, { method: 'POST', headers: { @@ -145,10 +172,18 @@ async function requestDate(date: string): Promise { }, // Intentionally omit plateNumbers: the API then returns every vehicle // authorized for this application on the requested natural day. - body: JSON.stringify({ date }), + body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), }); const payload = await response.json().catch(() => null) as OneOsMileageResponse | null; + if ( + includeProtocolPriority && + response.status === 400 && + payload?.code === 'INVALID_REQUEST' + ) { + markProtocolPriorityUnsupported(); + continue; + } if (!response.ok || payload?.code !== 'SUCCESS') { const trace = payload?.traceId ? `, traceId=${payload.traceId}` : ''; const error = new Error( @@ -157,6 +192,7 @@ async function requestDate(date: string): Promise { if (response.status < 500 || attempt === 2) throw error; lastError = error; } else { + if (includeProtocolPriority) protocolPrioritySupported = true; return normalizeRows(payload.data, date); } } catch (error) { @@ -175,6 +211,7 @@ async function requestRange( startDate: string, endDate: string, plateNumbers: string[], + protocolPriority: OneOsProtocol[], ): Promise> { const { baseUrl, apiKey, timeoutMs } = apiConfig(); const allRows: unknown[] = []; @@ -192,6 +229,8 @@ async function requestRange( endDate, pageSize: RANGE_PAGE_SIZE, }; + const includeProtocolPriority = protocolPrioritySupported !== false; + if (includeProtocolPriority) body.protocolPriority = protocolPriority; if (plateNumbers.length > 0) body.plateNumbers = plateNumbers; if (cursor) body.cursor = cursor; const response = await fetch(`${baseUrl}${RANGE_ENDPOINT}`, { @@ -204,6 +243,14 @@ async function requestRange( signal: AbortSignal.timeout(timeoutMs), }); payload = await response.json().catch(() => null) as OneOsMileageRangeResponse | null; + if ( + includeProtocolPriority && + response.status === 400 && + payload?.code === 'INVALID_REQUEST' + ) { + markProtocolPriorityUnsupported(); + continue; + } if (!response.ok || payload?.code !== 'SUCCESS') { const trace = payload?.traceId ? `, traceId=${payload.traceId}` : ''; const error = new Error( @@ -212,6 +259,7 @@ async function requestRange( if (response.status < 500 || attempt === 2) throw error; lastError = error; } else { + if (includeProtocolPriority) protocolPrioritySupported = true; break; } } catch (error) { @@ -252,26 +300,29 @@ function trimCache(): void { export async function fetchOneOsDailyMileage( date: string, plateNumbers?: string[], + protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY, ): Promise { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`); - const hit = cache.get(date); + const policyKey = protocolPriority.join('>'); + const cacheKey = `${date}:${policyKey}`; + const hit = cache.get(cacheKey); let rows: OneOsDailyMileage[]; if (hit && hit.expiresAt > Date.now()) { rows = hit.rows; } else { - let pending = inflight.get(date); + let pending = inflight.get(cacheKey); if (!pending) { - pending = requestDate(date).then(result => { + pending = requestDate(date, protocolPriority).then(result => { const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS; - cache.delete(date); + cache.delete(cacheKey); if (ttl > 0) { - cache.set(date, { rows: result, expiresAt: Date.now() + ttl }); + cache.set(cacheKey, { rows: result, expiresAt: Date.now() + ttl }); trimCache(); } return result; - }).finally(() => inflight.delete(date)); - inflight.set(date, pending); + }).finally(() => inflight.delete(cacheKey)); + inflight.set(cacheKey, pending); } rows = await pending; } @@ -284,6 +335,7 @@ export async function fetchOneOsDailyMileage( export async function fetchOneOsMileageDates( dates: string[], plateNumbers?: string[], + protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY, ): Promise> { const result = new Map(); const uniqueDates = Array.from(new Set(dates)).sort(); @@ -312,10 +364,20 @@ export async function fetchOneOsMileageDates( await Promise.all(groups.map(async group => { const startDate = group[0]; const endDate = group[group.length - 1]; - const key = `${startDate}:${endDate}:${selectedPlates.length > 0 ? selectedPlates.join('\u0000') : '*'}`; + const key = [ + startDate, + endDate, + selectedPlates.length > 0 ? selectedPlates.join('\u0000') : '*', + protocolPriority.join('>'), + ].join(':'); let pending = rangeInflight.get(key); if (!pending) { - pending = requestRange(startDate, endDate, selectedPlates).finally(() => rangeInflight.delete(key)); + pending = requestRange( + startDate, + endDate, + selectedPlates, + protocolPriority, + ).finally(() => rangeInflight.delete(key)); rangeInflight.set(key, pending); } const rowsByDate = await pending; diff --git a/src/server/routes/mileage/source-policy.ts b/src/server/routes/mileage/source-policy.ts new file mode 100644 index 0000000..59318cb --- /dev/null +++ b/src/server/routes/mileage/source-policy.ts @@ -0,0 +1,38 @@ +export type MileageSourceGroup = 'instrument' | 'gps'; +export type OneOsProtocol = 'GB32960' | 'MQTT' | 'JT808'; +export type MileageSourceCategory = 'INSTRUMENT' | 'GPS'; + +export const DEFAULT_MILEAGE_SOURCE_PRIORITY: MileageSourceGroup[] = ['instrument', 'gps']; +export const DEFAULT_ONEOS_PROTOCOL_PRIORITY: OneOsProtocol[] = ['GB32960', 'MQTT', 'JT808']; + +export function parseMileageSourcePriority(value?: string): MileageSourceGroup[] { + if (!value) return [...DEFAULT_MILEAGE_SOURCE_PRIORITY]; + const groups = value.split(',') + .map(item => item.trim().toLowerCase()) + .filter((item): item is MileageSourceGroup => item === 'instrument' || item === 'gps'); + const unique = Array.from(new Set(groups)); + return unique.length > 0 ? unique : [...DEFAULT_MILEAGE_SOURCE_PRIORITY]; +} + +export function protocolsForSourcePriority(groups: MileageSourceGroup[]): OneOsProtocol[] { + return groups.flatMap(group => group === 'instrument' + ? ['GB32960', 'MQTT'] as OneOsProtocol[] + : ['JT808'] as OneOsProtocol[]); +} + +export function normalizeOneOsProtocol(value: unknown): OneOsProtocol | null { + if (typeof value !== 'string') return null; + const normalized = value.trim().toUpperCase().replace(/[^A-Z0-9]/g, ''); + if (normalized === 'GB32960' || normalized === '32960') return 'GB32960'; + if (normalized === 'MQTT') return 'MQTT'; + if (normalized === 'JT808' || normalized === '808') return 'JT808'; + return null; +} + +export function sourceCategoryFromProtocol( + protocol: OneOsProtocol | null, +): MileageSourceCategory | null { + if (protocol === 'GB32960' || protocol === 'MQTT') return 'INSTRUMENT'; + if (protocol === 'JT808') return 'GPS'; + return null; +} diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts index 87daa65..028a3d4 100644 --- a/src/server/routes/mileage/types.ts +++ b/src/server/routes/mileage/types.ts @@ -6,6 +6,9 @@ export interface CachedVehicle { dailyMileage?: Record; totalKm: number | null; source: string; + sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null; + sourceCategory: 'INSTRUMENT' | 'GPS' | 'MIXED' | null; + dailySourceProtocols?: Record; dataTime: string | null; calculatedAt: string | null; updatedAt: string | null; @@ -72,6 +75,7 @@ export interface MonitoringResponse { page: number; totalPages: number; updatedAt: string; + sourcePriority: ('instrument' | 'gps')[]; } /** 车辆关联信息(从 lingniu_prod 查出的原始行) */ diff --git a/src/server/routes/mileage/vehicle-recent.ts b/src/server/routes/mileage/vehicle-recent.ts index 9f8a8f4..623867a 100644 --- a/src/server/routes/mileage/vehicle-recent.ts +++ b/src/server/routes/mileage/vehicle-recent.ts @@ -1,5 +1,10 @@ import { Hono } from 'hono'; import { fetchOneOsMileageDates } from './oneos-api.js'; +import { + parseMileageSourcePriority, + protocolsForSourcePriority, + sourceCategoryFromProtocol, +} from './source-policy.js'; const app = new Hono(); @@ -23,6 +28,8 @@ const MAX_DAYS = 366; app.get('/:plate/recent', async (c) => { const plate = c.req.param('plate'); if (!plate) return c.json({ plate: '', days: [] }, 400); + const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority')); + const protocolPriority = protocolsForSourcePriority(sourcePriority); const today = new Date(); today.setHours(0, 0, 0, 0); @@ -58,10 +65,16 @@ app.get('/:plate/recent', async (c) => { dates.push(fmt(dateCursor)); dateCursor.setDate(dateCursor.getDate() + 1); } - const rowsByDate = await fetchOneOsMileageDates(dates, [plate]); + const rowsByDate = await fetchOneOsMileageDates(dates, [plate], protocolPriority); // 补全:从 start 到 end 每天一条 - const result: { date: string; dailyKm: number; isDataSynced: boolean }[] = []; + const result: { + date: string; + dailyKm: number; + isDataSynced: boolean; + sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null; + sourceCategory: 'INSTRUMENT' | 'GPS' | null; + }[] = []; const cursor = new Date(start); while (cursor <= end) { const key = fmt(cursor); @@ -70,11 +83,19 @@ app.get('/:plate/recent', async (c) => { date: key, dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0, isDataSynced: hit?.status === 'NORMAL', + sourceProtocol: hit?.sourceProtocol || null, + sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null), }); cursor.setDate(cursor.getDate() + 1); } - return c.json({ plate, start: fmt(start), end: fmt(end), days: result }); + return c.json({ + plate, + start: fmt(start), + end: fmt(end), + sourcePriority, + days: result, + }); } catch (e: unknown) { console.error('vehicle recent error:', e); return c.json({ plate, days: [] }, 500);