'use strict'; /** * 智能问数 · 指标查询工具 * ---------------------------------------------------------------------------- * metric_query:按指标 key 查逐日序列(支持派生指标如 7 日移动平均)。 * 模型引用指标 key,聚合口径全部由指标层决定——比率加权/比值/移动平均都不许自算。 * 移动平均窗口取「当日及前 N-1 天内已有数据的天」(缺数日按实算,不插值)。 */ const metrics = require('../metrics.js'); const { eachDay, ymdDash } = require('./lake.js'); 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 metricSanitize(args, ctx) { const a = args || {}; const m = metrics.get(String(a.metric || '')); if (!m) { throw new Error(`未知指标:${a.metric}。可用指标:\n${metrics.manifestText()}`); } 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 { metric: String(a.metric), from: from < dateRange.from ? dateRange.from : from, to: to > dateRange.to ? dateRange.to : to, shopIds, window: m.window || 1, }; } function metricHandler(ctx, args) { const { svc, entityKey } = ctx; const { metric, from, to, shopIds, window } = args; const def = metrics.get(metric); const reg = new Map((svc.REPORT_COLUMNS || []).map((c) => [c.key, c])); const unitFmt = { money: (v) => `${Math.round(v).toLocaleString('zh-CN')}元`, pct: (v) => `${(v * 100).toFixed(2)}%`, int: (v) => String(Math.round(v)) }; // 派生移动平均需要窗口前置数据:多取 window-1 天 const leadFrom = window > 1 ? dayShift(from, -(window - 1)) : from; let rows = svc.store.queryRows(entityKey, { from: leadFrom, to, scope: 'shop' }); if (shopIds) { const wanted = new Set(shopIds.map(String)); rows = rows.filter((r) => wanted.has(String(r.shopId))); } const byDate = new Map(); for (const r of rows) { if (!byDate.has(r.date)) byDate.set(r.date, []); byDate.get(r.date).push(r); } const dayVal = (d) => metrics.dayValue(def.base || metric, byDate.get(d) || []); const days = eachDay(from, to); const series = []; for (const d of days) { let value = null; if (window > 1) { const win = []; for (let i = 0; i < window; i++) { const v = dayVal(dayShift(d, -i)); if (v != null) win.push(v); } value = win.length ? +(win.reduce((s, x) => s + x, 0) / win.length).toFixed(2) : null; } else { value = dayVal(d); } series.push({ date: d, value }); } const values = series.map((s) => s.value).filter((v) => v != null); const fmt = unitFmt[def.unit] || ((v) => String(v)); const lines = [`${def.name}(${ymdDash(from)} ~ ${ymdDash(to)}${window > 1 ? `,${window} 日移动平均,缺数日按实算` : ''}):`]; for (const s of series) lines.push(` ${ymdDash(s.date)}:${s.value == null ? '无数据' : fmt(s.value)}`); if (values.length) { const avg = values.reduce((s, x) => s + x, 0) / values.length; lines.push(`区间:均值 ${fmt(+avg.toFixed(2))},最高 ${fmt(Math.max(...values))},最低 ${fmt(Math.min(...values))}。`); } else { lines.push('区间内无数据。'); } return { summary: lines.join('\n'), data: { metric, name: def.name, unit: def.unit, from, to, window, series, totalRows: series.length, stats: values.length ? { avg: +(values.reduce((s, x) => s + x, 0) / values.length).toFixed(2), max: Math.max(...values), min: Math.min(...values) } : null, }, }; } module.exports = { metricSanitize, metricHandler };