verify-payroll-source.mjs 3.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
  1. #!/usr/bin/env node
  2. const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
  3. const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
  4. const PARSE_URL = (process.env.XIAOSHU_PARSE_URL || 'https://server.xiaoshu.pro/parse').replace(/\/$/, '');
  5. const month = process.argv.find((value) => /^\d{4}-\d{2}$/.test(value)) || '2026-08';
  6. if (!MASTER_KEY) throw new Error('缺少 XIAOSHU_MASTER_KEY');
  7. const configResponse = await fetch(`${PARSE_URL}/config`, { headers: { 'X-Parse-Application-Id': APP_ID, 'X-Parse-Master-Key': MASTER_KEY } });
  8. const configPayload = await configResponse.json();
  9. if (!configResponse.ok) throw new Error(configPayload.error || `读取 Parse Config 失败:${configResponse.status}`);
  10. const params = configPayload.params || {};
  11. const url = String(params.legacyScheduleApiUrl || '');
  12. const apiId = String(params.legacyScheduleApiId || '');
  13. const apiKey = String(params.legacyScheduleApiKey || '');
  14. if (!url || !apiId || !apiKey) throw new Error('生产 Parse Config 未配置旧系统业务接口');
  15. async function page(cpage, psize = 1000) {
  16. const query = new URLSearchParams({ action: 'e_plrecord_list', nid: '296', myfield: `plpjsj=${month}`, psize: String(psize), cpage: String(cpage), apiId, apiKey });
  17. const response = await fetch(`${url}${url.includes('?') ? '&' : '?'}${query}`);
  18. const payload = await response.json();
  19. if (!response.ok || Number(payload.retcode) === -1) throw new Error(payload.retmsg || `旧系统接口失败:${response.status}`);
  20. let result = payload.result;
  21. if (typeof result === 'string') result = JSON.parse(result);
  22. return { items: Array.isArray(result) ? result : [], page: payload.page || {} };
  23. }
  24. function validDate(value) {
  25. const raw = String(value || '').trim();
  26. return /^\d{4}-\d{1,2}-\d{1,2}(?:[ T]\d{1,2}:\d{2})?/.test(raw) && !raw.startsWith('{') && !raw.startsWith('[') ? raw : '';
  27. }
  28. const first = await page(1);
  29. const total = Number(first.page.itemCount || first.items.length);
  30. const pageCount = Math.max(1, Number(first.page.pageCount || Math.ceil(total / 1000)));
  31. const items = [...first.items];
  32. for (let index = 2; index <= pageCount; index += 1) items.push(...(await page(index)).items);
  33. const normalized = items.map((row) => ({
  34. generalId: Number(row.GeneralID || 0),
  35. lessonAt: validDate(row.plpjsj) || validDate(row.CreateTime),
  36. studentName: String(row.Title || row.xymz || ''),
  37. coachName: String(row.plxm || row.jsmc || row.jsmz || ''),
  38. classType: Number(row.kclx || 0),
  39. })).filter((row) => row.lessonAt.startsWith(month));
  40. const typeCounts = Object.fromEntries([1, 2, 3, 4].map((type) => [type, normalized.filter((row) => row.classType === type).length]));
  41. const rateByType = { 1: { hours: 0.5, amount: 20 }, 2: { hours: 1, amount: 40 }, 3: { hours: 1, amount: 40 }, 4: { hours: 0, amount: 0 } };
  42. const totals = normalized.reduce((result, row) => {
  43. const rate = rateByType[row.classType] || { hours: 0, amount: 0 };
  44. result.hours += rate.hours;
  45. result.amount += rate.amount;
  46. return result;
  47. }, { hours: 0, amount: 0 });
  48. const samples = [850746, 850469].map((id) => normalized.find((row) => row.generalId === id) || { generalId: id, missing: true });
  49. const duplicateCount = normalized.length - new Set(normalized.map((row) => row.generalId)).size;
  50. if (normalized.length !== total) throw new Error(`月份记录归一化数量不一致:源端 ${total},有效 ${normalized.length}`);
  51. if (duplicateCount) throw new Error(`旧上课记录 GeneralID 重复:${duplicateCount} 条`);
  52. if (samples.some((sample) => sample.missing)) throw new Error('工资验收样本 850746 或 850469 缺失');
  53. console.log(JSON.stringify({ month, sourceTotal: total, normalizedCount: normalized.length, pageCount, typeCounts, totals, duplicateCount, samples }, null, 2));