Quellcode durchsuchen

feat: 趋势图表分析模板库独立文件 + 同行对标 bench 接口与冒烟覆盖

KenZ1117 vor 2 Wochen
Ursprung
Commit
1eba05bf6d
6 geänderte Dateien mit 1130 neuen und 3 gelöschten Zeilen
  1. 1 1
      napos-service.js
  2. 1083 0
      public/analysis-templates.js
  3. 1 1
      public/reports.js
  4. 11 1
      scripts/smoke.js
  5. 33 0
      server/routes/reports.js
  6. 1 0
      server/routes/static.js

+ 1 - 1
napos-service.js

@@ -510,7 +510,7 @@ function rowsToCsv(columns, rows) {
 }
 
 module.exports = {
-  DEFAULT_CDP_PORT, REPORT_COLUMNS,
+  DEFAULT_CDP_PORT, REPORT_COLUMNS, FIELD_MAP,
   naposStatus, fetchTree,
   runSync, syncSnapshot, requestSyncCancel,
   queryReport, aggregateReport, summarizeRows,

+ 1083 - 0
public/analysis-templates.js

@@ -0,0 +1,1083 @@
+'use strict';
+
+/**
+ * 趋势图表 · 分析模板库
+ * ----------------------------------------------------------------------------
+ * 与页面主逻辑(reports.js)解耦:本文件只定义「模板 = 对比方式 + 指标组合 +
+ * 图表布局」的预设。模板与看谁(整校/某食堂/某档口)正交,对象由主体选择器
+ * 与门店筛选决定。
+ *
+ * 依赖注入:reports.js 调用 shanAnalysisTemplates(kit) 取回注册表。kit 提供
+ * 格式化 / 面板与图表构造 / 逐日序列与数据质量标注 / 汇总辅助 / 颜色常量,
+ * 以及当前主指标 metric() 与「对比上期」开关 comparePrev() 的读取函数。
+ *
+ * 新增模板:在此追加 render 函数并往返回数组注册一项,reports.js 无需改动。
+ */
+(function () {
+  window.shanAnalysisTemplates = function (k) {
+    const {
+      chartCol, metric, comparePrev,
+      fmtNum, fmtMoney, fmtPct, fmtByCol, fmtCompact, ymdToDash, ymdDate, shiftYmd,
+      makePanel, panelChart, panelNote, panelEmpty, panelEmptyIn,
+      buildDailySeries, missingMarkAreas, hollowPoints, dataQualityNote,
+      sumField, wAvgField, deltaBadge, rangeOverlapsVacation,
+      benchData, crossData, shopFilterCount,
+      RATE_THRESHOLDS, AXIS_TXT, SPLIT_LINE,
+      ACCENT, COLOR_OK, COLOR_FAIL, COLOR_INFO, COLOR_PREV,
+    } = k;
+
+    const WEEKDAY_CN = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
+
+    // ---------- 模板一:综合总览(原有四图,保持默认行为) ----------
+
+    function renderOverviewTpl(grid, data) {
+      const chartMetric = metric();
+      const col = chartCol(chartMetric);
+      const compareOn = comparePrev();
+      const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
+
+      const trendPanel = makePanel(grid, '', 2);
+      const trendTitle = document.createElement('span');
+      trendTitle.textContent = `主指标趋势 · ${col.label}(${ymdToDash(data.from)} ~ ${ymdToDash(data.to)})`;
+      trendPanel.head.appendChild(trendTitle);
+      dataQualityNote(trendPanel, buildDailySeries(data, chartMetric));
+      const trend = panelChart(trendPanel, 'trend');
+      if (trend) {
+        const series = buildDailySeries(data, chartMetric);
+        const xData = series.dates.map(ymdToDash);
+        const lines = [{
+          name: `本期·${col.label}`, type: 'line', smooth: true, symbolSize: 5,
+          data: hollowPoints(series),
+          itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 },
+          areaStyle: { color: 'rgba(255,107,53,.08)' },
+        }];
+        const areas = missingMarkAreas(series);
+        if (areas.length) lines[0].markArea = { silent: true, data: areas };
+        if (compareOn && prevSorted.length) {
+          lines.push({
+            name: '上期同期', type: 'line', smooth: true, symbol: 'none',
+            data: series.dates.map((_, i) => (prevSorted[i] ? Number(prevSorted[i][chartMetric]) || 0 : null)),
+            itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.6 },
+          });
+        }
+        trend.setOption({
+          tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+          legend: { data: lines.map((x) => x.name), top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+          grid: { left: 64, right: 24, top: 36, bottom: 30 },
+          xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
+          yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+          series: lines,
+        }, true);
+      }
+
+      const rankPanel = makePanel(grid, '门店排行 Top 10', 1);
+      const rank = panelChart(rankPanel, 'rank');
+      if (rank) {
+        const top = data.byShop.slice()
+          .sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
+          .slice(0, 10).reverse();
+        rank.setOption({
+          tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+          grid: { left: 10, right: 34, top: 10, bottom: 24, containLabel: true },
+          xAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+          yAxis: {
+            type: 'category', data: top.map((r) => r.shopName || r.shopId),
+            axisLabel: { color: '#2b2a28', width: 130, overflow: 'truncate' },
+            axisLine: { lineStyle: { color: '#e8e4df' } },
+          },
+          series: [{ type: 'bar', barMaxWidth: 18, data: top.map((r) => Number(r[chartMetric]) || 0), itemStyle: { color: ACCENT, borderRadius: [0, 6, 6, 0] } }],
+        }, true);
+      }
+
+      const funnelPanel = makePanel(grid, '转化漏斗(曝光 → 进店 → 下单)', 1);
+      const funnel = panelChart(funnelPanel, 'funnel');
+      if (funnel) {
+        const expUv = sumField(data.byDate, 'expUv');
+        const enter = data.byDate.reduce((a, r) => a + (Number(r.expUv) || 0) * (Number(r.clkExpRate) || 0), 0);
+        const order = sumField(data.byDate, 'orderUserCnt');
+        funnel.setOption({
+          tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${fmtNum(Math.round(p.value))} 人` },
+          series: [{
+            type: 'funnel', left: '8%', width: '84%', top: 12, bottom: 8, minSize: '20%',
+            label: { color: '#fff', fontWeight: 600, formatter: (p) => `${p.name}  ${fmtNum(Math.round(p.value))}` },
+            data: [
+              { name: '曝光人数', value: Math.round(expUv), itemStyle: { color: '#ffb199' } },
+              { name: '进店人数', value: Math.round(enter), itemStyle: { color: '#ff8a5c' } },
+              { name: '下单人数', value: Math.round(order), itemStyle: { color: ACCENT } },
+            ],
+          }],
+        }, true);
+      }
+
+      const comparePanel = makePanel(grid, '', 2);
+      const compareTitle = document.createElement('span');
+      comparePanel.head.appendChild(compareTitle);
+      const compare = panelChart(comparePanel, 'compare');
+      const top5 = data.byShop.slice()
+        .sort((a, b) => (Number(b[chartMetric]) || 0) - (Number(a[chartMetric]) || 0))
+        .slice(0, 5);
+      compareTitle.textContent = `门店对比 · Top ${top5.length}(${col.label})`;
+      if (compare && top5.length) {
+        const dates = [...new Set(data.topDaily.map((r) => r.date))].sort();
+        const palette = ['#ff6b35', '#2563eb', '#1a8a4c', '#b8860b', '#8b5cf6'];
+        const series = top5.map((shop, i) => ({
+          name: shop.shopName || shop.shopId,
+          type: 'line', smooth: true, symbolSize: 4,
+          data: dates.map((d) => {
+            const r = data.topDaily.find((x) => x.date === d && String(x.shopId) === String(shop.shopId));
+            return r ? (Number(r[chartMetric]) || 0) : 0;
+          }),
+          itemStyle: { color: palette[i % palette.length] }, lineStyle: { width: 2 },
+        }));
+        compare.setOption({
+          tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+          legend: { top: 0, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+          grid: { left: 64, right: 24, top: 36, bottom: 30 },
+          xAxis: { type: 'category', boundaryGap: false, data: dates.map(ymdToDash), ...AXIS_TXT },
+          yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+          series,
+        }, true);
+      }
+    }
+
+    // ---------- 模板二:环比对照 ----------
+
+    function buildMomTable(data) {
+      const cur = data.byDate;
+      const prev = data.byDatePrev;
+      const curO = sumField(cur, 'validOrderCnt');
+      const prevO = sumField(prev, 'validOrderCnt');
+      const defs = [
+        { label: '营业额', cur: sumField(cur, 'grossAmt'), prev: sumField(prev, 'grossAmt'), fmt: fmtMoney },
+        { label: '收入', cur: sumField(cur, 'incomeAmt'), prev: sumField(prev, 'incomeAmt'), fmt: fmtMoney },
+        { label: '支出', cur: sumField(cur, 'expenseAmt'), prev: sumField(prev, 'expenseAmt'), fmt: fmtMoney },
+        { label: '有效订单', cur: curO, prev: prevO, fmt: fmtNum },
+        { label: '曝光人数', cur: sumField(cur, 'expUv'), prev: sumField(prev, 'expUv'), fmt: fmtNum },
+        { label: '下单人数', cur: sumField(cur, 'orderUserCnt'), prev: sumField(prev, 'orderUserCnt'), fmt: fmtNum },
+        { label: '客单价', cur: curO > 0 ? sumField(cur, 'grossAmt') / curO : 0, prev: prevO > 0 ? sumField(prev, 'grossAmt') / prevO : 0, fmt: fmtMoney },
+        { label: '进店转化率', cur: wAvgField(cur, 'clkExpRate', 'expUv'), prev: wAvgField(prev, 'clkExpRate', 'expUv'), fmt: fmtPct },
+        { label: '下单转化率', cur: wAvgField(cur, 'ordClkRate', 'orderUserCnt'), prev: wAvgField(prev, 'ordClkRate', 'orderUserCnt'), fmt: fmtPct },
+        { label: '活动订单', cur: sumField(cur, 'actOrdCnt'), prev: sumField(prev, 'actOrdCnt'), fmt: fmtNum },
+        { label: '活动补贴', cur: sumField(cur, 'actSubsidyAmt'), prev: sumField(prev, 'actSubsidyAmt'), fmt: fmtMoney },
+        { label: '30日复购率', cur: wAvgField(cur, 'rebuyRate30d', 'orderUserCnt'), prev: wAvgField(prev, 'rebuyRate30d', 'orderUserCnt'), fmt: fmtPct },
+      ];
+      const rowsHtml = defs.map((d) => `<tr>
+        <td>${d.label}</td>
+        <td>${d.fmt(Math.round(d.cur * 100) / 100)}</td>
+        <td>${d.fmt(Math.round(d.prev * 100) / 100)}</td>
+        <td>${deltaBadge(d.cur, d.prev)}</td>
+      </tr>`).join('');
+      return `<table class="ana-table">
+        <thead><tr><th>指标</th><th>本期</th><th>上期</th><th>环比</th></tr></thead>
+        <tbody>${rowsHtml}</tbody></table>`;
+    }
+
+    function renderMomTpl(grid, data) {
+      const chartMetric = metric();
+      const col = chartCol(chartMetric);
+      const vacNames = [...new Set([...rangeOverlapsVacation(data.from, data.to), ...rangeOverlapsVacation(data.prevFrom, data.prevTo)])];
+      const series = buildDailySeries(data, chartMetric);
+      const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
+      const prevVals = series.dates.map((_, i) => (prevSorted[i] ? Number(prevSorted[i][chartMetric]) || 0 : null));
+      const diffs = series.values.map((v, i) => (v == null || prevVals[i] == null ? null : +(v - prevVals[i]).toFixed(2)));
+
+      const head = makePanel(grid, '', 2);
+      const title = document.createElement('span');
+      title.textContent = `环比对照 · ${col.label}(本期 ${ymdToDash(data.from)}~${ymdToDash(data.to)} vs 上期 ${ymdToDash(data.prevFrom)}~${ymdToDash(data.prevTo)})`;
+      head.head.appendChild(title);
+      if (vacNames.length) {
+        const chip = document.createElement('span');
+        chip.className = 'badge badge-warn';
+        chip.textContent = `对比区间跨${vacNames.join('、')}`;
+        chip.title = '假期前后环比受学期节奏影响(寒/暑假营业基数骤降),涨跌请结合更长区间或去年同期判断';
+        head.head.appendChild(chip);
+      }
+      dataQualityNote(head, series);
+      const chart = panelChart(head, 'momTrend');
+      if (chart) {
+        const xData = series.dates.map(ymdToDash);
+        const lines = [
+          { name: `本期·${col.label}`, type: 'line', smooth: true, symbolSize: 5, data: hollowPoints(series), itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 } },
+          { name: '上期同期', type: 'line', smooth: true, symbol: 'none', data: prevVals, itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.6 } },
+        ];
+        const areas = missingMarkAreas(series);
+        if (areas.length) lines[0].markArea = { silent: true, data: areas };
+        chart.setOption({
+          tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+          legend: { data: [...lines.map((x) => x.name), '逐日差值'], top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+          grid: { left: 64, right: 64, top: 36, bottom: 30 },
+          xAxis: { type: 'category', data: xData, ...AXIS_TXT },
+          yAxis: [
+            { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+            { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: { show: false } },
+          ],
+          series: [
+            ...lines,
+            {
+              name: '逐日差值', type: 'bar', yAxisIndex: 1, barMaxWidth: 10,
+              data: diffs.map((v) => (v == null ? null : { value: v, itemStyle: { color: v >= 0 ? 'rgba(26,138,76,.55)' : 'rgba(200,64,46,.55)' } })),
+            },
+          ],
+        }, true);
+      }
+
+      const tbl = makePanel(grid, '指标环比一览', 1);
+      const wrap = document.createElement('div');
+      wrap.className = 'ana-table-scroll';
+      wrap.innerHTML = buildMomTable(data);
+      tbl.cell.appendChild(wrap);
+    }
+
+    // ---------- 模板三:量价归因 ----------
+    // 营业额 = 曝光人数 × 进店转化 × 下单转化 × 人均单量 × 客单价(乘积链严格闭合)
+    // 贡献拆解用 LMDI(对数平均迪氏分解):各因子贡献之和恰好等于总变化,无残差。
+
+    function attrFactors(rows) {
+      const E = sumField(rows, 'expUv');
+      const enter = rows.reduce((a, r) => a + (Number(r.expUv) || 0) * (Number(r.clkExpRate) || 0), 0);
+      const U = sumField(rows, 'orderUserCnt');
+      const O = sumField(rows, 'validOrderCnt');
+      const G = sumField(rows, 'grossAmt');
+      return { E, C1: E > 0 ? enter / E : 0, C2: enter > 0 ? U / enter : 0, F: U > 0 ? O / U : 0, P: O > 0 ? G / O : 0, G };
+    }
+
+    function logMean(a, b) {
+      if (a <= 0 || b <= 0) return (a + b) / 2;
+      return a === b ? a : (a - b) / (Math.log(a) - Math.log(b));
+    }
+
+    function renderAttributionTpl(grid, data) {
+      const f0 = attrFactors(data.byDatePrev);
+      const f1 = attrFactors(data.byDate);
+      const factors = [
+        { label: '流量·曝光人数', f0: f0.E, f1: f1.E, fmt: fmtNum },
+        { label: '进店转化率', f0: f0.C1, f1: f1.C1, fmt: fmtPct },
+        { label: '下单转化率', f0: f0.C2, f1: f1.C2, fmt: fmtPct },
+        { label: '人均单量', f0: f0.F, f1: f1.F, fmt: (v) => Number(v).toFixed(2) },
+        { label: '客单价', f0: f0.P, f1: f1.P, fmt: fmtMoney },
+      ];
+      const decomposable = f0.G > 0 && f1.G > 0 && factors.every((x) => x.f0 > 0 && x.f1 > 0);
+      if (decomposable) {
+        const L = logMean(f1.G, f0.G);
+        for (const x of factors) x.contrib = L * Math.log(x.f1 / x.f0);
+      }
+
+      const panel = makePanel(grid, '', 2);
+      const title = document.createElement('span');
+      title.textContent = `量价归因 · 营业额(本期 ${ymdToDash(data.from)}~${ymdToDash(data.to)} vs 上期)`;
+      panel.head.appendChild(title);
+      const chart = panelChart(panel, 'attrWf');
+      if (chart) {
+        if (decomposable) {
+          const cats = ['上期营业额', ...factors.map((x) => x.label), '本期营业额'];
+          let cum = f0.G;
+          const bases = [0];
+          const bars = [{ value: +f0.G.toFixed(2), itemStyle: { color: COLOR_PREV }, label: { show: true, position: 'top', fontSize: 10, color: '#6b6560', formatter: fmtCompact(f0.G) } }];
+          for (const x of factors) {
+            const c = x.contrib;
+            bases.push(+Math.min(cum, cum + c).toFixed(2));
+            bars.push({
+              value: +c.toFixed(2),
+              itemStyle: { color: c >= 0 ? COLOR_OK : COLOR_FAIL },
+              // 贡献柱相对总额很细,靠标签读数(精确值见右侧因子明细表)
+              label: { show: true, position: 'top', fontSize: 10, color: c >= 0 ? COLOR_OK : COLOR_FAIL, formatter: (c >= 0 ? '+' : '') + fmtCompact(c) },
+            });
+            cum += c;
+          }
+          bases.push(0);
+          bars.push({ value: +f1.G.toFixed(2), itemStyle: { color: ACCENT }, label: { show: true, position: 'top', fontSize: 11, color: '#2b2a28', fontWeight: 600, formatter: fmtCompact(f1.G) } });
+          chart.setOption({
+            tooltip: {
+              trigger: 'axis',
+              formatter: (ps) => {
+                const p = ps.find((x) => x.seriesName === '贡献');
+                if (!p) return '';
+                const v = Number(p.value);
+                if (p.dataIndex === 0 || p.dataIndex === cats.length - 1) return `${cats[p.dataIndex]}:${fmtMoney(v)} 元`;
+                return `${cats[p.dataIndex]}:贡献 ${v >= 0 ? '+' : ''}${fmtMoney(v)} 元`;
+              },
+            },
+            grid: { left: 72, right: 20, top: 34, bottom: 52 },
+            xAxis: { type: 'category', data: cats, ...AXIS_TXT, axisLabel: { ...AXIS_TXT.axisLabel, interval: 0, rotate: 18 } },
+            yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+            series: [
+              { name: '底', type: 'bar', stack: 'wf', barWidth: 34, itemStyle: { color: 'transparent' }, emphasis: { itemStyle: { color: 'transparent' } }, tooltip: { show: false }, data: bases },
+              { name: '贡献', type: 'bar', stack: 'wf', barWidth: 34, data: bars },
+            ],
+          }, true);
+        } else {
+          chart.setOption({
+            tooltip: { trigger: 'axis', valueFormatter: (v) => fmtMoney(v) + ' 元' },
+            grid: { left: 72, right: 20, top: 30, bottom: 30 },
+            xAxis: { type: 'category', data: ['上期营业额', '本期营业额'], ...AXIS_TXT },
+            yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+            series: [{ type: 'bar', barWidth: 50, data: [
+              { value: +f0.G.toFixed(2), itemStyle: { color: COLOR_PREV } },
+              { value: +f1.G.toFixed(2), itemStyle: { color: ACCENT } },
+            ] }],
+          }, true);
+          panelNote(panel, '上期或本期存在零值因子,无法做乘法拆解,仅展示两期总额');
+        }
+      }
+
+      const tbl = makePanel(grid, '因子明细', 1);
+      const wrap = document.createElement('div');
+      wrap.className = 'ana-table-scroll';
+      const dG = f1.G - f0.G;
+      const rowsHtml = factors.map((x) => {
+        const chg = x.f0 > 0 ? (x.f1 - x.f0) / x.f0 : null;
+        const contrib = decomposable ? x.contrib : null;
+        return `<tr>
+          <td>${x.label}</td>
+          <td>${x.fmt(x.f0)}</td>
+          <td>${x.fmt(x.f1)}</td>
+          <td>${chg == null ? '—' : `<span class="delta ${chg >= 0 ? 'up' : 'down'}">${chg >= 0 ? '↑' : '↓'} ${Math.abs(chg * 100).toFixed(1)}%</span>`}</td>
+          <td>${contrib == null ? '—' : (contrib >= 0 ? '+' : '') + fmtMoney(contrib)}</td>
+          <td>${contrib == null || !dG ? '—' : Math.round(Math.abs(contrib / dG) * 100) + '%'}</td>
+        </tr>`;
+      }).join('');
+      wrap.innerHTML = `<table class="ana-table">
+        <thead><tr><th>因子</th><th>上期</th><th>本期</th><th>变化</th><th>贡献(元)</th><th>占Δ</th></tr></thead>
+        <tbody>${rowsHtml}
+        <tr><td><b>营业额</b></td><td>${fmtMoney(f0.G)}</td><td>${fmtMoney(f1.G)}</td>
+        <td>${deltaBadge(f1.G, f0.G)}</td>
+        <td>${dG >= 0 ? '+' : ''}${fmtMoney(dG)}</td><td>100%</td></tr></tbody></table>`;
+      tbl.cell.appendChild(wrap);
+    }
+
+    // ---------- 模板四:档口问责 ----------
+
+    function renderAccountabilityTpl(grid, data) {
+      const chartMetric = metric();
+      const col = chartCol(chartMetric);
+      const curMap = new Map(data.byShop.map((r) => [String(r.shopId), r]));
+      const prevMap = new Map(data.byShopPrev.map((r) => [String(r.shopId), r]));
+      const shops = [...new Set([...curMap.keys(), ...prevMap.keys()])].map((key) => {
+        const cur = curMap.get(key) || null;
+        const prev = prevMap.get(key) || null;
+        const curV = cur ? Number(cur[chartMetric]) || 0 : null;
+        const prevV = prev ? Number(prev[chartMetric]) || 0 : null;
+        return {
+          key,
+          name: (cur && cur.shopName) || (prev && prev.shopName) || key,
+          curV, prevV,
+          delta: curV != null && prevV != null ? curV - prevV : null,
+          pay: Number((cur || prev || {}).perActualPay) || 0,
+        };
+      });
+      if (!shops.length) {
+        panelEmpty(grid, '暂无档口明细数据(需按门店粒度同步)', 3);
+        return;
+      }
+      const hasPrev = shops.some((s) => (s.prevV || 0) > 0);
+      const newShops = shops.filter((s) => s.prevV == null && s.curV != null).length;
+
+      // 涨跌榜(按 Δ 排序,而非绝对值——直接回答"谁在涨谁在跌")
+      const rankPanel = makePanel(grid, hasPrev ? `档口涨跌榜 · ${col.label} 对上期` : `档口排行 · ${col.label}`, 1);
+      if (!hasPrev) panelNote(rankPanel, '上期无有效数据,暂展示本期排行');
+      else if (newShops) panelNote(rankPanel, `${newShops} 家档口上期无数据(新增),未纳入涨跌对比`);
+      const rank = panelChart(rankPanel, 'accDelta');
+      if (rank) {
+        if (hasPrev) {
+          let list = shops.filter((s) => s.delta != null).sort((a, b) => b.delta - a.delta);
+          if (list.length > 14) list = [...list.slice(0, 7), ...list.slice(-7)];
+          const ordered = list.slice().reverse(); // echarts category 第 0 项在底部
+          rank.setOption({
+            tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+            grid: { left: 10, right: 34, top: 10, bottom: 24, containLabel: true },
+            xAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+            yAxis: {
+              type: 'category', data: ordered.map((s) => s.name),
+              axisLabel: { color: '#2b2a28', width: 96, overflow: 'truncate' },
+              axisLine: { lineStyle: { color: '#e8e4df' } },
+            },
+            series: [{
+              type: 'bar', barMaxWidth: 14,
+              data: ordered.map((s) => ({
+                value: +s.delta.toFixed(2),
+                itemStyle: { color: s.delta >= 0 ? COLOR_OK : COLOR_FAIL, borderRadius: s.delta >= 0 ? [0, 6, 6, 0] : [6, 0, 0, 6] },
+              })),
+            }],
+          }, true);
+        } else {
+          const top = shops.filter((s) => s.curV != null).sort((a, b) => b.curV - a.curV).slice(0, 10).reverse();
+          rank.setOption({
+            tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+            grid: { left: 10, right: 34, top: 10, bottom: 24, containLabel: true },
+            xAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+            yAxis: {
+              type: 'category', data: top.map((s) => s.name),
+              axisLabel: { color: '#2b2a28', width: 96, overflow: 'truncate' },
+              axisLine: { lineStyle: { color: '#e8e4df' } },
+            },
+            series: [{ type: 'bar', barMaxWidth: 14, data: top.map((s) => +s.curV.toFixed(2)), itemStyle: { color: ACCENT, borderRadius: [0, 6, 6, 0] } }],
+          }, true);
+        }
+      }
+
+      // 帕累托:档口贡献 + 累计占比(80/20 线)
+      const parPanel = makePanel(grid, `档口贡献帕累托 · ${col.label}`, 2);
+      const par = panelChart(parPanel, 'accPareto');
+      if (par) {
+        const sorted = shops.filter((s) => (s.curV || 0) > 0).sort((a, b) => b.curV - a.curV);
+        const total = sorted.reduce((a, s) => a + s.curV, 0);
+        let cum = 0;
+        const cumPct = sorted.map((s) => { cum += s.curV; return total > 0 ? +(cum / total * 100).toFixed(1) : 0; });
+        par.setOption({
+          tooltip: {
+            trigger: 'axis',
+            formatter: (ps) => {
+              const idx = ps[0].dataIndex;
+              return `<b>${sorted[idx].name}</b><br>${col.label}:${fmtByCol(col, sorted[idx].curV)}<br>累计占比:${cumPct[idx]}%`;
+            },
+          },
+          grid: { left: 10, right: 40, top: 30, bottom: 64, containLabel: true },
+          xAxis: { type: 'category', data: sorted.map((s) => s.name), ...AXIS_TXT, axisLabel: { ...AXIS_TXT.axisLabel, interval: 0, rotate: 26, width: 100, overflow: 'truncate' } },
+          yAxis: [
+            { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+            { type: 'value', max: 100, axisLabel: { color: '#6b6560', formatter: '{value}%' }, splitLine: { show: false } },
+          ],
+          series: [
+            { name: col.label, type: 'bar', barMaxWidth: 18, data: sorted.map((s) => +s.curV.toFixed(2)), itemStyle: { color: ACCENT, borderRadius: [6, 6, 0, 0] } },
+            {
+              name: '累计占比', type: 'line', yAxisIndex: 1, data: cumPct, symbolSize: 4,
+              itemStyle: { color: COLOR_INFO }, lineStyle: { width: 1.8 },
+              markLine: { symbol: 'none', data: [{ yAxis: 80 }], lineStyle: { type: 'dashed', color: COLOR_PREV }, label: { formatter: '80%', color: '#6b6560' } },
+            },
+          ],
+        }, true);
+      }
+
+      // 四象限:体量 × 环比增速,气泡 = 客单价
+      const quadPanel = makePanel(grid, '档口四象限(体量 × 环比增速)', 2);
+      const pts = shops.filter((s) => s.prevV != null && s.prevV > 0 && s.curV != null);
+      const excluded = shops.filter((s) => !((s.prevV || 0) > 0) && (s.curV || 0) > 0).length;
+      if (excluded) panelNote(quadPanel, `${excluded} 家档口上期无有效数据,未纳入四象限`);
+      const quad = panelChart(quadPanel, 'accQuad');
+      if (quad && pts.length) {
+        const ratio = (s) => (s.curV - s.prevV) / s.prevV;
+        const CLAMP = 3; // 增速轴截断 ±300%,避免上期近零的档口把整张图压扁
+        if (pts.some((s) => Math.abs(ratio(s)) > CLAMP)) panelNote(quadPanel, '增速轴截断于 ±300%,极值请悬停查看');
+        const curVals = pts.map((s) => s.curV).sort((a, b) => a - b);
+        const median = curVals.length % 2
+          ? curVals[(curVals.length - 1) / 2]
+          : (curVals[curVals.length / 2 - 1] + curVals[curVals.length / 2]) / 2;
+        const avgPay = pts.reduce((a, s) => a + s.pay, 0) / pts.length;
+        quad.setOption({
+          tooltip: {
+            formatter: (p) => {
+              const s = p.data.meta;
+              return `<b>${s.name}</b><br>本期 ${fmtByCol(col, s.curV)}<br>上期 ${fmtByCol(col, s.prevV)}<br>环比 ${(ratio(s) * 100).toFixed(1)}%${s.pay ? `<br>客单价 ${fmtMoney(s.pay)}` : ''}`;
+            },
+          },
+          grid: { left: 64, right: 30, top: 30, bottom: 46 },
+          xAxis: {
+            type: 'value', name: `本期${col.label}`, nameLocation: 'middle', nameGap: 28, nameTextStyle: { color: '#6b6560' },
+            axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE, axisLine: { show: true, lineStyle: { color: '#e8e4df' } },
+          },
+          yAxis: { type: 'value', name: '环比', nameTextStyle: { color: '#6b6560' }, axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE },
+          series: [{
+            type: 'scatter',
+            data: pts.map((s) => ({
+              value: [s.curV, Math.max(-CLAMP, Math.min(CLAMP, ratio(s)))],
+              meta: s,
+              symbolSize: Math.max(8, Math.min(42, 8 + Math.sqrt(avgPay > 0 ? s.pay / avgPay : 1) * 10)),
+              itemStyle: { color: 'rgba(255,107,53,.75)', borderColor: '#fff', borderWidth: 1 },
+            })),
+            markLine: {
+              symbol: 'none', lineStyle: { color: COLOR_PREV, type: 'dashed' }, label: { color: '#6b6560' },
+              data: [
+                { xAxis: +median.toFixed(2), label: { formatter: '体量中位' } },
+                { yAxis: 0, label: { formatter: '持平' } },
+              ],
+            },
+          }],
+          graphic: [
+            { type: 'text', right: 40, top: 32, style: { text: '明星 · 量大在涨', fill: '#a5a09a', fontSize: 11 } },
+            { type: 'text', left: 70, top: 32, style: { text: '潜力 · 量小在涨', fill: '#a5a09a', fontSize: 11 } },
+            { type: 'text', right: 40, bottom: 50, style: { text: '现金牛 · 量大微跌', fill: '#a5a09a', fontSize: 11 } },
+            { type: 'text', left: 70, bottom: 50, style: { text: '观察 · 量小在跌', fill: '#a5a09a', fontSize: 11 } },
+          ],
+        }, true);
+      } else if (quad) {
+        panelEmptyIn(quadPanel, '上期无有效数据,无法计算环比增速');
+      }
+
+      const helpPanel = makePanel(grid, '象限怎么用', 1);
+      const helpBox = document.createElement('ul');
+      helpBox.className = 'ana-help';
+      helpBox.innerHTML = `
+        <li><b>明星</b>(右上):体量 ≥ 中位且在涨 —— 保供给保体验,优先投资源</li>
+        <li><b>潜力</b>(左上):量小在涨 —— 加曝光、试推广、扩菜品</li>
+        <li><b>现金牛</b>(右下):量大但跌 —— 查菜品结构与评价维护</li>
+        <li><b>观察</b>(左下):量小且跌 —— 评估调整或汰换</li>
+        <li>气泡大小=客单价;虚线=体量中位数 / 持平线</li>`;
+      helpPanel.cell.appendChild(helpBox);
+    }
+
+    // ---------- 模板五:流量卡点 ----------
+
+    function renderFunnelTpl(grid, data) {
+      const clkSeries = buildDailySeries(data, 'clkExpRate');
+      const ordSeries = buildDailySeries(data, 'ordClkRate');
+      const xData = clkSeries.dates.map(ymdToDash);
+
+      const ratePanel = makePanel(grid, '转化率日趋势(虚线=健康基准,浅红区=偏低区)', 2);
+      dataQualityNote(ratePanel, clkSeries);
+      const rateChart = panelChart(ratePanel, 'funRates');
+      if (rateChart) {
+        const areas = [
+          [{ xAxis: xData[0], yAxis: 0, itemStyle: { color: 'rgba(200,64,46,.04)' } }, { xAxis: xData[xData.length - 1], yAxis: RATE_THRESHOLDS.clkExpRate.bad }],
+          ...missingMarkAreas(clkSeries),
+        ];
+        rateChart.setOption({
+          tooltip: { trigger: 'axis', valueFormatter: (v) => fmtPct(v) },
+          legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+          grid: { left: 56, right: 20, top: 36, bottom: 30 },
+          xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
+          yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(1) + '%' }, splitLine: SPLIT_LINE },
+          series: [
+            {
+              name: '进店转化率', type: 'line', smooth: true, symbolSize: 4, connectNulls: false,
+              data: hollowPoints(clkSeries), itemStyle: { color: ACCENT }, lineStyle: { width: 2 },
+              markLine: { symbol: 'none', lineStyle: { type: 'dashed', color: ACCENT, opacity: 0.55 }, label: { formatter: '健康线 9%', color: '#6b6560', position: 'insideEndTop' }, data: [{ yAxis: 0.09 }] },
+              markArea: { silent: true, data: areas },
+            },
+            {
+              name: '下单转化率', type: 'line', smooth: true, symbolSize: 4, connectNulls: false,
+              data: hollowPoints(ordSeries), itemStyle: { color: COLOR_INFO }, lineStyle: { width: 2 },
+              markLine: { symbol: 'none', lineStyle: { type: 'dashed', color: COLOR_INFO, opacity: 0.55 }, label: { formatter: '健康线 2.5%', color: '#6b6560', position: 'insideEndBottom' }, data: [{ yAxis: 0.025 }] },
+            },
+          ],
+        }, true);
+      }
+
+      // 卡点诊断卡:率值对照健康基准,量值对照上期
+      const diagPanel = makePanel(grid, '卡点诊断', 1);
+      const box = document.createElement('div');
+      box.className = 'diag-steps';
+      diagPanel.cell.appendChild(box);
+      const curClk = wAvgField(data.byDate, 'clkExpRate', 'expUv');
+      const prevClk = wAvgField(data.byDatePrev, 'clkExpRate', 'expUv');
+      const curOrd = wAvgField(data.byDate, 'ordClkRate', 'orderUserCnt');
+      const prevOrd = wAvgField(data.byDatePrev, 'ordClkRate', 'orderUserCnt');
+      const curExp = sumField(data.byDate, 'expUv');
+      const prevExp = sumField(data.byDatePrev, 'expUv');
+      function rateStatus(key, v) {
+        const t = RATE_THRESHOLDS[key];
+        if (!(v > 0)) return { cls: 'badge badge-info', text: '无数据' };
+        if (v >= t.good) return { cls: 'badge badge-ok', text: '达标' };
+        if (v < t.bad) return { cls: 'badge badge-fail', text: '明显偏低' };
+        return { cls: 'badge badge-warn', text: '偏低' };
+      }
+      const expDelta = prevExp > 0 ? (curExp - prevExp) / prevExp : null;
+      const expBadge = expDelta == null ? '<span class="badge badge-info">上期无数据</span>'
+        : Math.abs(expDelta) < 0.02 ? '<span class="badge badge-ok">持平</span>'
+          : `<span class="badge ${expDelta > 0 ? 'badge-ok' : 'badge-fail'}">${expDelta > 0 ? '↑' : '↓'} ${Math.abs(expDelta * 100).toFixed(1)}%</span>`;
+      box.innerHTML = `
+        <div class="diag-step">
+          <div class="diag-step-head"><span class="diag-step-name">① 曝光人数(量)</span>${expBadge}</div>
+          <div class="diag-step-val">${fmtNum(curExp)}</div>
+          <div class="diag-step-meta">上期 ${fmtNum(prevExp)} —— 瓶颈在平台流量侧时看这里</div>
+        </div>
+        <div class="diag-step">
+          <div class="diag-step-head"><span class="diag-step-name">② 进店转化率</span><span class="${rateStatus('clkExpRate', curClk).cls}">${rateStatus('clkExpRate', curClk).text}</span></div>
+          <div class="diag-step-val">${fmtPct(curClk)}</div>
+          <div class="diag-step-meta">健康线 ≥9% · 上期 ${fmtPct(prevClk)}</div>
+        </div>
+        <div class="diag-step">
+          <div class="diag-step-head"><span class="diag-step-name">③ 下单转化率</span><span class="${rateStatus('ordClkRate', curOrd).cls}">${rateStatus('ordClkRate', curOrd).text}</span></div>
+          <div class="diag-step-val">${fmtPct(curOrd)}</div>
+          <div class="diag-step-meta">健康线 ≥2.5% · 上期 ${fmtPct(prevOrd)}</div>
+        </div>`;
+      const gaps = [
+        { key: 'clkExpRate', label: '进店转化率', v: curClk, good: RATE_THRESHOLDS.clkExpRate.good },
+        { key: 'ordClkRate', label: '下单转化率', v: curOrd, good: RATE_THRESHOLDS.ordClkRate.good },
+      ].filter((g) => g.v > 0 && g.v < g.good);
+      let conclusion;
+      if (gaps.length) {
+        gaps.sort((a, b) => a.v / a.good - b.v / b.good);
+        const g = gaps[0];
+        const advice = g.key === 'clkExpRate' ? '优先优化门头图/招牌/活动利益点' : '优先优化菜单结构、价格带与评价维护';
+        conclusion = `最大卡点:${g.label}(${fmtPct(g.v)},距健康线还差 ${fmtPct(g.good - g.v)})—— ${advice}`;
+      } else if (prevExp > 0 && curExp < prevExp * 0.9) {
+        conclusion = `两步转化均达标,但曝光较上期降 ${Math.round((1 - curExp / prevExp) * 100)}% —— 瓶颈更可能在平台流量侧,关注排名/推广/评分`;
+      } else {
+        conclusion = '两步转化均达标、曝光未见明显下滑,暂无明显卡点';
+      }
+      const concl = document.createElement('div');
+      concl.className = 'diag-conclusion';
+      concl.textContent = conclusion;
+      diagPanel.cell.appendChild(concl);
+
+      // 三个量级日趋势:曝光 / 下单人数 / 客单价(含上期对照)
+      const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
+      const minis = [
+        { field: 'expUv', label: '曝光人数', fmt: fmtNum },
+        { field: 'orderUserCnt', label: '下单人数', fmt: fmtNum },
+        { field: 'perActualPay', label: '客单价', fmt: fmtMoney },
+      ];
+      for (const m of minis) {
+        const p = makePanel(grid, `${m.label}日趋势`, 1);
+        const c = panelChart(p, 'funMini_' + m.field);
+        if (!c) continue;
+        const s = buildDailySeries(data, m.field);
+        c.setOption({
+          tooltip: { trigger: 'axis', valueFormatter: (v) => m.fmt(v) },
+          legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+          grid: { left: 56, right: 16, top: 30, bottom: 26 },
+          xAxis: { type: 'category', boundaryGap: false, data: s.dates.map(ymdToDash), ...AXIS_TXT },
+          yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+          series: [
+            { name: '本期', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: hollowPoints(s), itemStyle: { color: ACCENT }, lineStyle: { width: 2 } },
+            { name: '上期', type: 'line', smooth: true, symbol: 'none', data: s.dates.map((_, i) => (prevSorted[i] ? Number(prevSorted[i][m.field]) || 0 : null)), itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.4 } },
+          ],
+        }, true);
+      }
+    }
+
+    // ---------- 模板六:周内节律 ----------
+
+    function renderRhythmTpl(grid, data) {
+      const chartMetric = metric();
+      const col = chartCol(chartMetric);
+      const byDate = new Map(data.byDate.map((r) => [r.date, Number(r[chartMetric]) || 0]));
+      // 周次行:区间内每个周一一行(首尾不足一周的照常显示,缺数留白)
+      const weekStarts = [];
+      const dowOf = (ymd) => (ymdDate(ymd).getDay() + 6) % 7; // 0=周一
+      let ws = shiftYmd(data.from, -dowOf(data.from));
+      for (; ws <= data.to; ws = shiftYmd(ws, 7)) weekStarts.push(ws);
+      const weeks = weekStarts.slice().reverse(); // 最新一周显示在最上
+      const rowOf = new Map(weekStarts.map((w, i) => [w, weeks.length - 1 - i]));
+      const cells = [];
+      for (const w of weekStarts) {
+        for (let di = 0; di < 7; di++) {
+          const d = shiftYmd(w, di);
+          if (d < data.from || d > data.to) continue;
+          if (!byDate.has(d)) continue;
+          cells.push([di, rowOf.get(w), byDate.get(d)]);
+        }
+      }
+      const max = Math.max(0, ...cells.map((c) => c[2]));
+
+      const hmPanel = makePanel(grid, `周内节律热力图 · ${col.label}(色深=数值高,留白=缺数)`, 2);
+      const hm = panelChart(hmPanel, 'rhythmHm');
+      if (hm) {
+        hm.setOption({
+          tooltip: {
+            formatter: (p) => {
+              const d = shiftYmd(weeks[p.value[1]], p.value[0]);
+              return `${ymdToDash(d)} ${WEEKDAY_CN[p.value[0]]}<br>${col.label}:${fmtByCol(col, p.value[2])}`;
+            },
+          },
+          grid: { left: 62, right: 20, top: 12, bottom: 64 },
+          xAxis: { type: 'category', data: WEEKDAY_CN, splitArea: { show: true }, ...AXIS_TXT },
+          yAxis: { type: 'category', data: weeks.map(ymdToDash), ...AXIS_TXT },
+          visualMap: {
+            min: 0, max: max > 0 ? max : 1, calculable: false, orient: 'horizontal', left: 'center', bottom: 0,
+            text: ['高', '低'], textStyle: { color: '#6b6560' },
+            inRange: { color: ['#f0ede8', '#ffd2b8', '#ff8a5c', '#e0501c'] },
+          },
+          series: [{
+            type: 'heatmap',
+            data: cells,
+            itemStyle: { borderColor: '#fff', borderWidth: 1, borderRadius: 2 },
+          }],
+        }, true);
+        if (max <= 0) panelNote(hmPanel, '区间内无有效数据');
+      }
+
+      const dayPanel = makePanel(grid, '周内均值', 1);
+      const dayChart = panelChart(dayPanel, 'rhythmDay');
+      if (dayChart) {
+        const buckets = Array.from({ length: 7 }, () => []);
+        for (const [d, v] of byDate) buckets[dowOf(d)].push(v);
+        const avg = buckets.map((list) => (list.length ? +(list.reduce((a, b) => a + b, 0) / list.length).toFixed(2) : 0));
+        dayChart.setOption({
+          tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+          grid: { left: 56, right: 16, top: 20, bottom: 30 },
+          xAxis: { type: 'category', data: WEEKDAY_CN, ...AXIS_TXT },
+          yAxis: { type: 'value', axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+          series: [{ type: 'bar', barMaxWidth: 22, data: avg, itemStyle: { color: ACCENT, borderRadius: [6, 6, 0, 0] } }],
+        }, true);
+      }
+    }
+
+    // ---------- 模板七:收入结构 ----------
+
+    function renderIncomeTpl(grid, data) {
+      const incomeS = buildDailySeries(data, 'incomeAmt');
+      const expenseS = buildDailySeries(data, 'expenseAmt');
+      const subsidyS = buildDailySeries(data, 'actSubsidyAmt');
+      const ordersS = buildDailySeries(data, 'validOrderCnt');
+      const actOrdS = buildDailySeries(data, 'actOrdCnt');
+      const prevSorted = data.byDatePrev.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
+      const prevGross = incomeS.dates.map((_, i) => (prevSorted[i] ? +(Number(prevSorted[i].incomeAmt) + Number(prevSorted[i].expenseAmt)).toFixed(2) : null));
+      const xData = incomeS.dates.map(ymdToDash);
+      const rate = (num, den) => (den != null && den > 0 ? num / den : null);
+
+      // 流水构成:收入 + 支出 = 营业额(堆叠面积)
+      const structPanel = makePanel(grid, '流水构成(收入 + 支出 = 营业额)', 2);
+      dataQualityNote(structPanel, incomeS);
+      const structChart = panelChart(structPanel, 'incStruct');
+      if (structChart) {
+        const areas = missingMarkAreas(incomeS);
+        const series = [
+          { name: '收入', type: 'line', stack: 'gross', smooth: true, symbol: 'none', areaStyle: { opacity: 0.85 }, data: incomeS.values, itemStyle: { color: ACCENT }, lineStyle: { width: 1.2 } },
+          { name: '支出(平台扣费等)', type: 'line', stack: 'gross', smooth: true, symbol: 'none', areaStyle: { opacity: 0.85 }, data: expenseS.values, itemStyle: { color: '#b8860b' }, lineStyle: { width: 1.2 } },
+          { name: '上期营业额', type: 'line', smooth: true, symbol: 'none', data: prevGross, itemStyle: { color: COLOR_PREV }, lineStyle: { type: 'dashed', width: 1.6 } },
+        ];
+        if (areas.length) series[0].markArea = { silent: true, data: areas };
+        structChart.setOption({
+          tooltip: { trigger: 'axis' },
+          legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+          grid: { left: 64, right: 24, top: 36, bottom: 30 },
+          xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
+          yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE },
+          series,
+        }, true);
+      }
+
+      // 收入率(左轴)与补贴率(右轴)
+      const ratePanel = makePanel(grid, '收入率 与 补贴率', 1);
+      const rateChart = panelChart(ratePanel, 'incRate');
+      if (rateChart) {
+        const incomeRate = incomeS.values.map((v, i) => (v == null ? null : rate(v, v + (expenseS.values[i] || 0))));
+        const subsidyRate = subsidyS.values.map((v, i) => (v == null ? null : rate(v, incomeS.values[i])));
+        rateChart.setOption({
+          tooltip: { trigger: 'axis', valueFormatter: (v) => (v == null ? '—' : (v * 100).toFixed(1) + '%') },
+          legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+          grid: { left: 48, right: 44, top: 36, bottom: 30 },
+          xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
+          yAxis: [
+            { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE },
+            { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(1) + '%' }, splitLine: { show: false } },
+          ],
+          series: [
+            { name: '收入率(收入/营业额)', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: incomeRate, itemStyle: { color: COLOR_OK }, lineStyle: { width: 2 } },
+            { name: '补贴率(补贴/收入)', type: 'line', yAxisIndex: 1, smooth: true, symbolSize: 3, connectNulls: false, data: subsidyRate, itemStyle: { color: '#b8860b' }, lineStyle: { width: 2 } },
+          ],
+        }, true);
+      }
+
+      // 活动强度:补贴额(柱,右轴)× 活动订单占比(线,左轴)
+      const actPanel = makePanel(grid, '活动强度:补贴额 与 活动订单占比', 2);
+      const actChart = panelChart(actPanel, 'incAct');
+      if (actChart) {
+        const actShare = actOrdS.values.map((v, i) => (v == null ? null : rate(v, ordersS.values[i])));
+        actChart.setOption({
+          tooltip: { trigger: 'axis' },
+          legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+          grid: { left: 48, right: 56, top: 36, bottom: 30 },
+          xAxis: { type: 'category', data: xData, ...AXIS_TXT },
+          yAxis: [
+            { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE },
+            { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: { show: false } },
+          ],
+          series: [
+            { name: '活动订单占比', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: actShare, itemStyle: { color: COLOR_INFO }, lineStyle: { width: 2 } },
+            { name: '活动补贴(元)', type: 'bar', yAxisIndex: 1, barMaxWidth: 10, data: subsidyS.values, itemStyle: { color: 'rgba(184,134,11,.45)' } },
+          ],
+        }, true);
+      }
+
+      // 结构一览表
+      const tbl = makePanel(grid, '结构一览', 1);
+      const wrap = document.createElement('div');
+      wrap.className = 'ana-table-scroll';
+      const cur = data.byDate;
+      const prev = data.byDatePrev;
+      const pair = (label, c, p, fmt, isRate) => ({ label, c, p, fmt, isRate });
+      const defs = [
+        pair('营业额', sumField(cur, 'grossAmt'), sumField(prev, 'grossAmt'), fmtMoney),
+        pair('收入', sumField(cur, 'incomeAmt'), sumField(prev, 'incomeAmt'), fmtMoney),
+        pair('支出', sumField(cur, 'expenseAmt'), sumField(prev, 'expenseAmt'), fmtMoney),
+        pair('收入率', rate(sumField(cur, 'incomeAmt'), sumField(cur, 'grossAmt')), rate(sumField(prev, 'incomeAmt'), sumField(prev, 'grossAmt')), fmtPct),
+        pair('活动补贴', sumField(cur, 'actSubsidyAmt'), sumField(prev, 'actSubsidyAmt'), fmtMoney),
+        pair('补贴率', rate(sumField(cur, 'actSubsidyAmt'), sumField(cur, 'incomeAmt')), rate(sumField(prev, 'actSubsidyAmt'), sumField(prev, 'incomeAmt')), fmtPct),
+        pair('活动订单占比', rate(sumField(cur, 'actOrdCnt'), sumField(cur, 'validOrderCnt')), rate(sumField(prev, 'actOrdCnt'), sumField(prev, 'validOrderCnt')), fmtPct),
+      ];
+      wrap.innerHTML = `<table class="ana-table">
+        <thead><tr><th>指标</th><th>本期</th><th>上期</th><th>环比</th></tr></thead>
+        <tbody>${defs.map((d) => `<tr>
+          <td>${d.label}</td>
+          <td>${d.c == null ? '—' : d.fmt(d.c)}</td>
+          <td>${d.p == null ? '—' : d.fmt(d.p)}</td>
+          <td>${d.c == null || d.p == null ? '—' : deltaBadge(d.c, d.p)}</td>
+        </tr>`).join('')}</tbody></table>`;
+      tbl.cell.appendChild(wrap);
+    }
+
+    // ---------- 模板八:跨校对比 ----------
+    // 读湖中全部账号(门店筛选与主体选择不作用于本模板),补上"集团层"视角。
+
+    async function renderCrossTpl(grid, data) {
+      const chartMetric = metric();
+      const col = chartCol(chartMetric);
+      const cross = await crossData(data);
+      const ents = cross.entities;
+      if (!ents.length) {
+        panelEmpty(grid, '湖中暂无任何账号数据 —— 先在「数据采集」页完成同步', 3);
+        return;
+      }
+      const palette = ['#ff6b35', '#2563eb', '#1a8a4c', '#b8860b', '#8b5cf6', '#0d9488', '#c2410c', '#7c3aed'];
+      const stats = ents.map((e, idx) => {
+        const cur = sumField(e.byDate, chartMetric);
+        const prev = sumField(e.byDatePrev, chartMetric);
+        return {
+          ...e, color: palette[idx % palette.length], cur, prev,
+          days: new Set(e.byDate.map((r) => r.date)).size,
+          orders: sumField(e.byDate, 'validOrderCnt'),
+          gross: sumField(e.byDate, 'grossAmt'),
+        };
+      });
+      if (ents.length === 1) panelNote(makePanel(grid, '', 3), '湖中目前只有 1 个有数据的账号 —— 多账号同步后对比自动生效');
+
+      // 各校日趋势
+      const dates = [...new Set(ents.flatMap((e) => e.byDate.map((r) => r.date)))].sort();
+      const trendPanel = makePanel(grid, `各账号日趋势 · ${col.label}(${ymdToDash(data.from)} ~ ${ymdToDash(data.to)})`, 2);
+      const trendChart = panelChart(trendPanel, 'crossTrend');
+      if (trendChart) {
+        trendChart.setOption({
+          tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+          legend: { top: 0, type: 'scroll', icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+          grid: { left: 64, right: 24, top: 36, bottom: 30 },
+          xAxis: { type: 'category', boundaryGap: false, data: dates.map(ymdToDash), ...AXIS_TXT },
+          yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE },
+          series: stats.map((e) => ({
+            name: e.name, type: 'line', smooth: true, symbolSize: 3, connectNulls: false,
+            data: dates.map((d) => { const r = e.byDate.find((x) => x.date === d); return r ? Number(r[chartMetric]) || 0 : null; }),
+            itemStyle: { color: e.color }, lineStyle: { width: 2 },
+          })),
+        }, true);
+      }
+
+      // 本期合计
+      const barPanel = makePanel(grid, `本期合计 · ${col.label}`, 1);
+      const barChart = panelChart(barPanel, 'crossBar');
+      if (barChart) {
+        const sorted = stats.slice().sort((a, b) => b.cur - a.cur).reverse();
+        barChart.setOption({
+          tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+          grid: { left: 10, right: 40, top: 10, bottom: 24, containLabel: true },
+          xAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE },
+          yAxis: {
+            type: 'category', data: sorted.map((e) => e.name),
+            axisLabel: { color: '#2b2a28', width: 96, overflow: 'truncate' },
+            axisLine: { lineStyle: { color: '#e8e4df' } },
+          },
+          series: [{
+            type: 'bar', barMaxWidth: 16,
+            data: sorted.map((e) => ({ value: +e.cur.toFixed(2), itemStyle: { color: e.color, borderRadius: [0, 6, 6, 0] } })),
+            label: { show: true, position: 'right', fontSize: 10, color: '#6b6560', formatter: (p) => fmtCompact(p.value) },
+          }],
+        }, true);
+      }
+
+      // 对比明细表
+      const tbl = makePanel(grid, '账号对比一览', 2);
+      const wrap = document.createElement('div');
+      wrap.className = 'ana-table-scroll';
+      wrap.innerHTML = `<table class="ana-table">
+        <thead><tr><th>账号</th><th>本期</th><th>上期</th><th>环比</th><th>日均</th><th>有效订单</th><th>客单价</th><th>档口数</th></tr></thead>
+        <tbody>${stats.map((e) => `<tr>
+          <td>${e.name}</td>
+          <td>${fmtByCol(col, e.cur)}</td>
+          <td>${fmtByCol(col, e.prev)}</td>
+          <td>${deltaBadge(e.cur, e.prev)}</td>
+          <td>${e.days > 0 ? fmtByCol(col, e.cur / e.days) : '—'}</td>
+          <td>${fmtNum(e.orders)}</td>
+          <td>${e.orders > 0 ? fmtMoney(e.gross / e.orders) : '—'}</td>
+          <td>${fmtNum(e.shopCount)}</td>
+        </tr>`).join('')}</tbody></table>`;
+      tbl.cell.appendChild(wrap);
+
+      // 占比
+      const piePanel = makePanel(grid, `本期占比 · ${col.label}`, 1);
+      const pieChart = panelChart(piePanel, 'crossPie');
+      if (pieChart) {
+        pieChart.setOption({
+          tooltip: { trigger: 'item', formatter: (p) => `${p.name}:${fmtByCol(col, p.value)}(${p.percent}%)` },
+          series: [{
+            type: 'pie', radius: ['42%', '70%'], center: ['50%', '52%'],
+            itemStyle: { borderColor: '#fff', borderWidth: 2, borderRadius: 4 },
+            label: { color: '#6b6560', formatter: '{b}\n{d}%' },
+            data: stats.map((e) => ({ name: e.name, value: +e.cur.toFixed(2), itemStyle: { color: e.color } })),
+          }],
+        }, true);
+      }
+    }
+
+    // ---------- 模板九:同行对标 ----------
+    // 自身值 vs 商圈同行均值/排名(raw.*_mall_avg / _mall_avg_rank,全字段落库自带)。
+    // 账号/多店看"合计 vs 同行均值合计";单店可看排名走势。
+
+    async function renderBenchTpl(grid, data) {
+      const chartMetric = metric();
+      const col = chartCol(chartMetric);
+      let bench;
+      try { bench = await benchData(data); } catch {
+        panelEmpty(grid, '同行数据加载失败,请稍后重试', 3);
+        return;
+      }
+      const rows = bench.rows;
+      if (!rows.length) {
+        panelEmpty(grid, '暂无档口明细数据(需按门店粒度同步)', 3);
+        return;
+      }
+      const isClk = chartMetric === 'clkExpRate';
+      const isPct = col.type === 'pct';
+      // 日聚合:加性指标求和;率指标按权重(进店率按曝光、其余按下单人数)加权
+      const byDate = new Map();
+      for (const r of rows) {
+        let b = byDate.get(r.date);
+        if (!b) { b = { vNum: 0, vDen: 0, aNum: 0, aDen: 0 }; byDate.set(r.date, b); }
+        const w = isPct ? (isClk ? r.w1 : r.w2) : 1;
+        b.vNum += r.v * w;
+        b.vDen += w;
+        if (r.avg != null && Number.isFinite(r.avg)) { b.aNum += r.avg * w; b.aDen += w; }
+      }
+      const dates = [...byDate.keys()].sort();
+      const ownVals = dates.map((d) => { const b = byDate.get(d); return isPct ? (b.vDen > 0 ? b.vNum / b.vDen : null) : b.vNum; });
+      const avgVals = dates.map((d) => { const b = byDate.get(d); return b.aDen > 0 ? b.aNum / b.aDen : null; });
+      const hasAvg = avgVals.some((v) => v != null);
+      const xData = dates.map(ymdToDash);
+
+      const headPanel = makePanel(grid, `本店 vs 商圈同行均值 · ${col.label}`, 2);
+      if (!hasAvg) panelNote(headPanel, '当前数据未含同行均值 —— 2026-10-16 起同步的数据全字段落库自带,重新同步即可补全');
+      const headChart = panelChart(headPanel, 'benchTrend');
+      if (headChart) {
+        const series = [
+          { name: `本店·${col.label}`, type: 'line', smooth: true, symbolSize: 4, connectNulls: false, data: ownVals, itemStyle: { color: ACCENT }, lineStyle: { width: 2.5 } },
+        ];
+        if (hasAvg) {
+          series.push({
+            name: '商圈同行均值', type: 'line', smooth: true, symbolSize: 3, connectNulls: false, data: avgVals,
+            itemStyle: { color: COLOR_INFO }, lineStyle: { type: 'dashed', width: 1.8 },
+          });
+        }
+        headChart.setOption({
+          tooltip: { trigger: 'axis', valueFormatter: (v) => fmtByCol(col, v) },
+          legend: { top: 0, icon: 'roundRect', itemWidth: 14, itemHeight: 4, textStyle: { color: '#6b6560' } },
+          grid: { left: 64, right: 24, top: 36, bottom: 30 },
+          xAxis: { type: 'category', boundaryGap: false, data: xData, ...AXIS_TXT },
+          yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => fmtCompact(v) }, splitLine: SPLIT_LINE },
+          series,
+        }, true);
+      }
+
+      // 超越率:本店 / 同行均值 - 1(逐日)
+      const edgePanel = makePanel(grid, '超越同行幅度(逐日)', 1);
+      const edgeChart = panelChart(edgePanel, 'benchEdge');
+      if (edgeChart) {
+        if (!hasAvg) panelEmptyIn(edgePanel, '无同行均值数据');
+        else {
+          const edges = dates.map((_, i) => (avgVals[i] != null && avgVals[i] > 0 ? ownVals[i] / avgVals[i] - 1 : null));
+          edgeChart.setOption({
+            tooltip: { trigger: 'axis', valueFormatter: (v) => (v == null ? '—' : (v >= 0 ? '+' : '') + (v * 100).toFixed(1) + '%') },
+            grid: { left: 48, right: 16, top: 20, bottom: 30 },
+            xAxis: { type: 'category', data: xData, ...AXIS_TXT },
+            yAxis: { type: 'value', axisLabel: { color: '#6b6560', formatter: (v) => (v * 100).toFixed(0) + '%' }, splitLine: SPLIT_LINE },
+            series: [{
+              type: 'bar', barMaxWidth: 10,
+              data: edges.map((v) => (v == null ? null : { value: +v.toFixed(4), itemStyle: { color: v >= 0 ? COLOR_OK : COLOR_FAIL } })),
+            }],
+          }, true);
+        }
+      }
+
+      // 排名:单店 = 排名日走势(名次越小越靠上);多店 = 最新日各店排名
+      const rankPanel = makePanel(grid, '商圈同行排名', 2);
+      const rankRows = rows.filter((r) => r.rank != null && Number.isFinite(r.rank));
+      const rankChart = panelChart(rankPanel, 'benchRank');
+      if (rankChart && rankRows.length) {
+        if (shopFilterCount() === 1) {
+          const single = rankRows.slice().sort((a, b) => (a.date < b.date ? -1 : 1));
+          rankChart.setOption({
+            tooltip: { trigger: 'axis', formatter: (ps) => `${ps[0].axisValue}<br>商圈排名:第 ${Math.round(ps[0].value)} 名` },
+            grid: { left: 56, right: 20, top: 20, bottom: 30 },
+            xAxis: { type: 'category', boundaryGap: false, data: single.map((r) => ymdToDash(r.date)), ...AXIS_TXT },
+            yAxis: { type: 'value', inverse: true, name: '名次', nameTextStyle: { color: '#6b6560' }, axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+            series: [{ type: 'line', smooth: true, symbolSize: 4, data: single.map((r) => Math.round(r.rank)), itemStyle: { color: COLOR_INFO }, lineStyle: { width: 2 }, areaStyle: { color: 'rgba(37,99,235,.06)' } }],
+          }, true);
+        } else {
+          const latest = dates.slice().reverse().find((d) => rows.some((r) => r.date === d && r.rank != null));
+          const list = rows.filter((r) => r.date === latest && r.rank != null).sort((a, b) => a.rank - b.rank).reverse();
+          panelNote(rankPanel, `取最新有排名日(${ymdToDash(latest)}),数字越小越好`);
+          rankChart.setOption({
+            tooltip: { trigger: 'axis', formatter: (ps) => `${ps[0].name}<br>商圈排名:第 ${Math.round(ps[0].value)} 名` },
+            grid: { left: 10, right: 44, top: 10, bottom: 24, containLabel: true },
+            xAxis: { type: 'value', inverse: true, axisLabel: { color: '#6b6560' }, splitLine: SPLIT_LINE },
+            yAxis: {
+              type: 'category', data: list.map((r) => r.shopName || r.shopId),
+              axisLabel: { color: '#2b2a28', width: 110, overflow: 'truncate' },
+              axisLine: { lineStyle: { color: '#e8e4df' } },
+            },
+            series: [{
+              type: 'bar', barMaxWidth: 14,
+              data: list.map((r) => ({ value: Math.round(r.rank), itemStyle: { color: r.rank <= 3 ? COLOR_OK : (r.rank >= list.length * 0.7 ? COLOR_FAIL : ACCENT) } })),
+              label: { show: true, position: 'left', fontSize: 10, color: '#6b6560', formatter: (p) => '第' + Math.round(p.value) + '名' },
+            }],
+          }, true);
+        }
+      } else if (rankChart) {
+        panelEmptyIn(rankPanel, '该指标暂无同行排名数据');
+      }
+
+      // 对标一览
+      const tbl = makePanel(grid, '对标一览(本期合计)', 1);
+      const ownTotal = isPct
+        ? rows.reduce((a, r) => { const w = isClk ? r.w1 : r.w2; return a + r.v * w; }, 0) / Math.max(1, rows.reduce((a, r) => a + (isClk ? r.w1 : r.w2), 0))
+        : rows.reduce((a, r) => a + r.v, 0);
+      const avgTotal = isPct
+        ? rows.reduce((a, r) => { if (r.avg == null) return a; const w = isClk ? r.w1 : r.w2; return a + r.avg * w; }, 0) / Math.max(1, rows.reduce((a, r) => (r.avg == null ? a : a + (isClk ? r.w1 : r.w2)), 0))
+        : rows.reduce((a, r) => a + (r.avg != null ? r.avg : 0), 0);
+      const shopTotals = new Map();
+      for (const r of rows) shopTotals.set(r.shopName || r.shopId, (shopTotals.get(r.shopName || r.shopId) || 0) + r.v);
+      const ranked = [...shopTotals.entries()].sort((a, b) => b[1] - a[1]);
+      const wrap = document.createElement('div');
+      wrap.className = 'ana-table-scroll';
+      wrap.innerHTML = `<table class="ana-table">
+        <tbody>
+          <tr><td>本店(${isPct ? '加权均值' : '合计'})</td><td>${fmtByCol(col, ownTotal)}</td></tr>
+          <tr><td>商圈同行均值</td><td>${hasAvg ? fmtByCol(col, avgTotal) : '—'}</td></tr>
+          <tr><td>超越同行</td><td>${hasAvg && avgTotal > 0 ? `<span class="delta ${ownTotal >= avgTotal ? 'up' : 'down'}">${ownTotal >= avgTotal ? '↑' : '↓'} ${Math.abs((ownTotal / avgTotal - 1) * 100).toFixed(1)}%</span>` : '—'}</td></tr>
+          <tr><td>最强档口</td><td>${ranked.length ? `${ranked[0][0]}(${fmtByCol(col, ranked[0][1])})` : '—'}</td></tr>
+          <tr><td>最弱档口</td><td>${ranked.length > 1 ? `${ranked[ranked.length - 1][0]}(${fmtByCol(col, ranked[ranked.length - 1][1])})` : '—'}</td></tr>
+        </tbody></table>`;
+      tbl.cell.appendChild(wrap);
+    }
+
+    // ---------- 注册表 ----------
+
+    return [
+      { id: 'overview', name: '综合总览', group: '经营视角', usesMetric: true, usesCompare: true,
+        desc: '主指标趋势 / 门店排行 / 转化漏斗 / 门店对比,一屏总览',
+        render: renderOverviewTpl },
+      { id: 'mom', name: '环比对照', group: '经营视角', usesMetric: true,
+        desc: '本期 vs 上期:逐日对照、逐日差值与指标环比一览',
+        render: renderMomTpl },
+      { id: 'attribution', name: '量价归因', group: '经营视角',
+        desc: '营业额涨跌拆解为 流量 × 进店率 × 下单率 × 人均单量 × 客单价 的贡献',
+        render: renderAttributionTpl },
+      { id: 'accountability', name: '档口问责', group: '经营视角', usesMetric: true,
+        desc: '涨跌榜 / 贡献帕累托 / 体量×增速四象限,直接回答"谁在涨、谁在跌"',
+        render: renderAccountabilityTpl },
+      { id: 'income', name: '收入结构', group: '经营视角',
+        desc: '收入 + 支出 = 营业额 的构成、收入率与补贴率、活动强度',
+        render: renderIncomeTpl },
+      { id: 'cross', name: '跨校对比', group: '经营视角', usesMetric: true,
+        desc: '湖中全部账号并排对比(趋势 / 合计 / 占比 / 一览表);主体与门店筛选不作用于本模板',
+        render: renderCrossTpl },
+      { id: 'funnel', name: '流量卡点', group: '运营视角',
+        desc: '进店率与下单率对照健康基准(9% / 2.5%),定位漏斗卡在哪一步',
+        render: renderFunnelTpl },
+      { id: 'rhythm', name: '周内节律', group: '运营视角', usesMetric: true,
+        desc: '星期 × 周次热力图与周内均值,辅助排班与备货',
+        render: renderRhythmTpl },
+      { id: 'bench', name: '同行对标', group: '运营视角', usesMetric: true,
+        desc: '本店 vs 商圈同行均值与排名(来自全字段落库的同行字段;单店可看排名走势)',
+        render: renderBenchTpl },
+    ];
+  };
+})();

+ 1 - 1
public/reports.js

@@ -1261,7 +1261,7 @@
     if (!grid) return;
     grid.innerHTML = '';
     renderDeltas(data);
-    tpl.render(grid, data);
+    await tpl.render(grid, data); // 模板可为 async(同行对标/跨校对比自带取数)
   }
 
   // ---------- 模板库装配 ----------

+ 11 - 1
scripts/smoke.js

@@ -362,12 +362,22 @@ async function main() {
     });
 
     // ---- 静态与路由行为 ----
-    for (const p of ['/', '/index.html', '/app.js', '/crawl.js', '/gen.js', '/history.js', '/tasks.js', '/reports.js', '/lake.js', '/style.css', '/vendor/echarts.min.js']) {
+    for (const p of ['/', '/index.html', '/app.js', '/crawl.js', '/gen.js', '/history.js', '/tasks.js', '/reports.js', '/analysis-templates.js', '/lake.js', '/style.css', '/vendor/echarts.min.js']) {
       await check(`静态 ${p}`, async () => {
         const r = await request(port, 'GET', p);
         expect(r.status === 200, `status=${r.status}`);
       });
     }
+    await check('GET /api/reports/bench(合法字段)', async () => {
+      const r = await request(port, 'GET', '/api/reports/bench?field=gross_amt&from=20260101&to=20260102');
+      expect(r.status === 200, `status=${r.status}`);
+      const body = r.json();
+      expect(Array.isArray(body.rows), 'rows 应为数组');
+    });
+    await check('GET /api/reports/bench(非法字段 → 400)', async () => {
+      const r = await request(port, 'GET', '/api/reports/bench?field=no_such');
+      expect(r.status === 400, `status=${r.status}`);
+    });
     await check('GET /no-such-path → 404', async () => {
       const r = await request(port, 'GET', '/no-such-path');
       expect(r.status === 404, `status=${r.status}`);

+ 33 - 0
server/routes/reports.js

@@ -214,6 +214,39 @@ function register(app, ctx) {
     }));
   });
 
+  // 同行对标:按字段取 门店×天 的自身值 + 同行均值/排名(来自全字段落库的 raw.*_mall_avg/_rank)
+  // field 用原始蛇形名(与 historyOverview 一致),白名单 = FIELD_MAP 的键
+  app.get('/api/reports/bench', async ({ res, query }) => {
+    const field = String(query.field || 'gross_amt');
+    const camel = naposService.FIELD_MAP[field];
+    if (!camel) return sendJson(res, 400, { error: `不支持的对标字段:${field}` });
+    const store = naposService.store;
+    const key = store.sanitizeKey(query.entity);
+    const meta = store.getEntity(key);
+    let rows = store.queryRows(key, { from: query.from, to: query.to, scope: 'shop' });
+    if (query.shops) {
+      const wanted = new Set(query.shops.split(',').filter(Boolean));
+      rows = rows.filter((r) => wanted.has(String(r.shopId)));
+    }
+    const avgKey = field + '_mall_avg';
+    const rankKey = field + '_mall_avg_rank';
+    const out = rows.map((r) => ({
+      date: r.date,
+      shopId: String(r.shopId),
+      shopName: r.shopName || '',
+      v: Number(r[camel]) || 0,
+      avg: r.raw && r.raw[avgKey] != null && r.raw[avgKey] !== '' ? Number(r.raw[avgKey]) : null,
+      rank: r.raw && r.raw[rankKey] != null && r.raw[rankKey] !== '' ? Number(r.raw[rankKey]) : null,
+      w1: Number(r.expUv) || 0,        // pct 指标跨店加权用(曝光人数)
+      w2: Number(r.orderUserCnt) || 0, // 下单转化率加权用
+    }));
+    sendJson(res, 200, {
+      entity: meta ? { key: meta.key, name: meta.name } : { key, name: key },
+      field, from: query.from || null, to: query.to || null,
+      rows: out,
+    });
+  });
+
   app.get('/api/reports/export', async ({ res, query }) => {
     const group = query.group === 'date' ? 'date' : (query.group === 'shop' ? 'shop' : null);
     let rows;

+ 1 - 0
server/routes/static.js

@@ -13,6 +13,7 @@ const STATIC_MAP = {
   '/gen.js': ['gen.js', 'application/javascript; charset=utf-8'],
   '/history.js': ['history.js', 'application/javascript; charset=utf-8'],
   '/tasks.js': ['tasks.js', 'application/javascript; charset=utf-8'],
+  '/analysis-templates.js': ['analysis-templates.js', 'application/javascript; charset=utf-8'],
   '/reports.js': ['reports.js', 'application/javascript; charset=utf-8'],
   '/lake.js': ['lake.js', 'application/javascript; charset=utf-8'],
   '/vendor/echarts.min.js': ['vendor/echarts.min.js', 'application/javascript; charset=utf-8'],