'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 };