reconcile-legacy-lesson-pay.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. #!/usr/bin/env node
  2. import { createHash, randomUUID } from 'node:crypto';
  3. import { mkdir, writeFile } from 'node:fs/promises';
  4. import { isAbsolute, join } from 'node:path';
  5. import { pathToFileURL } from 'node:url';
  6. function uniqueBy(rows, key) {
  7. const indexed = new Map();
  8. for (const row of rows) {
  9. const id = String(row[key] ?? '');
  10. indexed.set(id, indexed.has(id) ? null : row);
  11. }
  12. return indexed;
  13. }
  14. export function legacyLessonPayEvidence(lessons, appointments, coachId) {
  15. const orders = uniqueBy(appointments, 'GeneralID');
  16. const uniqueLessons = uniqueBy(lessons, 'yyds');
  17. const lessonIds = uniqueBy(lessons, 'GeneralID');
  18. const evidence = [];
  19. for (const [appointmentId, lesson] of uniqueLessons) {
  20. const order = orders.get(appointmentId);
  21. if (!lesson || !order || !lessonIds.get(String(lesson.GeneralID))) continue;
  22. const generalId = String(lesson.GeneralID), studentId = String(lesson.xymz ?? ''), classType = String(lesson.kclx ?? '');
  23. const amount = String(order.yongjin ?? '').trim(), completedAt = String(lesson.plpjsj ?? '').trim();
  24. if (![generalId, appointmentId, studentId].every(id => /^[1-9]\d*$/.test(id))) continue;
  25. if (String(lesson.jsmz) !== String(coachId) || String(order.pl) !== String(coachId) || String(order.szyh) !== studentId) continue;
  26. if (!['30', '40'].includes(String(order.dszt)) || !['1', '2', '3'].includes(classType) || String(order.dslx) !== classType) continue;
  27. if (!/^\d{1,6}(?:\.\d{1,2})?$/.test(amount) || !completedAt) continue;
  28. evidence.push({ generalId, appointmentId, studentId, coachId: String(coachId), classType, amount: Number(amount), completedAt });
  29. }
  30. return evidence.sort((a, b) => Number(a.generalId) - Number(b.generalId));
  31. }
  32. const eligible = `WITH evidence AS (
  33. SELECT * FROM jsonb_to_recordset($2::jsonb) AS e("generalId" text, "appointmentId" text,
  34. "studentId" text, "coachId" text, "classType" text, amount numeric, "completedAt" text, version text)
  35. ), matched AS (
  36. SELECT l."objectId", l."updatedAt"::text AS version, e.amount, e."generalId", e."appointmentId",
  37. count(*) OVER (PARTITION BY e."generalId") AS matches
  38. FROM evidence e
  39. JOIN "CommonModel" c ON c.company=$1 AND c."modelId"=59 AND c."generalId"::text=e."generalId"
  40. AND c.status=99 AND c."sourceKey"='legacy:model:59:general:'||e."generalId"
  41. JOIN "LessonRecord" l ON l.company=c.company AND l.id=c."itemId" AND l."sourceKey"=c."sourceKey"
  42. JOIN "CommonModel" ac ON ac.company=c.company AND ac."modelId"=54 AND ac."generalId"::text=e."appointmentId"
  43. AND ac.status=99 AND ac."sourceKey"='legacy:model:54:general:'||e."appointmentId"
  44. JOIN "CourseAppointment" a ON a.company=ac.company AND a.id=ac."itemId" AND a."sourceKey"=ac."sourceKey"
  45. WHERE l.xymz::text=e."studentId" AND l.jsmz::text=e."coachId" AND l.yyds::text=e."appointmentId"
  46. AND l.kclx::text=e."classType" AND l.plpjsj=e."completedAt" AND a.szyh::text=e."studentId" AND a.pl::text=e."coachId"
  47. AND l."teacherPaySnapshot" IS NULL AND a."teacherPaySnapshot" IS NULL
  48. AND (e.version IS NULL OR e.version=l."updatedAt"::text)
  49. AND NOT EXISTS (SELECT 1 FROM "PayrollLine" p WHERE p.company=l.company AND p.kind='lesson'
  50. AND (p."standardLessonKey"='legacy:'||e."generalId" OR p."lessonId"=l."objectId"))
  51. )`;
  52. export const legacyLessonPayPreviewSql = eligible + `
  53. SELECT "objectId", version, amount, "generalId", "appointmentId" FROM matched WHERE matches=1 ORDER BY "generalId"`;
  54. export const legacyLessonPayApplySql = eligible + `
  55. UPDATE "LessonRecord" l SET "teacherPaySnapshot"=m.amount, "updatedAt"=now()
  56. FROM matched m WHERE m.matches=1 AND l.company=$1 AND l."objectId"=m."objectId"
  57. AND l."updatedAt"::text=m.version AND l."teacherPaySnapshot" IS NULL
  58. RETURNING l."objectId", l."teacherPaySnapshot" AS amount, m."generalId", m."appointmentId"`;
  59. async function main() {
  60. const opt = key => process.argv.find(arg => arg.startsWith(key + '='))?.slice(key.length + 1);
  61. const coachId = Number(opt('--coach-id')), out = opt('--output');
  62. const applyHash = opt('--apply-plan'), token = process.env.XIAOSHU_SESSION_TOKEN, master = process.env.XIAOSHU_MASTER_KEY;
  63. if (!Number.isSafeInteger(coachId) || coachId <= 0 || !out || !isAbsolute(out) || !token) {
  64. throw Error('需要 --coach-id、--output=私有绝对路径及 XIAOSHU_SESSION_TOKEN');
  65. }
  66. if (applyHash && !master) throw Error('补记课酬需要 XIAOSHU_MASTER_KEY');
  67. const base = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
  68. const headers = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main', 'X-Parse-Session-Token': token };
  69. async function parse(path, method = 'GET', body, privileged = false) {
  70. const r = await fetch(base + path, { method, headers: privileged ? { ...headers, 'X-Parse-Master-Key': master } : headers,
  71. body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(30000) });
  72. if (!r.ok) throw Error('Parse 请求失败 ' + r.status);
  73. return r.json();
  74. }
  75. const user = await parse('/users/me');
  76. const company = user.company?.objectId;
  77. if (!company || user.isDisabled || user.isDeleted || !['super-admin', 'ops-manager'].includes(user.adminRoleKey)) throw Error('需要有效的帐套管理员');
  78. const config = (await parse('/config')).params;
  79. async function source(params) {
  80. const rows = [];
  81. let expected;
  82. for (let cpage = 1; ; cpage++) {
  83. const url = new URL(config.legacyScheduleApiUrl);
  84. for (const [key, value] of Object.entries({ ...params, action: 'content_list', cpage, psize: 1000, apiId: config.legacyScheduleApiId, apiKey: config.legacyScheduleApiKey })) url.searchParams.set(key, value);
  85. const r = await fetch(url, { signal: AbortSignal.timeout(30000) });
  86. if (!r.ok) throw Error('旧系统课酬来源不可用');
  87. const j = await r.json(), items = typeof j.result === 'string' ? JSON.parse(j.result) : j.result;
  88. if (j.retcode < 0 || !Array.isArray(items) || !Number.isSafeInteger(Number(j.page?.itemCount))) throw Error('旧系统返回不完整');
  89. expected ??= Number(j.page.itemCount);
  90. if (expected !== Number(j.page.itemCount)) throw Error('旧系统分页过程中数据已变动');
  91. rows.push(...items);
  92. if (cpage >= Number(j.page.pageCount || 1)) break;
  93. }
  94. if (rows.length !== expected) throw Error('旧系统分页不完整');
  95. return rows;
  96. }
  97. const [lessons, appointments] = await Promise.all([
  98. source({ modelId: 59, myfield2: 'jsmz=' + coachId }),
  99. source({ uid: coachId, nid: 29, myfield2: 'pl=' + coachId, orders: 'B.yysj=DESC', dszt: 30, xiaokejilu: 1 }),
  100. ]);
  101. const allEvidence = legacyLessonPayEvidence(lessons, appointments, coachId);
  102. const requested = opt('--lesson-ids');
  103. const ids = new Set(requested?.split(',') ?? []);
  104. if ([...ids].some(id => !/^[1-9]\d*$/.test(id))) throw Error('课次编号格式无效');
  105. const evidence = requested ? allEvidence.filter(row => ids.has(row.generalId)) : allEvidence;
  106. if (requested && evidence.length !== ids.size) throw Error('有指定课次缺少唯一可核验的旧课酬');
  107. const plan = { company, coachId, evidence };
  108. const sha256 = createHash('sha256').update(JSON.stringify(plan)).digest('hex');
  109. await mkdir(out, { recursive: true, mode: 0o700 });
  110. const save = (name, value) => writeFile(join(out, name), JSON.stringify(value, null, 2), { mode: 0o600, flag: 'wx' });
  111. await save('source-plan-' + randomUUID() + '.json', { ...plan, sha256 });
  112. if (!applyHash) {
  113. console.log(JSON.stringify({ sha256, sourceLessons: lessons.length, sourceAppointments: appointments.length, matched: evidence.length, applied: false }));
  114. return;
  115. }
  116. if (applyHash !== sha256 || !evidence.length) throw Error('旧课酬来源已变化,请重新预览');
  117. const name = 'xiaoshu/system/legacy-lesson-pay-' + randomUUID(), expires = Date.now() + 5 * 60000;
  118. const code = `async function handler(request,response){try{
  119. const current=request.user;
  120. if(!current||current.id!==${JSON.stringify(user.objectId)}||Date.now()>${expires})return response.status(403).json({success:false});
  121. await current.fetch({useMasterKey:true});
  122. const co=${JSON.stringify(company)};
  123. if(current.get('company')?.id!==co||current.get('isDisabled')||current.get('isDeleted')||!['super-admin','ops-manager'].includes(current.get('adminRoleKey')))return response.status(403).json({success:false});
  124. const evidence=${JSON.stringify(evidence)};
  125. const p=request.body.params||{};
  126. const data=await Psql.tx(async t=>{
  127. await t.none("SET LOCAL lock_timeout='3s'");
  128. await t.none("SET LOCAL statement_timeout='45s'");
  129. await t.one('SELECT pg_advisory_xact_lock(hashtext($1))',['direct-sync:'+co]);
  130. await t.one('SELECT pg_advisory_xact_lock(hashtext($1))',['xs-payroll:'+co]);
  131. const active=await t.one('SELECT count(*)::int n FROM "LegacyDirectRun" WHERE company=$1 AND status=$2 AND "leaseUntil">now()',[co,'running']);
  132. if(active.n)throw Error('夜间同步正在运行');
  133. const found=await t.query(${JSON.stringify(legacyLessonPayPreviewSql)},[co,JSON.stringify(evidence)]);
  134. if(p.mode==='preview')return found;
  135. if(p.mode!=='apply'||JSON.stringify(p.expected)!==JSON.stringify(found))throw Error('待补课次或版本已改变,请重新预览');
  136. const versions=new Map(found.map(row=>[row.generalId,row.version]));
  137. const ready=evidence.filter(row=>versions.has(row.generalId)).map(row=>({...row,version:versions.get(row.generalId)}));
  138. const changed=await t.query(${JSON.stringify(legacyLessonPayApplySql)},[co,JSON.stringify(ready)]);
  139. if(changed.length!==found.length)throw Error('课酬补记数量变化,事务已回滚');
  140. for(const row of changed)await t.one('SELECT xs_payroll_audit($1,$2,$3,$4,$5,$6::jsonb,$7::jsonb)',[co,current.id,'restore-legacy-lesson-pay',row.objectId,'核对旧销课接口原金额,来源计划 ${sha256}',JSON.stringify({teacherPaySnapshot:null}),JSON.stringify(row)]);
  141. return changed;
  142. });return response.json({success:true,data});
  143. }catch(error){return response.status(409).json({success:false,message:String(error.message)});}}`;
  144. let id;
  145. async function invoke(params) {
  146. const r = await fetch(base.replace(/\/parse$/, '/api/functions/') + name, { method: 'POST', headers, body: JSON.stringify({ token, params }), signal: AbortSignal.timeout(60000) });
  147. const j = await r.json();
  148. if (!r.ok || !j.success) throw Error(j.message || '课酬核对失败');
  149. return j.data;
  150. }
  151. try {
  152. id = (await parse('/classes/Function', 'POST', { name, path: name, code, type: 'standalone', enabled: true, params: [], paramList: [], respType: 'json' }, true)).objectId;
  153. const expected = await invoke({ mode: 'preview' });
  154. if (requested && expected.length !== evidence.length) throw Error('指定课次的线上状态已变化,请重新核对');
  155. await save('before-' + sha256 + '.json', expected);
  156. const changed = await invoke({ mode: 'apply', expected });
  157. await save('receipt-' + sha256 + '.json', changed);
  158. if ((await invoke({ mode: 'preview' })).length !== 0) throw Error('应用后仍有符合条件的未补课次');
  159. console.log(JSON.stringify({ sha256, updated: changed.length, applied: true }));
  160. } finally {
  161. if (id) await parse('/classes/Function/' + id, 'DELETE', undefined, true);
  162. }
  163. }
  164. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  165. main().catch(error => { console.error(error.message); process.exitCode = 1; });
  166. }