import { normalizeOneOsProtocol, type OneOsProtocol } from './source-policy.js'; export interface OneOsDailyMileage { vin: string; plateNumber: string; date: string; dailyMileageKm: number | null; totalMileageKm: number | null; status: 'NORMAL' | 'NO_DATA' | 'DATA_ANOMALY'; dataQuality?: string; dataTime: string | null; calculatedAt: string | null; updatedAt: string | null; sourceProtocol: OneOsProtocol | null; } /** Missing or unusable daily mileage must not contribute to interval statistics. */ export function validDailyMileage(row: OneOsDailyMileage | undefined): number | null { const km = row?.dailyMileageKm; return row?.status === 'NORMAL' && row.dataQuality !== 'CARRIED_FORWARD' && km != null && Number.isFinite(km) && km >= 0 ? km : null; } const MILEAGE_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; export function assertMileageDate(date: string): void { if (!MILEAGE_DATE_PATTERN.test(date)) throw new Error(`Invalid mileage date: ${date}`); } function stringField(row: Record, ...names: string[]): string | null { for (const name of names) { const value = row[name]; if (typeof value === 'string' && value.trim()) return value.trim(); } return null; } function nonNegativeMileage(value: unknown, status: OneOsDailyMileage['status']): number | null { if (status !== 'NORMAL' || value === null || value === undefined) return null; const numeric = Number(value); return Number.isFinite(numeric) ? Math.max(0, numeric) : null; } /** Normalize the provider response and retain the highest mileage row per plate. */ export function normalizeOneOsRows(value: unknown, requestedDate: string): OneOsDailyMileage[] { if (!Array.isArray(value)) throw new Error('OneOS mileage API returned a non-array data field'); const best = new Map(); for (const item of value) { if (!item || typeof item !== 'object') continue; const row = item as Record; const vin = typeof row.vin === 'string' ? row.vin.trim() : ''; const plateNumber = typeof row.plateNumber === 'string' ? row.plateNumber.trim() : ''; const date = typeof row.date === 'string' ? row.date : requestedDate; const status = row.status === 'NORMAL' || row.status === 'DATA_ANOMALY' ? row.status : 'NO_DATA'; // The aliases keep the BI compatible while the Open API formalizes its fields. const dataTime = stringField(row, 'dataTime', 'statisticTime', 'recordTime'); const calculatedAt = stringField(row, 'calculatedAt', 'calculationTime'); const updatedAt = stringField(row, 'updatedAt'); const sourceProtocol = normalizeOneOsProtocol( stringField(row, 'sourceProtocol', 'protocol', 'dataSource'), ); if (!plateNumber || date !== requestedDate) continue; const normalized: OneOsDailyMileage = { vin, plateNumber, date, dailyMileageKm: nonNegativeMileage(row.dailyMileageKm, status), totalMileageKm: nonNegativeMileage(row.totalMileageKm, status === 'DATA_ANOMALY' ? 'NORMAL' : status), status, ...((row.dataQuality || status === 'DATA_ANOMALY') ? { dataQuality: stringField(row, 'dataQuality') || 'DATA_ANOMALY' } : {}), dataTime, calculatedAt, updatedAt, sourceProtocol, }; const existing = best.get(plateNumber); if (!existing || (normalized.dailyMileageKm ?? -1) > (existing.dailyMileageKm ?? -1)) { best.set(plateNumber, normalized); } } return Array.from(best.values()); } export function normalizeOneOsRangeRows( value: unknown, startDate: string, endDate: string, ): Map { if (!Array.isArray(value)) throw new Error('OneOS mileage range API returned a non-array data field'); const rawByDate = new Map(); for (const item of value) { if (!item || typeof item !== 'object') continue; const date = (item as Record).date; if (typeof date !== 'string' || date < startDate || date > endDate) continue; const rows = rawByDate.get(date) || []; rows.push(item); rawByDate.set(date, rows); } const result = new Map(); for (const [date, rows] of rawByDate) result.set(date, normalizeOneOsRows(rows, date)); return result; } export function normalizeSelectedPlates(plateNumbers?: string[]): string[] { return Array.from(new Set( (plateNumbers || []).map(plate => plate.trim()).filter(Boolean), )).sort(); } export function groupContiguousMileageDates(dates: string[], maxGroupSize = 366): string[][] { const groups: string[][] = []; for (const date of Array.from(new Set(dates)).sort()) { assertMileageDate(date); const current = groups[groups.length - 1]; if (!current) { groups.push([date]); continue; } const nextDate = new Date(`${current[current.length - 1]}T00:00:00Z`); nextDate.setUTCDate(nextDate.getUTCDate() + 1); if (nextDate.toISOString().slice(0, 10) === date && current.length < maxGroupSize) { current.push(date); } else { groups.push([date]); } } return groups; } export function oneOsRangeRequestKey( startDate: string, endDate: string, selectedPlates: string[], protocolPriority: OneOsProtocol[], ): string { return [ startDate, endDate, selectedPlates.length > 0 ? selectedPlates.join('\u0000') : '*', protocolPriority.join('>'), ].join(':'); }