'use strict'; // 数据采集与智能分析路由:同步任务、数据湖查询/聚合/导出、经营周报 HTML、智能问数 const { sendJson, readBody } = require('../util.js'); const { ASK_CLIENT_CODES } = require('../middleware.js'); const { makeChatCfg } = require('../context.js'); const naposService = require('../../napos-service.js'); const nlQuery = require('../../nl-query.js'); const reportHtml = require('../../report-html.js'); const { pushSummary } = require('../services/aitable.js'); function register(app, ctx) { // 不限方法(保持拆分前行为:该路由未做方法检查) app.any('/api/reports/status', async ({ res }) => { const napos = await naposService.naposStatus(); sendJson(res, 200, { napos, entities: naposService.store.listEntitiesFull() }); }); // ---------- 智能问数(NL2Query,对话模型驱动) ---------- app.post('/api/reports/ask', async ({ req, res }) => { const body = await readBody(req); const entity = body.entity || (naposService.store.listEntities()[0] || {}).key; if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'ASK_NO_ENTITY'; throw e; } try { const result = await nlQuery.ask({ q: body.q, entity, cfg: makeChatCfg(ctx), svc: naposService, history: Array.isArray(body.history) ? body.history : undefined }); sendJson(res, 200, result); } catch (e) { // 参数类错误码 → 400(错误边界统一处理),其余(模型/网络失败)保持 502 if (!ASK_CLIENT_CODES.has(e.code)) e.status = 502; throw e; } }); app.get('/api/reports/report-html', async ({ res, query }) => { const entity = query.entity || (naposService.store.listEntities()[0] || {}).key; if (!entity) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end('暂无数据主体,请先在「数据报表」完成一次同步。'); } const range = nlQuery.lakeDateRange(entity, naposService); const from = /^\d{8}$/.test(query.from || '') ? query.from : range.from; const to = /^\d{8}$/.test(query.to || '') ? query.to : range.to; const html = reportHtml.buildReportHtml({ entity, from, to }); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(html); }); app.get('/api/reports/entities', async ({ res }) => { sendJson(res, 200, { entities: naposService.store.listEntitiesFull() }); }); // 同步历史(数据采集页「同步历史」面板) app.get('/api/reports/syncs', async ({ res, query }) => { const entity = query.entity || (naposService.store.listEntities()[0] || {}).key; if (!entity) { sendJson(res, 200, { runs: [] }); return; } sendJson(res, 200, { entity, runs: naposService.store.loadSyncLogs(entity) }); }); // 数据中台:本地湖全景(主体覆盖、行数分布、最近同步/推送),供中台页渲染 app.get('/api/reports/lake', async ({ res }) => { const store = naposService.store; const entities = store.listEntitiesFull().map((e) => { const rows = store.loadRows(e.key); const byScope = { company: 0, chain: 0, shop: 0, summary: 0 }; let from = ''; let to = ''; for (const r of rows) { if (byScope[r.scope] != null) byScope[r.scope] += 1; if (r.date && (!from || r.date < from)) from = r.date; if (r.date && r.date > to) to = r.date; } const lastSync = (store.loadSyncLogs(e.key)[0] || null); return { key: e.key, name: e.name, shopCount: e.shopCount || 0, chainCount: e.chainCount || 0, rows: rows.length, byScope, coverage: rows.length ? { from, to } : null, lastSync: lastSync ? { at: lastSync.at || null, from: lastSync.from || null, to: lastSync.to || null, ok: !lastSync.cancelled && !lastSync.failed } : null, lastPush: ctx.aitable ? ctx.aitable.lastPushInfo(e.key) : null, pending: ctx.aitable ? ctx.aitable.pendingCount(e.key) : null, }; }); const totalRows = entities.reduce((s, e) => s + e.rows, 0); const coverage = entities.filter((e) => e.coverage).map((e) => e.coverage); sendJson(res, 200, { lakeDir: store.LAKE_DIR, entities, totalRows, coverage: coverage.length ? { from: coverage.map((c) => c.from).sort()[0], to: coverage.map((c) => c.to).sort().pop() } : null, aitable: { enabled: !!(ctx.config.aitable && ctx.config.aitable.enabled), operatorConfigured: !!(ctx.config.aitable && ctx.config.aitable.operatorId), }, }); }); app.get('/api/reports/tree', async ({ res, query }) => { try { const tree = await naposService.fetchTree({ refresh: query.refresh === '1' }); sendJson(res, 200, { merchant: tree.merchant, chains: tree.chains, shops: tree.shops.map((s) => ({ shopId: String(s.shopId), shopName: s.shopName, chainId: s.chainId ? String(s.chainId) : '', chainName: s.chainName || '', busyLevel: s.busyLevel || null })), entity: tree.entity, }); } catch (e) { // 取数通道不可达属于上游失败,保持 502 e.status = 502; throw e; } }); app.get('/api/reports/columns', async ({ res }) => { sendJson(res, 200, { columns: naposService.REPORT_COLUMNS }); }); app.post('/api/reports/sync', async ({ req, res }) => { const body = await readBody(req); const entityPromise = naposService.runSync({ from: body.from, to: body.to, scope: body.scope || 'all', chainId: body.chainId || '', shopIds: Array.isArray(body.shopIds) ? body.shopIds : undefined, }).catch((e) => { ctx.logs.pushSystemLog(`同步失败:${e.message || e}`, 'error'); return null; }); // 同步完成后:若启用钉钉 AI 表格,自动把增量推送云端(失败不影响本地同步结果) entityPromise.then(async (snap) => { if (!snap) return; // 失败已在 catch 记日志 if (snap.entity) { const extra = snap.variantHits > 0 ? `,${snap.variantHits} 家门店改用连锁ID参数取到数据` : ''; ctx.logs.pushSystemLog( `同步完成:${snap.entity.name},新增 ${snap.added} 行、更新 ${snap.updated} 行、失败 ${snap.failed} 项${extra}`, snap.failed > 0 ? 'warn' : 'success' ); } if (!snap.entity || !(ctx.config.aitable && ctx.config.aitable.enabled)) return; try { const r = await ctx.aitable.pushLake(snap.entity.key); ctx.logs.pushSystemLog(`推送云端完成:${r.account ? r.account + ',' : ''}${pushSummary(r)}`, 'success'); } catch (e) { ctx.logs.pushSystemLog(`推送云端失败:${e.message || e}`, 'error'); } }); return sendJson(res, 200, { ok: true, ...naposService.syncSnapshot() }); }); // ---------- 钉钉 AI 表格(在线主档案/共享层) ---------- app.post('/api/reports/aitable/test', async ({ res }) => { const r = await ctx.aitable.testConnection(); sendJson(res, 200, r); }); app.post('/api/reports/aitable/push', async ({ req, res }) => { const body = await readBody(req); const entity = body.entity || (naposService.store.listEntities()[0] || {}).key; if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'AITABLE_NO_ENTITY'; e.status = 400; throw e; } const r = await ctx.aitable.pushLake(entity, { force: !!body.force }); ctx.logs.pushSystemLog(`推送云端完成:${r.account ? r.account + ',' : ''}${pushSummary(r)}`, 'success'); sendJson(res, 200, r); }); app.post('/api/reports/aitable/pull', async ({ req, res }) => { const body = await readBody(req); const r = await ctx.aitable.pullLake(body.entity); const accounts = (r.accounts || []).map((a) => `${a.account}(新增${a.added}/更新${a.updated})`).join('、'); ctx.logs.pushSystemLog(`云端拉取完成:${r.pulled} 行${accounts ? `(${accounts})` : ''}`, 'success'); sendJson(res, 200, r); }); // 取数诊断(网页一键 · 只读):门店级参数对照 + 指标字段探针 + 暂停营业清单 app.post('/api/reports/diagnose/napos', async ({ req, res }) => { const body = await readBody(req); const r = await naposService.runNaposDiagnose({ date: body.date, shopId: body.shopId }); sendJson(res, 200, r); }); app.get('/api/reports/aitable/status', async ({ res, query }) => { const entity = query.entity || (naposService.store.listEntities()[0] || {}).key || ''; const a = ctx.config.aitable || {}; sendJson(res, 200, { enabled: !!a.enabled, configured: !!(a.baseId && a.appKey && a.operatorId), lastPush: entity ? ctx.aitable.lastPushInfo(entity) : null, pending: entity ? ctx.aitable.pendingCount(entity) : null, }); }); app.get('/api/reports/sync/status', async ({ res }) => { sendJson(res, 200, naposService.syncSnapshot()); }); app.post('/api/reports/sync/cancel', async ({ res }) => { sendJson(res, 200, naposService.requestSyncCancel()); }); // 明细行分页:明细(门店×天)量大(90 天 × 数十店 = 数千行), // 前端全量渲染会卡;summary 仍是全区间汇总(分页前计算),KPI 卡不受分页影响 app.get('/api/reports/daily', async ({ res, query }) => { const r = naposService.queryReport({ entity: query.entity, from: query.from, to: query.to, shopId: query.shopId, shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined, scope: query.scope || 'shop', q: query.q, }); const total = r.rows.length; const pageSize = Math.min(1000, Math.max(50, Number(query.pageSize) || 200)); const pages = Math.max(1, Math.ceil(total / pageSize)); const page = Math.min(Math.max(1, Number(query.page) || 1), pages); sendJson(res, 200, { ...r, rows: r.rows.slice((page - 1) * pageSize, page * pageSize), total, page, pageSize, pages, }); }); app.get('/api/reports/aggregate', async ({ res, query }) => { sendJson(res, 200, naposService.aggregateReport({ entity: query.entity, from: query.from, to: query.to, group: query.group || 'shop', shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined, })); }); // 同行对标:按字段取 门店×天 的自身值 + 同行均值/排名(来自全字段落库的 raw.*_mall_avg/_rank) // field 用原始蛇形名(与 historyOverview 一致),白名单 = FIELD_MAP 的键 app.get('/api/reports/bench', async ({ res, query }) => { const field = String(query.field || 'gross_amt'); const camel = naposService.FIELD_MAP[field]; if (!camel) return sendJson(res, 400, { error: `不支持的对标字段:${field}` }); const store = naposService.store; const key = store.sanitizeKey(query.entity); const meta = store.getEntity(key); let rows = store.queryRows(key, { from: query.from, to: query.to, scope: 'shop' }); if (query.shops) { const wanted = new Set(query.shops.split(',').filter(Boolean)); rows = rows.filter((r) => wanted.has(String(r.shopId))); } const avgKey = field + '_mall_avg'; const rankKey = field + '_mall_avg_rank'; const out = rows.map((r) => ({ date: r.date, shopId: String(r.shopId), shopName: r.shopName || '', v: Number(r[camel]) || 0, avg: r.raw && r.raw[avgKey] != null && r.raw[avgKey] !== '' ? Number(r.raw[avgKey]) : null, rank: r.raw && r.raw[rankKey] != null && r.raw[rankKey] !== '' ? Number(r.raw[rankKey]) : null, w1: Number(r.expUv) || 0, // pct 指标跨店加权用(曝光人数) w2: Number(r.orderUserCnt) || 0, // 下单转化率加权用 })); sendJson(res, 200, { entity: meta ? { key: meta.key, name: meta.name } : { key, name: key }, field, from: query.from || null, to: query.to || null, rows: out, }); }); app.get('/api/reports/export', async ({ res, query }) => { const group = query.group === 'date' ? 'date' : (query.group === 'shop' ? 'shop' : null); let rows; if (group) { const agg = naposService.aggregateReport({ entity: query.entity, from: query.from, to: query.to, group }); rows = agg.rows; } else { const daily = naposService.queryReport({ entity: query.entity, from: query.from, to: query.to, shopId: query.shopId, shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined, scope: query.scope || 'shop', q: query.q, }); rows = daily.rows; } const columns = naposService.REPORT_COLUMNS; const csv = naposService.rowsToCsv(columns, rows); // 文件名只用消毒过的主体 key 与合法日期,避免畸形参数把响应头打出 500 const safeEntity = String(query.entity || 'all').replace(/[^\w-]/g, '') || 'all'; const safeFrom = /^\d{8}$/.test(query.from || '') ? query.from : ''; const safeTo = /^\d{8}$/.test(query.to || '') ? query.to : ''; res.writeHead(200, { 'Content-Type': 'text/csv; charset=utf-8', 'Content-Disposition': `attachment; filename="report-${safeEntity}-${safeFrom}-${safeTo}.csv"`, }); return res.end(csv); }); app.del('/api/reports/data', async ({ res, query }) => { if (!query.entity) return sendJson(res, 400, { error: '缺少 entity 参数' }); naposService.store.deleteEntityData(query.entity); ctx.logs.pushSystemLog(`本地数据已清除:主体 ${query.entity}`, 'warn'); sendJson(res, 200, { ok: true }); }); } module.exports = { register };