cross.js 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106
  1. 'use strict';
  2. /**
  3. * 智能问数智能体 · 层间一致性核对工具
  4. * ----------------------------------------------------------------------------
  5. * cross_check:按日核对 门店合计 vs 连锁合计 vs 公司行(营业额 + 有效订单)。
  6. * 从 data_quality 中独立成工具,供模型对指定区间单独抽查(如环比异常时先排除
  7. * 数据质量问题)。容差与判定同 data_quality:相对差 >0.5% 记不一致、>20% 判严重。
  8. */
  9. const { eachDay, ymdDash } = require('./lake.js');
  10. const CROSS_TOL = 0.005;
  11. const CROSS_FAIL = 0.2;
  12. function crossSanitize(args, ctx) {
  13. const a = args || {};
  14. const { dateRange } = ctx;
  15. const from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : dateRange.from;
  16. const to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : dateRange.to;
  17. return {
  18. from: from < dateRange.from ? dateRange.from : from,
  19. to: to > dateRange.to ? dateRange.to : to,
  20. };
  21. }
  22. function sumBy(rows, key) {
  23. const m = new Map();
  24. for (const r of rows) m.set(r.date, (m.get(r.date) || 0) + (Number(r[key]) || 0));
  25. return m;
  26. }
  27. function relDiff(a, b) {
  28. return Math.abs(a - b) / Math.max(Math.abs(b), 1);
  29. }
  30. function crossHandler(ctx, args) {
  31. const { svc, entityKey } = ctx;
  32. const store = svc.store;
  33. const { from, to } = args;
  34. const inRange = (r) => r.date >= from && r.date <= to;
  35. const shopRows = store.queryRows(entityKey, { from, to, scope: 'shop' }).filter(inRange);
  36. const chainRows = store.queryRows(entityKey, { from, to, scope: 'chain' }).filter(inRange);
  37. const companyRows = store.queryRows(entityKey, { from, to, scope: 'company' }).filter(inRange);
  38. if (!shopRows.length && !chainRows.length) {
  39. return { summary: `区间 ${ymdDash(from)} ~ ${ymdDash(to)} 无可核对的行(门店/连锁层均无数据)。`, data: { from, to, checked: 0 } };
  40. }
  41. const metrics = [
  42. { key: 'grossAmt', label: '营业额' },
  43. { key: 'validOrderCnt', label: '有效订单' },
  44. ];
  45. // 层×指标 各建一张按日求和表(日期字符串不做数值运算)
  46. const sums = {};
  47. for (const m of metrics) {
  48. sums[m.key] = { shop: sumBy(shopRows, m.key), chain: sumBy(chainRows, m.key), company: sumBy(companyRows, m.key) };
  49. }
  50. const days = eachDay(from, to).filter((d) => sums.grossAmt.shop.has(d) || sums.grossAmt.chain.has(d));
  51. const mismatches = [];
  52. let checked = 0;
  53. for (const d of days) {
  54. for (const m of metrics) {
  55. const shop = sums[m.key].shop.get(d);
  56. const chain = sums[m.key].chain.get(d);
  57. const company = sums[m.key].company.get(d);
  58. // 同层求和口径:门店合计对比连锁合计;连锁合计对比公司行(任一侧缺失跳过该对比)
  59. const pairs = [];
  60. if (shop !== undefined && chain !== undefined) pairs.push(['门店合计 vs 连锁', shop, chain]);
  61. if (chain !== undefined && company !== undefined) pairs.push(['连锁合计 vs 公司', chain, company]);
  62. for (const [kind, a, b] of pairs) {
  63. checked += 1;
  64. const diff = relDiff(a, b);
  65. if (diff > CROSS_TOL) mismatches.push({ date: d, metric: m.label, kind, a: Math.round(a * 100) / 100, b: Math.round(b * 100) / 100, diffPct: +(diff * 100).toFixed(2) });
  66. }
  67. }
  68. }
  69. mismatches.sort((x, y) => y.diffPct - x.diffPct);
  70. const severe = mismatches.some((m) => m.diffPct / 100 > CROSS_FAIL);
  71. const lines = [];
  72. if (!mismatches.length) {
  73. lines.push(`层间一致性通过:抽查 ${days.length} 天 × 2 指标(共 ${checked} 组对比),门店合计/连锁/公司层间一致(容差 ${(CROSS_TOL * 100).toFixed(1)}%)。`);
  74. } else {
  75. lines.push(`层间一致性${severe ? '严重不一致' : '存在不一致'}:${mismatches.length} 组对比超容差(共核对 ${checked} 组),最差 ${ymdDash(mismatches[0].date)} ${mismatches[0].kind} ${mismatches[0].metric}:${mismatches[0].a} vs ${mismatches[0].b}(差 ${mismatches[0].diffPct}%)。`);
  76. for (const m of mismatches.slice(0, 8)) {
  77. lines.push(` ${ymdDash(m.date)} ${m.metric} ${m.kind}:${m.a} vs ${m.b}(差 ${m.diffPct}%)`);
  78. }
  79. if (mismatches.length > 8) lines.push(` …其余 ${mismatches.length - 8} 组略`);
  80. lines.push('层间不一致通常意味着某一粒度取数异常(如门店行全零、部分门店缺数),相关结论应标注「数据质量存疑」。');
  81. }
  82. return {
  83. summary: lines.join('\n'),
  84. data: {
  85. from, to, checked, mismatchCount: mismatches.length, severe, mismatches: mismatches.slice(0, 20),
  86. verdicts: [{
  87. kind: 'cross-layer', check: `层间一致性 · 门店/连锁/公司(${ymdDash(from)} ~ ${ymdDash(to)})`,
  88. status: mismatches.length === 0 ? 'ok' : (severe ? 'fail' : 'warn'),
  89. detail: mismatches.length === 0
  90. ? `抽查 ${days.length} 天 × 2 指标共 ${checked} 组对比全部一致`
  91. : `${mismatches.length} 组超容差,最差 ${ymdDash(mismatches[0].date)} ${mismatches[0].metric} 差 ${mismatches[0].diffPct}%`,
  92. }],
  93. },
  94. };
  95. }
  96. module.exports = { crossSanitize, crossHandler, CROSS_TOL, CROSS_FAIL };