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