Files
ln-bi/src/server/routes/asset-history/reconstruction.test.ts
T

88 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { AuthUser } from '../../auth/types.js';
import { MemoryHistoryReader, testVehicle } from './fixtures.js';
import { HistoryService, HistoryPeriodUnavailable } from './service.js';
import { FileReconstructionReader, publishReconstruction, reconstructionPoint, validateReconstruction, type ReconstructionData } from './reconstruction.js';
const user = (permissionLevel: AuthUser['permissionLevel'] = 'full'): AuthUser => ({ userId:'1',userName:'测试',loginName:'test',depName:'甲',depCode:'a',permissionLevel,roles:[] });
const now = () => new Date('2026-10-19T14:38:33+08:00');
const data = (): ReconstructionData => ({ schemaVersion:1,method:'HANDOVER_REVIEW_V1',workbookSha256:'a'.repeat(64),sourceAsOf:'2026-10-19T14:38:33+08:00',sourceReference:'测试核对表.xlsx',
periods:[{month:'2026-08',operating:1,inventory:0,unclassified:2,currentlyExited:1,vehicles:[{id:'90071992547409931',plateNumber:'历史车牌',vin:'OLDVIN',disposition:'operating',basis:'交车 D:1,2026-08-01 10:00:00'}]},
{month:'2026-09',operating:0,inventory:1,unclassified:2,currentlyExited:1,vehicles:[{id:'90071992547409931',plateNumber:'历史车牌',vin:'OLDVIN',disposition:'inventory',basis:'还车 R:2,2026-09-01 10:00:00'}]}] });
function setup(value = data()) {
const store = new MemoryHistoryReader(); let reads = 0;
const service = new HistoryService(store,async()=>[testVehicle('today')],now,{read:async()=>{reads++;return value;}});
return { store,service,reads:()=>reads };
}
test('reconstruction preserves unknown total, source date and string IDs without claiming a captured snapshot', async()=>{
const {service}=setup(); const trend=await service.trend(user(),{year:'2026'});
const p=trend.points[8];
assert.equal(p.state,'RECONSTRUCTED');assert.equal(p.counts,null);assert.equal(p.asOf,null);
assert.equal(p.reconstruction!.operating,0);assert.equal(p.reconstruction!.inventory,1);
assert.deepEqual(p.reconstruction!.difference,{operating:-1,inventory:1});
assert.equal(trend.points[7].reconstruction!.difference,null);
assert.equal(trend.points[9].state,'LIVE');assert.equal(trend.points[9].difference,null);
assert.equal(trend.points[10].state,'FUTURE');
const detail=await service.details(user(),{month:'2026-09',batchId:p.batchId!});
assert.equal(detail.counts,null);assert.equal(detail.items[0].id,'90071992547409931');
assert.equal(detail.items[0].plateNumber,'历史车牌');assert.equal(detail.items[0].subjectOrg,null);
assert.equal(detail.items[0].operationStatus,null);assert.equal(detail.items[0].customerName,null);
assert.match(detail.items[0].reconstructionBasis!,/R:2/);
});
test('company/department/personal scopes cannot load or query unowned reconstruction rows', async()=>{
for(const [level,params] of [['department',{}],['personal',{}],['full',{subject:'任意公司'}]] as const){
const {service,reads}=setup();const identity=user(level);
const trend=await service.trend(identity,{year:'2026',...params});assert.equal(trend.points[8].state,'MISSING');
await assert.rejects(()=>service.details(identity,{month:'2026-09',batchId:`rec-${'a'.repeat(12)}-2026-09`,...params}),HistoryPeriodUnavailable);
assert.equal(reads(),0);
}
});
test('formal archives take precedence and forged or stale reconstruction batch IDs are refused',async()=>{
const {service,store}=setup();store.add('2026-09',[testVehicle('formal')]);
const trend=await service.trend(user(),{});assert.equal(trend.points[8].state,'ARCHIVED');assert.equal(trend.points[8].reconstruction,undefined);
await assert.rejects(()=>service.details(user(),{month:'2026-09',batchId:`rec-${'a'.repeat(12)}-2026-09`}),HistoryPeriodUnavailable);
await assert.rejects(()=>service.details(user(),{month:'2026-08',batchId:'rec-forged'}),HistoryPeriodUnavailable);
assert.equal((await service.details(user(),{month:'2026-09'})).items[0].id,'formal');
});
test('reconstructed category/search/pagination counts agree with the selected period; missing months stay absent',async()=>{
const value=data();value.periods[1].vehicles=Array.from({length:61},(_,i)=>({id:String(i+1),plateNumber:`历史${i}`,vin:`OLD${i}`,disposition:'inventory',basis:'还车 R:1'}));value.periods[1].inventory=61;
const {service}=setup(value);
const all=await service.details(user(),{month:'2026-09',page:'2',pageSize:'30'});assert.equal(all.total,61);assert.equal(all.items.length,30);
assert.equal((await service.details(user(),{month:'2026-09',category:'operating'})).total,0);
assert.equal((await service.details(user(),{month:'2026-09',search:'OLD60'})).total,1);
assert.equal((await service.trend(user(),{})).points[6].state,'MISSING');
});
test('annual estimates reuse December only; no sum, current-month replacement, or cross-source difference',async()=>{
const value=data();value.periods.push({...value.periods[0],month:'2025-12'});
const {service}=setup(value);const result=await service.trend(user(),{mode:'annual'});
assert.equal(result.points.find(p=>p.period==='2025')!.reconstruction!.operating,1);
assert.equal(result.points.find(p=>p.period==='2026')!.state,'LIVE');assert.equal(result.points.at(-1)!.difference,null);
assert.equal(reconstructionPoint(value,'2026-10',false,now()),null);
});
test('schema rejects duplicates, unsafe IDs, invented counts, current/future months and missing provenance',()=>{
const mutate=[(d:ReconstructionData)=>{d.periods.push(d.periods[0]);},(d:ReconstructionData)=>{d.periods[0].vehicles[0].id=1 as never;},
(d:ReconstructionData)=>{d.periods[0].operating=99;},(d:ReconstructionData)=>{d.periods[0].month='2026-10';},
(d:ReconstructionData)=>{d.workbookSha256='';},(d:ReconstructionData)=>{d.periods[0].vehicles.push(d.periods[0].vehicles[0]);d.periods[0].operating=2;},
(d:ReconstructionData)=>{d.periods[0].unclassified=-1;}];
for(const change of mutate){const d=data();change(d);assert.throws(()=>validateReconstruction(d),/无效/);}
const d=data();Object.assign(d.periods[0].vehicles[0],{subjectOrg:'今日主体',customerName:'不应导入的客户'});
assert.equal(JSON.stringify(validateReconstruction(d)).includes('今日主体'),false);
const zero=data();zero.periods[0]={month:'2026-08',operating:0,inventory:0,unclassified:3,currentlyExited:0,vehicles:[]};
assert.equal(reconstructionPoint(validateReconstruction(zero),'2026-08',false,now())!.reconstruction!.operating,0);
});
test('explicit local publication is atomic, idempotent and immutable; reader has no database dependencies',async()=>{
const dir=await mkdtemp(join(tmpdir(),'asset-reconstruction-'));
try{
const path=join(dir,'history.json');const value=data();
const results=await Promise.all([publishReconstruction(path,value),publishReconstruction(path,value)]);
assert.deepEqual(results.sort(),['created','unchanged']);
const before=await readFile(path,'utf8');const different=data();different.workbookSha256='b'.repeat(64);
await assert.rejects(()=>publishReconstruction(path,different),/禁止自动覆盖/);assert.equal(await readFile(path,'utf8'),before);
assert.deepEqual(await new FileReconstructionReader(path).read(),validateReconstruction(value));
}finally{await rm(dir,{recursive:true,force:true});}
});