attribution.js 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. 'use strict';
  2. /**
  3. * 智能问数 · LMDI 归因工具
  4. * ----------------------------------------------------------------------------
  5. * attribution:把营业额变化分解为 曝光效应 × 进店率效应 × 客单价效应
  6. * (对数平均迪氏指数法,V = expUv × clkExpRate × perActualPay)。
  7. * 仅在可比门店集(两期都在线)上计算;比率取加权口径。
  8. * 单店场景恒等式严格成立(残差≈0);多店聚合存在混合残差,如实输出。
  9. */
  10. const nlQuery = require('../../../nl-query.js');
  11. const { ymdDash } = require('./lake.js');
  12. const z2 = (n) => String(n).padStart(2, '0');
  13. function dayShift(ymd, delta) {
  14. const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
  15. d.setDate(d.getDate() + delta);
  16. return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`;
  17. }
  18. function attributionSanitize(args, ctx) {
  19. const a = args || {};
  20. const { dateRange } = ctx;
  21. const dates = ctx.svc.store.datesInLake(ctx.entityKey);
  22. let from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : '';
  23. let to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : '';
  24. from = from && from >= dateRange.from && from <= dateRange.to ? from : '';
  25. to = to && to >= dateRange.from && to <= dateRange.to ? to : '';
  26. if (!from || !to || from > to) {
  27. if (!dates.length) return { empty: true };
  28. to = dates[dates.length - 1];
  29. from = dates[Math.max(0, dates.length - 7)];
  30. }
  31. const len = Math.max(1, Math.round(
  32. (new Date(Number(to.slice(0, 4)), Number(to.slice(4, 6)) - 1, Number(to.slice(6, 8)))
  33. - new Date(Number(from.slice(0, 4)), Number(from.slice(4, 6)) - 1, Number(from.slice(6, 8)))) / 86400000) + 1);
  34. const prevTo = dayShift(from, -1);
  35. const prevFrom = dayShift(prevTo, -(len - 1));
  36. let shopIds = null;
  37. if (Array.isArray(a.shops) && a.shops.length) {
  38. shopIds = nlQuery.matchShopsByName(ctx.shops, a.shops);
  39. if (!shopIds) throw new Error(`门店名未匹配:${a.shops.join('、')}`);
  40. }
  41. return { from, to, prevFrom, prevTo, shopIds, empty: false };
  42. }
  43. function windowSummary(ctx, from, to, shopIds) {
  44. const { svc, entityKey } = ctx;
  45. let rows = svc.store.queryRows(entityKey, { from, to, scope: 'shop' });
  46. if (shopIds) {
  47. const wanted = new Set(shopIds.map(String));
  48. rows = rows.filter((r) => wanted.has(String(r.shopId)));
  49. }
  50. const s = svc.summarizeRows(rows);
  51. return { rows: rows.length, expUv: s.expUv, clk: s.clkExpRate, pay: s.perActualPay, gross: s.grossAmt };
  52. }
  53. function attributionHandler(ctx, args) {
  54. const { svc } = ctx;
  55. if (args.empty) return { summary: '湖内暂无数据,无法归因。', data: { empty: true } };
  56. const { from, to, prevFrom, prevTo, shopIds } = args;
  57. const cur = windowSummary(ctx, from, to, shopIds);
  58. const prev = windowSummary(ctx, prevFrom, prevTo, shopIds);
  59. if (!cur.rows || !prev.rows) {
  60. return { summary: `本期或上期无可比数据(本期 ${cur.rows} 行 / 上期 ${prev.rows} 行),无法归因。`, data: { from, to, prevFrom, prevTo } };
  61. }
  62. const v0 = prev.gross;
  63. const v1 = cur.gross;
  64. const delta = v1 - v0;
  65. const factors = [
  66. { key: 'expUv', name: '曝光效应', cur: cur.expUv, prev: prev.expUv },
  67. { key: 'clkExpRate', name: '进店率效应', cur: cur.clk, prev: prev.clk },
  68. { key: 'perActualPay', name: '客单价效应', cur: cur.pay, prev: prev.pay },
  69. ];
  70. let lines;
  71. let outFactors = [];
  72. let residual = null;
  73. if (v0 <= 0 || v1 <= 0 || delta === 0 || !v0 || !v1) {
  74. lines = [`营业额 ${Math.round(v0)} → ${Math.round(v1)}:基期为 0 或无变化,LMDI 分解不适用。`];
  75. } else {
  76. const L = delta / (Math.log(v1) - Math.log(v0)); // 对数平均权重
  77. let sum = 0;
  78. for (const f of factors) {
  79. let contribution = null;
  80. let note = '';
  81. if (!f.cur || !f.prev || f.cur <= 0 || f.prev <= 0) note = '(某期为 0,无法对数分解)';
  82. else {
  83. contribution = +(L * Math.log(f.cur / f.prev)).toFixed(2);
  84. sum += contribution;
  85. }
  86. outFactors.push({ key: f.key, name: f.name, cur: f.cur, prev: f.prev, contribution, note });
  87. }
  88. residual = +(delta - sum).toFixed(2);
  89. lines = [`营业额归因(LMDI,${ymdDash(from)} ~ ${ymdDash(to)} vs ${ymdDash(prevFrom)} ~ ${ymdDash(prevTo)},可比口径):总变化 ${Math.round(delta)} 元,分解为——`];
  90. for (const f of outFactors) {
  91. const share = f.contribution != null && delta !== 0 ? `(占 ${(f.contribution / delta * 100).toFixed(0)}%)` : '';
  92. lines.push(` ${f.name}:${f.contribution == null ? note : `${f.contribution >= 0 ? '+' : ''}${f.contribution} 元${share}`}`);
  93. }
  94. if (Math.abs(residual) > Math.abs(delta) * 0.02 + 1) {
  95. lines.push(` 混合残差 ${residual} 元(多店聚合与店间结构变化的交叉项,非某单一因素)。`);
  96. }
  97. const top = outFactors.filter((f) => f.contribution != null).sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution))[0];
  98. if (top) lines.push(`主因:${top.name}(${top.contribution >= 0 ? '拉动' : '拖累'} ${Math.abs(top.contribution)} 元)。结论请结合业务事件解读。`);
  99. }
  100. return {
  101. summary: lines.join('\n'),
  102. data: { from, to, prevFrom, prevTo, cur, prev, factors: outFactors, residual, delta },
  103. };
  104. }
  105. module.exports = { attributionSanitize, attributionHandler };