瀏覽代碼

feat: 钉钉 AI 表格三表单粒度拆分 + 智能问数两段式回复 + 行键补连锁ID

钉钉侧(云端按维度拆表,AI 问数/聚合不再跨维度算重):
- 三张表:门店明细(shop)/连锁汇总(chain)/公司汇总(company),同一套 23 列;
  summary 区间汇总行不上云;旧单表 sheetName 废弃,配置键 sheetDetail/Chain/Company
  (读取处兜底默认值,老 config 零迁移),表名指纹变化自动全量重推
- pushLake 分流逐表 upsert(冲突/全零保护语义保留,全零拦截仅对门店明细);
  pullLake 三表合并回灌;testConnection 逐表探测;定时自动同步后补推送(修漏)
- 行键修复:本地 rowKey 与云端业务键加入连锁ID——同日多连锁/多汇总行
  此前会互相覆盖只留一条(历史数据需重跑同步回补)

站内问数(两段式,reply 能报真实数字):
- 第一段 prompt 去掉 reply 字段,flash 专注查询规划;行数据仍不出网
- 第二段把 Top≤10 聚合结果+区间合计回传模型写结论句,失败降级模板句;
  buildResultDigest 纯函数导出;前端对话历史摘要补充指标名

smoke 63/63:分流/新行键/digest 纯函数回归 + 原有全量用例
文档:docs/钉钉问数上线指引.md(含实测问题清单与验收标准)
KenZ1117 2 周之前
父節點
當前提交
384bd25238

+ 2 - 2
README.md

@@ -34,7 +34,7 @@
 - **BI 展示**:KPI 卡片 + 明细 / 按门店 / 按日期三种视图,列可自定义显示(列设置),支持排序、门店筛选、名称搜索与 CSV 导出。
 - **趋势图表**(独立菜单):环比指标条(7 项指标 vs 上期 ±%)、主指标趋势(可叠加上期虚线对比)、门店排行 Top10、转化漏斗(曝光→进店→下单)、门店对比 Top5 折线;一键导出自包含 HTML 经营周报。
 - **智能问数机器人**:仅经营分析相关页面显示的右下角悬浮助手,弹窗对话、支持上下文追问(如先问"上月营业额Top5门店"再问"那二店的单量呢"),回答可附图表并一键应用到数据采集报表。需在「系统设置 → API 设置 → 对话模型」选择对话模型(未保存时默认 deepseek-v4.1-flash,Key 留空复用生图 API Key)。
