Compare commits
3
Commits
17c839a35e
...
d610e4b841
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d610e4b841 | ||
|
|
7fce6b8474 | ||
|
|
bd0627d00f |
+2
-1
@@ -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",
|
||||
|
||||
@@ -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<Map<string, MileageReportTrendPoint[]>> {
|
||||
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<string, MileageReportTrendPoint[]>();
|
||||
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<SnapshotRow[]>(`
|
||||
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();
|
||||
}
|
||||
@@ -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<string, Record<string, Record<string, number>>> = {
|
||||
'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<unknown[]>(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<string, MileageReportVehicle[]>();
|
||||
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<void> {
|
||||
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<TargetRow[]>(
|
||||
'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();
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="min-h-screen bg-[var(--app-bg)] text-slate-800 font-sans p-3 md:p-6 relative" style={{ overflowX: 'clip' }}>
|
||||
<div className={cn(maxWidth, 'mx-auto flex flex-col gap-4 pb-20 md:pb-6')}>
|
||||
<motion.header
|
||||
{!hideHeader ? <motion.header
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: 'easeOut' }}
|
||||
@@ -106,7 +108,7 @@ export function PageFrame({
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</motion.header>
|
||||
</motion.header> : null}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md">
|
||||
<div className={cn(
|
||||
'rounded-2xl border border-slate-100 bg-white shadow-sm transition-all hover:-translate-y-0.5 hover:shadow-md',
|
||||
compact ? 'p-3' : 'p-4',
|
||||
className,
|
||||
)}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 text-[11px] font-black text-slate-400">{label}</div>
|
||||
{Icon ? (
|
||||
<span className={cn('inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1', toneClass)}>
|
||||
<Icon size={18} />
|
||||
<span className={cn('inline-flex shrink-0 items-center justify-center rounded-xl ring-1', compact ? 'h-8 w-8' : 'h-9 w-9', toneClass)}>
|
||||
<Icon size={compact ? 16 : 18} />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex min-w-0 items-end gap-1">
|
||||
<span className="min-w-0 whitespace-nowrap text-[clamp(1.65rem,6vw,2rem)] font-black leading-none tracking-tight text-slate-950 tabular-nums">
|
||||
<div className={cn('flex min-w-0 items-end gap-1', compact ? 'mt-2' : 'mt-3')}>
|
||||
<span className={cn(
|
||||
'min-w-0 whitespace-nowrap font-black leading-none tracking-tight text-slate-950 tabular-nums',
|
||||
compact ? 'text-[clamp(1.45rem,6vw,1.7rem)]' : 'text-[clamp(1.65rem,6vw,2rem)]',
|
||||
)}>
|
||||
{value}
|
||||
</span>
|
||||
{unit ? <span className="shrink-0 pb-0.5 text-[11px] font-black text-slate-400">{unit}</span> : null}
|
||||
</div>
|
||||
{helper ? <div className="mt-3 text-[11px] font-bold leading-relaxed text-slate-500">{helper}</div> : null}
|
||||
{helper ? <div className={cn('font-bold leading-relaxed text-slate-500', compact ? 'mt-2 text-[10px]' : 'mt-3 text-[11px]')}>{helper}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ export default function MileageModule() {
|
||||
eyebrow="MILEAGE BI"
|
||||
meta="实时监控 · 统计报表 · 每日汇报"
|
||||
compactInfo
|
||||
hideHeader
|
||||
>
|
||||
<div className="sticky top-0 z-30 -mx-3 bg-[var(--app-bg)] px-3 pb-2 pt-1 shadow-[0_8px_12px_-12px_rgba(15,23,42,0.08)] md:-mx-6 md:top-12 md:px-6">
|
||||
<SegmentedNav tabs={MILEAGE_TABS} active={activeSubTab} onChange={setActiveSubTab} />
|
||||
|
||||
+18
-128
@@ -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<DailyMileageReport> {
|
||||
return fetchJson<DailyMileageReport>(`${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<DailyReportData> {
|
||||
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<string, string>();
|
||||
for (const target of targets) {
|
||||
const vehicles = targetVehiclesMap.get(target.id) ?? [];
|
||||
for (const vehicle of vehicles) targetNameByPlate.set(vehicle.plateNumber, compactTargetName(target.targetName));
|
||||
}
|
||||
|
||||
const topVehicles: DailyReportVehicle[] = topMonitoring.vehicles.map(vehicle => ({
|
||||
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<MileageReportHistoryResponse> {
|
||||
return fetchJson<MileageReportHistoryResponse>(`${BASE}/reports/history?limit=${limit}`);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface MonitoringVehicle {
|
||||
entity: string | null;
|
||||
project: string | null;
|
||||
region: string | null;
|
||||
inventoryLocation: string | null;
|
||||
brand: string | null;
|
||||
targetNames: string[];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> = {}): 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 },
|
||||
]);
|
||||
});
|
||||
@@ -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<string, number>();
|
||||
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 || [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
const SUMMARY_WINDOW_MINUTES = 5;
|
||||
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<void> {
|
||||
const minuteOfDay = shanghaiMinuteOfDay();
|
||||
if (
|
||||
archiving
|
||||
|| minuteOfDay < SUMMARY_MINUTE_OF_DAY
|
||||
|| minuteOfDay >= SUMMARY_MINUTE_OF_DAY + SUMMARY_WINDOW_MINUTES
|
||||
) 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();
|
||||
}
|
||||
@@ -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<string, OneOsDailyMileage[]>,
|
||||
): { daily: Map<string, Map<string, number>>; sourceUpdatedAt: string | null } {
|
||||
const daily = new Map<string, Map<string, number>>();
|
||||
let sourceUpdatedAt: string | null = null;
|
||||
for (const date of dates) {
|
||||
const dayMap = new Map<string, number>();
|
||||
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<string, { count: number; mileage: number }>();
|
||||
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<AssessmentRow[]> {
|
||||
const [rows] = await pool.query<AssessmentRow[]>(`
|
||||
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<DailyMileageReport> {
|
||||
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<number, AssessmentRow[]>();
|
||||
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<DailyMileageReport> {
|
||||
const snapshot = await getDailyReportSnapshot(reportDate);
|
||||
if (snapshot) return snapshot;
|
||||
return generateDailyMileageReport(reportDate, 'PREVIEW', 'LIVE');
|
||||
}
|
||||
|
||||
export async function archiveDailyMileageReport(reportDate: string): Promise<DailyMileageReport> {
|
||||
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);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<DailyMileageReport | null> {
|
||||
await ensureDailyReportTable();
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`SELECT report_payload FROM ${TABLE} WHERE report_date = ? LIMIT 1`,
|
||||
[reportDate],
|
||||
);
|
||||
return parsePayload(rows[0]?.report_payload);
|
||||
}
|
||||
|
||||
export async function hasDailyReportSnapshot(reportDate: string): Promise<boolean> {
|
||||
await ensureDailyReportTable();
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`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<boolean> {
|
||||
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<ResultSetHeader>(
|
||||
`${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<boolean> {
|
||||
await ensureDailyReportTable();
|
||||
const [result] = await pool.query<ResultSetHeader>(
|
||||
`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<MileageReportHistoryResponse> {
|
||||
await ensureDailyReportTable();
|
||||
const safeLimit = Math.min(366, Math.max(1, limit));
|
||||
const [rows] = await pool.query<RowDataPacket[]>(
|
||||
`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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user