report-aggregate.mjs 8.1 KB

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