reports.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  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. function register(app, ctx) {
  10. // 不限方法(保持拆分前行为:该路由未做方法检查)
  11. app.any('/api/reports/status', async ({ res }) => {
  12. const napos = await naposService.naposStatus();
  13. sendJson(res, 200, { napos, entities: naposService.store.listEntities() });
  14. });
  15. // ---------- 智能问数(NL2Query,对话模型驱动) ----------
  16. app.post('/api/reports/ask', async ({ req, res }) => {
  17. const body = await readBody(req);
  18. const entity = body.entity || (naposService.store.listEntities()[0] || {}).key;
  19. if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'ASK_NO_ENTITY'; throw e; }
  20. try {
  21. const result = await nlQuery.ask({ q: body.q, entity, cfg: makeChatCfg(ctx), svc: naposService, history: Array.isArray(body.history) ? body.history : undefined });
  22. sendJson(res, 200, result);
  23. } catch (e) {
  24. // 参数类错误码 → 400(错误边界统一处理),其余(模型/网络失败)保持 502
  25. if (!ASK_CLIENT_CODES.has(e.code)) e.status = 502;
  26. throw e;
  27. }
  28. });
  29. app.get('/api/reports/report-html', async ({ res, query }) => {
  30. const entity = query.entity || (naposService.store.listEntities()[0] || {}).key;
  31. if (!entity) {
  32. res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  33. return res.end('<meta charset="utf-8"><body style="font-family:sans-serif;padding:40px">暂无数据主体,请先在「数据报表」完成一次同步。</body>');
  34. }
  35. const range = nlQuery.lakeDateRange(entity, naposService);
  36. const from = /^\d{8}$/.test(query.from || '') ? query.from : range.from;
  37. const to = /^\d{8}$/.test(query.to || '') ? query.to : range.to;
  38. const html = reportHtml.buildReportHtml({ entity, from, to });
  39. res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  40. return res.end(html);
  41. });
  42. app.get('/api/reports/entities', async ({ res }) => {
  43. sendJson(res, 200, { entities: naposService.store.listEntities() });
  44. });
  45. app.get('/api/reports/tree', async ({ res, query }) => {
  46. try {
  47. const tree = await naposService.fetchTree({ refresh: query.refresh === '1' });
  48. sendJson(res, 200, {
  49. merchant: tree.merchant, chains: tree.chains,
  50. 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 })),
  51. entity: tree.entity,
  52. });
  53. } catch (e) {
  54. // 取数通道不可达属于上游失败,保持 502
  55. e.status = 502;
  56. throw e;
  57. }
  58. });
  59. app.get('/api/reports/columns', async ({ res }) => {
  60. sendJson(res, 200, { columns: naposService.REPORT_COLUMNS });
  61. });
  62. app.post('/api/reports/sync', async ({ req, res }) => {
  63. const body = await readBody(req);
  64. const entityPromise = naposService.runSync({
  65. from: body.from, to: body.to,
  66. scope: body.scope || 'all',
  67. chainId: body.chainId || '',
  68. shopIds: Array.isArray(body.shopIds) ? body.shopIds : undefined,
  69. }).catch((e) => {
  70. // 同步失败也要让自动推送保持静默,但保留日志
  71. ctx.logs.pushSystemLog(`同步结束(失败):${e.message || e}`, 'warn');
  72. return null;
  73. });
  74. // 同步完成后:若启用钉钉 AI 表格,自动把增量推上云端(失败不影响本地同步结果)
  75. entityPromise.then(async (snap) => {
  76. if (!snap || !snap.entity || !(ctx.config.aitable && ctx.config.aitable.enabled)) return;
  77. try {
  78. const r = await ctx.aitable.pushLake(snap.entity.key);
  79. ctx.logs.pushSystemLog(`AI 表格推送完成:新增 ${r.created} 行、更新 ${r.updated} 行`, 'success');
  80. } catch (e) {
  81. ctx.logs.pushSystemLog(`AI 表格推送失败:${e.message || e}`, 'error');
  82. }
  83. });
  84. return sendJson(res, 200, { ok: true, ...naposService.syncSnapshot() });
  85. });
  86. // ---------- 钉钉 AI 表格(在线主档案/共享层) ----------
  87. app.post('/api/reports/aitable/test', async ({ res }) => {
  88. const r = await ctx.aitable.testConnection();
  89. sendJson(res, 200, r);
  90. });
  91. app.post('/api/reports/aitable/push', async ({ req, res }) => {
  92. const body = await readBody(req);
  93. const entity = body.entity || (naposService.store.listEntities()[0] || {}).key;
  94. if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'AITABLE_NO_ENTITY'; e.status = 400; throw e; }
  95. const r = await ctx.aitable.pushLake(entity, { force: !!body.force });
  96. ctx.logs.pushSystemLog(`AI 表格推送完成:新增 ${r.created} 行、更新 ${r.updated} 行`, 'success');
  97. sendJson(res, 200, r);
  98. });
  99. app.post('/api/reports/aitable/pull', async ({ req, res }) => {
  100. const body = await readBody(req);
  101. const r = await ctx.aitable.pullLake(body.entity);
  102. const accounts = (r.accounts || []).map((a) => `${a.account}(新增${a.added}/更新${a.updated})`).join('、');
  103. ctx.logs.pushSystemLog(`AI 表格拉取完成:${r.pulled} 行 ${accounts}`, 'success');
  104. sendJson(res, 200, r);
  105. });
  106. app.get('/api/reports/aitable/status', async ({ res, query }) => {
  107. const entity = query.entity || (naposService.store.listEntities()[0] || {}).key || '';
  108. sendJson(res, 200, {
  109. enabled: !!(ctx.config.aitable && ctx.config.aitable.enabled),
  110. configured: !!(ctx.config.aitable && ctx.config.aitable.baseId && ctx.config.aitable.sheetName && ctx.config.aitable.appKey),
  111. lastPush: entity ? ctx.aitable.lastPushInfo(entity) : null,
  112. });
  113. });
  114. app.get('/api/reports/sync/status', async ({ res }) => {
  115. sendJson(res, 200, naposService.syncSnapshot());
  116. });
  117. app.post('/api/reports/sync/cancel', async ({ res }) => {
  118. sendJson(res, 200, naposService.requestSyncCancel());
  119. });
  120. app.get('/api/reports/daily', async ({ res, query }) => {
  121. sendJson(res, 200, naposService.queryReport({
  122. entity: query.entity, from: query.from, to: query.to,
  123. shopId: query.shopId,
  124. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  125. scope: query.scope || 'shop', q: query.q,
  126. }));
  127. });
  128. app.get('/api/reports/aggregate', async ({ res, query }) => {
  129. sendJson(res, 200, naposService.aggregateReport({
  130. entity: query.entity, from: query.from, to: query.to,
  131. group: query.group || 'shop',
  132. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  133. }));
  134. });
  135. app.get('/api/reports/export', async ({ res, query }) => {
  136. const group = query.group === 'date' ? 'date' : (query.group === 'shop' ? 'shop' : null);
  137. let rows;
  138. if (group) {
  139. const agg = naposService.aggregateReport({ entity: query.entity, from: query.from, to: query.to, group });
  140. rows = agg.rows;
  141. } else {
  142. const daily = naposService.queryReport({
  143. entity: query.entity, from: query.from, to: query.to,
  144. shopId: query.shopId,
  145. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  146. scope: query.scope || 'shop', q: query.q,
  147. });
  148. rows = daily.rows;
  149. }
  150. const columns = naposService.REPORT_COLUMNS;
  151. const csv = naposService.rowsToCsv(columns, rows);
  152. res.writeHead(200, {
  153. 'Content-Type': 'text/csv; charset=utf-8',
  154. 'Content-Disposition': `attachment; filename="report-${query.entity || 'all'}-${query.from || ''}-${query.to || ''}.csv"`,
  155. });
  156. return res.end(csv);
  157. });
  158. app.get('/api/reports/syncs', async ({ res, query }) => {
  159. const key = naposService.store.sanitizeKey(query.entity);
  160. sendJson(res, 200, { runs: naposService.store.loadSyncLogs(key) });
  161. });
  162. app.del('/api/reports/data', async ({ res, query }) => {
  163. if (!query.entity) return sendJson(res, 400, { error: '缺少 entity 参数' });
  164. naposService.store.deleteEntityData(query.entity);
  165. ctx.logs.pushSystemLog(`数据报表:已清除主体 ${query.entity} 的本地数据`, 'warn');
  166. sendJson(res, 200, { ok: true });
  167. });
  168. }
  169. module.exports = { register };