|
|
@@ -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,
|
|
|
+};
|