优化里程日期选择并修复区间累计里程缺失
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
shishengliang
2026-11-03 09:29:54 +08:00
parent a81ace14e4
commit 12324006db
13 changed files with 67 additions and 58 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.22", "version": "1.1.23",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ln-bi", "name": "ln-bi",
"version": "1.1.22", "version": "1.1.23",
"dependencies": { "dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1", "@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0", "@hono/node-server": "^1.13.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ln-bi", "name": "ln-bi",
"private": true, "private": true,
"version": "1.1.22", "version": "1.1.23",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"", "dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
-1
View File
@@ -391,7 +391,6 @@ export default function MonitoringView() {
rangeEnd={rangeEnd} rangeEnd={rangeEnd}
onRangeStartChange={setRangeStart} onRangeStartChange={setRangeStart}
onRangeEndChange={setRangeEnd} onRangeEndChange={setRangeEnd}
onApplyRangePreset={applyRangePreset}
departments={departments} departments={departments}
filterDept={filterDept} filterDept={filterDept}
onFilterDeptChange={setFilterDept} onFilterDeptChange={setFilterDept}
@@ -1,7 +1,8 @@
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import type { MonitoringFilters } from '../../types'; import type { MonitoringFilters } from '../../types';
import { defaultMileageDate, type RangePreset } from '../date-range'; import { DateRangePicker } from '../../../../components/ui/DateRangePicker';
import { defaultMileageDate, getRangePreset, type RangePreset } from '../date-range';
import BatchMultiSelect from './BatchMultiSelect'; import BatchMultiSelect from './BatchMultiSelect';
import SearchableSelect from './SearchableSelect'; import SearchableSelect from './SearchableSelect';
@@ -12,7 +13,6 @@ export default function FilterPanel({
rangeEnd, rangeEnd,
onRangeStartChange, onRangeStartChange,
onRangeEndChange, onRangeEndChange,
onApplyRangePreset,
departments, departments,
filterDept, filterDept,
onFilterDeptChange, onFilterDeptChange,
@@ -42,7 +42,6 @@ export default function FilterPanel({
rangeEnd: string; rangeEnd: string;
onRangeStartChange: (value: string) => void; onRangeStartChange: (value: string) => void;
onRangeEndChange: (value: string) => void; onRangeEndChange: (value: string) => void;
onApplyRangePreset: (preset: RangePreset) => void;
departments: string[]; departments: string[];
filterDept: string; filterDept: string;
onFilterDeptChange: (value: string) => void; onFilterDeptChange: (value: string) => void;
@@ -76,47 +75,25 @@ export default function FilterPanel({
className="overflow-hidden" className="overflow-hidden"
> >
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4"> <div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-sm mb-2 space-y-4">
{/* Date range */} <DateRangePicker
<div className="space-y-1.5"> label="查询时间区间"
<label className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">查询时间区间</label> value={{ start: rangeStart, end: rangeEnd }}
<div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2"> onChange={({ start, end }) => {
<input onRangeStartChange(start);
type="date" onRangeEndChange(end);
aria-label="开始日期" }}
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none" quickPicks={([
value={rangeStart}
onChange={(e) => onRangeStartChange(e.target.value)}
/>
<span className="text-[10px] font-bold text-slate-300">至</span>
<input
type="date"
aria-label="结束日期"
className="w-full bg-slate-50 border-none rounded-xl py-2 px-3 text-xs focus:ring-2 focus:ring-blue-500/20 outline-none"
value={rangeEnd}
onChange={(e) => onRangeEndChange(e.target.value)}
/>
</div>
{rangeStart && rangeEnd && rangeStart > rangeEnd && <p role="alert" className="text-xs text-red-600">开始日期不能晚于结束日期</p>}
<div className="flex items-center gap-1.5 pt-1">
{([
['today', '今天'], ['today', '今天'],
['yesterday', '昨天'], ['yesterday', '昨天'],
['thisWeek', '本周'], ['thisWeek', '本周'],
['thisMonth', '本月'], ['thisMonth', '本月'],
['last7', '近7天'], ['last7', '近7天'],
['last15', '近15天'], ['last15', '近15天'],
] as Array<[RangePreset, string]>).map(([preset, label]) => ( ] satisfies Array<[RangePreset, string]>).map(([preset, label]) => ({
<button label,
key={preset} range: getRangePreset(preset),
type="button" }))}
className="rounded-lg bg-slate-50 px-2 py-1 text-[10px] font-bold text-slate-500 hover:bg-blue-50 hover:text-blue-600" />
onClick={() => onApplyRangePreset(preset)}
>
{label}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{/* Department */} {/* Department */}
@@ -331,7 +331,8 @@ export default function FullscreenMonitoring({
</td> </td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-slate-300' : 'text-slate-600'}`}> <span className={`text-xs font-mono font-bold ${v.isDataSynced ? 'text-slate-300' : 'text-slate-600'}`}>
{v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>} {v.totalKm != null ? <>{v.totalKm.toLocaleString()} <span className="text-[8px] text-slate-500">km</span></> : <span className="text-[8px] text-amber-500/50">暂无读数</span>}
{v.totalKmFallbackDate && <span className="block text-[10px] text-slate-400">截至 {v.totalKmFallbackDate}</span>}
</span> </span>
</td> </td>
</tr> </tr>
@@ -113,7 +113,8 @@ export default function VehicleList({
<div className="hidden text-right md:block"> <div className="hidden text-right md:block">
<div className="text-xs font-black tabular-nums text-slate-700"> <div className="text-xs font-black tabular-nums text-slate-700">
{v.totalKm != null ? v.totalKm.toLocaleString() : '-'} {v.totalKm != null ? v.totalKm.toLocaleString() : '-'}
<span className="ml-1 text-[9px] font-bold text-slate-400">km</span> {v.totalKm != null && <span className="ml-1 text-[9px] font-bold text-slate-400">km</span>}
{v.totalKmFallbackDate && <div className="text-[10px] font-normal text-slate-500">截至 {v.totalKmFallbackDate}</div>}
</div> </div>
</div> </div>
<div className="flex flex-shrink-0 flex-col items-end text-right md:hidden"> <div className="flex flex-shrink-0 flex-col items-end text-right md:hidden">
@@ -129,7 +130,8 @@ export default function VehicleList({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[7px] font-black text-slate-400/60 bg-slate-100 w-3 h-3 rounded flex items-center justify-center leading-none">总</span> <span className="text-[7px] font-black text-slate-400/60 bg-slate-100 w-3 h-3 rounded flex items-center justify-center leading-none">总</span>
<span className="text-[8px] font-bold text-slate-300"> <span className="text-[8px] font-bold text-slate-300">
{v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '未对接'} {v.totalKm != null ? `${v.totalKm.toLocaleString()} km` : '暂无读数'}
{v.totalKmFallbackDate && <span className="block">截至 {v.totalKmFallbackDate}</span>}
</span> </span>
</div> </div>
</div> </div>
+1
View File
@@ -16,6 +16,7 @@ export interface MonitoringVehicle {
mileageAnomaly?: string | null; mileageAnomaly?: string | null;
dailyMileage?: Record<string, number | null>; dailyMileage?: Record<string, number | null>;
totalKm: number | null; totalKm: number | null;
totalKmFallbackDate?: string;
source: string; source: string;
sourceProtocol: MileageSourceProtocol | null; sourceProtocol: MileageSourceProtocol | null;
sourceCategory: MileageSourceCategory | null; sourceCategory: MileageSourceCategory | null;
+6
View File
@@ -50,6 +50,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
...(dayKeys.length > 0 ? dailyHeaders : []), ...(dayKeys.length > 0 ? dailyHeaders : []),
isRange ? '区间合计(km)' : '当日里程(km)', isRange ? '区间合计(km)' : '当日里程(km)',
'累计里程(km)', '累计里程(km)',
...(isRange ? ['累计读数日期'] : []),
]; ];
const summaryData: (string | number)[][] = [ const summaryData: (string | number)[][] = [
@@ -71,6 +72,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
...dailyCols, ...dailyCols,
mileageCell(v, 'today'), mileageCell(v, 'today'),
mileageCell(v, 'total'), mileageCell(v, 'total'),
...(isRange ? [v.totalKm != null ? v.totalKmFallbackDate || end : ''] : []),
]; ];
}), }),
]; ];
@@ -92,6 +94,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 }); for (let i = 0; i < dayKeys.length; i++) wsCols.push({ wch: 12 });
wsCols.push({ wch: 14 }); // 区间合计 wsCols.push({ wch: 14 }); // 区间合计
wsCols.push({ wch: 14 }); // 累计里程 wsCols.push({ wch: 14 }); // 累计里程
if (isRange) wsCols.push({ wch: 16 });
ws['!cols'] = wsCols; ws['!cols'] = wsCols;
// 冻结首行 + 固定列 // 冻结首行 + 固定列
@@ -126,6 +129,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
...dayKeys.map(day => `${day}里程(km)`), ...dayKeys.map(day => `${day}里程(km)`),
'区间合计(km)', '区间合计(km)',
'累计里程(km)', '累计里程(km)',
...(isRange ? ['累计读数日期'] : []),
]; ];
const detailData: (string | number)[][] = [ const detailData: (string | number)[][] = [
detailHeaders, detailHeaders,
@@ -140,6 +144,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
...dayKeys.map(day => v.dailyMileage?.[day] ?? '—'), ...dayKeys.map(day => v.dailyMileage?.[day] ?? '—'),
mileageCell(v, 'today'), mileageCell(v, 'today'),
v.totalKm != null ? v.totalKm : '', v.totalKm != null ? v.totalKm : '',
...(isRange ? [v.totalKm != null ? v.totalKmFallbackDate || end : ''] : []),
]), ]),
]; ];
const detailWs = XLSX.utils.aoa_to_sheet(detailData); const detailWs = XLSX.utils.aoa_to_sheet(detailData);
@@ -154,6 +159,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
...dayKeys.map(() => ({ wch: 14 })), ...dayKeys.map(() => ({ wch: 14 })),
{ wch: 14 }, { wch: 14 },
{ wch: 14 }, { wch: 14 },
...(isRange ? [{ wch: 16 }] : []),
]; ];
detailWs['!freeze'] = { xSplit: 7, 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++) {
+1 -1
View File
@@ -127,7 +127,7 @@ export async function queryRangeMileage(
]); ]);
const { summary, yesterdayRows } = cached.result; const { summary, yesterdayRows } = cached.result;
return { return {
vehicles: buildSummaryMileage(summary, yesterdayRows, infoMap, targetRows, regionMap), vehicles: buildSummaryMileage(summary, yesterdayRows, infoMap, targetRows, regionMap, cached.result.priorOdometers),
summary, summary,
start: startDate, end: endDate, start: startDate, end: endDate,
queriedAt: cached.queriedAt, queriedAt: cached.queriedAt,
@@ -54,11 +54,14 @@ test('summary integration preserves scope, valid partial mileage, trend nulls, r
assert.equal(first.vehicles[0].plate,'TEST1');assert.equal(first.stats.totalToday,50); assert.equal(first.vehicles[0].plate,'TEST1');assert.equal(first.stats.totalToday,50);
assert.equal(first.rangeDailyTotals?.[1].totalKm,null); assert.equal(first.rangeDailyTotals?.[1].totalKm,null);
assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1); assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1);
assert.equal(requests.filter(r=>r.url.endsWith('/range/query')).length,0); assert.deepEqual(requests.filter(r=>r.url.endsWith('/range/query')).map(r=>r.body.plateNumbers), [['TEST0']]);
const second=await read('&page=2&limit=1&includeDailyTrend=0');assert.equal(second.vehicles[0].periodMileageKm,20); const second=await read('&page=2&limit=1&includeDailyTrend=0');assert.equal(second.vehicles[0].periodMileageKm,20);
const sorted=await read('&sortOrder=asc&includeDailyTrend=0');assert.equal(sorted.vehicles[0].periodMileageKm,0); const sorted=await read('&sortOrder=asc&includeDailyTrend=0');assert.equal(sorted.vehicles[0].periodMileageKm,0);
const exported=await read('&limit=9999&includeDailyTrend=0'); const exported=await read('&limit=9999&includeDailyTrend=0');
assert.equal(exported.vehicles.length,4);assert.equal(exported.vehicles[3].periodMileageKm,null); assert.equal(exported.vehicles.length,4);assert.equal(exported.vehicles[3].periodMileageKm,null);
const fallback = exported.vehicles.find(v => v.plate === 'TEST0')!;
assert.equal(fallback.totalKm,100);
assert.equal(fallback.totalKmFallbackDate,'2026-10-10');
assert.equal(exported.queriedAt,first.queriedAt);assert.ok(!exported.vehicles[0].dailyMileage); assert.equal(exported.queriedAt,first.queriedAt);assert.ok(!exported.vehicles[0].dailyMileage);
assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1); assert.equal(requests.filter(r=>r.url.endsWith('/summary/query')).length,1);
user={...user,permissionLevel:'department'}; user={...user,permissionLevel:'department'};
+20 -3
View File
@@ -1,5 +1,5 @@
import { fetchOneOsDailyMileage, type OneOsDailyMileage } from './oneos-api.js'; import { fetchOneOsDailyMileage, fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js';
import { previousMileageDate } from './cache-model.js'; import { mileageDatesBetween, previousMileageDate } from './cache-model.js';
import { fetchMileageSummary, type MileageSummary } from './summary-api.js'; import { fetchMileageSummary, type MileageSummary } from './summary-api.js';
import type { OneOsProtocol } from './source-policy.js'; import type { OneOsProtocol } from './source-policy.js';
@@ -10,6 +10,7 @@ const MAX_ENTRIES = 4;
export interface RangeMileageSource { export interface RangeMileageSource {
summary: MileageSummary; summary: MileageSummary;
yesterdayRows: OneOsDailyMileage[]; yesterdayRows: OneOsDailyMileage[];
priorOdometers?: Map<string, { date: string; km: number }>;
} }
type Entry = { result: RangeMileageSource; queriedAt: string; expiresAt: number }; type Entry = { result: RangeMileageSource; queriedAt: string; expiresAt: number };
@@ -19,7 +20,23 @@ async function loadSource(start: string, end: string, protocols: OneOsProtocol[]
fetchMileageSummary(start, end, protocols, plates), fetchMileageSummary(start, end, protocols, plates),
plates ? Promise.resolve([]) : fetchOneOsDailyMileage(previousMileageDate(start), undefined, protocols, refresh), plates ? Promise.resolve([]) : fetchOneOsDailyMileage(previousMileageDate(start), undefined, protocols, refresh),
]); ]);
return { summary, yesterdayRows }; // Plate-scoped summary requests are used only for filtered trends.
if (plates || start === end) return { summary, yesterdayRows };
const missing = summary.vehicles.filter(v => v.endDateTotalMileageKm === null && v.validDays + v.anomalyDays > 0);
if (!missing.length) return { summary, yesterdayRows };
const dates = mileageDatesBetween(start, previousMileageDate(end));
const history = await fetchOneOsMileageDates(dates, missing.map(v => v.plateNumber), protocols);
const priorOdometers = new Map<string, { date: string; km: number }>();
for (const date of dates.reverse()) {
for (const row of history.get(date) || []) {
const plate = row.plateNumber.toUpperCase();
const trusted = row.status === 'NORMAL' || row.dataQuality === 'BASELINE_MISSING' || row.dataQuality === 'ODOMETER_SOURCE_CHANGED';
if (!priorOdometers.has(plate) && trusted && row.dataQuality !== 'CARRIED_FORWARD' && row.totalMileageKm != null) {
priorOdometers.set(plate, { date, km: row.totalMileageKm });
}
}
}
return { summary, yesterdayRows, priorOdometers };
} }
export function createMonitoringQueryCache( export function createMonitoringQueryCache(
+4 -2
View File
@@ -4,15 +4,17 @@ import { buildPlateTargetNamesMap, dailyMileageMap, mergeMonitoringVehicles, typ
import type { CachedVehicle, VehicleInfoRow } from './types.js'; import type { CachedVehicle, VehicleInfoRow } from './types.js';
import { sourceCategoryFromProtocol } from './source-policy.js'; import { sourceCategoryFromProtocol } from './source-policy.js';
export function buildSummaryMileage(summary: MileageSummary, yesterdayRows: OneOsDailyMileage[], infoMap: Map<string, VehicleInfoRow>, targets: TargetRow[], regions: Record<string, string>): CachedVehicle[] { export function buildSummaryMileage(summary: MileageSummary, yesterdayRows: OneOsDailyMileage[], infoMap: Map<string, VehicleInfoRow>, targets: TargetRow[], regions: Record<string, string>, priorOdometers?: Map<string, { date: string; km: number }>): CachedVehicle[] {
const byPlate = new Map(summary.vehicles.map(v => [v.plateNumber, v])); const byPlate = new Map(summary.vehicles.map(v => [v.plateNumber, v]));
return mergeMonitoringVehicles([], infoMap, dailyMileageMap(yesterdayRows), buildPlateTargetNamesMap(targets), regions).map(vehicle => { return mergeMonitoringVehicles([], infoMap, dailyMileageMap(yesterdayRows), buildPlateTargetNamesMap(targets), regions).map(vehicle => {
const row = byPlate.get(vehicle.plate.trim().toUpperCase()); const row = byPlate.get(vehicle.plate.trim().toUpperCase());
if (!row) return { ...vehicle, periodMileageKm: null }; if (!row) return { ...vehicle, periodMileageKm: null };
const prior = priorOdometers?.get(row.plateNumber);
const categories = new Set(row.sourceProtocols.map(sourceCategoryFromProtocol)); const categories = new Set(row.sourceProtocols.map(sourceCategoryFromProtocol));
return { ...vehicle, vin: row.vin || vehicle.vin, return { ...vehicle, vin: row.vin || vehicle.vin,
dailyKm: row.periodMileageKm ?? 0, periodMileageKm: row.periodMileageKm, dailyKm: row.periodMileageKm ?? 0, periodMileageKm: row.periodMileageKm,
totalKm: row.endDateTotalMileageKm, totalKm: row.endDateTotalMileageKm ?? prior?.km ?? null,
totalKmFallbackDate: prior?.date,
validDays: row.validDays, missingDays: row.missingDays, anomalyDays: row.anomalyDays, validDays: row.validDays, missingDays: row.missingDays, anomalyDays: row.anomalyDays,
qualityStatus: row.status, qualityReasons: row.qualityReasons, sourceProtocols: row.sourceProtocols, qualityStatus: row.status, qualityReasons: row.qualityReasons, sourceProtocols: row.sourceProtocols,
mileageAnomaly: row.anomalyDays ? row.qualityReasons.join(', ') || 'DATA_ANOMALY' : null, mileageAnomaly: row.anomalyDays ? row.qualityReasons.join(', ') || 'DATA_ANOMALY' : null,
+1
View File
@@ -13,6 +13,7 @@ export interface CachedVehicle {
mileageAnomaly?: string | null; mileageAnomaly?: string | null;
dailyMileage?: Record<string, number | null>; dailyMileage?: Record<string, number | null>;
totalKm: number | null; totalKm: number | null;
totalKmFallbackDate?: string;
source: string; source: string;
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null; sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
sourceCategory: 'INSTRUMENT' | 'GPS' | 'MIXED' | null; sourceCategory: 'INSTRUMENT' | 'GPS' | 'MIXED' | null;