Răsfoiți Sursa

feat: report column defaults, sync step diagnostics, napos diagnose script

- default visible columns: 日期/门店名称/营业时长/异常关店时长/有效订单/
  收入/营业额/商品销售额/曝光人数/下单人数/进店转化率; derived metrics
  (下单转化率/客单价/支出/活动订单/活动补贴/ROI/复购率) opt-in via 列设置;
  prefs key bumped to v2 so the new default applies once
- sync steps carry diagnostics: request params, row count, non-zero rows,
  order/gross sums, and an automatic ⚠全零 mark when a shop-level query
  returns all-zero rows (also persisted in 同步记录)
- scripts/diagnose-napos.js: copy-run-and-report diagnoser for machines
  with the merchant client — chain control query vs shop-level param
  variants, writes a JSON report to bring back for analysis
KenZ1117 2 săptămâni în urmă
părinte
comite
424fb77ff3
4 a modificat fișierele cu 191 adăugiri și 9 ștergeri
  1. 2 0
      README.md
  2. 14 4
      napos-service.js
  3. 13 5
      public/reports.js
  4. 162 0
      scripts/diagnose-napos.js

+ 2 - 0
README.md

@@ -34,6 +34,8 @@
 - **BI 展示**:KPI 卡片 + 明细 / 按门店 / 按日期三种视图,列可自定义显示(列设置),支持排序、门店筛选、名称搜索与 CSV 导出。
 - **趋势图表**(独立菜单):环比指标条(7 项指标 vs 上期 ±%)、主指标趋势(可叠加上期虚线对比)、门店排行 Top10、转化漏斗(曝光→进店→下单)、门店对比 Top5 折线;一键导出自包含 HTML 经营周报。
 - **智能问数机器人**:仅经营分析相关页面显示的右下角悬浮助手,弹窗对话、支持上下文追问(如先问"上月营业额Top5门店"再问"那二店的单量呢"),回答可附图表并一键应用到数据采集报表。需在「系统设置 → API 设置 → 对话模型」选择对话模型(未保存时默认 deepseek-v4.1-flash,Key 留空复用生图 API Key)。
+- **同步记录诊断**:每次同步的步骤记录会携带请求参数与返回概况(行数/非零行/订单与营业额合计),门店粒度返回全零时自动标记「⚠全零」,便于远程排查。
+- **取数诊断脚本**:在装有商家客户端的电脑上运行 `node scripts/diagnose-napos.js [日期] [门店ID]`,对门店维度取数做参数对照实验并生成 JSON 诊断报告,拷回开发机即可定位取数异常。
 - **MCP 工具**:`node reports-mcp.js` 启动 stdio MCP server(reports_list_entities / reports_summary / reports_query),注册到 `~/.workbuddy/mcp.json` 即可供 Agent 直接查数,与 napos-mcp(取数)形成闭环。
 - **定时自动同步**:在「同步数据」弹窗开启后,每天到达设定时间(默认 06:00)且商家客户端在线时,自动把昨日经营数据补进本地数据湖(客户端离线则当天在线后补跑);默认关闭。同步记录照常进入同步记录列表。
 - **任务中心**:生成任务与门店采集任务统一展示,采集任务显示各门店完成进度,服务重启后被中断的采集会标记「已中断」。

+ 14 - 4
napos-service.js

