payroll-integrity.test.mjs 23 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
  1. import {test,before,after} 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';
  2. const db=new PGlite(),source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
  3. const q=async(sql,args=[])=>(await db.query(sql,args)).rows,one=async(sql,args=[])=>(await q(sql,args))[0];
  4. const migration=readFileSync(new URL('../sql/payroll-integrity.sql',import.meta.url),'utf8');
  5. const context=vm.createContext({Date,Psql:{query:q,one,oneOrNone:one}});
  6. vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/review-rewards.js',import.meta.url),'utf8')+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),context);
  7. context.companyIdOf=()=> 'co';
  8. const manager={roles:['ops-manager'],current:{id:'teacher'}};
  9. async function command(op,target,req,id){return(await one('SELECT xs_payroll_command(\'co\',\'teacher\',$1,$2,$3::jsonb,$4) AS result',[op,target,JSON.stringify({reason:'测试工资',...req}),id])).result;}
  10. async function version(){return(await one('SELECT xs_payroll_source_version(\'co\') AS value')).value;}
  11. before(async()=>{
  12. const block=source.slice(source.indexOf('async function ensurePayrollSchemas()'));const schemas=vm.runInNewContext('('+block.match(/const schemas = (\{[\s\S]*?\n });/)[1]+')');
  13. for(const [name,fields] of Object.entries(schemas)){const cols={objectId:'text PRIMARY KEY',createdAt:'timestamptz',updatedAt:'timestamptz',...Object.fromEntries(Object.entries(fields).map(([k,v])=>[k,({Number:'double precision',Date:'timestamptz',Boolean:'boolean',Array:'jsonb',Object:'jsonb'})[v.type]||'text']))};await db.exec(`CREATE TABLE "${name}" (${Object.entries(cols).map(([k,v])=>`"${k}" ${v}`).join(',')})`);}
  14. await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,company text,"legacyUserId" double precision,"legacyGroupId" numeric,"identityType" text,"legacyUserData" jsonb,"realName" text,nickname text,username text,mobile text,"isDisabled" boolean,"isDeleted" boolean,"createdAt" timestamptz,"updatedAt" timestamptz);
  15. CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,company text,"generalId" numeric,"itemId" numeric,"modelId" numeric,"nodeId" numeric,"createdAt" timestamptz,"updatedAt" timestamptz,title text,subtitle text,"tableName" text,status numeric,"sourceKey" text);
  16. CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,company text,id numeric,"yhid" text,kcid text,"sourceKey" text);
  17. CREATE TABLE "DailyStudyRecord" ("objectId" text);
  18. CREATE TABLE "MemoryPracticeRecord" ("objectId" text PRIMARY KEY,company text,"sourceKey" text,id numeric,plid text,yhid text,xxjlid text,kywrq text,kywsj text,kcid numeric,fxzt text,wcsj text,"updatedAt" timestamptz);
  19. CREATE TABLE "Node" (company text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
  20. CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,company text,"sourceKey" text NOT NULL UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,id numeric,cdate timestamptz,cname text,type1 text,type2 text,type3 text,clevel numeric,"cadminId" numeric,detail text,content1 text,content2 text);
  21. ALTER TABLE "CourseAppointment" ADD COLUMN szyh text,ADD COLUMN kcid text,ADD COLUMN pl text,ADD COLUMN fxpl text,ADD COLUMN dszt text,ADD COLUMN dslx text,ADD COLUMN jffs text,ADD COLUMN jssj text,ADD COLUMN kssj text,ADD COLUMN yysj text,ADD COLUMN bxrq text,ADD COLUMN sdsd text,ADD COLUMN plxm text,ADD COLUMN yykcid text;
  22. INSERT INTO "_User" ("objectId",company,"legacyUserId","legacyGroupId","identityType","legacyUserData","realName",username) VALUES ('teacher','co',30,3,'coach','{"ParentUserID":10}','老师','teacher'),('student','co',20,1,'member','{"UserPoint":2,"Purse":2}','学员','student'),('store','co',10,2,'store','{}','门店','store');`);
  23. for(const table of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec(`CREATE TABLE "${table}" ("objectId" text PRIMARY KEY,company text,"sourceKey" text UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" numeric,score numeric,"scoreBefore" numeric,"hisTime" timestamptz,operator numeric,"scoreType" numeric,detail text,remark text,"payMethod" text)`);
  24. await db.exec(readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8'));
  25. await db.exec(readFileSync(new URL('../sql/review-rewards.sql',import.meta.url),'utf8'));
  26. await db.exec(migration);
  27. context.teachingCategoryRows=async()=>[];context.courseTeachingRows=async()=>[];context.payRateRows=async()=>context.defaultPayRates();
  28. });after(()=>db.close());
  29. const line={kind:'lesson',standardLessonKey:'legacy:123',legacyGeneralId:123,coachId:30,coachName:'老师',amount:40,rateAmount:40,durationHours:1,originalMonth:'2026-09',lessonAt:'2026-09-01T00:00:00Z',exception:''};let batch;
  30. test('北京时间月界,工资快照含零元不被规则覆盖',()=>{assert.equal(context.payrollBusinessDate('2026-09-01 07:30:00'),'2026-09-01');assert.equal(context.payrollDateIso(new Date('2026-09-01T00:00:00Z')),'2026-09-01T00:00:00.000Z');for(const pay of [0,40]){const r=context.payrollCalculatedLine({...line,classType:2,courseCategoryKey:'word',teacherPaySnapshot:pay},[],'2026-09',[],new Map());assert.equal(r.amount,pay);assert.equal(r.exception,'');}});
  31. test('未知运营角色不能获得经理权限',()=>assert.equal(context.roleOf({roles:['ops-unknown']}),'denied'));
  32. test('创建、课次跨批次防重、失败不留半张工资单',async()=>{batch=await command('create','',{month:'2026-09',lines:[line],sourceVersion:await version()},'create-001');assert.equal(batch.payableAmount,40);const retry=await command('create','',{month:'2026-09',lines:[],sourceVersion:'changed'},'create-001');assert.equal(retry.objectId,batch.objectId);await assert.rejects(command('create','',{month:'2026-10',lines:[line],sourceVersion:await version()},'duplicate-01'),/unique/);assert.equal((await one(`SELECT count(*)::int AS n FROM "PayrollBatch"`)).n,1);});
  33. test('调整幂等、门店和月份完整,过期版本拒绝',async()=>{const req={version:batch.version,coachId:30,amount:10};batch=await command('adjustment',batch.objectId,req,'adjustment-001');assert.equal(batch.payableAmount,50);const again=await command('adjustment',batch.objectId,req,'adjustment-001');assert.equal(again.payableAmount,50);const l=await one(`SELECT * FROM "PayrollLine" WHERE kind='adjustment'`);assert.equal(l.storeId,10);assert.equal(l.settlementMonth,'2026-09');await assert.rejects(command('adjustment',batch.objectId,req,'adjustment-002'),/已更新/);});
  34. test('重算已有明细保留调整,异常禁止审核,审计失败全部回滚',async()=>{batch=await command('refresh',batch.objectId,{version:batch.version,month:'2026-09',lines:[{...line,amount:45}],sourceVersion:await version()},'refresh-001');assert.equal(batch.payableAmount,55);await assert.rejects(command('review',batch.objectId,{version:batch.version,month:'2026-09',lines:[{...line,amount:0,exception:'来源异常'}],sourceVersion:await version()},'review-bad'),/异常/);assert.equal((await one(`SELECT "payableAmount" FROM "PayrollBatch"`)).payableAmount,55);await db.exec(`CREATE FUNCTION fail_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'audit failed';END $$; CREATE TRIGGER fail_audit BEFORE INSERT ON "SysLog" FOR EACH ROW EXECUTE FUNCTION fail_audit();`);await assert.rejects(command('adjustment',batch.objectId,{version:batch.version,coachId:30,amount:9},'audit-failure'),/audit failed/);await db.exec('DROP TRIGGER fail_audit ON "SysLog"');assert.equal((await one(`SELECT "payableAmount" FROM "PayrollBatch"`)).payableAmount,55);});
  35. test('审核永久锁定、登记发放校验凭据',async()=>{batch=await command('review',batch.objectId,{version:batch.version,month:'2026-09',lines:[line],sourceVersion:await version()},'review-good');await assert.rejects(command('adjustment',batch.objectId,{version:batch.version,coachId:30,amount:1},'locked-adjust'),/草稿/);await assert.rejects(command('mark-paid',batch.objectId,{version:batch.version},'paid-missing'),/凭证/);batch=await command('mark-paid',batch.objectId,{version:batch.version,paymentMethod:'转账',paymentReference:'test-001'},'paid-good');assert.equal(batch.status,'paid');});
  36. test('新完课入工资、事实不覆盖快照、预览和结算来源一致',async()=>{await db.exec(`INSERT INTO "CourseAppointment" ("objectId",company,id,szyh,kcid,pl,dszt,"teacherPaySnapshot","courseCategoryKey","durationMinutes",jffs) VALUES ('appointment','co',5,'20','100','30','11',40,'word',60,'线上'); INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",title,subtitle,status) VALUES ('ac','co',54,5,5,'学员','单词',99),('lc','co',59,6,6,'学员','单词',99); INSERT INTO "LessonRecord" ("objectId",company,id,jsmz,xymz,yyds,kcid,kclx,"lessonAt","teacherPaySnapshot") VALUES ('lesson','co',6,'30','20','5','100','2','2026-09-01 07:30+08',40); INSERT INTO "LessonRecordFact" ("objectId",company,"legacyGeneralId","teacherPaySnapshot") VALUES ('f','co',6,99);` .replace(',"teacherPaySnapshot") VALUES (\'f\',\'co\',6,99)',') VALUES (\'f\',\'co\',6)'));
  37. const snapshot=await context.payrollCanonicalSnapshot(manager,{});assert.equal(snapshot.items.length,1);assert.equal(snapshot.items[0].teacherPaySnapshot,40);const preview=await context.payrollPreviewData(manager,{},'2026-09',true);assert.equal(preview.baseAmount,40);});
  38. test('规则同日修订保留旧版本,禁止直接覆盖',async()=>{const req={categoryKey:'word',durationMinutes:60,deliveryMode:'online',teacherPay:40,effectiveFrom:'2026-09-01',creditCosts:[{account:'SilverCoin',amount:1}],active:true,supersedesId:'',reason:'调整规则'};const a=(await one('SELECT xs_payroll_rule(\'co\',\'teacher\',$1) r',[JSON.stringify(req)])).r;const b=(await one('SELECT xs_payroll_rule(\'co\',\'teacher\',$1) r',[JSON.stringify({...req,teacherPay:99,supersedesId:a.objectId})])).r;assert.equal(b.revision,2);await assert.rejects(q('UPDATE "TeachingCompensationRule" SET "teacherPay"=1 WHERE "objectId"=$1',[a.objectId]),/不可改写/);const rows=await context.teachingRuleRows(manager,{});assert.equal(rows.filter(r=>r.categoryKey==='word'&&r.durationMinutes===60&&r.effectiveFrom==='2026-09-01'&&r.deliveryMode==='online'&&r.active).length,1);});
  39. test('按门店和老师导出,排除记录单列,汇总明细一致',async()=>{const ctx=vm.createContext({Date,Psql:{query:q,one,oneOrNone:one}});vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);ctx.payrollDetailData=async()=>({month:'2026-09',lines:[{...line,storeId:10},{...line,coachId:31,storeId:11},{...line,storeId:10,excluded:true,exclusionReason:'作废'}]});const data=await ctx.payrollExportData({}, {targetId:'batch',storeId:10,coachId:30});assert.equal(data.lines.length,1);assert.equal(data.excluded.length,1);assert.equal(data.summary.payableAmount,40);});
  40. test('课时差额通过账户事务退回一次,预览扣除已退金额',async()=>{await db.exec(`INSERT INTO "CourseAppointment" ("objectId",company,id,szyh,dszt,"courseCategoryKey","durationMinutes",yysj) VALUES ('review-appointment','co',7,'20','11','review',30,'2026-09-02 09:00');INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",status) VALUES ('review-common','co',54,7,7,99);INSERT INTO "UserUserPoint" ("objectId",company,"sourceKey",score) VALUES ('charge','co','cloud:e_order_update:7:period',-1);`);const before=Number((await one(`SELECT "legacyUserData"->>'UserPoint' b FROM "_User" WHERE "objectId"='student'`)).b);const run=async()=>(await one('SELECT xs_payroll_refunds(\'co\',\'teacher\',\'["review-common"]\',\'核对差额\') r')).r;assert.equal((await run()).refundTotal,.5);assert.equal((await run()).refundTotal,0);assert.equal(Number((await one(`SELECT "legacyUserData"->>'UserPoint' b FROM "_User" WHERE "objectId"='student'`)).b),before+.5);const rows=await context.creditReconciliationPreview(manager,{});assert.equal(rows.items[0].adjustment,0);assert.equal(rows.items[0].status,'已处理');});
  41. test('历史预留同事务、重复提交不重复预留,异常时整体回滚',async()=>{await db.exec(`INSERT INTO "CourseAppointment" ("objectId",company,id,szyh,dszt,kcid,yysj,"updatedAt") VALUES ('future','co',8,'20','0','100','2099-01-01 10:00',now());INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",status) VALUES ('future-common','co',54,8,8,99);`);const a=await one(`SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"='future'`);const i={appointmentId:'future-common',expectedUpdatedAt:a.updatedAt,status:'ready',reason:'',categoryKey:'word',categoryName:'单词课',durationMinutes:30,deliveryMode:'online',teachingRuleId:'saved',teacherPay:15,creditCosts:[{account:'Purse',amount:1}]};const run=async(items)=>(await one('SELECT xs_payroll_reservations(\'co\',\'teacher\',$1,\'补建预留\',$2) r',[JSON.stringify(items),await version()])).r;await assert.rejects(run([i,{...i,appointmentId:'missing'}]),/不存在/);assert.equal((await one(`SELECT count(*)::int n FROM "CourseCreditReservation"`)).n,0);assert.equal((await run([i])).reserved,1);const updated=await one(`SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"='future'`);assert.equal((await run([{...i,expectedUpdatedAt:updated.updatedAt}])).reserved,0);});
  42. test('阅读写作确认不受无关更新时间影响,确认后不再返回待核对列表',async()=>{await db.exec(`UPDATE "CommonModel" SET subtitle='阅读写作' WHERE "objectId"='future-common';`);const a=await one(`SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"='future'`);await assert.rejects(q('SELECT xs_payroll_writing_mapping(\'co\',\'teacher\',\'future-common\',\'primary_writing\',\'小学五年级\',$1,\'核实年级\')',[a.updatedAt]),/快照/);await db.exec(`INSERT INTO "CourseAppointment" ("objectId",company,id,szyh,dszt,kcid,yysj,"updatedAt") VALUES ('writing','co',9,'20','0','398','2099-01-02 10:00',now());INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",status,subtitle) VALUES ('writing-common','co',54,9,9,99,'阅读写作');`);assert.equal((await context.writingMappingPage(manager,{})).items.some(row=>row.appointmentId==='writing-common'),true);const stale=await one(`SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"='writing'`);await db.exec(`UPDATE "CourseAppointment" SET "updatedAt"=now()+interval '1 minute' WHERE "objectId"='writing'`);const saved=(await one('SELECT xs_payroll_writing_mapping(\'co\',\'teacher\',\'writing-common\',\'primary_writing\',\'用户核实小学\',$1,\'核实年级\') r',[stale.updatedAt])).r;assert.equal(saved.updated,true);assert.equal((await one(`SELECT "courseCategoryKey" k FROM "CourseAppointment" WHERE "objectId"='writing'`)).k,'primary_writing');assert.equal((await context.writingMappingPage(manager,{})).items.some(row=>row.appointmentId==='writing-common'),false);const repeated=(await one('SELECT xs_payroll_writing_mapping(\'co\',\'teacher\',\'writing-common\',\'primary_writing\',\'用户核实小学\',$1,\'核实年级\') r',[stale.updatedAt])).r;assert.equal(repeated.alreadyConfirmed,true);await assert.rejects(q('SELECT xs_payroll_writing_mapping(\'co\',\'teacher\',\'writing-common\',\'middle_writing\',\'用户核实初中\',$1,\'核实年级\')',[stale.updatedAt]),/其他人确认/);});
  43. test('奖励导出快照及游标不受分页期间新增记录影响',async()=>{for(let i=0;i<3;i++)await q('INSERT INTO "ReviewReward" ("objectId","company","taskId","status","amount") VALUES ($1,\'co\',$1,\'historical\',0)',['receipt-'+i]);const page=await context.reviewRewardPage('co',{exportMode:true,pageSize:2});await q(`INSERT INTO "ReviewReward" ("objectId",company,"taskId",status,amount) VALUES ('receipt-new','co','receipt-new','historical',0)`);const next=await context.reviewRewardPage('co',{exportMode:true,pageSize:2,snapshot:page.snapshot,afterId:page.nextCursor});assert.equal(page.total,3);assert.equal(next.total,3);assert.equal(next.items.length,1);assert.notEqual(next.items[0].objectId,'receipt-new');});
  44. test('工资表禁止直接绕过事务写入,迁移重跑不改变已发放金额',async()=>{await assert.rejects(q(`INSERT INTO "PayrollBatch" ("objectId",company,month,status) VALUES ('bypass','co','2099-01','paid')`),/业务事务/);const before=await one(`SELECT "payableAmount",status FROM "PayrollBatch" WHERE "objectId"=$1`,[batch.objectId]);await db.exec(migration);assert.deepEqual(await one(`SELECT "payableAmount",status FROM "PayrollBatch" WHERE "objectId"=$1`,[batch.objectId]),before);});
  45. test('历史课次门店名称跟随保存的门店编号而不是教师现门店',async()=>{const ctx=vm.createContext({Date,Psql:{one:async()=>({rows:[{objectId:'history',jsmz:30,szmdid:11}],version:'v'}),query:async()=>[{objectId:'teacher',legacyUserId:30,legacyUserData:{ParentUserID:10},realName:'老师'},{objectId:'old-store',legacyUserId:11,realName:'历史门店'},{objectId:'new-store',legacyUserId:10,realName:'现门店'}]}});vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);ctx.companyIdOf=()=> 'co';const result=await ctx.payrollCanonicalSnapshot(manager,{});assert.equal(result.items[0].storeId,11);assert.equal(result.items[0].storeName,'历史门店');});
  46. test('陪练默认按未来排课由近到远,再按历史排课由近到远',()=>{const rows=[{userId:1,displayName:'无记录',nextAppointmentAt:'',lastAppointmentAt:''},{userId:2,displayName:'较早历史',nextAppointmentAt:'',lastAppointmentAt:'2026-08-01T10:00:00.000Z'},{userId:3,displayName:'较晚未来',nextAppointmentAt:'2026-09-20T10:00:00.000Z',lastAppointmentAt:'2026-09-01T10:00:00.000Z'},{userId:4,displayName:'较近未来',nextAppointmentAt:'2026-09-12T10:00:00.000Z',lastAppointmentAt:'2025-01-01T10:00:00.000Z'},{userId:5,displayName:'最近历史',nextAppointmentAt:'',lastAppointmentAt:'2026-09-09T10:00:00.000Z'}];rows.sort(context.coachActivityCompare);assert.deepEqual(rows.map(row=>row.userId),[4,3,5,2,1]);});
  47. test('陪练活跃记录忽略已取消预约和已结束的未来异常记录',()=>{const result=context.appointmentHistory([{coachId:1,startsAt:'2099-01-01T10:00:00.000Z',statusCode:0,cancelled:true},{coachId:1,startsAt:'2099-01-02T10:00:00.000Z',statusCode:30,cancelled:false},{coachId:1,startsAt:'2099-01-03T10:00:00.000Z',statusCode:10,cancelled:false}]);assert.equal(result.totalAppointments,2);assert.equal(result.byCoach.get(1).nextAppointmentAt,'2099-01-03T10:00:00.000Z');});
  48. test('旧单价接口也保留修订记录且审计在同一事务中',async()=>{const req={classType:2,amount:40,durationHours:1,effectiveFrom:'2026-08-01',active:true,reason:'历史工资规则修订'};const first=(await one('SELECT xs_payroll_legacy_rate(\'co\',\'teacher\',$1) r',[JSON.stringify(req)])).r;const v=await version();await one('SELECT xs_payroll_legacy_rate(\'co\',\'teacher\',$1) r',[JSON.stringify({...req,amount:99})]);assert.notEqual(await version(),v);assert.equal((await one(`SELECT amount FROM "CoachPayRate" WHERE "objectId"=$1`,[first.objectId])).amount,40);await assert.rejects(q(`UPDATE "CoachPayRate" SET amount=1 WHERE "objectId"=$1`,[first.objectId]),/不可改写/);assert.equal((await one(`SELECT count(*)::int n FROM "SysLog" WHERE type2='save-legacy-rate'`)).n,2);});
  49. test('旧系统复习凭据仅人工确认后计薪,旧月份拒绝且重复确认幂等',async()=>{
  50. await db.exec(`INSERT INTO "CommonModel" ("objectId",company,"modelId","generalId") VALUES ('legacy-study','co',56,6001);
  51. INSERT INTO "MemoryPracticeRecord" ("objectId",company,"sourceKey",id,plid,yhid,xxjlid,kywrq,kywsj,kcid,fxzt,wcsj) VALUES
  52. ('legacy-review-new','co','legacy-sync:memory-records:addon:991',991,'30','20','6001','2026-09-05','2026-09-05',1,'1','2026-09-05 10:00:00'),
  53. ('legacy-review-old','co','legacy-sync:memory-records:addon:992',992,'30','20','6001','2026-08-05','2026-08-05',1,'1','2026-08-05 10:00:00');`);
  54. const current=await one('SELECT "objectId",status,amount FROM "ReviewReward" WHERE "taskId"=$1',['legacy-review-new']);assert.equal(current.status,'historical');assert.equal(Number(current.amount),0);
  55. const initialVersion=await version();const invoke=async(id)=>(await one('SELECT xs_review_reward_confirm(\'co\',\'teacher\',$1::jsonb,\'已核对旧系统未发放\') AS result',[JSON.stringify([id])])).result;
  56. await assert.rejects(invoke((await one('SELECT "objectId" FROM "ReviewReward" WHERE "taskId"=$1',['legacy-review-old'])).objectId),/未通过/);
  57. const confirmed=await invoke(current.objectId);assert.equal(confirmed.confirmed,1);assert.notEqual(await version(),initialVersion);
  58. assert.equal((await invoke(current.objectId)).alreadyConfirmed,1);assert.equal(Number((await one('SELECT amount FROM "ReviewReward" WHERE "objectId"=$1',[current.objectId])).amount),1);
  59. await assert.rejects(q('UPDATE "ReviewReward" SET amount=2 WHERE "objectId"=$1',[current.objectId]),/不可改写/);
  60. });
  61. test('工资结算仅在三个数据集追平、两分钟内同步且无失败时开放',async()=>{
  62. const keys=['appointments','lessons','memory-records'],countByModel={54:[1,2],59:[3,4,5],60:[6,7,8,9]};
  63. let checkpoints=keys.map((dataset,index)=>({dataset,state:'healthy',cursor:'v2',lastSuccessAt:new Date().toISOString(),lastWatermark:index+10})),issues=[];
  64. const ctx=vm.createContext({Date,process:{env:{XIAOSHU_LEGACY_SYNC_BASE_URL:'https://example.test',XIAOSHU_LEGACY_SYNC_KEY_ID:'key',XIAOSHU_LEGACY_SYNC_SECRET:'secret'}},Psql:{query:async(sql)=>sql.includes('LegacySyncCheckpoint')?checkpoints:sql.includes('LegacySyncDeadLetter')?issues:Object.entries(countByModel).map(([model,keys])=>({model,keys}))}});
  65. vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/payroll-integrity.js',import.meta.url),'utf8'),ctx);
  66. ctx.companyIdOf=()=> 'co';ctx.sha256=async(value)=>'digest-'+value.replaceAll('\n',',');ctx.syncBridgeRequest=async()=>({consistent:true,datasets:keys.map((key,index)=>({key,count:[2,3,4][index],watermark:index+10,checksum:'digest-'+countByModel[[54,59,60][index]].join(',')+','}))});
  67. const healthy=await ctx.payrollSettlementSyncStatus(manager,{});assert.equal(healthy.settlementReady,true,JSON.stringify(healthy));
  68. checkpoints=checkpoints.map(row=>row.dataset==='lessons'?{...row,state:'catching_up'}:row);assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).settlementReady,false);
  69. checkpoints=checkpoints.map(row=>({...row,state:'healthy'}));issues=[{dataset:'appointments',kind:'failure',total:1}];assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).settlementReady,false);
  70. issues=[];checkpoints=checkpoints.map(row=>row.dataset==='memory-records'?{...row,lastSuccessAt:new Date(Date.now()-121000).toISOString()}:row);assert.equal((await ctx.payrollSettlementSyncStatus(manager,{})).settlementReady,false);
  71. });