Bläddra i källkod

feat: 网页一键「取数诊断」(连点侧边栏版本号 5 次显现):门店级参数对照 + 指标字段探针(下单人数/营业时长)+ 暂停营业门店清单

KenZ1117 2 veckor sedan
förälder
incheckning
bfac7b1916
8 ändrade filer med 364 tillägg och 3 borttagningar
  1. 161 1
      napos-service.js
  2. 29 0
      public/app.js
  3. 29 1
      public/index.html
  4. 98 0
      public/reports.js
  5. 12 0
      public/style.css
  6. 21 0
      scripts/diagnose-napos.js
  7. 7 1
      scripts/smoke.js
  8. 7 0
      server/routes/reports.js

+ 161 - 1
napos-service.js

@@ -188,6 +188,166 @@ async function naposStatus() {
   return out;
 }
 
+// ---------- 取数诊断(网页一键版 · 全程只读) ----------
+// 给「装了商家客户端」的电脑用:网页连点侧边栏版本号出现入口,一键跑门店级取数对照实验,
+// 报告在页面直接看 / 复制回开发侧。覆盖三类定位:
+//   · 门店维度参数(现行 chainId=-1+shopListIn 是否有数,与 B/D 变体对照)
+//   · 指标字段存在性(下单人数门店级是否缺失、营业时长/异常关店时长是否有源)
+//   · 暂停营业门店清单(busyLevel 当前状态快照)
+
+let diagnoseRunning = false;
+
+const DIAGNOSE_CORE_METRICS = ['valid_order_cnt', 'gross_amt', 'income_amt', 'expense_amt', 'exp_uv',
+  'order_user_cnt', 'clk_exp_rate', 'ord_clk_rate', 'per_actual_pay', 'act_ord_cnt',
+  'act_shop_subsidy_amt', 'shop_roi', 'rebuy_rate_30d'];
+
+function diagnoseMetricProbe(row) {
+  if (!row) return null;
+  const keys = Object.keys(row);
+  const coreMetrics = {};
+  for (const k of DIAGNOSE_CORE_METRICS) {
+    coreMetrics[k] = Object.prototype.hasOwnProperty.call(row, k) ? row[k] : '(字段不存在)';
+  }
+  return {
+    fieldCount: keys.length,
+    coreMetrics,
+    relatedExtraKeys: keys
+      .filter((k) => /user|buyer|hour|close|open_time|biz|pause|rest/i.test(k))
+      .map((k) => `${k}=${JSON.stringify(row[k])}`),
+  };
+}
+
+async function runNaposDiagnose({ date, shopId } = {}) {
+  if (diagnoseRunning) {
+    const e = new Error('已有诊断在进行中,请稍候');
+    e.code = 'DIAGNOSE_BUSY'; e.status = 429;
+    throw e;
+  }
+  diagnoseRunning = true;
+  const t0 = Date.now();
+  const report = { generatedAt: new Date().toISOString(), port: DEFAULT_CDP_PORT };
+  try {
+    const st = await kit.status(DEFAULT_CDP_PORT);
+    report.status = { portOk: !!st.portOk, browser: st.browser || null, targets: (st.targets || []).length };
+    if (!st.portOk) {
+      report.conclusion = '客户端不在线:请完全退出商家客户端,用「启动.bat」附带调试端口的方式重启并登录后再诊断。';
+      return report;
+    }
+
+    const tree = await kit.shopTree({ port: DEFAULT_CDP_PORT });
+    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;
+    report.busyLevelSummary = shops.reduce((a, s) => { const b = s.busyLevel || '(未知)'; a[b] = (a[b] || 0) + 1; return a; }, {});
+    report.pausedShops = shops.filter((s) => s.busyLevel === 'SUSPEND_BUSINESS' || s.busyLevel === 'INVALID');
+    if (!report.chains.length) {
+      report.conclusion = '组织树没有连锁信息,无法构造连锁对照实验。';
+      return report;
+    }
+    const chainId = report.chains[0].chainId;
+    const z = (n) => String(n).padStart(2, '0');
+    const 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);
+      return `${d.getFullYear()}${z(d.getMonth() + 1)}${z(d.getDate())}`;
+    };
+    const now = new Date();
+    const todayYmd = `${now.getFullYear()}${z(now.getMonth() + 1)}${z(now.getDate())}`;
+    const testDate = /^\d{8}$/.test(date || '') ? date : ymdShift(todayYmd, -1);
+    report.testDate = testDate;
+    const picked = [];
+    if (/^\d+$/.test(shopId || '')) {
+      const one = shops.find((s) => s.shopId === shopId);
+      if (one) picked.push(one);
+    }
+    for (const s of shops) {
+      if (picked.length >= 2) break;
+      if (!picked.includes(s)) picked.push(s);
+    }
+    report.testShops = picked.map((s) => ({ shopId: s.shopId, shopName: s.shopName, busyLevel: s.busyLevel || null }));
+
+    const sumOf = (rows, f) => Math.round((rows || []).reduce((s2, r) => s2 + (Number(r[f]) || 0), 0) * 100) / 100;
+    const runCase = async (tag, params) => {
+      const out = { tag, chainId: params.chainId, shopListIn: params.shopListIn, rows: 0, ordersSum: 0, grossSum: 0, expSum: 0, orderUserSum: 0 };
+      try {
+        const r = await kit.statsHistory({ port: DEFAULT_CDP_PORT, from: params.from, to: params.to, dateType: 'self_define',
+          chainId: params.chainId, shopListIn: params.shopListIn });
+        const daily = r.daily || [];
+        out.rows = daily.length;
+        out.ordersSum = sumOf(daily, 'valid_order_cnt');
+        out.grossSum = sumOf(daily, 'gross_amt');
+        out.expSum = sumOf(daily, 'exp_uv');
+        out.orderUserSum = sumOf(daily, 'order_user_cnt');
+        out.firstRow = daily[0] ? (({ raw, ...rest }) => rest)(daily[0]) : null;
+        out.metricProbe = diagnoseMetricProbe(daily[0]);
+        out.ok = !!r.ok;
+        if (r.shapeWarning) out.shapeWarning = true;
+      } catch (e) {
+        out.error = e.message || String(e);
+      }
+      return out;
+    };
+
+    report.chainControl = await runCase('连锁对照·不带门店', { from: testDate, to: testDate, chainId, shopListIn: '' });
+    report.cases = [];
+    for (const s of picked) {
+      for (const c of [
+        { tag: 'A 现行 chainId=-1+shopListIn', chainId: '-1' },
+        { tag: `B 变体 chainId=${chainId}+shopListIn`, chainId },
+        { tag: 'D 变体 chainId=(空)+shopListIn', chainId: '' },
+      ]) {
+        const r = await runCase(c.tag, { from: testDate, to: testDate, chainId: c.chainId, shopListIn: s.shopId });
+        r.shopId = s.shopId;
+        r.shopName = s.shopName;
+        report.cases.push(r);
+        await new Promise((res) => setTimeout(res, 600));
+      }
+    }
+    const chainKeys = new Set(Object.keys(report.chainControl.firstRow || {}));
+    for (const c of report.cases) {
+      if (!c.firstRow) continue;
+      c.missingVsChain = [...chainKeys].filter((k) => !(k in c.firstRow));
+    }
+
+    // 结论:把开发侧关心的判定直接写成人话
+    const lines = [];
+    const cc = report.chainControl;
+    if (!cc || !cc.rows || !cc.ordersSum) {
+      lines.push('连锁对照查询也为零:该日期可能无经营数据,换一个链级行有数的日期再诊断一次。');
+    } else {
+      lines.push(`连锁对照正常(${cc.rows} 行 / 有效订单 ${cc.ordersSum} / 营业额 ${cc.grossSum})。`);
+      const hit = report.cases.filter((c) => c.rows > 0 && (c.ordersSum > 0 || c.grossSum > 0));
+      if (hit.length) {
+        lines.push(`门店维度有数据的参数形式:${[...new Set(hit.map((c) => c.tag.split(' ')[0]))].join('、')}——把本报告发回开发侧即可修正取数参数。`);
+      } else {
+        lines.push('所有参数形式下门店维度均为零:需核对数据权限/门店经营状态(详见各变体原始返回首行)。');
+      }
+      const probeSrc = hit[0] || report.cases.find((c) => c.metricProbe);
+      const probe = probeSrc && probeSrc.metricProbe;
+      if (probe) {
+        const ouc = probe.coreMetrics.order_user_cnt;
+        lines.push(ouc === '(字段不存在)'
+          ? '门店级返回中「下单人数(order_user_cnt)」字段不存在——上游门店粒度不提供该指标,报表该列将恒为 0。'
+          : `门店级「下单人数(order_user_cnt)」字段存在,样本值 ${JSON.stringify(ouc)}。`);
+      }
+      const hourKeys = [cc, ...report.cases]
+        .flatMap((c) => (c.metricProbe && c.metricProbe.relatedExtraKeys) || []);
+      lines.push(hourKeys.length
+        ? `原始返回中与时长/关店相关的键:${[...new Set(hourKeys)].slice(0, 8).join(';')}`
+        : '原始返回中没有任何营业时长/异常关店相关字段——这两列为预留列,上游接口暂不提供。');
+    }
+    report.conclusionLines = lines;
+    report.conclusion = lines.join(' ');
+    return report;
+  } finally {
+    report.durationMs = Date.now() - t0;
+    diagnoseRunning = false;
+  }
+}
+
 // ---------- 主体结构(多主体 tab 的来源) ----------
 
 let treeCache = { at: 0, tree: null };
