Ver código fonte

feat: 平台明细并入店铺运营矩阵(人工优先、平台补位)

- /api/ops/rows 与 /api/ops/export 改为合并数据源:平台行按 有效订单/营业额/曝光人数/进店转化率 映射成矩阵指标
- summary 新增 matrixRowCount(两表按 日期+门店 去重后的行数),学校列表改为两表 UNION
- 前端空态/工具条改用 matrixRowCount,平台-only 时也能渲染矩阵
- README 补充映射规则与优先级
liangzhao 2 semanas atrás
pai
commit
bfb81bdf4e
5 arquivos alterados com 80 adições e 11 exclusões
  1. 1 0
      README.md
  2. 11 4
      public/ops.js
  3. 47 2
      server/routes/ops.js
  4. 11 3
      server/stores/ops-pg.js
  5. 10 2
      server/stores/ops-sqlite.js

+ 1 - 0
README.md

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

+ 11 - 4
public/ops.js

@@ -126,7 +126,8 @@
     const bar = $('opsBar');
     if (!bar) return;
     const summary = state.summary;
-    if (!summary || !summary.enabled || !summary.rowCount) {
+    const matrixRows = matrixCount(summary);
+    if (!summary || !summary.enabled || !matrixRows) {
       bar.innerHTML = '';
       return;
     }
@@ -184,11 +185,16 @@
     $('opsTo').addEventListener('change', (e) => { state.to = inputToYmd(e.target.value); loadRows(); });
   }
 
