Forráskód Böngészése

feat: 经营报告中心(保存 / 列表 / 查看 / 删除)+ 默认 AI 生成

- 新增 ops_report 表(SQLite/PG)与 4 个接口:保存、列表、查看、删除
- 报告页新增「保存报告」「历史报告」抽屉;保存的是完整 Report JSON 快照(含当时数值与 AI 文字)
- 打开报告页默认请求 AI 版本,失败自动退回规则版并在页脚标注原因
- 报告 ID 用 rpt_ 前缀 + 正则校验,防路径注入
- 提示词补充术语约束(revenue=营业额,不是收入/利润;日期用 YYYY-MM-DD)
- 测试:报告中心 CRUD 1 条、冒烟列表断言(48/48、114/114)
liangzhao 2 hete
szülő
commit
d6fab54216

+ 1 - 0
README.md

@@ -53,6 +53,7 @@
 - **重复校验**:平台明细按「日期 + 门店编号」判断——同一天同一店的相同数据自动跳过;内容有变化记为「冲突」不覆盖,勾选「覆盖已导入的文件」才覆盖。上传结果会显示「新增 N / 已存在 N / 冲突 N」。
 - **报表展示**:一张表、一行一店一天,列 = 店铺 / 日期 / 勾选的指标(勾几个显示几列),按学校分组并带每日小计。平台明细同样进入「店铺运营」报表——有效订单→订单量、营业额→营收、曝光人数→曝光量、进店转化率→进店转化率;同一天同一店两种来源都有时,以人工表为准(人工优先、平台补位)。
 - **经营驾驶舱**:一屏看经营大盘——6 个 KPI(营业额 / 有效订单 / 客单价 / 曝光人数 / 进店转化率 / 店铺评分,带环比)+ 营业额与订单趋势、门店营业额 Top10、转化漏斗(曝光→进店→下单)、收入结构(收入/支出/补贴)。支持日期、学校、店铺筛选,环比按「紧邻的等长区间」计算;下方「零单诊断」用平台字段区分未开业 / 当日闭店 / 无曝光 / 无进店 / 无转化。
+- **报告中心**:报告页可「保存报告」,历史列表支持按区间/来源查看与删除;保存的是完整 Report JSON 快照(含 AI 文字与当时数值),数据后续变化不影响已保存版本。
 - **解读与报告同源**:驾驶舱的「经营解读」是经营报告 JSON 的摘要视图(同一份生成结果、同一缓存),卡片上可一键跳到同区间的完整报告;报告页「重新生成」会调用 AI 补齐文字,模型不可用时自动退回规则版并标注。
 - **AI 经营解读**:点「生成解读」,把当前范围的 KPI、趋势、门店增减、漏斗与成本结构整理成上下文交给对话模型,输出 3~5 条结论与建议(引用具体数字);结果缓存 10 分钟,可复制。未配置对话模型时会明确提示去「系统设置 → API 设置」配置。
 - **经营报告**:按统一 Report JSON 渲染「图文报告 / 交互报告」两版,支持选择日期区间、重新生成、浏览器打印导出 PDF(A4 打印样式已适配)。报告含管理层摘要(带证据)、核心指标、经营/门店/流量/费用四节图表、风险与行动计划、数据质量与方法论。

+ 4 - 0
ops-store.js

