| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112 |
- 'use strict';
- /**
- * 智能问数 · LMDI 归因工具
- * ----------------------------------------------------------------------------
- * attribution:把营业额变化分解为 曝光效应 × 进店率效应 × 客单价效应
- * (对数平均迪氏指数法,V = expUv × clkExpRate × perActualPay)。
- * 仅在可比门店集(两期都在线)上计算;比率取加权口径。
- * 单店场景恒等式严格成立(残差≈0);多店聚合存在混合残差,如实输出。
- */
- const nlQuery = require('../../../nl-query.js');
- const { ymdDash } = require('./lake.js');
- const z2 = (n) => String(n).padStart(2, '0');
- function dayShift(ymd, delta) {
- const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
- d.setDate(d.getDate() + delta);
- return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`;
- }
- function attributionSanitize(args, ctx) {
- const a = args || {};
- const { dateRange } = ctx;
- const dates = ctx.svc.store.datesInLake(ctx.entityKey);
- let from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : '';
- let to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : '';
- from = from && from >= dateRange.from && from <= dateRange.to ? from : '';
- to = to && to >= dateRange.from && to <= dateRange.to ? to : '';
- if (!from || !to || from > to) {
- if (!dates.length) return { empty: true };
- to = dates[dates.length - 1];
- from = dates[Math.max(0, dates.length - 7)];
- }
- const len = Math.max(1, Math.round(
- (new Date(Number(to.slice(0, 4)), Number(to.slice(4, 6)) - 1, Number(to.slice(6, 8)))
- - new Date(Number(from.slice(0, 4)), Number(from.slice(4, 6)) - 1, Number(from.slice(6, 8)))) / 86400000) + 1);
- const prevTo = dayShift(from, -1);
- const prevFrom = dayShift(prevTo, -(len - 1));
- let shopIds = null;
- if (Array.isArray(a.shops) && a.shops.length) {
- shopIds = nlQuery.matchShopsByName(ctx.shops, a.shops);
- if (!shopIds) throw new Error(`门店名未匹配:${a.shops.join('、')}`);
- }
- return { from, to, prevFrom, prevTo, shopIds, empty: false };
- }
- function windowSummary(ctx, from, to, shopIds) {
- const { svc, entityKey } = ctx;
- let rows = svc.store.queryRows(entityKey, { from, to, scope: 'shop' });
- if (shopIds) {
- const wanted = new Set(shopIds.map(String));
- rows = rows.filter((r) => wanted.has(String(r.shopId)));
- }
- const s = svc.summarizeRows(rows);
- return { rows: rows.length, expUv: s.expUv, clk: s.clkExpRate, pay: s.perActualPay, gross: s.grossAmt };
- }
- function attributionHandler(ctx, args) {
- const { svc } = ctx;
- if (args.empty) return { summary: '湖内暂无数据,无法归因。', data: { empty: true } };
- const { from, to, prevFrom, prevTo, shopIds } = args;
- const cur = windowSummary(ctx, from, to, shopIds);
- const prev = windowSummary(ctx, prevFrom, prevTo, shopIds);
- if (!cur.rows || !prev.rows) {
- return { summary: `本期或上期无可比数据(本期 ${cur.rows} 行 / 上期 ${prev.rows} 行),无法归因。`, data: { from, to, prevFrom, prevTo } };
- }
- const v0 = prev.gross;
- const v1 = cur.gross;
- const delta = v1 - v0;
- const factors = [
- { key: 'expUv', name: '曝光效应', cur: cur.expUv, prev: prev.expUv },
- { key: 'clkExpRate', name: '进店率效应', cur: cur.clk, prev: prev.clk },
- { key: 'perActualPay', name: '客单价效应', cur: cur.pay, prev: prev.pay },
- ];
- let lines;
- let outFactors = [];
- let residual = null;
- if (v0 <= 0 || v1 <= 0 || delta === 0 || !v0 || !v1) {
- lines = [`营业额 ${Math.round(v0)} → ${Math.round(v1)}:基期为 0 或无变化,LMDI 分解不适用。`];
- } else {
- const L = delta / (Math.log(v1) - Math.log(v0)); // 对数平均权重
- let sum = 0;
- for (const f of factors) {
- let contribution = null;
- let note = '';
- if (!f.cur || !f.prev || f.cur <= 0 || f.prev <= 0) note = '(某期为 0,无法对数分解)';
- else {
- contribution = +(L * Math.log(f.cur / f.prev)).toFixed(2);
- sum += contribution;
- }
- outFactors.push({ key: f.key, name: f.name, cur: f.cur, prev: f.prev, contribution, note });
- }
- residual = +(delta - sum).toFixed(2);
- lines = [`营业额归因(LMDI,${ymdDash(from)} ~ ${ymdDash(to)} vs ${ymdDash(prevFrom)} ~ ${ymdDash(prevTo)},可比口径):总变化 ${Math.round(delta)} 元,分解为——`];
- for (const f of outFactors) {
- const share = f.contribution != null && delta !== 0 ? `(占 ${(f.contribution / delta * 100).toFixed(0)}%)` : '';
- lines.push(` ${f.name}:${f.contribution == null ? note : `${f.contribution >= 0 ? '+' : ''}${f.contribution} 元${share}`}`);
- }
- if (Math.abs(residual) > Math.abs(delta) * 0.02 + 1) {
- lines.push(` 混合残差 ${residual} 元(多店聚合与店间结构变化的交叉项,非某单一因素)。`);
- }
- const top = outFactors.filter((f) => f.contribution != null).sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution))[0];
- if (top) lines.push(`主因:${top.name}(${top.contribution >= 0 ? '拉动' : '拖累'} ${Math.abs(top.contribution)} 元)。结论请结合业务事件解读。`);
- }
- return {
- summary: lines.join('\n'),
- data: { from, to, prevFrom, prevTo, cur, prev, factors: outFactors, residual, delta },
- };
- }
- module.exports = { attributionSanitize, attributionHandler };
|