| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180 |
- #!/usr/bin/env node
- 'use strict';
- /**
- * 智慧运营中台 · 数据报表 MCP Server(stdio JSON-RPC)
- * ----------------------------------------------------------------------------
- * 把本地数据湖暴露成 MCP 工具,供 WorkBuddy 等 Agent 直接「查数」,
- * 与 napos-mcp(负责「取数」)形成闭环。零第三方依赖。
- *
- * 注册(~/.workbuddy/mcp.json):
- * "napos-reports": { "command": "node", "args": ["<本项目路径>/reports-mcp.js"] }
- *
- * 工具:
- * reports_list_entities — 列出数据湖中的主体(含门店清单)
- * reports_summary — 区间 KPI 汇总
- * reports_query — 查询明细/按门店汇总/按日期汇总
- */
- const naposService = require('./napos-service.js');
- const nlQuery = require('./nl-query.js');
- const TOOLS = [
- {
- name: 'reports_list_entities',
- description: '列出本地数据湖中已沉淀的经营数据主体(商家账号),含每个主体的门店清单、数据覆盖的日期范围',
- inputSchema: { type: 'object', properties: {} },
- },
- {
- name: 'reports_summary',
- description: '查询某主体在指定日期区间的 KPI 汇总(营业额/收入/有效订单/曝光/下单人数/转化率/客单价)',
- inputSchema: {
- type: 'object',
- properties: {
- entity: { type: 'string', description: '主体 key(来自 reports_list_entities)' },
- from: { type: 'string', description: '起始日 YYYYMMDD,缺省=可用区间起点' },
- to: { type: 'string', description: '结束日 YYYYMMDD,缺省=可用区间终点' },
- },
- required: ['entity'],
- },
- },
- {
- name: 'reports_query',
- description: '查询经营数据行。mode=shop 按门店汇总(可排序取 TopN)、mode=date 按日期汇总、mode=detail 日期×门店明细',
- inputSchema: {
- type: 'object',
- properties: {
- entity: { type: 'string', description: '主体 key' },
- from: { type: 'string', description: '起始日 YYYYMMDD' },
- to: { type: 'string', description: '结束日 YYYYMMDD' },
- mode: { type: 'string', enum: ['detail', 'shop', 'date'], description: '缺省 shop' },
- shopIds: { type: 'array', items: { type: 'string' }, description: '可选,限定门店' },
- q: { type: 'string', description: '可选,按门店名称模糊过滤' },
- sort: { type: 'string', description: '排序指标 key,如 grossAmt' },
- sortDir: { type: 'string', enum: ['asc', 'desc'] },
- limit: { type: 'number', description: '返回行数上限,缺省 50' },
- },
- required: ['entity'],
- },
- },
- ];
- function jsonOut(obj) {
- process.stdout.write(JSON.stringify(obj) + '\n');
- }
- function clampRange(entity) {
- const range = nlQuery.lakeDateRange(entity, naposService);
- return range;
- }
- function handleListEntities() {
- const entities = naposService.store.listEntities().map((e) => {
- const range = nlQuery.lakeDateRange(e.key, naposService);
- return {
- key: e.key, name: e.name, shopCount: (e.shops || []).length,
- chains: e.chains || [], shops: e.shops || [],
- dataRange: range,
- };
- });
- return { entities };
- }
- function handleSummary(args) {
- const entity = String(args.entity || '');
- const range = clampRange(entity);
- const from = /^\d{8}$/.test(args.from || '') ? args.from : range.from;
- const to = /^\d{8}$/.test(args.to || '') ? args.to : range.to;
- const daily = naposService.queryReport({ entity, from, to, scope: 'shop' });
- return { entity, from, to, summary: daily.summary };
- }
- function handleQuery(args) {
- const entity = String(args.entity || '');
- const range = clampRange(entity);
- const from = /^\d{8}$/.test(args.from || '') ? args.from : range.from;
- const to = /^\d{8}$/.test(args.to || '') ? args.to : range.to;
- const mode = ['detail', 'shop', 'date'].includes(args.mode) ? args.mode : 'shop';
- const limit = Math.min(Number(args.limit) > 0 ? Number(args.limit) : 50, 500);
- let result;
- if (mode === 'detail') {
- result = naposService.queryReport({ entity, from, to, scope: 'shop', shops: args.shopIds, q: args.q });
- } else {
- result = naposService.aggregateReport({ entity, from, to, group: mode === 'date' ? 'date' : 'shop', shops: args.shopIds });
- if (args.q) result.rows = result.rows.filter((r) => String(r.shopName || '').toLowerCase().includes(String(args.q).toLowerCase()));
- }
- let rows = result.rows || [];
- const sortKey = naposService.REPORT_COLUMNS.some((c) => c.key === args.sort) ? args.sort : null;
- if (sortKey) {
- const dir = args.sortDir === 'asc' ? 1 : -1;
- rows = rows.slice().sort((a, b) => {
- const an = Number(a[sortKey]), bn = Number(b[sortKey]);
- if (Number.isFinite(an) && Number.isFinite(bn)) return (an - bn) * dir;
- return String(a[sortKey] || '').localeCompare(String(b[sortKey] || ''), 'zh-CN') * dir;
- });
- }
- return {
- entity, from, to, mode,
- columns: result.columns.map((c) => ({ key: c.key, label: c.label, type: c.type })),
- summary: result.summary,
- rows: rows.slice(0, limit),
- totalRows: rows.length,
- truncated: rows.length > limit,
- };
- }
- function dispatch(name, args) {
- switch (name) {
- case 'reports_list_entities': return handleListEntities();
- case 'reports_summary': return handleSummary(args || {});
- case 'reports_query': return handleQuery(args || {});
- default: throw new Error('未知工具: ' + name);
- }
- }
- let buffer = '';
- process.stdin.setEncoding('utf8');
- process.stdin.on('data', (chunk) => {
- buffer += chunk;
- let idx;
- while ((idx = buffer.indexOf('\n')) !== -1) {
- const line = buffer.slice(0, idx).trim();
- buffer = buffer.slice(idx + 1);
- if (!line) continue;
- let msg = null;
- try { msg = JSON.parse(line); } catch { continue; }
- const { id, method, params } = msg;
- if (method === 'initialize') {
- jsonOut({
- jsonrpc: '2.0', id,
- result: {
- protocolVersion: (params && params.protocolVersion) || '2024-11-05',
- capabilities: { tools: {} },
- serverInfo: { name: 'napos-reports', version: '1.0.0' },
- },
- });
- } else if (method === 'notifications/initialized' || (method || '').startsWith('notifications/')) {
- // 通知无需回应
- } else if (method === 'tools/list') {
- jsonOut({ jsonrpc: '2.0', id, result: { tools: TOOLS } });
- } else if (method === 'tools/call') {
- try {
- const data = dispatch(params.name, params.arguments);
- jsonOut({
- jsonrpc: '2.0', id,
- result: { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] },
- });
- } catch (e) {
- jsonOut({
- jsonrpc: '2.0', id,
- result: { content: [{ type: 'text', text: '错误: ' + (e.message || e) }], isError: true },
- });
- }
- } else if (method === 'ping') {
- jsonOut({ jsonrpc: '2.0', id, result: {} });
- } else if (id !== undefined) {
- jsonOut({ jsonrpc: '2.0', id, error: { code: -32601, message: 'method not found: ' + method } });
- }
- }
- });
- process.stdin.on('end', () => process.exit(0));
|