@@ -123,6 +123,10 @@ function createOpsStore(config) {
     getContent: call('getContent'),
     queryRows: call('queryRows'),
     queryPlatformRows: call('queryPlatformRows'),
+    insertReport: call('insertReport'),
+    listReports: call('listReports'),
+    getReport: call('getReport'),
+    deleteReport: call('deleteReport'),
     summary: call('summary'),
     get failure() {
       return lastFailure;

+ 102 - 4
public/report.js

@@ -49,8 +49,8 @@
     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);
+  async function api(url, options) {
+    const res = await fetch(url, options);
     const body = await res.json().catch(() => null);
     return { res, body };
   }
@@ -72,11 +72,22 @@
           <button type="button" data-mode="interactive">交互报告</button>
         </div>
         <div class="rp-actions">
+          <button type="button" class="btn btn-ghost btn-small" id="rpArchive">历史报告</button>
+          <button type="button" class="btn btn-ghost btn-small" id="rpSave">保存报告</button>
           <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>`;
+      <article class="rp-page" id="rpPage"></article>
+      <div class="modal-overlay" id="rpHistoryModal" hidden>
+        <div class="modal rp-history-modal">
+          <div class="modal-header">
+            <div class="modal-title"><span>历史报告</span></div>
+            <button class="modal-close" id="rpHistoryClose" type="button" aria-label="关闭">×</button>
+          </div>
+          <div class="modal-body" id="rpHistoryList"></div>
+        </div>
+      </div>`;
     root.querySelectorAll('#rpTabs [data-mode]').forEach((btn) => {
       btn.addEventListener('click', () => {
         state.mode = btn.dataset.mode;
@@ -89,6 +100,9 @@
     $('rpTo').addEventListener('change', (e) => { state.to = String(e.target.value || '').replace(/-/g, ''); load(true); });
     $('rpRefresh').addEventListener('click', () => load(true));
     $('rpPrint').addEventListener('click', () => window.print());
+    $('rpSave').addEventListener('click', saveReport);
+    $('rpArchive').addEventListener('click', openHistory);
+    $('rpHistoryClose').addEventListener('click', () => { $('rpHistoryModal').hidden = true; });
   }
 
   function renderCover(report) {
@@ -327,6 +341,90 @@
     }
   }
 
+  // ---------- 报告中心 ----------
+
+  async function saveReport() {
+    const btn = $('rpSave');
+    btn.disabled = true;
+    btn.textContent = '保存中…';
+    try {
+      const { res, body } = await api('/api/ops/reports', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ from: state.from, to: state.to }),
+      });
+      if (!res.ok) throw new Error((body && (body.detail || body.error)) || ('HTTP ' + res.status));
+      btn.textContent = '已保存';
+      setTimeout(() => { btn.textContent = '保存报告'; btn.disabled = false; }, 1500);
+    } catch (e) {
+      btn.textContent = '保存报告';
+      btn.disabled = false;
+      alert('保存失败:' + (e.message || e));
+    }
+  }
+
+  async function openHistory() {
+    $('rpHistoryModal').hidden = false;
+    await renderHistory();
+  }
+
+  async function renderHistory() {
+    const list = $('rpHistoryList');
+    list.innerHTML = '<div class="rp-loading" style="padding:24px 0">加载中…</div>';
+    const { res, body } = await api('/api/ops/reports');
+    if (!res.ok) {
+      list.innerHTML = `<div class="rp-loading">读取失败:${esc((body && (body.error || body.detail)) || res.status)}</div>`;
+      return;
+    }
+    const reports = (body && body.reports) || [];
+    if (!reports.length) {
+      list.innerHTML = '<div class="rp-loading" style="padding:24px 0">还没有保存过报告</div>';
+      return;
+    }
+    list.innerHTML = `<table class="rp-table">
+      <thead><tr><th>保存时间</th><th>区间</th><th>来源</th><th>关键指标</th><th class="rp-col-actions">操作</th></tr></thead>
+      <tbody>${reports.map((r) => `<tr>
+        <td>${esc(new Date(r.createdAt).toLocaleString('zh-CN'))}</td>
+        <td>${esc(r.from)} ~ ${esc(r.to)}</td>
+        <td>${r.source === 'llm' ? 'AI 生成' : '规则生成'}</td>
+        <td class="rp-history-kpis">${(r.kpis || []).map((k) => `${esc(k.label)} ${esc(k.current)}`).join(' · ')}</td>
+        <td class="rp-col-actions">
+          <button type="button" class="btn btn-ghost btn-small" data-view="${esc(r.id)}">查看</button>
+          <button type="button" class="btn btn-ghost btn-small" data-del="${esc(r.id)}">删除</button>
+        </td>
+      </tr>`).join('')}</tbody>
+    </table>`;
+    list.querySelectorAll('[data-view]').forEach((btn) => {
+      btn.addEventListener('click', () => loadSaved(btn.dataset.view));
+    });
+    list.querySelectorAll('[data-del]').forEach((btn) => {
+      btn.addEventListener('click', async () => {
+        if (!confirm('删除这份历史报告?')) return;
+        btn.disabled = true;
+        await api('/api/ops/reports/' + encodeURIComponent(btn.dataset.del), { method: 'DELETE' });
+        renderHistory();
+      });
+    });
+  }
+
+  async function loadSaved(id) {
+    const { res, body } = await api('/api/ops/reports/' + encodeURIComponent(id));
+    if (!res.ok || !body || !body.report) {
+      alert('读取失败');
+      return;
+    }
+    const report = body.report;
+    state.report = report;
+    const from = String(report.meta.period_start || '').replace(/-/g, '');
+    const to = String(report.meta.period_end || '').replace(/-/g, '');
+    state.from = from;
+    state.to = to;
+    $('rpFrom').value = String(report.meta.period_start || '');
+    $('rpTo').value = String(report.meta.period_end || '');
+    renderReport(report);
+    $('rpHistoryModal').hidden = true;
+  }
+
   // ---------- 数据 ----------
 
   async function load(force) {
@@ -338,7 +436,7 @@
       const params = new URLSearchParams();
       if (state.from) params.set('from', state.from);
       if (state.to) params.set('to', state.to);
-      if (force) params.set('ai', '1');
+      params.set('ai', '1'); // 默认走 AI,失败自动退回规则版
       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) {

+ 6 - 0
public/style.css

@@ -1461,3 +1461,9 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 }
 .rp-range { display: flex; align-items: center; gap: 6px; margin-right: auto; }
 @media print { .rp-range { display: none !important; } }
