85 lines
5.3 KiB
TypeScript
85 lines
5.3 KiB
TypeScript
import { filterAssetVehiclesByPermission, maskCustomerNames } from '../../auth/permissions.js';
|
|
import type { AuthUser } from '../../auth/types.js';
|
|
import type { Vehicle, VehicleRow } from '../../types.js';
|
|
import { isOperatingVehicle, isInventoryVehicle, transformRow } from '../vehicles/model.js';
|
|
import { CAPTURE_WINDOW_MS, monthCutoff, shanghaiTimestamp, type HistoryCounts, type HistoryPoint } from '../../../shared/assets/history.js';
|
|
|
|
export interface HistoryVehicle extends Vehicle {
|
|
vehicleStatus: string | null;
|
|
disposition: 'operating' | 'inventory';
|
|
}
|
|
export interface HistoryBatch {
|
|
id: string; month: string; status: 'COMPLETE' | 'FAILED' | 'MISSED';
|
|
source: 'AUTO' | 'IMPORT'; scheduledFor: string; capturedAt: string | null;
|
|
counts: HistoryCounts;
|
|
}
|
|
export interface HistorySettings { enabledAt: string }
|
|
export class HistoryDataError extends Error {}
|
|
export class HistoryStorageUnavailable extends Error {}
|
|
|
|
export function validateVehicles(vehicles: Vehicle[]): void {
|
|
const ids = new Set<string>();
|
|
for (const vehicle of vehicles) {
|
|
if (typeof vehicle.id !== 'string' || !vehicle.id || ids.has(vehicle.id)) throw new HistoryDataError('车辆 ID 缺失或重复,无法生成可信快照');
|
|
ids.add(vehicle.id);
|
|
if (!isOperatingVehicle(vehicle) && !isInventoryVehicle(vehicle)) throw new HistoryDataError('存在未分类运营状态,无法生成可信快照');
|
|
}
|
|
}
|
|
export function freezeRows(rows: VehicleRow[]): HistoryVehicle[] {
|
|
const vehicles = rows.map(row => {
|
|
const vehicle = transformRow(row);
|
|
return { ...vehicle, vehicleStatus: row.车辆租赁状态,
|
|
disposition: isOperatingVehicle(vehicle) ? 'operating' as const : 'inventory' as const };
|
|
});
|
|
validateVehicles(vehicles);
|
|
// Only freeze fields used by asset history. Movement work/customer attachments are unnecessary.
|
|
return vehicles.map(({ abnormalMove: _movement, pendingDelivery: _pending, ...vehicle }) => vehicle);
|
|
}
|
|
export function freezeLive(vehicles: Vehicle[]): HistoryVehicle[] {
|
|
validateVehicles(vehicles);
|
|
return vehicles.map(({ abnormalMove: _movement, pendingDelivery: _pending, ...v }) => ({ ...v,
|
|
vehicleStatus: v.vehicleStatus || null, disposition: isOperatingVehicle(v) ? 'operating' : 'inventory' }));
|
|
}
|
|
export function authorizedVehicles(vehicles: HistoryVehicle[], user: AuthUser, subject?: string): HistoryVehicle[] {
|
|
return maskCustomerNames(filterAssetVehiclesByPermission(vehicles, user).filter(v => !subject || v.subjectOrg === subject));
|
|
}
|
|
export function counts(vehicles: Vehicle[]): HistoryCounts {
|
|
return { total: vehicles.length, operating: vehicles.filter(isOperatingVehicle).length, inventory: vehicles.filter(isInventoryVehicle).length };
|
|
}
|
|
export function makePoint(month: string, now: Date, batch?: HistoryBatch, data?: HistoryVehicle[], live?: HistoryVehicle[], annual = false): HistoryPoint {
|
|
const cutoff = monthCutoff(month);
|
|
let state: HistoryPoint['state'] = 'MISSING';
|
|
if (cutoff > now) state = month.slice(0, 4) === shanghaiTimestamp(now).slice(0, 4) && (annual || month === shanghaiTimestamp(now).slice(0, 7)) ? 'LIVE' : 'FUTURE';
|
|
else if (batch?.status === 'COMPLETE') state = 'ARCHIVED';
|
|
else if (batch?.status === 'FAILED') state = 'FAILED';
|
|
else if (now.getTime() < cutoff.getTime() + CAPTURE_WINDOW_MS && batch?.status !== 'MISSED') state = 'CAPTURING';
|
|
const available = state === 'ARCHIVED' || state === 'LIVE';
|
|
const pointCounts = state === 'ARCHIVED' ? counts(data || []) : state === 'LIVE' ? counts(live || []) : null;
|
|
return {
|
|
period: annual ? month.slice(0, 4) : month,
|
|
label: annual ? `${month.slice(0, 4)}年` : `${Number(month.slice(5))}月`, month,
|
|
scheduledFor: shanghaiTimestamp(cutoff),
|
|
asOf: state === 'LIVE' ? shanghaiTimestamp(now) : state === 'ARCHIVED' ? batch!.capturedAt : null,
|
|
state, source: state === 'LIVE' ? 'LIVE' : state === 'ARCHIVED' ? batch!.source : null,
|
|
batchId: state === 'ARCHIVED' ? batch!.id : null, counts: pointCounts,
|
|
difference: null, comparisonLabel: '暂无可比数据', detailAvailable: available,
|
|
};
|
|
}
|
|
export function comparePoint(point: HistoryPoint, previous: HistoryPoint | undefined, annual: boolean): HistoryPoint {
|
|
if (point.reconstruction || previous?.reconstruction) {
|
|
const comparable = point.reconstruction && previous?.reconstruction && point.reconstruction.workbookSha256 === previous.reconstruction.workbookSha256;
|
|
return { ...point, difference: null,
|
|
...(point.reconstruction ? { reconstruction: { ...point.reconstruction, difference: comparable ? {
|
|
operating: point.reconstruction.operating - previous!.reconstruction!.operating,
|
|
inventory: point.reconstruction.inventory - previous!.reconstruction!.inventory,
|
|
} : null } } : {}),
|
|
comparisonLabel: comparable ? `推算值较${annual ? '上年年末' : '上月末'}` : '推算与实时或正式快照口径不同,暂无可比数据' };
|
|
}
|
|
const label = annual ? '上年末' : '上月末';
|
|
return { ...point, difference: point.counts && previous?.counts ? {
|
|
total: point.counts.total - previous.counts.total,
|
|
inventory: point.counts.inventory - previous.counts.inventory,
|
|
operating: point.counts.operating - previous.counts.operating,
|
|
} : null, comparisonLabel: point.counts && previous?.counts ? `${point.state === 'LIVE' ? '截至当前' : ''}较${label}` : '暂无可比数据' };
|
|
}
|