-- **钉钉 AI 表格同步**:把数据湖增量推送到钉钉 AI 表格(在线主档案/共享层),实现一人取数、多人共享、在线存档。其他电脑在「系统设置 → 钉钉 AI 表格」点「从云端拉取」即可补全本地数据(新账号自动注册主体),报表/图表/问数照常使用。需钉钉企业内部应用(开通 AI 表格 API 权限),配置 AppKey/AppSecret/Base ID/表名。
+- **钉钉 AI 表格同步(三表单粒度)**:把数据湖增量推送到钉钉 AI 表格(在线主档案/共享层),云端按维度拆三张表——门店明细 / 连锁汇总 / 公司汇总,每张表内粒度唯一,钉钉 AI 问数或人工透视都不会跨维度把数字加翻倍;区间汇总行不上云(可由门店行推导)。其他电脑在「系统设置 → 钉钉 AI 表格」点「从云端拉取」即可补全本地数据(新账号自动注册主体),报表/图表/问数照常使用。需钉钉企业内部应用(开通 AI 表格 API 权限),配置 AppKey/AppSecret/Base ID/三张表名(留空用默认名)。定时自动同步完成后也会自动推送云端。
 - **同步记录诊断**:每次同步的步骤记录会携带请求参数与返回概况(行数/非零行/订单与营业额合计),门店粒度返回全零时自动标记「⚠全零」,便于远程排查。
 - **取数诊断脚本**:在装有商家客户端的电脑上运行 `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(取数)形成闭环。
@@ -94,7 +94,7 @@
 | Cookie | 用于淘宝闪购后台采集,仅保存在本地 `session.json`。 |
 | `accessToken` | 局域网访问口令:设置后其他电脑访问本服务需输入口令(本机 127.0.0.1 不受影响)。留空保持不变,输入 `-` 清除。也可用环境变量 `ACCESS_TOKEN` 临时开关(不落盘)。 |
 | `autoSync` | 每日自动同步:`{ "enabled": true, "time": "06:00" }`,也可在「同步数据」弹窗里开关。 |
-| `aitable` | 钉钉 AI 表格同步:`{ "enabled": true, "appKey": "...", "appSecret": "...", "baseId": "...", "sheetName": "经营数据" }`,也可在「系统设置 → 钉钉 AI 表格」配置。
+| `aitable` | 钉钉 AI 表格同步:`{ "enabled": true, "appKey": "...", "appSecret": "...", "baseId": "...", "sheetDetail": "门店明细", "sheetChain": "连锁汇总", "sheetCompany": "公司汇总" }`,三张表名留空用默认值;也可在「系统设置 → 钉钉 AI 表格」配置。问数落地钉钉的实测问题清单见 `docs/钉钉问数上线指引.md`。
 | `httpLog` | `true` 时在控制台打印每个 HTTP 请求(5xx 与超过 1 秒的慢请求始终打印)。 |
 
 API Key 获取与充值入口可在“系统设置 → API 设置”中打开。

+ 57 - 0
docs/钉钉问数上线指引.md

@@ -0,0 +1,57 @@
+# 钉钉问数上线指引
+
+> 背景:站内「智能问数」已定型为兜底入口(本地取数、两段式回答),数据消费的主场随钉钉 AI 表格迁到钉钉。
+> 云端表按维度拆成三张单粒度表后,才能放心在钉钉侧用 AI 问数 / 透视表做聚合——否则任何求和都会把门店、连锁、公司行加总,数字直接翻几倍。
+
+## 一、云端表结构(已就绪)
+
+同一 Base 下三张数据表,每张表内粒度唯一:
+
+| 数据表 | 存什么 | 对应本地 scope | 业务键 |
+|---|---|---|---|
+| 门店明细 | 日期 × 门店的逐日指标 | `shop` | 日期|账号名|门店维度|连锁ID|门店ID |
+| 连锁汇总 | 日期 × 连锁的逐日汇总 | `chain` | 日期|账号名|连锁维度|连锁ID |
+| 公司汇总 | 日期 × 账号(全公司)逐日汇总 | `company` | 日期|账号名|公司维度 |
+
+- 三张表同一套 23 列(7 个维度列 + 16 个指标列),缺维度的列留空:
+  `日期、账号名、维度、门店ID、门店名称、连锁ID、连锁名称、有效订单、收入(元)、营业额(元)、支出(元)、曝光人数、下单人数、进店转化率、下单转化率、客单价(元)、活动订单、活动补贴(元)、推广ROI、30日复购率、营业时长(h)、异常关店时长(h)、商品销售额(元)`
+- 本地区间汇总行(scope=summary)不上云:可由门店行推导,混入任何一张表都会造成表内算重。
+- 旧「数据表」(混维度单表)已停写归档,确认新表数据无误后可在钉钉里手动删除。
+- 表名可在「系统设置 → 钉钉 AI 表格」修改(留空用默认名);表名变更后首次推送会自动全量重推。
+
+## 二、建表说明(已完成,备用)
+
+本次三张表由程序通过钉钉 notable API 自动创建(应用自身凭据)。如日后需要在新 Base 重建:
+
+- 建表:`POST /v1.0/notable/bases/{baseId}/sheets?operatorId=…`,请求体 `{ "name": "门店明细", "columns": [{ "name": "日期", "type": "text" }, …] }`(维度列 `text`、指标列 `number`)
+- 补列:`POST …/sheets/{表名}/fields?operatorId=…`,请求体 `{ "name": "营业额(元)", "type": "number" }`
+- ⚠ 建表/建列接口有应用级限流:突发约 5 次后返回 500 `internalError`,慢速补列即可(每列间隔几秒);记录读写接口不受影响
+- ⚠ 记录写入遇到不存在的列名会整批 404(`failed to find the field '…'`)——先建齐列再推送
+
+## 三、数据推送与核对
+
+1. 「数据中台」页对应主体点「推送」,或同步完成后自动推送(含定时自动同步)。
+2. 首次切换到三表后推送为全量(表名指纹变化自动触发,无需手动强推)。
+3. 核对口径:三张云端表行数之和 = 本地湖行数 − 本地区间汇总行数;门店行大量全零会被「全零拦截」挡下(确认无误可强推)。
+4. 历史连锁/汇总行:旧版本行键缺连锁ID,同日多连锁曾互相覆盖。修复后新同步自动正确;如需回补历史,在装有商家客户端的机器上对历史区间重跑一次同步再推送。
+
+## 四、钉钉侧问数实测清单(验收 = 数字与站内报表一致)
+
+在钉钉里打开该 AI 表格(或建一个绑定此表的钉钉 AI 助理),逐条问:
+
+| # | 问题 | 期望 |
+|---|---|---|
+| 1 | 上个月总营业额是多少 | 只汇总「公司汇总」表(或门店明细求和),数字 = 站内经营分析同区间营业额 |
+| 2 | 上个月营业额 Top10 门店 | 来自「门店明细」,排序与站内排行一致 |
+| 3 | 各连锁上个月营业额对比 | 来自「连锁汇总」,只有连锁粒度、不含公司/门店行 |
+| 4 | 昨天XX门店的有效订单和客单价 | 单门店单日,与站内明细一致 |
+| 5 | 最近 30 天营业额趋势 | 按日期汇总,无翻倍 |
+| 6 | 进店转化率最近一周变化 | 比率类不做求和(应取均值或按日展示) |
+
+验收要点:**任一问题的答案都不能出现跨维度加总**(如门店+连锁+公司相加)。若钉钉 AI 在单表上仍算错,检查它是否绑定到了正确的一张表。
+
+## 五、站内问数(兜底)现状
+
+- 机制:第一段对话模型只做「问题 → 结构化查询」的规划(指标语义 + 门店清单进 prompt,行数据不出网),本地数据湖执行;第二段把 Top≤10 聚合结果回传模型写出带真实数字的结论句,失败自动降级模板句。
+- 配置:「系统设置 → API 设置 → 对话模型」(默认 deepseek-v4.1-flash,Key 留空复用生图 Key)。
+- 定位:采集机上的日常快查;团队/手机端问数走钉钉。

+ 77 - 7
nl-query.js

@@ -1,12 +1,15 @@
 'use strict';
 
 /**
- * 智能问数 · NL2Query
+ * 智能问数 · NL2Query(两段式)
  * ----------------------------------------------------------------------------
  * 参考 spring-ai-alibaba/DataAgent 的「意图理解 → 查询规划 → 报告呈现」链路,
  * 但不引入 SQL/向量库/Java 栈:
- *   · 语义对齐:基于 napos-service 的列注册表(同义词 + 口径描述),一个 prompt 装得下
- *   · 查询执行:把自然语言翻译成现有 /api/reports 查询参数,复用数据湖查询
+ *   · 第一段(规划):基于 napos-service 列注册表(同义词 + 口径)把问题翻译成
+ *     结构化查询 JSON —— 行数据不出网,模型只做规划不做计算
+ *   · 查询执行:本地数据湖取数/聚合/排序(sanitizeQuery 钳制参数)
+ *   · 第二段(结论):把聚合后的少量结果(Top≤10 行 + 区间合计)发回模型,
+ *     写一句带真实数字的回答;失败自动降级为模板句
  *   · 结果呈现:reply 文本 + ECharts 图表配置(前端渲染)
  *
  * 需要 OpenAI 兼容的对话模型(config.chatModel / chatApiKey / chatApiBase)。
@@ -104,7 +107,6 @@ ${shopLines || '(暂无门店清单,shopNames 留空)'}
 
 ## 输出格式(只输出一个 JSON 对象,不要输出其他文字)
 {
-  "reply": "结合对话上下文,用一两句话直接回答用户本轮问题(可沿用上一轮已确认的门店/指标);不要编造具体数字",
   "mode": "detail|shop|date",        // detail=按日期×门店明细; shop=按门店汇总; date=按日期汇总。问"每家/各门店/排行/Top"用 shop;问"每天/趋势/最近几天"用 date;问具体某天各门店用 detail
   "from": "YYYYMMDD",                // 缺省 = 昨天-29
   "to": "YYYYMMDD",                  // 缺省 = 昨天
@@ -121,7 +123,7 @@ ${shopLines || '(暂无门店清单,shopNames 留空)'}
 - 对话可能带上下文(之前的问答):用户说"那它呢/换成二店/再看单量"时,结合上一轮的门店与指标补全本轮查询
 - 指标同义词要对齐:如"流水/GMV/销售额"→grossAmt,"单量"→validOrderCnt,"进店率"→clkExpRate,"访购率"→ordClkRate
 - "上月/最近30天/本周"等要换算成具体 from/to,且不晚于 ${dateRange.to}
-- 预留列(营业时长/异常关店时长/商品销售额)没有数据:用户问到时,reply 里说明该数据源暂未接入,metrics 仍可带上(展示为空)
+- 预留列(营业时长/异常关店时长/商品销售额)没有数据:用户问到时 metrics 仍可带上(展示为空),无需额外说明
 - 除非用户明确要全部门店,shop 模式默认 limit 10`;
   const msgs = [{ role: 'system', content: system }];
   for (const h of (Array.isArray(history) ? history : []).slice(-8)) {
@@ -207,6 +209,73 @@ function executeQuery(entityKey, query, svc) {
   };
 }
 
