'use strict'; /** * 智能问数智能体 · 湖↔云比对工具 * ---------------------------------------------------------------------------- * aitable_verify:本地数据湖 vs 钉钉 AI 表格(在线主档案)只读比对。 * 行数按粒度对比 + 共同行键的营业额抽样值比对,只读不写(记录接口无建列限流, * 但仍控制在整表拉一次)。云端未启用/配置不齐时优雅降级为说明性结论。 * ctx.cloud 由路由层装配(aitable 服务实例);未装配时同样优雅降级。 */ const { ymdDash } = require('./lake.js'); function cloudSanitize(args, ctx) { const a = args || {}; const { dateRange } = ctx; const from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : dateRange.from; const to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : dateRange.to; return { from: from < dateRange.from ? dateRange.from : from, to: to > dateRange.to ? dateRange.to : to, sample: Math.min(Number(a.sample) > 0 ? Number(a.sample) : 5, 10) }; } function rowKey(row) { return `${row.date}|${row.scope || ''}|${row.chainId == null ? '' : String(row.chainId)}|${row.shopId == null ? '' : String(row.shopId)}`; } async function cloudHandler(ctx, args) { const { svc, entityKey, cloud } = ctx; const { from, to, sample } = args; if (!cloud) { return { summary: '湖↔云比对不可用:云端服务未装配(仅云端协同模式提供)。', data: { available: false, verdicts: [{ kind: 'cloud', check: '湖↔云比对', status: 'warn', detail: '云端服务未装配' }], }, }; } let snapshot; try { snapshot = await cloud.snapshotForVerify(entityKey); } catch (e) { return { summary: `湖↔云比对不可用:${e.message || e}(云端未启用或配置不齐时属预期;如需比对请先到系统设置开启并测试连通)。`, data: { available: false, reason: String(e.message || e).slice(0, 200) }, verdicts: [{ kind: 'cloud', check: '湖↔云比对', status: 'warn', detail: `云端不可用:${String(e.message || e).slice(0, 80)}` }], }; } const inRange = (r) => String(r.date || '') >= from && String(r.date || '') <= to; const lines = [`湖↔云比对(区间 ${ymdDash(from)} ~ ${ymdDash(to)},业务键 日期|维度|连锁|门店):`]; const perScope = []; let totalDiff = 0; const samples = []; for (const scope of ['shop', 'chain', 'company']) { const localRows = svc.store.queryRows(entityKey, { from, to, scope }).filter(inRange); const cloudRows = (snapshot[scope] || []).filter(inRange); const localMap = new Map(localRows.map((r) => [rowKey(r), r])); const cloudMap = new Map(cloudRows.map((r) => [rowKey(r), r])); let onlyLocal = 0; let onlyCloud = 0; let valueDiff = 0; const shared = []; for (const [k, r] of localMap) { if (!cloudMap.has(k)) { onlyLocal += 1; continue; } shared.push([k, r, cloudMap.get(k)]); } for (const k of cloudMap.keys()) if (!localMap.has(k)) onlyCloud += 1; for (const [k, lr, cr] of shared) { const lv = Number(lr.grossAmt) || 0; const cv = Number(cr.grossAmt) || 0; if (Math.abs(lv - cv) > Math.max(lv * 0.005, 1)) { valueDiff += 1; if (samples.length < sample) samples.push({ scope, key: k, local: Math.round(lv * 100) / 100, cloud: Math.round(cv * 100) / 100 }); } } perScope.push({ scope, local: localRows.length, cloud: cloudRows.length, onlyLocal, onlyCloud, valueDiff, shared: shared.length }); totalDiff += onlyLocal + onlyCloud + valueDiff; } const label = { shop: '门店明细', chain: '连锁汇总', company: '公司汇总' }; for (const s of perScope) { lines.push(` ${label[s.scope]}:本地 ${s.local} 行 / 云端 ${s.cloud} 行(仅本地 ${s.onlyLocal}、仅云端 ${s.onlyCloud}、抽样值不一致 ${s.valueDiff})`); } if (samples.length) { lines.push(`抽样值差异示例:${samples.map((s) => `${label[s.scope]} ${s.key} 本地 ${s.local} vs 云端 ${s.cloud}`).join(';')}`); } const ok = totalDiff === 0; if (!ok) lines.push('湖与云端存在差异:通常由「上次同步/推送之后本地又有新数据」导致,可到数据采集页推送或从中台拉取后再问。'); else lines.push('湖与云端一致。'); return { summary: lines.join('\n'), data: { available: true, from, to, perScope, samples, verdicts: [{ kind: 'cloud', check: `湖↔云一致性(${ymdDash(from)} ~ ${ymdDash(to)})`, status: ok ? 'ok' : 'warn', detail: ok ? '三表行数与抽样值一致' : `差异 ${totalDiff} 处(行数或抽样值)`, }], }, }; } module.exports = { cloudSanitize, cloudHandler };