Przeglądaj źródła

feat: 经营报告(图文/交互双视图 + A4 打印导出)

- 新增 GET /api/ops/report:由聚合结果生成统一 Report JSON(meta/摘要/KPI/章节/图表/表格/告警/行动计划/数据质量/方法论)
- 数值全部后端计算;摘要与告警由规则引擎生成(零单按原因分类、转化率下滑、头部集中度、无上期提示)
- 前端「经营报告」菜单:图文报告(图表静态)与交互报告(ECharts 悬浮提示)共用同一份 JSON
- 打印样式适配 A4,浏览器打印即可导出 PDF(demo 验证 357KB)
- 冒烟新增 report 只读断言(113/113)
liangzhao 2 tygodni temu
rodzic
commit
7d9e35cacf
8 zmienionych plików z 745 dodań i 0 usunięć
  1. 1 0
      README.md
  2. 2 0
      public/app.js
  3. 15 0
      public/index.html
  4. 383 0
      public/report.js
  5. 81 0
      public/style.css
  6. 5 0
      scripts/smoke.js
  7. 257 0
      server/routes/ops.js
  8. 1 0
      server/routes/static.js

+ 1 - 0
README.md

@@ -54,6 +54,7 @@
 - **报表展示**:一张表、一行一店一天,列 = 店铺 / 日期 / 勾选的指标(勾几个显示几列),按学校分组并带每日小计。平台明细同样进入「店铺运营」报表——有效订单→订单量、营业额→营收、曝光人数→曝光量、进店转化率→进店转化率;同一天同一店两种来源都有时,以人工表为准(人工优先、平台补位)。
 - **经营驾驶舱**:一屏看经营大盘——6 个 KPI(营业额 / 有效订单 / 客单价 / 曝光人数 / 进店转化率 / 店铺评分,带环比)+ 营业额与订单趋势、门店营业额 Top10、转化漏斗(曝光→进店→下单)、收入结构(收入/支出/补贴)。支持日期、学校、店铺筛选,环比按「紧邻的等长区间」计算;下方「零单诊断」用平台字段区分未开业 / 当日闭店 / 无曝光 / 无进店 / 无转化。
 - **AI 经营解读**:点「生成解读」,把当前范围的 KPI、趋势、门店增减、漏斗与成本结构整理成上下文交给对话模型,输出 3~5 条结论与建议(引用具体数字);结果缓存 10 分钟,可复制。未配置对话模型时会明确提示去「系统设置 → API 设置」配置。
+- **经营报告**:按统一 Report JSON 渲染「图文报告 / 交互报告」两版,支持选择日期区间、重新生成、浏览器打印导出 PDF(A4 打印样式已适配)。报告含管理层摘要(带证据)、核心指标、经营/门店/流量/费用四节图表、风险与行动计划、数据质量与方法论。
 - **字段与店铺筛选**:字段下拉可勾选平台导出里的任意列(默认 4 个基础指标),勾几个就显示几列;店铺下拉可多选门店,只看选中的店;选择保存在浏览器本地。
 - **学校归属**:平台表里没有学校列时,优先用上传时选的学校,其次从文件名识别(如「浙江工商桐庐校区20261018…」匹配到已登记学校);都匹配不到会要求先选学校。
 - **原件存档**:每次上传的 xlsx 原样入库,可在「导入记录」里下载回;解析规则升级后可「重新解析」而不必重传。

+ 2 - 0
public/app.js

@@ -35,6 +35,7 @@
     history: document.getElementById('viewHistory'),
     reports: document.getElementById('viewReports'),
     cockpit: document.getElementById('viewCockpit'),
+    report: document.getElementById('viewReport'),
     ops: document.getElementById('viewOps'),
     schools: document.getElementById('viewSchools'),
     lake: document.getElementById('viewLake'),
@@ -403,6 +404,7 @@
     if (name === 'history' && window.shanHistoryInit) window.shanHistoryInit();
     if (name === 'reports' && window.shanReportsInit) window.shanReportsInit();
     if (name === 'cockpit' && window.shanCockpitInit) window.shanCockpitInit();
+    if (name === 'report' && window.shanReportInit) window.shanReportInit();
     if (name === 'ops' && window.shanOpsInit) window.shanOpsInit();
     if (name === 'schools' && window.shanSchoolsInit) window.shanSchoolsInit();
     if (name === 'lake' && window.shanLakeInit) window.shanLakeInit(); // 明细 tab 激活时再懒加载报表

+ 15 - 0
public/index.html

@@ -42,6 +42,7 @@
     <button class="nav-item" data-view="reports"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12"/><path d="M7 10l5 5 5-5"/><path d="M4 21h16"/></svg></span><span>数据采集</span></button>
     <button class="nav-item" data-view="schools"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="10" r="3"/><path d="M12 21s7-6.2 7-11a7 7 0 1 0-14 0c0 4.8 7 11 7 11Z"/></svg></span><span>校区地图</span></button>
     <button class="nav-item" data-view="cockpit"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 13a9 9 0 0 1 18 0"/><path d="M12 13l4-4"/><circle cx="12" cy="13" r="1.6"/><path d="M3 17h18"/></svg></span><span>经营驾驶舱</span></button>
+    <button class="nav-item" data-view="report"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 3h7l4 4v14H7z"/><path d="M14 3v4h4"/><path d="M10 12h6M10 16h6"/></svg></span><span>经营报告</span></button>
     <button class="nav-item" data-view="ops"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 20h16"/><path d="M7 20V9M12 20V4M17 20v-7"/></svg></span><span>店铺运营</span></button>
     <button class="nav-item" data-view="lake"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="7" ry="2.5"/><path d="M5 5v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V5"/><path d="M5 11v6c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5v-6"/></svg></span><span>数据中台</span></button>
     <button class="nav-item" data-view="analysis"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 17l5-6 4 3 5-8 4 5"/><path d="M3 21h18"/></svg></span><span>趋势图表</span><span class="nav-beta">Beta</span></button>
@@ -367,6 +368,19 @@
       </main>
     </div>
 
