Jelajahi Sumber

fix: exclude voided lessons from training and payroll

彭峰 1 jam lalu
induk
melakukan
46ed98f330

+ 1 - 1
scripts/cloud/lesson-records.js

@@ -20,7 +20,7 @@ ${lean?'':lessonHistoryLabelColumns()+','}
  LEFT JOIN LATERAL (SELECT * FROM "CommonModel" c WHERE c.company=l.company AND c."modelId"=59 AND c."itemId"=l.id ORDER BY c."objectId" LIMIT 1) c ON true
 ${lean?'':lessonHistoryLabelJoins()}
  LEFT JOIN LATERAL (SELECT "contentSummary","actualStartAt" FROM "LessonCompletionReport" r WHERE r.company=l.company AND (r."lessonId"=l."objectId" OR r."appointmentGeneralId"::text=l.yyds) ORDER BY r."submittedAt" DESC LIMIT 1) r ON true
- WHERE l.company=$1
+ WHERE l.company=$1 AND c.status IS DISTINCT FROM -2
  ), ranked AS (SELECT facts.*,row_number() OVER(PARTITION BY company,xymz,"classKey" ORDER BY "actualAt" DESC NULLS LAST,"objectId") rn,count(*) OVER(PARTITION BY company,xymz,"classKey") duplicates FROM facts), lessons AS (SELECT * FROM ranked WHERE rn=1)`;}
 function lessonHistoryFilters(input,args){const f={...(input.filters||{}),...input},conditions=[];
  const add=(sql,value)=>{if(value!==undefined&&value!==null&&String(value).trim()){args.push(String(value).trim());conditions.push(sql.replaceAll('?',String(args.length)));}};

+ 8 - 2
scripts/cloud/payroll-integrity.js

@@ -73,7 +73,7 @@ function payrollCalculatedLine(item,rates,settlementMonth,rules,mappings){
 async function payrollPreviewData(context,input,month,includeUsed=false,requireLive=false,options={}){
  // Generation, refresh, review and forced-live callers retain uncached financial checks.
  if(!includeUsed||requireLive||input.targetId)return payrollPreviewUncached(context,input,month,includeUsed,requireLive,options);
- const co=payrollTenant(context,input),key='preview-v1:'+JSON.stringify([month,context.storeId||0,number(input.storeId||(input.filters||{}).storeId),text(input.search),options.forDashboard===true,options.omitSourceVersion===true]);
+ const co=payrollTenant(context,input),key='preview-v2:'+JSON.stringify([month,context.storeId||0,number(input.storeId||(input.filters||{}).storeId),text(input.search),options.forDashboard===true,options.omitSourceVersion===true]);
  let cached;try{cached=(await Psql.one('SELECT xs_payroll_preview_cache_read($1,$2) AS result',[co,key])).result;}catch{ /* Missing/failed cache always falls back to the authoritative path. */ }
  if(cached?.payload){const result=cached.payload,refreshedAt=new Date().toISOString(),sync={...result.sourceStatus.sync};if(sync.lastSuccessAt)sync.lagSeconds=Math.max(0,Math.floor((Date.now()-new Date(sync.lastSuccessAt).getTime())/1000));return {...result,refreshedAt,sourceStatus:{...result.sourceStatus,refreshedAt,cacheHit:true,sync}};}
  const result=await payrollPreviewUncached(context,input,month,includeUsed,requireLive,{...options,readAcceleration:Boolean(cached?.stamp)});
@@ -87,7 +87,13 @@ async function payrollPreviewUncached(context,input,month,includeUsed=false,requ
  const other=new Set(used.filter(r=>r.batchId!==input.targetId).map(r=>r.standardLessonKey)),mappingMap=new Map(mappings.filter(r=>r.confirmed).map(r=>[r.courseId,r]));
  const previous=month.endsWith('-01')?(Number(month.slice(0,4))-1)+'-12':month.slice(0,5)+String(Number(month.slice(5))-1).padStart(2,'0');
  const allowLate=!includeUsed&&Boolean((await Psql.one('SELECT EXISTS(SELECT 1 FROM "PayrollBatch" WHERE "company"=$1 AND "month"=$2 AND COALESCE("scopeStoreId",0)=$3 AND "status" IN (\'reviewed\',\'paid\')) AS value',[co,previous,context.storeId||0])).value);
- const grouped=new Map();for(const item of source.items){const key=payrollIdentity(item);if(grouped.has(key))grouped.get(key).duplicate=true;else grouped.set(key,item);}
+ const grouped=new Map();
+ for(const item of source.items){
+  if(item.commonStatus===-2)continue;
+  const key=payrollIdentity(item);
+  if(grouped.has(key))grouped.get(key).duplicate=true;
+  else grouped.set(key,item);
+ }
  const lines=Array.from(grouped.values()).filter(item=>{const m=payrollBusinessDate(item.lessonAt).slice(0,7);return (m===month||!m||allowLate&&m===previous)&& (includeUsed||!other.has(payrollIdentity(item)));}).map(item=>payrollCalculatedLine(item,rates,month,rules,mappingMap));
  const rewards=await Psql.query('SELECT r.* FROM "ReviewRewardIncome" r WHERE r."company"=$1 AND r."status"=\'earned\' AND '+(includeUsed?'r."incomeMonth"=$2':'r."incomeMonth"<=$2 AND NOT EXISTS(SELECT 1 FROM "PayrollLine" l WHERE l."company"=r."company" AND l."reviewRewardId"=r."objectId" AND NOT COALESCE(l."excluded",false) AND l."batchId"<>$3)')+' ORDER BY r."objectId"',includeUsed?[co,month]:[co,month,text(input.targetId)]);
  lines.push(...rewards.map(r=>rewardPayrollLine(r,month)));

+ 14 - 0
scripts/tests/lesson-records.test.mjs

@@ -26,6 +26,20 @@ before(async()=>{
  await task('pending1','2026-01-01');await task('pending2',new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}));await task('done3','2026-01-03',{status:'1'});
 });
 after(()=>db.close());
+test('已作废课次不出现在训练记录、累计节数或详情中',async()=>{
+ await query(`INSERT INTO "CommonModel"("objectId",company,"generalId","itemId","modelId",status) VALUES('void-common','c',9999,55,59,-2)`);
+ try{
+  for(const filters of [{studentId:20},{studentName:'林'}]){
+   const page=await ctx.lessonHistoryPage(manager,filters);
+   assert.equal(page.total,54);
+   assert.equal(page.cumulativeTotal,54);
+   assert.ok(!page.items.some(item=>item.lessonId==='lesson55'));
+  }
+  await assert.rejects(ctx.lessonHistoryDetail(manager,{lessonId:'lesson55'}),/未找到/);
+  await assert.rejects(ctx.lessonHistoryDetail(manager,{appointmentId:'1054'}),/未找到/);
+  assert.equal((await ctx.lessonHistoryDetail(manager,{lessonId:'lesson54'})).lessonId,'lesson54');
+ }finally{await query(`DELETE FROM "CommonModel" WHERE "objectId"='void-common'`);}
+});
 test('历史查询按实际日期倒序、分页计数去重;姓名ID门店课程日期组合筛选',async()=>{
  const p=await ctx.lessonHistoryPage(manager,{});assert.equal(p.total,55);assert.equal(p.cumulativeTotal,55);assert.equal(p.items.length,20);assert.equal(p.items[0].lessonId,'lesson55');
  const next=await ctx.lessonHistoryPage(manager,{page:3});assert.equal(next.items.length,15);assert.equal(next.items.at(-1).duplicateCount,2);

+ 17 - 0
scripts/tests/payroll-integrity.test.mjs

@@ -35,6 +35,23 @@ test('重算已有明细保留调整,异常禁止审核,审计失败全部
 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');});
 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)'));
  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);});
+test('作废课次留在核对来源中,但不进入收益、工资预览或异常数',async()=>{
+ await db.exec(`INSERT INTO "CommonModel" ("objectId",company,"modelId","itemId","generalId",status) VALUES ('void-common','co',59,999,999,-2);
+ INSERT INTO "LessonRecord" ("objectId",company,id,jsmz,xymz,yyds,kcid,kclx,"lessonAt","teacherPaySnapshot") VALUES ('void-lesson','co',999,'30','20','5','100','2','2026-09-01 16:06+08',40);`);
+ try{
+  const snapshot=await context.payrollCanonicalSnapshot(manager,{});
+  assert.equal(snapshot.items.find(row=>row.objectId==='void-lesson').commonStatus,-2);
+  for(const includeUsed of [true,false]){
+   const preview=await context.payrollPreviewData(manager,{},'2026-09',includeUsed);
+   assert.equal(preview.baseAmount,40);
+   assert.equal(preview.lessonCount,1);
+   assert.equal(preview.exceptionCount,0);
+   assert.ok(!preview.lines.some(row=>row.standardLessonKey==='legacy:999'));
+  }
+ }finally{
+  await db.exec(`DELETE FROM "LessonRecord" WHERE "objectId"='void-lesson'; DELETE FROM "CommonModel" WHERE "objectId"='void-common';`);
+ }
+});
 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);});
 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);});
 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,'已处理');});