import type { RowDataPacket } from 'mysql2'; import pool from '../../db.js'; import type { DailyMileageReport, MileageReportBreakdown, MileageReportGroup, MileageReportQualityNote, MileageReportVehicle, } from '../../../shared/mileage/daily-report.js'; import { fetchOneOsMileageDates, type OneOsDailyMileage } from './oneos-api.js'; import { DEFAULT_ONEOS_PROTOCOL_PRIORITY } from './source-policy.js'; import { fetchVehicleInfoByPlates } from './vehicle-info.js'; import { buildDailyMileageReport, compactMileageTargetName, isOperatingStatus, mileageBand, roundMileage, } from './daily-report-model.js'; import { getDailyReportSnapshot, saveDailyReportSnapshot } from './daily-report-store.js'; interface AssessmentRow extends RowDataPacket { target_id: number; target_name: string; annual_mileage_per_vehicle: string | number; assessment_vehicle_id: number; plate_number: string; assessment_start_date: string; assessment_end_date: string; total_mileage_task: string | number; current_mileage: string | number; completion_rate: string | number; current_year_mileage_task: string | number; current_year_mileage: string | number; current_year_completion_rate: string | number; current_year_is_qualified: number; daily_required_mileage: string | number; current_year_assessment_end_date: string | null; } function parseYmd(value: string): Date | null { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); if (!match) return null; const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]))); return Number.isFinite(date.getTime()) ? date : null; } function fmtYmd(date: Date): string { return date.toISOString().slice(0, 10); } function reportDates(reportDate: string, days = 7): string[] { const end = parseYmd(reportDate); if (!end) throw new Error('日报日期格式不正确'); const dates: string[] = []; for (let offset = days - 1; offset >= 0; offset -= 1) { const date = new Date(end); date.setUTCDate(end.getUTCDate() - offset); dates.push(fmtYmd(date)); } return dates; } function numberValue(value: unknown): number { return Number(value) || 0; } function normalizeInventoryRegion(location: string | null | undefined): string { const value = (location || '').trim(); if (/广东|广州|深圳|佛山|东莞|珠海|惠州|中山/.test(value)) return '广东'; if (/嘉兴|浙江|上海|江苏|杭州|苏州|无锡|宁波/.test(value)) return '嘉兴'; if (/新疆|乌鲁木齐/.test(value)) return '新疆'; return '其他'; } function bestMileageByPlate( dates: string[], rowsByDate: Map, ): { daily: Map>; sourceUpdatedAt: string | null } { const daily = new Map>(); let sourceUpdatedAt: string | null = null; for (const date of dates) { const dayMap = new Map(); for (const row of rowsByDate.get(date) || []) { if (row.status !== 'NORMAL') continue; const mileage = Math.max(0, numberValue(row.dailyMileageKm)); if (mileage > (dayMap.get(row.plateNumber) || 0)) dayMap.set(row.plateNumber, mileage); if (date === dates[dates.length - 1]) { const timestamp = row.updatedAt || row.calculatedAt || row.dataTime; if (timestamp && (!sourceUpdatedAt || timestamp > sourceUpdatedAt)) sourceUpdatedAt = timestamp; } } daily.set(date, dayMap); } return { daily, sourceUpdatedAt }; } function breakdown( rows: MileageReportVehicle[], key: 'department' | 'inventoryRegion', ): MileageReportBreakdown[] { const groups = new Map(); for (const row of rows) { const name = row[key] || '未标注'; const current = groups.get(name) || { count: 0, mileage: 0 }; current.count += 1; current.mileage += row.dailyMileage; groups.set(name, current); } return Array.from(groups.entries()) .map(([name, value]) => ({ name, vehicleCount: value.count, totalMileage: roundMileage(value.mileage), averageMileage: value.count > 0 ? roundMileage(value.mileage / value.count) : 0, })) .sort((a, b) => b.vehicleCount - a.vehicleCount || b.totalMileage - a.totalMileage); } function shanghaiIsoNow(): string { const now = new Date(Date.now() + 8 * 60 * 60 * 1000); return `${now.toISOString().slice(0, 19)}+08:00`; } async function fetchAssessmentRows(): Promise { const [rows] = await pool.query(` SELECT t.id AS target_id, t.target_name, t.annual_mileage_per_vehicle, v.id AS assessment_vehicle_id, v.plate_number, DATE_FORMAT(v.assessment_start_date, '%Y-%m-%d') AS assessment_start_date, DATE_FORMAT(v.assessment_end_date, '%Y-%m-%d') AS assessment_end_date, v.total_mileage_task, v.current_mileage, v.completion_rate, v.current_year_mileage_task, v.current_year_mileage, v.current_year_completion_rate, v.current_year_is_qualified, v.daily_required_mileage, DATE_FORMAT(v.current_year_assessment_end_date, '%Y-%m-%d') AS current_year_assessment_end_date FROM lingniu_prod.tab_mileage_assessment_target t JOIN lingniu_prod.tab_mileage_assessment_vehicle v ON v.target_id = t.id AND v.is_deleted = 0 WHERE t.is_deleted = 0 ORDER BY t.id, v.plate_number `); return rows; } function calculatedDailyRequiredMileage(row: AssessmentRow, reportDate: string): number { const remaining = Math.max(0, numberValue(row.current_year_mileage_task) - numberValue(row.current_year_mileage)); if (remaining === 0) return 0; const endDate = row.current_year_assessment_end_date ? parseYmd(row.current_year_assessment_end_date) : null; const date = parseYmd(reportDate); if (!endDate || !date || endDate < date) return Math.max(0, numberValue(row.daily_required_mileage)); const daysLeft = Math.max(1, Math.ceil((endDate.getTime() - date.getTime()) / 86400000)); return remaining / daysLeft; } export async function generateDailyMileageReport( reportDate: string, status: 'PREVIEW' | 'ARCHIVED' = 'PREVIEW', source: 'LIVE' | 'AUTO' = status === 'ARCHIVED' ? 'AUTO' : 'LIVE', ): Promise { if (!parseYmd(reportDate)) throw new Error('日报日期格式不正确'); const assessmentRows = await fetchAssessmentRows(); const plates = Array.from(new Set(assessmentRows.map(row => row.plate_number))); const dates = reportDates(reportDate); const [rowsByDate, vehicleInfo] = await Promise.all([ fetchOneOsMileageDates(dates, plates, DEFAULT_ONEOS_PROTOCOL_PRIORITY), fetchVehicleInfoByPlates(plates), ]); const mileage = bestMileageByPlate(dates, rowsByDate); const reportDay = dates[dates.length - 1]; const previousDay = dates[dates.length - 2]; const targetRows = new Map(); for (const row of assessmentRows) { const rows = targetRows.get(row.target_id) || []; rows.push(row); targetRows.set(row.target_id, rows); } const groups: MileageReportGroup[] = Array.from(targetRows.entries()).map(([targetId, rows]) => { const first = rows[0]; const vehicles: MileageReportVehicle[] = rows.map(row => { const info = vehicleInfo.get(row.plate_number); const statusLabel = info?.rent_status || '待运营'; const vehicleTrend = dates.map(date => ({ date, totalMileage: roundMileage(mileage.daily.get(date)?.get(row.plate_number) || 0), })); const dailyMileage = vehicleTrend[vehicleTrend.length - 1]?.totalMileage || 0; const operating = isOperatingStatus(statusLabel); return { plate: row.plate_number, status: operating ? statusLabel : '在库', customer: info?.customer || null, department: operating ? info?.department || null : null, inventoryRegion: operating ? null : normalizeInventoryRegion(info?.inventory_location), dailyMileage, cumulativeMileage: roundMileage(numberValue(row.current_mileage)), completionRate: Math.round(numberValue(row.completion_rate) * 10000) / 100, mileageBand: mileageBand(statusLabel, dailyMileage), trend: vehicleTrend, }; }); const operatingVehicles = vehicles.filter(vehicle => vehicle.mileageBand !== 'INVENTORY'); const inventoryVehicles = vehicles.filter(vehicle => vehicle.mileageBand === 'INVENTORY'); const periods = Array.from(new Set(rows.map(row => `${row.assessment_start_date} ~ ${row.assessment_end_date}`))); const trend = dates.map(date => ({ date, totalMileage: roundMileage(rows.reduce( (total, row) => total + (mileage.daily.get(date)?.get(row.plate_number) || 0), 0, )), })); const dailyMileage = roundMileage(trend[trend.length - 1]?.totalMileage || 0); const yesterdayMileage = roundMileage(mileage.daily.get(previousDay) ? trend[trend.length - 2]?.totalMileage || 0 : 0); const totalMileageTarget = roundMileage(rows.reduce((total, row) => total + numberValue(row.total_mileage_task), 0)); const cumulativeMileage = roundMileage(rows.reduce((total, row) => total + numberValue(row.current_mileage), 0)); const currentYearTarget = roundMileage(rows.reduce((total, row) => total + numberValue(row.current_year_mileage_task), 0)); const currentYearCompleted = roundMileage(rows.reduce((total, row) => total + numberValue(row.current_year_mileage), 0)); return { targetId, targetName: first.target_name, displayName: compactMileageTargetName(first.target_name), vehicleCount: vehicles.length, operatingCount: operatingVehicles.length, inventoryCount: inventoryVehicles.length, dailyMileage, operatingMileage: roundMileage(operatingVehicles.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)), inventoryMileage: roundMileage(inventoryVehicles.reduce((total, vehicle) => total + vehicle.dailyMileage, 0)), yesterdayMileage, dayOverDayDelta: 0, dayOverDayRate: null, lowMileageCount: operatingVehicles.filter(vehicle => vehicle.mileageBand === 'LOW').length, highMileageCount: operatingVehicles.filter(vehicle => vehicle.mileageBand === 'HIGH').length, cumulativeMileage, totalMileageTarget, completionRate: totalMileageTarget > 0 ? Math.round((cumulativeMileage / totalMileageTarget) * 10000) / 100 : 0, qualifiedCount: rows.filter(row => Number(row.current_year_is_qualified) === 1).length, halfQualifiedCount: rows.filter(row => numberValue(row.current_year_completion_rate) >= 0.5).length, currentYearTarget, currentYearCompleted, remainingMileage: roundMileage(Math.max(0, currentYearTarget - currentYearCompleted)), dailyRequiredMileage: roundMileage(rows.reduce( (total, row) => total + calculatedDailyRequiredMileage(row, reportDate), 0, )), assessmentPeriods: periods, trend, inventoryRegions: breakdown(inventoryVehicles, 'inventoryRegion'), departments: breakdown(operatingVehicles, 'department'), vehicles: vehicles.sort((a, b) => { if (a.mileageBand === 'INVENTORY' && b.mileageBand !== 'INVENTORY') return 1; if (a.mileageBand !== 'INVENTORY' && b.mileageBand === 'INVENTORY') return -1; return b.dailyMileage - a.dailyMileage || a.plate.localeCompare(b.plate, 'zh-CN'); }), }; }); const qualityNotes: MileageReportQualityNote[] = []; if (status === 'PREVIEW') { qualityNotes.push({ level: 'warning', message: '该日期尚未归档,车辆名单、运营状态及考核进度按当前配置生成。', }); } return buildDailyMileageReport({ reportDate, status, source, generatedAt: shanghaiIsoNow(), sourceUpdatedAt: mileage.sourceUpdatedAt, groups, qualityNotes, }); } export async function getOrGenerateDailyMileageReport(reportDate: string): Promise { const snapshot = await getDailyReportSnapshot(reportDate); if (snapshot) return snapshot; return generateDailyMileageReport(reportDate, 'PREVIEW', 'LIVE'); } export async function archiveDailyMileageReport( reportDate: string, options: { replace?: boolean } = {}, ): Promise { 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); }