audit-teacher-earnings.mjs 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142
  1. #!/usr/bin/env node
  2. // Read-only: no login, session creation, synchronization, completion or payroll mutation.
  3. import {mkdir,writeFile} from 'node:fs/promises';
  4. import {resolve} from 'node:path';
  5. const args=Object.fromEntries(process.argv.slice(2).map(a=>{const i=a.indexOf('=');return i<0?[a,true]:[a.slice(0,i),a.slice(i+1)];}));
  6. const token=process.env.XIAOSHU_SESSION_TOKEN,master=process.env.XIAOSHU_MASTER_KEY;
  7. if(args['--check']){console.log(JSON.stringify({readOnly:true,sessionAvailable:!!token,masterAvailable:!!master,requiredArguments:['--coach-id=ID','--from=YYYY-MM-DD','--to=YYYY-MM-DD'],privateOutputDefault:'tmp/teacher-earnings-audit'}));process.exit(0);}
  8. const coach=Number(args['--coach-id']),from=args['--from'],to=args['--to'];
  9. if(!token||!Number.isSafeInteger(coach)||coach<=0||!/^\d{4}-\d{2}-\d{2}$/.test(from||'')||!/^\d{4}-\d{2}-\d{2}$/.test(to||'')||from>to)throw Error('需要有效管理会话、明确的老师编号和核对日期范围;不会猜测同名老师身份');
  10. const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
  11. const headers={'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Session-Token':token,...(master?{'X-Parse-Master-Key':master}:{})};
  12. async function get(path){const r=await fetch(base+path,{headers,signal:AbortSignal.timeout(30000)}),d=await r.json();if(!r.ok||d.error)throw Error('只读查询失败:'+(d.error||r.status));return d;}
  13. async function all(name,where,keys){const rows=[];let after='';for(;;){const filter=after?{$and:[where,{objectId:{$gt:after}}]}:where;const d=await get('/classes/'+name+'?'+new URLSearchParams({where:JSON.stringify(filter),keys,limit:'500',order:'objectId'}));rows.push(...d.results);if(d.results.length<500)break;after=d.results.at(-1).objectId;}return rows;}
  14. const current=await get('/users/me');if(!['super-admin','ops-manager','ops-staff','ops-auditor'].includes(current.adminRoleKey))throw Error('需要管理端只读访问身份');
  15. const company=current.company;if(!company?.objectId)throw Error('管理会话缺少帐套');
  16. const report={generatedAt:new Date().toISOString(),coachId:coach,from,to,readOnly:true,issues:[],lessons:[],incorrectRewards:[],limitations:[]};
  17. const teachers=await all('_User',{company,legacyUserId:coach},'objectId,realName,nickname,legacyUserId');if(teachers.length!==1)throw Error('老师身份不存在或不唯一');report.teacher=teachers[0];
  18. const appointments=await all('CourseAppointment',{company,pl:String(coach),bxrq:{$gte:from,$lte:to+' 23:59:59'}},'id,szyh,pl,fxpl,dszt,bxrq,yysj,jssj,teacherPaySnapshot');
  19. const ids=appointments.map(a=>a.id);const common=ids.length?await all('CommonModel',{company,modelId:54,itemId:{$in:ids}},'itemId,generalId,status'):[];
  20. for(const a of appointments){const c=common.find(c=>Number(c.itemId)===Number(a.id));if(!c){report.issues.push({appointmentId:a.objectId,reason:'预约索引缺失'});continue;}
  21. const gid=String(c.generalId);const [lessons,studies,reviews]=await Promise.all([all('LessonRecord',{company,yyds:gid},'objectId,teacherPaySnapshot,lessonAt'),all('DailyStudyRecord',{company,dsid:gid},'objectId,id,learned,xxqs,fxrl,dqrq'),all('MemoryPracticeRecord',{company,orderId:Number(gid)},'objectId,xxjlid,plid,kywrq,fxzt,wcsj')]);
  22. const studyIds=studies.map(s=>s.id),studyIndexes=studyIds.length?await all('CommonModel',{company,modelId:56,itemId:{$in:studyIds}},'itemId,generalId'):[];
  23. const completionValue=String(a.jssj||lessons[0]?.lessonAt?.iso||lessons[0]?.lessonAt||'');
  24. const completionDay=/(Z|[+-]\d{2}:?\d{2})$/.test(completionValue)?new Date(completionValue).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):completionValue.slice(0,10);
  25. const cycleDates=/^\d{4}-\d{2}-\d{2}$/.test(completionDay)?[1,2,3,5,7,9,11,14,17,21].map(n=>new Date(Date.parse(completionDay+'T00:00:00+08:00')+n*86400000).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'})):[];
  26. const taskChecks=studies.filter(s=>Number(s.learned)>0||(()=>{try{return JSON.parse(s.xxqs||'[]').length>0;}catch{return false;}})()).map(study=>{
  27. const studyIndex=studyIndexes.find(c=>Number(c.itemId)===Number(study.id));
  28. const savedDates=String(study.fxrl||'').split(',').filter(d=>/^\d{8}$/.test(d)).map(d=>d.slice(0,4)+'-'+d.slice(4,6)+'-'+d.slice(6,8));
  29. const expectedDates=cycleDates.length?cycleDates:savedDates;
  30. const tasks=reviews.filter(r=>studyIndex&&String(r.xxjlid)===String(studyIndex.generalId));
  31. return {studyId:study.objectId,studyGeneralId:studyIndex?.generalId,expectedDates,savedDates,planMismatch:cycleDates.length>0&&JSON.stringify([...savedDates].sort())!==JSON.stringify([...cycleDates].sort()),missingDates:expectedDates.filter(d=>!tasks.some(r=>r.kywrq===d)),duplicateDates:expectedDates.filter(d=>tasks.filter(r=>r.kywrq===d).length>1),wrongCoachTasks:tasks.filter(r=>String(r.plid)!==String(a.fxpl||a.pl)).map(r=>r.objectId),needsPlanReview:!cycleDates.length||!studyIndex};
  32. });
  33. const item={appointmentId:a.objectId,generalId:gid,status:a.dszt,lessonCount:lessons.length,studyCount:studies.length,reviewCount:reviews.length,taskChecks,assignedReviewCoach:a.fxpl||a.pl,teacherPay:lessons[0]?.teacherPaySnapshot??a.teacherPaySnapshot??null};report.lessons.push(item);
  34. if(Number(a.dszt)>=30&&(lessons.length!==1||taskChecks.some(c=>c.missingDates.length||c.duplicateDates.length||c.wrongCoachTasks.length||c.needsPlanReview||c.planMismatch)))report.issues.push({...item,reason:lessons.length!==1?'完课凭据缺失或重复':'逐学习批次检查计划、日期或复习老师归属(人工转交需另行核对)'});
  35. }
  36. for(let page=1;;page++){
  37. const r=await fetch(base.replace(/\/parse$/,'/api/functions/xiaoshu/ops/gateway-v3'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{operation:'ops/payroll/review-rewards',coachId:coach,status:'incorrect',page,pageSize:100}}),signal:AbortSignal.timeout(30000)});const d=await r.json();if(!r.ok||!d.success)throw Error('错计凭据查询失败(需先发布兼容只读接口)');
  38. if(d.data.ruleVersion!=='beijing-day-v1')throw Error('当前网关尚不支持当天计薪核对,禁止输出可能不完整的错计清单');
  39. report.incorrectRewards.push(...d.data.items);if(page*100>=d.data.total)break;
  40. }
  41. report.limitations.push('计划日期同时核对实际结课日后的既定周期和已保存计划;实际结课日缺失时只能核对保存计划;任务归属不自动更改,未执行工资纠正或补发。');
  42. const out=resolve(String(args['--output']||'tmp/teacher-earnings-audit'));await mkdir(out,{recursive:true,mode:0o700});await writeFile(out+'/audit.json',JSON.stringify(report,null,2),{mode:0o600});console.log(JSON.stringify({output:out+'/audit.json',lessons:report.lessons.length,issues:report.issues.length,incorrectRewards:report.incorrectRewards.length,mutations:0}));