compare.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. 'use strict';
  2. /**
  3. * 智能问数智能体 · 同比环比对比工具
  4. * ----------------------------------------------------------------------------
  5. * compare_periods:本期 vs 基期(默认等长紧邻平移 = 环比;gap 支持隔段对比)。
  6. * 方法论固定在实现里,模型无权偏离:
  7. * · 门店集合对齐 —— 两期都在线的店才进可比集,开/闭店单列(防开闭店污染 delta)
  8. * · 比率指标用 summarizeRows 的加权口径(曝光/进店加权),绝不算术平均
  9. * · 缺失日显式列出;恒等式(收入+支出=营业额)逐期校验
  10. */
  11. const nlQuery = require('../../../nl-query.js');
  12. const { pickRow, eachDay, ymdDash } = require('./lake.js');
  13. const ADDITIVE = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt', 'actOrdCnt', 'actSubsidyAmt'];
  14. const z2 = (n) => String(n).padStart(2, '0');
  15. function dayShift(ymd, delta) {
  16. const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
  17. d.setDate(d.getDate() + delta);
  18. return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`;
  19. }
  20. function clampDay(v, lo, hi) {
  21. const s = /^\d{8}$/.test(String(v || '')) ? String(v) : '';
  22. if (!s) return '';
  23. return s < lo ? lo : (s > hi ? hi : s);
  24. }
  25. const VALID_METRICS = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt',
  26. 'clkExpRate', 'ordClkRate', 'perActualPay', 'actOrdCnt', 'actSubsidyAmt', 'shopRoi', 'rebuyRate30d'];
  27. function compareSanitize(args, ctx) {
  28. const { shops, dateRange } = ctx;
  29. const a = args || {};
  30. const ms = (Array.isArray(a.metrics) ? a.metrics : []).map(String).filter((k) => VALID_METRICS.includes(k)).slice(0, 4);
  31. const finalMetrics = ms.length ? ms : ['grossAmt', 'validOrderCnt'];
  32. // 本期区间:缺省取湖内最后 7 个有数日(稀疏数据下比「自然日倒退 7 天」更贴近直觉)
  33. const dates = ctx.svc.store.datesInLake(ctx.entityKey);
  34. let from = clampDay(a.from, dateRange.from, dateRange.to);
  35. let to = clampDay(a.to, dateRange.from, dateRange.to);
  36. if (!from || !to || from > to) {
  37. if (!dates.length) return { metrics: finalMetrics, from: dateRange.from, to: dateRange.to, prevFrom: dateRange.from, prevTo: dateRange.to, shops: null, empty: true };
  38. to = dates[dates.length - 1];
  39. from = dates[Math.max(0, dates.length - 7)];
  40. }
  41. const gap = Number(a.gap) > 0 ? Math.floor(Number(a.gap)) : 0;
  42. const len = Math.max(1, Number(
  43. (new Date(Number(to.slice(0, 4)), Number(to.slice(4, 6)) - 1, Number(to.slice(6, 8)))
  44. - new Date(Number(from.slice(0, 4)), Number(from.slice(4, 6)) - 1, Number(from.slice(6, 8)))) / 86400000) + 1);
  45. const prevTo = dayShift(from, -1 - gap);
  46. const prevFrom = dayShift(prevTo, -(len - 1));
  47. let shopIds = null;
  48. const rawShops = Array.isArray(a.shops) ? a.shops.filter(Boolean) : [];
  49. if (rawShops.length) {
  50. shopIds = nlQuery.matchShopsByName(shops, rawShops);
  51. if (!shopIds) throw new Error(`门店名未匹配到任何门店:${rawShops.join('、')}(可用 lake_overview 查看门店清单)`);
  52. }
  53. return { metrics: finalMetrics, from, to, prevFrom, prevTo, shops: shopIds, empty: false };
  54. }
  55. function windowStats(ctx, from, to, shopIds) {
  56. const { svc, entityKey } = ctx;
  57. let rows = svc.store.queryRows(entityKey, { from, to, scope: 'shop' });
  58. if (shopIds) {
  59. const wanted = new Set(shopIds.map(String));
  60. rows = rows.filter((r) => wanted.has(String(r.shopId)));
  61. }
  62. const shopsPresent = new Map();
  63. const dates = new Set();
  64. for (const r of rows) {
  65. shopsPresent.set(String(r.shopId), (shopsPresent.get(String(r.shopId)) || 0) + 1);
  66. dates.add(r.date);
  67. }
  68. return { rows, summary: svc.summarizeRows(rows), shopsPresent, dates: [...dates].sort() };
  69. }
  70. function identityCheck(summary) {
  71. const g = Number(summary.grossAmt) || 0;
  72. const lhs = (Number(summary.incomeAmt) || 0) + (Number(summary.expenseAmt) || 0);
  73. const bad = Math.abs(lhs - g) > Math.max(g * 0.005, 1);
  74. return { ok: !bad, lhs: Math.round(lhs * 100) / 100, gross: Math.round(g * 100) / 100 };
  75. }
  76. function bucketBy(ctx, rows, group, metrics) {
  77. // group: 'date' | 'shopId';给图表用的轻量逐桶聚合(加权口径与 summarizeRows 一致)
  78. const { svc } = ctx;
  79. const map = new Map();
  80. for (const r of rows) {
  81. const k = group === 'date' ? r.date : String(r.shopId);
  82. if (!map.has(k)) map.set(k, []);
  83. map.get(k).push(r);
  84. }
  85. return [...map.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([k, list]) => {
  86. const s = svc.summarizeRows(list);
  87. const out = group === 'date' ? { date: k } : { shopId: k, shopName: (list[0] || {}).shopName || k };
  88. for (const m of metrics) out[m] = s[m];
  89. return out;
  90. });
  91. }
  92. function compareHandler(ctx, args) {
  93. const { svc, entityKey, meta } = ctx;
  94. const reg = new Map((svc.REPORT_COLUMNS || []).map((c) => [c.key, c]));
  95. const label = (k) => (reg.get(k) || {}).label || k;
  96. if (args.empty) {
  97. return { summary: '湖内暂无数据,无法对比。请先完成同步。', data: { empty: true } };
  98. }
  99. const { metrics, from, to, prevFrom, prevTo, shops } = args;
  100. const shopNameOf = new Map((meta.shops || []).map((s) => [String(s.shopId), s.shopName]));
  101. const cur = windowStats(ctx, from, to, shops);
  102. const prev = windowStats(ctx, prevFrom, prevTo, shops);
  103. // 可比集:两期都出现过的门店
  104. const comparable = [...cur.shopsPresent.keys()].filter((id) => prev.shopsPresent.has(id));
  105. const curCmp = comparable.length ? windowStats(ctx, from, to, comparable) : cur;
  106. const prevCmp = comparable.length ? windowStats(ctx, prevFrom, prevTo, comparable) : prev;
  107. const deltas = metrics.map((k) => {
  108. const c = Number(curCmp.summary[k]) || 0;
  109. const p = Number(prevCmp.summary[k]) || 0;
  110. const type = (reg.get(k) || {}).type || 'num';
  111. const deltaPct = (type === 'pct')
  112. ? (p > 0 ? (c - p) / p : null) // 比率的「变化」用相对差表述
  113. : (p !== 0 ? (c - p) / Math.abs(p) : null);
  114. return { key: k, label: label(k), type, cur: c, prev: p, delta: Math.round((c - p) * 100) / 100, deltaPct };
  115. });
  116. const opened = [...cur.shopsPresent.keys()].filter((id) => !prev.shopsPresent.has(id));
  117. const closed = [...prev.shopsPresent.keys()].filter((id) => !cur.shopsPresent.has(id));
  118. const expected = eachDay(from, to);
  119. const missingDays = expected.filter((d) => !cur.dates.includes(d));
  120. const idCur = identityCheck(curCmp.summary);
  121. const idPrev = identityCheck(prevCmp.summary);
  122. const lines = [];
  123. lines.push(`对比口径:本期 ${ymdDash(from)} ~ ${ymdDash(to)} vs 上期 ${ymdDash(prevFrom)} ~ ${ymdDash(prevTo)}(等长平移),可比门店 ${comparable.length} 家${shops ? '(已限定指定门店)' : ''}。`);
  124. for (const d of deltas) {
  125. const chg = d.deltaPct == null ? '上期为 0/无数据,无法算变化率'
  126. : `${d.deltaPct >= 0 ? '+' : ''}${(d.deltaPct * 100).toFixed(1)}%`;
  127. const fmt = (v) => d.type === 'pct' ? `${(v * 100).toFixed(2)}%` : String(Math.round(v * 100) / 100);
  128. lines.push(`${d.label}:本期 ${fmt(d.cur)} vs 上期 ${fmt(d.prev)}(${chg})`);
  129. }
  130. if (opened.length) lines.push(`本期新出现门店(未进可比集):${opened.map((id) => shopNameOf.get(id) || id).join('、')}`);
  131. if (closed.length) lines.push(`上期有数本期无数门店(未进可比集):${closed.map((id) => shopNameOf.get(id) || id).join('、')}`);
  132. if (missingDays.length) lines.push(`本期缺数 ${missingDays.length} 天:${missingDays.slice(0, 8).map(ymdDash).join('、')}${missingDays.length > 8 ? ' 等' : ''}`);
  133. if (!idCur.ok || !idPrev.ok) lines.push(`⚠ 恒等式(收入+支出=营业额)不满足:本期 ${idCur.lhs} vs ${idCur.gross},上期 ${idPrev.lhs} vs ${idPrev.gross},数据质量存疑`);
  134. return {
  135. summary: lines.join('\n'),
  136. data: {
  137. cur: { from, to, summary: curCmp.summary },
  138. prev: { from: prevFrom, to: prevTo, summary: prevCmp.summary },
  139. comparableShopCount: comparable.length,
  140. opened: opened.map((id) => ({ shopId: id, shopName: shopNameOf.get(id) || id })),
  141. closed: closed.map((id) => ({ shopId: id, shopName: shopNameOf.get(id) || id })),
  142. missingDays,
  143. deltas,
  144. identity: { cur: idCur, prev: idPrev },
  145. curByDate: bucketBy(ctx, curCmp.rows, 'date', metrics),
  146. curByShop: bucketBy(ctx, curCmp.rows, 'shopId', metrics).sort((a, b) => (Number(b.grossAmt) || 0) - (Number(a.grossAmt) || 0)).slice(0, 50),
  147. verdicts: [
  148. { kind: 'identity', check: '恒等式 收入+支出=营业额(本期)', status: idCur.ok ? 'ok' : 'fail', detail: `本期 ${idCur.lhs} vs ${idCur.gross}` },
  149. { kind: 'identity', check: '恒等式 收入+支出=营业额(上期)', status: idPrev.ok ? 'ok' : 'fail', detail: `上期 ${idPrev.lhs} vs ${idPrev.gross}` },
  150. ...(missingDays.length ? [{
  151. kind: 'freshness', check: '本期数据完整性',
  152. status: missingDays.length / expected.length > 0.3 ? 'warn' : 'ok',
  153. detail: `缺 ${missingDays.length}/${expected.length} 天(${missingDays.slice(0, 3).map(ymdDash).join('、')}${missingDays.length > 3 ? ' 等' : ''})`,
  154. }] : []),
  155. { kind: 'coverage', check: '门店可比集', status: opened.length || closed.length ? 'warn' : 'ok', detail: `可比 ${comparable.length} 家${opened.length ? `,新出现 ${opened.length} 家` : ''}${closed.length ? `,缺数 ${closed.length} 家` : ''}` },
  156. ],
  157. },
  158. };
  159. }
  160. module.exports = { compareSanitize, compareHandler, windowStats, identityCheck, bucketBy, ADDITIVE };