import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import pool from '../../db.js'; import { fetchVehicleInfoMap } from './vehicle-info.js'; import { fetchOneOsDailyMileage, fetchOneOsMileageDates } from './oneos-api.js'; import { sourceCategoryFromProtocol, type MileageSourceCategory, type OneOsProtocol, } from './source-policy.js'; import { buildMonitoringFilters, buildPlateTargetNamesMap, buildTargetPlatesMap, dailyMileageMap, mergeMonitoringVehicles, mileageDatesBetween, previousMileageDate, toMileageRows, type DailyMileageRow, type MileageRow, type TargetRow, } from './cache-model.js'; import type { CachedVehicle, MonitoringCache, MonitoringFilters } from './types.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const regionMap: Record = JSON.parse( readFileSync(join(__dirname, 'region-map.json'), 'utf8') ); let monitoringCache: MonitoringCache | null = null; export function getCache(): MonitoringCache | null { return monitoringCache; } function shanghaiDate(): string { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).format(new Date()); } export interface RangeMileageResult { vehicles: CachedVehicle[]; dailyTotals: { date: string; totalKm: number }[]; start: string; end: string; } async function fetchTargetRows(): Promise { return pool.execute( `SELECT t.id, t.target_name, v.plate_number 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` ).then(([rows]) => rows as TargetRow[]); } export async function refreshMonitoringCache(): Promise { try { console.log('[mileage] refreshing monitoring cache...'); const start = Date.now(); const date = shanghaiDate(); const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([ fetchOneOsDailyMileage(date), fetchOneOsDailyMileage(previousMileageDate(date)), fetchVehicleInfoMap(), fetchTargetRows(), ]); const mileageRows = toMileageRows(apiRows); const yesterdayMap = dailyMileageMap(yesterdayRows); const targetPlatesMap = buildTargetPlatesMap(targetRows); const targetNamesByPlate = buildPlateTargetNamesMap(targetRows); const targetNames = Array.from(targetPlatesMap.keys()); const vehicles = mergeMonitoringVehicles( mileageRows, infoMap, yesterdayMap, targetNamesByPlate, regionMap, ); const totalToday = Math.round(vehicles.reduce((sum, v) => sum + v.dailyKm, 0)); const totalAll = vehicles.reduce((sum, v) => sum + (v.totalKm || 0), 0); monitoringCache = { vehicles, stats: { totalToday, totalAll, vehicleCount: vehicles.length }, filters: buildMonitoringFilters(vehicles, targetNames), targetPlatesMap, updatedAt: new Date().toISOString(), }; console.log(`[mileage] cache refreshed: ${vehicles.length} vehicles in ${Date.now() - start}ms`); } catch (e: unknown) { console.error('[mileage] cache refresh error:', e); } } export async function queryDateMileage( dateStr: string, protocolPriority?: OneOsProtocol[], ): Promise { const [apiRows, yesterdayRows, infoMap, targetRows] = await Promise.all([ fetchOneOsDailyMileage(dateStr, undefined, protocolPriority), fetchOneOsDailyMileage(previousMileageDate(dateStr), undefined, protocolPriority), fetchVehicleInfoMap(), fetchTargetRows(), ]); const mileageRows = toMileageRows(apiRows); const yesterdayMap = dailyMileageMap(yesterdayRows); return mergeMonitoringVehicles( mileageRows, infoMap, yesterdayMap, buildPlateTargetNamesMap(targetRows), regionMap, ); } export async function queryRangeMileage( startDate: string, endDate: string, protocolPriority?: OneOsProtocol[], ): Promise { if (startDate === endDate) { const vehicles = (await queryDateMileage(startDate, protocolPriority)).map(vehicle => ({ ...vehicle, dailyMileage: { [startDate]: vehicle.dailyKm }, dailySourceProtocols: { [startDate]: vehicle.sourceProtocol }, })); return { vehicles, dailyTotals: [{ date: startDate, totalKm: Math.round(vehicles.reduce((sum, vehicle) => sum + vehicle.dailyKm, 0)), }], start: startDate, end: endDate, }; } const days = mileageDatesBetween(startDate, endDate); const [apiRowsByDate, endDateRows, yesterdayRows, infoMap, targetRows] = await Promise.all([ fetchOneOsMileageDates(days, undefined, protocolPriority), fetchOneOsDailyMileage(endDate, undefined, protocolPriority), fetchOneOsDailyMileage(previousMileageDate(startDate), undefined, protocolPriority), fetchVehicleInfoMap(), fetchTargetRows(), ]); const dailyRows: DailyMileageRow[] = []; for (const [date, apiRows] of apiRowsByDate) { for (const row of apiRows) { dailyRows.push({ plate: row.plateNumber, vin: row.vin, date, daily_km: row.dailyMileageKm, source: row.status !== 'NO_DATA' ? 'ONEOS_API' : 'NONE', mileage_anomaly: row.status === 'DATA_ANOMALY' ? row.dataQuality || 'DATA_ANOMALY' : null, source_protocol: row.sourceProtocol, data_time: row.dataTime, calculated_at: row.calculatedAt, updated_at: row.updatedAt, }); } } const perVehicleDaily = new Map>(); const perVehicleDailySources = new Map>(); const perVehicleSourceCategories = new Map>(); const perVehicleSum = new Map(); const dailyTotals = new Map(); const bestDailyRows = new Map(); for (const day of days) dailyTotals.set(day, 0); for (const row of dailyRows) { const key = `${row.plate}\u0000${row.date}`; const km = Math.max(0, Number(row.daily_km) || 0); const existing = bestDailyRows.get(key); if (!existing || km > Math.max(0, Number(existing.daily_km) || 0)) { bestDailyRows.set(key, row); } } for (const row of bestDailyRows.values()) { const km = Math.max(0, Number(row.daily_km) || 0); const date = row.date; const plate = row.plate; dailyTotals.set(date, (dailyTotals.get(date) || 0) + km); const daily = perVehicleDaily.get(plate) || {}; daily[date] = km; perVehicleDaily.set(plate, daily); const dailySources = perVehicleDailySources.get(plate) || {}; dailySources[date] = row.source_protocol; perVehicleDailySources.set(plate, dailySources); const category = sourceCategoryFromProtocol(row.source_protocol); if (category) { const categories = perVehicleSourceCategories.get(plate) || new Set(); categories.add(category); perVehicleSourceCategories.set(plate, categories); } const existing = perVehicleSum.get(plate); perVehicleSum.set(plate, { plate, vin: existing?.vin || row.vin || '', daily_km: String((Number(existing?.daily_km) || 0) + km), total_km: null, mileage_anomaly: existing?.mileage_anomaly || row.mileage_anomaly || null, source: existing?.source !== 'NONE' && existing?.source ? existing.source : (row.source || 'NONE'), source_protocol: row.source_protocol || existing?.source_protocol || null, data_time: row.data_time || existing?.data_time || null, calculated_at: row.calculated_at || existing?.calculated_at || null, updated_at: row.updated_at || existing?.updated_at || null, }); } const endDateMap = new Map(endDateRows.map(row => [row.plateNumber, row])); for (const [plate, aggregate] of perVehicleSum) { const endDateRow = endDateMap.get(plate); if (!endDateRow) continue; perVehicleSum.set(plate, { ...aggregate, vin: endDateRow.vin || aggregate.vin, total_km: endDateRow.totalMileageKm == null ? null : String(endDateRow.totalMileageKm), source_protocol: endDateRow.sourceProtocol || aggregate.source_protocol, data_time: endDateRow.dataTime || aggregate.data_time, updated_at: endDateRow.updatedAt || aggregate.updated_at, }); } const yesterdayMap = dailyMileageMap(yesterdayRows); const vehicles = mergeMonitoringVehicles( Array.from(perVehicleSum.values()), infoMap, yesterdayMap, buildPlateTargetNamesMap(targetRows), regionMap, ).map(vehicle => { const dailyMileage = perVehicleDaily.get(vehicle.plate) || {}; const dailySources = perVehicleDailySources.get(vehicle.plate) || {}; const categories = perVehicleSourceCategories.get(vehicle.plate); const completedDailyMileage: Record = {}; const completedDailySources: Record = {}; for (const day of days) completedDailyMileage[day] = dailyMileage[day] || 0; for (const day of days) completedDailySources[day] = dailySources[day] || null; return { ...vehicle, dailyMileage: completedDailyMileage, dailySourceProtocols: completedDailySources, sourceCategory: categories && categories.size > 1 ? 'MIXED' as const : categories?.values().next().value || vehicle.sourceCategory, }; }); return { vehicles, dailyTotals: days.map(date => ({ date, totalKm: Math.round(dailyTotals.get(date) || 0) })), start: startDate, end: endDate, }; } export function buildDateFilters(vehicles: CachedVehicle[]): MonitoringFilters { return buildMonitoringFilters(vehicles, monitoringCache?.filters.targetNames || []); }