+
+/* 报告中心 */
+.rp-history-modal { width: min(860px, 94vw); }
+.rp-history-kpis { color: var(--text-3); font-size: 11.5px; }
+.rp-col-actions { text-align: right; white-space: nowrap; }
+.rp-col-actions .btn { margin-left: 4px; }

+ 5 - 0
scripts/smoke.js

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

+ 21 - 0
scripts/test-ops-import.js

@@ -456,6 +456,27 @@ async function main() {
       expect(!!r.body.aiError, '应提示模型未配置');
     });
 
+    await check('E2E 报告中心:保存 / 列表 / 查看 / 删除', async () => {
+      const saved = await fetch(base + '/api/ops/reports', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({}),
+      }).then(readJson);
+      expect(saved.status === 200 && /^rpt_\d{8}-\d{6}-[a-z0-9]{6}$/.test(saved.body.id), `save ${saved.status} ${JSON.stringify(saved.body).slice(0, 120)}`);
+
+      const list = await fetch(base + '/api/ops/reports').then(readJson);
+      expect(list.status === 200 && list.body.reports.some((r) => r.id === saved.body.id), '列表缺少刚保存的报告');
+
+      const one = await fetch(base + '/api/ops/reports/' + saved.body.id).then(readJson);
+      expect(one.status === 200 && one.body.report && one.body.report.meta, '读取报告失败');
+
+      const del = await fetch(base + '/api/ops/reports/' + saved.body.id, { method: 'DELETE' }).then(readJson);
+      expect(del.status === 200 && del.body.removed === 1, `delete ${del.status}`);
+
+      const gone = await fetch(base + '/api/ops/reports/' + saved.body.id).then(readJson);
+      expect(gone.status === 404, `expect 404, got ${gone.status}`);
+    });
+
     await check('E2E 导入记录只含元数据', async () => {
       const r = await fetch(base + '/api/ops/uploads').then(readJson);
       expect(r.body.uploads.length >= 1, '记录为空');

+ 67 - 0
server/routes/ops.js

@@ -771,6 +771,7 @@ const REPORT_AI_PROMPT = [
   '要求:3~5 条 findings、2~4 条 alerts、1~4 条 action;金额保留两位小数,比率用百分比;',
   'zeroOrder 非空时必须按原因归类(未开业/闭店/无曝光/无进店/无转化各多少店次)并点明哪类需要运营介入;',
   '没有上期数据时明确说明不可做趋势判断;不输出「赋能」「抓手」等套话。',
+  '术语必须与指标名一致:revenue 是「营业额」(不是收入/结算收入/利润),avgTicket 是「客单价」,conversion 是「进店转化率」;日期一律写成 YYYY-MM-DD。',
 ].join('\n');
 
 /** 把模型返回的叙述字段覆盖到规则版报告上;结构不合法就返回 null(保留规则版) */
@@ -1218,6 +1219,72 @@ function register(app, ctx) {
     }
   });
 