@@ -519,7 +679,7 @@ function rowsToCsv(columns, rows) {
 
 module.exports = {
   DEFAULT_CDP_PORT, REPORT_COLUMNS, FIELD_MAP,
-  naposStatus, fetchTree,
+  naposStatus, fetchTree, runNaposDiagnose,
   runSync, syncSnapshot, requestSyncCancel,
   queryReport, aggregateReport, summarizeRows,
   rowsToCsv,

+ 29 - 0
public/app.js

@@ -408,6 +408,35 @@
 
   navItems.forEach((n) => n.addEventListener('click', () => switchView(n.dataset.view)));
 
+  // 隐藏诊断入口:连点侧边栏版本号 5 次(2 秒内不重置计数)显现数据采集页的「取数诊断」按钮。
+  // 开发者功能,常规用户不可见;页面刷新后重新隐藏。
+  {
+    const ver = document.getElementById('sidebarVersion');
+    const diagBtn = document.getElementById('btnNaposDiagnose');
+    if (ver && diagBtn) {
+      const verText = ver.textContent;
+      let clicks = 0, resetTimer = null, revertTimer = null;
+      ver.style.cursor = 'default';
+      ver.addEventListener('click', () => {
+        if (!diagBtn.hidden) return;
+        clicks += 1;
+        clearTimeout(resetTimer);
+        resetTimer = setTimeout(() => { clicks = 0; }, 2000);
+        clearTimeout(revertTimer);
+        if (clicks >= 3 && clicks < 5) {
+          ver.textContent = `再点 ${5 - clicks} 次开启诊断`;
+          revertTimer = setTimeout(() => { ver.textContent = verText; }, 2500);
+        }
+        if (clicks >= 5) {
+          clicks = 0;
+          ver.textContent = '诊断已开启';
+          revertTimer = setTimeout(() => { ver.textContent = verText; }, 2000);
+          diagBtn.hidden = false;
+        }
+      });
+    }
+  }
+
   const requestedView = new URLSearchParams(location.search).get('view');
   switchView(views[requestedView] ? requestedView : 'crawl');
 

+ 29 - 1
public/index.html

@@ -49,7 +49,7 @@
     <button class="nav-item" data-view="logs"><span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 5h16v11H8l-4 4Z"/><path d="M8 9h8M8 12h5"/></svg></span><span>系统日志</span></button>
 
     <div class="sidebar-bottom">
-      <span>淘宝闪购 · v1.0.0</span>
+      <span id="sidebarVersion">淘宝闪购 · v1.0.0</span>
       <img src="/assets/powered-by.png" alt="Hyreal IDE" class="sidebar-powered">
     </div>
   </aside>
@@ -287,6 +287,7 @@
                 <div class="help-popover-note">注意:客户端每次重启都必须带参启动,否则端口不会开启。</div>
               </div>
             </span>
+            <button class="btn btn-ghost btn-small" id="btnNaposDiagnose" hidden>取数诊断</button>
             <button class="btn btn-ghost btn-small" id="btnReportRefresh">
               <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12a8 8 0 1 1-2.3-5.6"/><path d="M20 4v5h-5"/></svg>
               刷新
@@ -894,6 +895,33 @@
   </div>
 </div>
 
+<!-- 取数诊断弹窗(隐藏入口:连点侧边栏版本号 5 次显现) -->
+<div class="modal-overlay" id="modalNaposDiagnose" hidden>
+  <div class="modal modal-wide">
+    <div class="modal-header">
+      <div class="modal-title">
+        <span class="modal-status-icon">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a4.5 4.5 0 0 0-6 6L3 18l3 3 5.7-5.7a4.5 4.5 0 0 0 6-6L14 13l-3-3 3.7-3.7z"/></svg>
+        </span>
+        <span>取数诊断</span>
+      </div>
+      <button class="modal-close" id="btnCloseDiagnose">&times;</button>
+    </div>
+    <div class="modal-body">
+      <div class="diagnose-summary" id="diagnoseSummary">正在诊断,约需半分钟,请勿关闭…</div>
+      <details class="diagnose-raw">
+        <summary>原始报告 JSON(复制或下载发给开发即可)</summary>
+        <pre id="diagnoseJson"></pre>
+      </details>
+    </div>
+    <div class="modal-footer">
+      <button class="btn btn-ghost btn-small" id="btnCopyDiagnose">复制报告</button>
+      <button class="btn btn-ghost btn-small" id="btnDownloadDiagnose">下载报告</button>
+      <button class="btn btn-primary btn-small" id="btnDismissDiagnose">关闭</button>
+    </div>
+  </div>
+</div>
+
 <!-- 数据同步弹窗 -->
 <div class="modal-overlay" id="modalReportSync" hidden>
   <div class="modal modal-wide">

+ 98 - 0
public/reports.js

@@ -1333,6 +1333,11 @@
       await loadReport();
       showToast('已刷新');
     });