+  function matrixCount(summary) {
+    if (!summary) return 0;
+    return Number(summary.matrixRowCount != null ? summary.matrixRowCount : summary.rowCount) || 0;
+  }
+
   function renderHeaderTools() {
     const el = $('opsHeaderTools');
     if (!el) return;
     const summary = state.summary;
-    if (!summary || !summary.enabled || !summary.rowCount) {
+    if (!summary || !summary.enabled || !matrixCount(summary)) {
       el.innerHTML = '';
       return;
     }
@@ -241,7 +247,8 @@
       $('opsRetry').addEventListener('click', () => init(true));
       return;
     }
-    if (!summary.rowCount) {
+    const matrixRows = matrixCount(summary);
+    if (!matrixRows) {
       const platformRows = Number(summary.platformRowCount) || 0;
       body.innerHTML = platformRows > 0
         ? `<div class="empty-hint ops-empty">
@@ -657,7 +664,7 @@
     renderBar();
     renderHeaderTools();
     renderBody();
-    if (state.summary && state.summary.enabled && state.summary.rowCount) await loadRows();
+    if (state.summary && state.summary.enabled && matrixCount(state.summary)) await loadRows();
   }
 
   function bindOnce() {

+ 47 - 2
server/routes/ops.js

@@ -109,6 +109,51 @@ function buildMatrix(rows, dates) {
   return groups;
 }
 
+function numOrNull(value) {
+  if (value === null || value === undefined || value === '') return null;
+  const n = Number(value);
+  return Number.isFinite(n) ? n : null;
+}
+
+/** 平台全字段行 → 矩阵行(只取 4 个可展示指标,其余字段留在 raw) */
+function platformRowToMatrix(row) {
+  const d = row.data || {};
+  return {
+    date: row.date,
+    shopKey: String(row.shopId || ''),
+    shopId: String(row.shopId || ''),
+    shopName: row.shopName || '',
+    school: row.school || '',
+    status: '',
+    order_cnt: numOrNull(d['有效订单']),
+    revenue: numOrNull(d['营业额']),
+    exposure: numOrNull(d['曝光人数']),
+    click_rate: numOrNull(d['进店转化率']),
+    raw: d,
+    quality: [],
+  };
+}
+
+/** 人工表 + 平台明细合并:同一天同一店,人工优先,平台补位 */
+async function loadMergedRows(store, { from, to, schools, shops } = {}) {
+  const manual = await store.queryRows({ from, to, schools, shops });
+  let platform = [];
+  try {
+    platform = await store.queryPlatformRows({ from, to });
+  } catch { /* 平台表不可用不影响人工数据 */ }
+  const map = new Map();
+  for (const row of manual) map.set(`${row.date}|${row.shopKey}`, row);
+  for (const row of platform) {
+    const key = `${row.date}|${row.shopId}`;
+    if (map.has(key)) continue;
+    map.set(key, platformRowToMatrix(row));
+  }
+  let rows = [...map.values()];
+  if (schools && schools.length) rows = rows.filter((r) => schools.includes(String(r.school || '')));
+  if (shops && shops.length) rows = rows.filter((r) => shops.includes(String(r.shopId || r.shopKey || '')));
+  return rows;
+}
+
 function csvCell(value) {
   const text = value == null ? '' : String(value);
   return /[",\n\r]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
@@ -349,7 +394,7 @@ function register(app, ctx) {
     const metrics = parseMetricsParam(query.metrics);
     let rows;
     try {
-      rows = await store.queryRows({ from: from || undefined, to: to || undefined, schools, shops });
+      rows = await loadMergedRows(store, { from: from || undefined, to: to || undefined, schools, shops });
     } catch (e) {
       return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
     }
@@ -477,7 +522,7 @@ function register(app, ctx) {
     const metrics = parseMetricsParam(query.metrics);
     let rows;
     try {
-      rows = await store.queryRows({ from: from || undefined, to: to || undefined, schools, shops });
+      rows = await loadMergedRows(store, { from: from || undefined, to: to || undefined, schools, shops });
     } catch (e) {
       return sendJson(res, 503, { error: '查询失败', detail: e.message || String(e) });
     }

+ 11 - 3
server/stores/ops-pg.js

@@ -311,12 +311,19 @@ function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public
 
   async function summary() {
     const poolRef = getPool();
-    const [uploadCount, rowCount, platformRowCount, range, schools, shops, last] = await Promise.all([
+    const [uploadCount, rowCount, platformRowCount, range, matrix, schools, shops, last] = await Promise.all([
       poolRef.query('SELECT COUNT(*)::int AS c FROM ops_upload'),
       poolRef.query('SELECT COUNT(*)::int AS c FROM ops_row'),
       poolRef.query('SELECT COUNT(*)::int AS c FROM ops_platform_row'),
-      poolRef.query('SELECT MIN(date) AS f, MAX(date) AS t FROM ops_row'),
-      poolRef.query('SELECT school, COUNT(*)::int AS c FROM ops_shop GROUP BY school ORDER BY school ASC'),
+      poolRef.query(`SELECT MIN(d) AS f, MAX(d) AS t FROM (
+        SELECT date AS d FROM ops_row UNION SELECT date AS d FROM ops_platform_row)`),
+      poolRef.query(`SELECT COUNT(*)::int AS c FROM (
+        SELECT date, shop_key FROM ops_row UNION SELECT date, shop_id FROM ops_platform_row)`),
+      poolRef.query(`SELECT school, COUNT(DISTINCT shop)::int AS c FROM (
+        SELECT school, shop_id AS shop FROM ops_platform_row WHERE school <> ''
+        UNION SELECT school, shop_key AS shop FROM ops_row WHERE school <> ''
+        UNION SELECT school, key AS shop FROM ops_shop WHERE school <> ''
+      ) AS t GROUP BY school ORDER BY school ASC`),
       poolRef.query('SELECT key, shop_id, shop_name, school, status FROM ops_shop ORDER BY school ASC, shop_name ASC'),
       poolRef.query('SELECT id, original_name, uploaded_at, report FROM ops_upload ORDER BY uploaded_at DESC LIMIT 1'),
     ]);
@@ -326,6 +333,7 @@ function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public
       uploadCount: uploadCount.rows[0].c,
       rowCount: rowCount.rows[0].c,
       platformRowCount: platformRowCount.rows[0].c,
+      matrixRowCount: matrix.rows[0].c,
       coverage: range.rows[0] && range.rows[0].f ? { from: String(range.rows[0].f).replace(/-/g, ''), to: String(range.rows[0].t).replace(/-/g, '') } : null,
       lastUpload: last.rows.length ? {
         id: last.rows[0].id,

+ 10 - 2
server/stores/ops-sqlite.js

@@ -350,8 +350,15 @@ function createSqliteStore({ file }) {
     const uploadCount = handle.prepare('SELECT COUNT(*) AS c FROM ops_upload').get().c;
     const rowCount = handle.prepare('SELECT COUNT(*) AS c FROM ops_row').get().c;
     const platformRowCount = handle.prepare('SELECT COUNT(*) AS c FROM ops_platform_row').get().c;
-    const range = handle.prepare('SELECT MIN(date) AS f, MAX(date) AS t FROM ops_row').get();
-    const schools = handle.prepare('SELECT school, COUNT(*) AS c FROM ops_shop GROUP BY school ORDER BY school ASC')
+    const range = handle.prepare(`SELECT MIN(d) AS f, MAX(d) AS t FROM (
+      SELECT date AS d FROM ops_row UNION SELECT date AS d FROM ops_platform_row)`).get();
+    const matrixRowCount = handle.prepare(`SELECT COUNT(*) AS c FROM (
+      SELECT date, shop_key FROM ops_row UNION SELECT date, shop_id FROM ops_platform_row)`).get().c;
+    const schools = handle.prepare(`SELECT school, COUNT(DISTINCT shop) AS c FROM (
+        SELECT school, shop_id AS shop FROM ops_platform_row WHERE school <> ''
+        UNION SELECT school, shop_key AS shop FROM ops_row WHERE school <> ''
+        UNION SELECT school, key AS shop FROM ops_shop WHERE school <> ''
+      ) GROUP BY school ORDER BY school ASC`)
       .all().map((r) => ({ name: r.school, shopCount: r.c }));
     const shops = handle.prepare('SELECT key, shop_id, shop_name, school, status FROM ops_shop ORDER BY school ASC, shop_name ASC')
       .all().map((r) => ({ key: r.key, shopId: r.shop_id, shopName: r.shop_name, school: r.school, status: r.status }));
@@ -363,6 +370,7 @@ function createSqliteStore({ file }) {
       uploadCount,
       rowCount,
       platformRowCount,
+      matrixRowCount,
       coverage: range && range.f ? { from: range.f, to: range.t } : null,
       lastUpload: last ? {
         id: last.id,