metric.js 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. 'use strict';
  2. /**
  3. * 智能问数 · 指标查询工具
  4. * ----------------------------------------------------------------------------
  5. * metric_query:按指标 key 查逐日序列(支持派生指标如 7 日移动平均)。
  6. * 模型引用指标 key,聚合口径全部由指标层决定——比率加权/比值/移动平均都不许自算。
  7. * 移动平均窗口取「当日及前 N-1 天内已有数据的天」(缺数日按实算,不插值)。
  8. */
  9. const metrics = require('../metrics.js');
  10. const { eachDay, ymdDash } = require('./lake.js');
  11. const z2 = (n) => String(n).padStart(2, '0');
  12. function dayShift(ymd, delta) {
  13. const d = new Date(Number(ymd.slice(0, 4)), Number(ymd.slice(4, 6)) - 1, Number(ymd.slice(6, 8)));
  14. d.setDate(d.getDate() + delta);
  15. return `${d.getFullYear()}${z2(d.getMonth() + 1)}${z2(d.getDate())}`;
  16. }
  17. function metricSanitize(args, ctx) {
  18. const a = args || {};
  19. const m = metrics.get(String(a.metric || ''));
  20. if (!m) {
  21. throw new Error(`未知指标:${a.metric}。可用指标:\n${metrics.manifestText()}`);
  22. }
  23. const { shops, dateRange } = ctx;
  24. const from = /^\d{8}$/.test(String(a.from || '')) ? String(a.from) : dateRange.from;
  25. const to = /^\d{8}$/.test(String(a.to || '')) ? String(a.to) : dateRange.to;
  26. let shopIds = null;
  27. if (Array.isArray(a.shops) && a.shops.length) {
  28. const nlQuery = require('../../../nl-query.js');
  29. shopIds = nlQuery.matchShopsByName(shops, a.shops);
  30. if (!shopIds) throw new Error(`门店名未匹配:${a.shops.join('、')}`);
  31. }
  32. return {
  33. metric: String(a.metric),
  34. from: from < dateRange.from ? dateRange.from : from,
  35. to: to > dateRange.to ? dateRange.to : to,
  36. shopIds,
  37. window: m.window || 1,
  38. };
  39. }
  40. function metricHandler(ctx, args) {
  41. const { svc, entityKey } = ctx;
  42. const { metric, from, to, shopIds, window } = args;
  43. const def = metrics.get(metric);
  44. const reg = new Map((svc.REPORT_COLUMNS || []).map((c) => [c.key, c]));
  45. const unitFmt = { money: (v) => `${Math.round(v).toLocaleString('zh-CN')}元`, pct: (v) => `${(v * 100).toFixed(2)}%`, int: (v) => String(Math.round(v)) };
  46. // 派生移动平均需要窗口前置数据:多取 window-1 天
  47. const leadFrom = window > 1 ? dayShift(from, -(window - 1)) : from;
  48. let rows = svc.store.queryRows(entityKey, { from: leadFrom, to, scope: 'shop' });
  49. if (shopIds) {
  50. const wanted = new Set(shopIds.map(String));
  51. rows = rows.filter((r) => wanted.has(String(r.shopId)));
  52. }
  53. const byDate = new Map();
  54. for (const r of rows) {
  55. if (!byDate.has(r.date)) byDate.set(r.date, []);
  56. byDate.get(r.date).push(r);
  57. }
  58. const dayVal = (d) => metrics.dayValue(def.base || metric, byDate.get(d) || []);
  59. const days = eachDay(from, to);
  60. const series = [];
  61. for (const d of days) {
  62. let value = null;
  63. if (window > 1) {
  64. const win = [];
  65. for (let i = 0; i < window; i++) {
  66. const v = dayVal(dayShift(d, -i));
  67. if (v != null) win.push(v);
  68. }
  69. value = win.length ? +(win.reduce((s, x) => s + x, 0) / win.length).toFixed(2) : null;
  70. } else {
  71. value = dayVal(d);
  72. }
  73. series.push({ date: d, value });
  74. }
  75. const values = series.map((s) => s.value).filter((v) => v != null);
  76. const fmt = unitFmt[def.unit] || ((v) => String(v));
  77. const lines = [`${def.name}(${ymdDash(from)} ~ ${ymdDash(to)}${window > 1 ? `,${window} 日移动平均,缺数日按实算` : ''}):`];
  78. for (const s of series) lines.push(` ${ymdDash(s.date)}:${s.value == null ? '无数据' : fmt(s.value)}`);
  79. if (values.length) {
  80. const avg = values.reduce((s, x) => s + x, 0) / values.length;
  81. lines.push(`区间:均值 ${fmt(+avg.toFixed(2))},最高 ${fmt(Math.max(...values))},最低 ${fmt(Math.min(...values))}。`);
  82. } else {
  83. lines.push('区间内无数据。');
  84. }
  85. return {
  86. summary: lines.join('\n'),
  87. data: {
  88. metric, name: def.name, unit: def.unit, from, to, window,
  89. series, totalRows: series.length,
  90. stats: values.length ? { avg: +(values.reduce((s, x) => s + x, 0) / values.length).toFixed(2), max: Math.max(...values), min: Math.min(...values) } : null,
  91. },
  92. };
  93. }
  94. module.exports = { metricSanitize, metricHandler };