Compare commits
4
Commits
main
...
00c09d7452
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00c09d7452 | ||
|
|
5751d1af15 | ||
|
|
24984fe033 | ||
|
|
7dd7e67602 |
Generated
+2
-2
@@ -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
@@ -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].业务负责人, '业务负责人');
|
||||
});
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -104,6 +104,7 @@ export interface InventoryTypeSummary {
|
||||
}
|
||||
|
||||
export interface VehicleListItem {
|
||||
opsManagers?: string | null;
|
||||
color?: string;
|
||||
operationStatus?: string | null;
|
||||
inventoryKey?: string;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 : '',
|
||||
]),
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('能源日期快捷区间继续按本地自然日计算', () => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -199,6 +199,7 @@ test('transforms database rows without changing the route model contract', () =>
|
||||
subjectOrg: '主体',
|
||||
projectName: '项目',
|
||||
customerManager: '经理',
|
||||
opsManagers: null,
|
||||
managerId: '9',
|
||||
brandLabel: '现代',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user