'use strict'; /** * 智能问数智能体 · 源侧字段与同行对标工具 * ---------------------------------------------------------------------------- * raw_inspect 取某行 raw 原始字段(环比差值/上期对比/同行均值排名,全字段落库), * 用于源侧交叉验证 —— 本地算的环比 vs 取数接口自带的环比差值 * peer_benchmark 同行对标:门店×区间 自身值 vs 同行均值 vs 排名(字段白名单 = * FIELD_MAP 键,蛇形命名与 historyOverview 一致) * * raw 字段命名以取数接口原样为准(已知后缀:_mall_avg / _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 };