+// ---------- 第二段:结果结论(少量聚合数据回传模型写结论句) ----------
+
+const MODE_LABEL = { shop: '按门店汇总', date: '按日期汇总', detail: '日期×门店明细' };
+const ymdDash = (v) => String(v || '').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3');
+
+function colRegistry() { return require('./napos-service.js').REPORT_COLUMNS; }
+
+/** 与前端报表一致的数值格式化(pct 存小数显示乘 100) */
+function fmtVal(type, v) {
+  const n = Number(v);
+  if (v == null || v === '' || !Number.isFinite(n)) return '无数据';
+  if (type === 'pct') return `${(n * 100).toFixed(2)}%`;
+  if (type === 'money') return `${n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}元`;
+  if (type === 'int') return n.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
+  return n.toFixed(2);
+}
+
+/** 把执行结果压缩成给模型看的摘要(纯函数,smoke 直接回归):
+ *  口径行 + Top≤10 行(店名/日期 × 所选指标)+ 区间合计/均值 + 空结果说明。 */
+function buildResultDigest(query, result) {
+  const reg = new Map(colRegistry().map((c) => [c.key, c]));
+  const label = (k) => (reg.get(k) || {}).label || k;
+  const type = (k) => (reg.get(k) || {}).type || 'num';
+  const lines = [];
+  lines.push(`口径:${MODE_LABEL[query.mode] || query.mode},区间 ${ymdDash(query.from)} ~ ${ymdDash(query.to)}`
+    + (query.shopIds && query.shopIds.length ? `,已限定 ${query.shopIds.length} 家门店` : '')
+    + `,指标:${query.metrics.map(label).join('、')}`);
+  const rows = (result.rows || []).slice(0, 10);
+  for (const r of rows) {
+    const name = query.mode === 'date' ? ymdDash(r.date)
+      : query.mode === 'shop' ? String(r.shopName || r.shopId || '')
+      : `${ymdDash(r.date)} ${r.shopName || ''}`;
+    lines.push(`${name}:${query.metrics.map((k) => `${label(k)} ${fmtVal(type(k), r[k])}`).join(',')}`);
+  }
+  if ((result.rows || []).length > 10) lines.push(`(其余 ${result.rows.length - 10} 行略)`);
+  const s = result.summary || {};
+  const totals = query.metrics
+    .map((k) => `${label(k)}${type(k) === 'pct' ? '均值' : '合计'} ${fmtVal(type(k), s[k])}`)
+    .filter((t) => !t.endsWith('无数据'));
+  if (totals.length) lines.push(`区间合计:${totals.join(',')}`);
+  if (!rows.length) lines.push('(查询结果为空:区间内可能没有数据)');
+  return lines.join('\n');
+}
+
+/** 第二段失败时的兜底话术(不引用数字,保证接口形状不变) */
+function fallbackReply(query) {
+  const reg = new Map(colRegistry().map((c) => [c.key, c]));
+  const labels = query.metrics.map((k) => (reg.get(k) || {}).label || k).join('、');
+  return `已查询 ${ymdDash(query.from)} ~ ${ymdDash(query.to)} 的${labels}数据(${MODE_LABEL[query.mode] || query.mode}),详细结果见下方数据与图表。`;
+}
+
+/** 第二段:digest 回传模型写一句带真实数字的结论;任何失败降级为模板句 */
+async function conclude({ q, cfg, query, result }) {
+  try {
+    const out = await chatCompletion({
+      apiBase: cfg.chatApiBase, apiKey: cfg.chatApiKey, model: cfg.chatModel,
+      messages: [
+        { role: 'system', content: '你是数据结论助手。只依据用户提供的查询结果数据,用中文一两句话直接回答用户的问题:优先给出关键数字(大金额可用"万元"表述),点出最大值/最小值或明显变化;数据为空或全为 0 时如实说明;禁止编造、推算或引用数据之外的信息。' },
+        { role: 'user', content: `问题:${q}\n\n查询结果数据:\n${buildResultDigest(query, result)}` },
+      ],
+    });
+    const text = String(out).trim();
+    if (text) return text.slice(0, 500);
+  } catch { /* 第二段失败不影响查询结果返回 */ }
+  return fallbackReply(query);
+}
+
 // ---------- 对外入口 ----------
 
 /** 湖内可用日期范围(用于 prompt 约束 + 查询钳制) */
@@ -249,7 +318,8 @@ async function ask(o) {
   const parsed = extractJson(raw);
   const query = sanitizeQuery(parsed, shops, dateRange);
   const result = executeQuery(entity, query, svc);
-  return { reply: String(parsed.reply || '').slice(0, 500), ...result, chart: query.chart, chartTitle: query.chartTitle };
+  const reply = await conclude({ q, cfg, query, result });
+  return { reply, ...result, chart: query.chart, chartTitle: query.chartTitle };
 }
 
 /** 设置页「测试对话模型」:1 token 的最小请求 */
@@ -261,4 +331,4 @@ async function ping(cfg) {
   return String(reply).trim().slice(0, 40);
 }
 
-module.exports = { ask, ping, extractJson, sanitizeQuery, matchShopsByName, lakeDateRange };
+module.exports = { ask, ping, extractJson, sanitizeQuery, matchShopsByName, lakeDateRange, buildResultDigest, fallbackReply };

+ 15 - 3
public/app.js

@@ -80,7 +80,9 @@
     document.getElementById('settingsAitableEnabled').checked = !!settings.aitableEnabled;
     document.getElementById('settingsAitableAppKey').value = settings.aitableAppKey || '';
     document.getElementById('settingsAitableBaseId').value = settings.aitableBaseId || '';
-    document.getElementById('settingsAitableSheetName').value = settings.aitableSheetName || '经营数据';
+    document.getElementById('settingsAitableSheetDetail').value = settings.aitableSheetDetail || '门店明细';
+    document.getElementById('settingsAitableSheetChain').value = settings.aitableSheetChain || '连锁汇总';
+    document.getElementById('settingsAitableSheetCompany').value = settings.aitableSheetCompany || '公司汇总';
     const aitableSecretInput = document.getElementById('settingsAitableAppSecret');
     aitableSecretInput.value = '';
     aitableSecretInput.placeholder = settings.aitableSecretSet ? '已设置,留空保持不变(输入 - 清除)' : '未设置';
@@ -147,7 +149,9 @@
       aitableEnabled: document.getElementById('settingsAitableEnabled').checked,
       aitableAppKey: val('settingsAitableAppKey'),
       aitableBaseId: val('settingsAitableBaseId'),
-      aitableSheetName: val('settingsAitableSheetName'),
+      aitableSheetDetail: val('settingsAitableSheetDetail'),
+      aitableSheetChain: val('settingsAitableSheetChain'),
+      aitableSheetCompany: val('settingsAitableSheetCompany'),
       ...(val('settingsAitableAppSecret') ? { aitableAppSecret: val('settingsAitableAppSecret') } : {}),
     }), 'btnSaveAitable', 'aitableSaveStatus');
   }
