4 Commits
Author SHA1 Message Date
shishengliang 00c09d7452 fix(mileage): skip missing daily mileage in interval totals
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-13 13:20:14 +08:00
shishengliang 5751d1af15 fix(assets): emphasize daily flow details and sort newest first
ci/woodpecker/push/woodpecker Pipeline was successful
2026-10-10 21:56:06 +08:00
shishengliang 24984fe033 fix(assets): show inventory operations managers and bump v1.1.19 2026-10-10 21:56:06 +08:00
shishengliang 7dd7e67602 fix(energy): connect Foshan external vehicle hydrogen orders
ci/woodpecker/push/woodpecker Pipeline was successful
2026-09-29 10:03:45 +08:00
31 changed files with 405 additions and 232 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ln-bi",
"version": "1.1.18",
"version": "1.1.19",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ln-bi",
"version": "1.1.18",
"version": "1.1.19",
"dependencies": {
"@amap/amap-jsapi-loader": "^1.0.1",
"@hono/node-server": "^1.13.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ln-bi",
"private": true,
"version": "1.1.18",
"version": "1.1.19",
"type": "module",
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev:server\" \"npm run dev:client\"",
@@ -41,3 +41,13 @@ test('one Excel workbook contains statistics and all details in two named sheets
const empty = await createAnalysisWorkbook([], []);
assert.deepEqual(empty.SheetNames, ['统计表', '详情表']);
});
test('inventory export uses assigned operations managers and a dash when unset', () => {
const vehicles = ['张三、李四', null, ' '].map(opsManagers => ({
opsManagers, customerManager: '业务负责人', customerName: '客户', status: 'Inventory',
} as VehicleListItem));
const rows = vehicleExportRows(vehicles, true);
assert.deepEqual(rows.map(row => row.运维负责人), ['张三、李四', '-', '-']);
assert.ok(rows.every(row => !('业务负责人' in row) && !('客户' in row)));
assert.equal(vehicleExportRows(vehicles)[0].业务负责人, '业务负责人');
});
+2 -2
View File
@@ -12,7 +12,7 @@ export function statisticsExportRows(labels: string[], groups: { values: string[
}));
}
export function vehicleExportRows(vehicles: VehicleListItem[]): ExportRow[] {
export function vehicleExportRows(vehicles: VehicleListItem[], inventory = false): ExportRow[] {
const status: Record<string, string> = { Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' };
const operation: Record<string, string> = { '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' };
const hasPending = vehicles.some(v => v.pendingDelivery);
@@ -21,7 +21,7 @@ export function vehicleExportRows(vehicles: VehicleListItem[]): ExportRow[] {
车牌: v.plateNumber, VIN: v.vin, 品牌: v.brandLabel, 车型: v.type, 型号: v.model,
省份: v.province, 城市: v.city || v.location,
状态: status[v.status] || v.status, 运营状态: operation[v.operationStatus || ''] || v.operationStatus,
客户: v.customerName, 签约公司: v.orgName, 部门: v.departmentName, 业务负责人: v.customerManager,
...(inventory ? { 运维负责人: v.opsManagers?.trim() || '-' } : { 客户: v.customerName, 签约公司: v.orgName, 部门: v.departmentName, 业务负责人: v.customerManager }),
所属公司: v.subjectOrg, 合同: v.contractNo,
...(hasPending ? { 待交车合同: v.pendingDelivery?.contractNo, 待交车客户: v.pendingDelivery?.customerName } : {}),
...(hasMovement ? {
@@ -48,7 +48,8 @@ function groupRows(rows: FlowDetailItem[], dimensions: Dimension[]): FlowGroup[]
group.rows.push(row);
groups.set(key, group);
}
return Array.from(groups.values()).sort((a, b) => b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN'));
const dateIndex = dimensions.indexOf('date');
return Array.from(groups.values()).sort((a, b) => (dateIndex >= 0 ? b.values[dateIndex].localeCompare(a.values[dateIndex]) : 0) || b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN'));
}
export function FlowAnalysisModal(props: FlowAnalysisModalProps) {
@@ -68,7 +69,8 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
useDialogFocus(true, dialog, close);
const rows = useMemo(() => selectedFlowDetails.filter(row => matchesType(row, selectedFlow.type)
&& ('date' in selectedFlow ? row.date === selectedFlow.date : row.date >= selectedFlow.start && row.date <= selectedFlow.end)), [selectedFlowDetails, selectedFlow]);
&& ('date' in selectedFlow ? row.date === selectedFlow.date : row.date >= selectedFlow.start && row.date <= selectedFlow.end))
.sort((a, b) => b.date.localeCompare(a.date) || (b.eventTime || '').localeCompare(a.eventTime || '')), [selectedFlowDetails, selectedFlow]);
const filterOptions = useMemo(() => Object.fromEntries(DIMENSIONS.map(({ key }) => {
const eligible = rows.filter(row => DIMENSIONS.every(({ key: other }) => other === key || !filters[other]?.length || filters[other]!.includes(dimensionValue(row, other))));
const counts = new Map<string, number>();
@@ -79,7 +81,8 @@ function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlo
const filteredRows = useMemo(() => rows.filter(row => DIMENSIONS.every(({ key }) => !filters[key]?.length || filters[key]!.includes(dimensionValue(row, key)))), [rows, filters]);
const groups = useMemo(() => {
const flat = groupRows(filteredRows, dimensions);
return mergeActive ? orderDimensionGroups(flat, dimensions.length, group => group.values, group => group.rows.length) : flat;
return mergeActive ? orderDimensionGroups(flat, dimensions.length, group => group.values, group => group.rows.length, dimensions.includes('date') ? 'label' : 'count-desc',
(a, b, index) => dimensions[index] === 'date' ? b.values[index].localeCompare(a.values[index]) : a.values[index].localeCompare(b.values[index], 'zh-CN')) : flat;
}, [filteredRows, dimensions, mergeActive]);
const detailGroup = useMemo(() => detailKey === null ? null : groups.find(group => group.key === detailKey), [groups, detailKey]);
const detailRows = detailGroup?.rows ?? EMPTY_FLOW_ROWS;
@@ -128,7 +128,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
extra: view === 'inventory' ? { 待交车: row.totals.pending, 异动: row.totals.abnormal } : undefined })),
showDetails ? rows.length : analysis.total,
);
await downloadAnalysisWorkbook(statistics, vehicleExportRows(rows), `${title}-${scopeLabel}${detailGroup ? `-${detailGroup.label}` : ''}`);
await downloadAnalysisWorkbook(statistics, vehicleExportRows(rows, view === 'inventory'), `${title}-${scopeLabel}${detailGroup ? `-${detailGroup.label}` : ''}`);
};
return <div className="asset-analysis-overlay">
<div ref={dialog} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} className={`asset-analysis-dialog ${view === 'abnormal' ? 'analysis-amber' : ''}`}>
@@ -164,7 +164,7 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
<div className="analysis-table-tools"><h3>车辆明细 <small>{number(visibleVehicles.length)} 台</small></h3><AnalysisExportButton disabled={detailBusy || !!detailError || !visibleVehicles.length} onExport={exportWorkbook} /><label className="analysis-search"><Search size={15} /><input aria-label="搜索车辆明细" placeholder="搜索车牌、VIN、型号、城市" value={search} onChange={event => { setSearch(event.target.value); }} /></label></div>
{detailBusy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在加载车辆明细…</div> : detailError ? <div className="analysis-message" role="alert"><p>{detailError}</p><button className="analysis-button" onClick={() => setDetailRetry(detailRetry + 1)}>重新加载明细</button></div> : <>
{vehicles.length !== detailGroup.count && <p className="analysis-definition">车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。</p>}
{(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? <AbnormalVehicleDetails vehicles={shownVehicles} /> : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? <PendingVehicleDetails vehicles={shownVehicles} /> : <VehicleRows vehicles={shownVehicles} />}
{(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? <AbnormalVehicleDetails vehicles={shownVehicles} /> : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? <PendingVehicleDetails vehicles={shownVehicles} /> : <VehicleRows vehicles={shownVehicles} inventory={view === 'inventory'} />}
<ProgressiveRowsStatus {...detailProgress} unit="台" />
</>}
</div>}
@@ -175,14 +175,14 @@ function AnalysisDialog({ selection, subject, close }: Props & { selection: Vehi
</div>;
}
function VehicleRows({ vehicles }: { vehicles: VehicleListItem[] }) {
function VehicleRows({ vehicles, inventory = false }: { vehicles: VehicleListItem[]; inventory?: boolean }) {
if (!vehicles.length) return <div className="analysis-message">暂无匹配车辆</div>;
const stateLabel = (vehicle: VehicleListItem) => {
const operation = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record<string, string>)[vehicle.operationStatus || ''] || vehicle.operationStatus;
const business = ({ Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' } as Record<string, string>)[vehicle.status] || vehicle.status;
return [business, operation].filter(Boolean).join(' · ');
};
return <div className="analysis-detail-table" tabIndex={0} role="region" aria-label="车辆明细表"><table><thead><tr>{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', '客户 / 签约公司', '部门 / 业务负责人', '所属公司'].map(label => <th key={label}>{label}</th>)}</tr></thead><tbody>{vehicles.map(vehicle => <tr key={vehicle.id}><td><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></td><td>{vehicle.brandLabel || '—'} · {vehicle.type}<small>{vehicle.model}</small></td><td>{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}</td><td>{stateLabel(vehicle)}</td><td><Blur>{vehicle.customerName || '—'}</Blur><small><Blur>{vehicle.orgName || '—'}</Blur></small></td><td><Blur>{vehicle.departmentName || '—'}</Blur><small><Blur>{vehicle.customerManager || '—'}</Blur></small></td><td>{vehicle.subjectOrg || '—'}</td></tr>)}</tbody></table></div>;
return <div className="analysis-detail-table" tabIndex={0} role="region" aria-label="车辆明细表"><table><thead><tr>{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', ...(inventory ? ['运维负责人'] : ['客户 / 签约公司', '部门 / 业务负责人']), '所属公司'].map(label => <th key={label}>{label}</th>)}</tr></thead><tbody>{vehicles.map(vehicle => <tr key={vehicle.id}><td><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></td><td>{vehicle.brandLabel || '—'} · {vehicle.type}<small>{vehicle.model}</small></td><td>{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}</td><td>{stateLabel(vehicle)}</td>{inventory ? <td><Blur>{vehicle.opsManagers?.trim() || '-'}</Blur></td> : <><td><Blur>{vehicle.customerName || '—'}</Blur><small><Blur>{vehicle.orgName || '—'}</Blur></small></td><td><Blur>{vehicle.departmentName || '—'}</Blur><small><Blur>{vehicle.customerManager || '—'}</Blur></small></td></>}<td>{vehicle.subjectOrg || '—'}</td></tr>)}</tbody></table></div>;
}
type WeeklyDimension = 'date' | 'contract_type';
@@ -1,4 +1,4 @@
import { CalendarDays, ChevronDown, Download, Loader2 } from 'lucide-react';
import { CalendarDays, ChevronDown, Download, ListFilter, Loader2 } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { FlowType } from '../../api';
import { FLOW_META } from '../FlowDetailModal';
@@ -65,18 +65,24 @@ export function FlowStatisticsCard({
<button
type="button"
data-testid="asset-flow-daily-toggle"
aria-expanded={flowDailyExpanded}
aria-controls="asset-flow-daily-details"
onClick={() => setFlowDailyExpanded((prev) => !prev)}
className="mt-2 flex w-full items-center justify-between rounded-xl border border-slate-100 bg-white px-3 py-1.5 text-[12px] font-black text-slate-500 transition hover:border-blue-100 hover:bg-blue-50/50 hover:text-blue-600"
className="mt-3 flex min-h-16 w-full items-center justify-between gap-3 rounded-xl border border-blue-200 bg-blue-50 px-4 py-3 text-left text-blue-700 shadow-sm transition hover:border-blue-400 hover:bg-blue-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
>
<span>{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span>
<span className="flex items-center gap-2 text-[11px] text-slate-400">
{flowStats?.daily.length ?? 0} 天
<ChevronDown size={15} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
<span className="flex min-w-0 items-center gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-white text-blue-600"><ListFilter size={21} /></span>
<span><span className="block text-sm font-black">{flowDailyExpanded ? '收起每日明细' : '展开每日明细'}</span><span className="mt-0.5 block text-[11px] font-medium text-blue-600">查看每日交还车数量 · 最新日期在前</span></span>
</span>
<span className="flex shrink-0 items-center gap-2 text-xs font-bold">
<span className="rounded-lg bg-white px-2 py-1">{flowStats?.daily.length ?? 0} 天</span>
<ChevronDown size={20} className={`transition-transform ${flowDailyExpanded ? 'rotate-180' : ''}`} />
</span>
</button>
<AnimatePresence initial={false}>
{flowDailyExpanded && (
<motion.div
id="asset-flow-daily-details"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
+1
View File
@@ -104,6 +104,7 @@ export interface InventoryTypeSummary {
}
export interface VehicleListItem {
opsManagers?: string | null;
color?: string;
operationStatus?: string | null;
inventoryKey?: string;
+7 -29
View File
@@ -28,6 +28,7 @@ export default function HydrogenDaily() {
useEffect(() => {
let cancelled = false;
setError(null);
setRows(null);
const query = pick === 'custom'
? { startDate: effectiveRange.start, endDate: effectiveRange.end }
: { range: pick };
@@ -81,34 +82,13 @@ export default function HydrogenDaily() {
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricTile icon={Fuel} label={`${scopeLabel}加氢量`} value={totalKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} unit="Kg" helper={rangeText} />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper="当前筛选口径" tone="emerald" />
<MetricTile icon={Truck} label="车辆归属" value={customer === 'external' ? '外部' : '羚牛'} helper={customer === 'external' ? '佛山南海羚牛加氢站 · 未关联车辆的订单' : '当前筛选口径'} tone="emerald" />
<MetricTile icon={TrendingUp} label="有效天数" value={`${activeDays}/${rows?.length ?? 0}`} helper={`日均 ${avgKg.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} Kg`} tone="amber" />
<MetricTile icon={Plug} label="涉及加氢站" value={stationCount} unit="站" helper="按明细站点去重" tone="slate" />
</div>
{/* 外部车辆:新系统数据还没准备好 */}
{customer === 'external' && rows !== null && totalKg === 0 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-2xl border border-slate-100 shadow-sm px-6 py-14 flex flex-col items-center text-center"
>
<div className="w-14 h-14 rounded-2xl bg-blue-50 flex items-center justify-center mb-3 relative">
<Plug size={22} className="text-blue-500" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-400 animate-ping" />
<span className="absolute -top-0.5 -right-0.5 w-3 h-3 rounded-full bg-blue-500" />
</div>
<div className="text-sm font-bold text-slate-700 mb-1">外部车辆 · 数据未就绪</div>
<div className="text-[11px] text-slate-400 max-w-[280px] leading-relaxed">
新系统的外部车辆加氢数据还在准备中
<br />
上线后此处将展示完整明细
</div>
</motion.div>
)}
{/* 时段加氢量柱图(外部车辆无数据时不渲染) */}
{!(customer === 'external' && totalKg === 0) && trendData.length > 0 && (
{/* 时段加氢量柱图 */}
{!error && trendData.length > 0 && totalKg > 0 && (
<SurfaceCard>
<div className="flex items-center justify-between px-4 pt-4 mb-2">
<span className="text-sm font-bold text-slate-700">每日加氢量</span>
@@ -184,8 +164,7 @@ export default function HydrogenDaily() {
</SurfaceCard>
)}
{/* 表格(外部车辆 + 全 0 时不渲染,由上方友好空状态替代) */}
{!(customer === 'external' && rows !== null && totalKg === 0) && (
{/* 日明细:加载失败、无订单与正常数据分别展示。 */}
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm overflow-hidden">
{/* 表头 */}
<div className="grid grid-cols-[minmax(0,1fr)_84px_80px] md:grid-cols-[minmax(0,1fr)_140px_120px_104px] gap-2 md:gap-3 px-3 py-2 bg-slate-50 text-[11px] font-bold text-slate-500">
@@ -206,8 +185,8 @@ export default function HydrogenDaily() {
<div className="p-3"><ErrorState message={error} /></div>
) : rows === null ? (
<div className="p-3"><LoadingState label="正在加载加氢明细" /></div>
) : rows.length === 0 ? (
<div className="p-3"><EmptyState title="暂无加氢数据" description="请切换时间范围或车辆归属" /></div>
) : rows.length === 0 || totalKg === 0 ? (
<div className="p-3"><EmptyState title="所选时段暂无加氢数据" description={customer === 'external' ? '佛山南海羚牛加氢站在该时段没有未关联车辆的加氢订单,请切换时间范围' : '请切换时间范围或车辆归属'} /></div>
) : rows.map(r => {
const open = expanded.has(r.date);
const isAbnormal = Math.abs(r.chainPct) >= 0.3;
@@ -270,7 +249,6 @@ export default function HydrogenDaily() {
);
})}
</div>
)}
</div>
);
+14 -15
View File
@@ -1,3 +1,4 @@
import { hasDailyMileage, recentMileageStats } from './mileage-display';
import { useEffect, useMemo, useState } from 'react';
import { motion, AnimatePresence, useDragControls } from 'motion/react';
import { X, Truck } from 'lucide-react';
@@ -97,13 +98,10 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
// 排除"今日"列(数据未到位时易引起误读)
const historyDays = useMemo(() => days.filter(d => !isToday(d.date)), [days]);
const stats = useMemo(() => {
const totalKm = historyDays.reduce((s, d) => s + d.dailyKm, 0);
const synced = historyDays.filter(d => d.isDataSynced).length;
const avg = synced > 0 ? totalKm / synced : 0;
const max = Math.max(1, ...historyDays.map(d => d.dailyKm));
return { totalKm, synced, avg, max, totalDays: historyDays.length };
}, [historyDays]);
const stats = useMemo(() => recentMileageStats(historyDays), [historyDays]);
const chartDays = useMemo(() => historyDays.map(day => ({
...day, dailyKm: hasDailyMileage(day) ? day.dailyKm : null,
})), [historyDays]);
// 骨架天数:根据区间预估
const skeletonCount = useMemo(() => {
@@ -206,14 +204,14 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
<div className="text-[9px] font-bold text-slate-400 uppercase">区间合计</div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-14 bg-slate-200 rounded animate-pulse align-middle" />
: <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
: stats.synced === 0 ? '—' : <>{Math.round(stats.totalKm).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
<div className="text-[9px] font-bold text-slate-400 uppercase">日均</div>
<div className="text-base font-black text-slate-900 leading-tight">
{loading ? <span className="inline-block h-4 w-10 bg-slate-200 rounded animate-pulse align-middle" />
: <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
: stats.synced === 0 ? '—' : <>{Math.round(stats.avg).toLocaleString()}<span className="text-[9px] font-bold text-slate-400 ml-0.5">km</span></>}
</div>
</div>
<div className="bg-slate-50 rounded-xl p-2.5">
@@ -237,7 +235,7 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
<SkeletonBars count={Math.min(skeletonCount, 30)} />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={historyDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<BarChart data={chartDays} margin={{ top: 8, right: 8, bottom: 0, left: 0 }}>
<XAxis
dataKey="date"
tickFormatter={(d) => formatLabel(d, range)}
@@ -249,7 +247,8 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
/>
<YAxis hide />
<Tooltip
formatter={(v) => [`${Math.round(Number(v) || 0).toLocaleString()} km`, '行驶里程']}
filterNull={false}
formatter={(v) => [v == null ? '—' : `${Math.round(Number(v)).toLocaleString()} km`, '行驶里程']}
labelFormatter={(d) => `日期 ${d}`}
contentStyle={{ borderRadius: 12, fontSize: 11, padding: '4px 8px' }}
cursor={{ fill: 'rgba(59, 130, 246, 0.06)' }}
@@ -306,15 +305,15 @@ export default function VehicleDetailModal({ vehicle, onClose, sourcePriority }:
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${(d.dailyKm / stats.max) * 100}%` }}
animate={{ width: `${hasDailyMileage(d) ? (d.dailyKm / stats.max) * 100 : 0}%` }}
transition={{ delay: Math.min(i * 0.012, 0.4) + 0.1, duration: 0.4 }}
className={`h-full rounded-full ${d.isDataSynced ? 'bg-blue-500' : 'bg-slate-200'}`}
/>
</div>
<span className={`text-[11px] font-mono font-bold w-20 text-right ${d.isDataSynced ? 'text-slate-700' : 'text-amber-500/60'}`}>
{d.isDataSynced
<span className={`text-[11px] font-mono font-bold w-20 text-right ${hasDailyMileage(d) ? 'text-slate-700' : 'text-slate-400'}`}>
{hasDailyMileage(d)
? <>{Math.round(d.dailyKm).toLocaleString()} <span className="text-[9px] text-slate-400">km</span></>
: <span className="text-[9px]">未对接</span>}
: <span>—</span>}
</span>
</div>
</motion.div>
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { monitoringMileageValue, recentMileageStats } from './mileage-display';
import type { VehicleRecentDay } from './api';
import type { MonitoringVehicle } from './types';
const day = (dailyKm: number, isDataSynced = true): VehicleRecentDay => ({
date: '2026-10-12', dailyKm, isDataSynced, sourceProtocol: null, sourceCategory: null,
});
test('详情合计、平均值和有数据天排除缺失日,保留真实零里程', () => {
assert.deepEqual(recentMileageStats([day(100), day(900, false), day(200), day(0)]), {
totalKm: 300, synced: 3, avg: 100, max: 200, totalDays: 4,
});
assert.equal(recentMileageStats([day(0, false)]).synced, 0);
});
test('列表全区间缺失时显示横杠,即使存在累计里程', () => {
const vehicle = { dailyKm: 0, isDataSynced: false, totalKm: 64413.6 } as MonitoringVehicle;
assert.equal(monitoringMileageValue(vehicle), '—');
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true }), 0);
assert.equal(monitoringMileageValue({ ...vehicle, isDataSynced: true, dailyKm: 5417 }), 5417);
});
+24
View File
@@ -0,0 +1,24 @@
import type { MonitoringVehicle } from './types';
import type { VehicleRecentDay } from './api';
export function monitoringMileageValue(vehicle: MonitoringVehicle): number | string {
if (vehicle.mileageAnomaly) return '数据异常';
if (!vehicle.isDataSynced) return '—';
return Math.max(0, vehicle.dailyKm || 0);
}
export function hasDailyMileage(day: VehicleRecentDay): boolean {
return day.isDataSynced && Number.isFinite(day.dailyKm) && day.dailyKm >= 0;
}
export function recentMileageStats(days: VehicleRecentDay[]) {
const validDays = days.filter(hasDailyMileage);
const totalKm = validDays.reduce((sum, day) => sum + day.dailyKm, 0);
return {
totalKm,
synced: validDays.length,
avg: validDays.length ? totalKm / validDays.length : 0,
max: Math.max(1, ...validDays.map(day => day.dailyKm)),
totalDays: days.length,
};
}
@@ -1,3 +1,4 @@
import { monitoringMileageValue } from '../../mileage-display';
import type { Dispatch, SetStateAction } from 'react';
import { ArrowDown, ArrowUp, Minimize2, RotateCcw } from 'lucide-react';
import { motion } from 'motion/react';
@@ -283,6 +284,8 @@ export default function FullscreenMonitoring({
<tbody className="divide-y divide-slate-800/30">
{fullscreenVehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const mileageValue = monitoringMileageValue(v);
const hasMileage = typeof mileageValue === 'number';
const statisticTime = vehicleStatisticTime(v, relativeNow);
const sourceDisplay = vehicleSourceDisplay(v);
return (
@@ -310,7 +313,7 @@ export default function FullscreenMonitoring({
<td className="px-3 py-2 text-[11px] text-slate-400">{v.department || '-'}</td>
<td className="px-3 py-2 text-right">
<span className={`text-xs font-mono font-bold ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-400' : 'text-blue-400') : 'text-amber-400'}`}>
{(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>{v.mileageAnomaly ? '' : 'km'}</span></> : <span className="text-[8px] text-amber-500/50">未对接</span>}
<>{mileageValue.toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400/70' : 'text-slate-500'}`}>{hasMileage ? 'km' : ''}</span></>
</span>
</td>
<td className="px-3 py-2 text-right">
@@ -1,3 +1,4 @@
import { monitoringMileageValue } from '../../mileage-display';
import type { RefObject } from 'react';
import { Truck } from 'lucide-react';
import { motion } from 'motion/react';
@@ -54,6 +55,8 @@ export default function VehicleList({
<div className="grid grid-cols-1 gap-1.5">
{vehicles.map((v) => {
const highMileageAlert = isHighMileageAlert(v);
const mileageValue = monitoringMileageValue(v);
const hasMileage = typeof mileageValue === 'number';
const statisticTime = vehicleStatisticTime(v, relativeNow);
const sourceDisplay = vehicleSourceDisplay(v);
return (
@@ -102,8 +105,8 @@ export default function VehicleList({
</div>
<div className="hidden text-right md:block">
<div className={`text-sm font-black tabular-nums ${highMileageAlert ? 'text-red-600' : 'text-blue-600'}`}>
{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()}
<span className="ml-1 text-[9px] font-bold text-slate-400">{v.mileageAnomaly ? '' : 'km'}</span>
{mileageValue.toLocaleString()}
<span className="ml-1 text-[9px] font-bold text-slate-400">{hasMileage ? 'km' : ''}</span>
</div>
</div>
<div className="hidden text-right md:block">
@@ -119,7 +122,7 @@ export default function VehicleList({
)}
<span className="text-[7px] font-black text-blue-600/50 bg-blue-50 w-3 h-3 rounded flex items-center justify-center leading-none" title={isRangeMode ? '区间内里程' : '当日里程'}>区</span>
<div className={`text-sm font-black leading-none ${(v.isDataSynced || v.totalKm != null) ? (highMileageAlert ? 'text-red-600' : 'text-blue-600') : 'text-amber-600'}`}>
{(v.isDataSynced || v.totalKm != null) ? <>{v.mileageAnomaly ? '数据异常' : Math.max(0, v.dailyKm || 0).toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>{v.mileageAnomaly ? '' : 'km'}</span></> : <span className="text-[7px] text-amber-500/70">未对接</span>}
<>{mileageValue.toLocaleString()} <span className={`text-[8px] ${highMileageAlert ? 'text-red-400' : 'text-slate-400'}`}>{hasMileage ? 'km' : ''}</span></>
</div>
</div>
<div className="flex items-center gap-1">
+1 -1
View File
@@ -7,7 +7,7 @@ export interface MonitoringVehicle {
vin: string;
dailyKm: number;
mileageAnomaly?: string | null;
dailyMileage?: Record<string, number>;
dailyMileage?: Record<string, number | null>;
totalKm: number | null;
source: string;
sourceProtocol: MileageSourceProtocol | null;
+5 -5
View File
@@ -1,3 +1,4 @@
import { monitoringMileageValue } from './mileage-display';
import * as XLSX from 'xlsx';
import type { MonitoringVehicle } from './types';
@@ -27,8 +28,7 @@ function statusLabel(v: MonitoringVehicle): string {
function mileageCell(v: MonitoringVehicle, kind: 'today' | 'total'): string | number {
if (kind === 'today') {
if (!v.isDataSynced && v.totalKm == null) return '未对接';
return Math.max(0, v.dailyKm || 0);
return monitoringMileageValue(v);
}
return v.totalKm != null ? v.totalKm : '未对接';
}
@@ -64,7 +64,7 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
v.rentStatus || '',
v.region || '',
];
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? 0);
const dailyCols = dayKeys.map(day => v.dailyMileage?.[day] ?? '—');
return [
...baseRow,
...dailyCols,
@@ -135,8 +135,8 @@ export function exportMileageXlsx(vehicles: MonitoringVehicle[], ctx: ExportCont
v.project || '',
v.rentStatus || '',
v.region || '',
...dayKeys.map(day => v.dailyMileage?.[day] || 0),
Math.max(0, v.dailyKm || 0),
...dayKeys.map(day => v.dailyMileage?.[day] ?? '—'),
mileageCell(v, 'today'),
v.totalKm != null ? v.totalKm : '',
]),
];
+1 -1
View File
@@ -39,7 +39,7 @@ export function registerHydrogenDailyRoute(
HYDROGEN_BASE_WHERE_B,
`b.${HYDROGEN_LOCAL} >= '${HYDROGEN_MIN_DATE}'`,
dateRangeClause(`b.${HYDROGEN_LOCAL}`),
customerClause(customer).replaceAll('customer_price', 'b.customer_price').replaceAll('fee_total', 'b.fee_total'),
customerClause(customer),
].join(' AND ');
// 站点级聚合(每日 × 每站)。前端组装成 day → stations
+26 -4
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import {
customerClause,
dateRangeClause,
@@ -7,10 +8,31 @@ import {
resolveDateRange,
} from './query-model.js';
test('客户范围继续让外部车辆返回空数据', () => {
assert.equal(customerClause('external'), '1=0');
assert.equal(customerClause('lingniu'), '1=1');
assert.equal(customerClause('all'), '1=1');
test('外部车辆只取佛山羚牛站未关联车辆的订单,车牌文字和收费不影响分类', () => {
const db = new DatabaseSync(':memory:');
try {
db.exec(`CREATE TABLE orders (
id INTEGER, station_id INTEGER, vehicle_id INTEGER,
license_plate TEXT, customer_price REAL, del_flag TEXT
);
INSERT INTO orders VALUES
(1, 341, NULL, '粤A外部', 38, '0'),
(2, 341, NULL, '', 0, '0'),
(3, 341, 123, '粤A羚牛', 38, '0'),
(4, 52, NULL, '粤A其他站', 38, '0'),
(5, NULL, NULL, '', 0, '0'),
(6, NULL, 456, '粤A未关联站点', 0, '0'),
(7, 341, NULL, '粤A已删除', 38, '1');`);
const ids = (kind: 'external' | 'lingniu' | 'all') => db
.prepare(`SELECT b.id FROM orders b WHERE b.del_flag = '0' AND ${customerClause(kind)} ORDER BY b.id`)
.all().map(row => row.id);
assert.deepEqual(ids('external'), [1, 2]);
assert.deepEqual(ids('lingniu'), [3, 4, 5, 6]);
assert.deepEqual(ids('all'), [1, 2, 3, 4, 5, 6]);
assert.deepEqual([...ids('external'), ...ids('lingniu')].sort(), ids('all'));
} finally {
db.close();
}
});
test('能源日期快捷区间继续按本地自然日计算', () => {
+7 -3
View File
@@ -8,10 +8,14 @@ export interface EnergyDateRange {
const YMD_RE = /^\d{4}-\d{2}-\d{2}$/;
// 新账本目前只承载羚牛车辆订单;外部车辆仍需返回空集合而不是混用羚牛数据。
// 主数据 ID 341:佛山南海羚牛加氢站。车牌文字存在不代表已关联车辆。
const FOSHAN_LINGNIU_STATION_ID = 341;
// 日明细账本使用别名 b;两类互斥,其他订单继续保留在原羚牛范围内。
export function customerClause(customer: CustomerKind): string {
if (customer === 'external') return '1=0';
if (customer === 'lingniu') return '1=1';
const external = `(COALESCE(b.station_id, 0) = ${FOSHAN_LINGNIU_STATION_ID} AND b.vehicle_id IS NULL)`;
if (customer === 'external') return external;
if (customer === 'lingniu') return `NOT ${external}`;
return '1=1';
}
+7 -2
View File
@@ -105,6 +105,11 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
'/hydrogen/daily?startDate=2025-01-01&endDate=2025-01-02&customer=external&force=1',
);
assert.equal(dailyResponse.status, 200);
assert.deepEqual(await dailyResponse.json(), [
{ date: '2025-01-02', totalKg: 10, chainPct: 0, customerType: 'external',
stations: [{ name: '测试站', pricePerKg: 35, kg: 10, chainPct: 0 }] },
{ date: '2025-01-01', totalKg: 0, chainPct: 0, customerType: 'external', stations: [] },
]);
const electricApp = new Hono();
const electricDependencies = {
@@ -127,7 +132,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
{ key: 'electric/monthly?customer=external&start=2025-01-01&end=2025-01-02', force: true },
]);
// 指纹来自拆分前的 13 条 SQL,连空白符变化也会使测试失败。
// 保留查询契约指纹;日明细已更新为佛山羚牛站未关联车辆的外部订单筛选。
assert.deepEqual(
[...hydrogenCalls, ...electricCalls].map(call => sqlHash(call.sql)),
[
@@ -138,7 +143,7 @@ test('四个处理器保持缓存和参数契约,加氢站点使用新主数
'256ac8309d5a',
'dac9e86fc527',
'a71da8f05765',
'24b23780a259',
'd1620280f520',
'226fd8f950a1',
'6e4031b10926',
'4fc77463294c',
+3 -147
View File
@@ -4,11 +4,8 @@ import { dirname, join } from 'node:path';
import pool from '../../db.js';
import { fetchVehicleInfoMap } from './vehicle-info.js';
import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js';
import {
sourceCategoryFromProtocol,
type MileageSourceCategory,
type OneOsProtocol,
} from './source-policy.js';
import type { OneOsProtocol } from './source-policy.js';
import { buildRangeMileage, type RangeMileageResult } from './range-model.js';
import {
buildMonitoringFilters,
buildPlateTargetNamesMap,
@@ -18,8 +15,6 @@ import {
mileageDatesBetween,
previousMileageDate,
toMileageRows,
type DailyMileageRow,
type MileageRow,
type TargetRow,
} from './cache-model.js';
import type { CachedVehicle, MonitoringCache, MonitoringFilters } from './types.js';
@@ -43,13 +38,6 @@ function shanghaiDate(): string {
}).format(new Date());
}
export interface RangeMileageResult {
vehicles: CachedVehicle[];
dailyTotals: { date: string; totalKm: number }[];
start: string;
end: string;
}
async function fetchTargetRows(): Promise<TargetRow[]> {
return pool.execute(
`SELECT t.id, t.target_name, v.plate_number
@@ -131,23 +119,6 @@ export async function queryRangeMileage(
endDate: string,
protocolPriority?: OneOsProtocol[],
): Promise<RangeMileageResult> {
if (startDate === endDate) {
const vehicles = (await queryDateMileage(startDate, protocolPriority)).map(vehicle => ({
...vehicle,
dailyMileage: { [startDate]: vehicle.dailyKm },
dailySourceProtocols: { [startDate]: vehicle.sourceProtocol },
}));
return {
vehicles,
dailyTotals: [{
date: startDate,
totalKm: Math.round(vehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)),
}],
start: startDate,
end: endDate,
};
}
const days = mileageDatesBetween(startDate, endDate);
const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([
fetchOneOsMileageDates(days, undefined, protocolPriority),
@@ -156,122 +127,7 @@ export async function queryRangeMileage(
fetchVehicleInfoMap(),
fetchTargetRows(),
]);
const dailyRows: DailyMileageRow[] = [];
for (const [date, apiRows] of apiRowsByDate) {
for (const row of apiRows) {
dailyRows.push({
plate: row.plateNumber,
vin: row.vin,
date,
daily_km: row.dailyMileageKm,
source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE',
mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null,
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
});
}
}
const perVehicleDaily = new Map<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, MileageRow>();
const dailyTotals = new Map<string, number>();
const bestDailyRows = new Map<string, DailyMileageRow>();
for (const day of days) dailyTotals.set(day, 0);
for (const row of dailyRows) {
const key = `${row.plate}\u0000${row.date}`;
const km = Math.max(0, Number(row.daily_km) || 0);
const existing = bestDailyRows.get(key);
if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) {
bestDailyRows.set(key, row);
}
}
for (const row of bestDailyRows.values()) {
const km = Math.max(0, Number(row.daily_km) || 0);
const date = row.date;
const plate = row.plate;
dailyTotals.set(date, (dailyTotals.get(date) || 0) + km);
const daily = perVehicleDaily.get(plate) || {};
daily[date] = km;
perVehicleDaily.set(plate, daily);
const dailySources = perVehicleDailySources.get(plate) || {};
dailySources[date] = row.source_protocol;
perVehicleDailySources.set(plate, dailySources);
const category = sourceCategoryFromProtocol(row.source_protocol);
if (category) {
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
categories.add(category);
perVehicleSourceCategories.set(plate, categories);
}
const existing = perVehicleSum.get(plate);
perVehicleSum.set(plate, {
plate,
vin: existing?.vin || row.vin || '',
daily_km: String((Number(existing?.daily_km) || 0) + km),
total_km: null,
mileage_anomaly: existing?.mileage_anomaly || row.mileage_anomaly || null,
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
source_protocol: row.source_protocol || existing?.source_protocol || null,
data_time: row.data_time || existing?.data_time || null,
calculated_at: row.calculated_at || existing?.calculated_at || null,
updated_at: row.updated_at || existing?.updated_at || null,
});
}
const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row]));
for (const [plate, aggregate] of perVehicleSum) {
const endDateRow = endDateMap.get(plate);
if (!endDateRow) continue;
perVehicleSum.set(plate, {
...aggregate,
vin: endDateRow.vin || aggregate.vin,
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
source_protocol: endDateRow.sourceProtocol || aggregate.source_protocol,
data_time: endDateRow.dataTime || aggregate.data_time,
updated_at: endDateRow.updatedAt || aggregate.updated_at,
});
}
const yesterdayMap = dailyMileageMap(yesterdayRows);
const vehicles = mergeMonitoringVehicles(
Array.from(perVehicleSum.values()),
infoMap,
yesterdayMap,
buildPlateTargetNamesMap(targetRows),
regionMap,
).map(vehicle => {
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
const categories = perVehicleSourceCategories.get(vehicle.plate);
const completedDailyMileage: Record<string, number> = {};
const completedDailySources: Record<string, OneOsProtocol | null> = {};
for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0;
for (const day of days) completedDailySources[day] = dailySources[day] || null;
return {
...vehicle,
dailyMileage: completedDailyMileage,
dailySourceProtocols: completedDailySources,
sourceCategory: categories && categories.size > 1
? 'MIXED' as const
: categories?.values().next().value || vehicle.sourceCategory,
};
});
return {
vehicles,
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
start: startDate,
end: endDate,
};
return buildRangeMileage(startDate, endDate, apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows, regionMap);
}
export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters {
+6
View File
@@ -14,6 +14,12 @@ export interface OneOsDailyMileage {
sourceProtocol: OneOsProtocol | null;
}
/** Missing or unusable daily mileage must not contribute to interval statistics. */
export function validDailyMileage(row: OneOsDailyMileage | undefined): number | null {
const km = row?.dailyMileageKm;
return row?.status === 'NORMAL' && km != null && Number.isFinite(km) && km >= 0 ? km : null;
}
const MILEAGE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
export function assertMileageDate(date: string): void {
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildRangeMileage } from './range-model.js';
import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js';
import type { VehicleInfoRow } from './types.js';
const info: VehicleInfoRow = {
plate: '粤A06619F', vin: 'test', customer: null, department: null, manager: null,
manager_id: null, rent_status: null, entity: null, project: null,
inventory_location: null, brand_label: null,
};
const row = (date: string, km: number | null, status: OneOsDailyMileage['status'] = 'NORMAL'): OneOsDailyMileage => ({
plateNumber: info.plate, vin: 'test', date, dailyMileageKm: km, status,
totalMileageKm: 64413.6, dataTime: null, calculatedAt: null, updatedAt: null,
sourceProtocol: 'GB32960',
});
function aggregate(rows: OneOsDailyMileage[], start = '2026-10-09', end = '2026-10-12') {
const byDate = new Map<string, OneOsDailyMileage[]>();
for (const item of rows) byDate.set(item.date, [...(byDate.get(item.date) || []), item]);
return buildRangeMileage(start, end, byDate, rows.filter(item => item.date === end), [], new Map([[info.plate, info]]), [], {});
}
test('缺失或异常的一天不覆盖其余日期合计,明细保留空值', () => {
for (const status of ['NO_DATA', 'DATA_ANOMALY', 'NORMAL'] as const) {
const result = aggregate([
row('2026-10-09', 1067), row('2026-10-10', null, status),
row('2026-10-11', 2212), row('2026-10-12', 2138),
]);
const vehicle = result.vehicles[0];
assert.equal(vehicle.dailyKm, 5417);
assert.equal(vehicle.mileageAnomaly, null);
assert.equal(vehicle.isDataSynced, true);
assert.equal(vehicle.totalKm, 64413.6);
assert.deepEqual(vehicle.dailyMileage, {
'2026-10-09': 1067, '2026-10-10': null, '2026-10-11': 2212, '2026-10-12': 2138,
});
assert.equal(result.dailyTotals.reduce((sum, day) => sum + day.totalKm, 0), 5417);
}
});
test('整段无日里程保留缺失状态及累计里程,不能伪装为有效零里程', () => {
const vehicle = aggregate([row('2026-10-12', null, 'DATA_ANOMALY')]).vehicles[0];
assert.equal(vehicle.dailyKm, 0);
assert.equal(vehicle.isDataSynced, false);
assert.equal(vehicle.mileageAnomaly, null);
assert.equal(vehicle.totalKm, 64413.6);
assert.deepEqual(Object.values(vehicle.dailyMileage!), [null, null, null, null]);
});
test('真实零里程是有效数据,缺失日期及重复无效记录不影响它', () => {
const vehicle = aggregate([row('2026-10-09', null, 'NO_DATA'), row('2026-10-09', 0)]).vehicles[0];
assert.equal(vehicle.isDataSynced, true);
assert.equal(vehicle.dailyMileage?.['2026-10-09'], 0);
assert.equal(vehicle.dailyMileage?.['2026-10-10'], null);
});
test('单日区间同样跳过缺失数据', () => {
const vehicle = aggregate([row('2026-10-12', null, 'DATA_ANOMALY')], '2026-10-12').vehicles[0];
assert.equal(vehicle.mileageAnomaly, null);
assert.equal(vehicle.isDataSynced, false);
assert.deepEqual(vehicle.dailyMileage, { '2026-10-12': null });
});
test('区间汇总和单车明细共用有效里程口径', () => {
assert.equal(validDailyMileage(undefined), null);
assert.equal(validDailyMileage(row('2026-10-12', null)), null);
assert.equal(validDailyMileage(row('2026-10-12', 123, 'DATA_ANOMALY')), null);
assert.equal(validDailyMileage(row('2026-10-12', NaN)), null);
assert.equal(validDailyMileage(row('2026-10-12', 0)), 0);
assert.equal(validDailyMileage(row('2026-10-12', 12.3)), 12.3);
});
+148
View File
@@ -0,0 +1,148 @@
import { sourceCategoryFromProtocol, type MileageSourceCategory, type OneOsProtocol } from './source-policy.js';
import { validDailyMileage, type OneOsDailyMileage } from './oneos-model.js';
import {
buildPlateTargetNamesMap, dailyMileageMap, mergeMonitoringVehicles, mileageDatesBetween,
type DailyMileageRow, type MileageRow, type TargetRow,
} from './cache-model.js';
import type { CachedVehicle, VehicleInfoRow } from './types.js';
export interface RangeMileageResult {
vehicles: CachedVehicle[];
dailyTotals: { date: string; totalKm: number }[];
start: string;
end: string;
}
export function buildRangeMileage(
startDate: string,
endDate: string,
apiRowsByDate: Map<string, OneOsDailyMileage[]>,
endDateRows: OneOsDailyMileage[],
yesterdayRows: OneOsDailyMileage[],
infoMap: Map<string, VehicleInfoRow>,
targetRows: TargetRow[],
regionMap: Record<string, string>,
): RangeMileageResult {
const days = mileageDatesBetween(startDate, endDate);
const dailyRows: DailyMileageRow[] = [];
for (const [date, apiRows] of apiRowsByDate) {
for (const row of apiRows) {
dailyRows.push({
plate: row.plateNumber,
vin: row.vin,
date,
daily_km: validDailyMileage(row),
source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE',
mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null,
source_protocol: row.sourceProtocol,
data_time: row.dataTime,
calculated_at: row.calculatedAt,
updated_at: row.updatedAt,
});
}
}
const perVehicleDaily = new Map<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, MileageRow>();
const dailyTotals = new Map<string, number>();
const bestDailyRows = new Map<string, DailyMileageRow>();
for (const day of days) dailyTotals.set(day, 0);
for (const row of dailyRows) {
if (row.daily_km == null) continue;
const key = `${row.plate}\u0000${row.date}`;
const km = Math.max(0, Number(row.daily_km) || 0);
const existing = bestDailyRows.get(key);
if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) {
bestDailyRows.set(key, row);
}
}
for (const row of bestDailyRows.values()) {
const km = Math.max(0, Number(row.daily_km) || 0);
const date = row.date;
const plate = row.plate;
dailyTotals.set(date, (dailyTotals.get(date) || 0) + km);
const daily = perVehicleDaily.get(plate) || {};
daily[date] = km;
perVehicleDaily.set(plate, daily);
const dailySources = perVehicleDailySources.get(plate) || {};
dailySources[date] = row.source_protocol;
perVehicleDailySources.set(plate, dailySources);
const category = sourceCategoryFromProtocol(row.source_protocol);
if (category) {
const categories = perVehicleSourceCategories.get(plate) || new Set<MileageSourceCategory>();
categories.add(category);
perVehicleSourceCategories.set(plate, categories);
}
const existing = perVehicleSum.get(plate);
perVehicleSum.set(plate, {
plate,
vin: existing?.vin || row.vin || '',
daily_km: String((Number(existing?.daily_km) || 0) + km),
total_km: null,
mileage_anomaly: null,
source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'),
source_protocol: row.source_protocol || existing?.source_protocol || null,
data_time: row.data_time || existing?.data_time || null,
calculated_at: row.calculated_at || existing?.calculated_at || null,
updated_at: row.updated_at || existing?.updated_at || null,
});
}
const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row]));
// Preserve end-of-range odometer data even when every daily mileage is missing.
for (const [plate, endDateRow] of endDateMap) {
const aggregate = perVehicleSum.get(plate);
perVehicleSum.set(plate, {
plate,
daily_km: aggregate?.daily_km || '0',
source: aggregate?.source || 'NONE',
mileage_anomaly: null,
vin: endDateRow.vin || aggregate?.vin || '',
total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm),
source_protocol: endDateRow.sourceProtocol || aggregate?.source_protocol || null,
data_time: endDateRow.dataTime || aggregate?.data_time || null,
calculated_at: endDateRow.calculatedAt || aggregate?.calculated_at || null,
updated_at: endDateRow.updatedAt || aggregate?.updated_at || null,
});
}
const yesterdayMap = dailyMileageMap(yesterdayRows);
const vehicles = mergeMonitoringVehicles(
Array.from(perVehicleSum.values()),
infoMap,
yesterdayMap,
buildPlateTargetNamesMap(targetRows),
regionMap,
).map(vehicle => {
const dailyMileage = perVehicleDaily.get(vehicle.plate) || {};
const dailySources = perVehicleDailySources.get(vehicle.plate) || {};
const categories = perVehicleSourceCategories.get(vehicle.plate);
const completedDailyMileage: Record<string, number | null> = {};
const completedDailySources: Record<string, OneOsProtocol | null> = {};
for (const day of days) completedDailyMileage[day] = dailyMileage[day] ?? null;
for (const day of days) completedDailySources[day] = dailySources[day] || null;
return {
...vehicle,
dailyMileage: completedDailyMileage,
dailySourceProtocols: completedDailySources,
sourceCategory: categories && categories.size > 1
? 'MIXED' as const
: categories?.values().next().value || vehicle.sourceCategory,
};
});
return {
vehicles,
dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })),
start: startDate,
end: endDate,
};
}
+1 -1
View File
@@ -4,7 +4,7 @@ export interface CachedVehicle {
vin: string;
dailyKm: number;
mileageAnomaly?: string | null;
dailyMileage?: Record<string, number>;
dailyMileage?: Record<string, number | null>;
totalKm: number | null;
source: string;
sourceProtocol: 'GB32960' | 'MQTT' | 'JT808' | null;
+3 -2
View File
@@ -1,4 +1,5 @@
import { Hono } from 'hono';
import { validDailyMileage } from './oneos-model.js';
import { fetchOneOsMileageDates } from './oneos-api.js';
import {
parseMileageSourcePriority,
@@ -81,8 +82,8 @@ app.get('/:plate/recent', async (c) => {
const hit = (rowsByDate.get(key) || []).find(row => row.plateNumber === plate);
result.push({
date: key,
dailyKm: hit?.status === 'NORMAL' ? (hit.dailyMileageKm || 0) : 0,
isDataSynced: hit?.status === 'NORMAL',
dailyKm: validDailyMileage(hit) ?? 0,
isDataSynced: validDailyMileage(hit) != null,
sourceProtocol: hit?.sourceProtocol || null,
sourceCategory: sourceCategoryFromProtocol(hit?.sourceProtocol || null),
});
+1
View File
@@ -544,6 +544,7 @@ function vehicleListItem(v: Vehicle) {
subjectOrg: v.subjectOrg,
departmentName: v.departmentName,
customerManager: v.customerManager,
opsManagers: v.opsManagers || null,
brandLabel: v.brandLabel,
orgName: v.orgName,
};
+1
View File
@@ -199,6 +199,7 @@ test('transforms database rows without changing the route model contract', () =>
subjectOrg: '主体',
projectName: '项目',
customerManager: '经理',
opsManagers: null,
managerId: '9',
brandLabel: '现代',
});
+1
View File
@@ -255,6 +255,7 @@ export function transformRow(row: VehicleRow): Vehicle {
subjectOrg: row.主体,
projectName: row.项目名称,
customerManager: row.客户经理,
opsManagers: row.ops_managers?.trim() || null,
managerId: row.经理ID || null,
brandLabel: row.车辆品牌Label,
};
+5
View File
@@ -100,6 +100,11 @@ export const MAIN_SQL = `SELECT
NULLIF(vi.registered_ownership, '') AS 主体,
vor.project_name AS 项目名称,
vor.business_manager AS 客户经理,
(SELECT GROUP_CONCAT(
COALESCE(NULLIF(TRIM(ops.nick_name), ''), NULLIF(TRIM(ops.user_name), ''), CAST(ops.user_id AS CHAR))
ORDER BY ops.id SEPARATOR '、')
FROM vehicle_ops_manager_rel ops
WHERE ops.vehicle_id = vi.id AND ops.del_flag = '0') AS ops_managers,
CAST(COALESCE(c.business_manager_id, vi.business_id) AS CHAR) AS 经理ID
FROM vehicle_info vi
LEFT JOIN vehicle_status vs
+2
View File
@@ -1,6 +1,7 @@
import type { AbnormalMoveDetail } from '../shared/assets/abnormal-move.js';
export interface VehicleRow {
ops_managers?: string | null;
pending_delivery_id?: string | null;
pending_contract_no?: string | null;
pending_customer_name?: string | null;
@@ -42,6 +43,7 @@ export interface VehicleRow {
}
export interface Vehicle {
opsManagers?: string | null;
pendingDelivery?: { contractNo: string | null; customerName: string | null } | null;
abnormalMove?: AbnormalMoveDetail | null;
id: string;