| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179 |
- 'use strict';
- /**
- * 智能问数智能体 · 同比环比对比工具
- * ----------------------------------------------------------------------------
- * compare_periods:本期 vs 基期(默认等长紧邻平移 = 环比;gap 支持隔段对比)。
- * 方法论固定在实现里,模型无权偏离:
- * · 门店集合对齐 —— 两期都在线的店才进可比集,开/闭店单列(防开闭店污染 delta)
- * · 比率指标用 summarizeRows 的加权口径(曝光/进店加权),绝不算术平均
- * · 缺失日显式列出;恒等式(收入+支出=营业额)逐期校验
- */
- const nlQuery = require('../../../nl-query.js');
- const { pickRow, eachDay, ymdDash } = require('./lake.js');
- const ADDITIVE = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt', 'actOrdCnt', 'actSubsidyAmt'];
- 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 clampDay(v, lo, hi) {
- const s = /^\d{8}$/.test(String(v || '')) ? String(v) : '';
- if (!s) return '';
- return s < lo ? lo : (s > hi ? hi : s);
- }
- const VALID_METRICS = ['validOrderCnt', 'incomeAmt', 'grossAmt', 'expenseAmt', 'expUv', 'orderUserCnt',
- 'clkExpRate', 'ordClkRate', 'perActualPay', 'actOrdCnt', 'actSubsidyAmt', 'shopRoi', 'rebuyRate30d'];
- function compareSanitize(args, ctx) {
- const { shops, dateRange } = ctx;
- const a = args || {};
- const ms = (Array.isArray(a.metrics) ? a.metrics : []).map(String).filter((k) => VALID_METRICS.includes(k)).slice(0, 4);
- const finalMetrics = ms.length ? ms : ['grossAmt', 'validOrderCnt'];
- // 本期区间:缺省取湖内最后 7 个有数日(稀疏数据下比「自然日倒退 7 天」更贴近直觉)
- const dates = ctx.svc.store.datesInLake(ctx.entityKey);
- let from = clampDay(a.from, dateRange.from, dateRange.to);
- let to = clampDay(a.to, dateRange.from, dateRange.to);
- if (!from || !to || from > to) {
- if (!dates.length) return { metrics: finalMetrics, from: dateRange.from, to: dateRange.to, prevFrom: dateRange.from, prevTo: dateRange.to, shops: null, empty: true };
- to = dates[dates.length - 1];
- from = dates[Math.max(0, dates.length - 7)];
- }
- const gap = Number(a.gap) > 0 ? Math.floor(Number(a.gap)) : 0;
- const len = Math.max(1, Number(
- (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 - gap);
- const prevFrom = dayShift(prevTo, -(len - 1));
- let shopIds = null;
- const rawShops = Array.isArray(a.shops) ? a.shops.filter(Boolean) : [];
- if (rawShops.length) {
- shopIds = nlQuery.matchShopsByName(shops, rawShops);
- if (!shopIds) throw new Error(`门店名未匹配到任何门店:${rawShops.join('、')}(可用 lake_overview 查看门店清单)`);
- }
- return { metrics: finalMetrics, from, to, prevFrom, prevTo, shops: shopIds, empty: false };
- }
- function windowStats(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 shopsPresent = new Map();
- const dates = new Set();
- for (const r of rows) {
- shopsPresent.set(String(r.shopId), (shopsPresent.get(String(r.shopId)) || 0) + 1);
- dates.add(r.date);
- }
- return { rows, summary: svc.summarizeRows(rows), shopsPresent, dates: [...dates].sort() };
- }
- function identityCheck(summary) {
- const g = Number(summary.grossAmt) || 0;
- const lhs = (Number(summary.incomeAmt) || 0) + (Number(summary.expenseAmt) || 0);
- const bad = Math.abs(lhs - g) > Math.max(g * 0.005, 1);
- return { ok: !bad, lhs: Math.round(lhs * 100) / 100, gross: Math.round(g * 100) / 100 };
- }
- function bucketBy(ctx, rows, group, metrics) {
- // group: 'date' | 'shopId';给图表用的轻量逐桶聚合(加权口径与 summarizeRows 一致)
- const { svc } = ctx;
- const map = new Map();
- for (const r of rows) {
- const k = group === 'date' ? r.date : String(r.shopId);
- if (!map.has(k)) map.set(k, []);
- map.get(k).push(r);
- }
- return [...map.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([k, list]) => {
- const s = svc.summarizeRows(list);
- const out = group === 'date' ? { date: k } : { shopId: k, shopName: (list[0] || {}).shopName || k };
- for (const m of metrics) out[m] = s[m];
- return out;
- });
- }
- function compareHandler(ctx, args) {
- const { svc, entityKey, meta } = ctx;
- const reg = new Map((svc.REPORT_COLUMNS || []).map((c) => [c.key, c]));
- const label = (k) => (reg.get(k) || {}).label || k;
- if (args.empty) {
- return { summary: '湖内暂无数据,无法对比。请先完成同步。', data: { empty: true } };
- }
- const { metrics, from, to, prevFrom, prevTo, shops } = args;
- const shopNameOf = new Map((meta.shops || []).map((s) => [String(s.shopId), s.shopName]));
- const cur = windowStats(ctx, from, to, shops);
- const prev = windowStats(ctx, prevFrom, prevTo, shops);
- // 可比集:两期都出现过的门店
- const comparable = [...cur.shopsPresent.keys()].filter((id) => prev.shopsPresent.has(id));
- const curCmp = comparable.length ? windowStats(ctx, from, to, comparable) : cur;
- const prevCmp = comparable.length ? windowStats(ctx, prevFrom, prevTo, comparable) : prev;
- const deltas = metrics.map((k) => {
- const c = Number(curCmp.summary[k]) || 0;
- const p = Number(prevCmp.summary[k]) || 0;
- const type = (reg.get(k) || {}).type || 'num';
- const deltaPct = (type === 'pct')
- ? (p > 0 ? (c - p) / p : null) // 比率的「变化」用相对差表述
- : (p !== 0 ? (c - p) / Math.abs(p) : null);
- return { key: k, label: label(k), type, cur: c, prev: p, delta: Math.round((c - p) * 100) / 100, deltaPct };
- });
- const opened = [...cur.shopsPresent.keys()].filter((id) => !prev.shopsPresent.has(id));
- const closed = [...prev.shopsPresent.keys()].filter((id) => !cur.shopsPresent.has(id));
- const expected = eachDay(from, to);
- const missingDays = expected.filter((d) => !cur.dates.includes(d));
- const idCur = identityCheck(curCmp.summary);
- const idPrev = identityCheck(prevCmp.summary);
- const lines = [];
- lines.push(`对比口径:本期 ${ymdDash(from)} ~ ${ymdDash(to)} vs 上期 ${ymdDash(prevFrom)} ~ ${ymdDash(prevTo)}(等长平移),可比门店 ${comparable.length} 家${shops ? '(已限定指定门店)' : ''}。`);
- for (const d of deltas) {
- const chg = d.deltaPct == null ? '上期为 0/无数据,无法算变化率'
- : `${d.deltaPct >= 0 ? '+' : ''}${(d.deltaPct * 100).toFixed(1)}%`;
- const fmt = (v) => d.type === 'pct' ? `${(v * 100).toFixed(2)}%` : String(Math.round(v * 100) / 100);
- lines.push(`${d.label}:本期 ${fmt(d.cur)} vs 上期 ${fmt(d.prev)}(${chg})`);
- }
- if (opened.length) lines.push(`本期新出现门店(未进可比集):${opened.map((id) => shopNameOf.get(id) || id).join('、')}`);
- if (closed.length) lines.push(`上期有数本期无数门店(未进可比集):${closed.map((id) => shopNameOf.get(id) || id).join('、')}`);
- if (missingDays.length) lines.push(`本期缺数 ${missingDays.length} 天:${missingDays.slice(0, 8).map(ymdDash).join('、')}${missingDays.length > 8 ? ' 等' : ''}`);
- if (!idCur.ok || !idPrev.ok) lines.push(`⚠ 恒等式(收入+支出=营业额)不满足:本期 ${idCur.lhs} vs ${idCur.gross},上期 ${idPrev.lhs} vs ${idPrev.gross},数据质量存疑`);
- return {
- summary: lines.join('\n'),
- data: {
- cur: { from, to, summary: curCmp.summary },
- prev: { from: prevFrom, to: prevTo, summary: prevCmp.summary },
- comparableShopCount: comparable.length,
- opened: opened.map((id) => ({ shopId: id, shopName: shopNameOf.get(id) || id })),
- closed: closed.map((id) => ({ shopId: id, shopName: shopNameOf.get(id) || id })),
- missingDays,
- deltas,
- identity: { cur: idCur, prev: idPrev },
- curByDate: bucketBy(ctx, curCmp.rows, 'date', metrics),
- curByShop: bucketBy(ctx, curCmp.rows, 'shopId', metrics).sort((a, b) => (Number(b.grossAmt) || 0) - (Number(a.grossAmt) || 0)).slice(0, 50),
- verdicts: [
- { kind: 'identity', check: '恒等式 收入+支出=营业额(本期)', status: idCur.ok ? 'ok' : 'fail', detail: `本期 ${idCur.lhs} vs ${idCur.gross}` },
- { kind: 'identity', check: '恒等式 收入+支出=营业额(上期)', status: idPrev.ok ? 'ok' : 'fail', detail: `上期 ${idPrev.lhs} vs ${idPrev.gross}` },
- ...(missingDays.length ? [{
- kind: 'freshness', check: '本期数据完整性',
- status: missingDays.length / expected.length > 0.3 ? 'warn' : 'ok',
- detail: `缺 ${missingDays.length}/${expected.length} 天(${missingDays.slice(0, 3).map(ymdDash).join('、')}${missingDays.length > 3 ? ' 等' : ''})`,
- }] : []),
- { kind: 'coverage', check: '门店可比集', status: opened.length || closed.length ? 'warn' : 'ok', detail: `可比 ${comparable.length} 家${opened.length ? `,新出现 ${opened.length} 家` : ''}${closed.length ? `,缺数 ${closed.length} 家` : ''}` },
- ],
- },
- };
- }
- module.exports = { compareSanitize, compareHandler, windowStats, identityCheck, bucketBy, ADDITIVE };
|