@@ -266,12 +266,12 @@ async function runSync({ from, to, scope = 'all', chainId, shopIds, port } = {})
 
     const jobs = [];
     if (scope === 'all' || scope === 'company') {
-      jobs.push({ kind: 'company', label: '全公司', fn: () => callHistory({ chainId: '-1', shopListIn: '' }, { scope: 'company', chainId: '-1', chainName: '' }) });
+      jobs.push({ kind: 'company', label: '全公司', params: { chainId: '-1', shopListIn: '' }, fn: () => callHistory({ chainId: '-1', shopListIn: '' }, { scope: 'company', chainId: '-1', chainName: '' }) });
     }
     if (scope === 'all' || scope === 'chain') {
       for (const c of tree.chains) {
         jobs.push({
-          kind: 'chain', label: `连锁·${c.chainName}`,
+          kind: 'chain', label: `连锁·${c.chainName}`, params: { chainId: c.chainId, shopListIn: '' },
           fn: () => callHistory({ chainId: c.chainId, shopListIn: '' }, { scope: 'chain', chainId: c.chainId, chainName: c.chainName }),
         });
       }
@@ -285,7 +285,7 @@ async function runSync({ from, to, scope = 'all', chainId, shopIds, port } = {})
       }
       for (const s of shops) {
         jobs.push({
-          kind: 'shop', label: `门店·${s.shopName}`,
+          kind: 'shop', label: `门店·${s.shopName}`, params: { chainId: '-1', shopListIn: String(s.shopId) },
           fn: () => callHistory(
             { chainId: '-1', shopListIn: String(s.shopId) },
             { scope: 'shop', shopId: s.shopId, shopName: s.shopName, chainId: s.chainId || '', chainName: s.chainName || '' },
@@ -310,7 +310,17 @@ async function runSync({ from, to, scope = 'all', chainId, shopIds, port } = {})
         const res = store.upsertRows(entity.key, rows);
         syncState.added += res.added;
         syncState.updated += res.updated;
-        syncState.steps.push({ label: job.label, ok: true, added: res.added, updated: res.updated });
+        // 诊断信息:请求参数 + 返回数据概况,便于在「同步记录」里远程排查取数异常
+        const nonZero = rows.filter((r) => (Number(r.validOrderCnt) || 0) > 0 || (Number(r.grossAmt) || 0) > 0).length;
+        const step = {
+          label: job.label, ok: true, added: res.added, updated: res.updated,
+          rows: rows.length, nonZero,
+          ordersSum: Math.round(rows.reduce((s, r) => s + (Number(r.validOrderCnt) || 0), 0) * 100) / 100,
+          grossSum: Math.round(rows.reduce((s, r) => s + (Number(r.grossAmt) || 0), 0) * 100) / 100,
+          params: job.params || null,
+        };
+        if (rows.length && nonZero === 0) step.warn = '返回全零,疑似取数参数或数据权限异常';
+        syncState.steps.push(step);
       } catch (e) {
         syncState.failed += 1;
         syncState.steps.push({ label: job.label, ok: false, error: e.message || String(e) });

+ 13 - 5
public/reports.js

@@ -381,8 +381,14 @@
   // ---------- 列设置 ----------
 
   function loadColumnPrefs() {
-    const saved = LS.get('shanReports.columns.v1', null);
-    const defaults = new Set(state.columns.filter((c) => !c.reserved).map((c) => c.key));
+    // 默认只展示原始/主指标列(营业时长、异常关店时长、商品销售额为预留列,数据源接入后自动回填);
+    // 下单转化率、客单价、支出、活动订单、活动补贴、推广ROI、30日复购率等派生指标默认隐藏,可在列设置打开。
+    const DEFAULT_VISIBLE_COLS = new Set([
+      'bizHours', 'abnormalCloseHours', 'validOrderCnt', 'incomeAmt', 'grossAmt',
+      'goodsSaleAmt', 'expUv', 'orderUserCnt', 'clkExpRate',
+    ]);
+    const saved = LS.get('shanReports.columns.v2', null);
+    const defaults = new Set(state.columns.filter((c) => DEFAULT_VISIBLE_COLS.has(c.key)).map((c) => c.key));
     if (Array.isArray(saved)) {
       const valid = new Set(state.columns.map((c) => c.key));
       state.visible = new Set(saved.filter((k) => valid.has(k)));
@@ -412,7 +418,7 @@
       if (cb.checked) picks.add(cb.value);
     });
     state.visible = picks;
-    LS.set('shanReports.columns.v1', Array.from(picks));
+    LS.set('shanReports.columns.v2', Array.from(picks));
     $('modalReportColumns').hidden = true;
     renderTable();
   }
@@ -429,7 +435,9 @@
     const steps = (st.steps || []).slice(-8);
     $('syncSteps').innerHTML = steps.map((s) => {
       const cls = s.ok ? 'ok' : 'fail';
-      const detail = s.ok ? `+${s.added}/~${s.updated}` : (s.error || '失败').slice(0, 60);
+      const detail = s.ok
+        ? `+${s.added}/~${s.updated}` + (s.rows != null ? ` · ${s.rows}行` : '') + (s.nonZero === 0 && s.rows > 0 ? ' · ⚠全零' : '')
+        : (s.error || '失败').slice(0, 60);
       return `<div class="sync-step ${cls}"><span></span><em>${detail}</em></div>`;
     }).join('');
     const stepNames = $('syncSteps').querySelectorAll('.sync-step span');
@@ -1031,7 +1039,7 @@
     $('btnApplyColumns').addEventListener('click', applyColumnCfg);
     $('btnResetColumns').addEventListener('click', () => {
       state.visible = new Set(state.columns.filter((c) => !c.reserved).map((c) => c.key));
-      LS.set('shanReports.columns.v1', Array.from(state.visible));
+      LS.set('shanReports.columns.v2', Array.from(state.visible));
       renderColumnCfg();
       renderTable();
     });

+ 162 - 0
scripts/diagnose-napos.js

@@ -0,0 +1,162 @@
+#!/usr/bin/env node
+'use strict';
+
+// 淘宝闪购经营数据 · 门店维度取数诊断
+// ----------------------------------------------------------------------------
+// 用途:在「装了商家客户端」的电脑上运行,对门店粒度取数做参数对照实验,
+//       生成 JSON 诊断报告,拷回开发机即可定位「门店数据全为 0」一类问题。
+// 运行:node scripts/diagnose-napos.js [日期YYYYMMDD] [门店ID]
+//       · 日期缺省取昨日;门店缺省取组织树前 2 家
+//       · 全程只调用只读查询接口,不写任何业务数据
+// 输出:napos-诊断报告-<时间戳>.json(与本文件同目录)+ 控制台摘要
+// 提示:Windows 建议在「启动.bat」打开的窗口里运行(已切 UTF-8,中文不乱码)。
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const kit = require('../napos/kit.js');
+
+const PORT = Number(process.env.NAPOS_CDP_PORT || 9669);
+
+function ymdShift(ymd, delta) {
+  const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
+  d.setDate(d.getDate() + delta);
+  const z = (n) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
+}
+
+function todayYmd() {
+  const d = new Date();
+  const z = (n) => String(n).padStart(2, '0');
+  return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
+}
+
+function sumOf(rows, field) {
+  return Math.round((rows || []).reduce((s, r) => s + (Number(r[field]) || 0), 0) * 100) / 100;
+}
+
+function firstRow(r) {
+  const row = (r.daily && r.daily[0]) || null;
+  if (!row) return null;
+  const { raw, ...rest } = row;
+  return rest;
+}
+
+async function runCase(tag, params) {
+  const out = { tag, ...params, rows: 0, ordersSum: 0, grossSum: 0, expSum: 0, firstRow: null };
+  try {
+    const r = await kit.statsHistory({ port: PORT, from: params.from, to: params.to, dateType: 'self_define',
+      chainId: params.chainId, shopListIn: params.shopListIn });
+    out.rows = (r.daily || []).length;
+    out.ordersSum = sumOf(r.daily, 'valid_order_cnt');
+    out.grossSum = sumOf(r.daily, 'gross_amt');
+    out.expSum = sumOf(r.daily, 'exp_uv');
+    out.firstRow = firstRow(r);
+    out.code = r.code ?? null;
+    out.msg = r.msg || null;
+    out.risk = r.risk || false;
+    out.authFail = r.authFail || false;
+    out.ok = r.ok;
+  } catch (e) {
+    out.error = e.message || String(e);
+  }
+  return out;
+}
+
+(async () => {
+  const report = {
+    generatedAt: new Date().toISOString(),
+    platform: `${os.platform()} ${os.release()}`,
+    node: process.version,
+    port: PORT,
+    HISTORY_URL: 'https://lsycm.alibaba.com/api/overview/chain/custom/historyOverview',
+    requestBodyTemplate: '{ shopId:"", secretkey, timestamp, chainId, dateType:"self_define", key:"self_define", compareOption:"", beginDate, endDate, bizDate, shopListIn }',
+  };
+
+  console.log(`[1/4] 检查客户端调试端口 ${PORT} ...`);
+  const st = await kit.status(PORT);
+  report.status = { portOk: !!st.portOk, browser: st.browser || null, targets: (st.targets || []).length, probeError: st.probeError || null };
+  if (!st.portOk) {
+    report.conclusion = '客户端不在线:请完全退出客户端后,以 --remote-debugging-port=' + PORT + ' 参数重新启动并登录,再运行本脚本。';
+    finish(report);
+    return;
+  }
+  console.log('      已连接:', st.browser || '(未知浏览器)');
+
+  console.log('[2/4] 读取门店组织树 ...');
+  let tree;
+  try {
+    tree = await kit.shopTree({ port: PORT });
+  } catch (e) {
+    report.treeError = e.message || String(e);
+    report.conclusion = '读取组织树失败:' + report.treeError;
+    finish(report);
+    return;
+  }
+  report.merchant = tree.merchant || null;
+  report.chains = (tree.chains || []).map((c) => ({ chainId: String(c.chainId), chainName: c.chainName }));
+  const shops = (tree.shops || []).map((s) => ({ shopId: String(s.shopId), shopName: s.shopName, chainId: String(s.chainId || ''), busyLevel: s.busyLevel || null }));
+  report.shopCount = shops.length;
+
+  const testDate = /^\d{8}$/.test(process.argv[2] || '') ? process.argv[2] : ymdShift(todayYmd(), -1);
+  const chainId = (report.chains[0] || {}).chainId || '96059480';
+  const picked = [];
+  if (/^\d+$/.test(process.argv[3] || '')) {
+    const one = shops.find((s) => s.shopId === process.argv[3]);
+    if (one) picked.push(one);
+  }
+  for (const s of shops) {
+    if (picked.length >= 2) break;
+    if (!picked.includes(s)) picked.push(s);
+  }
+  report.testDate = testDate;
+  report.testShops = picked;
+  console.log(`      主体: ${(tree.merchant && tree.merchant.name) || '?'} · ${shops.length} 家门店 · 测试日期: ${testDate}`);
+
+  console.log('[3/4] 连锁维度对照查询(应有真实数据)...');
+  report.chainControl = await runCase('连锁对照·不带门店', { from: testDate, to: testDate, chainId, shopListIn: '' });
+  console.log(`      行:${report.chainControl.rows} 有效订单:${report.chainControl.ordersSum} 营业额:${report.chainControl.grossSum}`);
+
+  console.log('[4/4] 门店维度参数对照实验 ...');
+  report.cases = [];
+  for (const s of picked) {
+    const cases = [
+      { tag: `A 现行方式 chainId=-1 + shopListIn`, chainId: '-1', shopListIn: s.shopId },
+      { tag: `B 变体     chainId=${chainId} + shopListIn`, chainId, shopListIn: s.shopId },
+      { tag: `D 变体     chainId=(空)  + shopListIn`, chainId: '', shopListIn: s.shopId },
+    ];
+    for (const c of cases) {
+      const r = await runCase(c.tag, { from: testDate, to: testDate, chainId: c.chainId, shopListIn: c.shopListIn });
+      r.shopId = s.shopId;
+      r.shopName = s.shopName;
+      report.cases.push(r);
+      console.log(`      ${s.shopName} | ${c.tag} => 行:${r.rows} 单:${r.ordersSum} 额:${r.grossSum}${r.error ? ' 异常:' + r.error : ''}`);
+      await new Promise((res) => setTimeout(res, 600));
+    }
+  }
+
+  // 结论:哪个变体在门店维度返回了非零数据
+  const hit = (report.cases || []).filter((c) => c.rows > 0 && (c.ordersSum > 0 || c.grossSum > 0));
+  const tags = [...new Set(hit.map((c) => c.tag.split(' ')[0]))];
+  if (!report.chainControl.rows || report.chainControl.ordersSum === 0) {
+    report.conclusion = '连锁对照查询也为零:该日期可能无经营数据,请换一个链级行有数的日期重跑(node scripts/diagnose-napos.js 日期YYYYMMDD)。';
+  } else if (hit.length) {
+    report.conclusion = `门店维度有数据的参数形式:${tags.join('、')}。请把本报告交回开发侧修正取数参数。`;
+  } else {
+    report.conclusion = '所有参数形式下门店维度均为零:需进一步核对数据权限/门店经营状态(报告含各变体原始返回首行)。';
+  }
+  finish(report);
+})().catch((e) => {
+  console.error('诊断脚本异常:', e.message);
+  process.exit(1);
+});
+
+function finish(report) {
+  report.conclusion = report.conclusion || '见各项数据';
+  const out = path.join(__dirname, `napos-诊断报告-${Date.now()}.json`);
+  fs.writeFileSync(out, JSON.stringify(report, null, 2), 'utf8');
+  console.log('\n========== 诊断结论 ==========');
+  console.log(report.conclusion);
+  console.log('\n报告已生成,请把这个文件拷回开发机:');
+  console.log('  ' + out);
+}