refactor: build drillable mileage daily report

This commit is contained in:
kkfluous
2026-08-07 14:27:19 +08:00
parent 91031bd034
commit b2044109e3
4 changed files with 690 additions and 121 deletions
+340 -10
View File
@@ -1,18 +1,348 @@
import { FileText } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react';
import { SurfaceCard } from '../../components/ui/surface'; import { motion } from 'motion/react';
import {
Activity,
AlertTriangle,
CalendarDays,
ChevronRight,
CircleGauge,
Database,
RefreshCw,
Route,
Truck,
} from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import Blur from '../../components/Blur';
import { MetricTile, SurfaceCard } from '../../components/ui/surface';
import {
fetchDailyReport,
fetchDailyReportTrend,
type DailyReportData,
type DailyReportVehicle,
} from './api';
import { buildMileageDrillUrl } from './drill-context';
function fmtKm(value: number): string {
if (value >= 10000) return `${(value / 10000).toFixed(2)}`;
return value.toLocaleString(undefined, { maximumFractionDigits: 1 });
}
function fmtTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
function VehicleRow({
vehicle,
mode,
onClick,
}: {
vehicle: DailyReportVehicle;
mode: 'top' | 'risk';
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className="grid w-full grid-cols-[minmax(0,1fr)_auto_18px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50"
>
<span className="min-w-0">
<span className="flex items-center gap-2">
<span className="truncate font-mono text-xs font-black text-slate-800"><Blur>{vehicle.plate}</Blur></span>
<span className={`h-1.5 w-1.5 shrink-0 rounded-full ${vehicle.isOnline ? 'bg-emerald-500' : 'bg-slate-300'}`} />
</span>
<span className="mt-0.5 block truncate text-[9px] font-bold text-slate-400">
{vehicle.model} · {vehicle.status} · <Blur>{vehicle.customer}</Blur>
</span>
</span>
<span className="text-right">
{mode === 'top' ? (
<>
<span className="block text-xs font-black text-blue-600">{fmtKm(vehicle.today)} km</span>
<span className="block text-[9px] font-bold text-slate-400"></span>
</>
) : (
<>
<span className="block text-xs font-black text-rose-600">{fmtKm(vehicle.shortfall)} km</span>
<span className="block text-[9px] font-bold text-slate-400"></span>
</>
)}
</span>
<ChevronRight size={14} className="text-slate-300" />
</button>
);
}
export default function DailyReportView() { export default function DailyReportView() {
const [report, setReport] = useState<DailyReportData | null>(null);
const [loading, setLoading] = useState(true);
const [trendLoading, setTrendLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(async (force = false) => {
setLoading(true);
setTrendLoading(true);
setError('');
const trendRequest = fetchDailyReportTrend(force);
try {
const nextReport = await fetchDailyReport(force);
setReport(nextReport);
setLoading(false);
try {
const trend = await trendRequest;
setReport(current => current ? {
...current,
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
} : current);
} catch {
setError('7 日趋势加载失败');
} finally {
setTrendLoading(false);
}
} catch (cause) {
void trendRequest.catch(() => {});
setError(cause instanceof Error ? cause.message : '日报加载失败');
setLoading(false);
setTrendLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const openTarget = useCallback((targetId: number) => {
const url = buildMileageDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#mileage/statistics' },
{ level: 'overview', targetId, sourcePriority: ['instrument'] },
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, []);
const openVehicle = useCallback((vehicle: DailyReportVehicle) => {
if (!report) return;
const url = buildMileageDrillUrl(
{ pathname: window.location.pathname, search: window.location.search, hash: '#mileage' },
{
level: 'vehicle',
targetId: vehicle.targetId,
plate: vehicle.plate,
startDate: report.reportDate,
endDate: report.reportDate,
sourcePriority: ['instrument'],
},
);
window.history.pushState(null, '', url);
window.dispatchEvent(new HashChangeEvent('hashchange'));
}, [report]);
if (loading && !report) {
return ( return (
<SurfaceCard className="min-h-[360px]"> <SurfaceCard className="min-h-[360px]">
<div className="flex min-h-[320px] flex-col items-center justify-center px-6 py-10 text-center"> <div className="flex min-h-[320px] items-center justify-center gap-2 text-xs font-bold text-slate-400">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400"> <RefreshCw size={16} className="animate-spin" />
<FileText size={26} />
</div>
<div className="mt-4 text-base font-black text-slate-800"></div>
<div className="mt-2 max-w-md text-xs font-bold leading-relaxed text-slate-400">
</div>
</div> </div>
</SurfaceCard> </SurfaceCard>
); );
} }
if (!report) {
return (
<SurfaceCard className="min-h-[360px]">
<div className="flex min-h-[320px] flex-col items-center justify-center gap-3 px-6 text-center">
<AlertTriangle size={24} className="text-rose-500" />
<div className="text-sm font-black text-slate-800"></div>
<div className="max-w-md text-xs font-bold text-slate-400">{error || '数据接口未返回有效结果'}</div>
<button
type="button"
onClick={() => void load(true)}
className="rounded-lg bg-slate-900 px-4 py-2 text-xs font-black text-white"
>
</button>
</div>
</SurfaceCard>
);
}
return (
<div className="space-y-3 pb-4">
<section className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-50 text-blue-600 ring-1 ring-blue-100">
<CalendarDays size={17} />
</span>
<div>
<h2 className="text-sm font-black text-slate-900">{report.reportDate} </h2>
<p className="mt-0.5 text-[10px] font-bold text-slate-400"> {fmtTime(report.updatedAt)} · </p>
</div>
</div>
<button
type="button"
onClick={() => void load(true)}
disabled={loading || trendLoading}
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-blue-100 bg-blue-50 px-3 text-xs font-black text-blue-600 transition-colors hover:bg-blue-100 disabled:opacity-50"
>
<RefreshCw size={14} className={loading || trendLoading ? 'animate-spin' : ''} />
</button>
</section>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-4">
<MetricTile
label="考核车辆当日里程"
value={fmtKm(report.totalToday)}
unit="km"
helper={`${report.activeCount}/${report.assessmentVehicleCount} 台产生里程`}
icon={Route}
tone="blue"
/>
<MetricTile
label="当日任务缺口"
value={fmtKm(report.shortfall)}
unit="km"
helper={`任务基准 ${fmtKm(report.dailyNeed)} km`}
icon={AlertTriangle}
tone={report.shortfall > 0 ? 'rose' : 'emerald'}
/>
<MetricTile
label="零里程车辆"
value={report.zeroCount}
unit="台"
helper={`考核车辆共 ${report.assessmentVehicleCount}`}
icon={Truck}
tone={report.zeroCount > 0 ? 'amber' : 'emerald'}
/>
<MetricTile
label="考核加权完成率"
value={report.completionRate.toFixed(1)}
unit="%"
helper={`${report.qualifiedCount} 台达标 · ${report.halfQualifiedCount} 台过半`}
icon={CircleGauge}
tone="emerald"
/>
</div>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)]">
<div className="space-y-3">
<SurfaceCard
title="全量监控车辆 7 日趋势"
subtitle={`当前监控范围 ${report.monitoringVehicleCount} 台,趋势不等同于考核车辆范围`}
>
<div className="h-[250px] px-2 py-3">
{report.trend.length === 0 && trendLoading ? (
<div className="flex h-full items-center justify-center gap-2 text-xs font-bold text-slate-400">
<RefreshCw size={14} className="animate-spin" />
</div>
) : report.trend.length === 0 ? (
<div className="flex h-full items-center justify-center text-xs font-bold text-slate-400">
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={report.trend} margin={{ top: 12, right: 12, left: -12, bottom: 0 }}>
<CartesianGrid vertical={false} stroke="#e2e8f0" strokeDasharray="3 3" />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 10, fill: '#94a3b8' }} tickFormatter={fmtKm} />
<Tooltip
formatter={(value) => [`${fmtKm(Number(value))} km`, '总里程']}
contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }}
/>
<Bar dataKey="value" fill="#2563eb" radius={[4, 4, 0, 0]} maxBarSize={30} />
</BarChart>
</ResponsiveContainer>
)}
</div>
</SurfaceCard>
<SurfaceCard title="考核目标表现" subtitle="按当日任务缺口排序,点击进入考核统计">
<div className="divide-y divide-slate-100">
{report.models.map((model, index) => (
<motion.button
type="button"
key={model.id}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.025 }}
onClick={() => openTarget(model.id)}
className="grid w-full grid-cols-[minmax(0,1fr)_72px_78px_20px] items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-slate-50"
>
<span className="min-w-0">
<span className="block truncate text-xs font-black text-slate-800">{model.name}</span>
<span className="mt-0.5 block text-[9px] font-bold text-slate-400">
{model.active}/{model.count} · {model.zero}
{model.missingDailyTaskCount > 0 ? ` · ${model.missingDailyTaskCount} 台缺任务值` : ''}
</span>
</span>
<span className="text-right">
<span className="block text-[11px] font-black text-blue-600">{fmtKm(model.today)}</span>
<span className="block text-[9px] font-bold text-slate-400"> km</span>
</span>
<span className="text-right">
<span className={`block text-[11px] font-black ${model.shortfall > 0 ? 'text-rose-600' : 'text-emerald-600'}`}>
{fmtKm(model.shortfall)}
</span>
<span className="block text-[9px] font-bold text-slate-400"> km</span>
</span>
<ChevronRight size={14} className="text-slate-300" />
</motion.button>
))}
</div>
</SurfaceCard>
</div>
<div className="space-y-3">
<SurfaceCard title="当日里程 TOP 5" subtitle="考核车辆范围">
<div className="divide-y divide-slate-100">
{report.topVehicles.map(vehicle => (
<VehicleRow key={vehicle.plate} vehicle={vehicle} mode="top" onClick={() => openVehicle(vehicle)} />
))}
</div>
</SurfaceCard>
<SurfaceCard title="任务缺口车辆" subtitle="按车辆当日任务缺口排序,仅含已返回任务值的车辆">
<div className="divide-y divide-slate-100">
{report.riskVehicles.map(vehicle => (
<VehicleRow key={vehicle.plate} vehicle={vehicle} mode="risk" onClick={() => openVehicle(vehicle)} />
))}
</div>
</SurfaceCard>
<section className="rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div className="flex items-start gap-3">
<Database size={16} className="mt-0.5 shrink-0 text-slate-400" />
<div className="min-w-0 text-[10px] font-bold leading-relaxed text-slate-500">
<div> {report.assessmentVehicleCount} · {report.monitoringVehicleCount} </div>
<div className="mt-1"> {report.duplicateAssignmentCount} </div>
<div className={report.missingDailyTaskCount > 0 ? 'mt-1 text-amber-600' : 'mt-1'}>
{report.missingDailyTaskCount}
</div>
{error && <div className="mt-1 text-rose-600">{error}</div>}
</div>
<Activity size={14} className="ml-auto shrink-0 text-emerald-500" />
</div>
</section>
</div>
</div>
</div>
);
}
+51 -110
View File
@@ -8,6 +8,16 @@ import type {
TrendPoint, TrendPoint,
} from './types'; } from './types';
import { fetchJson } from '../../auth/api-client'; import { fetchJson } from '../../auth/api-client';
import {
buildDailyReport,
type DailyReportData,
} from './daily-report';
export type {
DailyReportData,
DailyReportModel,
DailyReportVehicle,
} from './daily-report';
const BASE = '/api/mileage'; const BASE = '/api/mileage';
@@ -123,38 +133,6 @@ export async function fetchVehicleRecent(
); );
} }
export interface DailyReportModel {
id: number;
name: string;
count: number;
today: number;
total: number;
completion: number;
active: number;
zero: number;
dailyNeed: number;
}
export interface DailyReportVehicle {
plate: string;
model: string;
status: string;
customer: string;
today?: number;
completion?: number;
}
export interface DailyReportData {
reportDate: string;
updatedAt: string;
models: DailyReportModel[];
trend: { date: string; value: number }[];
topVehicles: DailyReportVehicle[];
zeroRisk: DailyReportVehicle[];
qualifiedCount: number;
halfQualifiedCount: number;
}
function reportDateFromUpdatedAt(updatedAt: string): string { function reportDateFromUpdatedAt(updatedAt: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(updatedAt)) return updatedAt; if (/^\d{4}-\d{2}-\d{2}$/.test(updatedAt)) return updatedAt;
const d = new Date(updatedAt); const d = new Date(updatedAt);
@@ -162,95 +140,58 @@ function reportDateFromUpdatedAt(updatedAt: string): string {
return new Date().toISOString().slice(0, 10); return new Date().toISOString().slice(0, 10);
} }
function compactTargetName(name: string): string { let dailyReportRequest: Promise<DailyReportData> | null = null;
return name let dailyReportCache: { data: DailyReportData; expiresAt: number } | null = null;
.replace(/^羚牛/, '') const DAILY_REPORT_CACHE_MS = 60_000;
.replace(/辆/g, '台')
.replace(/4\.5T普货/g, '普货')
.replace(/4\.5T冷链车/g, '冷链车')
.replace(/4\.5T冷链/g, '冷链车');
}
function normalizeStatus(status: string | null): string { async function loadDailyReport(): Promise<DailyReportData> {
if (!status) return '未标注'; const [targets, monitoring] = await Promise.all([
if (status === '自营' || status === '租赁') return status;
if (/租/.test(status)) return '租赁';
if (/自/.test(status)) return '自营';
if (/库|Inventory/i.test(status)) return '在库';
return status;
}
export async function fetchDailyReport(): Promise<DailyReportData> {
const [targets, trend, monitoring, topMonitoring] = await Promise.all([
fetchTargets(), fetchTargets(),
fetchTrend(undefined, 7),
fetchMonitoring({ limit: 1 }),
fetchMonitoring({ sortBy: 'today', sortOrder: 'desc', limit: 5 }), fetchMonitoring({ sortBy: 'today', sortOrder: 'desc', limit: 5 }),
]); ]);
const reportDate = monitoring.dateRange?.end || reportDateFromUpdatedAt(monitoring.updatedAt);
const targetVehiclesEntries = await Promise.all( const targetVehicles = await Promise.all(
targets.map(async target => { targets.map(async target => {
const vehicles = await fetchTargetVehicles(target.id); const vehicles = await fetchTargetVehicles(target.id, reportDate);
return [target.id, vehicles] as const; return { target, vehicles };
}), }),
); );
const targetVehiclesMap = new Map(targetVehiclesEntries);
const models: DailyReportModel[] = targets.map(target => { return buildDailyReport(targets, targetVehicles, monitoring, [], reportDate);
const vehicles = targetVehiclesMap.get(target.id) ?? [];
const active = vehicles.filter(vehicle => vehicle.todayMileage > 0).length;
return {
id: target.id,
name: compactTargetName(target.targetName),
count: target.vehicleCount,
today: target.todayTotal,
total: target.cumulativeTotal,
completion: target.avgCompletion,
active,
zero: Math.max(0, target.vehicleCount - active),
dailyNeed: target.dailyTarget,
};
});
const targetNameByPlate = new Map<string, string>();
for (const target of targets) {
const vehicles = targetVehiclesMap.get(target.id) ?? [];
for (const vehicle of vehicles) targetNameByPlate.set(vehicle.plateNumber, compactTargetName(target.targetName));
} }
const topVehicles: DailyReportVehicle[] = topMonitoring.vehicles.map(vehicle => ({ export async function fetchDailyReport(force = false): Promise<DailyReportData> {
plate: vehicle.plate, if (!force && dailyReportCache && dailyReportCache.expiresAt > Date.now()) {
model: targetNameByPlate.get(vehicle.plate) || vehicle.project || '未归入考核', return dailyReportCache.data;
status: normalizeStatus(vehicle.rentStatus), }
today: vehicle.dailyKm, if (!force && dailyReportRequest) return dailyReportRequest;
customer: vehicle.customer || '未绑定客户', const request = loadDailyReport();
})); dailyReportRequest = request;
try {
const data = await request;
dailyReportCache = { data, expiresAt: Date.now() + DAILY_REPORT_CACHE_MS };
return data;
} finally {
if (dailyReportRequest === request) dailyReportRequest = null;
}
}
const zeroRisk = targetVehiclesEntries let dailyReportTrendRequest: Promise<TrendPoint[]> | null = null;
.flatMap(([targetId, vehicles]) => { let dailyReportTrendCache: { data: TrendPoint[]; expiresAt: number } | null = null;
const target = targets.find(item => item.id === targetId);
const model = target ? compactTargetName(target.targetName) : '未归入考核';
return vehicles
.filter(vehicle => vehicle.todayMileage <= 0 && ['自营', '租赁'].includes(normalizeStatus(vehicle.rentStatus)))
.map(vehicle => ({
plate: vehicle.plateNumber,
model,
status: normalizeStatus(vehicle.rentStatus),
customer: vehicle.customer || '未绑定客户',
completion: vehicle.completionRate,
}));
})
.sort((a, b) => (b.completion ?? 0) - (a.completion ?? 0))
.slice(0, 5);
return { export async function fetchDailyReportTrend(force = false): Promise<TrendPoint[]> {
reportDate: reportDateFromUpdatedAt(monitoring.updatedAt), if (!force && dailyReportTrendCache && dailyReportTrendCache.expiresAt > Date.now()) {
updatedAt: monitoring.updatedAt, return dailyReportTrendCache.data;
models, }
trend: trend.map(item => ({ date: item.date, value: item.mileage })), if (!force && dailyReportTrendRequest) return dailyReportTrendRequest;
topVehicles, const request = fetchTrend(undefined, 7);
zeroRisk, dailyReportTrendRequest = request;
qualifiedCount: targets.reduce((sum, target) => sum + target.yearQualifiedCount, 0), try {
halfQualifiedCount: targets.reduce((sum, target) => sum + target.halfQualifiedCount, 0), const data = await request;
}; dailyReportTrendCache = { data, expiresAt: Date.now() + DAILY_REPORT_CACHE_MS };
return data;
} finally {
if (dailyReportTrendRequest === request) dailyReportTrendRequest = null;
}
} }
+98
View File
@@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildDailyReport } from './daily-report.js';
import type { MonitoringData, TargetSummary, TargetVehicle } from './types.js';
function target(id: number, completed: number, goal: number): TargetSummary {
return {
id,
targetName: `目标${id}`,
vehicleCount: 2,
cumulativeTotal: completed,
currentYearCompleted: completed,
currentYearTarget: goal,
yearQualifiedCount: 0,
halfQualifiedCount: 0,
} as TargetSummary;
}
function vehicle(plateNumber: string, todayMileage: number, dailyRequiredMileage: number): TargetVehicle {
return {
plateNumber,
todayMileage,
totalMileage: 1000,
completionRate: 0.5,
isQualified: false,
currentYearIsQualified: false,
dailyRequiredMileage,
rentStatus: '租赁',
department: '业务一部',
customer: '客户甲',
isOnline: true,
};
}
const monitoring = {
stats: { totalToday: 0, totalAll: 0, vehicleCount: 10, yesterdayTotal: 0 },
updatedAt: '2026-08-07T10:00:00+08:00',
} as MonitoringData;
test('builds a weighted, additive daily report', () => {
const first = target(1, 50, 100);
const second = target(2, 100, 300);
const report = buildDailyReport(
[first, second],
[
{ target: first, vehicles: [vehicle('粤A1', 120, 100), vehicle('粤A2', 0, 100)] },
{ target: second, vehicles: [vehicle('粤A3', 50, 100)] },
],
monitoring,
[{ date: '08-06', mileage: 500 }],
'2026-08-07',
);
assert.equal(report.totalToday, 170);
assert.equal(report.dailyNeed, 300);
assert.equal(report.shortfall, 150);
assert.equal(report.activeCount, 2);
assert.equal(report.zeroCount, 1);
assert.equal(report.completionRate, 37.5);
assert.equal(report.models[0].shortfall, 100);
assert.deepEqual(report.trend, [{ date: '08-06', value: 500 }]);
});
test('deduplicates vehicles assigned to multiple targets', () => {
const first = target(1, 50, 100);
const second = target(2, 100, 300);
const report = buildDailyReport(
[first, second],
[
{ target: first, vehicles: [vehicle('粤A1', 80, 100)] },
{ target: second, vehicles: [vehicle('粤A1', 80, 100)] },
],
monitoring,
[],
'2026-08-07',
);
assert.equal(report.assessmentVehicleCount, 1);
assert.equal(report.duplicateAssignmentCount, 1);
assert.equal(report.totalToday, 80);
});
test('falls back to the target daily need when vehicle tasks are missing', () => {
const item = target(5, 100, 300);
item.dailyTarget = 250;
const report = buildDailyReport(
[item],
[{ target: item, vehicles: [vehicle('粤A1', 100, 0), vehicle('粤A2', 50, 0)] }],
monitoring,
[],
'2026-08-07',
);
assert.equal(report.dailyNeed, 250);
assert.equal(report.shortfall, 100);
assert.equal(report.missingDailyTaskCount, 2);
assert.equal(report.models[0].missingDailyTaskCount, 2);
});
+200
View File
@@ -0,0 +1,200 @@
import { weightedCompletionRate } from '../../shared/analytics/metrics';
import type {
MonitoringData,
TargetSummary,
TargetVehicle,
TrendPoint,
} from './types';
export interface DailyReportModel {
id: number;
name: string;
count: number;
today: number;
total: number;
completion: number;
active: number;
zero: number;
dailyNeed: number;
shortfall: number;
missingDailyTaskCount: number;
}
export interface DailyReportVehicle {
targetId: number;
plate: string;
model: string;
status: string;
customer: string;
today: number;
completion: number;
dailyNeed: number;
shortfall: number;
isOnline: boolean;
hasDailyTask: boolean;
}
export interface DailyReportData {
reportDate: string;
updatedAt: string;
monitoringVehicleCount: number;
assessmentVehicleCount: number;
duplicateAssignmentCount: number;
missingDailyTaskCount: number;
totalToday: number;
activeCount: number;
zeroCount: number;
dailyNeed: number;
shortfall: number;
completionRate: number;
models: DailyReportModel[];
trend: { date: string; value: number }[];
topVehicles: DailyReportVehicle[];
riskVehicles: DailyReportVehicle[];
qualifiedCount: number;
halfQualifiedCount: number;
}
interface TargetVehicleEntry {
target: TargetSummary;
vehicles: TargetVehicle[];
}
function compactTargetName(name: string): string {
return name
.replace(/^羚牛/, '')
.replace(/辆/g, '台')
.replace(/4\.5T普货/g, '普货')
.replace(/4\.5T冷链车/g, '冷链车')
.replace(/4\.5T冷链/g, '冷链车');
}
function normalizeStatus(status: string | null): string {
if (!status) return '未标注';
if (status === '自营' || status === '租赁') return status;
if (/租/.test(status)) return '租赁';
if (/自/.test(status)) return '自营';
if (/库|Inventory/i.test(status)) return '在库';
return status;
}
function positive(value: number | null | undefined): number {
return Math.max(0, Number(value) || 0);
}
function vehicleReportRow(
vehicle: TargetVehicle,
targetId: number,
model: string,
targetDailyNeed: number,
): DailyReportVehicle {
const today = positive(vehicle.todayMileage);
const dailyNeed = positive(vehicle.dailyRequiredMileage);
return {
targetId,
plate: vehicle.plateNumber,
model,
status: normalizeStatus(vehicle.rentStatus),
customer: vehicle.customer || '未绑定客户',
today,
completion: positive(vehicle.completionRate) * 100,
dailyNeed,
shortfall: Math.max(0, dailyNeed - today),
isOnline: vehicle.isOnline,
hasDailyTask: dailyNeed > 0 || targetDailyNeed <= 0,
};
}
export function buildDailyReport(
targets: TargetSummary[],
targetVehicles: TargetVehicleEntry[],
monitoring: MonitoringData,
trend: TrendPoint[],
reportDate: string,
): DailyReportData {
const models = targetVehicles.map(({ target, vehicles }) => {
const today = vehicles.reduce((sum, vehicle) => sum + positive(vehicle.todayMileage), 0);
const vehicleDailyNeed = vehicles.reduce((sum, vehicle) => sum + positive(vehicle.dailyRequiredMileage), 0);
const targetDailyNeed = positive(target.dailyTarget);
const dailyNeed = Math.max(vehicleDailyNeed, targetDailyNeed);
const vehicleShortfall = vehicles.reduce((sum, vehicle) => (
sum + Math.max(0, positive(vehicle.dailyRequiredMileage) - positive(vehicle.todayMileage))
), 0);
const shortfall = Math.max(vehicleShortfall, Math.max(0, dailyNeed - today));
return {
id: target.id,
name: compactTargetName(target.targetName),
count: vehicles.length,
today,
total: positive(target.cumulativeTotal),
completion: weightedCompletionRate([{
completed: positive(target.currentYearCompleted),
target: positive(target.currentYearTarget),
}]),
active: vehicles.filter(vehicle => positive(vehicle.todayMileage) > 0).length,
zero: vehicles.filter(vehicle => positive(vehicle.todayMileage) <= 0).length,
dailyNeed,
shortfall,
missingDailyTaskCount: targetDailyNeed > 0
? vehicles.filter(vehicle => positive(vehicle.dailyRequiredMileage) <= 0).length
: 0,
};
});
const uniqueVehicles = new Map<string, DailyReportVehicle>();
let duplicateAssignmentCount = 0;
for (const { target, vehicles } of targetVehicles) {
const model = compactTargetName(target.targetName);
for (const vehicle of vehicles) {
const next = vehicleReportRow(vehicle, target.id, model, positive(target.dailyTarget));
const existing = uniqueVehicles.get(next.plate);
if (!existing) {
uniqueVehicles.set(next.plate, next);
continue;
}
duplicateAssignmentCount += 1;
uniqueVehicles.set(next.plate, {
...existing,
today: Math.max(existing.today, next.today),
completion: Math.max(existing.completion, next.completion),
dailyNeed: Math.max(existing.dailyNeed, next.dailyNeed),
shortfall: Math.max(existing.shortfall, next.shortfall),
isOnline: existing.isOnline || next.isOnline,
hasDailyTask: existing.hasDailyTask || next.hasDailyTask,
});
}
}
const vehicles = Array.from(uniqueVehicles.values());
const activeCount = vehicles.filter(vehicle => vehicle.today > 0).length;
const totalToday = vehicles.reduce((sum, vehicle) => sum + vehicle.today, 0);
const dailyNeed = models.reduce((sum, model) => sum + model.dailyNeed, 0);
const shortfall = models.reduce((sum, model) => sum + model.shortfall, 0);
return {
reportDate,
updatedAt: monitoring.updatedAt,
monitoringVehicleCount: monitoring.stats.vehicleCount,
assessmentVehicleCount: vehicles.length,
duplicateAssignmentCount,
missingDailyTaskCount: models.reduce((sum, model) => sum + model.missingDailyTaskCount, 0),
totalToday,
activeCount,
zeroCount: vehicles.length - activeCount,
dailyNeed,
shortfall,
completionRate: weightedCompletionRate(targets.map(target => ({
completed: positive(target.currentYearCompleted),
target: positive(target.currentYearTarget),
}))),
models: models.sort((a, b) => b.shortfall - a.shortfall || b.today - a.today),
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
topVehicles: [...vehicles].sort((a, b) => b.today - a.today).slice(0, 5),
riskVehicles: [...vehicles]
.filter(vehicle => vehicle.shortfall > 0)
.sort((a, b) => b.shortfall - a.shortfall || a.completion - b.completion)
.slice(0, 5),
qualifiedCount: targets.reduce((sum, target) => sum + target.yearQualifiedCount, 0),
halfQualifiedCount: targets.reduce((sum, target) => sum + target.halfQualifiedCount, 0),
};
}