+    <!-- ==================== 经营报告 ==================== -->
+    <div id="viewReport" class="view">
+      <main class="content">
+        <div class="page-header">
+          <div>
+            <h1>经营报告</h1>
+            <div class="sub">同一份数据生成的图文报告与交互报告,可下载 PDF</div>
+          </div>
+        </div>
+        <div id="reportRoot"></div>
+      </main>
+    </div>
+
     <!-- ==================== 店铺运营 ==================== -->
     <div id="viewOps" class="view">
       <main class="content">
@@ -1172,6 +1186,7 @@
 <script src="/multi-select.js"></script>
 <script src="/ops.js"></script>
 <script src="/cockpit.js"></script>
+<script src="/report.js"></script>
 <script src="/schools.js"></script>
 <script src="/ask.js"></script>
 <script src="/app.js"></script>

+ 383 - 0
public/report.js

@@ -0,0 +1,383 @@
+'use strict';
+
+// 经营报告:同一份 Report JSON 渲染「图文报告 / 交互报告」,PDF 走浏览器打印样式。
+// 数据来自 GET /api/ops/report(后端算指标、规则打标,模型只负责文字)。
+(function () {
+  const $ = (id) => document.getElementById(id);
+  const COLORS = { main: '#ff6b35', soft: '#ffb08a', grey: '#c9c2ba', blue: '#7a9cc6', light: '#ffd6c2' };
+
+  const state = {
+    inited: false,
+    loading: false,
+    report: null,
+    mode: 'text', // text | interactive
+    from: '',
+    to: '',
+    charts: {},
+  };
+
+  function esc(value) {
+    const div = document.createElement('div');
+    div.textContent = String(value == null ? '' : value);
+    return div.innerHTML;
+  }
+
+  function moneyText(v) {
+    if (v == null) return '–';
+    const n = Number(v);
+    return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '–';
+  }
+
+  function kpiText(kpi) {
+    if (kpi.current == null) return '–';
+    if (kpi.unit === '%') return kpi.current + '%';
+    if (kpi.unit === '单' || kpi.unit === '人') return numText(kpi.current);
+    return moneyText(kpi.current);
+  }
+
+  function numText(v) {
+    if (v == null) return '–';
+    const n = Number(v);
+    return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '–';
+  }
+
+  function deltaText(kpi) {
+    if (kpi.change_pct == null) return '<span class="rp-delta muted">—</span>';
+    const up = kpi.change_pct > 0;
+    const cls = kpi.change_pct === 0 ? 'muted' : up ? 'up' : 'down';
+    const unit = kpi.unit === '%' ? 'pp' : '%';
+    return `<span class="rp-delta ${cls}">${up ? '↑' : '↓'} ${Math.abs(kpi.change_pct).toFixed(1)}${unit}</span>`;
+  }
+
+  async function api(url) {
+    const res = await fetch(url);
+    const body = await res.json().catch(() => null);
+    return { res, body };
+  }
+
+  // ---------- 渲染 ----------
+
+  function renderShell() {
+    const root = $('reportRoot');
+    if (!root) return;
+    root.innerHTML = `
+      <div class="rp-toolbar">
+        <div class="rp-range">
+          <input type="date" class="ops-input" id="rpFrom">
+          <span class="ops-tilde">~</span>
+          <input type="date" class="ops-input" id="rpTo">
+        </div>
+        <div class="rp-tabs" id="rpTabs">
+          <button type="button" data-mode="text" class="on">图文报告</button>
+          <button type="button" data-mode="interactive">交互报告</button>
+        </div>
+        <div class="rp-actions">
+          <button type="button" class="btn btn-ghost btn-small" id="rpRefresh">重新生成</button>
+          <button type="button" class="btn btn-primary btn-small" id="rpPrint">下载 PDF</button>
+        </div>
+      </div>
+      <article class="rp-page" id="rpPage"></article>`;
+    root.querySelectorAll('#rpTabs [data-mode]').forEach((btn) => {
+      btn.addEventListener('click', () => {
+        state.mode = btn.dataset.mode;
+        root.querySelectorAll('#rpTabs [data-mode]').forEach((b) => b.classList.toggle('on', b === btn));
+        $('rpPage').classList.toggle('interactive', state.mode === 'interactive');
+        if (state.report) drawCharts(state.report);
+      });
+    });
+    $('rpFrom').addEventListener('change', (e) => { state.from = String(e.target.value || '').replace(/-/g, ''); load(true); });
+    $('rpTo').addEventListener('change', (e) => { state.to = String(e.target.value || '').replace(/-/g, ''); load(true); });
+    $('rpRefresh').addEventListener('click', () => load(true));
+    $('rpPrint').addEventListener('click', () => window.print());
+  }
+
+  function renderCover(report) {
+    const meta = report.meta || {};
+    return `
+      <header class="rp-cover">
+        <div class="rp-cover-main">
+          <div class="rp-kicker">AI 经营分析报告</div>
+          <h1>${esc(meta.title || '经营分析报告')}</h1>
+          <div class="rp-cover-sub">${esc(meta.organization || '')}${meta.organization ? ' · ' : ''}${esc(meta.period_start)} ~ ${esc(meta.period_end)}</div>
+        </div>
+        <div class="rp-cover-meta">
+          <div><span>统计范围</span><b>${esc(meta.data_scope || '–')}</b></div>
+          <div><span>数据来源</span><b>${esc((meta.source_files || []).join('、') || '平台导出明细')}</b></div>
+          <div><span>生成时间</span><b>${esc(new Date(meta.generated_at).toLocaleString('zh-CN'))}</b></div>
+        </div>
+      </header>`;
+  }
+
+  function renderSummary(report) {
+    const summary = report.executive_summary || {};
+    const dot = { positive: 'ok', neutral: 'na', warning: 'warn', critical: 'bad' };
+    const items = (summary.key_findings || []).map((f) => `
+      <li>
+        <span class="rp-dot ${dot[f.severity] || 'na'}"></span>
+        <div>
+          <b>${esc(f.finding)}</b>
+          <p>${esc(f.interpretation || '')}</p>
+          ${(f.evidence || []).length ? `<div class="rp-evidence">${f.evidence.map((e) => `<code>${esc(e)}</code>`).join('')}</div>` : ''}
+        </div>
+      </li>`).join('');
+    return `
+      <section class="rp-section rp-summary">
+        <h2>管理层摘要</h2>
+        <p class="rp-lead">${esc(summary.overall_assessment || '')}</p>
+        <ul class="rp-findings">${items}</ul>
+      </section>`;
+  }
+
+  function renderKpis(report) {
+    return `
+      <section class="rp-section">
+        <h2>核心指标</h2>
+        <div class="rp-kpis">
+          ${(report.kpis || []).map((kpi) => `
+            <div class="rp-kpi" title="${esc(kpi.definition)}">
+              <div class="rp-kpi-label">${esc(kpi.label)}<span>${esc(kpi.unit)}</span></div>
+              <div class="rp-kpi-value">${kpiText(kpi)}</div>
+              <div class="rp-kpi-foot">${deltaText(kpi)}<span class="rp-kpi-hint">较上期</span></div>
+            </div>`).join('')}
+        </div>
+        <p class="rp-note">口径:${(report.kpis || []).map((k) => `${k.label}=${k.definition}`).join(';')}</p>
+      </section>`;
+  }
+
+  function chartCard(id, title, subtitle) {
+    return `<figure class="rp-figure">
+      <figcaption><b>${esc(title)}</b><span>${esc(subtitle || '')}</span></figcaption>
+      <div class="rp-chart" id="${id}"></div>
+    </figure>`;
+  }
+
+  function renderSections(report) {
+    const chartById = new Map((report.charts || []).map((c) => [c.id, c]));
+    const tableById = new Map((report.tables || []).map((t) => [t.id, t]));
+    return (report.sections || []).map((section, index) => {
+      if (section.id === 'quality') return '';
+      const charts = (section.chart_ids || []).map((id) => chartById.get(id)).filter(Boolean);
+      const tables = (section.table_ids || []).map((id) => tableById.get(id)).filter(Boolean);
+      return `
+        <section class="rp-section">
+          <h2><em>${String(index + 1).padStart(2, '0')}</em>${esc(section.title)}</h2>
+          ${section.summary ? `<p class="rp-lead">${esc(section.summary)}</p>` : ''}
+          ${(section.paragraphs || []).map((p) => `<p>${esc(p)}</p>`).join('')}
+          ${charts.map((chart) => chartCard(`rpChart_${chart.id}`, chart.title, chart.description)).join('')}
+          ${tables.map((table) => renderTable(table)).join('')}
+        </section>`;
+    }).join('');
+  }
+
+  function renderTable(table) {
+    return `
+      <div class="rp-table-wrap">
+        <table class="rp-table">
+          <thead><tr>${table.columns.map((c) => `<th>${esc(c.label)}</th>`).join('')}</tr></thead>
+          <tbody>${(table.rows || []).map((row) => `<tr>${table.columns.map((c) => {
+            const v = row[c.key];
+            const text = c.key === 'revenue' ? moneyText(v) : c.key === 'share' ? (v == null ? '–' : v + '%') : (v == null ? '–' : esc(v));
+            return `<td>${text}</td>`;
+          }).join('')}</tr>`).join('')}</tbody>
+        </table>
+        ${table.footnote ? `<p class="rp-note">${esc(table.footnote)}</p>` : ''}
+      </div>`;
+  }
+
+  function renderAlerts(report) {
+    const alerts = report.alerts || [];
+    const plan = report.action_plan || [];
+    return `
+      <section class="rp-section">
+        <h2><em>05</em>风险与建议</h2>
+        <div class="rp-alerts">
+          ${alerts.map((a) => `
+            <div class="rp-alert ${esc(a.severity)}">
+              <div class="rp-alert-head"><span class="rp-badge">${esc(a.severity)}</span><b>${esc(a.title)}</b></div>
+              <ul>${(a.evidence || []).map((e) => `<li>${esc(e)}</li>`).join('')}</ul>
+              ${(a.hypotheses || []).length ? `<p class="rp-note">待验证:${a.hypotheses.map(esc).join('、')}</p>` : ''}
+            </div>`).join('') || '<p class="rp-note">本期未触发风险规则。</p>'}
+        </div>
+        <h3 class="rp-h3">行动计划</h3>
+        <table class="rp-table">
+          <thead><tr><th>优先级</th><th>行动</th><th>负责人</th><th>时间窗</th><th>验收指标</th></tr></thead>
+          <tbody>${plan.map((p) => `<tr>
+            <td><span class="rp-badge">${esc(p.priority)}</span></td>
+            <td>${esc(p.action)}</td>
+            <td>${esc(p.owner_role)}</td>
+            <td>${esc(p.time_horizon)}</td>
+            <td>${esc(p.success_metric)}</td>
+          </tr>`).join('')}</tbody>
+        </table>
+      </section>`;
+  }
+
+  function renderQuality(report) {
+    const q = report.data_quality || {};
+    return `
+      <section class="rp-section rp-quality">
+        <h2><em>06</em>数据质量与方法论</h2>
+        <p><b>覆盖范围:</b>${esc(q.coverage || '–')}</p>
+        <p><b>可比性:</b>${esc(q.comparability || '–')}</p>
+        ${(q.warnings || []).length ? `<p><b>提示:</b>${q.warnings.map(esc).join(';')}</p>` : ''}
+        ${(q.missing_metrics || []).length ? `<p><b>缺失指标:</b>${q.missing_metrics.map(esc).join(';')}</p>` : ''}
+        <p><b>方法论:</b></p>
+        <ul class="rp-method">${(report.methodology || []).map((m) => `<li>${esc(m)}</li>`).join('')}</ul>
+      </section>`;
+  }
+
+  function renderReport(report) {
+    const page = $('rpPage');
+    page.innerHTML = [
+      renderCover(report),
+      renderSummary(report),
+      renderKpis(report),
+      renderSections(report),
+      renderAlerts(report),
+      renderQuality(report),
+      `<footer class="rp-footer">本报告由系统按统一口径自动生成,数值均来自后端计算,模型仅参与文字表述。</footer>`,
+    ].join('');
+    drawCharts(report);
+  }
+
+  // ---------- 图表 ----------
+
+  function chartBase(interactive) {
+    return {
+      animation: !interactive,
+      silent: !interactive,
+      tooltip: interactive ? { trigger: 'axis' } : undefined,
+    };
+  }
+
+  function drawCharts(report) {
+    const interactive = state.mode === 'interactive';
+    const byId = new Map((report.charts || []).map((c) => [c.id, c]));
+    Object.values(state.charts).forEach((chart) => chart && chart.dispose());
+    state.charts = {};
+
+    const mount = (chart) => {
+      const el = $(`rpChart_${chart.id}`);
+      if (!el) return null;
+      const instance = echarts.init(el);
+      state.charts[chart.id] = instance;
+      return instance;
+    };
+
+    const trend = byId.get('chart_trend');
+    if (trend) {
+      const chart = mount(trend);
+      chart && chart.setOption({
+        ...chartBase(interactive),
+        grid: { left: 56, right: 48, top: 24, bottom: 30 },
+        legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
+        xAxis: { type: 'category', data: trend.data.map((d) => d.date.slice(4)), axisTick: { show: false }, axisLine: { lineStyle: { color: '#e5e0da' } } },
+        yAxis: [{ type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } }, { type: 'value', splitLine: { show: false } }],
+        series: [
+          { name: '营业额', type: 'bar', barMaxWidth: 22, itemStyle: { color: COLORS.main, borderRadius: [3, 3, 0, 0] }, data: trend.data.map((d) => d.revenue) },
+          { name: '订单', type: 'line', yAxisIndex: 1, symbolSize: 5, lineStyle: { width: 2, color: COLORS.blue }, itemStyle: { color: COLORS.blue }, data: trend.data.map((d) => d.orders) },
+        ],
+      });
+    }
+
+    const rank = byId.get('chart_rank');
+    if (rank) {
+      const rows = rank.data.slice().reverse();
+      const chart = mount(rank);
+      chart && chart.setOption({
+        ...chartBase(interactive),
+        grid: { left: 8, right: 60, top: 10, bottom: 10, containLabel: true },
+        xAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } },
+        yAxis: { type: 'category', data: rows.map((r) => r.shopName), axisTick: { show: false }, axisLine: { show: false }, axisLabel: { width: 140, overflow: 'truncate', fontSize: 11 } },
+        series: [{ type: 'bar', barMaxWidth: 14, itemStyle: { color: COLORS.soft, borderRadius: [0, 3, 3, 0] }, label: { show: true, position: 'right', fontSize: 10, color: '#6f6862' }, data: rows.map((r) => r.revenue) }],
+      });
+    }
+
+    const funnel = byId.get('chart_funnel');
+    if (funnel) {
+      const f = funnel.data;
+      const chart = mount(funnel);
+      chart && chart.setOption({
+        ...chartBase(interactive),
+        series: [{
+          type: 'funnel', left: '10%', right: '10%', top: 12, bottom: 12, minSize: '30%', gap: 3,
+          label: { show: true, position: 'inside', color: '#fff', fontSize: 11, formatter: (p) => `${p.name} ${Number(p.value).toLocaleString('zh-CN')}` },
+          itemStyle: { borderWidth: 0 },
+          color: [COLORS.main, '#ff9a70', COLORS.light],
+          data: [{ name: '曝光', value: f.exposure }, { name: '进店', value: f.visit }, { name: '下单', value: f.order }],
+        }],
+      });
+    }
+
+    const structure = byId.get('chart_structure');
+    if (structure) {
+      const chart = mount(structure);
+      chart && chart.setOption({
+        ...chartBase(interactive),
+        grid: { left: 56, right: 20, top: 28, bottom: 30 },
+        legend: { right: 0, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { fontSize: 11 } },
+        xAxis: { type: 'category', data: structure.data.map((d) => d.date.slice(4)), axisTick: { show: false }, axisLine: { lineStyle: { color: '#e5e0da' } } },
+        yAxis: { type: 'value', splitLine: { lineStyle: { color: '#f2efea' } } },
+        series: [
+          { name: '收入', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.main }, data: structure.data.map((d) => d.income) },
+          { name: '支出', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.grey }, data: structure.data.map((d) => d.expense) },
+          { name: '补贴', type: 'bar', barMaxWidth: 18, itemStyle: { color: COLORS.light }, data: structure.data.map((d) => d.subsidy) },
+        ],
+      });
+    }
+  }
+
+  // ---------- 数据 ----------
+
+  async function load(force) {
+    if (state.loading) return;
+    state.loading = true;
+    const page = $('rpPage');
+    if (page && !state.report) page.innerHTML = '<div class="rp-loading">正在生成报告…</div>';
+    try {
+      const params = new URLSearchParams();
+      if (state.from) params.set('from', state.from);
+      if (state.to) params.set('to', state.to);
+      const { res, body } = await api('/api/ops/report?' + params.toString());
+      if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
+      if (body.empty) {
+        page.innerHTML = '<div class="rp-loading">所选范围没有数据,请先导入明细。</div>';
+        return;
+      }
+      state.report = body.report;
+      renderReport(state.report);
+    } catch (e) {
+      page.innerHTML = `<div class="rp-loading">报告生成失败:${esc(e.message || e)}</div>`;
+    } finally {
+      state.loading = false;
+    }
+  }
+
+  async function initDates() {
+    const { body } = await api('/api/ops/summary');
+    const coverage = body && body.coverage;
+    if (!coverage) return;
+    const to = coverage.to;
+    const d = new Date(Date.UTC(Number(to.slice(0, 4)), Number(to.slice(4, 6)) - 1, Number(to.slice(6, 8)) - 6));
+    const from = `${d.getUTCFullYear()}${String(d.getUTCMonth() + 1).padStart(2, '0')}${String(d.getUTCDate()).padStart(2, '0')}`;
+    state.to = to;
+    state.from = from < coverage.from ? coverage.from : from;
+    const toInput = (ymd) => `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;
+    $('rpFrom').value = toInput(state.from);
+    $('rpTo').value = toInput(state.to);
+  }
+
+  window.shanReportInit = function () {
+    if (!state.inited) {
+      state.inited = true;
+      renderShell();
+      initDates().then(() => load());
+      window.addEventListener('resize', () => {
+        Object.values(state.charts).forEach((chart) => chart && chart.resize());
+      });
+      window.addEventListener('beforeprint', () => {
+        Object.values(state.charts).forEach((chart) => chart && chart.resize());
+      });
+    }
+  };
+})();

+ 81 - 0
public/style.css

@@ -1389,3 +1389,84 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .ckpt-zero-chip.warn { border-color: rgba(255, 107, 53, 0.35); background: #fff5ef; }
 .ckpt-zero-chip.info { border-color: #cfe0ef; background: #f3f8fc; }
 .ckpt-zero-chip.muted { color: var(--text-3); }
+
+/* ── 经营报告 ─────────────────────────────────────────── */
+.rp-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
+.rp-tabs { display: inline-flex; padding: 3px; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; }
+.rp-tabs button { padding: 6px 16px; font-size: 12.5px; color: var(--text-2); background: none; border: 0; border-radius: 7px; cursor: pointer; }
+.rp-tabs button.on { background: #fff; color: var(--text); box-shadow: 0 1px 3px rgba(31,27,24,.08); font-weight: 600; }
+.rp-actions { display: flex; gap: 8px; }
+.rp-page { max-width: 1020px; margin: 0 auto; padding: 44px 52px 40px; background: #fff; border: 1px solid var(--border); border-radius: 14px; box-shadow: 0 18px 48px rgba(31,27,24,.06); color: var(--text); }
+.rp-loading { padding: 80px 0; text-align: center; color: var(--text-3); font-size: 13px; }
+.rp-cover { display: flex; justify-content: space-between; gap: 32px; padding-bottom: 24px; border-bottom: 2px solid var(--text); }
+.rp-kicker { font-size: 11px; letter-spacing: .18em; color: var(--accent); font-weight: 700; }
+.rp-cover h1 { margin: 8px 0 6px; font-size: 30px; line-height: 1.25; letter-spacing: -0.02em; }
+.rp-cover-sub { font-size: 13px; color: var(--text-2); }
+.rp-cover-meta { flex: 0 0 auto; width: 280px; display: flex; flex-direction: column; gap: 8px; font-size: 11.5px; }
+.rp-cover-meta div { display: flex; gap: 8px; }
+.rp-cover-meta span { color: var(--text-3); flex: 0 0 56px; }
+.rp-cover-meta b { font-weight: 600; color: var(--text-2); word-break: break-all; }
+.rp-section { padding: 26px 0; border-bottom: 1px solid var(--border-light); }
+.rp-section:last-of-type { border-bottom: 0; }
+.rp-section h2 { display: flex; align-items: baseline; gap: 10px; margin: 0 0 12px; font-size: 17px; letter-spacing: -0.01em; }
+.rp-section h2 em { font-style: normal; font-size: 12px; font-weight: 700; color: var(--accent); letter-spacing: .06em; }
+.rp-h3 { margin: 22px 0 10px; font-size: 14px; }
+.rp-lead { margin: 0 0 12px; font-size: 13.5px; line-height: 1.8; color: var(--text-2); }
+.rp-note { margin: 10px 0 0; font-size: 11.5px; line-height: 1.7; color: var(--text-3); }
+.rp-findings { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 12px; }
+.rp-findings li { display: flex; gap: 10px; font-size: 13px; line-height: 1.7; }
+.rp-findings b { font-weight: 600; }
+.rp-findings p { margin: 2px 0 0; color: var(--text-2); font-size: 12.5px; }
+.rp-dot { flex: 0 0 auto; width: 8px; height: 8px; margin-top: 7px; border-radius: 50%; background: var(--text-3); }
+.rp-dot.ok { background: #2e7d4f; }
+.rp-dot.warn { background: var(--accent); }
+.rp-dot.bad { background: #c0392b; }
+.rp-evidence { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
+.rp-evidence code { padding: 1px 6px; font-size: 10.5px; color: var(--text-2); background: var(--bg); border-radius: 5px; }
+.rp-kpis { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 12px; }
+.rp-kpi { padding: 14px 16px; background: linear-gradient(135deg,#fff 0%,#faf8f5 100%); border: 1px solid var(--border-light); border-radius: 12px; }
+.rp-kpi-label { display: flex; justify-content: space-between; font-size: 11.5px; color: var(--text-3); }
+.rp-kpi-value { margin: 8px 0 6px; font-size: 22px; font-weight: 650; letter-spacing: -0.01em; font-variant-numeric: tabular-nums; }
+.rp-kpi-foot { display: flex; align-items: center; gap: 6px; font-size: 11.5px; }
+.rp-kpi-hint { color: var(--text-3); }
+.rp-delta.up { color: #2e7d4f; font-weight: 600; }
+.rp-delta.down { color: #c0392b; font-weight: 600; }
+.rp-delta.muted { color: var(--text-3); }
+.rp-figure { margin: 16px 0 6px; }
+.rp-figure figcaption { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; }
+.rp-figure figcaption b { font-size: 12.5px; }
+.rp-figure figcaption span { font-size: 11.5px; color: var(--text-3); }
+.rp-chart { width: 100%; height: 240px; }
+.rp-page.interactive .rp-chart { cursor: crosshair; }
+.rp-table { width: 100%; border-collapse: collapse; font-size: 12.5px; margin-top: 8px; }
+.rp-table th { padding: 8px 10px; text-align: left; font-weight: 600; color: var(--text-2); background: var(--bg); border-bottom: 1px solid var(--border); }
+.rp-table td { padding: 8px 10px; border-bottom: 1px solid var(--border-light); }
+.rp-table tr:last-child td { border-bottom: 0; }
+.rp-alerts { display: flex; flex-direction: column; gap: 10px; }
+.rp-alert { padding: 12px 14px; background: #fbfaf8; border: 1px solid var(--border-light); border-left: 3px solid var(--text-3); border-radius: 10px; }
+.rp-alert.P0 { border-left-color: #c0392b; background: #fdf6f5; }
+.rp-alert.P1 { border-left-color: var(--accent); background: #fff8f4; }
+.rp-alert-head { display: flex; align-items: center; gap: 8px; font-size: 13px; }
+.rp-alert ul { margin: 8px 0 0; padding-left: 18px; font-size: 12px; color: var(--text-2); line-height: 1.7; }
+.rp-badge { padding: 1px 6px; font-size: 10px; font-weight: 700; color: #fff; background: var(--text-3); border-radius: 4px; }
+.rp-alert.P0 .rp-badge { background: #c0392b; }
+.rp-alert.P1 .rp-badge { background: var(--accent); }
+.rp-quality p { margin: 4px 0; font-size: 12.5px; color: var(--text-2); }
+.rp-method { margin: 6px 0 0; padding-left: 18px; font-size: 12px; line-height: 1.8; color: var(--text-2); }
+.rp-footer { padding-top: 18px; border-top: 1px solid var(--border-light); font-size: 11px; color: var(--text-3); text-align: center; }
+
+@media print {
+  @page { size: A4; margin: 12mm 14mm; }
+  .sidebar, .rp-toolbar, .page-header, #splash { display: none !important; }
+  .app-frame { display: block; height: auto; overflow: visible; }
+  .app-frame > main { width: 100%; }
+  .content { padding: 0 !important; }
+  .view { display: none !important; }
+  .view.active { display: block !important; }
+  .rp-page { max-width: none; margin: 0; padding: 0; border: 0; box-shadow: none; }
+  .rp-section, .rp-kpi, .rp-figure, .rp-alert, .rp-table tr { break-inside: avoid; }
+  .rp-section h2 { break-after: avoid; }
+  body { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
+}
+.rp-range { display: flex; align-items: center; gap: 6px; margin-right: auto; }
+@media print { .rp-range { display: none !important; } }

+ 5 - 0
scripts/smoke.js

@@ -370,6 +370,11 @@ async function main() {
       const d = r.json();
       expect(r.status === 200 && d.kpis === null && Array.isArray(d.trend), `status=${r.status}`);
     });
+    await check('GET /api/ops/report(空库 → empty)', async () => {
+      const r = await request(port, 'GET', '/api/ops/report');
+      const d = r.json();
+      expect(r.status === 200 && d.empty === true, `status=${r.status}`);
+    });
     await check('GET /api/ops/rows(空库 → 空矩阵)', async () => {
       const r = await request(port, 'GET', '/api/ops/rows');
       const d = r.json();

+ 257 - 0
server/routes/ops.js

@@ -529,6 +529,243 @@ function parseInsightPoints(text) {
     .slice(0, 6);
 }
 
+/** 由聚合结果生成 Report JSON(图文 / 交互 / PDF 三版共用;数值全部来自后端计算) */
+function buildReport(data, meta = {}) {
+  const { kpis, trend, ranking, bottom, zeroOrder, funnel, structure, from, to, prev, shopCount } = data;
+  const money = (v) => (v == null ? null : Math.round(v * 100) / 100);
+  const pct = (v) => (v == null ? null : Math.round(v * 1000) / 10);
+  const findings = [];
+
+  const rev = kpis.revenue || {};
+  if (rev.delta != null) {
+    findings.push({
+      severity: rev.delta < -5 ? 'warning' : rev.delta > 5 ? 'positive' : 'neutral',
+      finding: `营业额 ${money(rev.value)} 元,较上期 ${rev.delta > 0 ? '增长' : '下降'} ${Math.abs(rev.delta)}%`,
+      evidence: [`revenue:${money(rev.value)}`, `previous:${money(rev.prev)}`],
+      interpretation: rev.delta < 0 ? '规模收缩,需要结合订单量与流量拆解原因' : '规模增长,关注是否由活动或流量投放带来',
+      confidence: 'high',
+    });
+  }
+
+  const conv = kpis.conversion || {};
+  if (conv.value != null) {
+    findings.push({
+      severity: conv.delta != null && conv.delta < -0.5 ? 'warning' : 'neutral',
+      finding: `进店转化率 ${pct(conv.value)}%${conv.delta != null ? `,较上期 ${conv.delta > 0 ? '+' : ''}${conv.delta}pp` : ''}`,
+      evidence: [`conversion:${conv.value}`],
+      interpretation: '转化率反映进店后的下单意愿,需结合定价、售罄与出餐时长判断',
+      confidence: 'medium',
+    });
+  }
+
+  const zeroByReason = (zeroOrder || []).reduce((acc, item) => {
+    acc[item.reason] = acc[item.reason] || [];
+    acc[item.reason].push(item);
+    return acc;
+  }, {});
+  const zeroTotal = (zeroOrder || []).length;
+  if (zeroTotal) {
+    const parts = Object.entries(zeroByReason).map(([reason, list]) => `${REASON_LABEL[reason] || reason} ${list.length} 店次`);
+    findings.push({
+      severity: (zeroByReason['no-conversion'] || []).length ? 'warning' : 'neutral',
+      finding: `零单合计 ${zeroTotal} 店次:${parts.join('、')}`,
+      evidence: ['zeroOrder'],
+      interpretation: '未开业/闭店属于营业状态问题;有进店无下单才是转化问题,需要优先处理',
+      confidence: 'high',
+    });
+  }
+
+  if (ranking && ranking.length && rev.value > 0) {
+    const share = Math.round((ranking[0].revenue / rev.value) * 1000) / 10;
+    findings.push({
+      severity: share > 40 ? 'warning' : 'neutral',
+      finding: `头部门店「${ranking[0].shopName}」贡献营业额 ${money(ranking[0].revenue)} 元,占比 ${share}%`,
+      evidence: [`top_shop:${ranking[0].shopId}`],
+      interpretation: share > 40 ? '集中度偏高,单店波动会显著影响大盘' : '门店贡献相对分散',
+      confidence: 'high',
+    });
+  }
+
+  if (!prev) {
+    findings.push({
+      severity: 'neutral',
+      finding: '所选区间之前没有可比数据,本期不计算环比趋势',
+      evidence: ['prev:null'],
+      interpretation: '只能做横向门店对比与当日诊断,不要据此判断长期趋势',
+      confidence: 'high',
+    });
+  }
+
+  const alerts = [];
+  for (const [reason, list] of Object.entries(zeroByReason)) {
+    alerts.push({
+      severity: reason === 'no-conversion' ? 'P1' : 'P2',
+      store_id: list[0].shopId || null,
+      title: `${REASON_LABEL[reason] || reason}:${list.length} 店次`,
+      evidence: list.slice(0, 5).map((z) => `${z.date} ${z.shopName}:${z.reasonText}`),
+      hypotheses: reason === 'no-conversion'
+        ? ['菜品/定价与周边相比缺乏竞争力', '出餐或配送时长导致流失', '活动力度不足或库存售罄']
+        : ['门店未上线或当日排班缺失', '平台侧闭店/审核状态异常'],
+      next_checks: reason === 'no-conversion'
+        ? ['核对当日菜品售罄记录', '查看差评与退款原因', '对比同商圈同类门店客单价']
+        : ['确认门店上线状态与营业时间设置', '核对平台闭店记录'],
+    });
+  }
+  if (conv.delta != null && conv.delta < -0.5) {
+    alerts.push({
+      severity: 'P1', store_id: null, title: '进店转化率下滑',
+      evidence: [`conversion:${conv.value}`, `previous:${conv.prev}`],
+      hypotheses: ['菜单改价或活动结束', '高峰期出餐变慢', '竞品补贴'],
+      next_checks: ['对比改价/活动时间', '查看出餐时长与超时单'],
+    });
+  }
+
+  const actionPlan = [];
+  if ((zeroByReason['no-conversion'] || []).length) {
+    actionPlan.push({
+      priority: 'P0',
+      action: '对「有进店无下单」门店做单店诊断:菜品可售、价格带、出餐与配送时长',
+      owner_role: '运营负责人',
+      time_horizon: '24 小时内',
+      success_metric: '次日进店→下单转化率回升',
+      related_alerts: ['有进店无下单'],
+    });
+  }
+  if ((zeroByReason['not-open'] || []).length || (zeroByReason['closed'] || []).length) {
+    actionPlan.push({
+      priority: 'P1',
+      action: '核对未开业/闭店门店的上线状态、营业时间与异常关店记录',
+      owner_role: '门店督导',
+      time_horizon: '48 小时内',
+      success_metric: '营业时长恢复、零单店次下降',
+      related_alerts: ['未开业', '当日闭店'],
+    });
+  }
+  if (conv.delta != null && conv.delta < -0.5) {
+    actionPlan.push({
+      priority: 'P1',
+      action: '排查转化率下滑:活动是否结束、是否改价、是否有售罄',
+      owner_role: '运营负责人',
+      time_horizon: '72 小时内',
+      success_metric: '转化率回到上期水平',
+      related_alerts: ['进店转化率下滑'],
+    });
+  }
+  if (prev) {
+    actionPlan.push({
+      priority: 'P2',
+      action: '补全成本与结算口径,再评估毛利与真实利润',
+      owner_role: '财务',
+      time_horizon: '2 周内',
+      success_metric: '可计算毛利与费用率',
+      related_alerts: [],
+    });
+  }
+
+  const kpiList = [
+    ['revenue', '营业额', 'CNY', '平台「营业额」列求和'],
+    ['orders', '有效订单', '单', '平台「有效订单」列求和'],
+    ['avgTicket', '客单价', 'CNY', '营业额 ÷ 有效订单'],
+    ['exposure', '曝光人数', '人', '平台「曝光人数」列求和'],
+    ['conversion', '进店转化率', '%', 'Σ进店人数 ÷ Σ曝光人数'],
+    ['rating', '店铺评分', '分', '有效评分门店均值(排除 0 分)'],
+  ].map(([key, label, unit, definition]) => {
+    const item = kpis[key] || {};
+    const fmt = (value) => {
+      if (value == null) return null;
+      if (unit === '%') return pct(value);
+      if (unit === '单' || unit === '人') return Math.round(value);
+      return money(value);
+    };
+    return {
+      key, label, unit, definition,
+      current: fmt(item.value),
+      previous: fmt(item.prev),
+      difference: item.value != null && item.prev != null && unit !== '%'
+        ? Math.round((item.value - item.prev) * 100) / 100
+        : (item.value != null && item.prev != null ? Math.round((item.value - item.prev) * 1000) / 10 : null),
+      change_pct: item.delta,
+      status: item.delta == null ? 'unknown' : item.delta > 0 ? 'up' : item.delta < 0 ? 'down' : 'flat',
+    };
+  });
+
+  return {
+    meta: {
+      title: meta.title || '经营分析报告',
+      organization: meta.organization || '',
+      period_start: formatYmd(from),
+      period_end: formatYmd(to),
+      generated_at: new Date().toISOString(),
+      currency: 'CNY',
+      data_scope: `${shopCount} 家门店 · ${trend.length} 天 · ${zeroTotal} 个零单店次`,
+      source_files: meta.sourceFiles || [],
+    },
+    executive_summary: {
+      overall_assessment: rev.delta == null
+        ? '本期有经营数据,但缺少可比的上期,暂不做趋势判断;以下结论基于本期横向对比与门店诊断。'
+        : `本期营业额 ${money(rev.value)} 元,较上期${rev.delta >= 0 ? '增长' : '下降'} ${Math.abs(rev.delta)}%,订单 ${kpis.orders.value} 单。`,
+      key_findings: findings,
+    },
+    kpis: kpiList,
+    sections: [
+      { id: 'overview', title: '经营概览', summary: rev.delta == null ? '本期无上期对比' : `营业额环比 ${rev.delta}%`, paragraphs: [], chart_ids: ['chart_trend'], table_ids: [], evidence_keys: ['revenue', 'orders'] },
+      { id: 'stores', title: '门店表现', summary: `共 ${shopCount} 家门店,Top1 为「${(ranking[0] || {}).shopName || '—'}」`, paragraphs: [], chart_ids: ['chart_rank'], table_ids: ['table_stores'], evidence_keys: ['revenue'] },
+      { id: 'funnel', title: '流量与转化', summary: `曝光 ${funnel.exposure} → 进店 ${funnel.visit} → 下单 ${funnel.order}`, paragraphs: [], chart_ids: ['chart_funnel'], table_ids: [], evidence_keys: ['conversion'] },
+      { id: 'finance', title: '收入与费用', summary: '仅展示平台收入、支出与补贴,不等同于利润', paragraphs: [], chart_ids: ['chart_structure'], table_ids: [], evidence_keys: ['revenue'] },
+      { id: 'quality', title: '数据质量', summary: '', paragraphs: [], chart_ids: [], table_ids: [], evidence_keys: [] },
+    ],
+    stores: (ranking || []).slice(0, 10).map((shop, index) => ({
+      store_id: shop.shopId,
+      name: shop.shopName,
+      rank: index + 1,
+      facts: [`营业额 ${money(shop.revenue)} 元,订单 ${shop.orders} 单`],
+      diagnosis: index === 0 ? '本期贡献最高,需保持供给与活动稳定' : '贡献靠后,结合流量与转化排查',
+      risks: [],
+      recommendations: [],
+    })),
+    charts: [
+      { id: 'chart_trend', type: 'line_bar', title: '营业额与订单趋势', description: '柱为营业额,线为订单量;天数较少时不做平滑趋势判断。', source: '平台明细按日聚合', unit: 'CNY', data: trend },
+      { id: 'chart_rank', type: 'horizontal_bar', title: '门店营业额 Top 10', description: '按所选区间营业额排序。', source: '平台明细按门店聚合', unit: 'CNY', data: ranking },
+      { id: 'chart_funnel', type: 'funnel', title: '曝光 → 进店 → 下单', description: '三层人数不可跨口径混算。', source: '平台「曝光人数/进店人数/下单人数」', unit: '人', data: funnel },
+      { id: 'chart_structure', type: 'bar', title: '收入与支出结构', description: '收入为营业额;支出与补贴按平台口径。', source: '平台「营业额/支出/活动总补贴」', unit: 'CNY', data: structure },
+    ],
+    tables: [{
+      id: 'table_stores',
+      title: '门店明细(Top 10)',
+      columns: [
+        { key: 'name', label: '门店' }, { key: 'revenue', label: '营业额' },
+        { key: 'orders', label: '订单' }, { key: 'share', label: '占比' },
+      ],
+      rows: (ranking || []).map((shop) => ({
+        name: shop.shopName, revenue: money(shop.revenue), orders: shop.orders,
+        share: rev.value > 0 ? Math.round((shop.revenue / rev.value) * 1000) / 10 : null,
+      })),
+      footnote: '占比 = 门店营业额 ÷ 本期总营业额',
+    }],
+    alerts,
+    action_plan: actionPlan,
+    data_quality: {
+      coverage: `${formatYmd(from)} ~ ${formatYmd(to)},共 ${trend.length} 天`,
+      warnings: prev ? [] : ['所选区间前无数据,环比不可用'],
+      comparability: '同店对比要求门店集合一致;本期未做同店筛选',
+      missing_metrics: ['成本、平台佣金明细缺失,无法计算毛利与净利'],
+    },
+    methodology: [
+      '金额与单量均由后端按「日期+门店」明细求和,不由模型生成',
+      '进店转化率 = Σ进店人数 ÷ Σ曝光人数(同层级口径)',
+      '客单价 = 营业额 ÷ 有效订单,订单为 0 时为 null',
+      '上期为紧邻的等长区间,比率变化用百分点(pp)',
+    ],
+  };
+}
+
+const REASON_LABEL = { 'not-open': '未开业', closed: '当日闭店', 'no-traffic': '无曝光', 'no-visit': '无进店', 'no-conversion': '有进店无下单', unknown: '数据不足' };
+
+function formatYmd(ymd) {
+  const s = String(ymd || '');
+  return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : '';
+}
+
 function csvCell(value) {
   const text = value == null ? '' : String(value);
   return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
@@ -851,6 +1088,26 @@ function register(app, ctx) {
     sendJson(res, 200, { enabled: true, ...value });
   });
 
+  app.get('/api/ops/report', async ({ res, query }) => {
+    if (!(await requireHealthy(store, res))) return;
+    const from = parseYmd(query.from);
+    const to = parseYmd(query.to);
+    const schools = splitList(query.schools);
+    const shops = splitList(query.shops);
+    try {
+      const data = await buildDashboard(store, { from: from || undefined, to: to || undefined, schools, shops });
+      if (!data.kpis) return sendJson(res, 200, { empty: true });
+      const uploads = await store.listUploads().catch(() => []);
+      const report = buildReport(data, {
+        organization: schools.length === 1 ? schools[0] : (schools[0] || ''),
+        sourceFiles: uploads.slice(0, 3).map((u) => u.originalName),
+      });
+      sendJson(res, 200, { report });
+    } catch (e) {
+      sendJson(res, 503, { error: '报告生成失败', detail: e.message || String(e) });
+    }
+  });
+
   app.get('/api/ops/dashboard', async ({ res, query }) => {
     if (!(await requireHealthy(store, res))) return;
     const from = parseYmd(query.from);

+ 1 - 0
server/routes/static.js

@@ -20,6 +20,7 @@ const STATIC_MAP = {
   '/schools.js': ['schools.js', 'application/javascript; charset=utf-8'],
   '/multi-select.js': ['multi-select.js', 'application/javascript; charset=utf-8'],
   '/cockpit.js': ['cockpit.js', 'application/javascript; charset=utf-8'],
+  '/report.js': ['report.js', 'application/javascript; charset=utf-8'],
   '/vendor/china-map.json': ['vendor/china-map.json', 'application/json; charset=utf-8'],
   '/vendor/cn-universities.json': ['vendor/cn-universities.json', 'application/json; charset=utf-8'],
   '/vendor/cn-cities.json': ['vendor/cn-cities.json', 'application/json; charset=utf-8'],