146 lines
5.3 KiB
TypeScript
146 lines
5.3 KiB
TypeScript
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<string, unknown>, ...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<string, OneOsDailyMileage>();
|
|
for (const item of value) {
|
|
if (!item || typeof item !== 'object') continue;
|
|
const row = item as Record<string, unknown>;
|
|
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<string, OneOsDailyMileage[]> {
|
|
if (!Array.isArray(value)) throw new Error('OneOS mileage range API returned a non-array data field');
|
|
const rawByDate = new Map<string, unknown[]>();
|
|
for (const item of value) {
|
|
if (!item || typeof item !== 'object') continue;
|
|
const date = (item as Record<string, unknown>).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<string, OneOsDailyMileage[]>();
|
|
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(':');
|
|
}
|