peer.js 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. 'use strict';
  2. /**
  3. * 智能问数智能体 · 源侧字段与同行对标工具
  4. * ----------------------------------------------------------------------------
  5. * raw_inspect 取某行 raw 原始字段(环比差值/上期对比/同行均值排名,全字段落库),
  6. * 用于源侧交叉验证 —— 本地算的环比 vs 取数接口自带的环比差值
  7. * peer_benchmark 同行对标:门店×区间 自身值 vs 同行均值 vs 排名(字段白名单 =
  8. * FIELD_MAP 键,蛇形命名与 historyOverview 一致)
  9. *
  10. * raw 字段命名以取数接口原样为准(已知后缀:<field>_mall_avg / <field>_mall_avg_rank,
  11. * 环比与上期对比后缀以实际返回为准)——工具按「包含匹配」取字段,不猜精确名。
  12. */
  13. const { pickRow, ymdDash } = require('./lake.js');
  14. // ---------- raw_inspect ----------
  15. function rawSanitize(args, ctx) {
  16. const a = args || {};
  17. const date = /^\d{8}$/.test(String(a.date || '')) ? String(a.date) : '';
  18. if (!date) throw new Error('缺少 date(YYYYMMDD)');
  19. const { shops, dateRange } = ctx;
  20. if (date < dateRange.from || date > dateRange.to) throw new Error(`date 超出湖内区间(${dateRange.from} ~ ${dateRange.to})`);
  21. let shopId = a.shopId != null ? String(a.shopId) : '';
  22. if (!shopId && Array.isArray(a.shops) && a.shops.length) {
  23. const nlQuery = require('../../../nl-query.js');
  24. const hit = nlQuery.matchShopsByName(shops, a.shops);
  25. if (!hit) throw new Error(`门店名未匹配:${a.shops.join('、')}`);
  26. shopId = String(hit[0]);
  27. }
  28. if (!shopId) {
  29. const first = (ctx.meta.shops || [])[0];
  30. if (!first) throw new Error('主体无门店清单');
  31. shopId = String(first.shopId);
  32. }
  33. const keys = (Array.isArray(a.keys) ? a.keys : []).map(String).slice(0, 6);
  34. return { date, shopId, keys };
  35. }
  36. function rawHandler(ctx, args) {
  37. const { svc, entityKey, meta } = ctx;
  38. const { date, shopId, keys } = args;
  39. const row = svc.store.queryRows(entityKey, { from: date, to: date, scope: 'shop', shopId })[0];
  40. if (!row) return { summary: `无该行:${ymdDash(date)} 门店 ${shopId}(当天可能缺数)。`, data: { date, shopId, found: false } };
  41. const shopName = row.shopName || (meta.shops || []).find((s) => String(s.shopId) === shopId)?.shopName || shopId;
  42. if (!row.raw || typeof row.raw !== 'object') {
  43. return { summary: `该行没有 raw 原始字段(旧版数据未经全字段落库,重新同步该区间即可补全)。`, data: { date, shopId, shopName, found: true, hasRaw: false } };
  44. }
  45. const wants = keys.length ? keys : ['gross_amt', 'valid_order_cnt'];
  46. const matched = Object.keys(row.raw).filter((k) => wants.some((w) => k.includes(w))).slice(0, 40);
  47. if (!matched.length) {
  48. return { summary: `raw 中未找到包含 ${wants.join('/')} 的字段(该行 raw 共 ${Object.keys(row.raw).length} 个字段)。`, data: { date, shopId, shopName, found: true, hasRaw: true, matched: [] } };
  49. }
  50. const picked = {};
  51. for (const k of matched) picked[k] = row.raw[k];
  52. const lines = [`${ymdDash(date)} 门店「${shopName}」的 raw 源侧字段(共匹配 ${matched.length} 个,字段名为取数接口原样命名):`];
  53. for (const k of matched) lines.push(` ${k} = ${row.raw[k] === '' || row.raw[k] == null ? '(空)' : row.raw[k]}`);
  54. return { summary: lines.join('\n'), data: { date, shopId, shopName, found: true, hasRaw: true, matched, values: picked, row: pickRow(row) } };
  55. }
  56. // ---------- peer_benchmark ----------
  57. const BENCH_KEYS = [
  58. 'valid_order_cnt', 'gross_amt', 'income_amt', 'expense_amt', 'exp_uv', 'order_user_cnt',
  59. 'clk_exp_rate', 'ord_clk_rate', 'per_actual_pay', 'act_ord_cnt', 'act_shop_subsidy_amt', 'shop_roi', 'rebuy_rate_30d',
  60. ];
  61. function benchSanitize(args, ctx) {
  62. const a = args || {};
  63. const field = String(a.field || 'gross_amt');
  64. if (!BENCH_KEYS.includes(field)) {
  65. throw new Error(`不支持的对标字段:${field}(可用:${BENCH_KEYS.join('、')})`);
  66. }
  67. const { shops, dateRange } = ctx;
  68. const from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : dateRange.from;
  69. const to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : dateRange.to;
  70. let shopIds = null;
  71. if (Array.isArray(a.shops) && a.shops.length) {
  72. const nlQuery = require('../../../nl-query.js');
  73. shopIds = nlQuery.matchShopsByName(shops, a.shops);
  74. if (!shopIds) throw new Error(`门店名未匹配:${a.shops.join('、')}`);
  75. }
  76. return { field, from: from < dateRange.from ? dateRange.from : from, to: to > dateRange.to ? dateRange.to : to, shopIds };
  77. }
  78. function benchHandler(ctx, args) {
  79. const { svc, entityKey } = ctx;
  80. const { field, from, to, shopIds } = args;
  81. const camel = svc.FIELD_MAP[field];
  82. if (!camel) return { summary: `字段映射缺失:${field}`, data: { field } };
  83. const reg = new Map((svc.REPORT_COLUMNS || []).map((c) => [c.key, c]));
  84. const isPct = (reg.get(camel) || {}).type === 'pct';
  85. const label = (reg.get(camel) || {}).label || field;
  86. const avgKey = `${field}_mall_avg`;
  87. const rankKey = `${field}_mall_avg_rank`;
  88. let rows = svc.store.queryRows(entityKey, { from, to, scope: 'shop' });
  89. if (shopIds) {
  90. const wanted = new Set(shopIds.map(String));
  91. rows = rows.filter((r) => wanted.has(String(r.shopId)));
  92. }
  93. if (!rows.length) return { summary: `区间 ${ymdDash(from)} ~ ${ymdDash(to)} 无门店明细行。`, data: { field, from, to, shops: [] } };
  94. // 门店聚合:加性指标简单均值;率指标按曝光(clk_exp_rate)或进店人数(ord_clk_rate)加权
  95. const byShop = new Map();
  96. for (const r of rows) {
  97. const id = String(r.shopId);
  98. if (!byShop.has(id)) byShop.set(id, { shopId: id, shopName: r.shopName || id, days: 0, vW: 0, aW: 0, w: 0, ranks: [] });
  99. const agg = byShop.get(id);
  100. const v = Number(r[camel]) || 0;
  101. const avg = r.raw && r.raw[avgKey] != null && r.raw[avgKey] !== '' ? Number(r.raw[avgKey]) : null;
  102. const rank = r.raw && r.raw[rankKey] != null && r.raw[rankKey] !== '' ? Number(r.raw[rankKey]) : null;
  103. const weight = isPct ? (field === 'clk_exp_rate' ? (Number(r.expUv) || 0) : (Number(r.orderUserCnt) || 0)) : 1;
  104. agg.days += 1;
  105. if (weight > 0) { agg.vW += v * weight; agg.aW += (avg || 0) * weight; agg.w += weight; }
  106. if (rank != null) agg.ranks.push(rank);
  107. }
  108. const shops = [...byShop.values()].map((agg) => ({
  109. shopId: agg.shopId,
  110. shopName: agg.shopName,
  111. days: agg.days,
  112. own: agg.w > 0 ? Math.round((agg.vW / agg.w) * 100) / 100 : null,
  113. peerAvg: agg.w > 0 ? Math.round((agg.aW / agg.w) * 100) / 100 : null,
  114. rankAvg: agg.ranks.length ? Math.round((agg.ranks.reduce((s, x) => s + x, 0) / agg.ranks.length) * 10) / 10 : null,
  115. rankBest: agg.ranks.length ? Math.min(...agg.ranks) : null,
  116. rankWorst: agg.ranks.length ? Math.max(...agg.ranks) : null,
  117. hasPeer: agg.w > 0 && agg.aW > 0,
  118. })).sort((a, b) => (a.rankAvg ?? 999) - (b.rankAvg ?? 999));
  119. const lines = [`${label} 同行对标(${ymdDash(from)} ~ ${ymdDash(to)},自身值与同行均值均为${isPct ? '加权平均' : '日均'};排名越小越好):`];
  120. for (const s of shops) {
  121. if (!s.hasPeer) {
  122. lines.push(` ${s.shopName}:无同行数据(raw 缺失,重新同步该区间可补全)`);
  123. continue;
  124. }
  125. const vs = s.own != null && s.peerAvg ? `自身 ${s.own} vs 同行均值 ${s.peerAvg}` : '均值缺失';
  126. const rankTxt = s.rankAvg != null ? `平均排名 ${s.rankAvg}(最好 ${s.rankBest} / 最差 ${s.rankWorst})` : '无排名';
  127. lines.push(` ${s.shopName}:${vs};${rankTxt}`);
  128. }
  129. return { summary: lines.join('\n'), data: { field, from, to, isPct, shops } };
  130. }
  131. module.exports = { rawSanitize, rawHandler, benchSanitize, benchHandler, BENCH_KEYS };