backfill-payroll-lessons.mjs 5.5 KB

1234567891011121314151617181920
  1. #!/usr/bin/env node
  2. const APP_ID=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',MASTER_KEY=process.env.XIAOSHU_MASTER_KEY||'',PARSE_URL=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,'');
  3. const commit=process.argv.includes('--commit');if(!MASTER_KEY)throw new Error('缺少 XIAOSHU_MASTER_KEY');
  4. const headers={'X-Parse-Application-Id':APP_ID,'X-Parse-Master-Key':MASTER_KEY,'Content-Type':'application/json'};
  5. async function parse(path,init={}){const response=await fetch(PARSE_URL+path,{...init,headers:{...headers,...(init.headers||{})}}),payload=await response.json().catch(()=>({}));if(!response.ok||payload.error)throw new Error(typeof payload.error==='string'?payload.error:JSON.stringify(payload.error||{status:response.status}));return payload;}
  6. const config=(await parse('/config')).params||{},legacyUrl=String(config.legacyScheduleApiUrl||''),apiId=String(config.legacyScheduleApiId||''),apiKey=String(config.legacyScheduleApiKey||'');if(!legacyUrl||!apiId||!apiKey)throw new Error('生产 Parse Config 未配置旧系统接口');
  7. const company=(await parse('/classes/Company?limit=1&keys=objectId')).results?.[0];if(!company)throw new Error('生产环境没有帐套');const companyPointer={__type:'Pointer',className:'Company',objectId:company.objectId};
  8. async function legacyPage(month,cpage,psize=1000){const values={action:'content_list',nid:'296',psize:String(psize),cpage:String(cpage),apiId,apiKey};if(month)values.myfield=`plpjsj=${month}`;const query=new URLSearchParams(values),response=await fetch(`${legacyUrl}${legacyUrl.includes('?')?'&':'?'}${query}`),payload=await response.json();if(!response.ok||Number(payload.retcode)===-1)throw new Error(`${month||'all'}: `+(payload.retmsg||`旧接口失败:${response.status}`));let result=payload.result;if(typeof result==='string')result=JSON.parse(result);return{items:Array.isArray(result)?result:[],page:payload.page||{}};}
  9. async function allExisting(className,keyField,extra={}){const map=new Map();let cursor=-1;for(;;){const where={company:companyPointer,...extra,[keyField]:{$gt:cursor}},query=new URLSearchParams({where:JSON.stringify(where),order:keyField,limit:'1000',keys:`objectId,${keyField},itemId`}),rows=(await parse(`/classes/${className}?${query}`)).results||[];for(const row of rows)map.set(Number(row[keyField]),row);if(rows.length<1000)break;cursor=Number(rows.at(-1)?.[keyField]);if(!Number.isFinite(cursor))break;}return map;}
  10. function dateIso(value){const raw=String(value||'').trim();if(!/^\d{4}-\d{1,2}-\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?/.test(raw)||raw.startsWith('{')||raw.startsWith('['))return'';const normalized=raw.replace(/^(\d{4})-(\d)(?=-)/,'$1-0$2').replace(/-(\d)(?=[ T])/,'-0$1').replace(' ','T'),date=new Date(normalized+(normalized.includes('T')?'+08:00':'T00:00:00+08:00'));return Number.isNaN(date.getTime())?'':date.toISOString();}
  11. const first=await legacyPage('',1),total=Number(first.page.itemCount||first.items.length),pageCount=Math.max(1,Number(first.page.pageCount||Math.ceil(total/1000))),source=[...first.items];for(let start=2;start<=pageCount;start+=6){const pages=Array.from({length:Math.min(6,pageCount-start+1)},(_,index)=>start+index),rows=await Promise.all(pages.map((page)=>legacyPage('',page)));for(const result of rows)source.push(...result.items);console.log(`read old lessons ${source.length}/${total}`);}const deduped=Array.from(new Map(source.map((row)=>[Number(row.GeneralID||0),row])).values()).filter((row)=>Number(row.GeneralID||0));source.length=0;source.push(...deduped);
  12. const lessonById=await allExisting('LessonRecordFact','legacyGeneralId');let creates=0,updates=0,invalidDates=0;const requests=[];
  13. for(const row of source){const generalId=Number(row.GeneralID||0);if(!generalId)continue;const lesson=lessonById.get(generalId),lessonAt=dateIso(row.plpjsj)||dateIso(row.CreateTime);if(!lessonAt)invalidDates++;
  14. const lessonBody={company:companyPointer,sourceKey:`legacy-payroll:lesson:${generalId}`,legacyGeneralId:generalId,lessonAt:lessonAt?{__type:'Date',iso:lessonAt}:null,sourceCreatedAt:dateIso(row.CreateTime)?{__type:'Date',iso:dateIso(row.CreateTime)}:null,studentName:String(row.Title||row.xymz||''),coachName:String(row.plxm||row.jsmc||row.jsmz||''),courseName:String(row.Subtitle||row.kcmc||''),storeId:Number(row.szmdid||0),pf:String(row.pf||''),jffs:String(row.jffs||''),jsmz:String(row.jsmz||''),kcid:String(row.kcid||''),kclx:String(row.kclx||''),kcmc:String(row.kcmc||row.Subtitle||''),pjnr:String(row.pjnr||''),pldp:String(row.pldp||''),xymz:String(row.xymz||row.Title||''),yyds:String(row.yyds||''),plpjsj:String(row.plpjsj||''),szmdid:String(row.szmdid||'')};if(!lesson)lessonBody.kzsj=String(row.kzsj||'');
  15. requests.push({method:lesson?'PUT':'POST',path:`/parse/classes/LessonRecordFact${lesson?'/'+lesson.objectId:''}`,body:lessonBody});if(lesson)updates++;else creates++;
  16. }
  17. console.log(JSON.stringify({mode:commit?'commit':'dry-run',sourceTotal:total,sourceRows:source.length,existingLessons:lessonById.size,creates,updates,invalidDates,requestCount:requests.length},null,2));
  18. if(!commit)process.exit(0);for(let index=0;index<requests.length;index+=50){const batch=requests.slice(index,index+50),result=await parse('/batch',{method:'POST',body:JSON.stringify({requests:batch})});const failures=(result||[]).filter((item)=>item.error);if(failures.length)throw new Error(`批次 ${index/50+1} 写入失败:${JSON.stringify(failures.slice(0,3))}`);if(index%500===0||index+50>=requests.length)console.log(`wrote ${Math.min(index+50,requests.length)}/${requests.length}`);}
  19. console.log(`lesson backfill complete: ${source.length} source records`);