318 lines
13 KiB
TypeScript
318 lines
13 KiB
TypeScript
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,
|
|
options: { replace?: boolean } = {},
|
|
): Promise<DailyMileageReport> {
|
|
const existing = await getDailyReportSnapshot(reportDate);
|
|
if (existing && !options.replace) return existing;
|
|
const report = await generateDailyMileageReport(reportDate, 'ARCHIVED', 'AUTO');
|
|
await saveDailyReportSnapshot(report, 'AUTO', { replace: options.replace });
|
|
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);
|
|
}
|