From bd0627d00ff3d1d8ca78d5d7879b588792fc4eb0 Mon Sep 17 00:00:00 2001 From: kkfluous Date: Wed, 12 Aug 2026 23:05:07 +0800 Subject: [PATCH] feat(mileage): add daily operations report --- package.json | 3 +- .../backfill-mileage-report-vehicle-trends.ts | 78 ++ scripts/import-mileage-daily-reports.ts | 223 ++++ src/components/ui/surface.tsx | 31 +- src/modules/mileage/DailyReportView.tsx | 1033 ++++++++++++++++- src/modules/mileage/MileageModule.tsx | 1 + src/modules/mileage/api.ts | 146 +-- src/modules/mileage/types.ts | 1 + src/server/routes/mileage/cache.ts | 1 + .../routes/mileage/daily-report-model.test.ts | 96 ++ .../routes/mileage/daily-report-model.ts | 172 +++ .../routes/mileage/daily-report-scheduler.ts | 70 ++ .../routes/mileage/daily-report-service.ts | 314 +++++ .../routes/mileage/daily-report-store.ts | 136 +++ src/server/routes/mileage/daily-report.ts | 32 + src/server/routes/mileage/index.ts | 4 + src/server/routes/mileage/types.ts | 2 + src/server/routes/mileage/vehicle-info.ts | 4 + src/shared/mileage/daily-report.ts | 122 ++ 19 files changed, 2320 insertions(+), 149 deletions(-) create mode 100644 scripts/backfill-mileage-report-vehicle-trends.ts create mode 100644 scripts/import-mileage-daily-reports.ts create mode 100644 src/server/routes/mileage/daily-report-model.test.ts create mode 100644 src/server/routes/mileage/daily-report-model.ts create mode 100644 src/server/routes/mileage/daily-report-scheduler.ts create mode 100644 src/server/routes/mileage/daily-report-service.ts create mode 100644 src/server/routes/mileage/daily-report-store.ts create mode 100644 src/server/routes/mileage/daily-report.ts create mode 100644 src/shared/mileage/daily-report.ts diff --git a/package.json b/package.json index 58a491c..50c62ba 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "dev:client": "vite --port=3000 --host=0.0.0.0", "build": "vite build", "start": "node --import tsx src/server/index.ts", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "test:mileage-report": "node --import tsx --test src/server/routes/mileage/daily-report-model.test.ts" }, "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", diff --git a/scripts/backfill-mileage-report-vehicle-trends.ts b/scripts/backfill-mileage-report-vehicle-trends.ts new file mode 100644 index 0000000..8713c66 --- /dev/null +++ b/scripts/backfill-mileage-report-vehicle-trends.ts @@ -0,0 +1,78 @@ +import type { RowDataPacket } from 'mysql2'; +import pool from '../src/server/db.js'; +import type { + DailyMileageReport, + MileageReportTrendPoint, +} from '../src/shared/mileage/daily-report.js'; +import { fetchOneOsMileageDates } from '../src/server/routes/mileage/oneos-api.js'; +import { DEFAULT_ONEOS_PROTOCOL_PRIORITY } from '../src/server/routes/mileage/source-policy.js'; +import { roundMileage } from '../src/server/routes/mileage/daily-report-model.js'; +import { updateDailyReportSnapshotPayload } from '../src/server/routes/mileage/daily-report-store.js'; + +interface SnapshotRow extends RowDataPacket { + report_date: string; + source: string; + report_payload: DailyMileageReport | string; +} + +function reportDates(reportDate: string): string[] { + const end = new Date(`${reportDate}T00:00:00Z`); + return Array.from({ length: 7 }, (_, index) => { + const date = new Date(end); + date.setUTCDate(end.getUTCDate() - (6 - index)); + return date.toISOString().slice(0, 10); + }); +} + +function parseReport(value: DailyMileageReport | string): DailyMileageReport { + return typeof value === 'string' ? JSON.parse(value) as DailyMileageReport : value; +} + +async function trendByPlate(report: DailyMileageReport): Promise> { + const dates = reportDates(report.reportDate); + const plates = report.groups.flatMap(group => group.vehicles.map(vehicle => vehicle.plate)); + const rowsByDate = await fetchOneOsMileageDates(dates, plates, DEFAULT_ONEOS_PROTOCOL_PRIORITY); + const result = new Map(); + for (const plate of plates) { + result.set(plate, dates.map(date => { + const mileage = (rowsByDate.get(date) || []) + .filter(row => row.status === 'NORMAL' && row.plateNumber === plate) + .reduce((maximum, row) => Math.max(maximum, Number(row.dailyMileageKm) || 0), 0); + return { date, totalMileage: roundMileage(Math.max(0, mileage)) }; + })); + } + return result; +} + +const apply = process.argv.includes('--apply'); +const [rows] = await pool.query(` + SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date, source, report_payload + FROM lingniu_prod.tab_mileage_daily_report + WHERE source <> 'XLSX_IMPORT' + ORDER BY report_date +`); + +try { + for (const row of rows) { + const report = parseReport(row.report_payload); + if (report.groups.every(group => group.vehicles.every(vehicle => vehicle.trend?.length === 7))) { + console.log(`${row.report_date}: already complete`); + continue; + } + const trends = await trendByPlate(report); + const updated: DailyMileageReport = { + ...report, + groups: report.groups.map(group => ({ + ...group, + vehicles: group.vehicles.map(vehicle => ({ + ...vehicle, + trend: trends.get(vehicle.plate) || [], + })), + })), + }; + if (apply) await updateDailyReportSnapshotPayload(updated); + console.log(`${row.report_date}: ${apply ? 'updated' : 'would update'} ${trends.size} vehicles`); + } +} finally { + await pool.end(); +} diff --git a/scripts/import-mileage-daily-reports.ts b/scripts/import-mileage-daily-reports.ts new file mode 100644 index 0000000..30e5f5b --- /dev/null +++ b/scripts/import-mileage-daily-reports.ts @@ -0,0 +1,223 @@ +import XLSX from 'xlsx'; +import type { WorkBook } from 'xlsx'; +import type { RowDataPacket } from 'mysql2'; +import pool from '../src/server/db.js'; +import type { + MileageReportBreakdown, + MileageReportGroup, + MileageReportVehicle, +} from '../src/shared/mileage/daily-report.js'; +import { + buildDailyMileageReport, + mileageBand, + roundMileage, +} from '../src/server/routes/mileage/daily-report-model.js'; +import { saveDailyReportSnapshot } from '../src/server/routes/mileage/daily-report-store.js'; + +interface TargetRow extends RowDataPacket { id: number; target_name: string } + +interface ImportSpec { + date: string; + file: string; +} + +const SHEET_SPECS = [ + { sheet: '40台普货', targetMatch: '交投40', summaryRow: 2, displayName: '40台普货' }, + { sheet: '190台冷藏车', targetMatch: '交投190', summaryRow: 3, displayName: '190台冷链' }, + { sheet: '136台冷藏车', targetMatch: '羚牛136', summaryRow: 4, displayName: '136台冷链' }, + { sheet: '100台双飞翼', targetMatch: '羚牛100', summaryRow: 5, displayName: '100台18T双飞翼' }, + { sheet: '50台普货', targetMatch: '恒运', summaryRow: 6, displayName: '26台恒运普货' }, +] as const; + +const INVENTORY_REGIONS: Record>> = { + '2026-03-30': { + '40台普货': { 广东: 2 }, + '190台冷链': { 嘉兴: 2, 广东: 38 }, + '136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 31 }, + '100台18T双飞翼': { 嘉兴: 9, 广东: 29 }, + '26台恒运普货': { 广东: 11, 嘉兴: 13 }, + }, + '2026-03-31': { + '40台普货': { 广东: 2 }, + '190台冷链': { 嘉兴: 2, 广东: 36 }, + '136台冷链': { 新疆: 1, 嘉兴: 13, 广东: 32 }, + '100台18T双飞翼': { 嘉兴: 8, 广东: 29 }, + '26台恒运普货': { 广东: 11, 嘉兴: 12 }, + }, +}; + +function parseArgs(values: string[]): ImportSpec[] { + return values.map(value => { + const split = value.indexOf(':'); + if (split < 10) throw new Error(`参数格式应为 YYYY-MM-DD:/path/file.xlsx,收到 ${value}`); + const date = value.slice(0, split); + const file = value.slice(split + 1); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !file) throw new Error(`参数不合法:${value}`); + return { date, file }; + }); +} + +function valueNumber(value: unknown): number { + return Number(value) || 0; +} + +function sheetRows(workbook: WorkBook, name: string): unknown[][] { + const sheet = workbook.Sheets[name]; + if (!sheet) throw new Error(`工作表不存在:${name}`); + return XLSX.utils.sheet_to_json(sheet, { header: 1, raw: true, defval: null }); +} + +function trendDate(reportDate: string, label: unknown): string { + const match = /^(\d{1,2})\.(\d{1,2})日/.exec(String(label || '')); + if (!match) throw new Error(`无法识别趋势日期:${String(label)}`); + let year = Number(reportDate.slice(0, 4)); + const candidate = `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`; + if (candidate > reportDate) year -= 1; + return `${year}-${String(Number(match[1])).padStart(2, '0')}-${String(Number(match[2])).padStart(2, '0')}`; +} + +function breakdown(rows: MileageReportVehicle[], key: 'department'): MileageReportBreakdown[] { + const grouped = new Map(); + for (const row of rows) { + const name = row[key] || '未标注'; + const items = grouped.get(name) || []; + items.push(row); + grouped.set(name, items); + } + return Array.from(grouped.entries()).map(([name, items]) => ({ + name, + vehicleCount: items.length, + totalMileage: roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0)), + averageMileage: items.length > 0 + ? roundMileage(items.reduce((total, item) => total + item.dailyMileage, 0) / items.length) + : 0, + })).sort((a, b) => b.vehicleCount - a.vehicleCount); +} + +function inventoryBreakdown(date: string, groupName: string): MileageReportBreakdown[] { + return Object.entries(INVENTORY_REGIONS[date]?.[groupName] || {}).map(([name, count]) => ({ + name, + vehicleCount: count, + totalMileage: 0, + averageMileage: 0, + })); +} + +function generatedAt(date: string): string { + const next = new Date(`${date}T00:00:00Z`); + next.setUTCDate(next.getUTCDate() + 1); + return `${next.toISOString().slice(0, 10)}T09:30:00+08:00`; +} + +async function importWorkbook(spec: ImportSpec, targets: TargetRow[]): Promise { + const workbook = XLSX.readFile(spec.file, { cellDates: true }); + const summary = sheetRows(workbook, '汇总'); + const groups: MileageReportGroup[] = SHEET_SPECS.map(sheetSpec => { + const rows = sheetRows(workbook, sheetSpec.sheet); + const headerIndex = rows.findIndex(row => row[0] === '序号'); + if (headerIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到明细表头`); + const header = rows[headerIndex]; + const currentDayIndex = header.findIndex(value => String(value || '').includes(`${Number(spec.date.slice(5, 7))}.${Number(spec.date.slice(8, 10))}日行驶里程`)); + if (currentDayIndex < 0) throw new Error(`${sheetSpec.sheet} 未找到 ${spec.date} 日里程列`); + const trendStartIndex = currentDayIndex - 6; + const trendDates = header.slice(trendStartIndex, currentDayIndex + 1).map(label => trendDate(spec.date, label)); + const detailRows = rows.slice(headerIndex + 1).filter(row => typeof row[0] === 'number'); + const vehicles: MileageReportVehicle[] = detailRows.map(row => { + const rawStatus = String(row[1] || '在库'); + const dailyMileage = roundMileage(valueNumber(row[currentDayIndex])); + const status = rawStatus === '租赁' || rawStatus === '自营' ? rawStatus : '在库'; + return { + plate: String(row[2] || ''), + status, + customer: row[3] ? String(row[3]) : null, + department: status === '在库' || !row[4] ? null : String(row[4]), + inventoryRegion: null, + dailyMileage, + cumulativeMileage: roundMileage(valueNumber(row[13])), + completionRate: Math.round(valueNumber(row[17]) * 10000) / 100, + mileageBand: mileageBand(status, dailyMileage), + trend: trendDates.map((date, index) => ({ + date, + totalMileage: roundMileage(valueNumber(row[trendStartIndex + index])), + })), + }; + }); + const operating = vehicles.filter(vehicle => vehicle.mileageBand !== 'INVENTORY'); + const inventory = vehicles.filter(vehicle => vehicle.mileageBand === 'INVENTORY'); + const summaryRow = summary[sheetSpec.summaryRow]; + const target = targets.find(item => item.target_name.includes(sheetSpec.targetMatch)); + if (!target) throw new Error(`未找到考核目标:${sheetSpec.targetMatch}`); + const periods = String(summaryRow[5] || '').split(/\n+|\s{2,}/).map(value => value.trim()).filter(Boolean); + const trend = trendDates.map((date, index) => ({ + date, + totalMileage: roundMileage(detailRows.reduce((total, row) => total + valueNumber(row[trendStartIndex + index]), 0)), + })); + const dailyMileage = roundMileage(vehicles.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)); + const yesterdayMileage = trend[trend.length - 2]?.totalMileage || 0; + const currentYearTarget = roundMileage(valueNumber(summaryRow[10])); + const currentYearCompleted = roundMileage(valueNumber(summaryRow[11])); + return { + targetId: target.id, + targetName: target.target_name, + displayName: sheetSpec.displayName, + vehicleCount: vehicles.length, + operatingCount: operating.length, + inventoryCount: inventory.length, + dailyMileage, + operatingMileage: roundMileage(operating.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)), + inventoryMileage: roundMileage(inventory.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)), + yesterdayMileage, + dayOverDayDelta: 0, + dayOverDayRate: null, + lowMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'LOW').length, + highMileageCount: operating.filter(vehicle => vehicle.mileageBand === 'HIGH').length, + cumulativeMileage: roundMileage(valueNumber(summaryRow[3])), + totalMileageTarget: roundMileage(valueNumber(summaryRow[2])), + completionRate: Math.round(valueNumber(summaryRow[4]) * 10000) / 100, + qualifiedCount: valueNumber(summaryRow[7]), + halfQualifiedCount: valueNumber(summaryRow[8]), + currentYearTarget, + currentYearCompleted, + remainingMileage: roundMileage(valueNumber(summaryRow[12])), + dailyRequiredMileage: roundMileage(valueNumber(summaryRow[14])), + assessmentPeriods: periods, + trend, + inventoryRegions: inventoryBreakdown(spec.date, sheetSpec.displayName), + departments: breakdown(operating, 'department'), + vehicles: vehicles.sort((a, b) => b.dailyMileage - a.dailyMileage), + }; + }); + + const qualityNotes = [ + { level: 'info' as const, message: '历史日报由已审核人工台账导入,车辆规模与当日台账保持一致。' }, + ]; + if (spec.date === '2026-03-30') { + qualityNotes.push({ level: 'info', message: '136台冷链库存按车辆明细修正为45台;100km车辆按低里程口径统计。' }); + } + qualityNotes.push({ level: 'info', message: '190台冷链累计里程采用190辆明细求和及汇总表值,忽略明细页失效的手工合计单元格。' }); + + const report = buildDailyMileageReport({ + reportDate: spec.date, + status: 'ARCHIVED', + source: 'XLSX_IMPORT', + generatedAt: generatedAt(spec.date), + sourceUpdatedAt: null, + groups, + qualityNotes, + }); + const inserted = await saveDailyReportSnapshot(report, 'XLSX_IMPORT', { replace }); + console.log(`${spec.date}: ${inserted ? (replace ? 'replaced' : 'imported') : 'already exists'}; ${report.totals.vehicleCount} vehicles; ${report.totals.dailyMileage} km`); +} + +const args = process.argv.slice(2); +const replace = args.includes('--replace'); +const specs = parseArgs(args.filter(value => value !== '--replace')); +if (specs.length === 0) throw new Error('请提供至少一个 YYYY-MM-DD:/path/file.xlsx 参数'); +const [targets] = await pool.query( + 'SELECT id, target_name FROM lingniu_prod.tab_mileage_assessment_target WHERE is_deleted = 0 ORDER BY id', +); +try { + for (const spec of specs) await importWorkbook(spec, targets); +} finally { + await pool.end(); +} diff --git a/src/components/ui/surface.tsx b/src/components/ui/surface.tsx index 4f2e240..26134c3 100644 --- a/src/components/ui/surface.tsx +++ b/src/components/ui/surface.tsx @@ -15,6 +15,7 @@ export function PageFrame({ children, maxWidth = 'max-w-6xl', compactInfo = false, + hideHeader = false, }: { title: string; subtitle?: string; @@ -25,13 +26,14 @@ export function PageFrame({ children: ReactNode; maxWidth?: string; compactInfo?: boolean; + hideHeader?: boolean; }) { const [infoOpen, setInfoOpen] = useState(false); return (
- {actions}
: null}
)} - + : null} {children} @@ -120,7 +122,7 @@ export function SurfaceCard({ children, className, }: { - title?: string; + title?: ReactNode; subtitle?: string; actions?: ReactNode; children: ReactNode; @@ -149,6 +151,8 @@ export function MetricTile({ helper, icon: Icon, tone = 'blue', + className, + compact = false, }: { label: string; value: ReactNode; @@ -156,6 +160,8 @@ export function MetricTile({ helper?: string; icon?: SurfaceIcon; tone?: 'blue' | 'emerald' | 'amber' | 'rose' | 'slate'; + className?: string; + compact?: boolean; }) { const toneClass = { blue: 'bg-blue-50 text-blue-600 ring-blue-100', @@ -166,22 +172,29 @@ export function MetricTile({ }[tone]; return ( -
+
{label}
{Icon ? ( - - + + ) : null}
-
- +
+ {value} {unit ? {unit} : null}
- {helper ?
{helper}
: null} + {helper ?
{helper}
: null}
); } diff --git a/src/modules/mileage/DailyReportView.tsx b/src/modules/mileage/DailyReportView.tsx index ea641b2..9da7f24 100644 --- a/src/modules/mileage/DailyReportView.tsx +++ b/src/modules/mileage/DailyReportView.tsx @@ -1,18 +1,1029 @@ -import { FileText } from 'lucide-react'; -import { SurfaceCard } from '../../components/ui/surface'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { + AlertTriangle, + ArrowDown, + ArrowDownUp, + ArrowUp, + Archive, + BarChart3, + CalendarDays, + CheckCircle2, + ChevronDown, + ChevronLeft, + ChevronRight, + CircleGauge, + Info, + MapPin, + RefreshCw, + Route, + Search, + TrendingDown, + TrendingUp, + Truck, + Warehouse, + X, +} from 'lucide-react'; +import { + Bar, + BarChart, + Cell, + CartesianGrid, + Legend, + Line, + LineChart, + ReferenceLine, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import Blur from '../../components/Blur'; +import { + ErrorState, + LoadingState, + MetricTile, + SurfaceCard, +} from '../../components/ui/surface'; +import { + fetchDailyMileageReport, + type DailyMileageReport, + type MileageReportGroup, + type MileageReportVehicle, +} from './api'; -export default function DailyReportView() { +type VehicleFilter = 'ALL' | 'LOW' | 'HIGH' | 'INVENTORY'; +type VehicleSortKey = 'dailyMileage' | 'sevenDayMileage' | 'completionRate'; +type SortDirection = 'asc' | 'desc'; + +const VEHICLE_PAGE_SIZE = 20; + +const VEHICLE_FILTERS: { id: VehicleFilter; label: string }[] = [ + { id: 'ALL', label: '全部' }, + { id: 'LOW', label: '≤100km' }, + { id: 'HIGH', label: '>100km' }, + { id: 'INVENTORY', label: '库存' }, +]; + +function shanghaiYmd(offsetDays = 0): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date()); + const date = new Date(`${parts}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + offsetDays); + return date.toISOString().slice(0, 10); +} + +function dateShift(date: string, days: number): string { + const value = new Date(`${date}T00:00:00Z`); + value.setUTCDate(value.getUTCDate() + days); + return value.toISOString().slice(0, 10); +} + +function initialReportDate(): string { + const params = new URLSearchParams(window.location.search); + const value = params.get('mileageReportDate'); + return /^\d{4}-\d{2}-\d{2}$/.test(value || '') ? value! : shanghaiYmd(-1); +} + +function updateReportDateInUrl(date: string): void { + const url = new URL(window.location.href); + url.searchParams.set('mileageReportDate', date); + window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`); +} + +function fmtKm(value: number): string { + if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(2)}万`; + return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 }); +} + +function fmtDate(value: string): string { + const [, month, day] = value.split('-'); + return `${Number(month)}月${Number(day)}日`; +} + +function fmtDateTime(value: string | null): string { + if (!value) return '数据时间未返回'; + 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 deltaLabel(value: number, rate: number | null): string { + const sign = value >= 0 ? '+' : ''; + return `${sign}${fmtKm(value)} km${rate == null ? '' : ` · ${sign}${rate.toFixed(1)}%`}`; +} + +function currentAssessmentYearLabel(periods: string[], reportDate: string): string | null { + const report = new Date(`${reportDate}T00:00:00Z`); + if (Number.isNaN(report.getTime())) return null; + const years = new Set(); + + for (const period of periods) { + const dates = Array.from(period.matchAll(/(\d{4})[/-](\d{1,2})[/-](\d{1,2})/g)); + const start = dates[0]; + const end = dates[1]; + if (!start || !end) continue; + const startDate = new Date(Date.UTC(Number(start[1]), Number(start[2]) - 1, Number(start[3]))); + const endDate = new Date(Date.UTC(Number(end[1]), Number(end[2]) - 1, Number(end[3]))); + if (report < startDate || report > endDate) continue; + + let yearNumber = report.getUTCFullYear() - startDate.getUTCFullYear() + 1; + if ( + report.getUTCMonth() < startDate.getUTCMonth() + || (report.getUTCMonth() === startDate.getUTCMonth() && report.getUTCDate() < startDate.getUTCDate()) + ) yearNumber -= 1; + if (yearNumber > 0) years.add(yearNumber); + } + + if (years.size === 0) return null; + const labels = Array.from(years).sort((a, b) => a - b).map(year => String(year)); + return `考核第${labels.join('/')}年`; +} + +function dailyMileageStatus(group: MileageReportGroup): { + label: string; + detail: string; + tone: 'positive' | 'warning' | 'neutral'; +} { + if (group.dailyRequiredMileage <= 0) { + if (group.remainingMileage <= 0) { + return { label: '本年度已完成', detail: '当前年度剩余任务为 0,无需设置每日应完成里程。', tone: 'positive' }; + } + return { label: '暂无法计算', detail: '缺少有效的当前考核年度截止日,暂不设每日应完成里程。', tone: 'warning' }; + } + + const gap = group.dailyMileage - group.dailyRequiredMileage; + const rate = (group.dailyMileage / group.dailyRequiredMileage) * 100; + if (gap >= 0) { + return { label: '今日已达标', detail: `超出应完成 ${fmtKm(gap)} km,完成 ${rate.toFixed(1)}%。`, tone: 'positive' }; + } + return { label: '今日未达标', detail: `距应完成差 ${fmtKm(Math.abs(gap))} km,完成 ${rate.toFixed(1)}%。`, tone: 'warning' }; +} + +function InsightIcon({ tone }: { tone: string }) { + if (tone === 'positive') return ; + if (tone === 'critical') return ; + if (tone === 'warning') return ; + return ; +} + +function sevenDayMileageOf(vehicle: MileageReportVehicle): number { + return (vehicle.trend || []).reduce((total, point) => total + point.totalMileage, 0); +} + +function SortableVehicleHeader({ + label, + sortKey, + activeSortKey, + sortDirection, + onSort, +}: { + label: string; + sortKey: VehicleSortKey; + activeSortKey: VehicleSortKey; + sortDirection: SortDirection; + onSort: (sortKey: VehicleSortKey) => void; +}) { + const active = activeSortKey === sortKey; + const Icon = !active ? ArrowDownUp : sortDirection === 'asc' ? ArrowUp : ArrowDown; + const order = active ? (sortDirection === 'asc' ? '升序' : '降序') : '未排序'; return ( - -
-
- + + ); +} + +function MobileDailyOverview({ + report, + highMileageRate, +}: { + report: DailyMileageReport; + highMileageRate: number; +}) { + const operatingRate = (report.totals.operatingCount / Math.max(1, report.totals.vehicleCount)) * 100; + const stats = [ + { + label: '运营车辆', + value: report.totals.operatingCount, + unit: '台', + detail: `${report.totals.vehicleCount} 台考核车辆 · ${operatingRate.toFixed(1)}%`, + icon: Truck, + tone: 'text-emerald-600 bg-emerald-50', + }, + { + label: '库存车辆', + value: report.totals.inventoryCount, + unit: '台', + detail: `产生 ${fmtKm(report.totals.inventoryMileage)} km`, + icon: Warehouse, + tone: 'text-rose-600 bg-rose-50', + }, + { + label: '运营单车均值', + value: fmtKm(report.totals.averageOperatingMileage), + unit: 'km/台', + detail: `运营里程 ${fmtKm(report.totals.operatingMileage)} km`, + icon: CircleGauge, + tone: 'text-slate-600 bg-slate-100', + }, + { + label: '高里程车辆', + value: report.totals.highMileageCount, + unit: '台', + detail: `${highMileageRate.toFixed(1)}% · 低里程 ${report.totals.lowMileageCount} 台`, + icon: TrendingUp, + tone: 'text-blue-600 bg-blue-50', + }, + ]; + + return ( +
+
+
+
当日总里程
+
+ {fmtKm(report.totals.dailyMileage)} + km +
-
每日汇报接入中
-
- 日报数据口径正在整理,完成后将接入数据库统计与导出能力。 +
= 0 ? 'bg-emerald-50' : 'bg-rose-50'}`}> +
较前一日
+
= 0 ? 'text-emerald-600' : 'text-rose-600'}`}> + {deltaLabel(report.totals.dayOverDayDelta, report.totals.dayOverDayRate)} +
- +
+ {stats.map((stat, index) => { + const Icon = stat.icon; + return ( +
+
+ {stat.label} + +
+
+ {stat.value} + {stat.unit} +
+
{stat.detail}
+
+ ); + })} +
+
+ ); +} + +function BreakdownList({ + title, + icon, + items, + showAverage = true, +}: { + title: string; + icon: React.ReactNode; + items: MileageReportGroup['departments']; + showAverage?: boolean; +}) { + const max = Math.max(1, ...items.map(item => item.vehicleCount)); + return ( +
+
+ {icon} + {title} +
+ {items.length === 0 ? ( +
暂无数据
+ ) : ( +
+ {items.map(item => ( +
+
+ {item.name} + + {item.vehicleCount} 台{showAverage ? ` · 均 ${fmtKm(item.averageMileage)} km` : ''} + +
+
+
+
+
+ ))} +
+ )} +
+ ); +} + +function VehicleTable({ group }: { group: MileageReportGroup }) { + const [filter, setFilter] = useState('ALL'); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [sortKey, setSortKey] = useState('dailyMileage'); + const [sortDirection, setSortDirection] = useState('desc'); + const [trendPlate, setTrendPlate] = useState(null); + const trendPanelRef = useRef(null); + const filteredVehicles = useMemo(() => { + const query = search.trim().toLowerCase(); + return group.vehicles.filter(vehicle => { + if (filter !== 'ALL' && vehicle.mileageBand !== filter) return false; + if (!query) return true; + return vehicle.plate.toLowerCase().includes(query) + || (vehicle.customer || '').toLowerCase().includes(query) + || (vehicle.department || '').toLowerCase().includes(query); + }); + }, [filter, group.vehicles, search]); + const vehicles = useMemo(() => [...filteredVehicles].sort((left, right) => { + const leftValue = sortKey === 'sevenDayMileage' ? sevenDayMileageOf(left) : left[sortKey]; + const rightValue = sortKey === 'sevenDayMileage' ? sevenDayMileageOf(right) : right[sortKey]; + const difference = leftValue - rightValue; + if (difference !== 0) return sortDirection === 'asc' ? difference : -difference; + return left.plate.localeCompare(right.plate, 'zh-CN'); + }), [filteredVehicles, sortDirection, sortKey]); + const totalPages = Math.max(1, Math.ceil(vehicles.length / VEHICLE_PAGE_SIZE)); + const currentPage = Math.min(page, totalPages); + const displayedVehicles = vehicles.slice( + (currentPage - 1) * VEHICLE_PAGE_SIZE, + currentPage * VEHICLE_PAGE_SIZE, + ); + const firstVehicleIndex = vehicles.length === 0 ? 0 : (currentPage - 1) * VEHICLE_PAGE_SIZE + 1; + const lastVehicleIndex = Math.min(currentPage * VEHICLE_PAGE_SIZE, vehicles.length); + const toggleSort = (nextSortKey: VehicleSortKey) => { + setPage(1); + if (nextSortKey === sortKey) { + setSortDirection(current => current === 'asc' ? 'desc' : 'asc'); + return; + } + setSortKey(nextSortKey); + setSortDirection('desc'); + }; + const trendVehicle = trendPlate ? group.vehicles.find(vehicle => vehicle.plate === trendPlate) || null : null; + const trendData = trendVehicle?.trend?.length + ? trendVehicle.trend + : group.trend.map((point, index) => ({ + date: point.date, + totalMileage: index === group.trend.length - 1 ? trendVehicle?.dailyMileage || 0 : 0, + })); + const sevenDayTotal = roundMileageForDisplay(trendData.reduce((total, point) => total + point.totalMileage, 0)); + + useEffect(() => { + if (!trendPlate) return; + const frame = window.requestAnimationFrame(() => { + trendPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }); + return () => window.cancelAnimationFrame(frame); + }, [trendPlate]); + + return ( +
+
+
+ {VEHICLE_FILTERS.map(item => ( + + ))} +
+ +
+ + + {trendVehicle ? ( + +
+
+
+
+ + {trendVehicle.plate} + 近7日里程 +
+
+ 区间累计 {fmtKm(sevenDayTotal)} km + 日均 {fmtKm(roundMileageForDisplay(sevenDayTotal / Math.max(1, trendData.length)))} km + 当日 {fmtKm(trendVehicle.dailyMileage)} km +
+
+ +
+
+ + + + + + fmtDate(String(label))} + formatter={value => [`${fmtKm(Number(value))} km`, '当日里程']} + contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }} + /> + + + {trendData.map(point => ( + 100 ? '#2563eb' : '#94a3b8'} /> + ))} + + + +
+
+
+ ) : null} +
+ +
+
+ 车牌/状态车牌 + 状态 + 部门/地区客户 + + + + +
+
+ {displayedVehicles.map(vehicle => { + const sevenDayMileage = sevenDayMileageOf(vehicle); + const selected = trendPlate === vehicle.plate; + const frozenCellClass = selected ? '!bg-blue-50' : '!bg-white'; + return ( +
setTrendPlate(vehicle.plate)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setTrendPlate(vehicle.plate); + } + }} + className={`grid cursor-pointer grid-cols-[112px_88px_minmax(140px,1fr)_82px_90px_80px_28px] items-center gap-3 px-3 py-2.5 text-[11px] font-bold outline-none transition-colors hover:bg-slate-50 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 md:grid-cols-[110px_72px_100px_minmax(160px,1fr)_90px_100px_88px_32px] ${selected ? 'bg-blue-50/60' : ''}`} + > + + {vehicle.plate} + {vehicle.status} + + + {vehicle.department || vehicle.inventoryRegion || '未标注'} + {vehicle.customer || '未绑定客户'} + 0 ? 'text-rose-600' : 'text-slate-700'}`}> + {fmtKm(vehicle.dailyMileage)} km + + {fmtKm(sevenDayMileage)} km + {vehicle.completionRate.toFixed(1)}% + +
+ );})} + {vehicles.length === 0 ?
没有符合条件的车辆
: null} +
+
+
+ 显示 {firstVehicleIndex}-{lastVehicleIndex} / {vehicles.length} 台 + {totalPages > 1 ? ( +
+ + {currentPage}/{totalPages} + +
+ ) : null} +
+
+ ); +} + +function GroupRow({ + group, + reportDate, + expanded, + onToggle, +}: { + group: MileageReportGroup; + reportDate: string; + expanded: boolean; + onToggle: () => void; +}) { + const highRate = group.operatingCount > 0 ? (group.highMileageCount / group.operatingCount) * 100 : 0; + const qualifiedRate = group.vehicleCount > 0 ? (group.qualifiedCount / group.vehicleCount) * 100 : 0; + const assessmentYearLabel = currentAssessmentYearLabel(group.assessmentPeriods, reportDate); + const dailyStatus = dailyMileageStatus(group); + const dailyStatusClass = dailyStatus.tone === 'positive' + ? 'border-emerald-100 bg-emerald-50/70 text-emerald-700' + : dailyStatus.tone === 'warning' + ? 'border-amber-100 bg-amber-50/70 text-amber-700' + : 'border-slate-100 bg-slate-50 text-slate-600'; + return ( +
+ + + + {expanded ? ( + +
+
+ } items={group.departments} /> + } items={group.inventoryRegions} showAverage={false} /> +
+
+ 考核进度 +
+
+
+
累计完成率
+
{group.completionRate.toFixed(1)}%
+
+
+
过半车辆
+
{group.halfQualifiedCount} 台
+
+
+
剩余任务
+
{fmtKm(group.remainingMileage)} km
+
+
+
每日应完成
+
{fmtKm(group.dailyRequiredMileage)} km
+
+
+
+
当日目标执行 · {dailyStatus.label}
+
当日实际 {fmtKm(group.dailyMileage)} km / 应完成 {fmtKm(group.dailyRequiredMileage)} km
+
+
{dailyStatus.detail}
+
+
+
+
累计达标率
+
已达标 {group.qualifiedCount} / {group.vehicleCount} 台
+
+
{qualifiedRate.toFixed(1)}%
+
+
+
{group.assessmentPeriods.join(';')}
+
+
+ +
+
+ ) : null} +
+
+ ); +} + +function roundMileageForDisplay(value: number): number { + return Math.round(value * 10) / 10; +} + +export default function DailyReportView() { + const maxDate = shanghaiYmd(-1); + const [selectedDate, setSelectedDate] = useState(initialReportDate); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [expandedTargetId, setExpandedTargetId] = useState(null); + const [trendTargetId, setTrendTargetId] = useState(null); + + useEffect(() => { + let active = true; + setLoading(true); + setError(''); + updateReportDateInUrl(selectedDate); + fetchDailyMileageReport(selectedDate) + .then(data => { + if (!active) return; + setReport(data); + setExpandedTargetId(current => current != null && data.groups.some(group => group.targetId === current) ? current : null); + setTrendTargetId(current => current != null && data.groups.some(group => group.targetId === current) ? current : null); + }) + .catch(cause => { + if (!active) return; + setError(cause instanceof Error ? cause.message : '日报加载失败'); + }) + .finally(() => { if (active) setLoading(false); }); + return () => { active = false; }; + }, [selectedDate]); + + const highMileageRate = report && report.totals.operatingCount > 0 + ? (report.totals.highMileageCount / report.totals.operatingCount) * 100 + : 0; + const comparisonData = report?.groups.map(group => ({ + name: group.displayName, + 运营里程: group.operatingMileage, + 库存里程: group.inventoryMileage, + })) || []; + const visibleQualityNotes = report?.qualityNotes.filter( + note => note.message !== '日行驶总里程延续人工汇报口径,包含库存车辆产生的里程。', + ) || []; + + if (loading && !report) return ; + if (!report) return ; + + const trendGroup = trendTargetId == null ? null : report.groups.find(group => group.targetId === trendTargetId) || null; + const trendData = trendGroup?.trend || report.trend; + const trendName = trendGroup?.displayName || '总计'; + const trendSubtitle = '选择总计或车型查看近 7 日每日里程,包含库存车辆里程'; + const trendColor = trendGroup ? '#0f766e' : '#2563eb'; + + return ( +
+
+
+
+
+

{report.title}

+ + {report.status === 'ARCHIVED' ? : } + {report.status === 'ARCHIVED' ? '已归档' : '预览'} + +
+

+ {fmtDate(report.reportDate)} · {report.source === 'XLSX_IMPORT' + ? '人工台账归档' + : `仪表数据更新至 ${fmtDateTime(report.sourceUpdatedAt)}`} +

+

+ 统计口径:每天 00:30 自动汇总前一日里程 +

+
+ +
+ + + +
+
+ +
+ + {visibleQualityNotes.length > 0 ? ( +
+ {visibleQualityNotes.map(note => ( +
+ {note.level === 'warning' ? : } + {note.message} +
+ ))} +
+ ) : null} + + + +
+ + + 0 ? 'rose' : 'amber'} + compact + /> + + +
+ +
+ {report.insights.map(insight => { + const toneClass = insight.tone === 'positive' + ? 'text-emerald-600 bg-emerald-50' + : insight.tone === 'critical' + ? 'text-rose-600 bg-rose-50' + : insight.tone === 'warning' + ? 'text-amber-600 bg-amber-50' + : 'text-blue-600 bg-blue-50'; + return ( +
+ +
+
{insight.title}
+
{insight.detail}
+
+
+ ); + })} +
+ +
+ + + {report.groups.map(group => ( + + ))} +
+ )} + > +
+ + + + + + fmtDate(String(label))} + formatter={value => [`${fmtKm(Number(value))} km`, trendName]} + contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }} + /> + + + +
+ + + + 车型里程构成 + 统计日期 {fmtDate(report.reportDate)} + + )} + subtitle="运营与库存里程分开展示" + > +
+ + + + + + [`${fmtKm(Number(value))} km`, String(name)]} contentStyle={{ borderRadius: 8, border: '1px solid #e2e8f0', fontSize: 11 }} /> + + + + + +
+
+
+ +
+
+ 里程分档口径 +
+
+ 低里程:当日里程 ≤100km + 高里程:当日里程 >100km + 仅统计运营车辆,库存车辆单独统计 +
+
+ + + 车型运营明细 + 统计日期 {fmtDate(report.reportDate)} + + )} + subtitle="点击车型查看部门、库存地区、考核进度及车辆近7日趋势" + > +
+ 考核车型当日里程环比变化低/高里程高里程占比年度达标 +
+ {report.groups.map(group => ( + setExpandedTargetId(current => current === group.targetId ? null : group.targetId)} + /> + ))} +
+ + {loading ? ( +
+ 正在切换日报 +
+ ) : null} + {error ? : null} +
); } diff --git a/src/modules/mileage/MileageModule.tsx b/src/modules/mileage/MileageModule.tsx index 51fa9d5..9466e5f 100644 --- a/src/modules/mileage/MileageModule.tsx +++ b/src/modules/mileage/MileageModule.tsx @@ -28,6 +28,7 @@ export default function MileageModule() { eyebrow="MILEAGE BI" meta="实时监控 · 统计报表 · 每日汇报" compactInfo + hideHeader >
diff --git a/src/modules/mileage/api.ts b/src/modules/mileage/api.ts index 6170e04..5b70579 100644 --- a/src/modules/mileage/api.ts +++ b/src/modules/mileage/api.ts @@ -8,6 +8,20 @@ import type { TrendPoint, } from './types'; import { fetchJson } from '../../auth/api-client'; +import type { + DailyMileageReport, + MileageReportHistoryResponse, +} from '../../shared/mileage/daily-report'; + +export type { + DailyMileageReport, + MileageReportBreakdown, + MileageReportGroup, + MileageReportHistoryItem, + MileageReportHistoryResponse, + MileageReportInsight, + MileageReportVehicle, +} from '../../shared/mileage/daily-report'; const BASE = '/api/mileage'; @@ -123,134 +137,10 @@ 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 async function fetchDailyMileageReport(date: string): Promise { + return fetchJson(`${BASE}/reports?date=${encodeURIComponent(date)}`); } -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); - if (!Number.isNaN(d.getTime())) return d.toISOString().slice(0, 10); - 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, '冷链车'); -} - -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([ - fetchTargets(), - fetchTrend(undefined, 7), - fetchMonitoring({ limit: 1 }), - fetchMonitoring({ sortBy: 'today', sortOrder: 'desc', limit: 5 }), - ]); - - const targetVehiclesEntries = await Promise.all( - targets.map(async target => { - const vehicles = await fetchTargetVehicles(target.id); - return [target.id, vehicles] as const; - }), - ); - 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(); - 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 => ({ - plate: vehicle.plate, - model: targetNameByPlate.get(vehicle.plate) || vehicle.project || '未归入考核', - status: normalizeStatus(vehicle.rentStatus), - today: vehicle.dailyKm, - customer: vehicle.customer || '未绑定客户', - })); - - 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 fetchDailyMileageReportHistory(limit = 90): Promise { + return fetchJson(`${BASE}/reports/history?limit=${limit}`); } diff --git a/src/modules/mileage/types.ts b/src/modules/mileage/types.ts index a31894c..3ca395f 100644 --- a/src/modules/mileage/types.ts +++ b/src/modules/mileage/types.ts @@ -24,6 +24,7 @@ export interface MonitoringVehicle { entity: string | null; project: string | null; region: string | null; + inventoryLocation: string | null; brand: string | null; targetNames: string[]; } diff --git a/src/server/routes/mileage/cache.ts b/src/server/routes/mileage/cache.ts index 962b8c9..51e7665 100644 --- a/src/server/routes/mileage/cache.ts +++ b/src/server/routes/mileage/cache.ts @@ -213,6 +213,7 @@ function mergeVehicles( entity: info.entity || null, project: info.project || null, region: regionMap[plate] || null, + inventoryLocation: info.inventory_location || null, brand: info.brand_label || null, targetNames: targetNamesByPlate.get(plate) || [], yesterdayKm: yesterdayMap.get(plate) || 0, diff --git a/src/server/routes/mileage/daily-report-model.test.ts b/src/server/routes/mileage/daily-report-model.test.ts new file mode 100644 index 0000000..bb3c284 --- /dev/null +++ b/src/server/routes/mileage/daily-report-model.test.ts @@ -0,0 +1,96 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { MileageReportGroup } from '../../../shared/mileage/daily-report.js'; +import { + buildDailyMileageReport, + isOperatingStatus, + mileageBand, +} from './daily-report-model.js'; + +function group(overrides: Partial = {}): MileageReportGroup { + return { + targetId: 1, + targetName: '交投40辆4.5T普货', + displayName: '40台普货', + vehicleCount: 4, + operatingCount: 3, + inventoryCount: 1, + dailyMileage: 500, + operatingMileage: 450, + inventoryMileage: 50, + yesterdayMileage: 400, + dayOverDayDelta: 0, + dayOverDayRate: null, + lowMileageCount: 2, + highMileageCount: 1, + cumulativeMileage: 1000, + totalMileageTarget: 4000, + completionRate: 25, + qualifiedCount: 1, + halfQualifiedCount: 2, + currentYearTarget: 2000, + currentYearCompleted: 1000, + remainingMileage: 1000, + dailyRequiredMileage: 600, + assessmentPeriods: [], + trend: [ + { date: '2026-03-30', totalMileage: 400 }, + { date: '2026-03-31', totalMileage: 500 }, + ], + inventoryRegions: [], + departments: [], + vehicles: [], + ...overrides, + }; +} + +test('uses the confirmed operation status and 100km boundary', () => { + assert.equal(isOperatingStatus('租赁'), true); + assert.equal(isOperatingStatus('自营'), true); + assert.equal(isOperatingStatus('可运营'), false); + assert.equal(mileageBand('租赁', 100), 'LOW'); + assert.equal(mileageBand('租赁', 100.1), 'HIGH'); + assert.equal(mileageBand('待运营', 500), 'INVENTORY'); +}); + +test('keeps inventory mileage in the headline while separating it', () => { + const report = buildDailyMileageReport({ + reportDate: '2026-03-31', + status: 'ARCHIVED', + source: 'XLSX_IMPORT', + generatedAt: '2026-04-01T09:30:00+08:00', + sourceUpdatedAt: null, + groups: [group(), group({ + targetId: 2, + vehicleCount: 2, + operatingCount: 1, + inventoryCount: 1, + dailyMileage: 200, + operatingMileage: 180, + inventoryMileage: 20, + yesterdayMileage: 100, + lowMileageCount: 0, + highMileageCount: 1, + qualifiedCount: 0, + halfQualifiedCount: 1, + dailyRequiredMileage: 100, + trend: [ + { date: '2026-03-30', totalMileage: 100 }, + { date: '2026-03-31', totalMileage: 200 }, + ], + })], + }); + + assert.equal(report.totals.vehicleCount, 6); + assert.equal(report.totals.operatingCount, 4); + assert.equal(report.totals.inventoryCount, 2); + assert.equal(report.totals.dailyMileage, 700); + assert.equal(report.totals.operatingMileage, 630); + assert.equal(report.totals.inventoryMileage, 70); + assert.equal(report.totals.dayOverDayDelta, 200); + assert.equal(report.totals.highMileageCount, 2); + assert.deepEqual(report.trend, [ + { date: '2026-03-30', totalMileage: 500 }, + { date: '2026-03-31', totalMileage: 700 }, + ]); +}); diff --git a/src/server/routes/mileage/daily-report-model.ts b/src/server/routes/mileage/daily-report-model.ts new file mode 100644 index 0000000..c0899ac --- /dev/null +++ b/src/server/routes/mileage/daily-report-model.ts @@ -0,0 +1,172 @@ +import type { + DailyMileageReport, + MileageReportGroup, + MileageReportInsight, + MileageReportQualityNote, + MileageReportSource, + MileageReportStatus, + MileageReportTotals, + MileageReportTrendPoint, +} from '../../../shared/mileage/daily-report.js'; + +export function roundMileage(value: number): number { + return Math.round((Number(value) || 0) * 10) / 10; +} + +export function isOperatingStatus(status: string | null | undefined): boolean { + return status === '租赁' || status === '自营'; +} + +export function mileageBand( + status: string | null | undefined, + dailyMileage: number, +): 'LOW' | 'HIGH' | 'INVENTORY' { + if (!isOperatingStatus(status)) return 'INVENTORY'; + return dailyMileage <= 100 ? 'LOW' : 'HIGH'; +} + +export function compactMileageTargetName(name: string): string { + return name + .replace(/^交投/, '') + .replace(/^羚牛/, '') + .replace(/辆/g, '台') + .replace(/4\.5T冷链车/g, '冷链') + .replace(/4\.5T冷链/g, '冷链') + .replace(/4\.5T普货/g, '普货') + .replace(/18T$/, '18T双飞翼'); +} + +function percentChange(current: number, previous: number): number | null { + if (previous <= 0) return null; + return Math.round(((current - previous) / previous) * 1000) / 10; +} + +function sum(groups: MileageReportGroup[], key: keyof MileageReportGroup): number { + return groups.reduce((total, group) => total + Number(group[key] || 0), 0); +} + +export function buildReportTotals(groups: MileageReportGroup[]): MileageReportTotals { + const dailyMileage = roundMileage(sum(groups, 'dailyMileage')); + const yesterdayMileage = roundMileage(sum(groups, 'yesterdayMileage')); + const operatingCount = sum(groups, 'operatingCount'); + const operatingMileage = roundMileage(sum(groups, 'operatingMileage')); + const dailyRequiredMileage = roundMileage(sum(groups, 'dailyRequiredMileage')); + return { + vehicleCount: sum(groups, 'vehicleCount'), + operatingCount, + inventoryCount: sum(groups, 'inventoryCount'), + dailyMileage, + operatingMileage, + inventoryMileage: roundMileage(sum(groups, 'inventoryMileage')), + yesterdayMileage, + dayOverDayDelta: roundMileage(dailyMileage - yesterdayMileage), + dayOverDayRate: percentChange(dailyMileage, yesterdayMileage), + averageOperatingMileage: operatingCount > 0 ? roundMileage(operatingMileage / operatingCount) : 0, + lowMileageCount: sum(groups, 'lowMileageCount'), + highMileageCount: sum(groups, 'highMileageCount'), + qualifiedCount: sum(groups, 'qualifiedCount'), + halfQualifiedCount: sum(groups, 'halfQualifiedCount'), + dailyRequiredMileage, + dailyTaskGap: roundMileage(dailyMileage - dailyRequiredMileage), + }; +} + +function describeDelta(value: number, rate: number | null): string { + const direction = value >= 0 ? '增加' : '减少'; + const rateText = rate == null ? '' : `,${Math.abs(rate).toFixed(1)}%`; + return `较前一日${direction} ${Math.abs(value).toLocaleString('zh-CN', { maximumFractionDigits: 1 })} km${rateText}`; +} + +export function buildReportInsights( + groups: MileageReportGroup[], + totals: MileageReportTotals, +): MileageReportInsight[] { + if (groups.length === 0) return []; + const largest = [...groups].sort((a, b) => b.dailyMileage - a.dailyMileage)[0]; + const weakest = [...groups].sort((a, b) => a.dayOverDayDelta - b.dayOverDayDelta)[0]; + const lowRate = totals.operatingCount > 0 ? totals.lowMileageCount / totals.operatingCount : 0; + const insights: MileageReportInsight[] = [ + { + tone: totals.dayOverDayDelta >= 0 ? 'positive' : 'warning', + title: totals.dayOverDayDelta >= 0 ? '总里程回升' : '总里程回落', + detail: describeDelta(totals.dayOverDayDelta, totals.dayOverDayRate), + }, + { + tone: 'neutral', + title: `${largest.displayName}贡献最高`, + detail: `当日 ${largest.dailyMileage.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} km,占全量 ${totals.dailyMileage > 0 ? ((largest.dailyMileage / totals.dailyMileage) * 100).toFixed(1) : '0.0'}%`, + }, + ]; + + if (weakest.dayOverDayDelta < 0) { + insights.push({ + tone: 'warning', + title: `${weakest.displayName}降幅最大`, + detail: describeDelta(weakest.dayOverDayDelta, weakest.dayOverDayRate), + }); + } else { + insights.push({ + tone: 'positive', + title: '各车型未出现环比下降', + detail: `增幅最低为 ${weakest.displayName},仍增加 ${weakest.dayOverDayDelta.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} km`, + }); + } + + if (totals.inventoryMileage > 0) { + insights.push({ + tone: 'critical', + title: '库存车辆产生里程', + detail: `${totals.inventoryCount} 台库存车辆合计产生 ${totals.inventoryMileage.toLocaleString('zh-CN', { maximumFractionDigits: 1 })} km,建议核对状态`, + }); + } else { + insights.push({ + tone: lowRate >= 0.5 ? 'warning' : 'neutral', + title: `${(lowRate * 100).toFixed(1)}% 运营车辆不超过 100km`, + detail: `${totals.lowMileageCount} 台低里程,${totals.highMileageCount} 台超过 100km`, + }); + } + return insights.slice(0, 4); +} + +export function buildOverallTrend(groups: MileageReportGroup[]): MileageReportTrendPoint[] { + const totals = new Map(); + for (const group of groups) { + for (const point of group.trend) { + totals.set(point.date, (totals.get(point.date) || 0) + point.totalMileage); + } + } + return Array.from(totals.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, totalMileage]) => ({ date, totalMileage: roundMileage(totalMileage) })); +} + +export function buildDailyMileageReport(input: { + reportDate: string; + status: MileageReportStatus; + source: MileageReportSource; + generatedAt: string; + sourceUpdatedAt: string | null; + groups: MileageReportGroup[]; + qualityNotes?: MileageReportQualityNote[]; +}): DailyMileageReport { + const groups = input.groups.map(group => ({ + ...group, + dayOverDayDelta: roundMileage(group.dailyMileage - group.yesterdayMileage), + dayOverDayRate: percentChange(group.dailyMileage, group.yesterdayMileage), + })); + const totals = buildReportTotals(groups); + return { + version: 1, + reportDate: input.reportDate, + title: `广州现代 ${totals.vehicleCount} 台车辆运营日报`, + status: input.status, + source: input.source, + generatedAt: input.generatedAt, + sourceUpdatedAt: input.sourceUpdatedAt, + totals, + trend: buildOverallTrend(groups), + groups, + insights: buildReportInsights(groups, totals), + qualityNotes: input.qualityNotes || [], + }; +} diff --git a/src/server/routes/mileage/daily-report-scheduler.ts b/src/server/routes/mileage/daily-report-scheduler.ts new file mode 100644 index 0000000..cfeff7a --- /dev/null +++ b/src/server/routes/mileage/daily-report-scheduler.ts @@ -0,0 +1,70 @@ +import { archiveDailyMileageReport, previousShanghaiDate } from './daily-report-service.js'; +import { hasDailyReportSnapshot } from './daily-report-store.js'; + +const FIRST_CHECK_DELAY_MS = 15 * 1000; +const SUMMARY_MINUTE_OF_DAY = 30; +let archiving = false; + +function shanghaiMinuteOfDay(): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(new Date()); + const hour = Number(parts.find(part => part.type === 'hour')?.value || 0); + const minute = Number(parts.find(part => part.type === 'minute')?.value || 0); + return hour * 60 + minute; +} + +function millisecondsUntilNextSummary(): number { + const now = new Date(); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(now); + const hour = Number(parts.find(part => part.type === 'hour')?.value || 0); + const minute = Number(parts.find(part => part.type === 'minute')?.value || 0); + const second = Number(parts.find(part => part.type === 'second')?.value || 0); + const currentSeconds = (hour * 60 + minute) * 60 + second; + const summarySeconds = SUMMARY_MINUTE_OF_DAY * 60; + const remainingSeconds = summarySeconds > currentSeconds + ? summarySeconds - currentSeconds + : summarySeconds + 24 * 60 * 60 - currentSeconds; + return Math.max(1_000, remainingSeconds * 1_000); +} + +async function checkAndArchive(): Promise { + if (archiving || shanghaiMinuteOfDay() < SUMMARY_MINUTE_OF_DAY) return; + const reportDate = previousShanghaiDate(); + archiving = true; + try { + if (await hasDailyReportSnapshot(reportDate)) return; + const report = await archiveDailyMileageReport(reportDate); + console.log(`[mileage-report] archived ${report.reportDate}: ${report.totals.vehicleCount} vehicles, ${report.totals.dailyMileage} km`); + } catch (error: unknown) { + console.error('[mileage-report] archive failed:', error); + } finally { + archiving = false; + } +} + +function scheduleNextArchive(): void { + setTimeout(async () => { + await checkAndArchive(); + scheduleNextArchive(); + }, millisecondsUntilNextSummary()); +} + +export function startDailyReportScheduler(): void { + if (process.env.MILEAGE_REPORT_AUTO_ARCHIVE === '0') { + console.log('[mileage-report] automatic archive disabled'); + return; + } + setTimeout(() => void checkAndArchive(), FIRST_CHECK_DELAY_MS); + scheduleNextArchive(); +} diff --git a/src/server/routes/mileage/daily-report-service.ts b/src/server/routes/mileage/daily-report-service.ts new file mode 100644 index 0000000..9474648 --- /dev/null +++ b/src/server/routes/mileage/daily-report-service.ts @@ -0,0 +1,314 @@ +import type { RowDataPacket } from 'mysql2'; +import pool from '../../db.js'; +import type { + DailyMileageReport, + MileageReportBreakdown, + MileageReportGroup, + MileageReportQualityNote, + MileageReportVehicle, +} from '../../../shared/mileage/daily-report.js'; +import { fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js'; +import { DEFAULT_ONEOS_PROTOCOL_PRIORITY } from './source-policy.js'; +import { fetchVehicleInfoByPlates } from './vehicle-info.js'; +import { + buildDailyMileageReport, + compactMileageTargetName, + isOperatingStatus, + mileageBand, + roundMileage, +} from './daily-report-model.js'; +import { getDailyReportSnapshot, saveDailyReportSnapshot } from './daily-report-store.js'; + +interface AssessmentRow extends RowDataPacket { + target_id: number; + target_name: string; + annual_mileage_per_vehicle: string | number; + assessment_vehicle_id: number; + plate_number: string; + assessment_start_date: string; + assessment_end_date: string; + total_mileage_task: string | number; + current_mileage: string | number; + completion_rate: string | number; + current_year_mileage_task: string | number; + current_year_mileage: string | number; + current_year_completion_rate: string | number; + current_year_is_qualified: number; + daily_required_mileage: string | number; + current_year_assessment_end_date: string | null; +} + +function parseYmd(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return null; + const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))); + return Number.isFinite(date.getTime()) ? date : null; +} + +function fmtYmd(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function reportDates(reportDate: string, days = 7): string[] { + const end = parseYmd(reportDate); + if (!end) throw new Error('日报日期格式不正确'); + const dates: string[] = []; + for (let offset = days - 1; offset >= 0; offset -= 1) { + const date = new Date(end); + date.setUTCDate(end.getUTCDate() - offset); + dates.push(fmtYmd(date)); + } + return dates; +} + +function numberValue(value: unknown): number { + return Number(value) || 0; +} + +function normalizeInventoryRegion(location: string | null | undefined): string { + const value = (location || '').trim(); + if (/广东|广州|深圳|佛山|东莞|珠海|惠州|中山/.test(value)) return '广东'; + if (/嘉兴|浙江|上海|江苏|杭州|苏州|无锡|宁波/.test(value)) return '嘉兴'; + if (/新疆|乌鲁木齐/.test(value)) return '新疆'; + return '其他'; +} + +function bestMileageByPlate( + dates: string[], + rowsByDate: Map, +): { daily: Map>; sourceUpdatedAt: string | null } { + const daily = new Map>(); + let sourceUpdatedAt: string | null = null; + for (const date of dates) { + const dayMap = new Map(); + for (const row of rowsByDate.get(date) || []) { + if (row.status !== 'NORMAL') continue; + const mileage = Math.max(0, numberValue(row.dailyMileageKm)); + if (mileage > (dayMap.get(row.plateNumber) || 0)) dayMap.set(row.plateNumber, mileage); + if (date === dates[dates.length - 1]) { + const timestamp = row.updatedAt || row.calculatedAt || row.dataTime; + if (timestamp && (!sourceUpdatedAt || timestamp > sourceUpdatedAt)) sourceUpdatedAt = timestamp; + } + } + daily.set(date, dayMap); + } + return { daily, sourceUpdatedAt }; +} + +function breakdown( + rows: MileageReportVehicle[], + key: 'department' | 'inventoryRegion', +): MileageReportBreakdown[] { + const groups = new Map(); + for (const row of rows) { + const name = row[key] || '未标注'; + const current = groups.get(name) || { count: 0, mileage: 0 }; + current.count += 1; + current.mileage += row.dailyMileage; + groups.set(name, current); + } + return Array.from(groups.entries()) + .map(([name, value]) => ({ + name, + vehicleCount: value.count, + totalMileage: roundMileage(value.mileage), + averageMileage: value.count > 0 ? roundMileage(value.mileage / value.count) : 0, + })) + .sort((a, b) => b.vehicleCount - a.vehicleCount || b.totalMileage - a.totalMileage); +} + +function shanghaiIsoNow(): string { + const now = new Date(Date.now() + 8 * 60 * 60 * 1000); + return `${now.toISOString().slice(0, 19)}+08:00`; +} + +async function fetchAssessmentRows(): Promise { + const [rows] = await pool.query(` + SELECT + t.id AS target_id, + t.target_name, + t.annual_mileage_per_vehicle, + v.id AS assessment_vehicle_id, + v.plate_number, + DATE_FORMAT(v.assessment_start_date, '%Y-%m-%d') AS assessment_start_date, + DATE_FORMAT(v.assessment_end_date, '%Y-%m-%d') AS assessment_end_date, + v.total_mileage_task, + v.current_mileage, + v.completion_rate, + v.current_year_mileage_task, + v.current_year_mileage, + v.current_year_completion_rate, + v.current_year_is_qualified, + v.daily_required_mileage, + DATE_FORMAT(v.current_year_assessment_end_date, '%Y-%m-%d') AS current_year_assessment_end_date + FROM lingniu_prod.tab_mileage_assessment_target t + JOIN lingniu_prod.tab_mileage_assessment_vehicle v + ON v.target_id = t.id AND v.is_deleted = 0 + WHERE t.is_deleted = 0 + ORDER BY t.id, v.plate_number + `); + return rows; +} + +function calculatedDailyRequiredMileage(row: AssessmentRow, reportDate: string): number { + const remaining = Math.max(0, numberValue(row.current_year_mileage_task) - numberValue(row.current_year_mileage)); + if (remaining === 0) return 0; + + const endDate = row.current_year_assessment_end_date ? parseYmd(row.current_year_assessment_end_date) : null; + const date = parseYmd(reportDate); + if (!endDate || !date || endDate < date) return Math.max(0, numberValue(row.daily_required_mileage)); + + const daysLeft = Math.max(1, Math.ceil((endDate.getTime() - date.getTime()) / 86400000)); + return remaining / daysLeft; +} + +export async function generateDailyMileageReport( + reportDate: string, + status: 'PREVIEW' | 'ARCHIVED' = 'PREVIEW', + source: 'LIVE' | 'AUTO' = status === 'ARCHIVED' ? 'AUTO' : 'LIVE', +): Promise { + if (!parseYmd(reportDate)) throw new Error('日报日期格式不正确'); + const assessmentRows = await fetchAssessmentRows(); + const plates = Array.from(new Set(assessmentRows.map(row => row.plate_number))); + const dates = reportDates(reportDate); + const [rowsByDate, vehicleInfo] = await Promise.all([ + fetchOneOsMileageDates(dates, plates, DEFAULT_ONEOS_PROTOCOL_PRIORITY), + fetchVehicleInfoByPlates(plates), + ]); + const mileage = bestMileageByPlate(dates, rowsByDate); + const reportDay = dates[dates.length - 1]; + const previousDay = dates[dates.length - 2]; + const targetRows = new Map(); + for (const row of assessmentRows) { + const rows = targetRows.get(row.target_id) || []; + rows.push(row); + targetRows.set(row.target_id, rows); + } + + const groups: MileageReportGroup[] = Array.from(targetRows.entries()).map(([targetId, rows]) => { + const first = rows[0]; + const vehicles: MileageReportVehicle[] = rows.map(row => { + const info = vehicleInfo.get(row.plate_number); + const statusLabel = info?.rent_status || '待运营'; + const vehicleTrend = dates.map(date => ({ + date, + totalMileage: roundMileage(mileage.daily.get(date)?.get(row.plate_number) || 0), + })); + const dailyMileage = vehicleTrend[vehicleTrend.length - 1]?.totalMileage || 0; + const operating = isOperatingStatus(statusLabel); + return { + plate: row.plate_number, + status: operating ? statusLabel : '在库', + customer: info?.customer || null, + department: operating ? info?.department || null : null, + inventoryRegion: operating ? null : normalizeInventoryRegion(info?.inventory_location), + dailyMileage, + cumulativeMileage: roundMileage(numberValue(row.current_mileage)), + completionRate: Math.round(numberValue(row.completion_rate) * 10000) / 100, + mileageBand: mileageBand(statusLabel, dailyMileage), + trend: vehicleTrend, + }; + }); + const operatingVehicles = vehicles.filter(vehicle => vehicle.mileageBand !== 'INVENTORY'); + const inventoryVehicles = vehicles.filter(vehicle => vehicle.mileageBand === 'INVENTORY'); + const periods = Array.from(new Set(rows.map(row => `${row.assessment_start_date} ~ ${row.assessment_end_date}`))); + const trend = dates.map(date => ({ + date, + totalMileage: roundMileage(rows.reduce( + (total, row) => total + (mileage.daily.get(date)?.get(row.plate_number) || 0), + 0, + )), + })); + const dailyMileage = roundMileage(trend[trend.length - 1]?.totalMileage || 0); + const yesterdayMileage = roundMileage(mileage.daily.get(previousDay) + ? trend[trend.length - 2]?.totalMileage || 0 + : 0); + const totalMileageTarget = roundMileage(rows.reduce((total, row) => total + numberValue(row.total_mileage_task), 0)); + const cumulativeMileage = roundMileage(rows.reduce((total, row) => total + numberValue(row.current_mileage), 0)); + const currentYearTarget = roundMileage(rows.reduce((total, row) => total + numberValue(row.current_year_mileage_task), 0)); + const currentYearCompleted = roundMileage(rows.reduce((total, row) => total + numberValue(row.current_year_mileage), 0)); + + return { + targetId, + targetName: first.target_name, + displayName: compactMileageTargetName(first.target_name), + vehicleCount: vehicles.length, + operatingCount: operatingVehicles.length, + inventoryCount: inventoryVehicles.length, + dailyMileage, + operatingMileage: roundMileage(operatingVehicles.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)), + inventoryMileage: roundMileage(inventoryVehicles.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)), + yesterdayMileage, + dayOverDayDelta: 0, + dayOverDayRate: null, + lowMileageCount: operatingVehicles.filter(vehicle => vehicle.mileageBand === 'LOW').length, + highMileageCount: operatingVehicles.filter(vehicle => vehicle.mileageBand === 'HIGH').length, + cumulativeMileage, + totalMileageTarget, + completionRate: totalMileageTarget > 0 ? Math.round((cumulativeMileage / totalMileageTarget) * 10000) / 100 : 0, + qualifiedCount: rows.filter(row => Number(row.current_year_is_qualified) === 1).length, + halfQualifiedCount: rows.filter(row => numberValue(row.current_year_completion_rate) >= 0.5).length, + currentYearTarget, + currentYearCompleted, + remainingMileage: roundMileage(Math.max(0, currentYearTarget - currentYearCompleted)), + dailyRequiredMileage: roundMileage(rows.reduce( + (total, row) => total + calculatedDailyRequiredMileage(row, reportDate), + 0, + )), + assessmentPeriods: periods, + trend, + inventoryRegions: breakdown(inventoryVehicles, 'inventoryRegion'), + departments: breakdown(operatingVehicles, 'department'), + vehicles: vehicles.sort((a, b) => { + if (a.mileageBand === 'INVENTORY' && b.mileageBand !== 'INVENTORY') return 1; + if (a.mileageBand !== 'INVENTORY' && b.mileageBand === 'INVENTORY') return -1; + return b.dailyMileage - a.dailyMileage || a.plate.localeCompare(b.plate, 'zh-CN'); + }), + }; + }); + + const qualityNotes: MileageReportQualityNote[] = []; + if (status === 'PREVIEW') { + qualityNotes.push({ + level: 'warning', + message: '该日期尚未归档,车辆名单、运营状态及考核进度按当前配置生成。', + }); + } + + return buildDailyMileageReport({ + reportDate, + status, + source, + generatedAt: shanghaiIsoNow(), + sourceUpdatedAt: mileage.sourceUpdatedAt, + groups, + qualityNotes, + }); +} + +export async function getOrGenerateDailyMileageReport(reportDate: string): Promise { + const snapshot = await getDailyReportSnapshot(reportDate); + if (snapshot) return snapshot; + return generateDailyMileageReport(reportDate, 'PREVIEW', 'LIVE'); +} + +export async function archiveDailyMileageReport(reportDate: string): Promise { + const existing = await getDailyReportSnapshot(reportDate); + if (existing) return existing; + const report = await generateDailyMileageReport(reportDate, 'ARCHIVED', 'AUTO'); + await saveDailyReportSnapshot(report, 'AUTO'); + return report; +} + +export function previousShanghaiDate(): string { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + const today = formatter.format(new Date()); + const date = parseYmd(today)!; + date.setUTCDate(date.getUTCDate() - 1); + return fmtYmd(date); +} diff --git a/src/server/routes/mileage/daily-report-store.ts b/src/server/routes/mileage/daily-report-store.ts new file mode 100644 index 0000000..ab54637 --- /dev/null +++ b/src/server/routes/mileage/daily-report-store.ts @@ -0,0 +1,136 @@ +import type { ResultSetHeader, RowDataPacket } from 'mysql2'; +import pool from '../../db.js'; +import type { + DailyMileageReport, + MileageReportHistoryItem, + MileageReportHistoryResponse, + MileageReportSource, +} from '../../../shared/mileage/daily-report.js'; + +const TABLE = 'lingniu_prod.tab_mileage_daily_report'; + +const CREATE_TABLE_SQL = ` +CREATE TABLE IF NOT EXISTS ${TABLE} ( + id BIGINT NOT NULL AUTO_INCREMENT, + report_date DATE NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ARCHIVED', + source VARCHAR(20) NOT NULL, + vehicle_count INT NOT NULL DEFAULT 0, + operating_count INT NOT NULL DEFAULT 0, + inventory_count INT NOT NULL DEFAULT 0, + daily_mileage DECIMAL(14,2) NOT NULL DEFAULT 0, + day_over_day_rate DECIMAL(8,2) NULL, + report_payload JSON NOT NULL, + generated_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_report_date (report_date), + KEY idx_generated_at (generated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='里程每日汇报归档快照' +`; + +let ensured = false; + +export async function ensureDailyReportTable(): Promise { + if (ensured) return; + await pool.query(CREATE_TABLE_SQL); + ensured = true; +} + +function parsePayload(value: unknown): DailyMileageReport | null { + if (!value) return null; + if (typeof value === 'object') return value as DailyMileageReport; + if (typeof value !== 'string') return null; + try { + return JSON.parse(value) as DailyMileageReport; + } catch { + return null; + } +} + +export async function getDailyReportSnapshot(reportDate: string): Promise { + await ensureDailyReportTable(); + const [rows] = await pool.query( + `SELECT report_payload FROM ${TABLE} WHERE report_date = ? LIMIT 1`, + [reportDate], + ); + return parsePayload(rows[0]?.report_payload); +} + +export async function hasDailyReportSnapshot(reportDate: string): Promise { + await ensureDailyReportTable(); + const [rows] = await pool.query( + `SELECT 1 AS found FROM ${TABLE} WHERE report_date = ? LIMIT 1`, + [reportDate], + ); + return rows.length > 0; +} + +export async function saveDailyReportSnapshot( + report: DailyMileageReport, + source: MileageReportSource, + options: { replace?: boolean } = {}, +): Promise { + await ensureDailyReportTable(); + const archived: DailyMileageReport = { ...report, status: 'ARCHIVED', source }; + const values = [ + archived.reportDate, + archived.status, + source, + archived.totals.vehicleCount, + archived.totals.operatingCount, + archived.totals.inventoryCount, + archived.totals.dailyMileage, + archived.totals.dayOverDayRate, + JSON.stringify(archived), + archived.generatedAt.replace('T', ' ').replace(/Z$/, '').slice(0, 19), + ]; + + const insert = options.replace ? 'REPLACE' : 'INSERT IGNORE'; + const [result] = await pool.query( + `${insert} INTO ${TABLE} + (report_date, status, source, vehicle_count, operating_count, inventory_count, + daily_mileage, day_over_day_rate, report_payload, generated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?)`, + values, + ); + return result.affectedRows > 0; +} + +export async function updateDailyReportSnapshotPayload(report: DailyMileageReport): Promise { + await ensureDailyReportTable(); + const [result] = await pool.query( + `UPDATE ${TABLE} + SET report_payload = CAST(? AS JSON) + WHERE report_date = ?`, + [JSON.stringify(report), report.reportDate], + ); + return result.affectedRows > 0; +} + +export async function listDailyReportHistory(limit = 90): Promise { + await ensureDailyReportTable(); + const safeLimit = Math.min(366, Math.max(1, limit)); + const [rows] = await pool.query( + `SELECT DATE_FORMAT(report_date, '%Y-%m-%d') AS report_date, + status, source, vehicle_count, operating_count, inventory_count, + daily_mileage, day_over_day_rate, + DATE_FORMAT(generated_at, '%Y-%m-%dT%H:%i:%s+08:00') AS generated_at + FROM ${TABLE} + ORDER BY report_date DESC + LIMIT ?`, + [safeLimit], + ); + const items: MileageReportHistoryItem[] = rows.map(row => ({ + reportDate: row.report_date, + status: row.status, + source: row.source, + generatedAt: row.generated_at, + vehicleCount: Number(row.vehicle_count) || 0, + operatingCount: Number(row.operating_count) || 0, + inventoryCount: Number(row.inventory_count) || 0, + dailyMileage: Number(row.daily_mileage) || 0, + dayOverDayRate: row.day_over_day_rate == null ? null : Number(row.day_over_day_rate), + })); + return { items, latestDate: items[0]?.reportDate || null }; +} diff --git a/src/server/routes/mileage/daily-report.ts b/src/server/routes/mileage/daily-report.ts new file mode 100644 index 0000000..371898d --- /dev/null +++ b/src/server/routes/mileage/daily-report.ts @@ -0,0 +1,32 @@ +import { Hono } from 'hono'; +import { getOrGenerateDailyMileageReport } from './daily-report-service.js'; +import { listDailyReportHistory } from './daily-report-store.js'; + +const app = new Hono(); + +function isYmd(value: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(value); +} + +app.get('/history', async (c) => { + try { + const limit = Number(c.req.query('limit')) || 90; + return c.json(await listDailyReportHistory(limit)); + } catch (error: unknown) { + console.error('mileage daily report history error:', error); + return c.json({ items: [], latestDate: null }, 500); + } +}); + +app.get('/', async (c) => { + const date = c.req.query('date') || ''; + if (!isYmd(date)) return c.json({ message: 'date 必须为 YYYY-MM-DD' }, 400); + try { + return c.json(await getOrGenerateDailyMileageReport(date)); + } catch (error: unknown) { + console.error('mileage daily report error:', error); + return c.json({ message: error instanceof Error ? error.message : '日报生成失败' }, 500); + } +}); + +export default app; diff --git a/src/server/routes/mileage/index.ts b/src/server/routes/mileage/index.ts index 6caccdc..824d04c 100644 --- a/src/server/routes/mileage/index.ts +++ b/src/server/routes/mileage/index.ts @@ -4,6 +4,8 @@ import monitoringRouter from './monitoring.js'; import targetsRouter from './targets.js'; import trendRouter from './trend.js'; import vehicleRecentRouter from './vehicle-recent.js'; +import dailyReportRouter from './daily-report.js'; +import { startDailyReportScheduler } from './daily-report-scheduler.js'; const app = new Hono(); @@ -12,9 +14,11 @@ app.route('/targets', targetsRouter); app.route('/target', targetsRouter); app.route('/trend', trendRouter); app.route('/vehicle', vehicleRecentRouter); +app.route('/reports', dailyReportRouter); // 启动时立即刷新缓存,之后每分钟刷新 refreshMonitoringCache(); setInterval(refreshMonitoringCache, 60 * 1000); +startDailyReportScheduler(); export default app; diff --git a/src/server/routes/mileage/types.ts b/src/server/routes/mileage/types.ts index 028a3d4..4344325 100644 --- a/src/server/routes/mileage/types.ts +++ b/src/server/routes/mileage/types.ts @@ -22,6 +22,7 @@ export interface CachedVehicle { entity: string | null; project: string | null; region: string | null; + inventoryLocation: string | null; brand: string | null; targetNames: string[]; yesterdayKm: number; @@ -89,5 +90,6 @@ export interface VehicleInfoRow { rent_status: string | null; entity: string | null; project: string | null; + inventory_location: string | null; brand_label: string | null; } diff --git a/src/server/routes/mileage/vehicle-info.ts b/src/server/routes/mileage/vehicle-info.ts index 8f5d181..64fa258 100644 --- a/src/server/routes/mileage/vehicle-info.ts +++ b/src/server/routes/mileage/vehicle-info.ts @@ -19,6 +19,7 @@ export const VEHICLE_INFO_SQL = `SELECT END AS rent_status, NULLIF(vi.registered_ownership, '') AS entity, COALESCE(c.project_name, vor.project_name) AS project, + COALESCE(vi_operation_city.NAME, NULLIF(vi.operation_city, '')) AS inventory_location, CASE vm.brand WHEN 'hyundai' THEN CASE WHEN vm.model LIKE '%帕力安%' OR vm.model LIKE '%冷链%' OR vm.model LIKE '%双飞翼%' THEN '帕力安牌' ELSE '现代' END WHEN 'yuejin' THEN '跃进' @@ -46,6 +47,9 @@ LEFT JOIN vehicle_status vs LEFT JOIN vehicle_model vm ON vm.id = vi.vehicle_model_id AND vm.del_flag = '0' +LEFT JOIN common_district vi_operation_city + ON vi_operation_city.CODE = vi.operation_city COLLATE utf8mb4_unicode_ci + AND vi_operation_city.STATUS = 'VALID' LEFT JOIN vehicle_lease_order_record vor ON vor.vehicle_id = vi.id AND vor.del_flag = '0' diff --git a/src/shared/mileage/daily-report.ts b/src/shared/mileage/daily-report.ts new file mode 100644 index 0000000..977b4e4 --- /dev/null +++ b/src/shared/mileage/daily-report.ts @@ -0,0 +1,122 @@ +export type MileageReportStatus = 'PREVIEW' | 'ARCHIVED'; +export type MileageReportSource = 'LIVE' | 'AUTO' | 'XLSX_IMPORT'; +export type MileageReportTone = 'positive' | 'warning' | 'critical' | 'neutral'; + +export interface MileageReportTrendPoint { + date: string; + totalMileage: number; +} + +export interface MileageReportBreakdown { + name: string; + vehicleCount: number; + totalMileage: number; + averageMileage: number; +} + +export interface MileageReportVehicle { + plate: string; + status: string; + customer: string | null; + department: string | null; + inventoryRegion: string | null; + dailyMileage: number; + cumulativeMileage: number; + completionRate: number; + mileageBand: 'LOW' | 'HIGH' | 'INVENTORY'; + /** 以日报日期为结束日的近7日逐日里程;旧快照迁移前可能为空。 */ + trend?: MileageReportTrendPoint[]; +} + +export interface MileageReportGroup { + targetId: number; + targetName: string; + displayName: string; + vehicleCount: number; + operatingCount: number; + inventoryCount: number; + dailyMileage: number; + operatingMileage: number; + inventoryMileage: number; + yesterdayMileage: number; + dayOverDayDelta: number; + dayOverDayRate: number | null; + lowMileageCount: number; + highMileageCount: number; + cumulativeMileage: number; + totalMileageTarget: number; + completionRate: number; + qualifiedCount: number; + halfQualifiedCount: number; + currentYearTarget: number; + currentYearCompleted: number; + remainingMileage: number; + dailyRequiredMileage: number; + assessmentPeriods: string[]; + trend: MileageReportTrendPoint[]; + inventoryRegions: MileageReportBreakdown[]; + departments: MileageReportBreakdown[]; + vehicles: MileageReportVehicle[]; +} + +export interface MileageReportTotals { + vehicleCount: number; + operatingCount: number; + inventoryCount: number; + dailyMileage: number; + operatingMileage: number; + inventoryMileage: number; + yesterdayMileage: number; + dayOverDayDelta: number; + dayOverDayRate: number | null; + averageOperatingMileage: number; + lowMileageCount: number; + highMileageCount: number; + qualifiedCount: number; + halfQualifiedCount: number; + dailyRequiredMileage: number; + dailyTaskGap: number; +} + +export interface MileageReportInsight { + tone: MileageReportTone; + title: string; + detail: string; +} + +export interface MileageReportQualityNote { + level: 'info' | 'warning'; + message: string; +} + +export interface DailyMileageReport { + version: 1; + reportDate: string; + title: string; + status: MileageReportStatus; + source: MileageReportSource; + generatedAt: string; + sourceUpdatedAt: string | null; + totals: MileageReportTotals; + trend: MileageReportTrendPoint[]; + groups: MileageReportGroup[]; + insights: MileageReportInsight[]; + qualityNotes: MileageReportQualityNote[]; +} + +export interface MileageReportHistoryItem { + reportDate: string; + status: MileageReportStatus; + source: MileageReportSource; + generatedAt: string; + vehicleCount: number; + operatingCount: number; + inventoryCount: number; + dailyMileage: number; + dayOverDayRate: number | null; +} + +export interface MileageReportHistoryResponse { + items: MileageReportHistoryItem[]; + latestDate: string | null; +}