aitable.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. 'use strict';
  2. // 钉钉 AI 表格(原多维表)同步服务 · 三表单粒度架构
  3. // ----------------------------------------------------------------------------
  4. // 双层架构的云端层:AI 表格 = 在线主档案 + 多人共享层;本地数据湖 = 计算缓存层。
  5. // 云端按维度拆三张表(每张表内粒度唯一,AI/人工任意聚合都不会跨维度算重):
  6. // · 门店明细(scope=shop) · 连锁汇总(scope=chain) · 公司汇总(scope=company)
  7. // · scope=summary 的区间汇总行不上云(可由门店行推导)
  8. // · pushLake — 把本地湖的新增/更新行(按 syncedAt 水位)分流 upsert 到三张表
  9. // · pullLake — 把三张表记录拉回本地湖(其他机器补数据用,云端为准)
  10. // 业务键:日期|账号名|维度|连锁ID|门店ID(连锁ID 参与键:连锁/汇总行 shopId 为空,
  11. // 缺它会互相覆盖)。与本地 rowKey(date|scope|chainId|shopId)语义对齐,幂等可重跑。
  12. //
  13. // 钉钉侧契约(企业内部应用 access_token,域 api.dingtalk.com;2026-10-17 真机核对):
  14. // token: POST /v1.0/oauth2/accessToken {appKey, appSecret} → {accessToken, expireIn}
  15. // 表清单: GET /v1.0/notable/bases/{baseId}/sheets?operatorId → {value:[{id,name}]}
  16. // 建表: POST /v1.0/notable/bases/{baseId}/sheets?operatorId {name, columns:[{name,type}]}
  17. // 建列: POST …/sheets/{sheetIdOrName}/fields?operatorId {name, type:'text'|'number'}
  18. // 列清单: GET …/sheets/{sheetIdOrName}/fields?operatorId → {value:[{name,type,property}]}
  19. // 列记录: GET …/records?operatorId&maxResults&nextToken → {records:[{id,fields}],nextToken,hasMore}
  20. // 新增: POST …/records {records:[{fields}]} → {value:[{id}]}(响应键是 value)
  21. // 更新: PUT …/records {records:[{id,fields}]}(记录标识键是 id,不是 record_id)
  22. // 删除: POST …/records/delete {recordIds:[…]}(同步语义只 upsert,用不到)
  23. // · 所有 notable 接口 query 必带 operatorId=操作人 unionId(scripts/aitable-discover.js 发现)
  24. // · 数值字段读回是字符串("12345.67"),recordToRow 已做 Number() 归一
  25. // · number 列 formatter=INT 只影响显示,存储小数保真
  26. // · 建表/建列接口有应用级限流(突发 ~5 次后 500 internalError,慢速补列即可),
  27. // 记录读写接口不受影响;记录写入遇到不存在的字段名报 404 notFound
  28. // ⚠ 批量上限(100)是保守值,首推大批量时如遇限流再调。
  29. const fs = require('fs');
  30. const path = require('path');
  31. const https = require('https');
  32. const API_BASE = 'https://api.dingtalk.com';
  33. const TOKEN_PATH = '/v1.0/oauth2/accessToken';
  34. const RECORDS_PATH = (baseId, sheetName) => `/v1.0/notable/bases/${encodeURIComponent(baseId)}/sheets/${encodeURIComponent(sheetName)}/records`;
  35. const INSERT_CHUNK = 100; // 单批新增上限(保守值,真机核对后可调)
  36. const UPDATE_CHUNK = 100;
  37. const LIST_PAGE = 100;
  38. // 三张表的配置键 → 默认表名(读取处兜底,老 config.json 缺键零迁移)
  39. const SHEET_DEFAULTS = { sheetDetail: '门店明细', sheetChain: '连锁汇总', sheetCompany: '公司汇总' };
  40. // 本地字段 → AI 表格列名(中文列头,人工可读可筛;三张表同一套列,缺维度的列留空)
  41. const FIELD_COLUMNS = {
  42. date: '日期',
  43. account: '账号名',
  44. dimension: '维度',
  45. shopId: '门店ID',
  46. shopName: '门店名称',
  47. chainId: '连锁ID',
  48. chainName: '连锁名称',
  49. validOrderCnt: '有效订单',
  50. incomeAmt: '收入(元)',
  51. grossAmt: '营业额(元)',
  52. expenseAmt: '支出(元)',
  53. expUv: '曝光人数',
  54. orderUserCnt: '下单人数',
  55. clkExpRate: '进店转化率',
  56. ordClkRate: '下单转化率',
  57. perActualPay: '客单价(元)',
  58. actOrdCnt: '活动订单',
  59. actSubsidyAmt: '活动补贴(元)',
  60. shopRoi: '推广ROI',
  61. rebuyRate30d: '30日复购率',
  62. bizHours: '营业时长(h)',
  63. abnormalCloseHours: '异常关店时长(h)',
  64. goodsSaleAmt: '商品销售额(元)',
  65. };
  66. const METRIC_FIELDS = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt',
  67. 'clkExpRate', 'ordClkRate', 'perActualPay', 'actOrdCnt', 'actSubsidyAmt', 'shopRoi', 'rebuyRate30d',
  68. 'bizHours', 'abnormalCloseHours', 'goodsSaleAmt'];
  69. const DIMENSION_LABEL = { company: '公司', chain: '连锁', shop: '门店', summary: '汇总' };
  70. // scope → 目标表配置键(summary 不上云)
  71. const SCOPE_SHEET_KEY = { shop: 'sheetDetail', chain: 'sheetChain', company: 'sheetCompany' };
  72. /** 层间矛盾检测(纯函数,推送前最后一道闸):
  73. * 门店明细行存在且指标全部全零、同时连锁/公司汇总行有非零指标 → 已知门店级取数异常形态。
  74. * 新店多导致的大比例全零(部分门店有数)与全新未营业主体(汇总也没数)都不命中。 */
  75. function detectLayerConflict(rows) {
  76. const shopRows = rows.filter((r) => r.scope === 'shop');
  77. if (!shopRows.length) return null;
  78. if (!shopRows.every((r) => METRIC_FIELDS.every((f) => !Number(r[f])))) return null;
  79. const summaryHasData = rows.some((r) => (r.scope === 'chain' || r.scope === 'company')
  80. && METRIC_FIELDS.some((f) => Number(r[f]) > 0));
  81. if (!summaryHasData) return null;
  82. return { shopRows: shopRows.length };
  83. }
  84. const lakeStore = require('../../reports-store.js');
  85. function rowToRecord(row, accountName) {
  86. const fields = {
  87. [FIELD_COLUMNS.date]: row.date || '',
  88. [FIELD_COLUMNS.account]: accountName,
  89. [FIELD_COLUMNS.dimension]: DIMENSION_LABEL[row.scope] || row.scope || '',
  90. [FIELD_COLUMNS.shopId]: String(row.shopId || ''),
  91. [FIELD_COLUMNS.shopName]: row.shopName || '',
  92. [FIELD_COLUMNS.chainId]: String(row.chainId || ''),
  93. [FIELD_COLUMNS.chainName]: row.chainName || '',
  94. };
  95. for (const f of METRIC_FIELDS) {
  96. const v = row[f];
  97. fields[FIELD_COLUMNS[f]] = v == null ? null : Number(v);
  98. }
  99. return { fields };
  100. }
  101. function recordKey(fields) {
  102. return [fields[FIELD_COLUMNS.date], fields[FIELD_COLUMNS.account], fields[FIELD_COLUMNS.dimension],
  103. String(fields[FIELD_COLUMNS.chainId] || ''), String(fields[FIELD_COLUMNS.shopId] || '')].join('|');
  104. }
  105. function recordToRow(record, defaultAccount) {
  106. const f = record.fields || {};
  107. const byName = (col) => {
  108. if (f[col] != null) return f[col];
  109. // 兼容按 fieldId 返回的形态:取 record.fields 中无法按名列出的场景交由真机联调修正
  110. return null;
  111. };
  112. const rev = {};
  113. for (const [k, col] of Object.entries(FIELD_COLUMNS)) rev[col] = k;
  114. const row = {
  115. date: String(byName(FIELD_COLUMNS.date) || '').replace(/-/g, ''),
  116. scope: Object.entries(DIMENSION_LABEL).find(([, label]) => label === byName(FIELD_COLUMNS.dimension))?.[0] || 'shop',
  117. shopId: String(byName(FIELD_COLUMNS.shopId) || ''),
  118. shopName: byName(FIELD_COLUMNS.shopName) || '',
  119. chainId: String(byName(FIELD_COLUMNS.chainId) || ''),
  120. chainName: byName(FIELD_COLUMNS.chainName) || '',
  121. account: byName(FIELD_COLUMNS.account) || defaultAccount || '',
  122. };
  123. for (const f2 of METRIC_FIELDS) {
  124. const v = byName(FIELD_COLUMNS[f2]);
  125. row[f2] = v == null || v === '' ? 0 : Number(v);
  126. }
  127. row.syncedAt = new Date().toISOString();
  128. return row;
  129. }
  130. /** 本地湖行分流到三张云端表(纯函数,smoke 直接回归)。
  131. * summary 行不上云(可由门店行推导,且混入任一表都会造成表内算重)。 */
  132. function splitRowsBySheet(rows) {
  133. const out = { detail: [], chain: [], company: [], skippedSummary: 0 };
  134. for (const row of Array.isArray(rows) ? rows : []) {
  135. const key = SCOPE_SHEET_KEY[row && row.scope];
  136. if (key === 'sheetDetail') out.detail.push(row);
  137. else if (key === 'sheetChain') out.chain.push(row);
  138. else if (key === 'sheetCompany') out.company.push(row);
  139. else out.skippedSummary += 1;
  140. }
  141. return out;
  142. }
  143. /** 解析三张表名(配置缺省用默认值)+ 表名指纹(水位失效判定用) */
  144. function resolveSheets(cfg) {
  145. const sheets = {
  146. detail: cfg.sheetDetail || SHEET_DEFAULTS.sheetDetail,
  147. chain: cfg.sheetChain || SHEET_DEFAULTS.sheetChain,
  148. company: cfg.sheetCompany || SHEET_DEFAULTS.sheetCompany,
  149. };
  150. return { sheets, fingerprint: [sheets.detail, sheets.chain, sheets.company].join('|') };
  151. }
  152. /** 推送结果的一行摘要(日志与状态徽标共用):合计 + 分表明细 */
  153. function pushSummary(r) {
  154. const base = `新增 ${r.created} 行、更新 ${r.updated} 行`;
  155. const parts = Object.entries(r.perSheet || {})
  156. .filter(([, s]) => s.rows > 0)
  157. .map(([, s]) => `${s.sheet} 新增${s.created}/更新${s.updated}`)
  158. .join(',');
  159. return parts ? `${base}(${parts})` : base;
  160. }
  161. function createAitableService(ctx) {
  162. const { config, workspace, logs } = ctx;
  163. // ---------- token(进程内缓存,提前 60s 过期) ----------
  164. let tokenCache = { token: null, expireAt: 0 };
  165. function transport(method, urlPath, bodyObj, token) {
  166. return new Promise((resolve, reject) => {
  167. const data = bodyObj !== undefined ? JSON.stringify(bodyObj) : null;
  168. const req = https.request({
  169. hostname: 'api.dingtalk.com', path: urlPath, method,
  170. headers: {
  171. 'Content-Type': 'application/json',
  172. ...(token ? { 'x-acs-dingtalk-access-token': token } : {}),
  173. ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}),
  174. },
  175. timeout: 30000,
  176. }, (res) => {
  177. const chunks = [];
  178. res.on('data', (c) => chunks.push(c));
  179. res.on('end', () => {
  180. const raw = Buffer.concat(chunks).toString('utf8');
  181. let json = null;
  182. try { json = JSON.parse(raw); } catch { /* keep raw */ }
  183. resolve({ statusCode: res.statusCode, json, raw: raw.slice(0, 800) });
  184. });
  185. });
  186. req.on('error', reject);
  187. req.on('timeout', () => req.destroy(new Error('请求超时')));
  188. if (data) req.write(data);
  189. req.end();
  190. });
  191. }
  192. async function getToken() {
  193. const cfg = config.aitable || {};
  194. if (!cfg.appKey || !cfg.appSecret) throw err('未配置钉钉应用凭据(appKey/appSecret)', 'AITABLE_NO_CFG');
  195. const now = Date.now();
  196. if (tokenCache.token && now < tokenCache.expireAt) return tokenCache.token;
  197. const res = await transport('POST', TOKEN_PATH, { appKey: cfg.appKey, appSecret: cfg.appSecret });
  198. const token = res.json && (res.json.accessToken || (res.json.result && res.json.result.accessToken));
  199. const expireIn = Number((res.json && (res.json.expireIn ?? res.json.result?.expireIn)) || 7200);
  200. if (res.statusCode !== 200 || !token) {
  201. throw err(`获取 access_token 失败 HTTP ${res.statusCode}: ${(res.json && (res.json.message || res.json.errmsg)) || res.raw}`, 'AITABLE_AUTH_FAIL');
  202. }
  203. tokenCache = { token, expireAt: now + Math.max(60, expireIn - 60) * 1000 };
  204. return token;
  205. }
  206. function err(message, code) { const e = new Error(message); e.code = code; e.status = 400; return e; }
  207. // notable 接口统一拼 query:业务参数 + 必填 operatorId
  208. function notableQuery(cfg, params = {}) {
  209. const q = new URLSearchParams({ operatorId: cfg.operatorId || '', ...params });
  210. return `?${q.toString()}`;
  211. }
  212. function requireConfig() {
  213. const cfg = config.aitable || {};
  214. if (!cfg.enabled) throw err('钉钉 AI 表格同步未启用(系统设置中开启)', 'AITABLE_DISABLED');
  215. if (!cfg.baseId || !cfg.appKey) throw err('钉钉 AI 表格配置不完整(AppKey / Base ID)', 'AITABLE_NO_CFG');
  216. if (!cfg.operatorId) throw err('缺少操作者 unionId:先在钉钉开放平台给应用开通通讯录读权限,再运行 node scripts/aitable-discover.js 发现并填入', 'AITABLE_NO_OPERATOR');
  217. return cfg;
  218. }
  219. async function listAllRecords(cfg, sheetName) {
  220. const token = await getToken();
  221. const out = [];
  222. let nextToken = '';
  223. for (;;) {
  224. const q = notableQuery(cfg, { maxResults: LIST_PAGE, ...(nextToken ? { nextToken } : {}) });
  225. const res = await transport('GET', RECORDS_PATH(cfg.baseId, sheetName) + q, undefined, token);
  226. if (res.statusCode !== 200) throw err(`拉取「${sheetName}」记录失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_LIST_FAIL');
  227. const d = res.json || {};
  228. const records = Array.isArray(d.records) ? d.records
  229. : Array.isArray(d.value) ? d.value
  230. : Array.isArray(d.data) ? d.data : [];
  231. out.push(...records);
  232. nextToken = d.nextToken || d.nextCursor || '';
  233. if (!nextToken) break;
  234. }
  235. return out;
  236. }
  237. function chunk(arr, n) {
  238. const out = [];
  239. for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
  240. return out;
  241. }
  242. // ---------- 推送:本地湖 → AI 表格(三表) ----------
  243. let pushing = false; // 单飞锁:手动推送与自动同步的 fire-and-forget 推送不得并发
  244. const pushStateFile = () => path.join(workspace.getWorkspaceRoot(), '.genpic', 'aitable-push.json');
  245. const BACKUP_KEEP = 20; // 每账号保留的推送前备份份数
  246. /** 推送前备份:把本次将被覆盖的云端记录(id+字段)落盘。坏推送可据此回滚;
  247. * 备份失败则中止推送(宁可推不出去,不可无备份地覆盖云端主档案)。 */
  248. function backupSheetUpdates(accountName, sheetName, toUpdate) {
  249. try {
  250. const dir = path.join(workspace.getWorkspaceRoot(), '.genpic', 'aitable-backup',
  251. String(accountName || 'account').replace(/[\\/:*?"<>|]/g, '_'));
  252. fs.mkdirSync(dir, { recursive: true });
  253. const payload = {
  254. savedAt: new Date().toISOString(), account: accountName, sheet: sheetName,
  255. updatedCount: toUpdate.length,
  256. records: toUpdate.map((r) => ({ id: r.id, fields: r.fields })),
  257. };
  258. const file = path.join(dir, `${Date.now()}-${sheetName}.json`);
  259. const temp = `${file}.tmp`;
  260. fs.writeFileSync(temp, JSON.stringify(payload), 'utf8');
  261. fs.renameSync(temp, file);
  262. const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
  263. for (const f of files.slice(0, Math.max(0, files.length - BACKUP_KEEP))) {
  264. try { fs.unlinkSync(path.join(dir, f)); } catch { /* 单份清理失败不影响 */ }
  265. }
  266. } catch (e) {
  267. throw err(`推送前备份失败(${e.message || e}),已中止本次推送以保护云端`, 'AITABLE_BACKUP_FAIL');
  268. }
  269. }
  270. function readPushState() {
  271. try { return JSON.parse(fs.readFileSync(pushStateFile(), 'utf8')); } catch { return {}; }
  272. }
  273. function writePushState(state) {
  274. try {
  275. const file = pushStateFile();
  276. fs.mkdirSync(path.dirname(file), { recursive: true });
  277. const temp = `${file}.${process.pid}.tmp`;
  278. fs.writeFileSync(temp, JSON.stringify(state, null, 2), 'utf8');
  279. fs.renameSync(temp, file);
  280. } catch { /* 状态落盘失败不阻断推送 */ }
  281. }
  282. /** 单张表的 upsert:diff 现有记录 → 分批新增/更新。返回 {created, updated, skippedStale}。
  283. * 冲突策略:同键行「数据新者胜」——云端记录的 lastModifiedTime 比 本地行 syncedAt
  284. * (取数时间)新则跳过(skippedStale),避免另一台机器后推的旧数据盖掉云端新数据。 */
  285. async function pushSheet(cfg, token, sheetName, accountName, rows, opts) {
  286. if (!rows.length) return { created: 0, updated: 0, skippedStale: 0 };
  287. const existing = new Map();
  288. for (const rec of await listAllRecords(cfg, sheetName)) {
  289. const f = rec.fields || {};
  290. const k = recordKey({
  291. [FIELD_COLUMNS.date]: f[FIELD_COLUMNS.date],
  292. [FIELD_COLUMNS.account]: f[FIELD_COLUMNS.account],
  293. [FIELD_COLUMNS.dimension]: f[FIELD_COLUMNS.dimension],
  294. [FIELD_COLUMNS.chainId]: f[FIELD_COLUMNS.chainId],
  295. [FIELD_COLUMNS.shopId]: f[FIELD_COLUMNS.shopId],
  296. });
  297. if (rec.id) existing.set(k, { id: rec.id, mtime: Number(rec.lastModifiedTime) || 0 });
  298. }
  299. let created = 0;
  300. let updated = 0;
  301. let skippedStale = 0;
  302. const toCreate = [];
  303. const toUpdate = []; // {id, fields}(更新接口的记录标识键是 id,不是 record_id)
  304. for (const row of rows) {
  305. const rec = rowToRecord(row, accountName);
  306. const k = recordKey(rec.fields);
  307. const hit = existing.get(k);
  308. if (hit) {
  309. const localAt = new Date(row.syncedAt || 0).getTime();
  310. if (!opts.force && hit.mtime && localAt && hit.mtime > localAt) { skippedStale += 1; continue; }
  311. toUpdate.push({ id: hit.id, fields: rec.fields });
  312. } else {
  313. toCreate.push(rec);
  314. }
  315. }
  316. if (toUpdate.length) backupSheetUpdates(accountName, sheetName, toUpdate);
  317. for (const part of chunk(toCreate, INSERT_CHUNK)) {
  318. const res = await transport('POST', RECORDS_PATH(cfg.baseId, sheetName) + notableQuery(cfg), { records: part }, token);
  319. if (res.statusCode >= 300) throw err(`「${sheetName}」批量新增失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_CREATE_FAIL');
  320. created += part.length;
  321. }
  322. for (const part of chunk(toUpdate, UPDATE_CHUNK)) {
  323. const res = await transport('PUT', RECORDS_PATH(cfg.baseId, sheetName) + notableQuery(cfg), { records: part }, token);
  324. if (res.statusCode >= 300) throw err(`「${sheetName}」批量更新失败 HTTP ${res.statusCode}: ${(res.json && res.json.message) || res.raw}`, 'AITABLE_UPDATE_FAIL');
  325. updated += part.length;
  326. }
  327. return { created, updated, skippedStale };
  328. }
  329. /** 推送一个主体的数据到三张表。force=true 时忽略水位推全量、并跳过冲突/全零保护。
  330. * 水位按「主体 + 三表名指纹」记录:表名变化(如单表→三表迁移)自动降级为全量重推。 */
  331. async function pushLake(entity, opts = {}) {
  332. // 云端写保护:本机只拉不推(防误推坏数据污染共享主档案),硬开关,force 也不绕过
  333. if (config.aitable && config.aitable.readOnly) {
  334. throw err('本机已开启「云端写保护」,数据只拉不推。如需推送,请在 系统设置 → 钉钉 AI 表格 里关闭写保护', 'AITABLE_READ_ONLY');
  335. }
  336. if (pushing) throw err('已有推送在进行中,请稍后再试', 'AITABLE_PUSH_BUSY');
  337. pushing = true;
  338. try {
  339. return await pushLakeLocked(entity, opts);
  340. } finally {
  341. pushing = false;
  342. }
  343. }
  344. async function pushLakeLocked(entity, opts = {}) {
  345. const cfg = requireConfig();
  346. const naposService = require('../../napos-service.js');
  347. const key = naposService.store.sanitizeKey(entity);
  348. const meta = naposService.store.getEntity(key);
  349. const accountName = meta ? meta.name : key;
  350. const { sheets, fingerprint } = resolveSheets(cfg);
  351. const state = readPushState();
  352. const prev = state[key] || {};
  353. // 水位取推送/拉取时间的较大者:拉取回来的行云端本来就有,不作为本地增量重推
  354. const watermark = opts.force || prev.sheets !== fingerprint
  355. ? 0
  356. : Math.max(prev.lastPushedAt || 0, prev.lastPullAt || 0);
  357. const all = naposService.store.loadRows(key) || [];
  358. const rows = watermark ? all.filter((r) => new Date(r.syncedAt || 0).getTime() > watermark) : all;
  359. const parts = splitRowsBySheet(rows);
  360. const pushed = parts.detail.length + parts.chain.length + parts.company.length;
  361. if (!pushed) return { pushed: 0, created: 0, updated: 0, skippedSummary: parts.skippedSummary, skipped: '无新增数据' };
  362. // 大批量(尤其首推全量)耗时较长,开始时先留痕,避免长时间静默
  363. logs.pushSystemLog(`推送云端开始:${accountName}(${watermark ? '增量' : '全量'} ${pushed} 行)`, 'info');
  364. // 层间矛盾保护:防止把取数异常的坏数据灌进云端主档案;force 可绕过。
  365. const conflict = !opts.force && detectLayerConflict(all);
  366. if (conflict) {
  367. throw err(`门店明细 ${conflict.shopRows} 行指标全部全零,而连锁/公司汇总行有数据——门店级取数异常的已知形态,已拦截推送,请先排查取数或重新同步`, 'AITABLE_ALL_ZERO');
  368. }
  369. const token = await getToken();
  370. const targets = [
  371. ['detail', sheets.detail, parts.detail],
  372. ['chain', sheets.chain, parts.chain],
  373. ['company', sheets.company, parts.company],
  374. ];
  375. const perSheet = {};
  376. let created = 0;
  377. let updated = 0;
  378. let skippedStale = 0;
  379. for (const [scope, sheetName, scopeRows] of targets) {
  380. const r = await pushSheet(cfg, token, sheetName, accountName, scopeRows, opts);
  381. perSheet[scope] = { sheet: sheetName, rows: scopeRows.length, ...r };
  382. created += r.created;
  383. updated += r.updated;
  384. skippedStale += r.skippedStale;
  385. }
  386. const now = Date.now();
  387. state[key] = { ...state[key], lastPushedAt: now, pushedRows: pushed, sheets: fingerprint, at: new Date().toISOString() };
  388. writePushState(state);
  389. return { pushed, created, updated, skippedStale, skippedSummary: parts.skippedSummary, perSheet, account: accountName };
  390. }
  391. // ---------- 拉取:AI 表格(三表) → 本地湖 ----------
  392. /**
  393. * 拉取三张 AI 表格记录回本地湖(云端为准,本地同键行被覆盖)。
  394. * entity 缺省 = 拉取云端全部账号:没有注册过的新账号会自动创建轻量主体
  395. * (fresh 机器拉一次即可用报表/图表/问数)。
  396. */
  397. async function pullLake(entity) {
  398. const cfg = requireConfig();
  399. const naposService = require('../../napos-service.js');
  400. logs.pushSystemLog(`云端拉取开始${entity ? `:${entity}` : '(全部账号)'}`, 'info');
  401. const { sheets } = resolveSheets(cfg);
  402. const records = [];
  403. for (const sheetName of [sheets.detail, sheets.chain, sheets.company]) {
  404. records.push(...await listAllRecords(cfg, sheetName));
  405. }
  406. const groups = new Map();
  407. const wantName = entity
  408. ? ((naposService.store.getEntity(naposService.store.sanitizeKey(entity)) || {}).name || entity)
  409. : null;
  410. for (const rec of records) {
  411. const f = rec.fields || {};
  412. const acc = String(f[FIELD_COLUMNS.account] || '').trim();
  413. if (!acc) continue;
  414. if (wantName && acc !== wantName) continue;
  415. if (!groups.has(acc)) groups.set(acc, []);
  416. groups.get(acc).push(recordToRow({ fields: f }, acc));
  417. }
  418. if (!groups.size) return { pulled: 0, accounts: [], message: '云端没有可拉取的数据' };
  419. const result = { pulled: 0, accounts: [] };
  420. const state = readPushState();
  421. const pulledKeys = [];
  422. for (const [acc, rows] of groups) {
  423. let key = naposService.store.sanitizeKey(acc);
  424. let meta = naposService.store.getEntity(key);
  425. if (!meta || !meta.key) {
  426. const created = naposService.store.upsertEntity({ merchantId: acc, merchantName: acc, chains: [], shops: [] });
  427. key = created.entity.key;
  428. meta = created.entity;
  429. }
  430. const res = naposService.store.upsertRows(key, rows);
  431. // 反推连锁/门店结构回填注册表(与已有树取并集,已有条目优先、保留 busyLevel 等字段)
  432. const merged = lakeStore.mergeTree(meta, lakeStore.treeFromRows(rows));
  433. naposService.store.upsertEntity({
  434. merchantId: meta.merchantId || acc,
  435. merchantName: meta.name || acc,
  436. chains: merged.chains,
  437. shops: merged.shops,
  438. });
  439. pulledKeys.push(key);
  440. result.pulled += rows.length;
  441. result.accounts.push({ account: acc, rows: rows.length, added: res.added, updated: res.updated });
  442. }
  443. // 留痕拉取水位:拉回的行云端已有,不计入本机「待推送」(消费方:pendingCount / pushLake 水位)
  444. const now = Date.now();
  445. for (const k of new Set(pulledKeys)) state[k] = { ...(state[k] || {}), lastPullAt: now };
  446. writePushState(state);
  447. return result;
  448. }
  449. /** 连通性测试:token + 操作者身份 + 三张表逐表可读(缺哪张报哪张) */
  450. async function testConnection() {
  451. const cfg = requireConfig();
  452. const { sheets } = resolveSheets(cfg);
  453. const token = await getToken();
  454. const result = { ok: true, baseId: cfg.baseId, operatorId: cfg.operatorId, sheets: {} };
  455. for (const [scope, sheetName] of [['detail', sheets.detail], ['chain', sheets.chain], ['company', sheets.company]]) {
  456. const res = await transport('GET', RECORDS_PATH(cfg.baseId, sheetName) + notableQuery(cfg, { maxResults: 1 }), undefined, token);
  457. const ok = res.statusCode === 200;
  458. const hint = !ok && (res.statusCode === 403 || res.statusCode === 404)
  459. ? '——表不存在或不可读:请在钉钉该 AI 表格里确认已建同名数据表并分享给操作者账号'
  460. : ok ? '' : (res.json && res.json.message) || res.raw;
  461. result.sheets[scope] = { name: sheetName, ok };
  462. if (!ok) { result.ok = false; result.sheets[scope].hint = hint; }
  463. }
  464. return result;
  465. }
  466. function lastPushInfo(entity) {
  467. const key = require('../../napos-service.js').store.sanitizeKey(entity);
  468. const s = readPushState()[key] || null;
  469. return s;
  470. }
  471. /** 待推送行数:水位(最近推送/最近拉取取较大者)之后有变化的行;配置不齐返回 null(调用方显示配置态)。
  472. * 本机从未本地同步过的主体(只读机:数据全部来自云端拉取)没有「待推送」概念,恒 0。 */
  473. function pendingCount(entity) {
  474. const cfg = config.aitable || {};
  475. if (!cfg.enabled || !cfg.baseId || !cfg.appKey || !cfg.operatorId) return null;
  476. const naposService = require('../../napos-service.js');
  477. const key = naposService.store.sanitizeKey(entity);
  478. if (!naposService.store.loadSyncLogs(key).length) return 0;
  479. const st = readPushState()[key] || {};
  480. const watermark = Math.max(st.lastPushedAt || 0, st.lastPullAt || 0);
  481. const all = naposService.store.loadRows(key) || [];
  482. if (!watermark) return all.length;
  483. return all.filter((r) => new Date(r.syncedAt || 0).getTime() > watermark).length;
  484. }
  485. /** 湖↔云只读比对快照(问数智能体 aitable_verify 工具用):
  486. * 三表记录按当前主体账号名过滤后整表拉回,不做任何写入。 */
  487. async function snapshotForVerify(entity) {
  488. const cfg = requireConfig();
  489. const naposService = require('../../napos-service.js');
  490. const { sheets } = resolveSheets(cfg);
  491. const name = (naposService.store.getEntity(naposService.store.sanitizeKey(entity)) || {}).name || entity;
  492. const out = {};
  493. for (const [scope, sheetName] of [['shop', sheets.detail], ['chain', sheets.chain], ['company', sheets.company]]) {
  494. const records = await listAllRecords(cfg, sheetName);
  495. out[scope] = records
  496. .map((rec) => recordToRow(rec, name))
  497. .filter((r) => String(r.account || '') === name);
  498. }
  499. return out;
  500. }
  501. logs.pushSystemLog('钉钉 AI 表格同步服务已就绪(三表:门店明细/连锁汇总/公司汇总)', 'info');
  502. return { pushLake, pullLake, testConnection, lastPushInfo, pendingCount, snapshotForVerify };
  503. }
  504. module.exports = {
  505. createAitableService, FIELD_COLUMNS, DIMENSION_LABEL, SHEET_DEFAULTS,
  506. rowToRecord, recordKey, recordToRow, splitRowsBySheet, resolveSheets, pushSummary,
  507. detectLayerConflict,
  508. treeFromRows: (...args) => lakeStore.treeFromRows(...args),
  509. };