#!/usr/bin/env node // Back up before applying. Reports contain business data and are stored privately. import fs from 'node:fs';import {randomBytes} from 'node:crypto';import path from 'node:path'; const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',master=process.env.XIAOSHU_MASTER_KEY; if(!master)throw new Error('缺少 XIAOSHU_MASTER_KEY'); const apply=process.argv.includes('--apply'),co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx',out=process.env.XIAOSHU_PAYROLL_REPORT_DIR||path.resolve('artifacts/payroll-integrity');fs.mkdirSync(out,{recursive:true,mode:0o700}); async function api(p,method='GET',body){const res=await fetch(base+p,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master},body:body?JSON.stringify(body):undefined});const data=await res.json();if(!res.ok||data.error)throw Error(data.error||'HTTP '+res.status);return data;} const suffix=Date.now()+'-'+randomBytes(6).toString('hex');let uid='',fid=''; try{ const user=await api('/users','POST',{username:'payroll_integrity_'+suffix,password:randomBytes(32).toString('base64url'),company:{__type:'Pointer',className:'Company',objectId:co},roles:['ops-manager'],isAdmin:true,testCreatedBy:'payroll-integrity-migration'});uid=user.objectId; const sql=fs.readFileSync(new URL('./sql/payroll-integrity.sql',import.meta.url),'utf8'); const before=`SELECT jsonb_build_object('batches',(SELECT COALESCE(jsonb_agg(to_jsonb(b)),'[]') FROM "PayrollBatch" b WHERE "company"=$1),'lines',(SELECT COALESCE(jsonb_agg(to_jsonb(l)),'[]') FROM "PayrollLine" l WHERE "company"=$1),'rules',(SELECT COALESCE(jsonb_agg(to_jsonb(r)),'[]') FROM "TeachingCompensationRule" r WHERE "company"=$1)) AS data`; const report=`SELECT jsonb_build_object('sourceCount',(SELECT count(*) FROM xs_payroll_sources($1)), 'missingFromFacts',(SELECT COALESCE(jsonb_agg(jsonb_build_object('lessonId',s->>'lessonObjectId','generalId',s->>'legacyGeneralId','lessonAt',s->>'lessonAt','snapshot',s->'teacherPaySnapshot')),'[]') FROM xs_payroll_sources($1) s WHERE s->>'source'='canonical' AND NOT EXISTS(SELECT 1 FROM "LessonRecordFact" f WHERE f."company"=$1 AND f."legacyGeneralId"::text=s->>'legacyGeneralId')), 'monthBoundary',(SELECT COALESCE(jsonb_agg(jsonb_build_object('lessonId',s->>'lessonObjectId','lessonAt',s->>'lessonAt','beijingMonth',to_char(xs_payroll_time(s->>'lessonAt') AT TIME ZONE 'Asia/Shanghai','YYYY-MM'))),'[]') FROM xs_payroll_sources($1) s WHERE to_char(xs_payroll_time(s->>'lessonAt') AT TIME ZONE 'UTC','YYYY-MM')<>to_char(xs_payroll_time(s->>'lessonAt') AT TIME ZONE 'Asia/Shanghai','YYYY-MM'))) AS data`; const code=`async function handler(request,response){try{if(!request.user||request.user.id!==${JSON.stringify(uid)})return response.status(403).json({success:false});const mode=(request.body.params||{}).mode;if(mode==='apply'){await Psql.none(${JSON.stringify(sql)});return response.json({success:true});}const rows=await Psql.query(mode==='after'?${JSON.stringify(report)}:${JSON.stringify(before)},[${JSON.stringify(co)}]);return response.json({success:true,rows});}catch(e){return response.status(500).json({success:false,message:String(e.message)});}}`; const route='xiaoshu/system/payroll-integrity-'+suffix;const fn=await api('/classes/Function','POST',{name:route,path:route,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});fid=fn.objectId; async function call(mode){const res=await fetch(base.replace(/\/parse$/,'/api/functions/')+route,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:user.sessionToken,params:{mode}})});const data=await res.json();if(!data.success)throw Error(data.message||'执行失败');return data;} const backup=await call('before');fs.writeFileSync(path.join(out,'before-'+suffix+'.json'),JSON.stringify(backup,null,2),{mode:0o600}); if(apply){for(const [table,fields] of Object.entries({PayrollLine:{standardLessonKey:{type:'String'},coachObjectId:{type:'String'},coachMobile:{type:'String'}},PayrollBatch:{version:{type:'Number'}},TeachingCompensationRule:{revision:{type:'Number'},supersedesId:{type:'String'}},CourseAppointment:{categoryEvidence:{type:'String'}}})){const schema=await api('/schemas/'+table);const missing=Object.fromEntries(Object.entries(fields).filter(([k])=>!schema.fields[k]));if(Object.keys(missing).length)await api('/schemas/'+table,'PUT',{fields:missing});} await call('apply');const result=await call('after');fs.writeFileSync(path.join(out,'impact-'+suffix+'.json'),JSON.stringify(result,null,2),{mode:0o600});console.log(JSON.stringify({applied:true,reportDirectory:out,sourceCount:result.rows[0]?.data?.sourceCount}));} else console.log(JSON.stringify({applied:false,reportDirectory:out,batchCount:backup.rows[0]?.data?.batches?.length})); }finally{const failures=[];if(fid)try{await api('/classes/Function/'+fid,'DELETE');}catch{failures.push('临时函数 '+fid);}if(uid)try{await api('/users/'+uid,'DELETE');}catch{failures.push('临时管理员 '+uid);}if(failures.length)throw Error('需清理:'+failures.join('、'));}