review-pay-policy.test.mjs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. import {test,before,after} from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import {readFileSync} from 'node:fs';
  4. import vm from 'node:vm';
  5. import {PGlite} from '@electric-sql/pglite';
  6. const db=new PGlite(),q=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await q(s,p))[0];
  7. const sql=name=>readFileSync(new URL('../sql/'+name+'.sql',import.meta.url),'utf8');
  8. const today=()=>new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'});
  9. const complete=async(id,actor='coach')=>(await one('SELECT xs_review_complete_result($1,$2,$3) result',['c',id,actor])).result;
  10. const receipt=async id=>one('SELECT * FROM "ReviewRewardIncome" WHERE "taskId"=$1',[id]);
  11. let sequence=100;
  12. async function task(name,{minutes=60,type='2',day=today(),student=20,source='',done=false}={}){
  13. const id=++sequence;
  14. await q(`INSERT INTO "CourseAppointment"("objectId",company,id,szyh,dslx,"durationMinutes") VALUES($1,'c',$2,$3,$4,$5)`,[name+'-appointment',id,String(student),type,minutes]);
  15. 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']);
  16. await q(`INSERT INTO "DailyStudyRecord"("objectId",company,id,"userId",dsid) VALUES($1,'c',$2,$3,$4)`,[name+'-study',id,student,String(id)]);
  17. await q(`INSERT INTO "MemoryPracticeRecord"("objectId",company,id,plid,yhid,xxjlid,"orderId",kywrq,kywsj,kcid,fxzt,wcsj,"sourceKey") VALUES($1,'c',$2,10,$3,$2,$2,$4,$4,30,$5,$6,$7)`,[name,id,student,day,done?'1':'0',done?day+' 10:00:00':'',source]);
  18. return id;
  19. }
  20. before(async()=>{
  21. await db.exec(readFileSync(new URL('./fixtures/lesson-records-schema.sql',import.meta.url),'utf8'));
  22. const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
  23. const schemas=vm.runInNewContext('('+source.slice(source.indexOf('async function ensurePayrollSchemas()')).match(/const schemas = (\{[\s\S]*?\n });/)[1]+')');
  24. for(const [name,fields] of Object.entries(schemas)){
  25. if(name==='CommonModel')continue;
  26. await db.exec(`CREATE TABLE IF NOT EXISTS "${name}" ("objectId" text PRIMARY KEY,"createdAt" timestamptz,"updatedAt" timestamptz)`);
  27. const existing=new Set((await q('SELECT column_name FROM information_schema.columns WHERE table_name=$1',[name])).map(r=>r.column_name));
  28. 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'}`);
  29. }
  30. 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;
  31. CREATE TABLE "CourseBinding"("objectId" text PRIMARY KEY,company text,id numeric,yhid text,kcid text,"sourceKey" text);
  32. 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);
  33. INSERT INTO "_User"("objectId",company,"legacyUserId","legacyGroupId","identityType","adminRoleKey","legacyUserData") VALUES
  34. ('manager','c',1,1,'member','ops-manager','{}'),('coach','c',10,3,'coach',null,'{}'),('student','c',20,1,'member',null,'{}'),('other-coach','c',11,3,'coach',null,'{}'),('other-tenant','x',10,3,'coach',null,'{}');
  35. INSERT INTO "Node" VALUES('c',30,'单词课');`);
  36. 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)`);
  37. await db.exec(sql('member-enrollment'));await db.exec(sql('review-rewards'));await db.exec(sql('lesson-records'));await db.exec(sql('payroll-integrity'));
  38. await task('old-rate',{minutes:30});await complete('old-rate');
  39. await task('old-overdue',{minutes:30,day:'2026-09-01'});
  40. const old=await receipt('old-rate');
  41. await q(`INSERT INTO "ReviewReward"("objectId",company,"taskId","coachId","studentId","plannedDate","completedAt","incomeMonth",amount,status) VALUES('old-overdue-receipt','c','old-overdue',10,20,'2026-09-01','2026-09-02 10:00+08','2026-09',1,'earned')`);
  42. assert.equal(Number(old.amount),1);
  43. await db.exec(sql('review-pay-policy'));
  44. });
  45. after(()=>db.close());
  46. test('30分钟0.5元、60分钟1元,按排课快照优先并记录实际老师身份',async()=>{
  47. for(const [name,minutes,type,expected] of [['thirty',30,'2',.5],['sixty',60,'1',1],['legacy30',null,'1',.5],['legacy60',null,'2',1]]){
  48. await task(name,{minutes,type});const r=await complete(name);assert.equal(Number(r.amount),expected);assert.equal(r.rewardStatus,'earned');
  49. const snapshot=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=$1',[name]);assert.equal(snapshot.completedBy,'coach');assert.equal(snapshot.completionRole,'coach');assert.equal(snapshot.durationMinutes,expected*60);
  50. assert.equal((await complete(name)).unchanged,true);assert.equal((await q('SELECT * FROM "ReviewReward" WHERE "taskId"=$1',[name])).length,1);
  51. }
  52. });
  53. test('学生自行复习保存完成进度但零工资;老师随后重试不能补记工资,旧分组3会员也不计薪',async()=>{
  54. for(const group of [1,3]){
  55. await q('UPDATE "_User" SET "legacyGroupId"=$1 WHERE "objectId"=\'student\'',[group]);
  56. const id='self-'+group;await task(id,{minutes:30});const result=await complete(id,'student');
  57. assert.equal(Number(result.amount),0);assert.equal(result.ineligibleReason,'self_study');assert.equal(result.rewardStatus,'ineligible');
  58. assert.equal((await one('SELECT fxzt FROM "MemoryPracticeRecord" WHERE "objectId"=$1',[id])).fxzt,'1');
  59. assert.equal(Number((await complete(id,'coach')).amount),0);
  60. assert.equal((await one('SELECT "completionRole" FROM "ReviewReward" WHERE "taskId"=$1',[id])).completionRole,'student');
  61. }
  62. });
  63. test('跨账号、跨帐套及停用账号拒绝;未认证的底层完成不能计薪,身份上下文不泄漏',async()=>{
  64. await task('protected');
  65. await assert.rejects(complete('protected','other-coach'),/不属于/);await assert.rejects(complete('protected','other-tenant'),/账号不可用/);
  66. await q(`UPDATE "_User" SET "isDisabled"=true WHERE "objectId"='coach'`);await assert.rejects(complete('protected'),/账号不可用/);await q(`UPDATE "_User" SET "isDisabled"=false WHERE "objectId"='coach'`);
  67. await task('no-actor');await q(`SELECT xiaoshu_complete_review('c','no-actor')`);assert.equal((await receipt('no-actor')).status,'exception');assert.equal(Number((await receipt('no-actor')).amount),0);
  68. assert.equal((await one("SELECT COALESCE(current_setting('xiaoshu.review_actor',true),'') value")).value,'');
  69. });
  70. test('逾期零元、未来拒绝、缺失或冲突时长不默认计薪',async()=>{
  71. await task('overdue',{minutes:30,day:'2001-01-01'});assert.equal((await complete('overdue')).ineligibleReason,'overdue');
  72. await task('future',{day:'2099-01-01'});await assert.rejects(complete('future'),/未到期/);
  73. for(const id of ['missing','unsupported','mismatch']){
  74. await task(id,{minutes:id==='unsupported'?45:30});
  75. if(id==='missing')await q(`DELETE FROM "CourseAppointment" WHERE "objectId"=$1`,[id+'-appointment']);
  76. if(id==='mismatch')await q(`UPDATE "MemoryPracticeRecord" SET "orderId"=99999 WHERE "objectId"=$1`,[id]);
  77. assert.equal((await complete(id)).rewardStatus,'exception');assert.equal(Number((await receipt(id)).amount),0);
  78. }
  79. });
  80. test('旧系统历史完成保持零元;人工核对后按课时时长计薪,不允许来源不明确认',async()=>{
  81. await task('imported',{minutes:30,day:'2026-09-05',source:'legacy:model:60:general:900',done:true});
  82. const r=await receipt('imported');assert.equal(r.status,'historical');assert.equal(Number(r.amount),0);
  83. const confirm=async(id,actor='manager')=>(await one('SELECT xs_review_reward_confirm(\'c\',$1,$2::jsonb,\'核实老师完成且未发放\') result',[actor,JSON.stringify([id])])).result;
  84. await assert.rejects(confirm(r.objectId,'student'),/仅管理/);
  85. assert.equal((await confirm(r.objectId)).confirmed,1);assert.equal(Number((await receipt('imported')).amount),.5);assert.equal((await confirm(r.objectId)).alreadyConfirmed,1);
  86. });
  87. test('历史时长差额以独立凭据纠正,幂等、不改原凭据,收入和工资明细消费净金额',async()=>{
  88. const r=await receipt('old-rate');
  89. const original=await one('SELECT * FROM "ReviewReward" WHERE "objectId"=$1',[r.objectId]);
  90. const correct=async(ids)=>(await one(`SELECT xs_review_correct_unbatched_rates('c','manager',$1::jsonb,'按30分钟0.5元核实') result`,[JSON.stringify(ids)])).result;
  91. await assert.rejects(correct([r.objectId,'missing']),/不存在/);assert.equal(Number((await receipt('old-rate')).amount),1);
  92. assert.equal((await correct([r.objectId])).corrected,1);assert.equal((await correct([r.objectId])).alreadyCorrected,1);
  93. assert.deepEqual(await one('SELECT * FROM "ReviewReward" WHERE "objectId"=$1',[r.objectId]),original);
  94. assert.equal(Number((await receipt('old-rate')).amount),.5);
  95. await assert.rejects(q(`UPDATE "ReviewRewardRateCorrection" SET amount=-1`),/只能/);
  96. const ctx=vm.createContext({Date,Psql:{one,query:q}});vm.runInContext(readFileSync(new URL('../cloud/review-rewards.js',import.meta.url),'utf8'),ctx);
  97. const expected=(await one(`SELECT sum(amount) total FROM "ReviewRewardIncome" WHERE company='c' AND status='earned' AND "coachId"=10`)).total;
  98. assert.equal(await ctx.reviewIncomeTotal('c',10),Number(expected));
  99. const lines=await ctx.reviewRewardPayrollLines('c','2099-01',false);assert.equal(lines.find(l=>l.reviewTaskId==='old-rate').amount,.5);
  100. const page=await ctx.reviewRewardPage('c',{});assert.equal(page.items.find(i=>i.taskId==='old-rate').statusLabel,'已按课时时长纠正');assert.equal(page.items.find(i=>i.taskId==='self-1').statusLabel,'学生自习,不计薪');
  101. await q(`SELECT set_config('xiaoshu.payroll_write','yes',false)`);
  102. await q(`INSERT INTO "PayrollBatch"("objectId",company,month,status) VALUES('draft','c','2099-01','draft')`);
  103. await q(`INSERT INTO "PayrollLine"("objectId",company,"batchId",kind,"reviewRewardId",amount) VALUES('line','c','draft','review_reward',$1,99)`,[r.objectId]);
  104. assert.equal(Number((await one(`SELECT amount FROM "PayrollLine" WHERE "objectId"='line'`)).amount),.5);
  105. await q(`SELECT set_config('xiaoshu.payroll_write','no',false)`);
  106. });
  107. test('时长差额纠正后仍能纠正逾期余额,累计扣减不超过原金额;迁移重跑不改变收据',async()=>{
  108. const r=await receipt('old-overdue');
  109. await one(`SELECT xs_review_correct_unbatched_rates('c','manager',$1::jsonb,'核实时长')`,[JSON.stringify([r.objectId])]);
  110. const result=await one(`SELECT xs_review_reward_correct('c','manager',$1,'核实逾期','',null) result`,[r.objectId]);assert.equal(Number(result.result.amount),-.5);
  111. assert.equal(Number((await receipt('old-overdue')).amount),0);assert.equal((await receipt('old-overdue')).status,'ineligible');
  112. const before=await q('SELECT * FROM "ReviewRewardIncome" ORDER BY "objectId"');await db.exec(sql('review-pay-policy'));assert.deepEqual(await q('SELECT * FROM "ReviewRewardIncome" ORDER BY "objectId"'),before);
  113. });
  114. test('历史核定需要明确口径,保留原凭据、实际完成身份未知,净额按时长计算',async()=>{
  115. await db.exec(sql('review-reconciliation'));
  116. await task('resolve-history',{minutes:30,day:'2026-09-05',source:'legacy:model:60:general:1001',done:true});
  117. const r=await receipt('resolve-history'),original=await one('SELECT * FROM "ReviewReward" WHERE "objectId"=$1',[r.objectId]);
  118. const items=JSON.stringify([{rewardId:r.objectId,reasonCode:'legacy_assignment_policy'}]);
  119. await assert.rejects(q("SELECT xs_review_resolve('c','manager',$1::jsonb,'历史按绑定老师核算',false)",[items]),/口径未确认/);
  120. const result=await one("SELECT xs_review_resolve('c','manager',$1::jsonb,'历史按绑定老师核算',true) result",[items]);assert.equal(result.result.resolved,1);
  121. assert.equal(Number((await receipt('resolve-history')).amount),.5);
  122. assert.deepEqual(await one('SELECT * FROM "ReviewReward" WHERE "objectId"=$1',[r.objectId]),original);
  123. const d=await one('SELECT * FROM "ReviewRewardResolution" WHERE "rewardId"=$1',[r.objectId]);assert.equal(d.evidence.completionIdentity,'not_recorded');
  124. assert.equal((await one("SELECT xs_review_resolve('c','manager',$1::jsonb,'历史按绑定老师核算',true) result",[items])).result.alreadyResolved,1);
  125. await assert.rejects(q('DELETE FROM "ReviewRewardResolution" WHERE "rewardId"=$1',[r.objectId]),/只能/);
  126. await assert.rejects(q("SELECT xs_review_reward_confirm('c','manager',$1::jsonb,'再次确认')",[JSON.stringify([r.objectId])]),/已有核定/);
  127. });
  128. test('确定逾期无需身份假设;重复副本只能关联同一任务的有效计薪凭据',async()=>{
  129. await task('resolve-late',{minutes:30,day:'2026-09-05',source:'legacy:model:60:general:1002'});
  130. // Historical import triggers preserve its source completion timestamp.
  131. await q(`UPDATE "MemoryPracticeRecord" SET fxzt='1',wcsj='2026-09-06 10:00' WHERE "objectId"='resolve-late'`);
  132. const r=await receipt('resolve-late');
  133. await q("SELECT xs_review_resolve('c','manager',$1::jsonb,'确认逾期')",[JSON.stringify([{rewardId:r.objectId,reasonCode:'overdue'}])]);
  134. assert.equal((await receipt('resolve-late')).status,'ineligible');assert.equal((await receipt('resolve-late')).ineligibleReason,'overdue');
  135. const source=await one('SELECT * FROM "MemoryPracticeRecord" WHERE "objectId"=\'resolve-history\'');
  136. await db.exec("SET session_replication_role='replica'");
  137. await q(`INSERT INTO "MemoryPracticeRecord"("objectId",company,id,plid,yhid,xxjlid,"orderId",kywrq,kywsj,kcid,fxzt,wcsj,"sourceKey") VALUES('resolve-copy','c',9001,10,20,$1,$1,'2026-09-05','2026-09-05',30,'1','2026-09-05 10:01','legacy:model:60:general:1003')`,[source.xxjlid]);
  138. await db.exec("SET session_replication_role='origin'");
  139. await q(`SELECT xiaoshu_record_review_reward(t,true) FROM "MemoryPracticeRecord" t WHERE "objectId"='resolve-copy'`);
  140. const copy=await receipt('resolve-copy'),canonical=await receipt('resolve-history');
  141. await assert.rejects(q("SELECT xs_review_resolve('c','manager',$1::jsonb,'确认重复')",[JSON.stringify([{rewardId:copy.objectId,reasonCode:'duplicate_review',canonicalRewardId:r.objectId}])]),/有效计薪凭据/);
  142. await q("SELECT xs_review_resolve('c','manager',$1::jsonb,'确认重复')",[JSON.stringify([{rewardId:copy.objectId,reasonCode:'duplicate_review',canonicalRewardId:canonical.objectId}])]);
  143. assert.equal((await receipt('resolve-copy')).ineligibleReason,'duplicate_review');
  144. // Another live completion of the same key cannot pay a second time.
  145. await db.exec("SET session_replication_role='replica'");
  146. await q(`INSERT INTO "MemoryPracticeRecord"("objectId",company,id,plid,yhid,xxjlid,"orderId",kywrq,kywsj,kcid,fxzt,wcsj,"sourceKey") VALUES('resolve-later','c',9002,10,20,$1,$1,'2026-09-05','2026-09-05',30,'0','','legacy:model:60:general:1004')`,[source.xxjlid]);
  147. await db.exec("SET session_replication_role='origin'");
  148. await q(`INSERT INTO "ReviewReward"("objectId",company,"taskId","coachId","studentId","plannedDate","completedAt","incomeMonth",amount,status) VALUES('late-receipt','c','resolve-later',10,20,'2026-09-05','2026-09-05 10:30+08','2026-09',1,'earned')`);
  149. assert.equal((await receipt('resolve-later')).ineligibleReason,'duplicate_review');
  150. });
  151. test('缺失来源不猜时长;旧课次证据必须匹配学生和学习记录,不可覆盖已知课次',async()=>{
  152. await task('source-evidence',{minutes:60,day:'2026-09-05',source:'legacy:model:60:general:1005',done:true});
  153. const m=await one('SELECT * FROM "MemoryPracticeRecord" WHERE "objectId"=\'source-evidence\'');
  154. await q('DELETE FROM "CommonModel" WHERE "modelId"=54 AND "generalId"=$1',[m.orderId]);
  155. const r=await receipt('source-evidence');
  156. const confirm=lesson=>q("SELECT xs_review_resolve('c','manager',$1::jsonb,'旧系统原课次核查',true)",[JSON.stringify([{rewardId:r.objectId,reasonCode:'legacy_assignment_policy',legacyLesson:lesson}])]);
  157. await assert.rejects(confirm(null),/仍不明确/);
  158. await assert.rejects(confirm({modelId:54,generalId:m.orderId,szyh:21,dslx:'1'}),/仍不明确/);
  159. await confirm({modelId:54,generalId:m.orderId,szyh:20,dslx:'1'});assert.equal(Number((await receipt('source-evidence')).amount),.5);
  160. await db.exec(sql('review-reconciliation'));assert.equal(Number((await receipt('source-evidence')).amount),.5);
  161. });
  162. test('历史老师的单词练习误标冲突须原系统身份佐证,真实学员关联仍阻止核定',async()=>{
  163. await task('verified-conflict',{minutes:60,day:'2026-09-05',source:'legacy:model:60:general:1006',done:true});
  164. await q(`UPDATE "_User" SET "identityType"='conflict',username='原老师',"legacyUserData"='{"UserID":10,"UserName":"原老师","GroupID":3}' WHERE "objectId"='coach'`);
  165. await db.exec('CREATE TABLE IF NOT EXISTS "PracticeRecord"("objectId" text PRIMARY KEY,company text,yhid numeric)');
  166. await q(`INSERT INTO "PracticeRecord"("objectId",company,yhid) VALUES('coach-word-practice','c',10)`);
  167. const r=await receipt('verified-conflict'),profile={userId:10,userName:'原老师',groupId:3,action:'user_info'};
  168. const confirm=proof=>q("SELECT xs_review_resolve('c','manager',$1::jsonb,'核对旧系统老师身份及单词练习误标',true)",[JSON.stringify([{rewardId:r.objectId,reasonCode:'legacy_assignment_policy',legacyCoachProfile:proof}])]);
  169. await assert.rejects(confirm(null),/身份冲突仍未核清/);
  170. await assert.rejects(confirm({...profile,userName:'另一人'}),/身份冲突仍未核清/);
  171. await q(`INSERT INTO "CourseBinding"("objectId",company,id,yhid,kcid) VALUES('actual-enrollment','c',99999,'10','30')`);
  172. await assert.rejects(confirm(profile),/身份冲突仍未核清/);
  173. await q(`DELETE FROM "CourseBinding" WHERE "objectId"='actual-enrollment'`);
  174. await confirm(profile);assert.equal(Number((await receipt('verified-conflict')).amount),1);
  175. assert.equal((await one(`SELECT "identityType" FROM "_User" WHERE "objectId"='coach'`)).identityType,'conflict');
  176. const evidence=await one('SELECT evidence FROM "ReviewRewardResolution" WHERE "rewardId"=$1',[r.objectId]);assert.equal(evidence.evidence.identityReview,'legacy_coach_with_word_practice_only');
  177. assert.equal(evidence.evidence.completionIdentity,'not_recorded');
  178. });