import { readFile, writeFile, link, unlink, mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; import { randomUUID } from 'node:crypto'; import { monthCutoff, shanghaiTimestamp, type HistoryDetailItem, type HistoryPoint } from '../../../shared/assets/history.js'; export interface ReconstructionPeriod { month: string; operating: number; inventory: number; unclassified: number; currentlyExited: number; vehicles: { id: string; plateNumber: string; vin: string; disposition: 'operating' | 'inventory'; basis: string }[]; } export interface ReconstructionData { schemaVersion: 1; method: 'HANDOVER_REVIEW_V1'; workbookSha256: string; sourceAsOf: string; sourceReference: string; periods: ReconstructionPeriod[]; } export function validateReconstruction(input: unknown): ReconstructionData { const fail = (): never => { throw new Error('历史回溯文件无效,未加载任何回填数据'); }; if (!input || typeof input !== 'object') return fail(); const data = input as ReconstructionData; if (data.schemaVersion !== 1 || data.method !== 'HANDOVER_REVIEW_V1' || !/^[a-f0-9]{64}$/.test(data.workbookSha256) || typeof data.sourceReference !== 'string' || !data.sourceReference.trim() || data.sourceReference.length > 200 || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?\+08:00$/.test(data.sourceAsOf) || !Number.isFinite(Date.parse(data.sourceAsOf)) || !Array.isArray(data.periods) || data.periods.length > 1200) return fail(); const months = new Set(); for (const period of data.periods) { if (!period || !/^20\d{2}-(0[1-9]|1[0-2])$/.test(period.month) || months.has(period.month) || monthCutoff(period.month).getTime() >= Date.parse(data.sourceAsOf) || ![period.operating, period.inventory, period.unclassified, period.currentlyExited].every(v => Number.isSafeInteger(v) && v >= 0) || period.currentlyExited > period.operating + period.inventory || !Array.isArray(period.vehicles) || period.vehicles.length !== period.operating + period.inventory) return fail(); months.add(period.month); const ids = new Set(); let operating = 0; for (const v of period.vehicles) { if (!v || typeof v.id !== 'string' || !/^[1-9]\d{0,63}$/.test(v.id) || ids.has(v.id) || !['operating', 'inventory'].includes(v.disposition) || typeof v.plateNumber !== 'string' || v.plateNumber.length > 100 || typeof v.vin !== 'string' || v.vin.length > 100 || typeof v.basis !== 'string' || !v.basis.trim() || v.basis.length > 1000) return fail(); ids.add(v.id); if (v.disposition === 'operating') operating++; } if (operating !== period.operating) return fail(); } // Return only reviewed fields. Ignore any attempted current company/customer/role injection. return { schemaVersion: 1, method: data.method, workbookSha256: data.workbookSha256, sourceAsOf: data.sourceAsOf, sourceReference: data.sourceReference, periods: data.periods.map(p => ({ month: p.month, operating: p.operating, inventory: p.inventory, unclassified: p.unclassified, currentlyExited: p.currentlyExited, vehicles: p.vehicles.map(v => ({ id: v.id, plateNumber: v.plateNumber, vin: v.vin, disposition: v.disposition, basis: v.basis })) })) }; } export interface ReconstructionReader { read(): Promise } /** Opt-in, immutable local file. Reading never creates storage or writes a database. */ export class FileReconstructionReader implements ReconstructionReader { private loaded?: Promise; constructor(private readonly path: string) {} read() { this.loaded ||= readFile(this.path, 'utf8').then(text => { if (text.length > 50_000_000) throw new Error('历史回溯文件超出限制'); return validateReconstruction(JSON.parse(text)); }); return this.loaded; } } /** Explicit local publication only: create atomically; never replace a different revision. */ export async function publishReconstruction(path: string, input: unknown): Promise<'created' | 'unchanged'> { const text = JSON.stringify(validateReconstruction(input)); await mkdir(dirname(path), { recursive: true }); const temporary = `${path}.${randomUUID()}.tmp`; await writeFile(temporary, text, { flag: 'wx', mode: 0o600 }); try { try { await link(temporary, path); return 'created'; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; if (await readFile(path, 'utf8') === text) return 'unchanged'; throw new Error('目标已有不同版本的回溯数据,禁止自动覆盖'); } } finally { await unlink(temporary); } } export function reconstructionPoint(data: ReconstructionData, month: string, annual: boolean, now: Date): HistoryPoint | null { const period = data.periods.find(p => p.month === month); if (!period || monthCutoff(month) >= now) return null; return { period: annual ? month.slice(0, 4) : month, label: annual ? `${month.slice(0, 4)}年` : `${Number(month.slice(5))}月`, month, scheduledFor: shanghaiTimestamp(monthCutoff(month)), asOf: null, state: 'RECONSTRUCTED', source: 'RECONSTRUCTED', batchId: `rec-${data.workbookSha256.slice(0, 12)}-${month}`, counts: null, difference: null, comparisonLabel: '暂无可比数据', detailAvailable: true, reconstruction: { operating: period.operating, inventory: period.inventory, unclassified: period.unclassified, currentlyExited: period.currentlyExited, sourceAsOf: data.sourceAsOf, sourceReference: data.sourceReference, workbookSha256: data.workbookSha256, difference: null } }; } export function reconstructionItems(period: ReconstructionPeriod): HistoryDetailItem[] { return period.vehicles.map(v => ({ id: v.id, plateNumber: v.plateNumber, vin: v.vin, disposition: v.disposition, type: '', model: '', operationStatus: null, vehicleStatus: null, subjectOrg: null, departmentName: null, customerManager: null, customerName: null, contractNo: null, reconstructionBasis: v.basis })); }