Parcourir la source

feat: 店铺运营报表改单表(一行一店一天,指标做列)

- 原来每个指标一张表,改为一张表:店铺 / 日期 / 勾选指标列,勾几个显示几列
- 同店多日合并为一个店铺单元格(rowspan),只展示有数据的日期
- 每个学校按日期出小计行(金额/单量求和,转化率按曝光加权)
- 导出 CSV 同步为「学校,店铺ID,店铺名称,日期,指标列」,与页面结构一致
- 测试更新导出用例(1045 行)→ 42/42
liangzhao il y a 2 semaines
Parent
commit
57b68acff2
5 fichiers modifiés avec 95 ajouts et 68 suppressions
  1. 1 1
      README.md
  2. 69 44
      public/ops.js
  3. 5 0
      public/style.css
  4. 2 1
      scripts/test-ops-import.js
  5. 18 22
      server/routes/ops.js

+ 1 - 1
README.md

@@ -51,7 +51,7 @@
   - **人工统计横表**:`学校名称 / 店铺名称 / ID / 上线状态` + 每天 4 列(订单量 / 营收 / 曝光量 / 进店转换率),日期列写成 `9.9订单量`,年份在上传时选。
   - **平台导出全字段明细**:一行一店一天,含 `日期 / 门店编号 / 门店名称` 与 100+ 列指标(营业额、有效订单、曝光、评分等),全部原样入库。
 - **重复校验**:平台明细按「日期 + 门店编号」判断——同一天同一店的相同数据自动跳过;内容有变化记为「冲突」不覆盖,勾选「覆盖已导入的文件」才覆盖。上传结果会显示「新增 N / 已存在 N / 冲突 N」。
-- **矩阵展示**:平台明细同样进入「店铺运营」报表——有效订单→订单量、营业额→营收、曝光人数→曝光量、进店转化率→进店转化率;同一天同一店两种来源都有时,以人工表为准(人工优先、平台补位)。
+- **报表展示**:一张表、一行一店一天,列 = 店铺 / 日期 / 勾选的指标(勾几个显示几列),按学校分组并带每日小计。平台明细同样进入「店铺运营」报表——有效订单→订单量、营业额→营收、曝光人数→曝光量、进店转化率→进店转化率;同一天同一店两种来源都有时,以人工表为准(人工优先、平台补位)。
 - **学校归属**:平台表里没有学校列时,优先用上传时选的学校,其次从文件名识别(如「浙江工商桐庐校区20261018…」匹配到已登记学校);都匹配不到会要求先选学校。
 - **原件存档**:每次上传的 xlsx 原样入库,可在「导入记录」里下载回;解析规则升级后可「重新解析」而不必重传。
 - **指标矩阵**:行=店铺(按学校分组)、列=日期,可按指标分区块查看;也支持卡片视图(区间合计 + 迷你趋势)。学校、日期、指标都可筛选,支持导出 CSV。

+ 69 - 44
public/ops.js

@@ -282,9 +282,13 @@
     body.innerHTML = state.view === 'cards' ? renderCards(state.data) : renderTables(state.data);
   }
 
