|
|
@@ -75,6 +75,19 @@ const DIMENSION_LABEL = { company: '公司', chain: '连锁', shop: '门店', su
|
|
|
// scope → 目标表配置键(summary 不上云)
|
|
|
const SCOPE_SHEET_KEY = { shop: 'sheetDetail', chain: 'sheetChain', company: 'sheetCompany' };
|
|
|
|
|
|
+/** 层间矛盾检测(纯函数,推送前最后一道闸):
|
|
|
+ * 门店明细行存在且指标全部全零、同时连锁/公司汇总行有非零指标 → 已知门店级取数异常形态。
|
|
|
+ * 新店多导致的大比例全零(部分门店有数)与全新未营业主体(汇总也没数)都不命中。 */
|
|
|
+function detectLayerConflict(rows) {
|
|
|
+ const shopRows = rows.filter((r) => r.scope === 'shop');
|
|
|
+ if (!shopRows.length) return null;
|
|
|
+ if (!shopRows.every((r) => METRIC_FIELDS.every((f) => !Number(r[f])))) return null;
|
|
|
+ const summaryHasData = rows.some((r) => (r.scope === 'chain' || r.scope === 'company')
|
|
|
+ && METRIC_FIELDS.some((f) => Number(r[f]) > 0));
|
|
|
+ if (!summaryHasData) return null;
|
|
|
+ return { shopRows: shopRows.length };
|
|
|
+}
|
|
|
+
|
|
|
function rowToRecord(row, accountName) {
|
|
|
const fields = {
|
|
|
[FIELD_COLUMNS.date]: row.date || '',
|
|
|
@@ -338,25 +351,22 @@ function createAitableService(ctx) {
|
|
|
|
|
|
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 watermark = opts.force || prev.sheets !== fingerprint
|
|
|
+ ? 0
|
|
|
+ : Math.max(prev.lastPushedAt || 0, prev.lastPullAt || 0);
|
|
|
+ const all = naposService.store.loadRows(key) || [];
|
|
|
+ const rows = watermark ? all.filter((r) => new Date(r.syncedAt || 0).getTime() > watermark) : all;
|
|
|
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: '无新增数据' };
|
|
|
// 大批量(尤其首推全量)耗时较长,开始时先留痕,避免长时间静默
|
|
|
logs.pushSystemLog(`推送云端开始:${accountName}(${watermark ? '增量' : '全量'} ${pushed} 行)`, 'info');
|
|
|
|
|
|
- // 全零保护:大批量门店行指标全零基本可断定取数异常(已知门店级 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');
|
|
|
+ // 层间矛盾保护:防止把取数异常的坏数据灌进云端主档案;force 可绕过。
|
|
|
+ const conflict = !opts.force && detectLayerConflict(all);
|
|
|
+ if (conflict) {
|
|
|
+ throw err(`门店明细 ${conflict.shopRows} 行指标全部全零,而连锁/公司汇总行有数据——门店级取数异常的已知形态,已拦截推送,请先排查取数或重新同步`, 'AITABLE_ALL_ZERO');
|
|
|
}
|
|
|
|
|
|
const token = await getToken();
|
|
|
@@ -378,7 +388,7 @@ function createAitableService(ctx) {
|
|
|
}
|
|
|
|
|
|
const now = Date.now();
|
|
|
- state[key] = { lastPushedAt: now, pushedRows: pushed, sheets: fingerprint, at: new Date().toISOString() };
|
|
|
+ state[key] = { ...state[key], lastPushedAt: now, pushedRows: pushed, sheets: fingerprint, at: new Date().toISOString() };
|
|
|
writePushState(state);
|
|
|
return { pushed, created, updated, skippedStale, skippedSummary: parts.skippedSummary, perSheet, account: accountName };
|
|
|
}
|
|
|
@@ -414,6 +424,8 @@ function createAitableService(ctx) {
|
|
|
if (!groups.size) return { pulled: 0, accounts: [], message: '云端没有可拉取的数据' };
|
|
|
|
|
|
const result = { pulled: 0, accounts: [] };
|
|
|
+ const state = readPushState();
|
|
|
+ const pulledKeys = [];
|
|
|
for (const [acc, rows] of groups) {
|
|
|
let key = naposService.store.sanitizeKey(acc);
|
|
|
const meta = naposService.store.getEntity(key);
|
|
|
@@ -422,9 +434,14 @@ function createAitableService(ctx) {
|
|
|
key = created.entity.key;
|
|
|
}
|
|
|
const res = naposService.store.upsertRows(key, rows);
|
|
|
+ pulledKeys.push(key);
|
|
|
result.pulled += rows.length;
|
|
|
result.accounts.push({ account: acc, rows: rows.length, added: res.added, updated: res.updated });
|
|
|
}
|
|
|
+ // 留痕拉取水位:拉回的行云端已有,不计入本机「待推送」(消费方:pendingCount / pushLake 水位)
|
|
|
+ const now = Date.now();
|
|
|
+ for (const k of new Set(pulledKeys)) state[k] = { ...(state[k] || {}), lastPullAt: now };
|
|
|
+ writePushState(state);
|
|
|
return result;
|
|
|
}
|
|
|
|
|
|
@@ -452,13 +469,16 @@ function createAitableService(ctx) {
|
|
|
return s;
|
|
|
}
|
|
|
|
|
|
- /** 待推送行数:水位之后有变化(syncedAt 更新)的行;配置不齐返回 null(调用方显示配置态) */
|
|
|
+ /** 待推送行数:水位(最近推送/最近拉取取较大者)之后有变化的行;配置不齐返回 null(调用方显示配置态)。
|
|
|
+ * 本机从未本地同步过的主体(只读机:数据全部来自云端拉取)没有「待推送」概念,恒 0。 */
|
|
|
function pendingCount(entity) {
|
|
|
const cfg = config.aitable || {};
|
|
|
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;
|
|
|
+ if (!naposService.store.loadSyncLogs(key).length) return 0;
|
|
|
+ const st = readPushState()[key] || {};
|
|
|
+ const watermark = Math.max(st.lastPushedAt || 0, st.lastPullAt || 0);
|
|
|
const all = naposService.store.loadRows(key) || [];
|
|
|
if (!watermark) return all.length;
|
|
|
return all.filter((r) => new Date(r.syncedAt || 0).getTime() > watermark).length;
|
|
|
@@ -488,4 +508,5 @@ function createAitableService(ctx) {
|
|
|
module.exports = {
|
|
|
createAitableService, FIELD_COLUMNS, DIMENSION_LABEL, SHEET_DEFAULTS,
|
|
|
rowToRecord, recordKey, recordToRow, splitRowsBySheet, resolveSheets, pushSummary,
|
|
|
+ detectLayerConflict,
|
|
|
};
|