Files
ln-bi/src/server/routes/mileage/cache-model.ts
T
2026-09-18 09:54:08 +08:00

225 lines
7.7 KiB
TypeScript

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<string, number>();
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<string, number> {
const map = new Map<string, number>();
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<string, Set<string>> {
const result = new Map<string, Set<string>>();
for (const row of targetRows) {
const plates = result.get(row.target_name) || new Set<string>();
plates.add(row.plate_number);
result.set(row.target_name, plates);
}
return result;
}
export function buildPlateTargetNamesMap(targetRows: TargetRow[]): Map<string, string[]> {
const result = new Map<string, string[]>();
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<string, VehicleInfoRow>,
yesterdayMap: Map<string, number>,
targetNamesByPlate: Map<string, string[]>,
regionByPlate: Record<string, string>,
): CachedVehicle[] {
const mileageMap = new Map<string, MileageRow>();
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;
}