| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145 |
- 'use strict';
- /**
- * 智能问数智能体 · 源侧字段与同行对标工具
- * ----------------------------------------------------------------------------
- * raw_inspect 取某行 raw 原始字段(环比差值/上期对比/同行均值排名,全字段落库),
- * 用于源侧交叉验证 —— 本地算的环比 vs 取数接口自带的环比差值
- * peer_benchmark 同行对标:门店×区间 自身值 vs 同行均值 vs 排名(字段白名单 =
- * FIELD_MAP 键,蛇形命名与 historyOverview 一致)
- *
- * raw 字段命名以取数接口原样为准(已知后缀:<field>_mall_avg / <field>_mall_avg_rank,
- * 环比与上期对比后缀以实际返回为准)——工具按「包含匹配」取字段,不猜精确名。
- */
- const { pickRow, ymdDash } = require('./lake.js');
- // ---------- raw_inspect ----------
- function rawSanitize(args, ctx) {
- const a = args || {};
- const date = /^\d{8}$/.test(String(a.date || '')) ? String(a.date) : '';
- if (!date) throw new Error('缺少 date(YYYYMMDD)');
- const { shops, dateRange } = ctx;
- if (date < dateRange.from || date > dateRange.to) throw new Error(`date 超出湖内区间(${dateRange.from} ~ ${dateRange.to})`);
- let shopId = a.shopId != null ? String(a.shopId) : '';
- if (!shopId && Array.isArray(a.shops) && a.shops.length) {
- const nlQuery = require('../../../nl-query.js');
- const hit = nlQuery.matchShopsByName(shops, a.shops);
- if (!hit) throw new Error(`门店名未匹配:${a.shops.join('、')}`);
- shopId = String(hit[0]);
- }
- if (!shopId) {
- const first = (ctx.meta.shops || [])[0];
- if (!first) throw new Error('主体无门店清单');
- shopId = String(first.shopId);
- }
- const keys = (Array.isArray(a.keys) ? a.keys : []).map(String).slice(0, 6);
- return { date, shopId, keys };
- }
- function rawHandler(ctx, args) {
- const { svc, entityKey, meta } = ctx;
- const { date, shopId, keys } = args;
- const row = svc.store.queryRows(entityKey, { from: date, to: date, scope: 'shop', shopId })[0];
- if (!row) return { summary: `无该行:${ymdDash(date)} 门店 ${shopId}(当天可能缺数)。`, data: { date, shopId, found: false } };
- const shopName = row.shopName || (meta.shops || []).find((s) => String(s.shopId) === shopId)?.shopName || shopId;
- if (!row.raw || typeof row.raw !== 'object') {
- return { summary: `该行没有 raw 原始字段(旧版数据未经全字段落库,重新同步该区间即可补全)。`, data: { date, shopId, shopName, found: true, hasRaw: false } };
- }
- const wants = keys.length ? keys : ['gross_amt', 'valid_order_cnt'];
- const matched = Object.keys(row.raw).filter((k) => wants.some((w) => k.includes(w))).slice(0, 40);
- if (!matched.length) {
- return { summary: `raw 中未找到包含 ${wants.join('/')} 的字段(该行 raw 共 ${Object.keys(row.raw).length} 个字段)。`, data: { date, shopId, shopName, found: true, hasRaw: true, matched: [] } };
- }
- const picked = {};
- for (const k of matched) picked[k] = row.raw[k];
- const lines = [`${ymdDash(date)} 门店「${shopName}」的 raw 源侧字段(共匹配 ${matched.length} 个,字段名为取数接口原样命名):`];
- for (const k of matched) lines.push(` ${k} = ${row.raw[k] === '' || row.raw[k] == null ? '(空)' : row.raw[k]}`);
- return { summary: lines.join('\n'), data: { date, shopId, shopName, found: true, hasRaw: true, matched, values: picked, row: pickRow(row) } };
- }
- // ---------- peer_benchmark ----------
- const BENCH_KEYS = [
- 'valid_order_cnt', 'gross_amt', 'income_amt', 'expense_amt', 'exp_uv', 'order_user_cnt',
- 'clk_exp_rate', 'ord_clk_rate', 'per_actual_pay', 'act_ord_cnt', 'act_shop_subsidy_amt', 'shop_roi', 'rebuy_rate_30d',
- ];
- function benchSanitize(args, ctx) {
- const a = args || {};
- const field = String(a.field || 'gross_amt');
- if (!BENCH_KEYS.includes(field)) {
- throw new Error(`不支持的对标字段:${field}(可用:${BENCH_KEYS.join('、')})`);
- }
- const { shops, dateRange } = ctx;
- const from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : dateRange.from;
- const to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : dateRange.to;
- let shopIds = null;
- if (Array.isArray(a.shops) && a.shops.length) {
- const nlQuery = require('../../../nl-query.js');
- shopIds = nlQuery.matchShopsByName(shops, a.shops);
- if (!shopIds) throw new Error(`门店名未匹配:${a.shops.join('、')}`);
- }
- return { field, from: from < dateRange.from ? dateRange.from : from, to: to > dateRange.to ? dateRange.to : to, shopIds };
- }
- function benchHandler(ctx, args) {
- const { svc, entityKey } = ctx;
- const { field, from, to, shopIds } = args;
- const camel = svc.FIELD_MAP[field];
- if (!camel) return { summary: `字段映射缺失:${field}`, data: { field } };
- const reg = new Map((svc.REPORT_COLUMNS || []).map((c) => [c.key, c]));
- const isPct = (reg.get(camel) || {}).type === 'pct';
- const label = (reg.get(camel) || {}).label || field;
- const avgKey = `${field}_mall_avg`;
- const rankKey = `${field}_mall_avg_rank`;
- 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)));
- }
- if (!rows.length) return { summary: `区间 ${ymdDash(from)} ~ ${ymdDash(to)} 无门店明细行。`, data: { field, from, to, shops: [] } };
- // 门店聚合:加性指标简单均值;率指标按曝光(clk_exp_rate)或进店人数(ord_clk_rate)加权
- const byShop = new Map();
- for (const r of rows) {
- const id = String(r.shopId);
- if (!byShop.has(id)) byShop.set(id, { shopId: id, shopName: r.shopName || id, days: 0, vW: 0, aW: 0, w: 0, ranks: [] });
- const agg = byShop.get(id);
- const v = Number(r[camel]) || 0;
- const avg = r.raw && r.raw[avgKey] != null && r.raw[avgKey] !== '' ? Number(r.raw[avgKey]) : null;
- const rank = r.raw && r.raw[rankKey] != null && r.raw[rankKey] !== '' ? Number(r.raw[rankKey]) : null;
- const weight = isPct ? (field === 'clk_exp_rate' ? (Number(r.expUv) || 0) : (Number(r.orderUserCnt) || 0)) : 1;
- agg.days += 1;
- if (weight > 0) { agg.vW += v * weight; agg.aW += (avg || 0) * weight; agg.w += weight; }
- if (rank != null) agg.ranks.push(rank);
- }
- const shops = [...byShop.values()].map((agg) => ({
- shopId: agg.shopId,
- shopName: agg.shopName,
- days: agg.days,
- own: agg.w > 0 ? Math.round((agg.vW / agg.w) * 100) / 100 : null,
- peerAvg: agg.w > 0 ? Math.round((agg.aW / agg.w) * 100) / 100 : null,
- rankAvg: agg.ranks.length ? Math.round((agg.ranks.reduce((s, x) => s + x, 0) / agg.ranks.length) * 10) / 10 : null,
- rankBest: agg.ranks.length ? Math.min(...agg.ranks) : null,
- rankWorst: agg.ranks.length ? Math.max(...agg.ranks) : null,
- hasPeer: agg.w > 0 && agg.aW > 0,
- })).sort((a, b) => (a.rankAvg ?? 999) - (b.rankAvg ?? 999));
- const lines = [`${label} 同行对标(${ymdDash(from)} ~ ${ymdDash(to)},自身值与同行均值均为${isPct ? '加权平均' : '日均'};排名越小越好):`];
- for (const s of shops) {
- if (!s.hasPeer) {
- lines.push(` ${s.shopName}:无同行数据(raw 缺失,重新同步该区间可补全)`);
- continue;
- }
- const vs = s.own != null && s.peerAvg ? `自身 ${s.own} vs 同行均值 ${s.peerAvg}` : '均值缺失';
- const rankTxt = s.rankAvg != null ? `平均排名 ${s.rankAvg}(最好 ${s.rankBest} / 最差 ${s.rankWorst})` : '无排名';
- lines.push(` ${s.shopName}:${vs};${rankTxt}`);
- }
- return { summary: lines.join('\n'), data: { field, from, to, isPct, shops } };
- }
- module.exports = { rawSanitize, rawHandler, benchSanitize, benchHandler, BENCH_KEYS };
|