| 123456789101112131415161718192021222324252627282930313233343536373839404142 |
- #!/usr/bin/env node
- // Read-only: no login, session creation, synchronization, completion or payroll mutation.
- import {mkdir,writeFile} from 'node:fs/promises';
- import {resolve} from 'node:path';
- 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)];}));
- const token=process.env.XIAOSHU_SESSION_TOKEN,master=process.env.XIAOSHU_MASTER_KEY;
- 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);}
- const coach=Number(args['--coach-id']),from=args['--from'],to=args['--to'];
- 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('需要有效管理会话、明确的老师编号和核对日期范围;不会猜测同名老师身份');
- const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
- const headers={'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Session-Token':token,...(master?{'X-Parse-Master-Key':master}:{})};
- 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;}
- 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;}
- const current=await get('/users/me');if(!['super-admin','ops-manager','ops-staff','ops-auditor'].includes(current.adminRoleKey))throw Error('需要管理端只读访问身份');
- const company=current.company;if(!company?.objectId)throw Error('管理会话缺少帐套');
- const report={generatedAt:new Date().toISOString(),coachId:coach,from,to,readOnly:true,issues:[],lessons:[],incorrectRewards:[],limitations:[]};
- const teachers=await all('_User',{company,legacyUserId:coach},'objectId,realName,nickname,legacyUserId');if(teachers.length!==1)throw Error('老师身份不存在或不唯一');report.teacher=teachers[0];
- 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');
- const ids=appointments.map(a=>a.id);const common=ids.length?await all('CommonModel',{company,modelId:54,itemId:{$in:ids}},'itemId,generalId,status'):[];
- 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;}
- 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')]);
- const studyIds=studies.map(s=>s.id),studyIndexes=studyIds.length?await all('CommonModel',{company,modelId:56,itemId:{$in:studyIds}},'itemId,generalId'):[];
- const completionValue=String(a.jssj||lessons[0]?.lessonAt?.iso||lessons[0]?.lessonAt||'');
- const completionDay=/(Z|[+-]\d{2}:?\d{2})$/.test(completionValue)?new Date(completionValue).toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}):completionValue.slice(0,10);
- 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'})):[];
- const taskChecks=studies.filter(s=>Number(s.learned)>0||(()=>{try{return JSON.parse(s.xxqs||'[]').length>0;}catch{return false;}})()).map(study=>{
- const studyIndex=studyIndexes.find(c=>Number(c.itemId)===Number(study.id));
- 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));
- const expectedDates=cycleDates.length?cycleDates:savedDates;
- const tasks=reviews.filter(r=>studyIndex&&String(r.xxjlid)===String(studyIndex.generalId));
- 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};
- });
- 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);
- 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?'完课凭据缺失或重复':'逐学习批次检查计划、日期或复习老师归属(人工转交需另行核对)'});
- }
- for(let page=1;;page++){
- 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('错计凭据查询失败(需先发布兼容只读接口)');
- if(d.data.ruleVersion!=='beijing-day-v1')throw Error('当前网关尚不支持当天计薪核对,禁止输出可能不完整的错计清单');
- report.incorrectRewards.push(...d.data.items);if(page*100>=d.data.total)break;
- }
- report.limitations.push('计划日期同时核对实际结课日后的既定周期和已保存计划;实际结课日缺失时只能核对保存计划;任务归属不自动更改,未执行工资纠正或补发。');
- 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}));
|