Files
ln-bi/src/server/routes/mileage/oneos-model.ts
T
shishengliang e2f932232f
ci/woodpecker/push/woodpecker Pipeline was successful
feat(mileage): 接入区间汇总并复用查询结果
2026-10-29 09:11:21 +08:00

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(':');
}