report-aggregate.mjs 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. #!/usr/bin/env node
  2. // 录音管道区间聚合(月报/年报/任意窗口): 拉取窗口内报告 + 逐日 reconcile 对账 → Markdown/JSON
  3. // 用法:
  4. // node report-aggregate.mjs # 默认: 本月 1 日 ~ 今天
  5. // node report-aggregate.mjs --start 2026-08-01 --end 2026-08-31 # 指定窗口(月报)
  6. // node report-aggregate.mjs --start 2026-01-01 --end 2026-12-31 # 年报
  7. // node report-aggregate.mjs --type week # 仅周报(默认 day+week 全量)
  8. // node report-aggregate.mjs --json # 机器可读输出
  9. // node report-aggregate.mjs --no-reconcile # 跳过逐日对账(仅报告列表)
  10. // node report-aggregate.mjs --out 月报数据.md # 同时写入文件
  11. // 密码: SMARTBADGE_PASSWORD 环境变量(无则复用 scripts/.token,失效时先 node api.mjs login)
  12. import fs from 'fs';
  13. import path from 'path';
  14. import { baseUrl, fail, login, loadToken, httpJson, fetchAllReports } from './lib.mjs';
  15. const args = process.argv.slice(2);
  16. const argVal = (name) => {
  17. const i = args.indexOf(name);
  18. return i >= 0 ? args[i + 1] : undefined;
  19. };
  20. function ymd(d) {
  21. return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
  22. }
  23. function parseDate(s, label) {
  24. if (!/^\d{4}-\d{2}-\d{2}$/.test(s ?? '')) fail(`${label} 需为 YYYY-MM-DD,收到: ${JSON.stringify(s)}`, 4);
  25. const d = new Date(`${s}T00:00:00`);
  26. if (Number.isNaN(d.getTime())) fail(`${label} 非合法日期: ${s}`, 4);
  27. return d;
  28. }
  29. const now = new Date();
  30. const todayYmd = ymd(now);
  31. const startArg = argVal('--start');
  32. const endArg = argVal('--end');
  33. const type = argVal('--type') ?? 'all';
  34. if (!['day', 'week', 'all'].includes(type)) fail(`--type 仅支持 day|week|all,收到: ${type}`, 4);
  35. const deviceNo = argVal('--deviceNo');
  36. const jsonOut = args.includes('--json');
  37. const noReconcile = args.includes('--no-reconcile');
  38. const concurrency = Math.min(Number(argVal('--concurrency') ?? 4) || 4, 10);
  39. const outPath = argVal('--out');
  40. const start = parseDate(startArg ?? `${todayYmd.slice(0, 8)}01`, '--start');
  41. const end = parseDate(endArg ?? todayYmd, '--end');
  42. if (start > end) fail(`--start(${startArg}) 晚于 --end(${endArg})`, 3);
  43. const base = baseUrl();
  44. let token = loadToken();
  45. if (process.env.SMARTBADGE_PASSWORD) {
  46. try { token = await login({ base, password: process.env.SMARTBADGE_PASSWORD }); } catch { /* 用现有 token 兜底 */ }
  47. }
  48. if (!token) fail(`未登录。先: node api.mjs login <密码>` +
  49. `\n 或在环境变量设 SMARTBADGE_PASSWORD,或指定 --base URL(当前 ${base})`, 2);
  50. const auth = { base, token };
  51. // 1. 报告列表(分页拉全)
  52. const reports = await fetchAllReports({
  53. base, token,
  54. type: type === 'all' ? undefined : type,
  55. start: ymd(start), end: ymd(end), deviceNo,
  56. });
  57. // 2. 逐日 reconcile(并发,有损:失败跳过但披露)
  58. const dayList = [];
  59. for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) dayList.push(ymd(d));
  60. const reconcile = new Map();
  61. if (!noReconcile) {
  62. for (let i = 0; i < dayList.length; i += concurrency) {
  63. const batch = dayList.slice(i, i + concurrency);
  64. await Promise.all(batch.map(async (d) => {
  65. try {
  66. const r = await httpJson('GET', `/api/analysis/reconcile?date=${d}`, undefined, { ...auth, relogin: true });
  67. reconcile.set(d, r.ok ? r.json?.data ?? { reconcileError: 'no-data' } : { reconcileError: `HTTP ${r.status}` });
  68. } catch (err) {
  69. reconcile.set(d, { reconcileError: err.message });
  70. }
  71. }));
  72. }
  73. }
  74. // 3. 聚合与输出
  75. const byType = (t) => reports.filter((r) => r.type === t);
  76. const dayReports = byType('day');
  77. const weekReports = byType('week');
  78. const deviceCountMax = Math.max(0, ...reports.map((r) => Number(r.deviceCount) || 0));
  79. const days = dayList.map((d) => {
  80. const dayR = dayReports.filter((r) => r.periodStart === d);
  81. const weekR = weekReports.filter((r) => r.periodStart === d);
  82. const rc = reconcile.get(d);
  83. return {
  84. date: d,
  85. reports: dayR,
  86. weekReports: weekR,
  87. transcribeDone: rc?.transcribeDone ?? null,
  88. localHit: rc?.localHit ?? null,
  89. ossHit: rc?.ossHit ?? null,
  90. missing: { cleansed: rc?.missingCleansed ?? [], transcript: rc?.missingTranscript ?? [] },
  91. reconcileError: rc?.reconcileError ?? null,
  92. };
  93. });
  94. const daysAgo = (d) => Math.round((new Date(todayYmd) - new Date(d)) / 86400000);
  95. const noReportDays = days.filter((x) => x.reports.length === 0).map((x) => x.date);
  96. const noTranscribeDays = days.filter((x) => x.transcribeDone === 0).map((x) => x.date);
  97. const missingDays = days.filter((x) => x.reconcileError || x.missing.cleansed.length || x.missing.transcript.length);
  98. const actions = noReportDays.map((d) => ({
  99. date: d,
  100. command: `node scripts/api.mjs post /api/analysis/run '{"period":{"start":"${d}","end":"${d}"},"type":"day"}'`,
  101. executable: daysAgo(d) <= 6,
  102. }));
  103. const data = {
  104. window: { start: ymd(start), end: ymd(end), days: dayList.length, generatedAt: new Date().toISOString() },
  105. type,
  106. deviceNo: deviceNo ?? '全部',
  107. totalReports: reports.length,
  108. reportsByType: { day: dayReports.length, week: weekReports.length },
  109. deviceCountMax,
  110. totalTranscribeDone: days.reduce((s, x) => s + (x.transcribeDone || 0), 0),
  111. days,
  112. missing: {
  113. noReport: noReportDays,
  114. noTranscribe: noTranscribeDays,
  115. reconcileErrorOrMissing: missingDays.map((x) => ({ date: x.date, error: x.reconcileError, cleansed: x.missing.cleansed, transcript: x.missing.transcript })),
  116. },
  117. actions,
  118. };
  119. if (jsonOut) {
  120. const str = JSON.stringify(data, null, 2);
  121. console.log(str);
  122. if (outPath) fs.writeFileSync(outPath, str);
  123. process.exit(0);
  124. }
  125. const lines = [];
  126. lines.push(`# 录音管道区间聚合(${ymd(start)} ~ ${ymd(end)})`);
  127. lines.push(`> 生成: ${data.window.generatedAt} | 口径: type=${type}, 设备=${data.deviceNo}, 窗口 ${dayList.length} 天`);
  128. lines.push('');
  129. lines.push('## 总体统计');
  130. lines.push('| 指标 | 值 |');
  131. lines.push('|---|---|');
  132. lines.push(`| 窗口天数 | ${dayList.length} |`);
  133. lines.push(`| 报告数(日/周) | ${data.reportsByType.day} / ${data.reportsByType.week} |`);
  134. lines.push(`| 覆盖设备数(报告最大口径) | ${deviceCountMax} |`);
  135. lines.push(`| 转录完成合计(逐日 reconcile 加总) | ${data.totalTranscribeDone} |`);
  136. lines.push('');
  137. lines.push('## 逐日明细');
  138. lines.push('| 日期 | 报告标题 | 周报 | 设备数 | 转录完成 | 缺失(清洗/转录) |');
  139. lines.push('|---|---|---|---|---|---|');
  140. for (const d of days) {
  141. const titles = d.reports.map((r) => r.title ?? r.id).join('; ') || '⚠ 无报告';
  142. const weekTitle = d.weekReports.map((r) => r.title ?? r.id).join('; ') || '—';
  143. const miss = (d.missing.cleansed.length || d.missing.transcript.length)
  144. ? `${d.missing.cleansed.length}/${d.missing.transcript.length}`
  145. : '—';
  146. const rc = d.reconcileError ? `(对账失败 ${d.reconcileError})` : '';
  147. lines.push(`| ${d.date} | ${titles} | ${weekTitle} | ${d.reports.map((r) => r.deviceCount ?? '-').join(';') || '—'} | ${d.transcribeDone ?? 'n/a'}${rc} | ${miss} |`);
  148. }
  149. lines.push('');
  150. lines.push('## 缺失清单');
  151. lines.push(`1. **无日报日**: ${noReportDays.length ? noReportDays.join(', ') : '无'}`);
  152. lines.push(`2. **转录完成=0 日**: ${noTranscribeDays.length ? noTranscribeDays.join(', ') : '无'}`);
  153. lines.push(`3. **对账失败或缺失非空日**: ${missingDays.length ? missingDays.map((x) => `${x.date}${x.reconcileError ? `[对账失败]` : ''}`).join(', ') : '无'}`);
  154. lines.push('');
  155. lines.push('## 建议动作');
  156. lines.push(actions.length ? actions.map((a) => `${a.executable ? '✅ 可执行' : '⛔ 超 7 天,上游已清理,不可补'}: ${a.command}`).join('\n') : '无(窗口完整)');
  157. const md = lines.join('\n');
  158. console.log(md);
  159. if (outPath) fs.writeFileSync(outPath, md);