feat(mileage): add OneOS source priority controls
This commit is contained in:
@@ -22,6 +22,7 @@ POST /api/v1/vehicles/mileage/range/query
|
|||||||
"startDate": "2026-07-01",
|
"startDate": "2026-07-01",
|
||||||
"endDate": "2026-07-23",
|
"endDate": "2026-07-23",
|
||||||
"plateNumbers": ["沪A00001", "沪A00002"],
|
"plateNumbers": ["沪A00001", "沪A00002"],
|
||||||
|
"protocolPriority": ["GB32960", "MQTT", "JT808"],
|
||||||
"cursor": null,
|
"cursor": null,
|
||||||
"pageSize": 5000
|
"pageSize": 5000
|
||||||
}
|
}
|
||||||
@@ -31,6 +32,14 @@ POST /api/v1/vehicles/mileage/range/query
|
|||||||
|
|
||||||
- `startDate`、`endDate` 必填,最长支持 366 天;
|
- `startDate`、`endDate` 必填,最长支持 366 天;
|
||||||
- `plateNumbers` 可省略,省略时返回授权范围内全部车辆;
|
- `plateNumbers` 可省略,省略时返回授权范围内全部车辆;
|
||||||
|
- `protocolPriority` 可省略;提供时只允许 `GB32960`、`MQTT`、`JT808`,
|
||||||
|
数组顺序表示逐车逐日的数据源优先级,未列出的协议视为禁用;
|
||||||
|
- BI 只会使用以下四种组合:
|
||||||
|
- 仪表数据优先:`["GB32960", "MQTT", "JT808"]`;
|
||||||
|
- GPS 数据优先:`["JT808", "GB32960", "MQTT"]`;
|
||||||
|
- 仅仪表数据:`["GB32960", "MQTT"]`;
|
||||||
|
- 仅 GPS 数据:`["JT808"]`;
|
||||||
|
- 仪表数据内部优先级固定为 `GB32960 > MQTT`,不提供反向切换;
|
||||||
- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`;
|
- 数据量过大时使用 `cursor` 分页,同一次查询使用相同 `snapshotId`;
|
||||||
- 每辆车每天返回一条记录;
|
- 每辆车每天返回一条记录;
|
||||||
- 无数据返回 `status: "NO_DATA"` 且 `dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"` 且 `dailyMileageKm: 0`。
|
- 无数据返回 `status: "NO_DATA"` 且 `dailyMileageKm: null`,真实零里程返回 `status: "NORMAL"` 且 `dailyMileageKm: 0`。
|
||||||
@@ -47,6 +56,7 @@ POST /api/v1/vehicles/mileage/range/query
|
|||||||
"plateNumber": "沪A00001",
|
"plateNumber": "沪A00001",
|
||||||
"date": "2026-07-01",
|
"date": "2026-07-01",
|
||||||
"dailyMileageKm": 182.437,
|
"dailyMileageKm": 182.437,
|
||||||
|
"sourceProtocol": "GB32960",
|
||||||
"dataTime": "2026-07-01T23:58:45+08:00",
|
"dataTime": "2026-07-01T23:58:45+08:00",
|
||||||
"updatedAt": "2026-07-02T05:10:00+08:00",
|
"updatedAt": "2026-07-02T05:10:00+08:00",
|
||||||
"status": "NORMAL"
|
"status": "NORMAL"
|
||||||
@@ -71,13 +81,15 @@ POST /api/v1/vehicles/mileage/query
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"date": "2026-07-23",
|
"date": "2026-07-23",
|
||||||
"plateNumbers": ["沪A00001", "沪A00002"]
|
"plateNumbers": ["沪A00001", "沪A00002"],
|
||||||
|
"protocolPriority": ["GB32960", "MQTT", "JT808"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
|
|
||||||
- `date` 和 `plateNumbers` 沿用现有接口规则;
|
- `date` 和 `plateNumbers` 沿用现有接口规则;
|
||||||
|
- `protocolPriority` 的枚举、顺序和禁用规则与区间接口完全一致;
|
||||||
- `plateNumbers` 省略时返回授权范围内全部车辆;
|
- `plateNumbers` 省略时返回授权范围内全部车辆;
|
||||||
- 接口默认在现有车辆结果中返回 `totalMileageKm`、`dataTime` 和 `updatedAt`;
|
- 接口默认在现有车辆结果中返回 `totalMileageKm`、`dataTime` 和 `updatedAt`;
|
||||||
- 只增加响应字段,不删除或修改任何现有字段及其语义;
|
- 只增加响应字段,不删除或修改任何现有字段及其语义;
|
||||||
@@ -100,6 +112,7 @@ POST /api/v1/vehicles/mileage/query
|
|||||||
"date": "2026-07-23",
|
"date": "2026-07-23",
|
||||||
"dailyMileageKm": 182.437,
|
"dailyMileageKm": 182.437,
|
||||||
"totalMileageKm": 12345.678,
|
"totalMileageKm": 12345.678,
|
||||||
|
"sourceProtocol": "GB32960",
|
||||||
"dataTime": "2026-07-23T10:35:42+08:00",
|
"dataTime": "2026-07-23T10:35:42+08:00",
|
||||||
"updatedAt": "2026-07-23T10:35:46+08:00",
|
"updatedAt": "2026-07-23T10:35:46+08:00",
|
||||||
"status": "NORMAL"
|
"status": "NORMAL"
|
||||||
@@ -114,6 +127,11 @@ POST /api/v1/vehicles/mileage/query
|
|||||||
## 通用要求
|
## 通用要求
|
||||||
|
|
||||||
- `dataTime`、`updatedAt` 使用带 `+08:00` 的 ISO 8601 格式;
|
- `dataTime`、`updatedAt` 使用带 `+08:00` 的 ISO 8601 格式;
|
||||||
|
- `sourceProtocol` 必须返回本条结果实际采用的协议:
|
||||||
|
`GB32960`、`MQTT` 或 `JT808`;无数据时返回 `null`;
|
||||||
|
- OneOS 必须按 `protocolPriority` 选择第一份有效数据,不得返回已禁用协议的数据;
|
||||||
|
- `sourceProtocol=GB32960/MQTT` 在 BI 显示为“仪表数据”,
|
||||||
|
`sourceProtocol=JT808` 显示为“GPS数据”;
|
||||||
- 每个响应返回 `traceId`;
|
- 每个响应返回 `traceId`;
|
||||||
- 接口返回值保留原始精度;
|
- 接口返回值保留原始精度;
|
||||||
- 负里程不得作为正常数据返回;
|
- 负里程不得作为正常数据返回;
|
||||||
@@ -127,3 +145,6 @@ POST /api/v1/vehicles/mileage/query
|
|||||||
3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。
|
3. 现有接口不传车牌时,默认返回全部授权车辆的日里程、累计里程和数据时间。
|
||||||
4. 正确区分无数据与真实零里程。
|
4. 正确区分无数据与真实零里程。
|
||||||
5. `dataTime` 能反映每辆车实际数据的新鲜度。
|
5. `dataTime` 能反映每辆车实际数据的新鲜度。
|
||||||
|
6. 四种 `protocolPriority` 组合均能按顺序选源,且响应中的
|
||||||
|
`sourceProtocol` 与实际采用协议一致。
|
||||||
|
7. 禁用某类协议后,响应不得回退到该协议。
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import {
|
|||||||
Truck, Filter, ChevronDown,
|
Truck, Filter, ChevronDown,
|
||||||
Maximize2, Minimize2, RotateCcw,
|
Maximize2, Minimize2, RotateCcw,
|
||||||
ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays,
|
ArrowUp, ArrowDown, ChevronsUp, Download, Check, CalendarDays,
|
||||||
RefreshCw,
|
RefreshCw, ArrowLeftRight, Power,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { BarChart, Bar, ResponsiveContainer, Tooltip, ReferenceLine, XAxis } from 'recharts';
|
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 { fetchMonitoring } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
import PlateMultiSelect from './PlateMultiSelect';
|
import PlateMultiSelect from './PlateMultiSelect';
|
||||||
@@ -19,6 +24,116 @@ const HIGH_MILEAGE_ALERT_TARGETS = new Set([
|
|||||||
'交投190辆4.5T冷链车',
|
'交投190辆4.5T冷链车',
|
||||||
]);
|
]);
|
||||||
const HIGH_MILEAGE_ALERT_KM = 800;
|
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 (
|
||||||
|
<div className="flex items-center gap-1.5 overflow-x-auto no-scrollbar">
|
||||||
|
<span className="shrink-0 text-[9px] font-black text-slate-400">数据来源</span>
|
||||||
|
{ordered.map(group => {
|
||||||
|
const enabledIndex = priority.indexOf(group);
|
||||||
|
const enabled = enabledIndex >= 0;
|
||||||
|
const meta = MILEAGE_SOURCE_META[group];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={group}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(group)}
|
||||||
|
className={`flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-1 transition-all ${
|
||||||
|
enabled
|
||||||
|
? 'border-blue-100 bg-blue-50 text-blue-700'
|
||||||
|
: 'border-slate-100 bg-slate-50 text-slate-400'
|
||||||
|
}`}
|
||||||
|
title={enabled && priority.length === 1 ? '至少保留一种数据来源' : enabled ? '点击停用' : '点击启用'}
|
||||||
|
>
|
||||||
|
<span className={`flex h-4 w-4 items-center justify-center rounded text-[8px] font-black ${
|
||||||
|
enabled ? 'bg-blue-600 text-white' : 'bg-slate-200 text-slate-500'
|
||||||
|
}`}>
|
||||||
|
{enabled ? enabledIndex + 1 : <Power size={9} />}
|
||||||
|
</span>
|
||||||
|
<span className="text-left leading-tight">
|
||||||
|
<span className="block text-[9px] font-black">{meta.label}</span>
|
||||||
|
<span className="block text-[7px] font-bold opacity-60">{enabled ? meta.protocol : '已停用'}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={swap}
|
||||||
|
disabled={priority.length !== 2}
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-slate-100 bg-white text-slate-400 transition-all hover:border-blue-100 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-30"
|
||||||
|
title="切换数据来源优先级"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function defaultMileageDate(): string {
|
function defaultMileageDate(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -354,6 +469,7 @@ export default function MonitoringView() {
|
|||||||
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
const [detailVehicle, setDetailVehicle] = useState<MonitoringVehicle | null>(null);
|
||||||
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
const [rangeStart, setRangeStart] = useState(defaultMileageDate);
|
||||||
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
|
const [rangeEnd, setRangeEnd] = useState(defaultMileageDate);
|
||||||
|
const [sourcePriority, setSourcePriority] = useState<MileageSourceGroup[]>(['instrument', 'gps']);
|
||||||
|
|
||||||
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
const [vehicles, setVehicles] = useState<MonitoringVehicle[]>([]);
|
||||||
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
const [stats, setStats] = useState<MonitoringStats>({ totalToday: 0, totalAll: 0, vehicleCount: 0, yesterdayTotal: 0 });
|
||||||
@@ -418,6 +534,7 @@ export default function MonitoringView() {
|
|||||||
startDate: rangeStart || undefined,
|
startDate: rangeStart || undefined,
|
||||||
endDate: rangeEnd || undefined,
|
endDate: rangeEnd || undefined,
|
||||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||||
|
sourcePriority,
|
||||||
}).then(d => {
|
}).then(d => {
|
||||||
setVehicles(d.vehicles);
|
setVehicles(d.vehicles);
|
||||||
setStats(d.stats);
|
setStats(d.stats);
|
||||||
@@ -431,7 +548,7 @@ export default function MonitoringView() {
|
|||||||
}).catch(() => {}).finally(() => {
|
}).catch(() => {}).finally(() => {
|
||||||
if (showPageLoading) setPageLoading(false);
|
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 () => {
|
const handleManualRefresh = useCallback(async () => {
|
||||||
if (manualRefreshing) return;
|
if (manualRefreshing) return;
|
||||||
@@ -468,12 +585,13 @@ export default function MonitoringView() {
|
|||||||
startDate: rangeStart || undefined,
|
startDate: rangeStart || undefined,
|
||||||
endDate: rangeEnd || undefined,
|
endDate: rangeEnd || undefined,
|
||||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||||
|
sourcePriority,
|
||||||
}).then(d => {
|
}).then(d => {
|
||||||
setVehicles(prev => [...prev, ...d.vehicles]);
|
setVehicles(prev => [...prev, ...d.vehicles]);
|
||||||
setPage(nextPage);
|
setPage(nextPage);
|
||||||
setHasMore(nextPage < d.totalPages);
|
setHasMore(nextPage < d.totalPages);
|
||||||
}).catch(() => {}).finally(() => setLoadingMore(false));
|
}).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(() => {
|
useEffect(() => {
|
||||||
@@ -513,6 +631,7 @@ export default function MonitoringView() {
|
|||||||
startDate: rangeStart || undefined,
|
startDate: rangeStart || undefined,
|
||||||
endDate: rangeEnd || undefined,
|
endDate: rangeEnd || undefined,
|
||||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||||
|
sourcePriority,
|
||||||
});
|
});
|
||||||
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
|
exportMileageXlsx(d.vehicles, { startDate: d.dateRange?.start || rangeStart, endDate: d.dateRange?.end || rangeEnd, sortBy });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -520,7 +639,7 @@ export default function MonitoringView() {
|
|||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -592,12 +711,13 @@ export default function MonitoringView() {
|
|||||||
startDate: rangeStart || undefined,
|
startDate: rangeStart || undefined,
|
||||||
endDate: rangeEnd || undefined,
|
endDate: rangeEnd || undefined,
|
||||||
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
brands: filterBrands.length > 0 ? filterBrands : undefined,
|
||||||
|
sourcePriority,
|
||||||
}).then(d => {
|
}).then(d => {
|
||||||
setFullscreenVehicles(d.vehicles);
|
setFullscreenVehicles(d.vehicles);
|
||||||
setFullscreenStats(d.stats);
|
setFullscreenStats(d.stats);
|
||||||
setFilterOptions(d.filters);
|
setFilterOptions(d.filters);
|
||||||
}).catch(() => {}).finally(() => setFullscreenLoading(false));
|
}).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(() => {
|
useEffect(() => {
|
||||||
@@ -665,6 +785,12 @@ export default function MonitoringView() {
|
|||||||
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span>
|
<span className="text-slate-500">车辆 <span className="text-white font-black">{fullscreenStats.vehicleCount}</span> 台</span>
|
||||||
<span className="text-slate-700">|</span>
|
<span className="text-slate-700">|</span>
|
||||||
<span className="text-slate-500">均 <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'today' ? fullscreenStats.totalToday : fullscreenStats.totalAll) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
|
<span className="text-slate-500">均 <span className="text-white font-black">{(fullscreenStats.vehicleCount > 0 ? (sortBy === 'today' ? fullscreenStats.totalToday : fullscreenStats.totalAll) / fullscreenStats.vehicleCount : 0).toFixed(0)}</span> <span className="text-blue-400">km</span></span>
|
||||||
|
<span className="text-slate-700">|</span>
|
||||||
|
<span className="text-slate-500">
|
||||||
|
来源 <span className="text-white font-black">
|
||||||
|
{sourcePriority.map(group => MILEAGE_SOURCE_META[group].label).join(' > ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -845,6 +971,7 @@ export default function MonitoringView() {
|
|||||||
{fullscreenVehicles.map((v) => {
|
{fullscreenVehicles.map((v) => {
|
||||||
const highMileageAlert = isHighMileageAlert(v);
|
const highMileageAlert = isHighMileageAlert(v);
|
||||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||||
|
const sourceDisplay = vehicleSourceDisplay(v);
|
||||||
return (
|
return (
|
||||||
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
|
<tr key={v.plate} className="hover:bg-slate-800/20 transition-colors">
|
||||||
<td className="px-3 py-2 text-center">
|
<td className="px-3 py-2 text-center">
|
||||||
@@ -855,6 +982,12 @@ export default function MonitoringView() {
|
|||||||
<div className={`mt-0.5 text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
<div className={`mt-0.5 text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
||||||
{statisticTime.label}
|
{statisticTime.label}
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 inline-flex rounded px-1 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
||||||
|
title={sourceDisplay.title}
|
||||||
|
>
|
||||||
|
{sourceDisplay.label}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
|
<td className="px-3 py-2 text-[11px] text-slate-400"><Blur>{v.customer || '-'}</Blur></td>
|
||||||
<td className="px-3 py-2 text-[11px] text-slate-400">{v.brand || '-'}</td>
|
<td className="px-3 py-2 text-[11px] text-slate-400">{v.brand || '-'}</td>
|
||||||
@@ -926,6 +1059,8 @@ export default function MonitoringView() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SourcePriorityControl priority={sourcePriority} onChange={setSourcePriority} />
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="grid flex-1 grid-cols-3 gap-1.5">
|
<div className="grid flex-1 grid-cols-3 gap-1.5">
|
||||||
<BatchMultiSelect options={filterOptions.targetNames} selected={filterTargetNames} onChange={setFilterTargetNames} placeholder="批次型号" />
|
<BatchMultiSelect options={filterOptions.targetNames} selected={filterTargetNames} onChange={setFilterTargetNames} placeholder="批次型号" />
|
||||||
@@ -1343,6 +1478,7 @@ export default function MonitoringView() {
|
|||||||
{filteredVehicles.map((v) => {
|
{filteredVehicles.map((v) => {
|
||||||
const highMileageAlert = isHighMileageAlert(v);
|
const highMileageAlert = isHighMileageAlert(v);
|
||||||
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
const statisticTime = vehicleStatisticTime(v, relativeNow);
|
||||||
|
const sourceDisplay = vehicleSourceDisplay(v);
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -1368,6 +1504,12 @@ export default function MonitoringView() {
|
|||||||
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
<div className={`text-[8px] font-bold ${statisticTime.color}`} title={statisticTime.title}>
|
||||||
{statisticTime.label}
|
{statisticTime.label}
|
||||||
</div>
|
</div>
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 inline-flex rounded px-1 py-0.5 text-[7px] font-black ${sourceDisplay.className}`}
|
||||||
|
title={sourceDisplay.title}
|
||||||
|
>
|
||||||
|
{sourceDisplay.label}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-1.5 md:hidden">
|
<div className="flex items-center gap-1.5 md:hidden">
|
||||||
<span className="text-[8px] text-slate-300 font-bold">{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
|
<span className="text-[8px] text-slate-300 font-bold">{v.rentStatus || ''}{v.department ? ` · ${v.department.replace('业务', '')}` : ''}</span>
|
||||||
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></span>
|
<span className="text-[9px] font-bold text-slate-600 truncate"><Blur>{v.customer || '-'}</Blur></span>
|
||||||
@@ -1435,7 +1577,11 @@ export default function MonitoringView() {
|
|||||||
<div ref={sentinelRef} className="h-1" />
|
<div ref={sentinelRef} className="h-1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VehicleDetailModal vehicle={detailVehicle} onClose={() => setDetailVehicle(null)} />
|
<VehicleDetailModal
|
||||||
|
vehicle={detailVehicle}
|
||||||
|
onClose={() => setDetailVehicle(null)}
|
||||||
|
sourcePriority={sourcePriority}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 回到顶部按钮 */}
|
{/* 回到顶部按钮 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { X, Truck } from 'lucide-react';
|
|||||||
import {
|
import {
|
||||||
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Tooltip, Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import type { MonitoringVehicle } from './types';
|
import type { MileageSourceGroup, MonitoringVehicle } from './types';
|
||||||
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
import { fetchVehicleRecent, type VehicleRecentDay } from './api';
|
||||||
import Blur from '../../components/Blur';
|
import Blur from '../../components/Blur';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vehicle: MonitoringVehicle | null;
|
vehicle: MonitoringVehicle | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
sourcePriority: MileageSourceGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type RangeKey = 'last15' | 'month' | 'quarter';
|
type RangeKey = 'last15' | 'month' | 'quarter';
|
||||||
@@ -56,7 +57,13 @@ function formatLabel(date: string, key: RangeKey): string {
|
|||||||
return date.slice(5);
|
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<VehicleRecentDay[]>([]);
|
const [days, setDays] = useState<VehicleRecentDay[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [range, setRange] = useState<RangeKey>('last15');
|
const [range, setRange] = useState<RangeKey>('last15');
|
||||||
@@ -74,12 +81,12 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setDays([]);
|
setDays([]);
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchVehicleRecent(vehicle.plate, { start, end })
|
fetchVehicleRecent(vehicle.plate, { start, end, sourcePriority })
|
||||||
.then(d => { if (!cancelled) setDays(d.days); })
|
.then(d => { if (!cancelled) setDays(d.days); })
|
||||||
.catch(() => { if (!cancelled) setDays([]); })
|
.catch(() => { if (!cancelled) setDays([]); })
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
return () => { cancelled = true; };
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -280,7 +287,21 @@ export default function VehicleDetailModal({ vehicle, onClose }: Props) {
|
|||||||
transition={{ delay: Math.min(i * 0.012, 0.4), duration: 0.18 }}
|
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"
|
className="flex items-center justify-between py-1.5 px-2 rounded-lg hover:bg-slate-50"
|
||||||
>
|
>
|
||||||
<span className="text-[11px] font-mono font-bold text-slate-600">{d.date}</span>
|
<div className="w-[88px] flex-shrink-0">
|
||||||
|
<div className="text-[11px] font-mono font-bold text-slate-600">{d.date}</div>
|
||||||
|
<div
|
||||||
|
className={`text-[8px] font-bold ${
|
||||||
|
d.sourceCategory === 'INSTRUMENT'
|
||||||
|
? 'text-violet-500'
|
||||||
|
: d.sourceCategory === 'GPS'
|
||||||
|
? 'text-emerald-500'
|
||||||
|
: 'text-amber-500'
|
||||||
|
}`}
|
||||||
|
title={d.sourceProtocol || 'OneOS 当前未返回 sourceProtocol'}
|
||||||
|
>
|
||||||
|
{daySourceLabel(d)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-1 ml-3">
|
<div className="flex items-center gap-2 flex-1 ml-3">
|
||||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
import type { MonitoringData, TargetSummary, TargetVehicle, TrendPoint } from './types';
|
import type {
|
||||||
|
MileageSourceCategory,
|
||||||
|
MileageSourceGroup,
|
||||||
|
MileageSourceProtocol,
|
||||||
|
MonitoringData,
|
||||||
|
TargetSummary,
|
||||||
|
TargetVehicle,
|
||||||
|
TrendPoint,
|
||||||
|
} from './types';
|
||||||
import { fetchJson } from '../../auth/api-client';
|
import { fetchJson } from '../../auth/api-client';
|
||||||
|
|
||||||
const BASE = '/api/mileage';
|
const BASE = '/api/mileage';
|
||||||
@@ -25,6 +33,7 @@ export async function fetchMonitoring(params?: {
|
|||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
brands?: string[];
|
brands?: string[];
|
||||||
|
sourcePriority?: MileageSourceGroup[];
|
||||||
}): Promise<MonitoringData> {
|
}): Promise<MonitoringData> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.sortBy) query.set('sortBy', params.sortBy);
|
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?.date) query.set('date', params.date);
|
||||||
if (params?.startDate) query.set('startDate', params.startDate);
|
if (params?.startDate) query.set('startDate', params.startDate);
|
||||||
if (params?.endDate) query.set('endDate', params.endDate);
|
if (params?.endDate) query.set('endDate', params.endDate);
|
||||||
|
if (params?.sourcePriority?.length) query.set('sourcePriority', params.sourcePriority.join(','));
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
|
return fetchJson<MonitoringData>(`${BASE}/monitoring${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
@@ -82,6 +92,8 @@ export interface VehicleRecentDay {
|
|||||||
date: string;
|
date: string;
|
||||||
dailyKm: number;
|
dailyKm: number;
|
||||||
isDataSynced: boolean;
|
isDataSynced: boolean;
|
||||||
|
sourceProtocol: MileageSourceProtocol | null;
|
||||||
|
sourceCategory: Exclude<MileageSourceCategory, 'MIXED'> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VehicleRecentResponse {
|
export interface VehicleRecentResponse {
|
||||||
@@ -89,16 +101,23 @@ export interface VehicleRecentResponse {
|
|||||||
start?: string;
|
start?: string;
|
||||||
end?: string;
|
end?: string;
|
||||||
days: VehicleRecentDay[];
|
days: VehicleRecentDay[];
|
||||||
|
sourcePriority?: MileageSourceGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVehicleRecent(
|
export async function fetchVehicleRecent(
|
||||||
plate: string,
|
plate: string,
|
||||||
range: { days?: number; start?: string; end?: string } = { days: 15 },
|
range: {
|
||||||
|
days?: number;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
sourcePriority?: MileageSourceGroup[];
|
||||||
|
} = { days: 15 },
|
||||||
): Promise<VehicleRecentResponse> {
|
): Promise<VehicleRecentResponse> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (range.start) params.set('start', range.start);
|
if (range.start) params.set('start', range.start);
|
||||||
if (range.end) params.set('end', range.end);
|
if (range.end) params.set('end', range.end);
|
||||||
if (range.days != null) params.set('days', String(range.days));
|
if (range.days != null) params.set('days', String(range.days));
|
||||||
|
if (range.sourcePriority?.length) params.set('sourcePriority', range.sourcePriority.join(','));
|
||||||
return fetchJson<VehicleRecentResponse>(
|
return fetchJson<VehicleRecentResponse>(
|
||||||
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
`${BASE}/vehicle/${encodeURIComponent(plate)}/recent?${params.toString()}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
export type MileageSourceGroup = 'instrument' | 'gps';
|
||||||
|
export type MileageSourceCategory = 'INSTRUMENT' | 'GPS' | 'MIXED';
|
||||||
|
export type MileageSourceProtocol = 'GB32960' | 'MQTT' | 'JT808';
|
||||||
|
|
||||||
export interface MonitoringVehicle {
|
export interface MonitoringVehicle {
|
||||||
plate: string;
|
plate: string;
|
||||||
vin: string;
|
vin: string;
|
||||||
@@ -5,6 +9,9 @@ export interface MonitoringVehicle {
|
|||||||
dailyMileage?: Record<string, number>;
|
dailyMileage?: Record<string, number>;
|
||||||
totalKm: number | null;
|
totalKm: number | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
sourceProtocol: MileageSourceProtocol | null;
|
||||||
|
sourceCategory: MileageSourceCategory | null;
|
||||||
|
dailySourceProtocols?: Record<string, MileageSourceProtocol | null>;
|
||||||
dataTime: string | null;
|
dataTime: string | null;
|
||||||
calculatedAt: string | null;
|
calculatedAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
@@ -51,6 +58,7 @@ export interface MonitoringData {
|
|||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
sourcePriority: MileageSourceGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TargetSummary {
|
export interface TargetSummary {
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ interface ExportContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const BASE_HEADERS = [
|
const BASE_HEADERS = [
|
||||||
'状态', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
'状态', '数据来源', '车牌号', '客户', '业务部门', '项目', '租赁状态',
|
||||||
'运营区域',
|
'运营区域',
|
||||||
] as const;
|
] 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 {
|
function statusLabel(v: MonitoringVehicle): string {
|
||||||
if (!v.isDataSynced) return '未对接';
|
if (!v.isDataSynced) return '未对接';
|
||||||
return v.isOnline ? '在线' : '离线';
|
return v.isOnline ? '在线' : '离线';
|
||||||
@@ -49,6 +56,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
...vehicles.map(v => {
|
...vehicles.map(v => {
|
||||||
const baseRow = [
|
const baseRow = [
|
||||||
statusLabel(v),
|
statusLabel(v),
|
||||||
|
sourceLabel(v),
|
||||||
v.plate,
|
v.plate,
|
||||||
v.customer || '',
|
v.customer || '',
|
||||||
v.department || '',
|
v.department || '',
|
||||||
@@ -71,6 +79,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
const numFixedCols = BASE_HEADERS.length;
|
const numFixedCols = BASE_HEADERS.length;
|
||||||
const wsCols: { wch: number }[] = [
|
const wsCols: { wch: number }[] = [
|
||||||
{ wch: 8 }, // 状态
|
{ wch: 8 }, // 状态
|
||||||
|
{ wch: 16 }, // 数据来源
|
||||||
{ wch: 12 }, // 车牌号
|
{ wch: 12 }, // 车牌号
|
||||||
{ wch: 28 }, // 客户
|
{ wch: 28 }, // 客户
|
||||||
{ wch: 14 }, // 业务部门
|
{ wch: 14 }, // 业务部门
|
||||||
@@ -111,7 +120,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
// 每日明细 sheet:保留原有格式
|
// 每日明细 sheet:保留原有格式
|
||||||
if (dayKeys.length > 0) {
|
if (dayKeys.length > 0) {
|
||||||
const detailHeaders = [
|
const detailHeaders = [
|
||||||
'车牌号', '客户', '业务部门', '项目', '租赁状态', '运营区域',
|
'车牌号', '数据来源', '客户', '业务部门', '项目', '租赁状态', '运营区域',
|
||||||
...dayKeys.map(day => `${day}里程(km)`),
|
...dayKeys.map(day => `${day}里程(km)`),
|
||||||
'区间合计(km)',
|
'区间合计(km)',
|
||||||
'累计里程(km)',
|
'累计里程(km)',
|
||||||
@@ -120,6 +129,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
detailHeaders,
|
detailHeaders,
|
||||||
...vehicles.map(v => [
|
...vehicles.map(v => [
|
||||||
v.plate,
|
v.plate,
|
||||||
|
sourceLabel(v),
|
||||||
v.customer || '',
|
v.customer || '',
|
||||||
v.department || '',
|
v.department || '',
|
||||||
v.project || '',
|
v.project || '',
|
||||||
@@ -133,6 +143,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
const detailWs = XLSX.utils.aoa_to_sheet(detailData);
|
const detailWs = XLSX.utils.aoa_to_sheet(detailData);
|
||||||
detailWs['!cols'] = [
|
detailWs['!cols'] = [
|
||||||
{ wch: 12 },
|
{ wch: 12 },
|
||||||
|
{ wch: 16 },
|
||||||
{ wch: 28 },
|
{ wch: 28 },
|
||||||
{ wch: 14 },
|
{ wch: 14 },
|
||||||
{ wch: 16 },
|
{ wch: 16 },
|
||||||
@@ -142,9 +153,9 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
|
|||||||
{ wch: 14 },
|
{ wch: 14 },
|
||||||
{ 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 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 });
|
const ref = XLSX.utils.encode_cell({ r, c });
|
||||||
if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########';
|
if (detailWs[ref]?.t === 'n') detailWs[ref].z = '0.##########';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { dirname, join } from 'node:path';
|
|||||||
import pool from '../../db.js';
|
import pool from '../../db.js';
|
||||||
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
import { fetchVehicleInfoMap } from './vehicle-info.js';
|
||||||
import { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.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';
|
import type { CachedVehicle, MonitoringCache, MonitoringFilters, PlatePrefix, VehicleInfoRow } from './types.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -64,6 +69,7 @@ interface MileageRow {
|
|||||||
daily_km: string;
|
daily_km: string;
|
||||||
total_km: string | null;
|
total_km: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
source_protocol: OneOsProtocol | null;
|
||||||
data_time: string | null;
|
data_time: string | null;
|
||||||
calculated_at: string | null;
|
calculated_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
@@ -75,6 +81,7 @@ interface DailyMileageRow {
|
|||||||
date: string;
|
date: string;
|
||||||
daily_km: string | number | null;
|
daily_km: string | number | null;
|
||||||
source: string | null;
|
source: string | null;
|
||||||
|
source_protocol: OneOsProtocol | null;
|
||||||
data_time: string | null;
|
data_time: string | null;
|
||||||
calculated_at: string | null;
|
calculated_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
@@ -87,6 +94,7 @@ function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] {
|
|||||||
daily_km: String(row.dailyMileageKm ?? 0),
|
daily_km: String(row.dailyMileageKm ?? 0),
|
||||||
total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm),
|
total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm),
|
||||||
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
|
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
|
||||||
|
source_protocol: row.sourceProtocol,
|
||||||
data_time: row.dataTime,
|
data_time: row.dataTime,
|
||||||
calculated_at: row.calculatedAt,
|
calculated_at: row.calculatedAt,
|
||||||
updated_at: row.updatedAt,
|
updated_at: row.updatedAt,
|
||||||
@@ -190,6 +198,8 @@ function mergeVehicles(
|
|||||||
// Never backfill it from another mileage source outside the assessment page.
|
// Never backfill it from another mileage source outside the assessment page.
|
||||||
totalKm: gpsTotal,
|
totalKm: gpsTotal,
|
||||||
source,
|
source,
|
||||||
|
sourceProtocol: m?.source_protocol || null,
|
||||||
|
sourceCategory: sourceCategoryFromProtocol(m?.source_protocol || null),
|
||||||
dataTime: m?.data_time || null,
|
dataTime: m?.data_time || null,
|
||||||
calculatedAt: m?.calculated_at || null,
|
calculatedAt: m?.calculated_at || null,
|
||||||
updatedAt: m?.updated_at || null,
|
updatedAt: m?.updated_at || null,
|
||||||
@@ -248,10 +258,13 @@ export async function refreshMonitoringCache(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryDateMileage(dateStr: string): Promise<CachedVehicle[]> {
|
export async function queryDateMileage(
|
||||||
|
dateStr: string,
|
||||||
|
protocolPriority?: OneOsProtocol[],
|
||||||
|
): Promise<CachedVehicle[]> {
|
||||||
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||||
fetchOneOsDailyMileage(dateStr),
|
fetchOneOsDailyMileage(dateStr, undefined, protocolPriority),
|
||||||
fetchOneOsDailyMileage(previousDate(dateStr)),
|
fetchOneOsDailyMileage(previousDate(dateStr), undefined, protocolPriority),
|
||||||
fetchVehicleInfoMap(),
|
fetchVehicleInfoMap(),
|
||||||
fetchTargetRows(),
|
fetchTargetRows(),
|
||||||
]);
|
]);
|
||||||
@@ -282,11 +295,16 @@ function datesBetween(start: string, end: string): string[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryRangeMileage(startDate: string, endDate: string): Promise<RangeMileageResult> {
|
export async function queryRangeMileage(
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
protocolPriority?: OneOsProtocol[],
|
||||||
|
): Promise<RangeMileageResult> {
|
||||||
if (startDate === endDate) {
|
if (startDate === endDate) {
|
||||||
const vehicles = (await queryDateMileage(startDate)).map(vehicle => ({
|
const vehicles = (await queryDateMileage(startDate, protocolPriority)).map(vehicle => ({
|
||||||
...vehicle,
|
...vehicle,
|
||||||
dailyMileage: { [startDate]: vehicle.dailyKm },
|
dailyMileage: { [startDate]: vehicle.dailyKm },
|
||||||
|
dailySourceProtocols: { [startDate]: vehicle.sourceProtocol },
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
vehicles,
|
vehicles,
|
||||||
@@ -301,9 +319,9 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
|
|||||||
|
|
||||||
const days = datesBetween(startDate, endDate);
|
const days = datesBetween(startDate, endDate);
|
||||||
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
|
||||||
fetchOneOsMileageDates(days),
|
fetchOneOsMileageDates(days, undefined, protocolPriority),
|
||||||
fetchOneOsDailyMileage(endDate),
|
fetchOneOsDailyMileage(endDate, undefined, protocolPriority),
|
||||||
fetchOneOsDailyMileage(previousDate(startDate)),
|
fetchOneOsDailyMileage(previousDate(startDate), undefined, protocolPriority),
|
||||||
fetchVehicleInfoMap(),
|
fetchVehicleInfoMap(),
|
||||||
fetchTargetRows(),
|
fetchTargetRows(),
|
||||||
]);
|
]);
|
||||||
@@ -316,6 +334,7 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
|
|||||||
date,
|
date,
|
||||||
daily_km: row.dailyMileageKm,
|
daily_km: row.dailyMileageKm,
|
||||||
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
|
source: row.status === 'NORMAL' ? 'ONEOS_API' : 'NONE',
|
||||||
|
source_protocol: row.sourceProtocol,
|
||||||
data_time: row.dataTime,
|
data_time: row.dataTime,
|
||||||
calculated_at: row.calculatedAt,
|
calculated_at: row.calculatedAt,
|
||||||
updated_at: row.updatedAt,
|
updated_at: row.updatedAt,
|
||||||
@@ -324,12 +343,15 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
const perVehicleDaily = new Map<string, Record<string, number>>();
|
const perVehicleDaily = new Map<string, Record<string, number>>();
|
||||||
|
const perVehicleDailySources = new Map<string, Record<string, OneOsProtocol | null>>();
|
||||||
|
const perVehicleSourceCategories = new Map<string, Set<MileageSourceCategory>>();
|
||||||
const perVehicleSum = new Map<string, {
|
const perVehicleSum = new Map<string, {
|
||||||
plate: string;
|
plate: string;
|
||||||
vin: string;
|
vin: string;
|
||||||
daily_km: string;
|
daily_km: string;
|
||||||
total_km: string | null;
|
total_km: string | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
source_protocol: OneOsProtocol | null;
|
||||||
data_time: string | null;
|
data_time: string | null;
|
||||||
calculated_at: string | null;
|
calculated_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
@@ -357,6 +379,15 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
|
|||||||
const daily = perVehicleDaily.get(plate) || {};
|
const daily = perVehicleDaily.get(plate) || {};
|
||||||
daily[date] = km;
|
daily[date] = km;
|
||||||
perVehicleDaily.set(plate, daily);
|
perVehicleDaily.set(plate, daily);
|
||||||
|
const dailySources = perVehicleDailySources.get(plate) || {};
|
||||||
|
dailySources[date] = row.source_protocol;
|
||||||
|
perVehicleDailySources.set(plate, dailySources);
|
||||||
|
const category = sourceCategoryFromProtocol(row.source_protocol);
|
||||||
|
if (category) {
|
||||||
|
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
|
||||||
|
categories.add(category);
|
||||||
|
perVehicleSourceCategories.set(plate, categories);
|
||||||
|
}
|
||||||
|
|
||||||
const existing = perVehicleSum.get(plate);
|
const existing = perVehicleSum.get(plate);
|
||||||
perVehicleSum.set(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),
|
daily_km: String((Number(existing?.daily_km) || 0) + km),
|
||||||
total_km: null,
|
total_km: null,
|
||||||
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
|
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,
|
data_time: row.data_time || existing?.data_time || null,
|
||||||
calculated_at: row.calculated_at || existing?.calculated_at || null,
|
calculated_at: row.calculated_at || existing?.calculated_at || null,
|
||||||
updated_at: row.updated_at || existing?.updated_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,
|
...aggregate,
|
||||||
vin: endDateRow.vin || aggregate.vin,
|
vin: endDateRow.vin || aggregate.vin,
|
||||||
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
|
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
|
||||||
|
source_protocol: endDateRow.sourceProtocol || aggregate.source_protocol,
|
||||||
data_time: endDateRow.dataTime || aggregate.data_time,
|
data_time: endDateRow.dataTime || aggregate.data_time,
|
||||||
updated_at: endDateRow.updatedAt || aggregate.updated_at,
|
updated_at: endDateRow.updatedAt || aggregate.updated_at,
|
||||||
});
|
});
|
||||||
@@ -393,9 +426,20 @@ export async function queryRangeMileage(startDate: string, endDate: string): Pro
|
|||||||
buildPlateTargetNamesMap(targetRows),
|
buildPlateTargetNamesMap(targetRows),
|
||||||
).map(vehicle => {
|
).map(vehicle => {
|
||||||
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
|
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
|
||||||
|
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
|
||||||
|
const categories = perVehicleSourceCategories.get(vehicle.plate);
|
||||||
const completedDailyMileage: Record<string, number> = {};
|
const completedDailyMileage: Record<string, number> = {};
|
||||||
|
const completedDailySources: Record<string, OneOsProtocol | null> = {};
|
||||||
for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0;
|
for (const day of days) 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 {
|
return {
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { getCache, queryDateMileage, queryRangeMileage, buildDateFilters } from
|
|||||||
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
import { filterByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
||||||
import type { AuthUser } from '../../auth/types.js';
|
import type { AuthUser } from '../../auth/types.js';
|
||||||
import type { CachedVehicle, MonitoringFilters, MonitoringResponse } from './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();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -14,6 +19,7 @@ const EMPTY_RESPONSE: MonitoringResponse = {
|
|||||||
page: 1,
|
page: 1,
|
||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
sourcePriority: [...DEFAULT_MILEAGE_SOURCE_PRIORITY],
|
||||||
};
|
};
|
||||||
|
|
||||||
function applyFilters(vehicles: CachedVehicle[], params: {
|
function applyFilters(vehicles: CachedVehicle[], params: {
|
||||||
@@ -104,6 +110,8 @@ app.get('/', async (c) => {
|
|||||||
const page = Number(c.req.query('page')) || 1;
|
const page = Number(c.req.query('page')) || 1;
|
||||||
const date = c.req.query('date') || '';
|
const date = c.req.query('date') || '';
|
||||||
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
|
const range = normalizeRange(c.req.query('startDate') || '', c.req.query('endDate') || '');
|
||||||
|
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
|
||||||
|
const protocolPriority = protocolsForSourcePriority(sourcePriority);
|
||||||
|
|
||||||
const filterParams = {
|
const filterParams = {
|
||||||
search: c.req.query('search') || '',
|
search: c.req.query('search') || '',
|
||||||
@@ -128,7 +136,7 @@ app.get('/', async (c) => {
|
|||||||
|
|
||||||
if (range) {
|
if (range) {
|
||||||
try {
|
try {
|
||||||
const result = await queryRangeMileage(range.start, range.end);
|
const result = await queryRangeMileage(range.start, range.end, protocolPriority);
|
||||||
allVehicles = result.vehicles;
|
allVehicles = result.vehicles;
|
||||||
rangeDailyTotals = result.dailyTotals;
|
rangeDailyTotals = result.dailyTotals;
|
||||||
dateRange = { start: result.start, end: result.end };
|
dateRange = { start: result.start, end: result.end };
|
||||||
@@ -139,7 +147,7 @@ app.get('/', async (c) => {
|
|||||||
}
|
}
|
||||||
} else if (date) {
|
} else if (date) {
|
||||||
try {
|
try {
|
||||||
allVehicles = await queryDateMileage(date);
|
allVehicles = await queryDateMileage(date, protocolPriority);
|
||||||
filters = buildDateFilters(allVehicles);
|
filters = buildDateFilters(allVehicles);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
console.error('monitoring date query error:', e);
|
console.error('monitoring date query error:', e);
|
||||||
@@ -200,6 +208,7 @@ app.get('/', async (c) => {
|
|||||||
page,
|
page,
|
||||||
totalPages: Math.ceil(total / limit),
|
totalPages: Math.ceil(total / limit),
|
||||||
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
|
updatedAt: dateRange?.end || date || getCache()?.updatedAt || new Date().toISOString(),
|
||||||
|
sourcePriority,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
import {
|
||||||
|
DEFAULT_ONEOS_PROTOCOL_PRIORITY,
|
||||||
|
normalizeOneOsProtocol,
|
||||||
|
type OneOsProtocol,
|
||||||
|
} from './source-policy.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -20,6 +25,7 @@ export interface OneOsDailyMileage {
|
|||||||
dataTime: string | null;
|
dataTime: string | null;
|
||||||
calculatedAt: string | null;
|
calculatedAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
|
sourceProtocol: OneOsProtocol | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OneOsMileageResponse {
|
interface OneOsMileageResponse {
|
||||||
@@ -42,6 +48,17 @@ interface CacheEntry {
|
|||||||
const cache = new Map<string, CacheEntry>();
|
const cache = new Map<string, CacheEntry>();
|
||||||
const inflight = new Map<string, Promise<OneOsDailyMileage[]>>();
|
const inflight = new Map<string, Promise<OneOsDailyMileage[]>>();
|
||||||
const rangeInflight = new Map<string, Promise<Map<string, OneOsDailyMileage[]>>>();
|
const rangeInflight = new Map<string, Promise<Map<string, OneOsDailyMileage[]>>>();
|
||||||
|
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 } {
|
function apiConfig(): { baseUrl: string; apiKey: string; timeoutMs: number } {
|
||||||
const baseUrl = (process.env.ONEOS_MILEAGE_API_BASE_URL || '').replace(/\/+$/, '');
|
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 dataTime = stringField('dataTime', 'statisticTime', 'recordTime');
|
||||||
const calculatedAt = stringField('calculatedAt', 'calculationTime');
|
const calculatedAt = stringField('calculatedAt', 'calculationTime');
|
||||||
const updatedAt = stringField('updatedAt');
|
const updatedAt = stringField('updatedAt');
|
||||||
|
const sourceProtocol = normalizeOneOsProtocol(
|
||||||
|
stringField('sourceProtocol', 'protocol', 'dataSource'),
|
||||||
|
);
|
||||||
if (!plateNumber || date !== requestedDate) continue;
|
if (!plateNumber || date !== requestedDate) continue;
|
||||||
|
|
||||||
const normalized: OneOsDailyMileage = {
|
const normalized: OneOsDailyMileage = {
|
||||||
@@ -106,6 +126,7 @@ function normalizeRows(value: unknown, requestedDate: string): OneOsDailyMileage
|
|||||||
dataTime,
|
dataTime,
|
||||||
calculatedAt,
|
calculatedAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
|
sourceProtocol,
|
||||||
};
|
};
|
||||||
const existing = best.get(plateNumber);
|
const existing = best.get(plateNumber);
|
||||||
if (!existing || (dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) {
|
if (!existing || (dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) {
|
||||||
@@ -131,12 +152,18 @@ function normalizeRangeRows(value: unknown, startDate: string, endDate: string):
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
|
async function requestDate(
|
||||||
|
date: string,
|
||||||
|
protocolPriority: OneOsProtocol[],
|
||||||
|
): Promise<OneOsDailyMileage[]> {
|
||||||
const { baseUrl, apiKey, timeoutMs } = apiConfig();
|
const { baseUrl, apiKey, timeoutMs } = apiConfig();
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
|
const includeProtocolPriority = protocolPrioritySupported !== false;
|
||||||
|
const body: Record<string, unknown> = { date };
|
||||||
|
if (includeProtocolPriority) body.protocolPriority = protocolPriority;
|
||||||
const response = await fetch(`${baseUrl}${ENDPOINT}`, {
|
const response = await fetch(`${baseUrl}${ENDPOINT}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -145,10 +172,18 @@ async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
|
|||||||
},
|
},
|
||||||
// Intentionally omit plateNumbers: the API then returns every vehicle
|
// Intentionally omit plateNumbers: the API then returns every vehicle
|
||||||
// authorized for this application on the requested natural day.
|
// authorized for this application on the requested natural day.
|
||||||
body: JSON.stringify({ date }),
|
body: JSON.stringify(body),
|
||||||
signal: AbortSignal.timeout(timeoutMs),
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => null) as OneOsMileageResponse | null;
|
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') {
|
if (!response.ok || payload?.code !== 'SUCCESS') {
|
||||||
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
|
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
@@ -157,6 +192,7 @@ async function requestDate(date: string): Promise<OneOsDailyMileage[]> {
|
|||||||
if (response.status < 500 || attempt === 2) throw error;
|
if (response.status < 500 || attempt === 2) throw error;
|
||||||
lastError = error;
|
lastError = error;
|
||||||
} else {
|
} else {
|
||||||
|
if (includeProtocolPriority) protocolPrioritySupported = true;
|
||||||
return normalizeRows(payload.data, date);
|
return normalizeRows(payload.data, date);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -175,6 +211,7 @@ async function requestRange(
|
|||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string,
|
endDate: string,
|
||||||
plateNumbers: string[],
|
plateNumbers: string[],
|
||||||
|
protocolPriority: OneOsProtocol[],
|
||||||
): Promise<Map<string, OneOsDailyMileage[]>> {
|
): Promise<Map<string, OneOsDailyMileage[]>> {
|
||||||
const { baseUrl, apiKey, timeoutMs } = apiConfig();
|
const { baseUrl, apiKey, timeoutMs } = apiConfig();
|
||||||
const allRows: unknown[] = [];
|
const allRows: unknown[] = [];
|
||||||
@@ -192,6 +229,8 @@ async function requestRange(
|
|||||||
endDate,
|
endDate,
|
||||||
pageSize: RANGE_PAGE_SIZE,
|
pageSize: RANGE_PAGE_SIZE,
|
||||||
};
|
};
|
||||||
|
const includeProtocolPriority = protocolPrioritySupported !== false;
|
||||||
|
if (includeProtocolPriority) body.protocolPriority = protocolPriority;
|
||||||
if (plateNumbers.length > 0) body.plateNumbers = plateNumbers;
|
if (plateNumbers.length > 0) body.plateNumbers = plateNumbers;
|
||||||
if (cursor) body.cursor = cursor;
|
if (cursor) body.cursor = cursor;
|
||||||
const response = await fetch(`${baseUrl}${RANGE_ENDPOINT}`, {
|
const response = await fetch(`${baseUrl}${RANGE_ENDPOINT}`, {
|
||||||
@@ -204,6 +243,14 @@ async function requestRange(
|
|||||||
signal: AbortSignal.timeout(timeoutMs),
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
});
|
});
|
||||||
payload = await response.json().catch(() => null) as OneOsMileageRangeResponse | null;
|
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') {
|
if (!response.ok || payload?.code !== 'SUCCESS') {
|
||||||
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
|
const trace = payload?.traceId ? `, traceId=${payload.traceId}` : '';
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
@@ -212,6 +259,7 @@ async function requestRange(
|
|||||||
if (response.status < 500 || attempt === 2) throw error;
|
if (response.status < 500 || attempt === 2) throw error;
|
||||||
lastError = error;
|
lastError = error;
|
||||||
} else {
|
} else {
|
||||||
|
if (includeProtocolPriority) protocolPrioritySupported = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -252,26 +300,29 @@ function trimCache(): void {
|
|||||||
export async function fetchOneOsDailyMileage(
|
export async function fetchOneOsDailyMileage(
|
||||||
date: string,
|
date: string,
|
||||||
plateNumbers?: string[],
|
plateNumbers?: string[],
|
||||||
|
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
|
||||||
): Promise<OneOsDailyMileage[]> {
|
): Promise<OneOsDailyMileage[]> {
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error(`Invalid mileage date: ${date}`);
|
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[];
|
let rows: OneOsDailyMileage[];
|
||||||
if (hit && hit.expiresAt > Date.now()) {
|
if (hit && hit.expiresAt > Date.now()) {
|
||||||
rows = hit.rows;
|
rows = hit.rows;
|
||||||
} else {
|
} else {
|
||||||
let pending = inflight.get(date);
|
let pending = inflight.get(cacheKey);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = requestDate(date).then(result => {
|
pending = requestDate(date, protocolPriority).then(result => {
|
||||||
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
|
const ttl = date === shanghaiDate() ? CURRENT_DAY_TTL_MS : HISTORICAL_TTL_MS;
|
||||||
cache.delete(date);
|
cache.delete(cacheKey);
|
||||||
if (ttl > 0) {
|
if (ttl > 0) {
|
||||||
cache.set(date, { rows: result, expiresAt: Date.now() + ttl });
|
cache.set(cacheKey, { rows: result, expiresAt: Date.now() + ttl });
|
||||||
trimCache();
|
trimCache();
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}).finally(() => inflight.delete(date));
|
}).finally(() => inflight.delete(cacheKey));
|
||||||
inflight.set(date, pending);
|
inflight.set(cacheKey, pending);
|
||||||
}
|
}
|
||||||
rows = await pending;
|
rows = await pending;
|
||||||
}
|
}
|
||||||
@@ -284,6 +335,7 @@ export async function fetchOneOsDailyMileage(
|
|||||||
export async function fetchOneOsMileageDates(
|
export async function fetchOneOsMileageDates(
|
||||||
dates: string[],
|
dates: string[],
|
||||||
plateNumbers?: string[],
|
plateNumbers?: string[],
|
||||||
|
protocolPriority: OneOsProtocol[] = DEFAULT_ONEOS_PROTOCOL_PRIORITY,
|
||||||
): Promise<Map<string, OneOsDailyMileage[]>> {
|
): Promise<Map<string, OneOsDailyMileage[]>> {
|
||||||
const result = new Map<string, OneOsDailyMileage[]>();
|
const result = new Map<string, OneOsDailyMileage[]>();
|
||||||
const uniqueDates = Array.from(new Set(dates)).sort();
|
const uniqueDates = Array.from(new Set(dates)).sort();
|
||||||
@@ -312,10 +364,20 @@ export async function fetchOneOsMileageDates(
|
|||||||
await Promise.all(groups.map(async group => {
|
await Promise.all(groups.map(async group => {
|
||||||
const startDate = group[0];
|
const startDate = group[0];
|
||||||
const endDate = group[group.length - 1];
|
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);
|
let pending = rangeInflight.get(key);
|
||||||
if (!pending) {
|
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);
|
rangeInflight.set(key, pending);
|
||||||
}
|
}
|
||||||
const rowsByDate = await pending;
|
const rowsByDate = await pending;
|
||||||
|
|||||||
38
src/server/routes/mileage/source-policy.ts
Normal file
38
src/server/routes/mileage/source-policy.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ export interface CachedVehicle {
|
|||||||
dailyMileage?: Record<string, number>;
|
dailyMileage?: Record<string, number>;
|
||||||
totalKm: number | null;
|
totalKm: number | null;
|
||||||
source: string;
|
source: string;
|
||||||
|
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
|
||||||
|
sourceCategory: 'INSTRUMENT' | 'GPS' | 'MIXED' | null;
|
||||||
|
dailySourceProtocols?: Record<string, 'GB32960' | 'MQTT' | 'JT808' | null>;
|
||||||
dataTime: string | null;
|
dataTime: string | null;
|
||||||
calculatedAt: string | null;
|
calculatedAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
@@ -72,6 +75,7 @@ export interface MonitoringResponse {
|
|||||||
page: number;
|
page: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
sourcePriority: ('instrument' | 'gps')[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
|
/** 车辆关联信息(从 lingniu_prod 查出的原始行) */
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { fetchOneOsMileageDates } from './oneos-api.js';
|
import { fetchOneOsMileageDates } from './oneos-api.js';
|
||||||
|
import {
|
||||||
|
parseMileageSourcePriority,
|
||||||
|
protocolsForSourcePriority,
|
||||||
|
sourceCategoryFromProtocol,
|
||||||
|
} from './source-policy.js';
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
@@ -23,6 +28,8 @@ const MAX_DAYS = 366;
|
|||||||
app.get('/:plate/recent', async (c) => {
|
app.get('/:plate/recent', async (c) => {
|
||||||
const plate = c.req.param('plate');
|
const plate = c.req.param('plate');
|
||||||
if (!plate) return c.json({ plate: '', days: [] }, 400);
|
if (!plate) return c.json({ plate: '', days: [] }, 400);
|
||||||
|
const sourcePriority = parseMileageSourcePriority(c.req.query('sourcePriority'));
|
||||||
|
const protocolPriority = protocolsForSourcePriority(sourcePriority);
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
@@ -58,10 +65,16 @@ app.get('/:plate/recent', async (c) => {
|
|||||||
dates.push(fmt(dateCursor));
|
dates.push(fmt(dateCursor));
|
||||||
dateCursor.setDate(dateCursor.getDate() + 1);
|
dateCursor.setDate(dateCursor.getDate() + 1);
|
||||||
}
|
}
|
||||||
const rowsByDate = await fetchOneOsMileageDates(dates, [plate]);
|
const rowsByDate = await fetchOneOsMileageDates(dates, [plate], protocolPriority);
|
||||||
|
|
||||||
// 补全:从 start 到 end 每天一条
|
// 补全:从 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);
|
const cursor = new Date(start);
|
||||||
while (cursor <= end) {
|
while (cursor <= end) {
|
||||||
const key = fmt(cursor);
|
const key = fmt(cursor);
|
||||||
@@ -70,11 +83,19 @@ app.get('/:plate/recent', async (c) => {
|
|||||||
date: key,
|
date: key,
|
||||||
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
|
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
|
||||||
isDataSynced: hit?.status === 'NORMAL',
|
isDataSynced: hit?.status === 'NORMAL',
|
||||||
|
sourceProtocol: hit?.sourceProtocol || null,
|
||||||
|
sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null),
|
||||||
});
|
});
|
||||||
cursor.setDate(cursor.getDate() + 1);
|
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) {
|
} catch (e: unknown) {
|
||||||
console.error('vehicle recent error:', e);
|
console.error('vehicle recent error:', e);
|
||||||
return c.json({ plate, days: [] }, 500);
|
return c.json({ plate, days: [] }, 500);
|
||||||
|
|||||||
Reference in New Issue
Block a user