Просмотр исходного кода

fix(admin): keep confirmed writing mappings out of pending

彭峰 4 недель назад
Родитель
Сommit
d9b1869399
2 измененных файлов с 2 добавлено и 2 удалено
  1. 1 1
      scripts/cloud/payroll-integrity.js
  2. 1 1
      scripts/tests/payroll-integrity.test.mjs

+ 1 - 1
scripts/cloud/payroll-integrity.js

@@ -84,7 +84,7 @@ async function historicalReservationPreview(context,input){const co=payrollTenan
 async function commitHistoricalReservations(context,input,payload){requireRole(context,['ops-manager']);const co=payrollTenant(context,input),selected=new Set((payload.appointmentIds||[]).map(text));if(!selected.size)fail(400,'请选择预约');const before=await Psql.one('SELECT xs_payroll_source_version($1) AS version',[co]),preview=await historicalReservationPreview(context,input),items=preview.items.filter(i=>selected.has(i.appointmentId));if(!items.length)return{reserved:0,held:0};return(await payrollSql('SELECT xs_payroll_reservations($1,$2,$3::jsonb,$4,$5) AS result',[co,context.current.id,JSON.stringify(items),reasonOf(input),before.version])).result;}
 async function creditReconciliationPreview(context,input){const rows=await Psql.query('SELECT s AS row FROM xs_payroll_refund_rows($1) s',[payrollTenant(context,input)]),items=rows.map(r=>r.row);return{items,summary:{completedReviewCount:items.length,differenceCount:items.filter(i=>i.adjustment>0).length,refundTotal:items.reduce((s,i)=>s+Number(i.adjustment),0)},refreshedAt:new Date().toISOString()};}
 async function commitCreditReconciliation(context,input,payload){requireRole(context,['ops-manager']);if(!payload.appointmentIds?.length)fail(400,'请选择要核对的预约');return(await payrollSql('SELECT xs_payroll_refunds($1,$2,$3::jsonb,$4) AS result',[payrollTenant(context,input),context.current.id,JSON.stringify(payload.appointmentIds.map(text)),reasonOf(input)])).result;}
-async function writingMappingPage(context,input){const co=payrollTenant(context,input),rows=await Psql.query(`SELECT c."objectId" AS "appointmentId",c."generalId" AS "appointmentGeneralId",c."title" AS "studentName",c."subtitle" AS "courseName",a."szyh" AS "studentId",a."yysj" AS "startsAt",a."updatedAt",a."courseCategoryKey" AS "categoryKey",a."categoryEvidence" AS evidence,COALESCE(to_jsonb(u)->>'grade',u."legacyUserData"->>'Position',u."legacyUserData"->'UserBase'->>'Position','') AS grade,u."nickname",u."username" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text LEFT JOIN "_User" u ON u."company"=c."company" AND u."legacyUserId"::text=a."szyh"::text WHERE c."company"=$1 AND c."modelId"=54 AND c."status"<>-2 AND c."subtitle" ~ '阅读|写作|语法|作文|强化' AND a."dszt" IN ('0','10') ORDER BY a."yysj",c."objectId"`,[co]);
+async function writingMappingPage(context,input){const co=payrollTenant(context,input),rows=await Psql.query(`SELECT c."objectId" AS "appointmentId",c."generalId" AS "appointmentGeneralId",c."title" AS "studentName",c."subtitle" AS "courseName",a."szyh" AS "studentId",a."yysj" AS "startsAt",a."updatedAt",a."courseCategoryKey" AS "categoryKey",a."categoryEvidence" AS evidence,COALESCE(to_jsonb(u)->>'grade',u."legacyUserData"->>'Position',u."legacyUserData"->'UserBase'->>'Position','') AS grade,u."nickname",u."username" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"::text=c."itemId"::text LEFT JOIN "_User" u ON u."company"=c."company" AND u."legacyUserId"::text=a."szyh"::text WHERE c."company"=$1 AND c."modelId"=54 AND c."status"<>-2 AND c."subtitle" ~ '阅读|写作|语法|作文|强化' AND a."dszt" IN ('0','10') AND NOT (COALESCE(a."courseCategoryKey",'') IN ('primary_writing','middle_writing','high_writing') AND length(trim(COALESCE(a."categoryEvidence",'')))>=2) ORDER BY a."yysj",c."objectId"`,[co]);
  return{items:rows.map(r=>{const stage=teachingStageFromDatabase([r.grade,r.nickname,r.username]);return{...r,suggestedCategoryKey:stage?stage+'_writing':'',evidence:r.evidence||r.grade||'',categoryKey:r.categoryKey||'',studentName:r.studentName||r.nickname};})};}
 async function writingMappingCommit(context,input,payload){requireRole(context,['ops-manager']);return(await payrollSql('SELECT xs_payroll_writing_mapping($1,$2,$3,$4,$5,$6::timestamptz,$7) AS result',[payrollTenant(context,input),context.current.id,input.targetId,text(payload.categoryKey),text(payload.evidence),dateIso(input.expectedUpdatedAt)||null,reasonOf(input)])).result;}
 async function payRateRows(context,input){

+ 1 - 1
scripts/tests/payroll-integrity.test.mjs

@@ -39,7 +39,7 @@ test('规则同日修订保留旧版本,禁止直接覆盖',async()=>{const re
 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,'已处理');});
 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);});
-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,'阅读写作');`);const w=await one(`SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"='writing'`);await q('SELECT xs_payroll_writing_mapping(\'co\',\'teacher\',\'writing-common\',\'primary_writing\',\'用户核实小学\',$1,\'核实年级\')',[w.updatedAt]);assert.equal((await one(`SELECT "courseCategoryKey" k FROM "CourseAppointment" WHERE "objectId"='writing'`)).k,'primary_writing');});
+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 w=await one(`SELECT "updatedAt" FROM "CourseAppointment" WHERE "objectId"='writing'`);await q('SELECT xs_payroll_writing_mapping(\'co\',\'teacher\',\'writing-common\',\'primary_writing\',\'用户核实小学\',$1,\'核实年级\')',[w.updatedAt]);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);});
 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');});
 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);});
 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,'历史门店');});