|
|
@@ -0,0 +1,100 @@
|
|
|
+import {test} from 'node:test';
|
|
|
+import assert from 'node:assert/strict';
|
|
|
+import {readFileSync} from 'node:fs';
|
|
|
+import vm from 'node:vm';
|
|
|
+import {PGlite} from '@electric-sql/pglite';
|
|
|
+
|
|
|
+const ctx=vm.createContext({Date});
|
|
|
+vm.runInContext(readFileSync(new URL('../cloud/legacy-finance.js',import.meta.url),'utf8'),ctx);
|
|
|
+const sourceRows=[
|
|
|
+ {legacyFinanceId:'1',score:35,scoreBefore:0,hisTime:'2026-08-04T08:00:00Z'},
|
|
|
+ {legacyFinanceId:'2',score:-14,scoreBefore:35,hisTime:'2026-08-31T08:00:00Z'},
|
|
|
+ {legacyFinanceId:'3',score:-19,scoreBefore:21,hisTime:'2026-10-30T08:00:00Z'},
|
|
|
+];
|
|
|
+const input=(extra={})=>({account:'SilverCoin',current:19,sourceBalance:2,importedAt:'2026-09-01T03:11:28Z',
|
|
|
+ legacy:sourceRows,local:[{sourceKey:'credit:complete:first',score:-1},{sourceKey:'credit:complete:second',score:-1}],
|
|
|
+ receiptIds:['1','2','3'],...extra});
|
|
|
+const plan=p=>{assert.equal(typeof ctx.legacyCreditBalancePlan,'function');return ctx.legacyCreditBalancePlan(p);};
|
|
|
+
|
|
|
+test('樊文昊旧余额21已扣两次变19,补齐旧系统漏同步19次后为0,保留两笔新扣课',()=>{
|
|
|
+ const result=plan(input());assert.equal(result.status,'ready');assert.equal(result.balance,0);assert.equal(result.localNet,-2);assert.equal(result.delta,-19);
|
|
|
+});
|
|
|
+test('同时保留新系统充值、退款与完课扣减,不直接覆盖成旧余额',()=>{
|
|
|
+ const result=plan(input({current:24,local:[{sourceKey:'credit:transfer:new',score:10},{sourceKey:'credit:refund:new',score:-3},{sourceKey:'credit:complete:new',score:-4}]}));
|
|
|
+ assert.equal(result.balance,5);assert.equal(result.localNet,3);
|
|
|
+});
|
|
|
+test('陪练时长保留旧系统扣减行将score_before存成扣后值的原始口径',()=>{
|
|
|
+ const result=plan(input({account:'UserPoint',legacy:sourceRows.map(r=>r.legacyFinanceId==='3'?{...r,scoreBefore:2}:r)}));assert.equal(result.balance,0);
|
|
|
+});
|
|
|
+test('来源余额与导入后的原始流水不一致时保留当前余额',()=>{
|
|
|
+ assert.equal(plan(input({sourceBalance:3})).status,'conflict');
|
|
|
+});
|
|
|
+test('未知本地流水、来源缺页、重复来源与无凭据的余额改动不能被自动覆盖',()=>{
|
|
|
+ for(const patch of [{local:[{sourceKey:'manual:unknown',score:-2}]},{receiptIds:['1','2']},{legacy:[...sourceRows,sourceRows[2]]},{current:18}])assert.equal(plan(input(patch)).status,'conflict');
|
|
|
+});
|
|
|
+test('小数课时与旧系统负余额保持真实数值,不能把欠费强制归零',()=>{
|
|
|
+ const result=plan(input({account:'UserPoint',current:0.5,sourceBalance:-1,legacy:[{legacyFinanceId:'1',score:2,scoreBefore:0,hisTime:'2026-08-31T08:00:00Z'},{legacyFinanceId:'2',score:-3,scoreBefore:-1,hisTime:'2026-10-30T08:00:00Z'}],receiptIds:['1','2'],local:[{sourceKey:'credit:complete:new',score:-1.5}]}));assert.equal(result.balance,-2.5);
|
|
|
+});
|
|
|
+test('重跑不重复扣减;建立基线后只合并新增旧流水与新增本地流水',()=>{
|
|
|
+ const previous={balance:0,sourceBalance:2,localNet:-2,sourceIds:['1','2','3']};
|
|
|
+ assert.equal(plan(input({current:0,previous})).delta,0);
|
|
|
+ const next=plan(input({current:-1,sourceBalance:1,previous,legacy:[...sourceRows,{legacyFinanceId:'4',score:-1,scoreBefore:2,hisTime:'2026-11-06T08:00:00Z'}],receiptIds:['1','2','3','4'],local:[...input().local,{sourceKey:'credit:complete:third',score:-1}]}));
|
|
|
+ assert.equal(next.balance,-2);assert.equal(next.delta,-1);
|
|
|
+ assert.equal(plan(input({current:1,previous})).status,'conflict');
|
|
|
+});
|
|
|
+test('既有来源凭据被删除,或来源变化没有新增流水时拒绝重算',()=>{
|
|
|
+ const previous={balance:0,sourceBalance:2,localNet:-2,sourceIds:['1','2','3']};
|
|
|
+ assert.equal(plan(input({current:0,previous,receiptIds:['1','2'],legacy:sourceRows.slice(0,2)})).status,'conflict');
|
|
|
+ assert.equal(plan(input({current:0,previous,sourceBalance:5})).status,'conflict');
|
|
|
+});
|
|
|
+
|
|
|
+test('同一预约在新旧系统各扣一次时只计一次,不删除任一原始流水',()=>{
|
|
|
+ const legacy=[...sourceRows.slice(0,2),{...sourceRows[2],score:-18},{legacyFinanceId:'4',score:-1,scoreBefore:3,hisTime:'2026-10-31T08:00:00Z'}];
|
|
|
+ const p=input({legacy,receiptIds:['1','2','3','4'],local:[{objectId:'local-1',sourceKey:'credit:complete:first',score:-1},{objectId:'local-2',sourceKey:'credit:complete:second',score:-1}],duplicates:[{localId:'local-1',sourceId:'4'}]});
|
|
|
+ const r=plan(p);assert.equal(r.balance,1);assert.equal(r.duplicateCredit,1);assert.equal(r.delta,-18);
|
|
|
+ assert.equal(plan({...p,current:1,previous:{...r}}).delta,0);
|
|
|
+ assert.equal(plan({...p,duplicates:[{localId:'local-1',sourceId:'3'}]}).status,'conflict');
|
|
|
+ assert.equal(plan({...p,duplicates:[...p.duplicates,...p.duplicates]}).status,'conflict');
|
|
|
+});
|
|
|
+
|
|
|
+test('早期来源抓取先于账号入库时,只接受一天窗口内可逐笔核清的起点',()=>{
|
|
|
+ const result=plan(input({current:38,sourceBalance:21,importedAt:'2026-10-12T23:00:00Z',legacy:[{legacyFinanceId:'1',score:40,scoreBefore:0,hisTime:'2026-10-11T12:00:00Z'},{legacyFinanceId:'2',score:-14,scoreBefore:40,hisTime:'2026-10-12T15:00:00Z'},{legacyFinanceId:'3',score:-5,scoreBefore:26,hisTime:'2026-10-30T15:00:00Z'}],local:[{sourceKey:'credit:complete:one',score:-2}]}));
|
|
|
+ assert.equal(result.balance,19);assert.equal(result.delta,-19);
|
|
|
+});
|
|
|
+
|
|
|
+test('数据库修复原子更新两类课时,保留业务流水,重复执行零扣减且未知改动受保护',async()=>{
|
|
|
+ const db=new PGlite();try{
|
|
|
+ await db.exec(`CREATE TABLE "_User"("objectId" text,company text,"legacyUserId" numeric,"sourceKey" text,"createdAt" timestamptz,"updatedAt" timestamptz,"legacyUserData" jsonb,"identityType" text,"isDeleted" boolean);
|
|
|
+ CREATE TABLE "LegacyFinanceReceipt"(company text,"studentId" numeric,account text,"runId" text,ids jsonb,checksum text);
|
|
|
+ CREATE TABLE "CommonModel"(company text,"objectId" text,"modelId" numeric,"generalId" numeric);
|
|
|
+ INSERT INTO "_User" VALUES('fan','co',1826,'legacy-sync:user:1826','2026-09-01T03:11:28Z',now(),'{"SilverCoin":19,"UserPoint":19,"Purse":0,"UserExp":0,"HoneyName":"樊文昊"}','member',false);`);
|
|
|
+ await db.exec(readFileSync(new URL('../sql/legacy-credit-balances.sql',import.meta.url),'utf8'));
|
|
|
+ const query=async(s,a=[])=>(await db.query(s,a)).rows,one=async(s,a)=>(await query(s,a))[0];
|
|
|
+ const Psql={any:query,query,one,oneOrNone:async(s,a)=>(await query(s,a))[0]||null,none:async(s,a)=>{assert.equal((await query(s,a)).length,0,'Psql.none must not receive rows');}};
|
|
|
+ Psql.tx=async f=>{await db.exec('BEGIN');try{const r=await f(Psql);await db.exec('COMMIT');return r;}catch(e){await db.exec('ROLLBACK');throw e;}};
|
|
|
+ const live=vm.createContext({Date,Psql});vm.runInContext(readFileSync(new URL('../cloud/legacy-finance.js',import.meta.url),'utf8'),live);
|
|
|
+ for(const [account,table] of [['Purse','UserExpDomP'],['SilverCoin','UserSIcon'],['UserExp','UserExpHis'],['UserPoint','UserUserPoint']]){
|
|
|
+ await db.exec(`CREATE TABLE "${table}"("objectId" text,company text,"userId" numeric,"sourceKey" text,score numeric,"scoreBefore" numeric,"hisTime" timestamptz,"legacyFinanceId" text,"legacyFinanceHash" text,"transactionId" text,detail text,"legacyFinanceRaw" jsonb)`);
|
|
|
+ if(account==='SilverCoin'||account==='UserPoint'){
|
|
|
+ for(const row of sourceRows)await query(`INSERT INTO "${table}"("objectId",company,"userId","sourceKey",score,"scoreBefore","hisTime","legacyFinanceId","legacyFinanceHash") VALUES($1,'co',1826,$2,$3,$4,$5,$1,'verified-'||$1)`,[row.legacyFinanceId,'legacy:finance:'+row.legacyFinanceId,row.score,account==='UserPoint'&&row.score<0?row.scoreBefore+row.score:row.scoreBefore,row.hisTime]);
|
|
|
+ await db.exec(`INSERT INTO "${table}"("objectId",company,"userId","sourceKey",score) VALUES('local-1','co',1826,'credit:complete:first',-1),('local-2','co',1826,'credit:complete:second',-1)`);
|
|
|
+ }
|
|
|
+ await query(`INSERT INTO "LegacyFinanceReceipt" SELECT 'co',1826,$1,'repair-0001',COALESCE(jsonb_agg("legacyFinanceId" ORDER BY "legacyFinanceId"::numeric),'[]'::jsonb),md5(COALESCE(jsonb_agg(jsonb_build_object('id',"legacyFinanceId",'hash',"legacyFinanceHash") ORDER BY "legacyFinanceId"::numeric),'[]'::jsonb)::text) FROM "${table}" WHERE "legacyFinanceId" IS NOT NULL`,[account]);
|
|
|
+ }
|
|
|
+ await db.exec(`INSERT INTO "LegacyCreditBalanceSnapshot"(company,"studentId","runId",balances) VALUES('co',1826,'repair-0001','{"SilverCoin":2,"UserPoint":2,"Purse":0,"UserExp":0}')`);
|
|
|
+ const result=await live.reconcileLegacyCreditBalances('co',1826,'repair-0001');assert.equal(result.applied,2);assert.equal(result.conflicts,0);
|
|
|
+ const balance=(await one('SELECT "legacyUserData" b FROM "_User"')).b;assert.equal(balance.SilverCoin,0);assert.equal(balance.UserPoint,0);assert.equal(balance.HoneyName,'樊文昊');
|
|
|
+ assert.equal((await live.reconcileLegacyCreditBalances('co',1826,'repair-0001')).applied,0);
|
|
|
+ assert.equal((await one('SELECT count(*)::int n FROM "LegacyCreditBalanceAudit"')).n,2);
|
|
|
+ assert.equal((await one('SELECT result FROM "LegacyCreditBalanceReceipt"')).result.applied,2);
|
|
|
+ assert.equal((await one('SELECT count(*)::int n FROM "UserSIcon"')).n,5);
|
|
|
+ await db.exec(`INSERT INTO "CommonModel" VALUES('co','appointment',54,102);
|
|
|
+ UPDATE "UserSIcon" SET "transactionId"='complete:appointment' WHERE "objectId"='local-1';
|
|
|
+ INSERT INTO "UserSIcon"("objectId",company,"userId","sourceKey",score,"scoreBefore","hisTime","legacyFinanceId","legacyFinanceHash",detail,"legacyFinanceRaw") VALUES('4','co',1826,'legacy:finance:4',-1,2,'2026-11-06T08:00:00Z','4','verified-4','学习结束,订单:102','{"Detail":"学习结束,订单:102"}');
|
|
|
+ UPDATE "LegacyCreditBalanceSnapshot" SET balances=balances||'{"SilverCoin":1}';`);
|
|
|
+ await db.exec(`UPDATE "LegacyFinanceReceipt" SET ids='["1","2","3","4"]',checksum=(SELECT md5(jsonb_agg(jsonb_build_object('id',"legacyFinanceId",'hash',"legacyFinanceHash") ORDER BY "legacyFinanceId"::numeric)::text) FROM "UserSIcon" WHERE "legacyFinanceId" IS NOT NULL) WHERE account='SilverCoin'`);
|
|
|
+ const deduped=await live.reconcileLegacyCreditBalances('co',1826,'repair-0001');assert.equal(deduped.accounts.find(a=>a.account==='SilverCoin').duplicateCredit,1);assert.equal((await one('SELECT "legacyUserData" b FROM "_User"')).b.SilverCoin,0);
|
|
|
+ await db.exec(`UPDATE "_User" SET "legacyUserData"="legacyUserData"||'{"SilverCoin":7}'`);
|
|
|
+ const protectedResult=await live.reconcileLegacyCreditBalances('co',1826,'repair-0001');assert.equal(protectedResult.conflicts,1);assert.equal((await one('SELECT "legacyUserData" b FROM "_User"')).b.SilverCoin,7);
|
|
|
+ }finally{await db.close();}
|
|
|
+});
|