|
@@ -0,0 +1,134 @@
|
|
|
|
|
+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';
|
|
|
|
|
+const db=new PGlite(),q=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await q(s,p))[0];
|
|
|
|
|
+const sql=n=>readFileSync(new URL('../sql/'+n+'.sql',import.meta.url),'utf8');
|
|
|
|
|
+let sequence=200;
|
|
|
|
|
+const snapshot=async id=>(await one('SELECT to_jsonb(m) value FROM "MemoryPracticeRecord" m WHERE "objectId"=$1',[id])).value;
|
|
|
|
|
+const income=async id=>one('SELECT * FROM "ReviewRewardIncome" WHERE "taskId"=$1',[id]);
|
|
|
|
|
+const patch=async(id,values,expected)=>(await one('SELECT xs_legacy_review_patch(\'c\',$1,$2::jsonb,$3::jsonb) result',[id,JSON.stringify(values),JSON.stringify(expected||await snapshot(id))])).result;
|
|
|
|
|
+const drain=async()=>{let cursor='',totals={earned:0,excluded:0,amount:0,pending:0};for(let i=0;i<100;i++){const d=(await one('SELECT xs_legacy_review_income(\'c\',\'manager\',100,$1) result',[cursor])).result;totals={earned:totals.earned+d.earned,excluded:totals.excluded+d.excluded,amount:totals.amount+Number(d.amount),pending:d.pending,errors:d.errors};if(!d.hasMore)return totals;cursor=d.afterTaskId;}throw Error('income cursor stalled');};
|
|
|
|
|
+async function task(name,{minutes=60,day='2026-09-05',duplicateOf=0}={}){
|
|
|
|
|
+ const id=++sequence,study=duplicateOf||id;
|
|
|
|
|
+ if(!duplicateOf){
|
|
|
|
|
+ await q(`INSERT INTO "CourseAppointment"("objectId",company,id,szyh,dslx,"durationMinutes") VALUES($1,'c',$2,'20','2',$3)`,[name+'-appointment',id,minutes]);
|
|
|
|
|
+ await q(`INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId") VALUES($1,'c',54,$2,$2),($3,'c',56,$2,$2)`,[name+'-appointment-common',id,name+'-study-common']);
|
|
|
|
|
+ await q(`INSERT INTO "DailyStudyRecord"("objectId",company,id,"userId",dsid) VALUES($1,'c',$2,20,$3)`,[name+'-study',id,String(id)]);
|
|
|
|
|
+ }
|
|
|
|
|
+ if(duplicateOf)await db.exec("SET session_replication_role='replica'");
|
|
|
|
|
+ try{await q(`INSERT INTO "MemoryPracticeRecord"("objectId",company,id,plid,yhid,xxjlid,"orderId",kywrq,kywsj,kcid,fxzt,wcsj,"sourceKey","createdAt","updatedAt","rewardDuplicate") VALUES($1,'c',$2,10,20,$3,$3,$4,$4,30,'0','',$5,'2026-09-01','2026-09-01',$6)`,[name,id,study,day,'legacy:model:60:general:'+id,Boolean(duplicateOf)]);}finally{if(duplicateOf)await db.exec("SET session_replication_role='origin'");}
|
|
|
|
|
+ await q(`INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId","sourceKey","createdAt","updatedAt") VALUES($1,'c',60,$2,$2,$3,'2026-09-01','2026-09-01')`,[name+'-common',id,'legacy:model:60:general:'+id]);return id;
|
|
|
|
|
+}
|
|
|
|
|
+before(async()=>{
|
|
|
|
|
+ await db.exec(readFileSync(new URL('./fixtures/lesson-records-schema.sql',import.meta.url),'utf8'));
|
|
|
|
|
+ const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
|
|
|
|
|
+ const schemas=vm.runInNewContext('('+source.slice(source.indexOf('async function ensurePayrollSchemas()')).match(/const schemas = (\{[\s\S]*?\n });/)[1]+')');
|
|
|
|
|
+ for(const [name,fields] of Object.entries(schemas)){
|
|
|
|
|
+ if(name==='CommonModel')continue;await db.exec(`CREATE TABLE IF NOT EXISTS "${name}" ("objectId" text PRIMARY KEY,"createdAt" timestamptz,"updatedAt" timestamptz)`);
|
|
|
|
|
+ const existing=new Set((await q('SELECT column_name FROM information_schema.columns WHERE table_name=$1',[name])).map(r=>r.column_name));
|
|
|
|
|
+ for(const [key,v] of Object.entries(fields))if(!existing.has(key))await db.exec(`ALTER TABLE "${name}" ADD COLUMN "${key}" ${({Number:'double precision',Date:'timestamptz',Boolean:'boolean',Array:'jsonb',Object:'jsonb'})[v.type]||'text'}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ await db.exec(`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;
|
|
|
|
|
+ CREATE TABLE "CourseBinding"("objectId" text PRIMARY KEY,company text,id numeric,yhid text,kcid text,"sourceKey" text);
|
|
|
|
|
+ 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);
|
|
|
|
|
+ INSERT INTO "_User"("objectId",company,"legacyUserId","legacyGroupId","identityType","adminRoleKey","legacyUserData") VALUES
|
|
|
|
|
+ ('manager','c',1,1,'member','ops-manager','{}'),('coach','c',10,3,'coach',null,'{}'),('student','c',20,1,'member',null,'{}'),('next-coach','c',11,3,'coach',null,'{}'),('third-coach','c',12,3,'coach',null,'{}');
|
|
|
|
|
+ INSERT INTO "Node" VALUES('c',30,'单词课');`);
|
|
|
|
|
+ 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)`);
|
|
|
|
|
+ for(const n of ['member-enrollment','review-rewards','lesson-records','payroll-integrity','review-pay-policy','review-reconciliation','mobile-teaching','legacy-direct-sync','legacy-review-sync'])await db.exec(sql(n));
|
|
|
|
|
+});
|
|
|
|
|
+after(()=>db.close());
|
|
|
|
|
+test('旧系统连续切换老师都可同步,来源版本和基线不会误标为新系统人工转交',async()=>{
|
|
|
|
|
+ await task('follow');assert.equal((await patch('follow',{plid:11})).applied,true);assert.equal((await snapshot('follow')).assignmentVersion,1);
|
|
|
|
|
+ assert.equal((await patch('follow',{plid:12})).applied,true);assert.equal((await snapshot('follow')).plid,12);assert.equal((await snapshot('follow')).assignmentVersion,2);
|
|
|
|
|
+ assert.equal(Number((await one('SELECT count(*) n FROM "LegacyReviewSyncAudit" WHERE "taskId"=\'follow\'')).n),2);
|
|
|
|
|
+});
|
|
|
|
|
+test('新系统人工转交后,旧系统再次更换老师或完成任务均不能覆盖',async()=>{
|
|
|
|
|
+ const id=await task('manual');await patch('manual',{plid:11});
|
|
|
|
|
+ const expected=(await one("SELECT md5(xs_review_transfer_snapshot('c','20',$1,'12')::text) value",[String(id)])).value;
|
|
|
|
|
+ await q("SELECT xs_review_transfer('c','manager','20',$1,'12',$2,'新系统转交','manual-transfer-test')",[String(id),expected]);
|
|
|
|
|
+ const saved=await snapshot('manual');assert.equal((await patch('manual',{plid:10,fxzt:'1',wcsj:'2026-09-05 10:00'})).applied,false);assert.deepEqual(await snapshot('manual'),saved);assert.equal(await income('manual'),undefined);
|
|
|
|
|
+});
|
|
|
|
|
+test('30分钟和60分钟旧完成自动按来源核算,原始收入凭据不改写,重复处理不重复计薪',async()=>{
|
|
|
|
|
+ for(const [name,minutes,amount] of [['paid30',30,.5],['paid60',60,1]]){
|
|
|
|
|
+ await task(name,{minutes});await patch(name,{plid:11,fxzt:'1',wcsj:'2026-09-05 10:00'});const original=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=$1',[name]);assert.equal(original.status,'historical');
|
|
|
|
|
+ const result=await drain();assert.equal(result.earned,1);assert.equal(result.amount,amount);assert.equal(Number((await income(name)).amount),amount);assert.deepEqual(await one('SELECT * FROM "ReviewReward" WHERE "taskId"=$1',[name]),original);
|
|
|
|
|
+ assert.equal((await drain()).earned,0);assert.equal((await patch(name,{plid:12})).applied,false);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+test('新系统老师完成、学生自习和既有计薪的原始归属及完成时间不被旧同步覆盖',async()=>{
|
|
|
|
|
+ const today=new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'});
|
|
|
|
|
+ for(const [name,actor] of [['live-earned','coach'],['self-study','student']]){
|
|
|
|
|
+ await task(name,{day:today});await q("SELECT xs_complete_assigned_review('c',$1,$2)",[name,actor]);const saved=await snapshot(name),reward=await income(name);
|
|
|
|
|
+ assert.equal((await patch(name,{plid:11,fxzt:'0',wcsj:''})).applied,false);assert.deepEqual(await snapshot(name),saved);assert.deepEqual(await income(name),reward);assert.equal((await drain()).earned,0);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+test('重复任务保留两份完成记录,但同一学习批次和日期只计一次工资',async()=>{
|
|
|
|
|
+ const id=await task('canonical');await task('copy',{duplicateOf:id});
|
|
|
|
|
+ for(const name of ['canonical','copy'])await patch(name,{plid:11,fxzt:'1',wcsj:'2026-09-05 10:00'});
|
|
|
|
|
+ const result=await drain();assert.equal(result.earned,1);assert.equal(result.excluded,1);assert.equal(result.amount,1);assert.equal((await income('copy')).ineligibleReason,'duplicate_review');
|
|
|
|
|
+});
|
|
|
|
|
+test('课次未齐先保留待核定并返回原因,来源补齐后自动重试成功;逾期不计薪',async()=>{
|
|
|
|
|
+ await task('missing');await q('DELETE FROM "CourseAppointment" WHERE "objectId"=\'missing-appointment\'');await patch('missing',{fxzt:'1',wcsj:'2026-09-05 10:00'});
|
|
|
|
|
+ const pending=await drain();assert.equal(pending.pending,1);assert.equal(pending.earned,0);assert.match(pending.errors[0].reason,/时长/);
|
|
|
|
|
+ const m=await snapshot('missing');await q(`INSERT INTO "CourseAppointment"("objectId",company,id,szyh,dslx,"durationMinutes") VALUES('missing-appointment','c',$1,'20','2',30)`,[m.orderId]);assert.equal((await drain()).amount,.5);
|
|
|
|
|
+ await task('late');await patch('late',{fxzt:'1',wcsj:'2026-09-06 10:00'});const late=await drain();assert.equal(late.amount,0);assert.equal(late.excluded,1);assert.equal((await income('late')).ineligibleReason,'overdue');
|
|
|
|
|
+});
|
|
|
|
|
+test('陈旧快照、未确认的本地修改、来源身份变化和无权限计薪被拒绝',async()=>{
|
|
|
|
|
+ await task('stale');const old=await snapshot('stale');await patch('stale',{plid:11});assert.equal((await patch('stale',{plid:12},old)).applied,false);
|
|
|
|
|
+ await q('UPDATE "MemoryPracticeRecord" SET kywsj=\'人工修改\' WHERE "objectId"=\'stale\'');assert.equal((await patch('stale',{plid:12})).applied,false);
|
|
|
|
|
+ await task('identity');assert.equal((await patch('identity',{yhid:999})).applied,false);await assert.rejects(q("SELECT xs_legacy_review_income('c','student')"),/仅管理/);
|
|
|
|
|
+});
|
|
|
|
|
+test('页面刷新和夜间同步共用保护及来源基线,页面同步后仍可跟进下一次旧切换',async()=>{
|
|
|
|
|
+ const id=await task('page-follow'),current=await snapshot('page-follow');
|
|
|
|
|
+ const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8').match(/const LEGACY_REVIEW_PULLS=new Map\(\);[\s\S]*?\nconst APP_LIVE_SCOPES=/)[0].replace(/\nconst APP_LIVE_SCOPES=[\s\S]*$/,'');
|
|
|
|
|
+ const Psql={query:q,one,none:async(s,p)=>{await q(s,p);},tx:async fn=>{await db.exec('BEGIN');try{const result=await fn(Psql);await db.exec('COMMIT');return result;}catch(e){await db.exec('ROLLBACK');throw e;}}};
|
|
|
|
|
+ const context={Psql,Map,Date,JSON,String,Number,Math,Promise,URLSearchParams,AbortSignal,DEFAULT_COMPANY_ID:'c',number:(v,f=0)=>Number.isFinite(Number(v))?Number(v):f,firstValue:(...v)=>v.find(x=>x!==undefined&&x!==null&&x!==''),legacyLearningIso:()=>new Date('2026-09-01').toISOString(),appLegacyConfig:async()=>({url:'https://old.invalid',apiId:'id',apiKey:'key'}),fetch:async()=>({ok:true,text:async()=>JSON.stringify({retcode:1,result:[{...current,GeneralID:id,plid:11}],page:{itemCount:1,pageCount:1}})}),mobileAtomicWrite:async()=>{throw Error('Existing tasks must use the shared source updater');}};
|
|
|
|
|
+ vm.createContext(context);vm.runInContext(source+';this.sync=performLegacyReviewSync;',context);
|
|
|
|
|
+ const result=await context.sync(20,{id:'student'},true);assert.equal(result.written,1);assert.equal(result.conflicts,0);assert.equal((await snapshot('page-follow')).plid,11);
|
|
|
|
|
+ assert.equal((await patch('page-follow',{plid:12})).applied,true);
|
|
|
|
|
+});
|
|
|
|
|
+test('已核实来源修复可建立来源版本,迁移重跑不会接管其后的新系统人工转交',async()=>{
|
|
|
|
|
+ const id=await task('adopted');await q('DELETE FROM "LegacyReviewSyncState" WHERE "taskId"=\'adopted\'');
|
|
|
|
|
+ await q("SELECT set_config('xiaoshu.review_transfer','legacy-repair',false)");await q('UPDATE "MemoryPracticeRecord" SET plid=11,"assignmentVersion"=1 WHERE "objectId"=\'adopted\'');await q("SELECT set_config('xiaoshu.review_transfer','',false)");
|
|
|
|
|
+ await q(`INSERT INTO "ReviewTransferCommand"("objectId",company,"actorId",key,fingerprint,reason,result) VALUES('prior-repair','c','manager','repair-legacy-review-transfers-2026-11-02','fingerprint','已核实旧来源','{}')`);
|
|
|
|
|
+ await q(`INSERT INTO "ReviewTransferAudit"("objectId",company,"commandId","taskId","studentId","appointmentId","oldCoachId","newCoachId","oldVersion","newVersion") VALUES('prior-repair-audit','c','prior-repair','adopted','20',$1,'10','11',0,1)`,[String(id)]);
|
|
|
|
|
+ assert.equal((await patch('adopted',{plid:12})).applied,false);await db.exec(sql('legacy-review-sync'));assert.equal((await patch('adopted',{plid:12})).applied,true);
|
|
|
|
|
+ const expected=(await one("SELECT md5(xs_review_transfer_snapshot('c','20',$1,'11')::text) value",[String(id)])).value;
|
|
|
|
|
+ await q("SELECT xs_review_transfer('c','manager','20',$1,'11',$2,'人工接手','adopted-human-transfer')",[String(id),expected]);
|
|
|
|
|
+ await db.exec(sql('legacy-review-sync'));assert.equal((await patch('adopted',{plid:10})).applied,false);assert.equal((await snapshot('adopted')).plid,11);
|
|
|
|
|
+});
|
|
|
|
|
+test('历史未初始化任务只在完整业务字段与旧来源一致时建立基线,已知本地修改不被重新接管',async()=>{
|
|
|
|
|
+ const id=await task('bootstrap');const source={...await snapshot('bootstrap'),GeneralID:id};
|
|
|
|
|
+ await q('DELETE FROM "LegacyReviewSyncState" WHERE "taskId"=\'bootstrap\'');await q('DELETE FROM "LegacyDirectBaseline" WHERE gid=$1',[id]);
|
|
|
|
|
+ await q('UPDATE "MemoryPracticeRecord" SET "updatedAt"=\'2026-10-08\' WHERE "objectId"=\'bootstrap\'');
|
|
|
|
|
+ assert.equal((await one("SELECT xs_legacy_review_observe('c',$1::jsonb) n",[JSON.stringify([{...source,plid:12}])])).n,0);
|
|
|
|
|
+ assert.equal((await patch('bootstrap',{plid:11})).applied,false);
|
|
|
|
|
+ assert.equal((await one("SELECT xs_legacy_review_observe('c',$1::jsonb) n",[JSON.stringify([source])])).n,1);assert.equal((await patch('bootstrap',{plid:11})).applied,true);
|
|
|
|
|
+ const id2=await task('known-local');const source2={...await snapshot('known-local'),GeneralID:id2};await q('DELETE FROM "LegacyReviewSyncState" WHERE "taskId"=\'known-local\'');await q('UPDATE "MemoryPracticeRecord" SET "updatedAt"=\'2026-10-08\' WHERE "objectId"=\'known-local\'');
|
|
|
|
|
+ assert.equal((await one("SELECT xs_legacy_review_observe('c',$1::jsonb) n",[JSON.stringify([source2])])).n,0);assert.equal((await patch('known-local',{plid:11})).applied,false);
|
|
|
|
|
+});
|
|
|
|
|
+test('明确自习老师账号的旧来源只保留进度与核查原因,不自动产生老师工资',async()=>{
|
|
|
|
|
+ await q(`INSERT INTO "_User"("objectId",company,"legacyUserId","legacyGroupId","identityType",nickname,"legacyUserData") VALUES('study-coach','c',13,3,'coach','自习账号','{}')`);
|
|
|
|
|
+ await task('old-self');await patch('old-self',{plid:13,fxzt:'1',wcsj:'2026-09-05 10:00'});const result=await drain();assert.equal(result.earned,0);assert.equal(result.pending,1);assert.match(result.errors[0].reason,/自习/);assert.equal(Number((await income('old-self')).amount),0);
|
|
|
|
|
+});
|
|
|
|
|
+test('先到队列的当天完成副本不被逾期原件永久排除,最终只为有效当天完成计一次薪',async()=>{
|
|
|
|
|
+ const id=await task('z-overdue-original');await task('a-valid-duplicate',{duplicateOf:id});
|
|
|
|
|
+ await patch('z-overdue-original',{plid:11,fxzt:'1',wcsj:'2026-09-06 10:00'});await patch('a-valid-duplicate',{plid:11,fxzt:'1',wcsj:'2026-09-05 10:00'});
|
|
|
|
|
+ const result=await drain();assert.equal(result.earned,1);assert.equal(result.excluded,1);assert.equal(result.amount,1);assert.equal((await income('a-valid-duplicate')).status,'earned');assert.equal((await income('z-overdue-original')).ineligibleReason,'overdue');
|
|
|
|
|
+});
|
|
|
|
|
+test('页面修复已存在主表的缺失附表时,同一事务建立来源状态和费用队列',async()=>{
|
|
|
|
|
+ const id=await task('page-orphan'),old=await snapshot('page-orphan');await q('DELETE FROM "MemoryPracticeRecord" WHERE "objectId"=\'page-orphan\'');await q('DELETE FROM "LegacyReviewSyncState" WHERE "taskId"=\'page-orphan\'');
|
|
|
|
|
+ const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8').match(/const LEGACY_REVIEW_PULLS=new Map\(\);[\s\S]*?\nconst APP_LIVE_SCOPES=/)[0].replace(/\nconst APP_LIVE_SCOPES=[\s\S]*$/,'');let serial=0;
|
|
|
|
|
+ const context={Psql:{query:q,one},Map,Date,JSON,String,Number,Math,Promise,URLSearchParams,AbortSignal,DEFAULT_COMPANY_ID:'c',number:(v,f=0)=>Number.isFinite(Number(v))?Number(v):f,firstValue:(...v)=>v.find(x=>x!==undefined&&x!==null&&x!==''),randomObjectId:()=>('orphan-object-'+(++serial)),legacyLearningIso:()=>new Date('2026-09-01').toISOString(),appLegacyConfig:async()=>({url:'https://old.invalid',apiId:'id',apiKey:'key'}),fetch:async()=>({ok:true,text:async()=>JSON.stringify({retcode:1,result:[{...old,GeneralID:id,plid:11,fxzt:'1',wcsj:'2026-09-05 10:00'}],page:{itemCount:1,pageCount:1}})}),mobileAtomicWrite:async(input,current,operations)=>(await one("SELECT xs_mobile_teaching_write('c','20','orphan-page-repair','fixed-fingerprint',$1::jsonb) result",[JSON.stringify(operations)])).result};
|
|
|
|
|
+ vm.createContext(context);vm.runInContext(source+';this.sync=performLegacyReviewSync;',context);const result=await context.sync(20,{id:'student'},true);assert.equal(result.written,1);
|
|
|
|
|
+ const tracked=await one('SELECT * FROM "LegacyReviewSyncState" WHERE "taskId"=(SELECT "objectId" FROM "MemoryPracticeRecord" WHERE id=$1)',[id]);assert.equal(tracked.pendingIncome,true);const completed=await drain();assert.equal(completed.earned,1);assert.equal(completed.amount,1);
|
|
|
|
|
+});
|
|
|
|
|
+test('同一计划日的未来完成原件不能把已经有效完成的副本永久判为重复零元',async()=>{
|
|
|
|
|
+ const day=new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}),id=await task('z-future-original',{day});await task('a-future-valid-copy',{day,duplicateOf:id});
|
|
|
|
|
+ await patch('z-future-original',{plid:11,fxzt:'1',wcsj:day+' 23:59:59'});await patch('a-future-valid-copy',{plid:11,fxzt:'1',wcsj:day+' 00:00:01'});
|
|
|
|
|
+ const result=await drain();assert.equal(result.earned,0);assert.equal(result.excluded,0);assert.equal((await income('a-future-valid-copy')).status,'exception');assert.equal((await income('z-future-original')).status,'historical');
|
|
|
|
|
+ assert((await one('SELECT "pendingIncome" FROM "LegacyReviewSyncState" WHERE "taskId"=\'a-future-valid-copy\'')).pendingIncome);
|
|
|
|
|
+});
|