lake.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. 'use strict';
  2. /**
  3. * 智能问数智能体 · 湖内取数与体检工具
  4. * ----------------------------------------------------------------------------
  5. * lake_overview 摸底:主体/门店/区间/行数/最近同步(循环第一步的事实基础)
  6. * query_report 查询:明细/按店/按日,内核复用 nl-query 的 sanitize+execute(口径同报表页)
  7. * data_quality 体检:全零行 / 层间一致性 / 恒等式 / 日期连续性 / 预留列 / 最近同步
  8. *
  9. * handler(ctx, args) → { summary(给模型的观察文本,纯文本无嵌套), data(给前端的产物,行数已裁剪) }
  10. * ctx = { svc: napos-service, entityKey, meta, shops, dateRange }
  11. */
  12. const nlQuery = require('../../../nl-query.js');
  13. const z2 = (n) => String(n).padStart(2, '0');
  14. function ymdLocal(d) { return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`; }
  15. function daysAgoYmd(n) { return ymdLocal(new Date(Date.now() - n * 86400000)); }
  16. function spanDays(from, to) {
  17. return Math.round((new Date(Number(to.slice(0, 4)), Number(to.slice(4, 6)) - 1, Number(to.slice(6, 8)))
  18. - new Date(Number(from.slice(0, 4)), Number(from.slice(4, 6)) - 1, Number(from.slice(6, 8)))) / 86400000) + 1;
  19. }
  20. function eachDay(from, to) {
  21. const out = [];
  22. const d = new Date(Number(from.slice(0, 4)), Number(from.slice(4, 6)) - 1, Number(from.slice(6, 8)));
  23. const end = to;
  24. for (;;) {
  25. const ymd = `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`;
  26. if (ymd > end) break;
  27. out.push(ymd);
  28. d.setDate(d.getDate() + 1);
  29. }
  30. return out;
  31. }
  32. function ymdDash(v) { return String(v || '').replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'); }
  33. /** 列注册表快捷访问:label / type */
  34. function colMap(svc) {
  35. return new Map((svc.REPORT_COLUMNS || []).map((c) => [c.key, c]));
  36. }
  37. /** 行瘦身:raw 原始字段不进产物(60+ 字段 × 200 行太大,模型与前端都不需要) */
  38. function pickRow(r) {
  39. const out = {};
  40. for (const k of Object.keys(r)) if (k !== 'raw') out[k] = r[k];
  41. return out;
  42. }
  43. // ---------- lake_overview ----------
  44. function overviewSanitize(args) { return {}; }
  45. function overviewHandler(ctx) {
  46. const { svc, entityKey, meta, dateRange } = ctx;
  47. const store = svc.store;
  48. const rows = store.loadRows(entityKey);
  49. const scopes = {};
  50. for (const r of rows) scopes[r.scope] = (scopes[r.scope] || 0) + 1;
  51. const dates = store.datesInLake(entityKey);
  52. const syncs = store.loadSyncLogs(entityKey);
  53. const last = syncs[0] || null;
  54. const shops = meta.shops || [];
  55. const lines = [];
  56. lines.push(`主体「${meta.name || entityKey}」:${shops.length} 家门店、${(meta.chains || []).length} 个连锁。`);
  57. if (dates.length) {
  58. lines.push(`湖内区间 ${ymdDash(dateRange.from)} ~ ${ymdDash(dateRange.to)}(${dates.length} 天有数据)。`);
  59. } else {
  60. lines.push('湖内暂无数据行:请先到「数据采集」完成一次同步,或确认已从云端拉取。');
  61. }
  62. lines.push(`行数:门店明细 ${scopes.shop || 0} / 连锁 ${scopes.chain || 0} / 公司 ${scopes.company || 0} / 区间汇总 ${scopes.summary || 0}。`);
  63. if (last) {
  64. lines.push(`最近同步 ${last.at}:${ymdDash(last.from || '')} ~ ${ymdDash(last.to || '')},新增 ${last.added ?? 0} 更新 ${last.updated ?? 0} 失败 ${last.failed ?? 0}${last.cancelled ? '(被取消)' : ''}。`);
  65. } else {
  66. lines.push('暂无同步记录。');
  67. }
  68. return {
  69. summary: lines.join('\n'),
  70. data: {
  71. entity: { key: entityKey, name: meta.name || entityKey },
  72. chains: meta.chains || [],
  73. shops: shops.slice(0, 300),
  74. dateRange,
  75. datesCount: dates.length,
  76. scopes,
  77. lastSync: last ? { at: last.at, from: last.from, to: last.to, added: last.added, updated: last.updated, failed: last.failed, cancelled: !!last.cancelled } : null,
  78. },
  79. };
  80. }
  81. // ---------- query_report ----------
  82. function querySanitize(args, ctx) {
  83. const { shops, dateRange } = ctx;
  84. return nlQuery.sanitizeQuery(args || {}, shops, dateRange);
  85. }
  86. function queryHandler(ctx, query) {
  87. const { svc, entityKey } = ctx;
  88. const result = nlQuery.executeQuery(entityKey, query, svc);
  89. const rows = (result.rows || []).map(pickRow);
  90. return {
  91. summary: nlQuery.buildResultDigest(query, { columns: result.columns, rows: result.rows, summary: result.summary }),
  92. data: {
  93. applied: result.applied,
  94. columns: (result.columns || []).map((c) => ({ key: c.key, label: c.label, type: c.type })),
  95. summary: result.summary,
  96. totalRows: rows.length,
  97. rows: rows.slice(0, 200),
  98. },
  99. };
  100. }
  101. // ---------- data_quality ----------
  102. const IDENTITY_TOL = 0.005; // 恒等式容差:收入+支出 与 营业额 相对差 0.5%
  103. const CROSS_TOL = 0.005; // 层间一致性容差:门店合计 vs 连锁/公司 相对差 0.5%
  104. const CROSS_FAIL = 0.2; // 层间相对差超过 20% 判 fail(典型:全零行)
  105. function qualitySanitize(args, ctx) {
  106. const a = args || {};
  107. const { dateRange } = ctx;
  108. const from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : dateRange.from;
  109. const to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : dateRange.to;
  110. return { from: from < dateRange.from ? dateRange.from : from, to: to > dateRange.to ? dateRange.to : to };
  111. }
  112. function qualityHandler(ctx, args) {
  113. const { svc, entityKey, dateRange } = ctx;
  114. const store = svc.store;
  115. const { from, to } = args;
  116. const inRange = (r) => r.date >= from && r.date <= to;
  117. const shopRows = store.queryRows(entityKey, { from, to, scope: 'shop' });
  118. const chainRows = store.queryRows(entityKey, { from, to, scope: 'chain' });
  119. const companyRows = store.queryRows(entityKey, { from, to, scope: 'company' });
  120. const findings = [];
  121. const note = (level, check, detail) => findings.push({ level, check, detail });
  122. // 1) 门店明细全零行(已知 bug 形态:shop 全零而 chain 有数)
  123. const zeroRows = shopRows.filter((r) => !(Number(r.validOrderCnt) > 0) && !(Number(r.grossAmt) > 0));
  124. if (shopRows.length) {
  125. const pct = zeroRows.length / shopRows.length;
  126. const byShop = new Map();
  127. for (const r of zeroRows) byShop.set(r.shopName || r.shopId, (byShop.get(r.shopName || r.shopId) || 0) + 1);
  128. const top = [...byShop.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5)
  129. .map(([name, n]) => `${name}×${n}`).join('、');
  130. const level = pct >= 0.3 ? 'fail' : (pct >= 0.05 ? 'warn' : 'ok');
  131. note(level, '门店明细全零行', zeroRows.length
  132. ? `${zeroRows.length}/${shopRows.length} 行营业额与单量全零(${(pct * 100).toFixed(1)}%),主要:${top}。疑似取数参数或数据权限异常`
  133. : '未发现全零行');
  134. }
  135. // 2) 层间一致性:sum(门店) vs sum(连锁) vs 公司行(按日比对营业额)
  136. const sumBy = (rows) => {
  137. const m = new Map();
  138. for (const r of rows) m.set(r.date, (m.get(r.date) || 0) + (Number(r.grossAmt) || 0));
  139. return m;
  140. };
  141. const shopSum = sumBy(shopRows);
  142. const chainSum = sumBy(chainRows);
  143. const companySum = sumBy(companyRows);
  144. const mismatches = [];
  145. const relDiff = (a, b) => Math.abs(a - b) / Math.max(Math.abs(b), 1);
  146. for (const [d, sv] of shopSum) {
  147. const cv = chainSum.get(d);
  148. if (cv !== undefined && relDiff(sv, cv) > CROSS_TOL) mismatches.push({ date: d, kind: '门店合计≠连锁', sv, cv });
  149. }
  150. for (const [d, cv] of chainSum) {
  151. const pv = companySum.get(d);
  152. if (pv !== undefined && relDiff(cv, pv) > CROSS_TOL) mismatches.push({ date: d, kind: '连锁合计≠公司', sv: cv, cv: pv });
  153. }
  154. if (shopSum.size || chainSum.size) {
  155. if (mismatches.length) {
  156. const worst = mismatches.slice().sort((a, b) => relDiff(b.sv, b.cv) - relDiff(a.sv, a.cv))[0];
  157. const level = mismatches.some((m) => relDiff(m.sv, m.cv) > CROSS_FAIL) ? 'fail' : 'warn';
  158. note(level, '层间一致性(营业额)', `${mismatches.length} 天层间对不齐,最差 ${ymdDash(worst.date)} ${worst.kind}:${Math.round(worst.sv)} vs ${Math.round(worst.cv)}。`);
  159. } else {
  160. note('ok', '层间一致性(营业额)', `抽查 ${Math.min(shopSum.size, chainSum.size)} 天门店/连锁/公司层间一致(容差 ${(CROSS_TOL * 100).toFixed(1)}%)。`);
  161. }
  162. }
  163. // 3) 恒等式:收入 + 支出 = 营业额
  164. const identityBad = shopRows.filter((r) => {
  165. const g = Number(r.grossAmt) || 0;
  166. const lhs = (Number(r.incomeAmt) || 0) + (Number(r.expenseAmt) || 0);
  167. return Math.abs(lhs - g) > Math.max(g * IDENTITY_TOL, 1);
  168. });
  169. note(identityBad.length ? 'warn' : 'ok', '恒等式 收入+支出=营业额',
  170. identityBad.length ? `${identityBad.length} 行不满足(示例:${ymdDash(identityBad[0].date)} ${identityBad[0].shopName || ''})` : '全部满足');
  171. // 4) 日期连续性
  172. const dates = [...new Set(shopRows.map((r) => r.date))].sort();
  173. if (dates.length) {
  174. const expected = eachDay(dates[0], dates[dates.length - 1]);
  175. const missing = expected.filter((d) => !dates.includes(d));
  176. const pct = missing.length / expected.length;
  177. note(pct >= 0.3 ? 'warn' : 'info', '日期连续性',
  178. missing.length ? `区间内缺 ${missing.length}/${expected.length} 天(${pct >= 0.3 ? '偏多,' : ''}示例:${missing.slice(0, 5).map(ymdDash).join('、')})。食堂休假/寒暑假属正常缺日`
  179. : `区间 ${ymdDash(dates[0])} ~ ${ymdDash(dates[dates.length - 1])} 逐日连续`);
  180. }
  181. // 5) 预留列(恒定无数据,属预期而非故障)
  182. note('info', '预留列', '营业时长/异常关店时长/商品销售额当前无数据源,恒为空(口径见语义层)');
  183. // 6) 最近同步
  184. const last = (store.loadSyncLogs(entityKey)[0]) || null;
  185. if (!last) note('info', '最近同步', '无同步记录');
  186. else {
  187. const failed = last.failed || 0;
  188. const warned = (last.steps || []).filter((s) => s && s.warn).length;
  189. note(failed > 0 ? 'warn' : (warned > 0 ? 'warn' : 'ok'), '最近同步',
  190. `${last.at} ${failed ? `失败 ${failed} 步` : ''}${warned ? `告警 ${warned} 步(含全零)` : ''}${!failed && !warned ? '正常完成' : ''}`);
  191. }
  192. const lines = findings.map((f) => `[${f.level}] ${f.check}:${f.detail}`);
  193. const rangeLine = `体检范围 ${ymdDash(from)} ~ ${ymdDash(to)}(湖内 ${ymdDash(dateRange.from)} ~ ${ymdDash(dateRange.to)}):`;
  194. return {
  195. summary: rangeLine + '\n' + lines.join('\n'),
  196. data: {
  197. from, to, findings,
  198. verdicts: findings.map((f) => ({
  199. kind: 'quality', check: f.check,
  200. status: f.level === 'fail' ? 'fail' : (f.level === 'warn' ? 'warn' : 'ok'),
  201. detail: f.detail,
  202. })),
  203. },
  204. };
  205. }
  206. module.exports = {
  207. overviewSanitize, overviewHandler,
  208. querySanitize, queryHandler,
  209. qualitySanitize, qualityHandler,
  210. pickRow, colMap, spanDays, eachDay, ymdDash, daysAgoYmd,
  211. };