#!/usr/bin/env node // 录音管道区间聚合(月报/年报/任意窗口): 拉取窗口内报告 + 逐日 reconcile 对账 → Markdown/JSON // 用法: // node report-aggregate.mjs # 默认: 本月 1 日 ~ 今天 // node report-aggregate.mjs --start 2026-08-01 --end 2026-08-31 # 指定窗口(月报) // node report-aggregate.mjs --start 2026-01-01 --end 2026-12-31 # 年报 // node report-aggregate.mjs --type week # 仅周报(默认 day+week 全量) // node report-aggregate.mjs --json # 机器可读输出 // node report-aggregate.mjs --no-reconcile # 跳过逐日对账(仅报告列表) // node report-aggregate.mjs --out 月报数据.md # 同时写入文件 // 密码: SMARTBADGE_PASSWORD 环境变量(无则复用 scripts/.token,失效时先 node api.mjs login) import fs from 'fs'; import path from 'path'; import { baseUrl, fail, login, loadToken, httpJson, fetchAllReports } from './lib.mjs'; const args = process.argv.slice(2); const argVal = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; }; // 日期口径固定 Asia/Shanghai(UTC+8,无夏令时): 容器默认 UTC 时区,若用本地年月日 // 「今天/本月1日」默认窗在每天 00:00~08:00(UTC)会错位一天(2026-09-06 Docker 适配) function ymdShanghai(d) { // 任意绝对时刻 → 北京时间 YYYY-MM-DD(内部 +8h 后读 UTC getters;输入勿再预偏移) const t = new Date(d.getTime() + 8 * 3600 * 1000); const p = (n) => String(n).padStart(2, '0'); return `${t.getUTCFullYear()}-${p(t.getUTCMonth() + 1)}-${p(t.getUTCDate())}`; } function parseDate(s, label) { if (!/^\d{4}-\d{2}-\d{2}$/.test(s ?? '')) fail(`${label} 需为 YYYY-MM-DD,收到: ${JSON.stringify(s)}`, 4); const d = new Date(`${s}T00:00:00+08:00`); // 显式 +08:00,避免容器本地时区漂移(UTC 容器差 8h) if (Number.isNaN(d.getTime())) fail(`${label} 非合法日期: ${s}`, 4); return d; } const todayYmd = ymdShanghai(new Date()); const startArg = argVal('--start'); const endArg = argVal('--end'); const type = argVal('--type') ?? 'all'; if (!['day', 'week', 'all'].includes(type)) fail(`--type 仅支持 day|week|all,收到: ${type}`, 4); const deviceNo = argVal('--deviceNo'); const jsonOut = args.includes('--json'); const noReconcile = args.includes('--no-reconcile'); const concurrency = Math.min(Number(argVal('--concurrency') ?? 4) || 4, 10); const outPath = argVal('--out'); const start = parseDate(startArg ?? `${todayYmd.slice(0, 8)}01`, '--start'); const end = parseDate(endArg ?? todayYmd, '--end'); if (start > end) fail(`--start(${startArg}) 晚于 --end(${endArg})`, 3); const base = baseUrl(); let token = loadToken(); if (process.env.SMARTBADGE_PASSWORD) { try { token = await login({ base, password: process.env.SMARTBADGE_PASSWORD }); } catch { /* 用现有 token 兜底 */ } } if (!token) fail(`未登录。先: node api.mjs login <密码>` + `\n 或在环境变量设 SMARTBADGE_PASSWORD,或指定 --base URL(当前 ${base})`, 2); const auth = { base, token }; // 1. 报告列表(分页拉全) const reports = await fetchAllReports({ base, token, type: type === 'all' ? undefined : type, start: ymdShanghai(start), end: ymdShanghai(end), deviceNo, }); // 2. 逐日 reconcile(并发,有损:失败跳过但披露) const dayList = []; for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) dayList.push(ymdShanghai(d)); const reconcile = new Map(); if (!noReconcile) { for (let i = 0; i < dayList.length; i += concurrency) { const batch = dayList.slice(i, i + concurrency); await Promise.all(batch.map(async (d) => { try { const r = await httpJson('GET', `/api/analysis/reconcile?date=${d}`, undefined, { ...auth, relogin: true }); reconcile.set(d, r.ok ? r.json?.data ?? { reconcileError: 'no-data' } : { reconcileError: `HTTP ${r.status}` }); } catch (err) { reconcile.set(d, { reconcileError: err.message }); } })); } } // 3. 聚合与输出 const byType = (t) => reports.filter((r) => r.type === t); const dayReports = byType('day'); const weekReports = byType('week'); const deviceCountMax = Math.max(0, ...reports.map((r) => Number(r.deviceCount) || 0)); const days = dayList.map((d) => { const dayR = dayReports.filter((r) => r.periodStart === d); const weekR = weekReports.filter((r) => r.periodStart === d); const rc = reconcile.get(d); return { date: d, reports: dayR, weekReports: weekR, transcribeDone: rc?.transcribeDone ?? null, localHit: rc?.localHit ?? null, ossHit: rc?.ossHit ?? null, missing: { cleansed: rc?.missingCleansed ?? [], transcript: rc?.missingTranscript ?? [] }, reconcileError: rc?.reconcileError ?? null, }; }); const daysAgo = (d) => Math.round((new Date(todayYmd) - new Date(d)) / 86400000); const noReportDays = days.filter((x) => x.reports.length === 0).map((x) => x.date); const noTranscribeDays = days.filter((x) => x.transcribeDone === 0).map((x) => x.date); const missingDays = days.filter((x) => x.reconcileError || x.missing.cleansed.length || x.missing.transcript.length); const actions = noReportDays.map((d) => ({ date: d, command: `node scripts/api.mjs post /api/analysis/run '{"period":{"start":"${d}","end":"${d}"},"type":"day"}'`, executable: daysAgo(d) <= 6, })); const data = { window: { start: ymdShanghai(start), end: ymdShanghai(end), days: dayList.length, generatedAt: new Date().toISOString() }, type, deviceNo: deviceNo ?? '全部', totalReports: reports.length, reportsByType: { day: dayReports.length, week: weekReports.length }, deviceCountMax, totalTranscribeDone: days.reduce((s, x) => s + (x.transcribeDone || 0), 0), days, missing: { noReport: noReportDays, noTranscribe: noTranscribeDays, reconcileErrorOrMissing: missingDays.map((x) => ({ date: x.date, error: x.reconcileError, cleansed: x.missing.cleansed, transcript: x.missing.transcript })), }, actions, }; if (jsonOut) { const str = JSON.stringify(data, null, 2); console.log(str); if (outPath) fs.writeFileSync(outPath, str); process.exit(0); } const lines = []; lines.push(`# 录音管道区间聚合(${ymdShanghai(start)} ~ ${ymdShanghai(end)})`); lines.push(`> 生成: ${data.window.generatedAt} | 口径: type=${type}, 设备=${data.deviceNo}, 窗口 ${dayList.length} 天`); lines.push(''); lines.push('## 总体统计'); lines.push('| 指标 | 值 |'); lines.push('|---|---|'); lines.push(`| 窗口天数 | ${dayList.length} |`); lines.push(`| 报告数(日/周) | ${data.reportsByType.day} / ${data.reportsByType.week} |`); lines.push(`| 覆盖设备数(报告最大口径) | ${deviceCountMax} |`); lines.push(`| 转录完成合计(逐日 reconcile 加总) | ${data.totalTranscribeDone} |`); lines.push(''); lines.push('## 逐日明细'); lines.push('| 日期 | 报告标题 | 周报 | 设备数 | 转录完成 | 缺失(清洗/转录) |'); lines.push('|---|---|---|---|---|---|'); for (const d of days) { const titles = d.reports.map((r) => r.title ?? r.id).join('; ') || '⚠ 无报告'; const weekTitle = d.weekReports.map((r) => r.title ?? r.id).join('; ') || '—'; const miss = (d.missing.cleansed.length || d.missing.transcript.length) ? `${d.missing.cleansed.length}/${d.missing.transcript.length}` : '—'; const rc = d.reconcileError ? `(对账失败 ${d.reconcileError})` : ''; lines.push(`| ${d.date} | ${titles} | ${weekTitle} | ${d.reports.map((r) => r.deviceCount ?? '-').join(';') || '—'} | ${d.transcribeDone ?? 'n/a'}${rc} | ${miss} |`); } lines.push(''); lines.push('## 缺失清单'); lines.push(`1. **无日报日**: ${noReportDays.length ? noReportDays.join(', ') : '无'}`); lines.push(`2. **转录完成=0 日**: ${noTranscribeDays.length ? noTranscribeDays.join(', ') : '无'}`); lines.push(`3. **对账失败或缺失非空日**: ${missingDays.length ? missingDays.map((x) => `${x.date}${x.reconcileError ? `[对账失败]` : ''}`).join(', ') : '无'}`); lines.push(''); lines.push('## 建议动作'); lines.push(actions.length ? actions.map((a) => `${a.executable ? '✅ 可执行' : '⛔ 超 7 天,上游已清理,不可补'}: ${a.command}`).join('\n') : '无(窗口完整)'); const md = lines.join('\n'); console.log(md); if (outPath) fs.writeFileSync(outPath, md);