diff --git a/src/modules/mileage/DailyReportView.tsx b/src/modules/mileage/DailyReportView.tsx index ea641b2..c333adf 100644 --- a/src/modules/mileage/DailyReportView.tsx +++ b/src/modules/mileage/DailyReportView.tsx @@ -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'; -export default function DailyReportView() { +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 ( - -
-
- + + ); +} + +export default function DailyReportView() { + const [report, setReport] = useState(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 ( + +
+ + 正在生成里程日报
-
每日汇报接入中
-
- 日报数据口径正在整理,完成后将接入数据库统计与导出能力。 + + ); + } + + if (!report) { + return ( + +
+ +
日报暂不可用
+
{error || '数据接口未返回有效结果'}
+
+
+ ); + } + + return ( +
+
+
+ + + +
+

{report.reportDate} 里程日报

+

统计时间 {fmtTime(report.updatedAt)} · 仪表数据

+
+
+ +
+ +
+ + 0 ? 'rose' : 'emerald'} + /> + 0 ? 'amber' : 'emerald'} + /> + +
+ +
+
+ +
+ {report.trend.length === 0 && trendLoading ? ( +
+ + 正在加载趋势 +
+ ) : report.trend.length === 0 ? ( +
+ 暂无趋势数据 +
+ ) : ( + + + + + + [`${fmtKm(Number(value))} km`, '总里程']} + contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }} + /> + + + + )} +
+
+ + +
+ {report.models.map((model, index) => ( + 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" + > + + {model.name} + + {model.active}/{model.count} 台有里程 · {model.zero} 台零里程 + {model.missingDailyTaskCount > 0 ? ` · ${model.missingDailyTaskCount} 台缺任务值` : ''} + + + + {fmtKm(model.today)} + 当日 km + + + 0 ? 'text-rose-600' : 'text-emerald-600'}`}> + {fmtKm(model.shortfall)} + + 缺口 km + + + + ))} +
+
+
+ +
+ +
+ {report.topVehicles.map(vehicle => ( + openVehicle(vehicle)} /> + ))} +
+
+ + +
+ {report.riskVehicles.map(vehicle => ( + openVehicle(vehicle)} /> + ))} +
+
+ +
+
+ +
+
考核车辆 {report.assessmentVehicleCount} 台 · 监控车辆 {report.monitoringVehicleCount} 台
+
重复考核归属 {report.duplicateAssignmentCount} 条
+
0 ? 'mt-1 text-amber-600' : 'mt-1'}> + 缺少车辆级任务值 {report.missingDailyTaskCount} 台 +
+ {error &&
最近刷新失败:{error}
} +
+ +
+
+
- +
); } diff --git a/src/modules/mileage/api.ts b/src/modules/mileage/api.ts index 6170e04..f557f94 100644 --- a/src/modules/mileage/api.ts +++ b/src/modules/mileage/api.ts @@ -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 | 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 { - const [targets, trend, monitoring, topMonitoring] = await Promise.all([ +async function loadDailyReport(): Promise { + 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, - }; - }); + return buildDailyReport(targets, targetVehicles, monitoring, [], reportDate); +} - const targetNameByPlate = new Map(); - for (const target of targets) { - const vehicles = targetVehiclesMap.get(target.id) ?? []; - for (const vehicle of vehicles) targetNameByPlate.set(vehicle.plateNumber, compactTargetName(target.targetName)); +export async function fetchDailyReport(force = false): Promise { + 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 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 || '未绑定客户', - })); +let dailyReportTrendRequest: Promise | null = null; +let dailyReportTrendCache: { data: TrendPoint[]; expiresAt: number } | null = 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); - - 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 { + 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; + } } diff --git a/src/modules/mileage/daily-report.test.ts b/src/modules/mileage/daily-report.test.ts new file mode 100644 index 0000000..0746518 --- /dev/null +++ b/src/modules/mileage/daily-report.test.ts @@ -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); +}); diff --git a/src/modules/mileage/daily-report.ts b/src/modules/mileage/daily-report.ts new file mode 100644 index 0000000..de840f9 --- /dev/null +++ b/src/modules/mileage/daily-report.ts @@ -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(); + 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), + }; +}