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(); 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}` : '暂无可比数据' }; }