feat(assets): add verified asset history trends and compact overview
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
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<string>();
|
||||
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<string>(); 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<ReconstructionData> }
|
||||
/** Opt-in, immutable local file. Reading never creates storage or writes a database. */
|
||||
export class FileReconstructionReader implements ReconstructionReader {
|
||||
private loaded?: Promise<ReconstructionData>;
|
||||
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 }));
|
||||
}
|
||||
Reference in New Issue
Block a user