refactor: build drillable mileage daily report
This commit is contained in:
@@ -1,18 +1,348 @@
|
||||
import { FileText } from 'lucide-react';
|
||||
import { SurfaceCard } from '../../components/ui/surface';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
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() {
|
||||
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 (
|
||||
<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 h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
|
||||
<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 className="flex min-h-[320px] items-center justify-center gap-2 text-xs font-bold text-slate-400">
|
||||
<RefreshCw size={16} className="animate-spin" />
|
||||
正在生成里程日报
|
||||
</div>
|
||||
</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
@@ -8,6 +8,16 @@ import type {
|
||||
TrendPoint,
|
||||
} from './types';
|
||||
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';
|
||||
|
||||
@@ -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 {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(updatedAt)) return updatedAt;
|
||||
const d = new Date(updatedAt);
|
||||
@@ -162,95 +140,58 @@ function reportDateFromUpdatedAt(updatedAt: string): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function compactTargetName(name: string): string {
|
||||
return name
|
||||
.replace(/^羚牛/, '')
|
||||
.replace(/辆/g, '台')
|
||||
.replace(/4\.5T普货/g, '普货')
|
||||
.replace(/4\.5T冷链车/g, '冷链车')
|
||||
.replace(/4\.5T冷链/g, '冷链车');
|
||||
}
|
||||
let dailyReportRequest: Promise<DailyReportData> | null = null;
|
||||
let dailyReportCache: { data: DailyReportData; expiresAt: number } | null = null;
|
||||
const DAILY_REPORT_CACHE_MS = 60_000;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function fetchDailyReport(): Promise<DailyReportData> {
|
||||
const [targets, trend, monitoring, topMonitoring] = await Promise.all([
|
||||
async function loadDailyReport(): Promise<DailyReportData> {
|
||||
const [targets, monitoring] = await Promise.all([
|
||||
fetchTargets(),
|
||||
fetchTrend(undefined, 7),
|
||||
fetchMonitoring({ limit: 1 }),
|
||||
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 => {
|
||||
const vehicles = await fetchTargetVehicles(target.id);
|
||||
return [target.id, vehicles] as const;
|
||||
const vehicles = await fetchTargetVehicles(target.id, reportDate);
|
||||
return { target, vehicles };
|
||||
}),
|
||||
);
|
||||
const targetVehiclesMap = new Map(targetVehiclesEntries);
|
||||
|
||||
const models: DailyReportModel[] = targets.map(target => {
|
||||
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));
|
||||
return buildDailyReport(targets, targetVehicles, monitoring, [], reportDate);
|
||||
}
|
||||
|
||||
const topVehicles: DailyReportVehicle[] = topMonitoring.vehicles.map(vehicle => ({
|
||||
plate: vehicle.plate,
|
||||
model: targetNameByPlate.get(vehicle.plate) || vehicle.project || '未归入考核',
|
||||
status: normalizeStatus(vehicle.rentStatus),
|
||||
today: vehicle.dailyKm,
|
||||
customer: vehicle.customer || '未绑定客户',
|
||||
}));
|
||||
export async function fetchDailyReport(force = false): Promise<DailyReportData> {
|
||||
if (!force && dailyReportCache && dailyReportCache.expiresAt > Date.now()) {
|
||||
return dailyReportCache.data;
|
||||
}
|
||||
if (!force && dailyReportRequest) return dailyReportRequest;
|
||||
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
|
||||
.flatMap(([targetId, vehicles]) => {
|
||||
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);
|
||||
let dailyReportTrendRequest: Promise<TrendPoint[]> | null = null;
|
||||
let dailyReportTrendCache: { data: TrendPoint[]; expiresAt: number } | null = null;
|
||||
|
||||
return {
|
||||
reportDate: reportDateFromUpdatedAt(monitoring.updatedAt),
|
||||
updatedAt: monitoring.updatedAt,
|
||||
models,
|
||||
trend: trend.map(item => ({ date: item.date, value: item.mileage })),
|
||||
topVehicles,
|
||||
zeroRisk,
|
||||
qualifiedCount: targets.reduce((sum, target) => sum + target.yearQualifiedCount, 0),
|
||||
halfQualifiedCount: targets.reduce((sum, target) => sum + target.halfQualifiedCount, 0),
|
||||
};
|
||||
export async function fetchDailyReportTrend(force = false): Promise<TrendPoint[]> {
|
||||
if (!force && dailyReportTrendCache && dailyReportTrendCache.expiresAt > Date.now()) {
|
||||
return dailyReportTrendCache.data;
|
||||
}
|
||||
if (!force && dailyReportTrendRequest) return dailyReportTrendRequest;
|
||||
const request = fetchTrend(undefined, 7);
|
||||
dailyReportTrendRequest = request;
|
||||
try {
|
||||
const data = await request;
|
||||
dailyReportTrendCache = { data, expiresAt: Date.now() + DAILY_REPORT_CACHE_MS };
|
||||
return data;
|
||||
} finally {
|
||||
if (dailyReportTrendRequest === request) dailyReportTrendRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user