-  function formatCell(shop, metric, date) {
+  function cellValue(shop, metric, date) {
     const series = shop.series[metric] || {};
-    const value = Object.prototype.hasOwnProperty.call(series, date) ? series[date] : null;
+    return Object.prototype.hasOwnProperty.call(series, date) ? series[date] : null;
+  }
+
+  function metricCell(shop, metric, date) {
+    const value = cellValue(shop, metric, date);
     const quality = (shop.quality || {})[date];
     const warn = quality && quality.length
       ? `<span class="ops-warn" title="${esc('解析标记:' + quality.join('、'))}">⚠</span>`
@@ -292,59 +296,80 @@
     return `<td class="ops-num${value == null ? ' ops-null' : ''}">${fmtValue(value, metric)}${warn}</td>`;
   }
 
-  function subtotalRow(group, metric, dates) {
-    const cells = dates.map((date) => {
-      if (metric === 'click_rate') {
-        let weight = 0;
-        let acc = 0;
-        for (const shop of group.shops) {
-          const exposure = (shop.series.exposure || {})[date];
-          const rate = (shop.series.click_rate || {})[date];
-          if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
-            weight += exposure;
-            acc += rate * exposure;
-          }
-        }
-        return `<td class="ops-num ops-subtotal">${weight > 0 ? fmtValue(acc / weight, metric) : '–'}</td>`;
-      }
-      let sum = 0;
-      let seen = false;
+  /** 学校小计:数量/金额求和,转化率按曝光加权 */
+  function subtotalValue(group, metric, date) {
+    if (metric === 'click_rate') {
+      let weight = 0;
+      let acc = 0;
       for (const shop of group.shops) {
-        const v = (shop.series[metric] || {})[date];
-        if (typeof v === 'number') { sum += v; seen = true; }
+        const exposure = cellValue(shop, 'exposure', date);
+        const rate = cellValue(shop, 'click_rate', date);
+        if (typeof exposure === 'number' && exposure > 0 && typeof rate === 'number') {
+          weight += exposure;
+          acc += rate * exposure;
+        }
       }
-      return `<td class="ops-num ops-subtotal">${seen ? fmtValue(sum, metric) : '–'}</td>`;
-    }).join('');
-    return `<tr class="ops-subtotal-row"><td class="ops-shop-cell">${esc(group.school)} · 小计</td>${cells}</tr>`;
+      return weight > 0 ? acc / weight : null;
+    }
+    let sum = 0;
+    let seen = false;
+    for (const shop of group.shops) {
+      const v = cellValue(shop, metric, date);
+      if (typeof v === 'number') { sum += v; seen = true; }
+    }
+    return seen ? sum : null;
   }
 
   function renderTables(data) {
-    const dates = data.dates || [];
     const metrics = state.metrics.filter((m) => META[m]);
     if (!metrics.length) return '<div class="empty-hint"><p>请至少选择一个指标</p></div>';
-    return metrics.map((metric) => {
-      const head = `<tr><th class="ops-shop-col">店铺</th>${dates.map((d) => `<th class="ops-date-col">${shortDate(d)}</th>`).join('')}</tr>`;
-      const rows = data.groups.map((group) => {
-        const schoolRow = `<tr class="ops-school-row"><td class="ops-shop-cell" colspan="${dates.length + 1}">${esc(group.school)}<em>${group.shops.length} 家</em></td></tr>`;
-        const shopRows = group.shops.map((shop) => `<tr>
-          <td class="ops-shop-cell" title="${esc(shop.shopName)}">${esc(shop.shopName)}${
+    const dates = data.dates || [];
+    const colCount = metrics.length + 2;
+
+    const head = `<tr>
+      <th class="ops-shop-col">店铺</th>
+      <th class="ops-date-col">日期</th>
+      ${metrics.map((m) => `<th class="ops-metric-col">${META[m].label}</th>`).join('')}
+    </tr>`;
+
+    const body = data.groups.map((group) => {
+      const schoolRow = `<tr class="ops-school-row"><td colspan="${colCount}">${esc(group.school)}<em>${group.shops.length} 家</em></td></tr>`;
+
+      const shopRows = group.shops.map((shop) => {
+        // 只展示该店有数据的日期(平台明细可能缺某天)
+        const shopDates = dates.filter((d) => metrics.some((m) => cellValue(shop, m, d) !== null));
+        const useDates = shopDates.length ? shopDates : dates.slice(0, 1);
+        return useDates.map((date, i) => `<tr>
+          ${i === 0 ? `<td class="ops-shop-cell" rowspan="${useDates.length}" title="${esc(shop.shopName)}">${esc(shop.shopName)}${
             shop.status && shop.status !== '已上线' ? `<span class="badge badge-muted ops-status">${esc(shop.status)}</span>` : ''
-          }</td>
-          ${dates.map((d) => formatCell(shop, metric, d)).join('')}
+          }</td>` : ''}
+          <td class="ops-date-cell">${shortDate(date)}</td>
+          ${metrics.map((m) => metricCell(shop, m, date)).join('')}
         </tr>`).join('');
-        return schoolRow + shopRows + subtotalRow(group, metric, dates);
       }).join('');
-      const shopCount = data.groups.reduce((n, g) => n + g.shops.length, 0);
-      return `<section class="ops-block">
-        <div class="ops-block-head"><h2>${META[metric].label}</h2><span>${shopCount} 家门店 · ${dates.length} 天</span></div>
-        <div class="ops-table-wrap">
-          <table class="ops-table">
-            <thead>${head}</thead>
-            <tbody>${rows}</tbody>
-          </table>
-        </div>
-      </section>`;
+
+      const subtotalDates = dates.filter((d) => group.shops.some((shop) => metrics.some((m) => cellValue(shop, m, d) !== null)));
+      const subtotalRows = subtotalDates.map((date) => `<tr class="ops-subtotal-row">
+        <td class="ops-subtotal-label" colspan="2">${esc(group.school)} · 小计 · ${shortDate(date)}</td>
+        ${metrics.map((m) => {
+          const v = subtotalValue(group, m, date);
+          return `<td class="ops-num ops-subtotal">${v == null ? '–' : fmtValue(v, m)}</td>`;
+        }).join('')}
+      </tr>`).join('');
+
+      return schoolRow + shopRows + subtotalRows;
     }).join('');
+
+    const shopCount = data.groups.reduce((n, g) => n + g.shops.length, 0);
+    return `<section class="ops-block">
+      <div class="ops-block-head"><h2>运营明细</h2><span>${shopCount} 家门店 · ${dates.length} 天 · ${metrics.length} 个指标</span></div>
+      <div class="ops-table-wrap">
+        <table class="ops-table ops-table-wide">
+          <thead>${head}</thead>
+          <tbody>${body}</tbody>
+        </table>
+      </div>
+    </section>`;
   }
 
   function sparkline(shop, metric, dates) {

+ 5 - 0
public/style.css

@@ -1284,3 +1284,8 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
   .schools-layout { grid-template-columns: minmax(0, 1fr); }
   .schools-list { max-height: 340px; }
 }
+.ops-metric-col { text-align: right; min-width: 92px; }
+.ops-table-wide .ops-date-col { min-width: 52px; text-align: center; }
+.ops-table-wide .ops-date-cell { text-align: center; color: var(--text-2); font-variant-numeric: tabular-nums; }
+.ops-subtotal-label { color: var(--text-2); font-weight: 600; }
+.ops-table-wide tbody tr.ops-subtotal-row td { border-top: 1px solid var(--border); }

+ 2 - 1
scripts/test-ops-import.js

@@ -309,7 +309,8 @@ async function main() {
       const text = await res.text();
       expect(/text\/csv/.test(res.headers.get('content-type') || ''), 'content-type');
       expect(text.includes('样例学校A'), 'CSV 缺少学校列');
-      expect(text.trim().split('\n').length === 37, `行数 ${text.trim().split('\n').length}`);
+      expect(text.split('\n')[0].includes('日期'), 'CSV 缺少日期列');
+      expect(text.trim().split('\n').length === 1045, `行数 ${text.trim().split('\n').length}`);
     });
 
     await check('E2E 非 xlsx 拒绝', async () => {

+ 18 - 22
server/routes/ops.js

@@ -526,29 +526,25 @@ function register(app, ctx) {
     } catch (e) {
       return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
     }
+    const labels = metrics.map((m) => (opsImport.METRICS[m] && opsImport.METRICS[m].label) || m);
+    const sorted = rows.slice().sort((a, b) => {
+      const school = String(a.school || '').localeCompare(String(b.school || ''), 'zh-Hans-CN');
+      if (school !== 0) return school;
+      const shop = String(a.shopName || '').localeCompare(String(b.shopName || ''), 'zh-Hans-CN');
+      if (shop !== 0) return shop;
+      return a.date < b.date ? -1 : a.date > b.date ? 1 : 0;
+    });
     const lines = [];
-    if (metrics.length === 1) {
-      const metric = metrics[0];
-      const dates = [...new Set(rows.map((r) => r.date))].sort();
-      lines.push(['学校', '店铺ID', '店铺名称', '状态', ...dates].map(csvCell).join(','));
-      const byShop = new Map();
-      for (const row of rows) {
-        if (!byShop.has(row.shopKey)) byShop.set(row.shopKey, { row, values: {} });
-        byShop.get(row.shopKey).values[row.date] = row[metric];
-      }
-      for (const { row, values } of byShop.values()) {
-        lines.push([row.school, row.shopId || '', row.shopName, row.status || '',
-          ...dates.map((d) => (values[d] == null ? '' : values[d]))].map(csvCell).join(','));
-      }
-    } else {
-      lines.push(['学校', '店铺ID', '店铺名称', '日期', '指标', '数值'].map(csvCell).join(','));
-      for (const row of rows) {
-        for (const metric of metrics) {
-          const value = row[metric];
-          lines.push([row.school, row.shopId || '', row.shopName, row.date, metric, value == null ? '' : value]
-            .map(csvCell).join(','));
-        }
-      }
+    // 与页面一致:一行一店一天,指标做列
+    lines.push(['学校', '店铺ID', '店铺名称', '日期', ...labels].map(csvCell).join(','));
+    for (const row of sorted) {
+      lines.push([
+        row.school || '',
+        row.shopId || '',
+        row.shopName || '',
+        row.date,
+        ...metrics.map((m) => (row[m] == null ? '' : row[m])),
+      ].map(csvCell).join(','));
     }
     const csv = '\ufeff' + lines.join('\r\n');
     res.writeHead(200, {