feat(mileage): add daily operations report

This commit is contained in:
kkfluous
2026-08-12 23:05:07 +08:00
parent 17c839a35e
commit bd0627d00f
19 changed files with 2320 additions and 149 deletions
+1
View File
@@ -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,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<void> {
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();
}
@@ -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 };
}
+32
View File
@@ -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
View File
@@ -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;
+2
View File
@@ -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'