reports.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. 'use strict';
  2. // 数据采集与智能分析路由:同步任务、数据湖查询/聚合/导出、经营周报 HTML、智能问数
  3. const { sendJson, readBody } = require('../util.js');
  4. const { ASK_CLIENT_CODES } = require('../middleware.js');
  5. const { makeChatCfg } = require('../context.js');
  6. const naposService = require('../../napos-service.js');
  7. const nlQuery = require('../../nl-query.js');
  8. const reportHtml = require('../../report-html.js');
  9. const { pushSummary } = require('../services/aitable.js');
  10. function register(app, ctx) {
  11. // 不限方法(保持拆分前行为:该路由未做方法检查)
  12. app.any('/api/reports/status', async ({ res }) => {
  13. const napos = await naposService.naposStatus();
  14. sendJson(res, 200, { napos, entities: naposService.store.listEntitiesFull() });
  15. });
  16. // ---------- 智能问数(NL2Query,对话模型驱动) ----------
  17. app.post('/api/reports/ask', async ({ req, res }) => {
  18. const body = await readBody(req);
  19. const entity = body.entity || (naposService.store.listEntities()[0] || {}).key;
  20. if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'ASK_NO_ENTITY'; throw e; }
  21. try {
  22. const result = await nlQuery.ask({ q: body.q, entity, cfg: makeChatCfg(ctx), svc: naposService, history: Array.isArray(body.history) ? body.history : undefined });
  23. sendJson(res, 200, result);
  24. } catch (e) {
  25. // 参数类错误码 → 400(错误边界统一处理),其余(模型/网络失败)保持 502
  26. if (!ASK_CLIENT_CODES.has(e.code)) e.status = 502;
  27. throw e;
  28. }
  29. });
  30. app.get('/api/reports/report-html', async ({ res, query }) => {
  31. const entity = query.entity || (naposService.store.listEntities()[0] || {}).key;
  32. if (!entity) {
  33. res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  34. return res.end('<meta charset="utf-8"><body style="font-family:sans-serif;padding:40px">暂无数据主体,请先在「数据报表」完成一次同步。</body>');
  35. }
  36. const range = nlQuery.lakeDateRange(entity, naposService);
  37. const from = /^\d{8}$/.test(query.from || '') ? query.from : range.from;
  38. const to = /^\d{8}$/.test(query.to || '') ? query.to : range.to;
  39. const html = reportHtml.buildReportHtml({ entity, from, to });
  40. res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  41. return res.end(html);
  42. });
  43. app.get('/api/reports/entities', async ({ res }) => {
  44. sendJson(res, 200, { entities: naposService.store.listEntitiesFull() });
  45. });
  46. // 同步历史(数据采集页「同步历史」面板)
  47. app.get('/api/reports/syncs', async ({ res, query }) => {
  48. const entity = query.entity || (naposService.store.listEntities()[0] || {}).key;
  49. if (!entity) { sendJson(res, 200, { runs: [] }); return; }
  50. sendJson(res, 200, { entity, runs: naposService.store.loadSyncLogs(entity) });
  51. });
  52. // 数据中台:本地湖全景(主体覆盖、行数分布、最近同步/推送),供中台页渲染
  53. app.get('/api/reports/lake', async ({ res }) => {
  54. const store = naposService.store;
  55. const entities = store.listEntitiesFull().map((e) => {
  56. const rows = store.loadRows(e.key);
  57. const byScope = { company: 0, chain: 0, shop: 0, summary: 0 };
  58. let from = '';
  59. let to = '';
  60. for (const r of rows) {
  61. if (byScope[r.scope] != null) byScope[r.scope] += 1;
  62. if (r.date && (!from || r.date < from)) from = r.date;
  63. if (r.date && r.date > to) to = r.date;
  64. }
  65. const lastSync = (store.loadSyncLogs(e.key)[0] || null);
  66. return {
  67. key: e.key, name: e.name, shopCount: e.shopCount || 0, chainCount: e.chainCount || 0,
  68. rows: rows.length, byScope, coverage: rows.length ? { from, to } : null,
  69. lastSync: lastSync ? { at: lastSync.at || null, from: lastSync.from || null, to: lastSync.to || null, ok: !lastSync.cancelled && !lastSync.failed } : null,
  70. lastPush: ctx.aitable ? ctx.aitable.lastPushInfo(e.key) : null,
  71. pending: ctx.aitable ? ctx.aitable.pendingCount(e.key) : null,
  72. };
  73. });
  74. const totalRows = entities.reduce((s, e) => s + e.rows, 0);
  75. const coverage = entities.filter((e) => e.coverage).map((e) => e.coverage);
  76. sendJson(res, 200, {
  77. lakeDir: store.LAKE_DIR,
  78. entities, totalRows,
  79. coverage: coverage.length ? { from: coverage.map((c) => c.from).sort()[0], to: coverage.map((c) => c.to).sort().pop() } : null,
  80. aitable: {
  81. enabled: !!(ctx.config.aitable && ctx.config.aitable.enabled),
  82. operatorConfigured: !!(ctx.config.aitable && ctx.config.aitable.operatorId),
  83. },
  84. });
  85. });
  86. app.get('/api/reports/tree', async ({ res, query }) => {
  87. try {
  88. const tree = await naposService.fetchTree({ refresh: query.refresh === '1' });
  89. sendJson(res, 200, {
  90. merchant: tree.merchant, chains: tree.chains,
  91. 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 })),
  92. entity: tree.entity,
  93. });
  94. } catch (e) {
  95. // 取数通道不可达属于上游失败,保持 502
  96. e.status = 502;
  97. throw e;
  98. }
  99. });
  100. app.get('/api/reports/columns', async ({ res }) => {
  101. sendJson(res, 200, { columns: naposService.REPORT_COLUMNS });
  102. });
  103. app.post('/api/reports/sync', async ({ req, res }) => {
  104. const body = await readBody(req);
  105. const entityPromise = naposService.runSync({
  106. from: body.from, to: body.to,
  107. scope: body.scope || 'all',
  108. chainId: body.chainId || '',
  109. shopIds: Array.isArray(body.shopIds) ? body.shopIds : undefined,
  110. }).catch((e) => {
  111. ctx.logs.pushSystemLog(`同步失败:${e.message || e}`, 'error');
  112. return null;
  113. });
  114. // 同步完成后:若启用钉钉 AI 表格,自动把增量推送云端(失败不影响本地同步结果)
  115. entityPromise.then(async (snap) => {
  116. if (!snap) return; // 失败已在 catch 记日志
  117. if (snap.entity) {
  118. const extra = snap.variantHits > 0 ? `,${snap.variantHits} 家门店改用连锁ID参数取到数据` : '';
  119. ctx.logs.pushSystemLog(
  120. `同步完成:${snap.entity.name},新增 ${snap.added} 行、更新 ${snap.updated} 行、失败 ${snap.failed} 项${extra}`,
  121. snap.failed > 0 ? 'warn' : 'success'
  122. );
  123. }
  124. if (!snap.entity || !(ctx.config.aitable && ctx.config.aitable.enabled)) return;
  125. try {
  126. const r = await ctx.aitable.pushLake(snap.entity.key);
  127. ctx.logs.pushSystemLog(`推送云端完成:${r.account ? r.account + ',' : ''}${pushSummary(r)}`, 'success');
  128. } catch (e) {
  129. ctx.logs.pushSystemLog(`推送云端失败:${e.message || e}`, 'error');
  130. }
  131. });
  132. return sendJson(res, 200, { ok: true, ...naposService.syncSnapshot() });
  133. });
  134. // ---------- 钉钉 AI 表格(在线主档案/共享层) ----------
  135. app.post('/api/reports/aitable/test', async ({ res }) => {
  136. const r = await ctx.aitable.testConnection();
  137. sendJson(res, 200, r);
  138. });
  139. app.post('/api/reports/aitable/push', async ({ req, res }) => {
  140. const body = await readBody(req);
  141. const entity = body.entity || (naposService.store.listEntities()[0] || {}).key;
  142. if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'AITABLE_NO_ENTITY'; e.status = 400; throw e; }
  143. const r = await ctx.aitable.pushLake(entity, { force: !!body.force });
  144. ctx.logs.pushSystemLog(`推送云端完成:${r.account ? r.account + ',' : ''}${pushSummary(r)}`, 'success');
  145. sendJson(res, 200, r);
  146. });
  147. app.post('/api/reports/aitable/pull', async ({ req, res }) => {
  148. const body = await readBody(req);
  149. const r = await ctx.aitable.pullLake(body.entity);
  150. const accounts = (r.accounts || []).map((a) => `${a.account}(新增${a.added}/更新${a.updated})`).join('、');
  151. ctx.logs.pushSystemLog(`云端拉取完成:${r.pulled} 行${accounts ? `(${accounts})` : ''}`, 'success');
  152. sendJson(res, 200, r);
  153. });
  154. // 取数诊断(网页一键 · 只读):门店级参数对照 + 指标字段探针 + 暂停营业清单
  155. app.post('/api/reports/diagnose/napos', async ({ req, res }) => {
  156. const body = await readBody(req);
  157. const r = await naposService.runNaposDiagnose({ date: body.date, shopId: body.shopId });
  158. sendJson(res, 200, r);
  159. });
  160. // 定向流量采集(只录不改):同事在客户端里操作目标页面 N 秒,录下业务接口清单供开发侧补目录。
  161. // start 立即返回、后台采集;前端轮询 status,结束后取 result(顺带落盘 workspace/.genpic/harvest-latest.json)
  162. app.post('/api/reports/harvest/start', async ({ req, res }) => {
  163. const body = await readBody(req);
  164. const st = naposService.harvestStatus();
  165. if (st.running) return sendJson(res, 409, { error: '已有定向采集在进行中' });
  166. const seconds = Math.max(10, Math.min(600, Number(body && body.seconds) || 120));
  167. naposService.startHarvest({ seconds }).catch(() => { /* 结果经 status/result 读取 */ });
  168. sendJson(res, 200, { ok: true, seconds });
  169. });
  170. app.get('/api/reports/harvest/status', async ({ res }) => {
  171. sendJson(res, 200, naposService.harvestStatus());
  172. });
  173. app.post('/api/reports/harvest/stop', async ({ res }) => {
  174. sendJson(res, 200, naposService.stopHarvest());
  175. });
  176. app.get('/api/reports/harvest/result', async ({ res }) => {
  177. const r = naposService.getHarvestResult();
  178. if (r) {
  179. try {
  180. const fs = require('fs');
  181. const path = require('path');
  182. const dir = path.join(ctx.workspace.getWorkspaceRoot(), '.genpic');
  183. fs.mkdirSync(dir, { recursive: true });
  184. fs.writeFileSync(path.join(dir, 'harvest-latest.json'), JSON.stringify(r, null, 2), 'utf8');
  185. } catch { /* 落盘失败不影响返回 */ }
  186. }
  187. sendJson(res, 200, { result: r || null });
  188. });
  189. app.get('/api/reports/aitable/status', async ({ res, query }) => {
  190. const entity = query.entity || (naposService.store.listEntities()[0] || {}).key || '';
  191. const a = ctx.config.aitable || {};
  192. sendJson(res, 200, {
  193. enabled: !!a.enabled,
  194. configured: !!(a.baseId && a.appKey && a.operatorId),
  195. lastPush: entity ? ctx.aitable.lastPushInfo(entity) : null,
  196. pending: entity ? ctx.aitable.pendingCount(entity) : null,
  197. });
  198. });
  199. app.get('/api/reports/sync/status', async ({ res }) => {
  200. sendJson(res, 200, naposService.syncSnapshot());
  201. });
  202. app.post('/api/reports/sync/cancel', async ({ res }) => {
  203. sendJson(res, 200, naposService.requestSyncCancel());
  204. });
  205. // 明细行分页:明细(门店×天)量大(90 天 × 数十店 = 数千行),
  206. // 前端全量渲染会卡;summary 仍是全区间汇总(分页前计算),KPI 卡不受分页影响
  207. app.get('/api/reports/daily', async ({ res, query }) => {
  208. const r = naposService.queryReport({
  209. entity: query.entity, from: query.from, to: query.to,
  210. shopId: query.shopId,
  211. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  212. scope: query.scope || 'shop', q: query.q,
  213. });
  214. const total = r.rows.length;
  215. const pageSize = Math.min(1000, Math.max(50, Number(query.pageSize) || 200));
  216. const pages = Math.max(1, Math.ceil(total / pageSize));
  217. const page = Math.min(Math.max(1, Number(query.page) || 1), pages);
  218. sendJson(res, 200, {
  219. ...r,
  220. rows: r.rows.slice((page - 1) * pageSize, page * pageSize),
  221. total, page, pageSize, pages,
  222. });
  223. });
  224. app.get('/api/reports/aggregate', async ({ res, query }) => {
  225. sendJson(res, 200, naposService.aggregateReport({
  226. entity: query.entity, from: query.from, to: query.to,
  227. group: query.group || 'shop',
  228. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  229. }));
  230. });
  231. // 同行对标:按字段取 门店×天 的自身值 + 同行均值/排名(来自全字段落库的 raw.*_mall_avg/_rank)
  232. // field 用原始蛇形名(与 historyOverview 一致),白名单 = FIELD_MAP 的键
  233. app.get('/api/reports/bench', async ({ res, query }) => {
  234. const field = String(query.field || 'gross_amt');
  235. const camel = naposService.FIELD_MAP[field];
  236. if (!camel) return sendJson(res, 400, { error: `不支持的对标字段:${field}` });
  237. const store = naposService.store;
  238. const key = store.sanitizeKey(query.entity);
  239. const meta = store.getEntity(key);
  240. let rows = store.queryRows(key, { from: query.from, to: query.to, scope: 'shop' });
  241. if (query.shops) {
  242. const wanted = new Set(query.shops.split(',').filter(Boolean));
  243. rows = rows.filter((r) => wanted.has(String(r.shopId)));
  244. }
  245. const avgKey = field + '_mall_avg';
  246. const rankKey = field + '_mall_avg_rank';
  247. const out = rows.map((r) => ({
  248. date: r.date,
  249. shopId: String(r.shopId),
  250. shopName: r.shopName || '',
  251. v: Number(r[camel]) || 0,
  252. avg: r.raw && r.raw[avgKey] != null && r.raw[avgKey] !== '' ? Number(r.raw[avgKey]) : null,
  253. rank: r.raw && r.raw[rankKey] != null && r.raw[rankKey] !== '' ? Number(r.raw[rankKey]) : null,
  254. w1: Number(r.expUv) || 0, // pct 指标跨店加权用(曝光人数)
  255. w2: Number(r.orderUserCnt) || 0, // 下单转化率加权用
  256. }));
  257. sendJson(res, 200, {
  258. entity: meta ? { key: meta.key, name: meta.name } : { key, name: key },
  259. field, from: query.from || null, to: query.to || null,
  260. rows: out,
  261. });
  262. });
  263. app.get('/api/reports/export', async ({ res, query }) => {
  264. const group = query.group === 'date' ? 'date' : (query.group === 'shop' ? 'shop' : null);
  265. let rows;
  266. if (group) {
  267. const agg = naposService.aggregateReport({ entity: query.entity, from: query.from, to: query.to, group });
  268. rows = agg.rows;
  269. } else {
  270. const daily = naposService.queryReport({
  271. entity: query.entity, from: query.from, to: query.to,
  272. shopId: query.shopId,
  273. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  274. scope: query.scope || 'shop', q: query.q,
  275. });
  276. rows = daily.rows;
  277. }
  278. const columns = naposService.REPORT_COLUMNS;
  279. const csv = naposService.rowsToCsv(columns, rows);
  280. // 文件名只用消毒过的主体 key 与合法日期,避免畸形参数把响应头打出 500
  281. const safeEntity = String(query.entity || 'all').replace(/[^\w-]/g, '') || 'all';
  282. const safeFrom = /^\d{8}$/.test(query.from || '') ? query.from : '';
  283. const safeTo = /^\d{8}$/.test(query.to || '') ? query.to : '';
  284. res.writeHead(200, {
  285. 'Content-Type': 'text/csv; charset=utf-8',
  286. 'Content-Disposition': `attachment; filename="report-${safeEntity}-${safeFrom}-${safeTo}.csv"`,
  287. });
  288. return res.end(csv);
  289. });
  290. app.del('/api/reports/data', async ({ res, query }) => {
  291. if (!query.entity) return sendJson(res, 400, { error: '缺少 entity 参数' });
  292. naposService.store.deleteEntityData(query.entity);
  293. ctx.logs.pushSystemLog(`本地数据已清除:主体 ${query.entity}`, 'warn');
  294. sendJson(res, 200, { ok: true });
  295. });
  296. }
  297. module.exports = { register };