@@ -240,7 +244,15 @@
       const data = await res.json();
       if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
       if (kind === 'test') {
-        if (status) { status.textContent = '连通正常'; status.className = 'badge badge-ok'; }
+        // 逐表探测:缺哪张表报哪张(testConnection 返回 { ok, sheets: { detail/chain/company: {name, ok, hint} } })
+        const failed = Object.values(data.sheets || {}).filter((s) => !s.ok);
+        if (status) {
+          if (data.ok) { status.textContent = '三张表连通正常'; status.className = 'badge badge-ok'; }
+          else {
+            status.textContent = `不可读:${failed.map((s) => s.name).join('、')}`;
+            status.className = 'badge badge-fail';
+          }
+        }
         return data;
       }
       if (kind === 'push') {

+ 5 - 3
public/index.html

@@ -695,10 +695,12 @@
                   <div class="help">Secret 保存后不回显;输入 - 清除。应用需开通「AI 表格」API 权限。</div>
                 </div>
                 <div class="field">
-                  <label>Base ID / 数据表名称</label>
+                  <label>Base ID / 三张数据表名称</label>
                   <input type="text" id="settingsAitableBaseId" placeholder="粘贴 AI 表格分享链接或直接填 Base ID" autocomplete="off">
-                  <input type="text" id="settingsAitableSheetName" placeholder="数据表" autocomplete="off" style="margin-top:6px;">
-                  <div class="help">Base ID 可直接粘贴分享链接(形如 alidocs.dingtalk.com/i/nodes/xxx),保存时自动提取 ID;网页版打开表格时地址栏里也能看到。</div>
+                  <input type="text" id="settingsAitableSheetDetail" placeholder="门店明细" autocomplete="off" style="margin-top:6px;">
+                  <input type="text" id="settingsAitableSheetChain" placeholder="连锁汇总" autocomplete="off" style="margin-top:6px;">
+                  <input type="text" id="settingsAitableSheetCompany" placeholder="公司汇总" autocomplete="off" style="margin-top:6px;">
+                  <div class="help">Base ID 可直接粘贴分享链接(形如 alidocs.dingtalk.com/i/nodes/xxx),保存时自动提取 ID。云端按维度拆三张表(门店明细/连锁汇总/公司汇总),每张表内粒度唯一,钉钉 AI 问数/人工聚合不会跨维度算重;表名留空用默认值。</div>
                 </div>
                 <div class="settings-links">
                   <button type="button" class="settings-link settings-link-btn" id="btnTestAitable"><span>测试连通</span></button>

+ 1 - 1
public/reports.js

@@ -1181,7 +1181,7 @@
         .filter((m) => !m.pending && (m.role === 'user' || (m.role === 'assistant' && m.text)))
         .slice(0, -1)
         .slice(-8)
-        .map((m) => ({ role: m.role, content: m.role === 'assistant' ? `${m.text}${m.applied ? `(已查询:mode=${m.applied.mode} ${m.applied.from}~${m.applied.to}${m.applied.shopIds && m.applied.shopIds.length ? ' 门店 ' + m.applied.shopIds.join(',') : ''})` : ''}` : m.text }));
+        .map((m) => ({ role: m.role, content: m.role === 'assistant' ? `${m.text}${m.applied ? `(已查询:mode=${m.applied.mode} ${m.applied.from}~${m.applied.to} 指标 ${(m.applied.metrics || []).join(',') || 'grossAmt'}${m.applied.shopIds && m.applied.shopIds.length ? ' 门店 ' + m.applied.shopIds.join(',') : ''})` : ''}` : m.text }));
       const result = await jsonFetch('/api/reports/ask', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },

+ 4 - 3
reports-store.js

@@ -183,11 +183,12 @@ function saveRows(entityKey, rows) {
 }
 
 function rowKey(row) {
-  // scope 必须参与键:全公司行与连锁行的 shopId 都为空串,缺 scope 会互相覆盖
-  return `${row.date}|${row.scope || ''}|${row.shopId == null ? '' : row.shopId}`;
+  // scope 与 chainId 都必须参与键:全公司行、连锁行、区间汇总行的 shopId 都为空串,
+  // 缺 chainId 时同日多个连锁/汇总行会互相覆盖(只留最后写入的一条)
+  return `${row.date}|${row.scope || ''}|${row.chainId == null ? '' : String(row.chainId)}|${row.shopId == null ? '' : String(row.shopId)}`;
 }
 
-/** 合并写入:按 date|scope|shopId upsert,浅字段覆盖(raw 整体替换)。
+/** 合并写入:按 date|scope|chainId|shopId upsert,浅字段覆盖(raw 整体替换)。
  *  只重写被本次数据触及的月份分片;旧单文件在首次写入时整体迁移。 */
 function upsertRows(entityKey, incoming) {
   if (!Array.isArray(incoming) || !incoming.length) return { added: 0, updated: 0, total: 0 };

+ 57 - 0
scripts/smoke.js

@@ -304,6 +304,63 @@ async function main() {
       expect(r.status === 400 && r.json().code === 'AITABLE_NO_ENTITY', `status=${r.status} code=${r.json().code}`);
     });
 
+    // ---- 钉钉三表拆分与行键(纯函数回归,不打真实钉钉 API) ----
+    const aitableLib = require('../server/services/aitable.js');
+    const { rowKey } = require('../reports-store.js');
+    await check('splitRowsBySheet → 按维度分流且 summary 不上云', async () => {
+      const s = aitableLib.splitRowsBySheet([
+        { scope: 'shop', shopId: '1', chainId: 'c1' },
+        { scope: 'shop', shopId: '2', chainId: 'c1' },
+        { scope: 'chain', shopId: '', chainId: 'c1' },
+        { scope: 'chain', shopId: '', chainId: 'c2' },
+        { scope: 'company', shopId: '', chainId: '-1' },
+        { scope: 'summary', shopId: '', chainId: '' },
+      ]);
+      expect(s.detail.length === 2 && s.chain.length === 2 && s.company.length === 1 && s.skippedSummary === 1,
+        `detail=${s.detail.length} chain=${s.chain.length} company=${s.company.length} skipped=${s.skippedSummary}`);
+    });
+    await check('recordKey / rowKey → 同日多连锁不碰撞', async () => {
+      const cloud = (chainId) => aitableLib.recordKey({ 日期: '20261017', 账号名: 'A', 维度: '连锁', 连锁ID: chainId, 门店ID: '' });
+      expect(cloud('c1') !== cloud('c2'), '连锁行云端业务键仍碰撞');
+      const local = (row) => rowKey(row);
+      expect(local({ date: '20261017', scope: 'chain', chainId: 'c1', shopId: '' }) !== local({ date: '20261017', scope: 'chain', chainId: 'c2', shopId: '' }), '连锁行本地行键仍碰撞');
+      expect(local({ date: '20261017', scope: 'summary', chainId: '', shopId: '' }) !== local({ date: '20261017', scope: 'summary', chainId: 'c1', shopId: '' }), '汇总行本地行键仍碰撞');
+    });
+    await check('resolveSheets → 缺省兜底三表名', async () => {
+      const { sheets, fingerprint } = aitableLib.resolveSheets({});
+      expect(sheets.detail === '门店明细' && sheets.chain === '连锁汇总' && sheets.company === '公司汇总' && fingerprint.split('|').length === 3,
+        JSON.stringify(sheets));
+    });
+
+    // ---- 问数两段式:结果摘要与兜底话术(纯函数,不调对话模型) ----
+    const nlQueryLib = require('../nl-query.js');
+    await check('buildResultDigest → 口径/Top行/合计齐全且按列类型格式化', async () => {
+      const digest = nlQueryLib.buildResultDigest(
+        { mode: 'shop', from: '20260918', to: '20261017', metrics: ['grossAmt', 'clkExpRate'], shopIds: [] },
+        {
+          rows: [
+            { shopName: '一食堂', grossAmt: 12345.6, clkExpRate: 0.0521 },
+            { shopName: '二食堂', grossAmt: 0, clkExpRate: 0 },
+          ],
+          summary: { grossAmt: 12345.6, clkExpRate: 0.0521 },
+        }
+      );
+      expect(digest.includes('按门店汇总') && digest.includes('2026-09-18') && digest.includes('2026-10-17'), digest);
+      expect(digest.includes('12,345.60元') && digest.includes('5.21%'), digest); // money 两位小数 / pct 小数×100
+      expect(digest.includes('区间合计') && digest.includes('均值'), digest);     // 金额=合计、比率=均值
+    });
+    await check('buildResultDigest → 空结果有明确说明', async () => {
+      const digest = nlQueryLib.buildResultDigest(
+        { mode: 'date', from: '20260918', to: '20261017', metrics: ['grossAmt'], shopIds: [] },
+        { rows: [], summary: {} }
+      );
+      expect(digest.includes('结果为空'), digest);
+    });
+    await check('fallbackReply → 模板句含区间与指标名', async () => {
+      const t = nlQueryLib.fallbackReply({ mode: 'date', from: '20260918', to: '20261017', metrics: ['grossAmt'] });
+      expect(t.includes('2026-09-18') && t.includes('营业额'), t);
+    });
+
     // ---- 静态与路由行为 ----
     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']) {
       await check(`静态 ${p}`, async () => {

+ 6 - 2
server/context.js

@@ -30,14 +30,18 @@ const CONFIG_DEFAULTS = {
   // HTTP 访问日志:true 时在控制台打印每个请求(5xx 与超过 1s 的慢请求始终打印)
   httpLog: false,
   // 钉钉 AI 表格(在线主档案/共享层):同步完成后自动推送,其他机器可拉取
-  // 非敏感项给组织级默认值(appKey/baseId/sheetName/operatorId),新机器只需填 AppSecret;
+  // 非敏感项给组织级默认值(appKey/baseId/三张表名/operatorId),新机器只需填 AppSecret;
   // appSecret 按规矩不进源码(gitignored config.json 各机自存)。
+  // 三张表按维度拆分(每张表内粒度唯一,AI 问数/人工聚合不会跨维度算重);
+  // 旧单表 sheetName 已废弃不读,读取处对缺省键兜底(老 config.json 零迁移)。
   aitable: {
     enabled: false,
     appKey: 'ding5vlpq3yeu7h6c6kj',
     appSecret: '',
     baseId: '7QG4Yx2JpLkaBl7EIpXNx0aGJ9dEq3XD',
-    sheetName: '数据表',
+    sheetDetail: '门店明细',
+    sheetChain: '连锁汇总',
+    sheetCompany: '公司汇总',
     operatorId: 'zkWXDRXbxDlUiPgRPI3XjpwiEiE',
   },
   // 经营数据每日自动同步(商家客户端在线时自动补昨日数据),默认关闭

+ 15 - 4
server/routes/reports.js

@@ -9,6 +9,16 @@ const naposService = require('../../napos-service.js');
 const nlQuery = require('../../nl-query.js');
 const reportHtml = require('../../report-html.js');
 
+/** 三表推送结果的一行摘要(日志用):合计 + 分表明细 */
+function aitablePushSummary(r) {
+  const base = `新增 ${r.created} 行、更新 ${r.updated} 行`;
+  const parts = Object.entries(r.perSheet || {})
+    .filter(([, s]) => s.rows > 0)
+    .map(([scope, s]) => `${s.sheet} 新增${s.created}/更新${s.updated}`)
+    .join(',');
+  return parts ? `${base}(${parts})` : base;
+}
+
 function register(app, ctx) {
   // 不限方法(保持拆分前行为:该路由未做方法检查)
   app.any('/api/reports/status', async ({ res }) => {
@@ -128,7 +138,7 @@ function register(app, ctx) {
       if (!snap || !snap.entity || !(ctx.config.aitable && ctx.config.aitable.enabled)) return;
       try {
         const r = await ctx.aitable.pushLake(snap.entity.key);
-        ctx.logs.pushSystemLog(`AI 表格推送完成:新增 ${r.created} 行、更新 ${r.updated} 行`, 'success');
+        ctx.logs.pushSystemLog(`AI 表格推送完成:${aitablePushSummary(r)}`, 'success');
       } catch (e) {
         ctx.logs.pushSystemLog(`AI 表格推送失败:${e.message || e}`, 'error');
       }
@@ -148,7 +158,7 @@ function register(app, ctx) {
     const entity = body.entity || (naposService.store.listEntities()[0] || {}).key;
     if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'AITABLE_NO_ENTITY'; e.status = 400; throw e; }
     const r = await ctx.aitable.pushLake(entity, { force: !!body.force });
-    ctx.logs.pushSystemLog(`AI 表格推送完成:新增 ${r.created} 行、更新 ${r.updated} 行`, 'success');
+    ctx.logs.pushSystemLog(`AI 表格推送完成:${aitablePushSummary(r)}`, 'success');
     sendJson(res, 200, r);
   });
 
@@ -162,9 +172,10 @@ function register(app, ctx) {
 
   app.get('/api/reports/aitable/status', async ({ res, query }) => {
     const entity = query.entity || (naposService.store.listEntities()[0] || {}).key || '';
+    const a = ctx.config.aitable || {};
     sendJson(res, 200, {
-      enabled: !!(ctx.config.aitable && ctx.config.aitable.enabled),
-      configured: !!(ctx.config.aitable && ctx.config.aitable.baseId && ctx.config.aitable.sheetName && ctx.config.aitable.appKey),
+      enabled: !!a.enabled,
+      configured: !!(a.baseId && a.appKey && a.operatorId),
       lastPush: entity ? ctx.aitable.lastPushInfo(entity) : null,
       pending: entity ? ctx.aitable.pendingCount(entity) : null,
     });

+ 9 - 4
server/routes/settings.js

@@ -34,7 +34,9 @@ function register(app, ctx) {
       aitableEnabled: !!(ctx.config.aitable && ctx.config.aitable.enabled),
       aitableAppKey: (ctx.config.aitable && ctx.config.aitable.appKey) || '',
       aitableBaseId: (ctx.config.aitable && ctx.config.aitable.baseId) || '',
-      aitableSheetName: (ctx.config.aitable && ctx.config.aitable.sheetName) || '数据表',
+      aitableSheetDetail: (ctx.config.aitable && ctx.config.aitable.sheetDetail) || '门店明细',
+      aitableSheetChain: (ctx.config.aitable && ctx.config.aitable.sheetChain) || '连锁汇总',
+      aitableSheetCompany: (ctx.config.aitable && ctx.config.aitable.sheetCompany) || '公司汇总',
       aitableSecretSet: !!(ctx.config.aitable && ctx.config.aitable.appSecret),
       aitableOperatorSet: !!(ctx.config.aitable && ctx.config.aitable.operatorId),
     });
@@ -88,8 +90,8 @@ function register(app, ctx) {
       if (t === '-') config.accessToken = ''; // 约定:输入 - 清除口令
       else if (t) config.accessToken = t;
     }
-    if (body.aitableEnabled !== undefined || body.aitableAppKey !== undefined || body.aitableBaseId !== undefined || body.aitableSheetName !== undefined || body.aitableAppSecret !== undefined || body.aitableOperatorId !== undefined) {
-      if (!config.aitable || typeof config.aitable !== 'object') config.aitable = { enabled: false, appKey: '', appSecret: '', baseId: '', sheetName: '数据表', operatorId: '' };
+    if (body.aitableEnabled !== undefined || body.aitableAppKey !== undefined || body.aitableBaseId !== undefined || body.aitableSheetDetail !== undefined || body.aitableSheetChain !== undefined || body.aitableSheetCompany !== undefined || body.aitableAppSecret !== undefined || body.aitableOperatorId !== undefined) {
+      if (!config.aitable || typeof config.aitable !== 'object') config.aitable = { enabled: false, appKey: '', appSecret: '', baseId: '', operatorId: '' };
       if (body.aitableEnabled !== undefined) config.aitable.enabled = !!body.aitableEnabled;
       if (body.aitableAppKey !== undefined) config.aitable.appKey = String(body.aitableAppKey).trim();
       if (body.aitableBaseId !== undefined) {
@@ -98,7 +100,10 @@ function register(app, ctx) {
         const m = /i\/nodes\/([A-Za-z0-9]+)/.exec(raw) || /[?&]baseId=([A-Za-z0-9]+)/.exec(raw);
         config.aitable.baseId = m ? m[1] : raw;
       }
-      if (body.aitableSheetName !== undefined) config.aitable.sheetName = String(body.aitableSheetName).trim() || '数据表';
+      // 三张表按维度拆分:门店明细/连锁汇总/公司汇总(空值回退默认名)
+      if (body.aitableSheetDetail !== undefined) config.aitable.sheetDetail = String(body.aitableSheetDetail).trim() || '门店明细';
+      if (body.aitableSheetChain !== undefined) config.aitable.sheetChain = String(body.aitableSheetChain).trim() || '连锁汇总';
+      if (body.aitableSheetCompany !== undefined) config.aitable.sheetCompany = String(body.aitableSheetCompany).trim() || '公司汇总';
       if (body.aitableAppSecret !== undefined) {
         const sec = String(body.aitableAppSecret).trim();
         if (sec === '-') config.aitable.appSecret = ''; // 约定:输入 - 清除

+ 138 - 60
server/services/aitable.js

@@ -1,14 +1,22 @@
 'use strict';
 
-// 钉钉 AI 表格(原多维表)同步服务
+// 钉钉 AI 表格(原多维表)同步服务 · 三表单粒度架构
 // ----------------------------------------------------------------------------
 // 双层架构的云端层:AI 表格 = 在线主档案 + 多人共享层;本地数据湖 = 计算缓存层。
-//   · pushLake  — 把本地湖的新增/更新行(按 syncedAt 水位)upsert 到 AI 表格
-//   · pullLake  — 把 AI 表格记录拉回本地湖(其他机器补数据用,云端为准)
-// 业务键:日期|账号名|维度|门店ID(与本地 rowKey 语义对齐,幂等可重跑)。
+// 云端按维度拆三张表(每张表内粒度唯一,AI/人工任意聚合都不会跨维度算重):
+//   · 门店明细(scope=shop)  · 连锁汇总(scope=chain)  · 公司汇总(scope=company)
+//   · scope=summary 的区间汇总行不上云(可由门店行推导)
+//   · pushLake — 把本地湖的新增/更新行(按 syncedAt 水位)分流 upsert 到三张表
+//   · pullLake — 把三张表记录拉回本地湖(其他机器补数据用,云端为准)
+// 业务键:日期|账号名|维度|连锁ID|门店ID(连锁ID 参与键:连锁/汇总行 shopId 为空,
+// 缺它会互相覆盖)。与本地 rowKey(date|scope|chainId|shopId)语义对齐,幂等可重跑。
 //
 // 钉钉侧契约(企业内部应用 access_token,域 api.dingtalk.com;2026-10-17 真机核对):
 //   token:   POST /v1.0/oauth2/accessToken  {appKey, appSecret} → {accessToken, expireIn}
+//   表清单:  GET  /v1.0/notable/bases/{baseId}/sheets?operatorId → {value:[{id,name}]}
+//   建表:    POST /v1.0/notable/bases/{baseId}/sheets?operatorId  {name, columns:[{name,type}]}
+//   建列:    POST …/sheets/{sheetIdOrName}/fields?operatorId  {name, type:'text'|'number'}
+//   列清单:  GET  …/sheets/{sheetIdOrName}/fields?operatorId → {value:[{name,type,property}]}
 //   列记录:  GET  …/records?operatorId&maxResults&nextToken → {records:[{id,fields}],nextToken,hasMore}
 //   新增:    POST …/records {records:[{fields}]} → {value:[{id}]}(响应键是 value)
 //   更新:    PUT  …/records {records:[{id,fields}]}(记录标识键是 id,不是 record_id)
@@ -16,6 +24,8 @@
 //   · 所有 notable 接口 query 必带 operatorId=操作人 unionId(scripts/aitable-discover.js 发现)
 //   · 数值字段读回是字符串("12345.67"),recordToRow 已做 Number() 归一
 //   · number 列 formatter=INT 只影响显示,存储小数保真
+//   · 建表/建列接口有应用级限流(突发 ~5 次后 500 internalError,慢速补列即可),
+//     记录读写接口不受影响;记录写入遇到不存在的字段名报 404 notFound
 // ⚠ 批量上限(100)是保守值,首推大批量时如遇限流再调。
 
 const fs = require('fs');
@@ -29,7 +39,10 @@ const INSERT_CHUNK = 100;      // 单批新增上限(保守值,真机核对
 const UPDATE_CHUNK = 100;
 const LIST_PAGE = 100;
 
-// 本地字段 → AI 表格列名(中文列头,人工可读可筛)
+// 三张表的配置键 → 默认表名(读取处兜底,老 config.json 缺键零迁移)
+const SHEET_DEFAULTS = { sheetDetail: '门店明细', sheetChain: '连锁汇总', sheetCompany: '公司汇总' };
+
+// 本地字段 → AI 表格列名(中文列头,人工可读可筛;三张表同一套列,缺维度的列留空)
 const FIELD_COLUMNS = {
   date: '日期',
   account: '账号名',
@@ -59,6 +72,8 @@ const METRIC_FIELDS = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', '
   'clkExpRate', 'ordClkRate', 'perActualPay', 'actOrdCnt', 'actSubsidyAmt', 'shopRoi', 'rebuyRate30d',
   'bizHours', 'abnormalCloseHours', 'goodsSaleAmt'];
 const DIMENSION_LABEL = { company: '公司', chain: '连锁', shop: '门店', summary: '汇总' };
+// scope → 目标表配置键(summary 不上云)
+const SCOPE_SHEET_KEY = { shop: 'sheetDetail', chain: 'sheetChain', company: 'sheetCompany' };
 
 function rowToRecord(row, accountName) {
   const fields = {
@@ -79,7 +94,7 @@ function rowToRecord(row, accountName) {
 
 function recordKey(fields) {
   return [fields[FIELD_COLUMNS.date], fields[FIELD_COLUMNS.account], fields[FIELD_COLUMNS.dimension],
-    String(fields[FIELD_COLUMNS.shopId] || '')].join('|');
+    String(fields[FIELD_COLUMNS.chainId] || ''), String(fields[FIELD_COLUMNS.shopId] || '')].join('|');
 }
 
 function recordToRow(record, defaultAccount) {
@@ -108,10 +123,35 @@ function recordToRow(record, defaultAccount) {
   return row;
 }
 
+/** 本地湖行分流到三张云端表(纯函数,smoke 直接回归)。
+ *  summary 行不上云(可由门店行推导,且混入任一表都会造成表内算重)。 */
+function splitRowsBySheet(rows) {
+  const out = { detail: [], chain: [], company: [], skippedSummary: 0 };
+  for (const row of Array.isArray(rows) ? rows : []) {
+    const key = SCOPE_SHEET_KEY[row && row.scope];
+    if (key === 'sheetDetail') out.detail.push(row);
+    else if (key === 'sheetChain') out.chain.push(row);
+    else if (key === 'sheetCompany') out.company.push(row);
+    else out.skippedSummary += 1;
+  }
+  return out;
+}
+
+/** 解析三张表名(配置缺省用默认值)+ 表名指纹(水位失效判定用) */
+function resolveSheets(cfg) {
+  const sheets = {
+    detail: cfg.sheetDetail || SHEET_DEFAULTS.sheetDetail,
+    chain: cfg.sheetChain || SHEET_DEFAULTS.sheetChain,
+    company: cfg.sheetCompany || SHEET_DEFAULTS.sheetCompany,
+  };
+  return { sheets, fingerprint: [sheets.detail, sheets.chain, sheets.company].join('|') };
+}
+
 function createAitableService(ctx) {
   const { config, workspace, logs } = ctx;
 
   // ---------- token(进程内缓存,提前 60s 过期) ----------
+
   let tokenCache = { token: null, expireAt: 0 };
 
   function transport(method, urlPath, bodyObj, token) {
@@ -168,19 +208,19 @@ function createAitableService(ctx) {
   function requireConfig() {
     const cfg = config.aitable || {};
     if (!cfg.enabled) throw err('钉钉 AI 表格同步未启用(系统设置中开启)', 'AITABLE_DISABLED');
-    if (!cfg.baseId || !cfg.sheetName || !cfg.appKey) throw err('钉钉 AI 表格配置不完整(AppKey / Base ID / 数据表名称)', 'AITABLE_NO_CFG');
+    if (!cfg.baseId || !cfg.appKey) throw err('钉钉 AI 表格配置不完整(AppKey / Base ID)', 'AITABLE_NO_CFG');
     if (!cfg.operatorId) throw err('缺少操作者 unionId:先在钉钉开放平台给应用开通通讯录读权限,再运行 node scripts/aitable-discover.js 发现并填入', 'AITABLE_NO_OPERATOR');
     return cfg;
   }
 
-  async function listAllRecords(cfg) {
+  async function listAllRecords(cfg, sheetName) {
     const token = await getToken();
     const out = [];
     let nextToken = '';
     for (;;) {
       const q = notableQuery(cfg, { maxResults: LIST_PAGE, ...(nextToken ? { nextToken } : {}) });
-      const res = await transport('GET', RECORDS_PATH(cfg.baseId, cfg.sheetName) + q, undefined, token);
-      if (res.statusCode !== 200) throw err(`拉取记录失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_LIST_FAIL');
+      const res = await transport('GET', RECORDS_PATH(cfg.baseId, sheetName) + q, undefined, token);
+      if (res.statusCode !== 200) throw err(`拉取「${sheetName}」记录失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_LIST_FAIL');
       const d = res.json || {};
       const records = Array.isArray(d.records) ? d.records
         : Array.isArray(d.value) ? d.value
@@ -198,7 +238,7 @@ function createAitableService(ctx) {
     return out;
   }
 
-  // ---------- 推送:本地湖 → AI 表格 ----------
+  // ---------- 推送:本地湖 → AI 表格(三表) ----------
 
   const pushStateFile = () => path.join(workspace.getWorkspaceRoot(), '.genpic', 'aitable-push.json');
 
@@ -216,43 +256,19 @@ function createAitableService(ctx) {
     } catch { /* 状态落盘失败不阻断推送 */ }
   }
 
-  /** 推送一个主体的数据。force=true 时忽略水位推全量、并跳过冲突/全零保护。
+  /** 单张表的 upsert:diff 现有记录 → 分批新增/更新。返回 {created, updated, skippedStale}。
    *  冲突策略:同键行「数据新者胜」——云端记录的 lastModifiedTime 比 本地行 syncedAt
    *  (取数时间)新则跳过(skippedStale),避免另一台机器后推的旧数据盖掉云端新数据。 */
-  async function pushLake(entity, opts = {}) {
-    const cfg = requireConfig();
-    const naposService = require('../../napos-service.js');
-    const key = naposService.store.sanitizeKey(entity);
-    const meta = naposService.store.getEntity(key);
-    const accountName = meta ? meta.name : key;
-
-    const state = readPushState();
-    const watermark = opts.force ? 0 : (state[key] || {}).lastPushedAt || 0;
-    let rows;
-    if (watermark) {
-      const all = naposService.store.loadRows(key) || [];
-      rows = all.filter((r) => new Date(r.syncedAt || 0).getTime() > watermark);
-    } else {
-      rows = naposService.store.loadRows(key) || [];
-    }
-    if (!rows.length) return { pushed: 0, created: 0, updated: 0, skipped: '无新增数据' };
-
-    // 全零保护:大批量行指标全零基本可断定取数异常(已知门店级 bug 的症状),
-    // 防止把坏数据灌进云端主档案;确认要推(force)可绕过。
-    const zeroRows = rows.filter((r) => METRIC_FIELDS.every((f) => !Number(r[f]))).length;
-    if (!opts.force && rows.length >= 20 && zeroRows / rows.length > 0.8) {
-      throw err(`本批 ${rows.length} 行中 ${zeroRows} 行指标全零,疑似取数异常,已拦截推送(确认无误可在数据中台强制推送)`, 'AITABLE_ALL_ZERO');
-    }
-
-    const token = await getToken();
-    // 现有记录索引:业务键 → {recordId, mtime}(分页拉全量)
+  async function pushSheet(cfg, token, sheetName, accountName, rows, opts) {
+    if (!rows.length) return { created: 0, updated: 0, skippedStale: 0 };
     const existing = new Map();
-    for (const rec of await listAllRecords(cfg)) {
+    for (const rec of await listAllRecords(cfg, sheetName)) {
       const f = rec.fields || {};
       const k = recordKey({
         [FIELD_COLUMNS.date]: f[FIELD_COLUMNS.date],
         [FIELD_COLUMNS.account]: f[FIELD_COLUMNS.account],
         [FIELD_COLUMNS.dimension]: f[FIELD_COLUMNS.dimension],
+        [FIELD_COLUMNS.chainId]: f[FIELD_COLUMNS.chainId],
         [FIELD_COLUMNS.shopId]: f[FIELD_COLUMNS.shopId],
       });
       if (rec.id) existing.set(k, { id: rec.id, mtime: Number(rec.lastModifiedTime) || 0 });
@@ -276,33 +292,88 @@ function createAitableService(ctx) {
       }
     }
     for (const part of chunk(toCreate, INSERT_CHUNK)) {
-      const res = await transport('POST', RECORDS_PATH(cfg.baseId, cfg.sheetName) + notableQuery(cfg), { records: part }, token);
-      if (res.statusCode >= 300) throw err(`批量新增失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_CREATE_FAIL');
+      const res = await transport('POST', RECORDS_PATH(cfg.baseId, sheetName) + notableQuery(cfg), { records: part }, token);
+      if (res.statusCode >= 300) throw err(`「${sheetName}」批量新增失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_CREATE_FAIL');
       created += part.length;
     }
     for (const part of chunk(toUpdate, UPDATE_CHUNK)) {
-      const res = await transport('PUT', RECORDS_PATH(cfg.baseId, cfg.sheetName) + notableQuery(cfg), { records: part }, token);
-      if (res.statusCode >= 300) throw err(`批量更新失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_UPDATE_FAIL');
+      const res = await transport('PUT', RECORDS_PATH(cfg.baseId, sheetName) + notableQuery(cfg), { records: part }, token);
+      if (res.statusCode >= 300) throw err(`「${sheetName}」批量更新失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_UPDATE_FAIL');
       updated += part.length;
     }
+    return { created, updated, skippedStale };
+  }
+
+  /** 推送一个主体的数据到三张表。force=true 时忽略水位推全量、并跳过冲突/全零保护。
+   *  水位按「主体 + 三表名指纹」记录:表名变化(如单表→三表迁移)自动降级为全量重推。 */
+  async function pushLake(entity, opts = {}) {
+    const cfg = requireConfig();
+    const naposService = require('../../napos-service.js');
+    const key = naposService.store.sanitizeKey(entity);
+    const meta = naposService.store.getEntity(key);
+    const accountName = meta ? meta.name : key;
+    const { sheets, fingerprint } = resolveSheets(cfg);
+
+    const state = readPushState();
+    const prev = state[key] || {};
+    const watermark = opts.force || prev.sheets !== fingerprint ? 0 : (prev.lastPushedAt || 0);
+    let rows;
+    if (watermark) {
+      const all = naposService.store.loadRows(key) || [];
+      rows = all.filter((r) => new Date(r.syncedAt || 0).getTime() > watermark);
+    } else {
+      rows = naposService.store.loadRows(key) || [];
+    }
+    const parts = splitRowsBySheet(rows);
+    const pushed = parts.detail.length + parts.chain.length + parts.company.length;
+    if (!pushed) return { pushed: 0, created: 0, updated: 0, skippedSummary: parts.skippedSummary, skipped: '无新增数据' };
+
+    // 全零保护:大批量门店行指标全零基本可断定取数异常(已知门店级 bug 的症状),
+    // 防止把坏数据灌进云端主档案;确认要推(force)可绕过。
+    const zeroRows = parts.detail.filter((r) => METRIC_FIELDS.every((f) => !Number(r[f]))).length;
+    if (!opts.force && parts.detail.length >= 20 && zeroRows / parts.detail.length > 0.8) {
+      throw err(`本批门店行 ${parts.detail.length} 行中 ${zeroRows} 行指标全零,疑似取数异常,已拦截推送(确认无误可在数据中台强制推送)`, 'AITABLE_ALL_ZERO');
+    }
+
+    const token = await getToken();
+    const targets = [
+      ['detail', sheets.detail, parts.detail],
+      ['chain', sheets.chain, parts.chain],
+      ['company', sheets.company, parts.company],
+    ];
+    const perSheet = {};
+    let created = 0;
+    let updated = 0;
+    let skippedStale = 0;
+    for (const [scope, sheetName, scopeRows] of targets) {
+      const r = await pushSheet(cfg, token, sheetName, accountName, scopeRows, opts);
+      perSheet[scope] = { sheet: sheetName, rows: scopeRows.length, ...r };
+      created += r.created;
+      updated += r.updated;
+      skippedStale += r.skippedStale;
+    }
 
     const now = Date.now();
-    state[key] = { lastPushedAt: now, pushedRows: rows.length, at: new Date().toISOString() };
+    state[key] = { lastPushedAt: now, pushedRows: pushed, sheets: fingerprint, at: new Date().toISOString() };
     writePushState(state);
-    return { pushed: rows.length, created, updated, skippedStale, account: accountName };
+    return { pushed, created, updated, skippedStale, skippedSummary: parts.skippedSummary, perSheet, account: accountName };
   }
 
-  // ---------- 拉取:AI 表格 → 本地湖 ----------
+  // ---------- 拉取:AI 表格(三表) → 本地湖 ----------
 
   /**
-   * 拉取 AI 表格记录回本地湖(云端为准,本地同键行被覆盖)。
+   * 拉取三张 AI 表格记录回本地湖(云端为准,本地同键行被覆盖)。
    * entity 缺省 = 拉取云端全部账号:没有注册过的新账号会自动创建轻量主体
    * (fresh 机器拉一次即可用报表/图表/问数)。
    */
   async function pullLake(entity) {
     const cfg = requireConfig();
     const naposService = require('../../napos-service.js');
-    const records = await listAllRecords(cfg);
+    const { sheets } = resolveSheets(cfg);
+    const records = [];
+    for (const sheetName of [sheets.detail, sheets.chain, sheets.company]) {
+      records.push(...await listAllRecords(cfg, sheetName));
+    }
     const groups = new Map();
     const wantName = entity
       ? ((naposService.store.getEntity(naposService.store.sanitizeKey(entity)) || {}).name || entity)
@@ -332,18 +403,22 @@ function createAitableService(ctx) {
     return result;
   }
 
-  /** 连通性测试:token + 操作者身份 + 表可读 */
+  /** 连通性测试:token + 操作者身份 + 三张表逐表可读(缺哪张报哪张) */
   async function testConnection() {
     const cfg = requireConfig();
+    const { sheets } = resolveSheets(cfg);
     const token = await getToken();
-    const res = await transport('GET', RECORDS_PATH(cfg.baseId, cfg.sheetName) + notableQuery(cfg, { maxResults: 1 }), undefined, token);
-    if (res.statusCode !== 200) {
-      const hint = res.statusCode === 403 || res.statusCode === 404
-        ? '——请确认该 AI 表格已分享给操作者账号(钉钉里把表格协作权限授给对应同事)'
-        : '';
-      throw err(`表读取失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}${hint}`, 'AITABLE_TEST_FAIL');
+    const result = { ok: true, baseId: cfg.baseId, operatorId: cfg.operatorId, sheets: {} };
+    for (const [scope, sheetName] of [['detail', sheets.detail], ['chain', sheets.chain], ['company', sheets.company]]) {
+      const res = await transport('GET', RECORDS_PATH(cfg.baseId, sheetName) + notableQuery(cfg, { maxResults: 1 }), undefined, token);
+      const ok = res.statusCode === 200;
+      const hint = !ok && (res.statusCode === 403 || res.statusCode === 404)
+        ? '——表不存在或不可读:请在钉钉该 AI 表格里确认已建同名数据表并分享给操作者账号'
+        : ok ? '' : (res.json && res.json.message) || res.raw;
+      result.sheets[scope] = { name: sheetName, ok };
+      if (!ok) { result.ok = false; result.sheets[scope].hint = hint; }
     }
-    return { ok: true, baseId: cfg.baseId, sheetName: cfg.sheetName, operatorId: cfg.operatorId };
+    return result;
   }
 
   function lastPushInfo(entity) {
@@ -355,7 +430,7 @@ function createAitableService(ctx) {
   /** 待推送行数:水位之后有变化(syncedAt 更新)的行;配置不齐返回 null(调用方显示配置态) */
   function pendingCount(entity) {
     const cfg = config.aitable || {};
-    if (!cfg.enabled || !cfg.baseId || !cfg.sheetName || !cfg.appKey || !cfg.operatorId) return null;
+    if (!cfg.enabled || !cfg.baseId || !cfg.appKey || !cfg.operatorId) return null;
     const naposService = require('../../napos-service.js');
     const key = naposService.store.sanitizeKey(entity);
     const watermark = (readPushState()[key] || {}).lastPushedAt || 0;
@@ -364,8 +439,11 @@ function createAitableService(ctx) {
     return all.filter((r) => new Date(r.syncedAt || 0).getTime() > watermark).length;
   }
 
-  logs.pushSystemLog('钉钉 AI 表格同步服务已就绪', 'info');
+  logs.pushSystemLog('钉钉 AI 表格同步服务已就绪(三表:门店明细/连锁汇总/公司汇总)', 'info');
   return { pushLake, pullLake, testConnection, lastPushInfo, pendingCount };
 }
 
-module.exports = { createAitableService, FIELD_COLUMNS, DIMENSION_LABEL, rowToRecord, recordKey, recordToRow };
+module.exports = {
+  createAitableService, FIELD_COLUMNS, DIMENSION_LABEL, SHEET_DEFAULTS,
+  rowToRecord, recordKey, recordToRow, splitRowsBySheet, resolveSheets,
+};

+ 9 - 0
server/services/auto-sync.js

@@ -90,6 +90,15 @@ function createAutoSyncService(ctx) {
           `自动同步完成:${snap.entity ? snap.entity.name + ',' : ''}新增 ${snap.added} 行、更新 ${snap.updated} 行、失败 ${snap.failed} 项`,
           snap.failed > 0 ? 'warn' : 'success'
         );
+        // 同步完成后:若启用钉钉 AI 表格,自动把增量推上云端(失败不影响本地同步结果)。
+        // 与手动同步路由同款 fire-and-forget;此前定时同步不推云端属遗漏。
+        if (snap.entity && ctx.config.aitable && ctx.config.aitable.enabled && ctx.aitable) {
+          ctx.aitable.pushLake(snap.entity.key).then((r) => {
+            logs.pushSystemLog(`AI 表格自动推送完成:新增 ${r.created} 行、更新 ${r.updated} 行`, 'success');
+          }).catch((e) => {
+            logs.pushSystemLog(`AI 表格自动推送失败:${e.message || e}`, 'error');
+          });
+        }
       }
     } catch (e) {
       logs.pushSystemLog(`自动同步失败:${e.message || e}`, 'error');