import { sourceCategoryFromProtocol, type OneOsProtocol } from './source-policy.js'; import type { OneOsDailyMileage } from './oneos-model.js'; import type { CachedVehicle, MonitoringFilters, PlatePrefix, VehicleInfoRow, } from './types.js'; const REGION_ORDER = ['华东区域', '华南区域', '西南区域', '西北区域', '华北区域', '华中区域', '东北区域']; const DEPARTMENT_ORDER = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十']; export interface MileageRow { plate: string; vin: string; daily_km: string; total_km: string | null; mileage_anomaly?: string | null; source: string; source_protocol: OneOsProtocol | null; data_time: string | null; calculated_at: string | null; updated_at: string | null; } export interface DailyMileageRow { plate: string; vin: string | null; date: string; daily_km: string | number | null; mileage_anomaly?: string | null; source: string | null; source_protocol: OneOsProtocol | null; data_time: string | null; calculated_at: string | null; updated_at: string | null; } export interface TargetRow { id: number; target_name: string; plate_number: string; } function sortDepartments(departments: string[]): string[] { return departments.sort((a, b) => { const ai = DEPARTMENT_ORDER.findIndex(value => a.includes(value)); const bi = DEPARTMENT_ORDER.findIndex(value => b.includes(value)); return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); }); } export function buildMonitoringFilters( vehicles: CachedVehicle[], targetNames: string[], ): MonitoringFilters { const departments = sortDepartments( Array.from(new Set(vehicles.map(vehicle => vehicle.department) .filter((value): value is string => value !== null))), ); const customers = Array.from(new Set(vehicles.map(vehicle => vehicle.customer) .filter((value): value is string => value !== null))); const plates = vehicles.map(vehicle => vehicle.plate); const projects = Array.from(new Set(vehicles.map(vehicle => vehicle.project) .filter((value): value is string => value !== null))); const entities = Array.from(new Set(vehicles.map(vehicle => vehicle.entity) .filter((value): value is string => value !== null))); const rentStatuses = Array.from(new Set(vehicles.map(vehicle => vehicle.rentStatus) .filter((value): value is string => value !== null))); const brands = Array.from(new Set(vehicles.map(vehicle => vehicle.brand) .filter((value): value is string => value !== null))).sort(); const prefixCount = new Map(); for (const vehicle of vehicles) { const prefix = vehicle.plate.charAt(0); prefixCount.set(prefix, (prefixCount.get(prefix) || 0) + 1); } const platePrefixes: PlatePrefix[] = Array.from(prefixCount.entries()) .map(([prefix, count]) => ({ prefix, count })) .sort((a, b) => b.count - a.count); const regionSet = new Set(vehicles.map(vehicle => vehicle.region) .filter((value): value is string => value !== null)); const regions = Array.from(regionSet).sort((a, b) => { const ai = REGION_ORDER.indexOf(a); const bi = REGION_ORDER.indexOf(b); return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); }); return { departments, customers, plates, projects, entities, rentStatuses, platePrefixes, targetNames, regions, brands, }; } export function toMileageRows(rows: OneOsDailyMileage[]): MileageRow[] { return rows.map(row => ({ plate: row.plateNumber, vin: row.vin, daily_km: String(row.dailyMileageKm ?? 0), total_km: row.totalMileageKm == null ? null : String(row.totalMileageKm), 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, })); } export function previousMileageDate(date: string): string { const [year, month, day] = date.split('-').map(Number); const value = new Date(Date.UTC(year, month - 1, day)); value.setUTCDate(value.getUTCDate() - 1); return value.toISOString().slice(0, 10); } export function dailyMileageMap(rows: OneOsDailyMileage[]): Map { const map = new Map(); for (const row of rows) { if (row.status !== 'NORMAL') continue; const km = Math.max(0, Number(row.dailyMileageKm) || 0); const existing = map.get(row.plateNumber) || 0; if (km > existing) map.set(row.plateNumber, km); } return map; } export function buildTargetPlatesMap(targetRows: TargetRow[]): Map> { const result = new Map>(); for (const row of targetRows) { const plates = result.get(row.target_name) || new Set(); plates.add(row.plate_number); result.set(row.target_name, plates); } return result; } export function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map { const result = new Map(); for (const row of targetRows) { const names = result.get(row.plate_number) || []; names.push(row.target_name); result.set(row.plate_number, names); } return result; } export function mergeMonitoringVehicles( mileageRows: MileageRow[], infoMap: Map, yesterdayMap: Map, targetNamesByPlate: Map, regionByPlate: Record, ): CachedVehicle[] { const mileageMap = new Map(); for (const row of mileageRows) { const existing = mileageMap.get(row.plate); if (!existing || Number(row.daily_km) > Number(existing.daily_km)) { mileageMap.set(row.plate, row); } } return Array.from(infoMap.values()).map(info => { const mileage = mileageMap.get(info.plate); const dailyKm = Number(mileage?.daily_km) || 0; const source = mileage?.source || 'NONE'; return { plate: info.plate, vin: mileage?.vin || info.vin || '', dailyKm, mileageAnomaly: mileage?.mileage_anomaly || null, // Cumulative mileage is never backfilled from a different source here. totalKm: mileage?.total_km != null ? Number(mileage.total_km) : null, source, sourceProtocol: mileage?.source_protocol || null, sourceCategory: sourceCategoryFromProtocol(mileage?.source_protocol || null), dataTime: mileage?.data_time || null, calculatedAt: mileage?.calculated_at || null, updatedAt: mileage?.updated_at || null, isOnline: source !== 'NONE' && dailyKm > 0, isDataSynced: source !== 'NONE', customer: info.customer || null, department: info.department || null, manager: info.manager || null, managerId: info.manager_id || null, rentStatus: info.rent_status || null, entity: info.entity || null, project: info.project || null, region: regionByPlate[info.plate] || null, inventoryLocation: info.inventory_location || null, brand: info.brand_label || null, targetNames: targetNamesByPlate.get(info.plate) || [], yesterdayKm: yesterdayMap.get(info.plate) || 0, }; }); } export function mileageDatesBetween(start: string, end: string): string[] { const result: string[] = []; const [startYear, startMonth, startDay] = start.split('-').map(Number); const [endYear, endMonth, endDay] = end.split('-').map(Number); const cursor = new Date(startYear, startMonth - 1, startDay); const last = new Date(endYear, endMonth - 1, endDay); cursor.setHours(0, 0, 0, 0); last.setHours(0, 0, 0, 0); while (cursor <= last) { result.push([ cursor.getFullYear(), String(cursor.getMonth() + 1).padStart(2, '0'), String(cursor.getDate()).padStart(2, '0'), ].join('-')); cursor.setDate(cursor.getDate() + 1); } return result; }