reports-mcp.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. #!/usr/bin/env node
  2. 'use strict';
  3. /**
  4. * 智慧运营中台 · 数据报表 MCP Server(stdio JSON-RPC)
  5. * ----------------------------------------------------------------------------
  6. * 把本地数据湖暴露成 MCP 工具,供 WorkBuddy 等 Agent 直接「查数」,
  7. * 与 napos-mcp(负责「取数」)形成闭环。零第三方依赖。
  8. *
  9. * 注册(~/.workbuddy/mcp.json):
  10. * "napos-reports": { "command": "node", "args": ["<本项目路径>/reports-mcp.js"] }
  11. *
  12. * 工具:
  13. * reports_list_entities — 列出数据湖中的主体(含门店清单)
  14. * reports_summary — 区间 KPI 汇总
  15. * reports_query — 查询明细/按门店汇总/按日期汇总
  16. */
  17. const naposService = require('./napos-service.js');
  18. const nlQuery = require('./nl-query.js');
  19. const TOOLS = [
  20. {
  21. name: 'reports_list_entities',
  22. description: '列出本地数据湖中已沉淀的经营数据主体(商家账号),含每个主体的门店清单、数据覆盖的日期范围',
  23. inputSchema: { type: 'object', properties: {} },
  24. },
  25. {
  26. name: 'reports_summary',
  27. description: '查询某主体在指定日期区间的 KPI 汇总(营业额/收入/有效订单/曝光/下单人数/转化率/客单价)',
  28. inputSchema: {
  29. type: 'object',
  30. properties: {
  31. entity: { type: 'string', description: '主体 key(来自 reports_list_entities)' },
  32. from: { type: 'string', description: '起始日 YYYYMMDD,缺省=可用区间起点' },
  33. to: { type: 'string', description: '结束日 YYYYMMDD,缺省=可用区间终点' },
  34. },
  35. required: ['entity'],
  36. },
  37. },
  38. {
  39. name: 'reports_query',
  40. description: '查询经营数据行。mode=shop 按门店汇总(可排序取 TopN)、mode=date 按日期汇总、mode=detail 日期×门店明细',
  41. inputSchema: {
  42. type: 'object',
  43. properties: {
  44. entity: { type: 'string', description: '主体 key' },
  45. from: { type: 'string', description: '起始日 YYYYMMDD' },
  46. to: { type: 'string', description: '结束日 YYYYMMDD' },
  47. mode: { type: 'string', enum: ['detail', 'shop', 'date'], description: '缺省 shop' },
  48. shopIds: { type: 'array', items: { type: 'string' }, description: '可选,限定门店' },
  49. q: { type: 'string', description: '可选,按门店名称模糊过滤' },
  50. sort: { type: 'string', description: '排序指标 key,如 grossAmt' },
  51. sortDir: { type: 'string', enum: ['asc', 'desc'] },
  52. limit: { type: 'number', description: '返回行数上限,缺省 50' },
  53. },
  54. required: ['entity'],
  55. },
  56. },
  57. ];
  58. function jsonOut(obj) {
  59. process.stdout.write(JSON.stringify(obj) + '\n');
  60. }
  61. function clampRange(entity) {
  62. const range = nlQuery.lakeDateRange(entity, naposService);
  63. return range;
  64. }
  65. function handleListEntities() {
  66. const entities = naposService.store.listEntities().map((e) => {
  67. const range = nlQuery.lakeDateRange(e.key, naposService);
  68. return {
  69. key: e.key, name: e.name, shopCount: (e.shops || []).length,
  70. chains: e.chains || [], shops: e.shops || [],
  71. dataRange: range,
  72. };
  73. });
  74. return { entities };
  75. }
  76. function handleSummary(args) {
  77. const entity = String(args.entity || '');
  78. const range = clampRange(entity);
  79. const from = /^\d{8}$/.test(args.from || '') ? args.from : range.from;
  80. const to = /^\d{8}$/.test(args.to || '') ? args.to : range.to;
  81. const daily = naposService.queryReport({ entity, from, to, scope: 'shop' });
  82. return { entity, from, to, summary: daily.summary };
  83. }
  84. function handleQuery(args) {
  85. const entity = String(args.entity || '');
  86. const range = clampRange(entity);
  87. const from = /^\d{8}$/.test(args.from || '') ? args.from : range.from;
  88. const to = /^\d{8}$/.test(args.to || '') ? args.to : range.to;
  89. const mode = ['detail', 'shop', 'date'].includes(args.mode) ? args.mode : 'shop';
  90. const limit = Math.min(Number(args.limit) > 0 ? Number(args.limit) : 50, 500);
  91. let result;
  92. if (mode === 'detail') {
  93. result = naposService.queryReport({ entity, from, to, scope: 'shop', shops: args.shopIds, q: args.q });
  94. } else {
  95. result = naposService.aggregateReport({ entity, from, to, group: mode === 'date' ? 'date' : 'shop', shops: args.shopIds });
  96. if (args.q) result.rows = result.rows.filter((r) => String(r.shopName || '').toLowerCase().includes(String(args.q).toLowerCase()));
  97. }
  98. let rows = result.rows || [];
  99. const sortKey = naposService.REPORT_COLUMNS.some((c) => c.key === args.sort) ? args.sort : null;
  100. if (sortKey) {
  101. const dir = args.sortDir === 'asc' ? 1 : -1;
  102. rows = rows.slice().sort((a, b) => {
  103. const an = Number(a[sortKey]), bn = Number(b[sortKey]);
  104. if (Number.isFinite(an) && Number.isFinite(bn)) return (an - bn) * dir;
  105. return String(a[sortKey] || '').localeCompare(String(b[sortKey] || ''), 'zh-CN') * dir;
  106. });
  107. }
  108. return {
  109. entity, from, to, mode,
  110. columns: result.columns.map((c) => ({ key: c.key, label: c.label, type: c.type })),
  111. summary: result.summary,
  112. rows: rows.slice(0, limit),
  113. totalRows: rows.length,
  114. truncated: rows.length > limit,
  115. };
  116. }
  117. function dispatch(name, args) {
  118. switch (name) {
  119. case 'reports_list_entities': return handleListEntities();
  120. case 'reports_summary': return handleSummary(args || {});
  121. case 'reports_query': return handleQuery(args || {});
  122. default: throw new Error('未知工具: ' + name);
  123. }
  124. }
  125. let buffer = '';
  126. process.stdin.setEncoding('utf8');
  127. process.stdin.on('data', (chunk) => {
  128. buffer += chunk;
  129. let idx;
  130. while ((idx = buffer.indexOf('\n')) !== -1) {
  131. const line = buffer.slice(0, idx).trim();
  132. buffer = buffer.slice(idx + 1);
  133. if (!line) continue;
  134. let msg = null;
  135. try { msg = JSON.parse(line); } catch { continue; }
  136. const { id, method, params } = msg;
  137. if (method === 'initialize') {
  138. jsonOut({
  139. jsonrpc: '2.0', id,
  140. result: {
  141. protocolVersion: (params && params.protocolVersion) || '2024-11-05',
  142. capabilities: { tools: {} },
  143. serverInfo: { name: 'napos-reports', version: '1.0.0' },
  144. },
  145. });
  146. } else if (method === 'notifications/initialized' || (method || '').startsWith('notifications/')) {
  147. // 通知无需回应
  148. } else if (method === 'tools/list') {
  149. jsonOut({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
  150. } else if (method === 'tools/call') {
  151. try {
  152. const data = dispatch(params.name, params.arguments);
  153. jsonOut({
  154. jsonrpc: '2.0', id,
  155. result: { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] },
  156. });
  157. } catch (e) {
  158. jsonOut({
  159. jsonrpc: '2.0', id,
  160. result: { content: [{ type: 'text', text: '错误: ' + (e.message || e) }], isError: true },
  161. });
  162. }
  163. } else if (method === 'ping') {
  164. jsonOut({ jsonrpc: '2.0', id, result: {} });
  165. } else if (id !== undefined) {
  166. jsonOut({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found: ' + method } });
  167. }
  168. }
  169. });
  170. process.stdin.on('end', () => process.exit(0));