+  // ---------- 报告中心(保存 / 列表 / 查看 / 删除) ----------
+
+  app.post('/api/ops/reports', async ({ req, res }) => {
+    if (!(await requireHealthy(store, res))) return;
+    const body = await readBody(req);
+    const params = {
+      from: parseYmd(body.from) || undefined,
+      to: parseYmd(body.to) || undefined,
+      schools: splitList(body.schools),
+      shops: splitList(body.shops),
+    };
+    try {
+      const report = await getReportPayload(params, { ai: false });
+      if (report.empty) return sendJson(res, 400, { error: '所选范围没有数据,无法保存' });
+      const id = `rpt_${newUploadId()}`;
+      const record = {
+        id,
+        createdAt: new Date().toISOString(),
+        periodFrom: String(report.meta.period_start || '').replace(/-/g, ''),
+        periodTo: String(report.meta.period_end || '').replace(/-/g, ''),
+        schools: params.schools,
+        shops: params.shops,
+        source: (report.ai && report.ai.source) || 'rules',
+        model: (report.ai && report.ai.model) || '',
+        title: report.meta.title || '经营分析报告',
+        report,
+      };
+      await store.insertReport(record);
+      sendJson(res, 200, { ok: true, id, createdAt: record.createdAt, source: record.source });
+    } catch (e) {
+      sendJson(res, 500, { error: '保存失败', detail: e.message || String(e) });
+    }
+  });
+
+  app.get('/api/ops/reports', async ({ res }) => {
+    if (!(await requireHealthy(store, res))) return;
+    try {
+      sendJson(res, 200, { reports: await store.listReports() });
+    } catch (e) {
+      sendJson(res, 503, { error: '读取失败', detail: e.message || String(e) });
+    }
+  });
+
+  app.get('/api/ops/reports/:id', async ({ res, params }) => {
+    if (!/^rpt_\d{8}-\d{6}-[a-z0-9]{6}$/.test(params.id)) return sendJson(res, 400, { error: '非法的报告 ID' });
+    if (!(await requireHealthy(store, res))) return;
+    try {
+      const report = await store.getReport(params.id);
+      if (!report) return sendJson(res, 404, { error: '报告不存在' });
+      sendJson(res, 200, { report });
+    } catch (e) {
+      sendJson(res, 503, { error: '读取失败', detail: e.message || String(e) });
+    }
+  });
+
+  app.del('/api/ops/reports/:id', async ({ res, params }) => {
+    if (!/^rpt_\d{8}-\d{6}-[a-z0-9]{6}$/.test(params.id)) return sendJson(res, 400, { error: '非法的报告 ID' });
+    if (!(await requireHealthy(store, res))) return;
+    try {
+      const removed = await store.deleteReport(params.id);
+      sendJson(res, 200, { ok: true, removed });
+    } catch (e) {
+      sendJson(res, 500, { 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);

+ 52 - 0
server/stores/ops-pg.js

@@ -68,6 +68,19 @@ const DDL = [
      PRIMARY KEY (date, shop_id)
    )`,
   `CREATE INDEX IF NOT EXISTS ops_platform_row_date_idx ON ops_platform_row (date)`,
+  `CREATE TABLE IF NOT EXISTS ops_report (
+     id text PRIMARY KEY,
+     created_at timestamptz NOT NULL,
+     period_from char(8) NOT NULL,
+     period_to char(8) NOT NULL,
+     schools jsonb NOT NULL DEFAULT '[]'::jsonb,
+     shops jsonb NOT NULL DEFAULT '[]'::jsonb,
+     source text NOT NULL DEFAULT 'rules',
+     model text NOT NULL DEFAULT '',
+     title text NOT NULL DEFAULT '',
+     report jsonb NOT NULL
+   )`,
+  `CREATE INDEX IF NOT EXISTS ops_report_created_idx ON ops_report (created_at DESC)`,
 ];
 
 function rowToApi(row) {
@@ -354,6 +367,41 @@ function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public
     };
   }
 
+  async function insertReport(record) {
+    await getPool().query(
+      `INSERT INTO ops_report (id, created_at, period_from, period_to, schools, shops, source, model, title, report)
+       VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
+      [record.id, record.createdAt, record.periodFrom, record.periodTo,
+        JSON.stringify(record.schools || []), JSON.stringify(record.shops || []),
+        record.source || 'rules', record.model || '', record.title || '', JSON.stringify(record.report)],
+    );
+  }
+
+  async function listReports() {
+    const r = await getPool().query(`SELECT id, created_at, period_from, period_to, source, model, title, report
+      FROM ops_report ORDER BY created_at DESC LIMIT 100`);
+    return r.rows.map((row) => ({
+      id: row.id,
+      createdAt: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
+      from: String(row.period_from).trim(),
+      to: String(row.period_to).trim(),
+      source: row.source,
+      model: row.model,
+      title: row.title,
+      kpis: ((row.report && row.report.kpis) || []).slice(0, 3).map((k) => ({ label: k.label, current: k.current, unit: k.unit })),
+    }));
+  }
+
+  async function getReport(id) {
+    const r = await getPool().query('SELECT report FROM ops_report WHERE id = $1', [id]);
+    return r.rows.length ? r.rows[0].report : null;
+  }
+
+  async function deleteReport(id) {
+    const r = await getPool().query('DELETE FROM ops_report WHERE id = $1', [id]);
+    return r.rowCount;
+  }
+
   async function close() {
     if (pool) {
       try { await pool.end(); } catch { /* ignore */ }
@@ -369,6 +417,10 @@ function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public
     withTransaction,
     findUploadBySha,
     queryPlatformRows,
+    insertReport,
+    listReports,
+    getReport,
+    deleteReport,
     listUploads,
     getUpload,
     getContent,

+ 54 - 0
server/stores/ops-sqlite.js

@@ -69,6 +69,19 @@ const DDL = [
      PRIMARY KEY (date, shop_id)
    )`,
   `CREATE INDEX IF NOT EXISTS ops_platform_row_date_idx ON ops_platform_row (date)`,
+  `CREATE TABLE IF NOT EXISTS ops_report (
+     id TEXT PRIMARY KEY,
+     created_at TEXT NOT NULL,
+     period_from TEXT NOT NULL,
+     period_to TEXT NOT NULL,
+     schools TEXT NOT NULL DEFAULT '[]',
+     shops TEXT NOT NULL DEFAULT '[]',
+     source TEXT NOT NULL DEFAULT 'rules',
+     model TEXT NOT NULL DEFAULT '',
+     title TEXT NOT NULL DEFAULT '',
+     report TEXT NOT NULL
+   )`,
+  `CREATE INDEX IF NOT EXISTS ops_report_created_idx ON ops_report (created_at DESC)`,
 ];
 
 function rowToApi(row) {
@@ -387,6 +400,43 @@ function createSqliteStore({ file }) {
     };
   }
 
+  async function insertReport(record) {
+    open().prepare(`INSERT INTO ops_report
+      (id, created_at, period_from, period_to, schools, shops, source, model, title, report)
+      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
+      record.id, record.createdAt, record.periodFrom, record.periodTo,
+      JSON.stringify(record.schools || []), JSON.stringify(record.shops || []),
+      record.source || 'rules', record.model || '', record.title || '',
+      JSON.stringify(record.report),
+    );
+  }
+
+  async function listReports() {
+    return open().prepare(`SELECT id, created_at, period_from, period_to, source, model, title, report
+      FROM ops_report ORDER BY created_at DESC LIMIT 100`).all().map((row) => {
+      const report = safeJson(row.report, {});
+      return {
+        id: row.id,
+        createdAt: row.created_at,
+        from: row.period_from,
+        to: row.period_to,
+        source: row.source,
+        model: row.model,
+        title: row.title,
+        kpis: (report.kpis || []).slice(0, 3).map((k) => ({ label: k.label, current: k.current, unit: k.unit })),
+      };
+    });
+  }
+
+  async function getReport(id) {
+    const row = open().prepare('SELECT report FROM ops_report WHERE id = ?').get(id);
+    return row ? safeJson(row.report, null) : null;
+  }
+
+  async function deleteReport(id) {
+    return open().prepare('DELETE FROM ops_report WHERE id = ?').run(id).changes;
+  }
+
   async function close() {
     if (db) {
       try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* ignore */ }
@@ -409,6 +459,10 @@ function createSqliteStore({ file }) {
     updateReport,
     findUploadBySha,
     queryPlatformRows,
+    insertReport,
+    listReports,
+    getReport,
+    deleteReport,
     listUploads,
     getUpload,
     getContent,