reports.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  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. /** 三表推送结果的一行摘要(日志用):合计 + 分表明细 */
  10. function aitablePushSummary(r) {
  11. const base = `新增 ${r.created} 行、更新 ${r.updated} 行`;
  12. const parts = Object.entries(r.perSheet || {})
  13. .filter(([, s]) => s.rows > 0)
  14. .map(([scope, s]) => `${s.sheet} 新增${s.created}/更新${s.updated}`)
  15. .join(',');
  16. return parts ? `${base}(${parts})` : base;
  17. }
  18. function register(app, ctx) {
  19. // 不限方法(保持拆分前行为:该路由未做方法检查)
  20. app.any('/api/reports/status', async ({ res }) => {
  21. const napos = await naposService.naposStatus();
  22. sendJson(res, 200, { napos, entities: naposService.store.listEntities() });
  23. });
  24. // ---------- 智能问数(NL2Query,对话模型驱动) ----------
  25. app.post('/api/reports/ask', async ({ req, res }) => {
  26. const body = await readBody(req);
  27. const entity = body.entity || (naposService.store.listEntities()[0] || {}).key;
  28. if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'ASK_NO_ENTITY'; throw e; }
  29. try {
  30. const result = await nlQuery.ask({ q: body.q, entity, cfg: makeChatCfg(ctx), svc: naposService, history: Array.isArray(body.history) ? body.history : undefined });
  31. sendJson(res, 200, result);
  32. } catch (e) {
  33. // 参数类错误码 → 400(错误边界统一处理),其余(模型/网络失败)保持 502
  34. if (!ASK_CLIENT_CODES.has(e.code)) e.status = 502;
  35. throw e;
  36. }
  37. });
  38. app.get('/api/reports/report-html', async ({ res, query }) => {
  39. const entity = query.entity || (naposService.store.listEntities()[0] || {}).key;
  40. if (!entity) {
  41. res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  42. return res.end('<meta charset="utf-8"><body style="font-family:sans-serif;padding:40px">暂无数据主体,请先在「数据报表」完成一次同步。</body>');
  43. }
  44. const range = nlQuery.lakeDateRange(entity, naposService);
  45. const from = /^\d{8}$/.test(query.from || '') ? query.from : range.from;
  46. const to = /^\d{8}$/.test(query.to || '') ? query.to : range.to;
  47. const html = reportHtml.buildReportHtml({ entity, from, to });
  48. res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
  49. return res.end(html);
  50. });
  51. app.get('/api/reports/entities', async ({ res }) => {
  52. sendJson(res, 200, { entities: naposService.store.listEntities() });
  53. });
  54. // 同步历史(数据采集页「同步历史」面板)
  55. app.get('/api/reports/syncs', async ({ res, query }) => {
  56. const entity = query.entity || (naposService.store.listEntities()[0] || {}).key;
  57. if (!entity) { sendJson(res, 200, { runs: [] }); return; }
  58. sendJson(res, 200, { entity, runs: naposService.store.loadSyncLogs(entity) });
  59. });
  60. // 数据中台:本地湖全景(主体覆盖、行数分布、最近同步/推送),供中台页渲染
  61. app.get('/api/reports/lake', async ({ res }) => {
  62. const store = naposService.store;
  63. const entities = store.listEntities().map((e) => {
  64. const rows = store.loadRows(e.key);
  65. const byScope = { company: 0, chain: 0, shop: 0, summary: 0 };
  66. let from = '';
  67. let to = '';
  68. for (const r of rows) {
  69. if (byScope[r.scope] != null) byScope[r.scope] += 1;
  70. if (r.date && (!from || r.date < from)) from = r.date;
  71. if (r.date && r.date > to) to = r.date;
  72. }
  73. const lastSync = (store.loadSyncLogs(e.key)[0] || null);
  74. return {
  75. key: e.key, name: e.name, shopCount: e.shopCount || 0, chainCount: e.chainCount || 0,
  76. rows: rows.length, byScope, coverage: rows.length ? { from, to } : null,
  77. lastSync: lastSync ? { at: lastSync.at || null, from: lastSync.from || null, to: lastSync.to || null, ok: !lastSync.cancelled && !lastSync.failed } : null,
  78. lastPush: ctx.aitable ? ctx.aitable.lastPushInfo(e.key) : null,
  79. pending: ctx.aitable ? ctx.aitable.pendingCount(e.key) : null,
  80. };
  81. });
  82. const totalRows = entities.reduce((s, e) => s + e.rows, 0);
  83. const coverage = entities.filter((e) => e.coverage).map((e) => e.coverage);
  84. sendJson(res, 200, {
  85. lakeDir: store.LAKE_DIR,
  86. entities, totalRows,
  87. coverage: coverage.length ? { from: coverage.map((c) => c.from).sort()[0], to: coverage.map((c) => c.to).sort().pop() } : null,
  88. aitable: {
  89. enabled: !!(ctx.config.aitable && ctx.config.aitable.enabled),
  90. operatorConfigured: !!(ctx.config.aitable && ctx.config.aitable.operatorId),
  91. },
  92. });
  93. });
  94. app.get('/api/reports/tree', async ({ res, query }) => {
  95. try {
  96. const tree = await naposService.fetchTree({ refresh: query.refresh === '1' });
  97. sendJson(res, 200, {
  98. merchant: tree.merchant, chains: tree.chains,
  99. 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 })),
  100. entity: tree.entity,
  101. });
  102. } catch (e) {
  103. // 取数通道不可达属于上游失败,保持 502
  104. e.status = 502;
  105. throw e;
  106. }
  107. });
  108. app.get('/api/reports/columns', async ({ res }) => {
  109. sendJson(res, 200, { columns: naposService.REPORT_COLUMNS });
  110. });
  111. app.post('/api/reports/sync', async ({ req, res }) => {
  112. const body = await readBody(req);
  113. const entityPromise = naposService.runSync({
  114. from: body.from, to: body.to,
  115. scope: body.scope || 'all',
  116. chainId: body.chainId || '',
  117. shopIds: Array.isArray(body.shopIds) ? body.shopIds : undefined,
  118. }).catch((e) => {
  119. // 同步失败也要让自动推送保持静默,但保留日志
  120. ctx.logs.pushSystemLog(`同步结束(失败):${e.message || e}`, 'warn');
  121. return null;
  122. });
  123. // 同步完成后:若启用钉钉 AI 表格,自动把增量推上云端(失败不影响本地同步结果)
  124. entityPromise.then(async (snap) => {
  125. if (!snap || !snap.entity || !(ctx.config.aitable && ctx.config.aitable.enabled)) return;
  126. try {
  127. const r = await ctx.aitable.pushLake(snap.entity.key);
  128. ctx.logs.pushSystemLog(`AI 表格推送完成:${aitablePushSummary(r)}`, 'success');
  129. } catch (e) {
  130. ctx.logs.pushSystemLog(`AI 表格推送失败:${e.message || e}`, 'error');
  131. }
  132. });
  133. return sendJson(res, 200, { ok: true, ...naposService.syncSnapshot() });
  134. });
  135. // ---------- 钉钉 AI 表格(在线主档案/共享层) ----------
  136. app.post('/api/reports/aitable/test', async ({ res }) => {
  137. const r = await ctx.aitable.testConnection();
  138. sendJson(res, 200, r);
  139. });
  140. app.post('/api/reports/aitable/push', async ({ req, res }) => {
  141. const body = await readBody(req);
  142. const entity = body.entity || (naposService.store.listEntities()[0] || {}).key;
  143. if (!entity) { const e = new Error('暂无数据主体,请先完成一次同步'); e.code = 'AITABLE_NO_ENTITY'; e.status = 400; throw e; }
  144. const r = await ctx.aitable.pushLake(entity, { force: !!body.force });
  145. ctx.logs.pushSystemLog(`AI 表格推送完成:${aitablePushSummary(r)}`, 'success');
  146. sendJson(res, 200, r);
  147. });
  148. app.post('/api/reports/aitable/pull', async ({ req, res }) => {
  149. const body = await readBody(req);
  150. const r = await ctx.aitable.pullLake(body.entity);
  151. const accounts = (r.accounts || []).map((a) => `${a.account}(新增${a.added}/更新${a.updated})`).join('、');
  152. ctx.logs.pushSystemLog(`AI 表格拉取完成:${r.pulled} 行 ${accounts}`, 'success');
  153. sendJson(res, 200, r);
  154. });
  155. app.get('/api/reports/aitable/status', async ({ res, query }) => {
  156. const entity = query.entity || (naposService.store.listEntities()[0] || {}).key || '';
  157. const a = ctx.config.aitable || {};
  158. sendJson(res, 200, {
  159. enabled: !!a.enabled,
  160. configured: !!(a.baseId && a.appKey && a.operatorId),
  161. lastPush: entity ? ctx.aitable.lastPushInfo(entity) : null,
  162. pending: entity ? ctx.aitable.pendingCount(entity) : null,
  163. });
  164. });
  165. app.get('/api/reports/sync/status', async ({ res }) => {
  166. sendJson(res, 200, naposService.syncSnapshot());
  167. });
  168. app.post('/api/reports/sync/cancel', async ({ res }) => {
  169. sendJson(res, 200, naposService.requestSyncCancel());
  170. });
  171. // 明细行分页:明细(门店×天)量大(90 天 × 数十店 = 数千行),
  172. // 前端全量渲染会卡;summary 仍是全区间汇总(分页前计算),KPI 卡不受分页影响
  173. app.get('/api/reports/daily', async ({ res, query }) => {
  174. const r = naposService.queryReport({
  175. entity: query.entity, from: query.from, to: query.to,
  176. shopId: query.shopId,
  177. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  178. scope: query.scope || 'shop', q: query.q,
  179. });
  180. const total = r.rows.length;
  181. const pageSize = Math.min(1000, Math.max(50, Number(query.pageSize) || 200));
  182. const pages = Math.max(1, Math.ceil(total / pageSize));
  183. const page = Math.min(Math.max(1, Number(query.page) || 1), pages);
  184. sendJson(res, 200, {
  185. ...r,
  186. rows: r.rows.slice((page - 1) * pageSize, page * pageSize),
  187. total, page, pageSize, pages,
  188. });
  189. });
  190. app.get('/api/reports/aggregate', async ({ res, query }) => {
  191. sendJson(res, 200, naposService.aggregateReport({
  192. entity: query.entity, from: query.from, to: query.to,
  193. group: query.group || 'shop',
  194. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  195. }));
  196. });
  197. app.get('/api/reports/export', async ({ res, query }) => {
  198. const group = query.group === 'date' ? 'date' : (query.group === 'shop' ? 'shop' : null);
  199. let rows;
  200. if (group) {
  201. const agg = naposService.aggregateReport({ entity: query.entity, from: query.from, to: query.to, group });
  202. rows = agg.rows;
  203. } else {
  204. const daily = naposService.queryReport({
  205. entity: query.entity, from: query.from, to: query.to,
  206. shopId: query.shopId,
  207. shops: query.shops ? query.shops.split(',').filter(Boolean) : undefined,
  208. scope: query.scope || 'shop', q: query.q,
  209. });
  210. rows = daily.rows;
  211. }
  212. const columns = naposService.REPORT_COLUMNS;
  213. const csv = naposService.rowsToCsv(columns, rows);
  214. res.writeHead(200, {
  215. 'Content-Type': 'text/csv; charset=utf-8',
  216. 'Content-Disposition': `attachment; filename="report-${query.entity || 'all'}-${query.from || ''}-${query.to || ''}.csv"`,
  217. });
  218. return res.end(csv);
  219. });
  220. app.get('/api/reports/syncs', async ({ res, query }) => {
  221. const key = naposService.store.sanitizeKey(query.entity);
  222. sendJson(res, 200, { runs: naposService.store.loadSyncLogs(key) });
  223. });
  224. app.del('/api/reports/data', async ({ res, query }) => {
  225. if (!query.entity) return sendJson(res, 400, { error: '缺少 entity 参数' });
  226. naposService.store.deleteEntityData(query.entity);
  227. ctx.logs.pushSystemLog(`数据报表:已清除主体 ${query.entity} 的本地数据`, 'warn');
  228. sendJson(res, 200, { ok: true });
  229. });
  230. }
  231. module.exports = { register };