+    $('btnNaposDiagnose').addEventListener('click', runNaposDiagnose);
+    $('btnCloseDiagnose').addEventListener('click', closeDiagnoseModal);
+    $('btnDismissDiagnose').addEventListener('click', closeDiagnoseModal);
+    $('btnCopyDiagnose').addEventListener('click', copyDiagnoseReport);
+    $('btnDownloadDiagnose').addEventListener('click', downloadDiagnoseReport);
 
     $('btnClientHelp').addEventListener('click', (event) => {
       event.stopPropagation();
@@ -1549,6 +1554,99 @@
       });
   }
 
+  // ---------- 取数诊断(隐藏入口:连点侧边栏版本号 5 次) ----------
+
+  const BUSY_LABEL = {
+    OPEN: '营业中', CLOSE: '休息中(非营业时段)', SUSPEND_BUSINESS: '暂停营业', INVALID: '未生效/已下架', '(未知)': '未知',
+  };
+  let lastDiagnoseReport = null;
+
+  async function runNaposDiagnose() {
+    const btn = $('btnNaposDiagnose');
+    btn.disabled = true;
+    btn.textContent = '诊断中…';
+    $('diagnoseSummary').innerHTML = '<div class="help">正在诊断,约需半分钟,请勿关闭…</div>';
+    $('diagnoseJson').textContent = '';
+    $('modalNaposDiagnose').hidden = false;
+    try {
+      const res = await fetch('/api/reports/diagnose/napos', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+      const data = await res.json();
+      if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
+      lastDiagnoseReport = data;
+      renderDiagnoseReport(data);
+    } catch (e) {
+      closeDiagnoseModal();
+      showToast(`诊断失败:${e.message}`, 'error');
+    } finally {
+      btn.disabled = false;
+      btn.textContent = '取数诊断';
+    }
+  }
+
+  function closeDiagnoseModal() { $('modalNaposDiagnose').hidden = true; }
+
+  function diagnoseRow(label, value) {
+    return `<div><span class="diagnose-k">${esc(label)}</span>${value}</div>`;
+  }
+
+  function renderDiagnoseReport(r) {
+    const busyText = Object.entries(r.busyLevelSummary || {}).map(([k, n]) => `${BUSY_LABEL[k] || k} ${n}`).join(',') || '无门店';
+    const paused = r.pausedShops || [];
+    const cases = r.cases || [];
+    const cc = r.chainControl || {};
+    const caseRows = cases.map((c) => `<tr>
+      <td>${esc((c.shopName || '') + ' · ' + c.tag)}</td>
+      <td class="num">${c.rows ?? '–'}</td>
+      <td class="num">${c.ordersSum ?? '–'}</td>
+      <td class="num">${c.grossSum ?? '–'}</td>
+      <td class="num">${c.orderUserSum ?? '–'}</td>
+      <td>${c.error ? `<span class="badge badge-fail">异常</span>` : (c.metricProbe ? `字段 ${c.metricProbe.fieldCount} 个` : '–')}</td>
+    </tr>`).join('');
+    $('diagnoseSummary').innerHTML = `
+      <div class="diagnose-block">
+        ${diagnoseRow('客户端', r.status && r.status.portOk ? `已连接(${esc(r.status.browser || '')})` : '不在线')}
+        ${diagnoseRow('主体', `${esc((r.merchant && r.merchant.name) || '?')}(${r.shopCount ?? 0} 家门店 · 测试日期 ${esc(r.testDate || '–')})`)}
+        ${diagnoseRow('门店状态', esc(busyText))}
+        ${diagnoseRow('暂停营业/下架', paused.length ? paused.map((s) => esc(s.shopName)).join('、') : '无')}
+        ${diagnoseRow('连锁对照', cc.rows ? `${cc.rows} 行 · 有效订单 ${cc.ordersSum} · 营业额 ${cc.grossSum} · 下单人数 ${cc.orderUserSum} · 字段 ${(cc.metricProbe && cc.metricProbe.fieldCount) ?? '–'} 个` : '无数据')}
+      </div>
+      ${cases.length ? `<table class="data-table diagnose-table"><thead><tr><th>取数变体</th><th>行</th><th>有效订单</th><th>营业额</th><th>下单人数</th><th>原始返回</th></tr></thead><tbody>${caseRows}</tbody></table>` : ''}
+      ${r.conclusionLines && r.conclusionLines.length
+        ? `<div class="diagnose-conclusion">${r.conclusionLines.map((l) => `<div>· ${esc(l)}</div>`).join('')}</div>`
+        : (r.conclusion ? `<div class="diagnose-conclusion">${esc(r.conclusion)}</div>` : '')}
+    `;
+    $('diagnoseJson').textContent = JSON.stringify(r, null, 2);
+  }
+
+  function copyDiagnoseReport() {
+    if (!lastDiagnoseReport) return;
+    const text = JSON.stringify(lastDiagnoseReport, null, 2);
+    if (navigator.clipboard && navigator.clipboard.writeText) {
+      navigator.clipboard.writeText(text).then(() => showToast('已复制,粘贴发给开发即可', 'success'), () => fallbackCopyDiagnose(text));
+    } else {
+      fallbackCopyDiagnose(text);
+    }
+  }
+
+  function fallbackCopyDiagnose(text) {
+    const ta = document.createElement('textarea');
+    ta.value = text;
+    document.body.appendChild(ta);
+    ta.select();
+    try { document.execCommand('copy'); showToast('已复制,粘贴发给开发即可', 'success'); } catch { showToast('复制失败,请改用「下载报告」', 'error'); }
+    ta.remove();
+  }
+
+  function downloadDiagnoseReport() {
+    if (!lastDiagnoseReport) return;
+    const blob = new Blob([JSON.stringify(lastDiagnoseReport, null, 2)], { type: 'application/json' });
+    const a = document.createElement('a');
+    a.href = URL.createObjectURL(blob);
+    a.download = `napos-诊断报告-${Date.now()}.json`;
+    a.click();
+    URL.revokeObjectURL(a.href);
+  }
+
   // ---------- 初始化 ----------
 
   // 数据采集页工具栏:推送当前主体到云端(本地湖 → 钉钉 AI 表格)。返回是否推送成功。

+ 12 - 0
public/style.css

@@ -1123,3 +1123,15 @@ main.content { padding: var(--page-padding-y) var(--page-padding-x) var(--page-p
 .report-table.raw-cols th, .report-table.raw-cols td { white-space: nowrap; }
 /* 中台页内 tab 条(湖全景/明细数据) */
 .lake-tabs { margin-bottom: 14px; max-width: fit-content; }
+
+/* ── 取数诊断弹窗(隐藏入口:连点侧边栏版本号 5 次) ───────── */
+#modalNaposDiagnose .modal { width: min(720px, calc(100vw - 48px)); }
+.diagnose-summary { display: grid; gap: 12px; font-size: 13px; }
+.diagnose-block { display: grid; gap: 6px; }
+.diagnose-k { display: inline-block; min-width: 110px; color: var(--text-2); }
+.diagnose-table { margin: 2px 0; font-size: 12.5px; }
+.diagnose-table th, .diagnose-table td { white-space: nowrap; }
+.diagnose-conclusion { display: grid; gap: 4px; padding: 10px 12px; border-radius: 8px; background: var(--warn-bg); border: 1px solid var(--border); font-size: 13px; }
+.diagnose-raw { margin-top: 12px; font-size: 12.5px; }
+.diagnose-raw summary { cursor: pointer; color: var(--text-2); }
+.diagnose-raw pre { max-height: 300px; overflow: auto; margin-top: 8px; padding: 10px; border-radius: 8px; background: var(--border-light); white-space: pre-wrap; word-break: break-all; font-size: 12px; }

+ 21 - 0
scripts/diagnose-napos.js

@@ -42,6 +42,25 @@ function firstRow(r) {
   return rest;
 }
 
+// 13 个核心指标的门店级存在性探针:区分「字段不存在」与「存在但为 0/空」,
+// 回答「下单人数门店级全 0」「营业时长/异常关店时长取不到」是上游不给还是键名不同。
+const CORE_METRICS = ['valid_order_cnt', 'gross_amt', 'income_amt', 'expense_amt', 'exp_uv',
+  'order_user_cnt', 'clk_exp_rate', 'ord_clk_rate', 'per_actual_pay', 'act_ord_cnt',
+  'act_shop_subsidy_amt', 'shop_roi', 'rebuy_rate_30d'];
+
+function metricProbe(row) {
+  if (!row) return null;
+  const keys = Object.keys(row);
+  const coreMetrics = {};
+  for (const k of CORE_METRICS) {
+    coreMetrics[k] = Object.prototype.hasOwnProperty.call(row, k) ? row[k] : '(字段不存在)';
+  }
+  const relatedExtraKeys = keys
+    .filter((k) => /user|buyer|hour|close|open_time|biz|pause|rest/i.test(k))
+    .map((k) => `${k}=${JSON.stringify(row[k])}`);
+  return { fieldCount: keys.length, coreMetrics, relatedExtraKeys };
+}
+
 async function runCase(tag, params) {
   const out = { tag, ...params, rows: 0, ordersSum: 0, grossSum: 0, expSum: 0, firstRow: null };
   try {
@@ -51,7 +70,9 @@ async function runCase(tag, params) {
     out.ordersSum = sumOf(r.daily, 'valid_order_cnt');
     out.grossSum = sumOf(r.daily, 'gross_amt');
     out.expSum = sumOf(r.daily, 'exp_uv');
+    out.orderUserSum = sumOf(r.daily, 'order_user_cnt');
     out.firstRow = firstRow(r);
+    out.metricProbe = metricProbe((r.daily || [])[0]);
     out.code = r.code ?? null;
     out.msg = r.msg || null;
     out.risk = r.risk || false;

+ 7 - 1
scripts/smoke.js

@@ -121,7 +121,7 @@ async function main() {
   const tmpMainConfig = path.join(tmpMain, 'config.json');
   fs.writeFileSync(tmpMainConfig, JSON.stringify({ aitable: { enabled: false } }));
   const child = spawn(process.execPath, [path.join(ROOT, 'server.js')], {
-    env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', GENPIC_LAKE_DIR: tmpMainLake, GENPIC_CONFIG: tmpMainConfig },
+    env: { ...process.env, PORT: String(port), HOST: '127.0.0.1', GENPIC_LAKE_DIR: tmpMainLake, GENPIC_CONFIG: tmpMainConfig, NAPOS_CDP_PORT: '1' },
     stdio: ['ignore', 'pipe', 'pipe'],
   });
   let bootLog = '';
@@ -318,6 +318,12 @@ async function main() {
       const r = await request(port, 'POST', '/api/reports/aitable/push', {});
       expect(r.status === 400 && r.json().code === 'AITABLE_NO_ENTITY', `status=${r.status} code=${r.json().code}`);
     });
+    await check('POST /api/reports/diagnose/napos(客户端离线 → 报告带指引)', async () => {
+      const r = await request(port, 'POST', '/api/reports/diagnose/napos', {});
+      const j = r.json();
+      expect(r.status === 200 && j.status && j.status.portOk === false && /客户端/.test(j.conclusion || ''),
+        `status=${r.status} conclusion=${j.conclusion}`);
+    });
 
     // ---- 钉钉三表拆分与行键(纯函数回归,不打真实钉钉 API) ----
     const aitableLib = require('../server/services/aitable.js');

+ 7 - 0
server/routes/reports.js

@@ -167,6 +167,13 @@ function register(app, ctx) {
     sendJson(res, 200, r);
   });
 
+  // 取数诊断(网页一键 · 只读):门店级参数对照 + 指标字段探针 + 暂停营业清单
+  app.post('/api/reports/diagnose/napos', async ({ req, res }) => {
+    const body = await readBody(req);
+    const r = await naposService.runNaposDiagnose({ date: body.date, shopId: body.shopId });
+    sendJson(res, 200, r);
+  });
+
   app.get('/api/reports/aitable/status', async ({ res, query }) => {
     const entity = query.entity || (naposService.store.listEntities()[0] || {}).key || '';
     const a = ctx.config.aitable || {};