From c81faba3915c12d78f2be06b0f6cf06e8402569d Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Tue, 29 Sep 2026 14:11:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20MCP=20=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E6=9F=A5=E8=AF=A2=E8=83=BD=E5=8A=9B=EF=BC=8C=E5=8C=85?= =?UTF-8?q?=E5=90=AB=E5=A4=9A=E8=A1=A8=E5=88=86=E9=A1=B5=E4=B8=8E=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 AgentInfo、CustomerInfo、ProductInfo 等多主数据的游标分页查询 新增应收/应付/开票/收款等财务单据的分页查询 新增库存、发货、项目等业务数据的批量与分页查询 新增 MCP 工具鉴权、异常处理、格式工具类 新增 OmsPurchaseOrderMap 绑定关系查询 新增 MCP 时间区间工具类 --- docx/mcp-data-tools-plan.md | 1934 +++++++++++++++++ docx/mcp-tools-index-ddl.sql | 70 + .../com/ruoyi/common/utils/McpErrorUtils.java | 2 + .../ruoyi/sip/flowable/mapper/TodoMapper.java | 15 + .../java/com/ruoyi/sip/llm/McpController.java | 58 +- .../com/ruoyi/sip/llm/McpQueryTimeout.java | 66 + .../sip/llm/McpQueryTimeoutInterceptor.java | 82 + .../com/ruoyi/sip/llm/McpRateLimiter.java | 108 + .../java/com/ruoyi/sip/llm/McpService.java | 92 +- .../ruoyi/sip/llm/router/ToolEmbedding.java | 188 ++ .../com/ruoyi/sip/llm/router/ToolRouter.java | 327 +++ .../llm/tools/ApprovalListToolProvider.java | 316 +++ .../CrossDomainAggregateToolProvider.java | 674 ++++++ .../FinanceBalanceAggregateToolProvider.java | 617 ++++++ .../llm/tools/FinanceListToolProvider.java | 1468 +++++++++++++ .../FinanceOrderPositionToolProvider.java | 901 ++++++++ .../llm/tools/InventoryFlowToolProvider.java | 776 +++++++ .../tools/InventorySnTraceToolProvider.java | 274 +++ .../InventoryStockAggregateToolProvider.java | 376 ++++ .../llm/tools/MasterDataListToolProvider.java | 950 ++++++++ .../llm/tools/ProjectListToolProvider.java | 1170 ++++++++++ .../PurchaseArrivalAggregateToolProvider.java | 456 ++++ .../llm/tools/PurchaseListToolProvider.java | 841 +++++++ .../llm/tools/WarehouseListToolProvider.java | 950 ++++++++ .../support/AbstractMcpQueryToolProvider.java | 184 ++ .../ruoyi/sip/llm/tools/support/McpAuth.java | 63 + .../sip/llm/tools/support/McpCursor.java | 119 + .../sip/llm/tools/support/McpFormat.java | 46 + .../ruoyi/sip/llm/tools/support/McpPages.java | 121 ++ .../sip/llm/tools/support/McpParams.java | 324 +++ .../sip/llm/tools/support/McpSubPage.java | 284 +++ .../sip/llm/tools/support/McpTables.java | 61 + .../sip/llm/tools/support/McpTimeRange.java | 33 + .../llm/tools/support/McpToolException.java | 46 + .../com/ruoyi/sip/mapper/AgentInfoMapper.java | 8 + .../mapper/CrossDomainAggregateMapper.java | 109 + .../ruoyi/sip/mapper/CustomerInfoMapper.java | 8 + .../ruoyi/sip/mapper/DeliveryListMapper.java | 5 + .../sip/mapper/InventoryDeliveryMapper.java | 17 + .../ruoyi/sip/mapper/InventoryInfoMapper.java | 68 + .../mapper/InventoryOuterDetailMapper.java | 6 + .../sip/mapper/InventoryOuterMapper.java | 13 + .../sip/mapper/OmsCompanyInfoMapper.java | 8 + .../sip/mapper/OmsFinAttachmentMapper.java | 31 + .../sip/mapper/OmsFinanceChargeMapper.java | 31 + .../OmsInventoryDeliveryDetailMapper.java | 6 + .../sip/mapper/OmsInventoryInnerMapper.java | 13 + .../sip/mapper/OmsInvoiceBillMapper.java | 26 + .../sip/mapper/OmsPayableBillMapper.java | 55 + .../mapper/OmsPayableTicketPlanMapper.java | 6 + .../sip/mapper/OmsPaymentBillMapper.java | 28 + .../mapper/OmsPurchaseOrderHistoryMapper.java | 35 + .../sip/mapper/OmsPurchaseOrderMapMapper.java | 28 + .../sip/mapper/OmsPurchaseOrderMapper.java | 73 + .../sip/mapper/OmsReceiptBillMapper.java | 28 + .../sip/mapper/OmsReceivableBillMapper.java | 66 + .../OmsReceivableInvoicePlanMapper.java | 6 + .../OmsReceivableReceiptPlanMapper.java | 6 + .../ruoyi/sip/mapper/OmsStockInfoMapper.java | 13 + .../ruoyi/sip/mapper/OmsTicketBillMapper.java | 26 + .../ruoyi/sip/mapper/OrderDeliveryMapper.java | 14 + .../com/ruoyi/sip/mapper/OrderInfoMapper.java | 23 + .../ruoyi/sip/mapper/PartnerInfoMapper.java | 8 + .../ruoyi/sip/mapper/ProductInfoMapper.java | 8 + .../ruoyi/sip/mapper/ProjectInfoMapper.java | 8 + .../mapper/ProjectOrderInfoRecallMapper.java | 12 + .../sip/mapper/ProjectPocInfoMapper.java | 14 + .../sip/mapper/ProjectProductInfoMapper.java | 8 + .../com/ruoyi/sip/mapper/QuotationMapper.java | 8 + .../ruoyi/sip/mapper/VendorInfoMapper.java | 9 + .../OmsReceiptBill/OmsReceiptBillMapper.xml | 60 + .../mapper/Quotation/QuotationMapper.xml | 29 + .../mapper/finance/OmsFinAttachmentMapper.xml | 46 + .../mapper/finance/OmsFinanceChargeMapper.xml | 54 + .../mapper/finance/OmsPayableBillMapper.xml | 135 ++ .../mapper/finance/OmsPaymentBillMapper.xml | 62 + .../main/resources/mapper/flow/TodoMapper.xml | 48 + .../inventory/InventoryDeliveryMapper.xml | 73 + .../mapper/inventory/InventoryInfoMapper.xml | 179 ++ .../inventory/InventoryOuterDetailMapper.xml | 15 + .../mapper/inventory/InventoryOuterMapper.xml | 56 + .../OmsInventoryDeliveryDetailMapper.xml | 9 + .../inventory/OmsInventoryInnerMapper.xml | 46 + .../mapper/manage/CustomerInfoMapper.xml | 22 + .../mapper/manage/DeliveryListMapper.xml | 10 + .../mapper/manage/OrderDeliveryMapper.xml | 80 +- .../mapper/manage/OrderInfoMapper.xml | 95 +- .../mapper/sip/CrossDomainAggregateMapper.xml | 299 +++ .../mapper/sip/OmsCompanyInfoMapper.xml | 21 + .../mapper/sip/OmsInvoiceBillMapper.xml | 55 + .../mapper/sip/OmsPayableTicketPlanMapper.xml | 11 + .../sip/OmsPurchaseOrderHistoryMapper.xml | 39 + .../mapper/sip/OmsPurchaseOrderMapMapper.xml | 39 + .../mapper/sip/OmsPurchaseOrderMapper.xml | 152 ++ .../mapper/sip/OmsReceivableBillMapper.xml | 161 ++ .../sip/OmsReceivableInvoicePlanMapper.xml | 11 + .../sip/OmsReceivableReceiptPlanMapper.xml | 11 + .../mapper/sip/OmsStockInfoMapper.xml | 45 + .../mapper/sip/OmsTicketBillMapper.xml | 57 + .../mapper/sip/ProjectInfoMapper.xml | 40 + .../sip/ProjectOrderInfoRecallMapper.xml | 33 + .../mapper/sip/ProjectPocInfoMapper.xml | 27 + .../mapper/sip/ProjectProductInfoMapper.xml | 27 + .../mapper/system/AgentInfoMapper.xml | 22 + .../mapper/system/PartnerInfoMapper.xml | 26 + .../mapper/system/ProductInfoMapper.xml | 28 +- .../mapper/system/VendorInfoMapper.xml | 26 + .../src/main/resources/mcp-tool-router.yml | 140 ++ 108 files changed, 17085 insertions(+), 27 deletions(-) create mode 100644 docx/mcp-data-tools-plan.md create mode 100644 docx/mcp-tools-index-ddl.sql create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpQueryTimeout.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpQueryTimeoutInterceptor.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpRateLimiter.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/router/ToolEmbedding.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/router/ToolRouter.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/ApprovalListToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/CrossDomainAggregateToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceBalanceAggregateToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceListToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceOrderPositionToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventoryFlowToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventorySnTraceToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventoryStockAggregateToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/MasterDataListToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/ProjectListToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/PurchaseArrivalAggregateToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/PurchaseListToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/WarehouseListToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/AbstractMcpQueryToolProvider.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpAuth.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpCursor.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpFormat.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpPages.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpParams.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpSubPage.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpTables.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpTimeRange.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpToolException.java create mode 100644 ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/CrossDomainAggregateMapper.java create mode 100644 ruoyi-sip/src/main/resources/mapper/sip/CrossDomainAggregateMapper.xml create mode 100644 ruoyi-sip/src/main/resources/mcp-tool-router.yml diff --git a/docx/mcp-data-tools-plan.md b/docx/mcp-data-tools-plan.md new file mode 100644 index 00000000..50d89789 --- /dev/null +++ b/docx/mcp-data-tools-plan.md @@ -0,0 +1,1934 @@ +# 仓储 / 采购 / 财务 / 项目 数据类 MCP 工具方案(v15 · 已实现并端到端验证) + +> 状态:**已实现(13 个新工具,共 15 个),编译通过 + 认证态端到端 34/34 用例通过** +> 适用范围:`ruoyi-sip` 模块 `com.ruoyi.sip.llm.tools` 下的只读查询工具 +> 关联文档:[prompt.md](./prompt.md)(MCP Server 原始规格)、[mcp-tools-index-ddl.sql](./mcp-tools-index-ddl.sql)(索引核对与补建脚本) + +## 最终方案速览(TL;DR) + +| 项 | 结论 | +|---|---| +| 工具数 | **13 个** = 标识符点查 3 + 聚合 3 + 列表/范围 5 + 扩展 2(`project_list`、`cross_domain_aggregate`) | +| 覆盖 | 仓储 / 采购 / 财务 / **项目(含 POC、进度、报价)** 四域 + **manage 域合同与合同明细** + **审批待办与已办**,含基础列表能力、**签收**、**撤回历史**、时间维度、计收、备货、主数据批量翻译(**含仓库与己方公司主体**)、**受限跨域透视(客户×产品×月 等)**、**账龄分桶**、**财务历史时点余额** | +| 性能 | 过滤键实测对齐索引;游标分页不重不漏;聚合默认 `mode=SUMMARY` 一次算完;类型对齐防索引退化;查询超时 + 限流;**RAG 路由控制 schema token** | +| 统计维度 | 产品 / 仓库 / 订单 / 采购单 / 供应商 / 合作伙伴 / 代表处 / 项目 / 状态 / 时间(月、季)/ **账龄分桶** / 无维度(`OWNER` 已移除,见 15.2) | +| 索引 | 必加 2(P0)+ 建议 3(P1,含覆盖索引)+ 条件加 23(P2 编号 26 条,其中 3 条已作废,按表规模触发) | +| 不改动 | **不改**数据库字段、**不改**任何业务写入逻辑、**不改**现有 2 个工具与 MCP 框架(`McpService`/`McpToolRegistry`/`ToolInitializer`/`McpController`) | +| 明确不做 | 写操作、**财务附件的文件内容(仅元数据)**、财务运营报表物化表(改用实时聚合 + 时点重算)、审计日志、报表导出文件、任意 SQL / 无白名单的任意维度 | +| 业务口径 | 已**配置化落地**(在库口径、价格含税口径),业务跑一次校验 SQL 后改配置即可,不阻塞编码(见 16.3) | +| 工程项 | 索引 DDL 执行方案(16.4)、时间区间策略(16.5)、从库路由(16.6)、**RAG 工具路由(16.7,13 个工具下为必须)** | + +--- + +## 一、目标与范围 + +### 1.1 目标 +在现有 MCP Server(已有 `project_order_info`、`product_info` 两个工具)基础上,新增覆盖**仓储、采购、财务**三个数据域的只读查询工具,满足: + +1. 覆盖高频业务问题,且**一次调用尽量拿全**,减少 Agent 轮次; +2. 单次调用**快**:过滤键全部命中索引,不产生 N+1 与全表扫描; +3. **数据不缺失**:范围类查询支持游标分页,由 Agent 自行翻页直至取完; +4. **不越权**:与页面入口的数据权限、菜单权限对齐; +5. **不改数据库字段、不改业务写入逻辑**:仅新增只读 SQL 与索引。 + +### 1.2 范围外(明确不做) +- 任何写操作(新增/修改/删除/审批/撤回/红冲); +- 基于 `oms_finance_operate_report` 的工具(原因见 2.1); +- 采购/财务**审批待办与已办**(`bu_todo` 相关,属流程域)、财务附件内容; +- 改造现有两个工具的行为。 + +--- + +## 二、前置验证结论(实测,非推断) + +验证环境:`oms_test`(`192.168.124.202:3307`),MySQL **8.0.37**。 + +### 2.1 财务物化表不可用 —— 已否决原设计 + +| 项 | 实测结论 | +|---|---| +| 刷新入口 | 全仓仅 `GET /test`,且带 `@Anonymous`(免鉴权),属临时手工触发口 | +| 定时任务 | **无**(全仓无 `@Scheduled` / quartz job 引用 `statisticsReport()`) | +| 刷新方式 | 增量水位制:以 `maxDataDate()` 为 watermark(`OmsFinanceOperateReportServiceImpl#statisticsReport`) | +| 数据现状 | `oms_finance_operate_report` 与 `oms_finance_operate_report_detail` **均为 0 行** | + +**结论**:该表是人工触发、且漏触发会永久偏差的物化表,**不能作为 MCP 财务数据源**。财务工具一律走实时单据表聚合。 + +### 2.2 数据量级(`oms_test`,仅作参考) + +| 表 | 行数 | 表 | 行数 | +|---|---|---|---| +| `oms_inventory_info` | **64,823**(精确) | `oms_inventory_delivery` | 756 | +| `oms_inventory_delivery_detail` | 49,015(估) | `oms_inventory_outer_detail` | 750 | +| `oms_purchase_order_item` | 951 | `oms_inventory_outer` | 734 | +| `oms_purchase_order` | 941 | `oms_receivable_bill` | 627 | +| `oms_inventory_inner` | 580 | `oms_payable_bill` | 583 | +| `oms_stock_info` | 526 | `oms_payment_bill` | 64 | +| `oms_ticket_bill` | 21 | `oms_warehouse_info` | 14 | +| `oms_receipt_bill` | 1 | `oms_invoice_bill` | 0 | + +**结论**:按**百万级**保守设计,约束集中在 `oms_inventory_info` 与 `oms_inventory_delivery_detail` 两张表。 + +### 2.3 `oms_inventory_info` 关键事实 + +- 主键 `id` 为 **int**(非 bigint),游标可安全编码; +- `inventory_status` 取值实测仅 `'0'`(在库 15,532) / `'1'`(出库 49,291); +- `tax_rate` 实测**存在 NULL**,未税换算需兜底为 0; +- 表结构含 `product_code / product_sn / inner_code / outer_code / warehouse_id / inner_price / outer_price / order_code / payable_bill_code`。 + +### 2.4 列存在性验证(用于索引 DDL 可行性) + +| 表 | 关键列存在情况 | +|---|---| +| `oms_inventory_outer` | 有 `outer_code`、`order_code`;**无 `warehouse_id`** | +| `oms_inventory_outer_detail` | 有 `outer_code`、`warehouse_id` | +| `oms_inventory_inner` | 有 `inner_code`、`order_code`、`purchase_no` | +| `oms_inventory_delivery` | 有 `outer_code`;**无 `order_code`、无 `product_code`**(均来自 join) | +| `oms_inventory_delivery_detail` | 有 `delivery_id`、`product_sn` | +| `oms_purchase_order` | 有 `purchase_no`、`vendor_id`、`status`、`purchase_date` | +| `oms_purchase_order_item` | 有 `purchase_id`、`product_code`、`inner_quantity` | +| `oms_purchase_order_map` | 有 `order_id`、`purchase_id`、`product_code`、`bind_num` | + +> **重要**:`oms_inventory_delivery.order_code` 不是本表列,**不能对其建索引**;按订单号查发货单必须经 `oms_inventory_outer.order_code` 关联(该列已有索引)。 + +### 2.5 现有索引实测(与本方案相关的部分) + +| 表 | 现有索引 | 是否满足需求 | +|---|---|---| +| `oms_inventory_info` | `product_sn`(UNIQUE)、`inner_code`、`outer_code`、`product_code` | ✅ 满足;`warehouse_id` 无索引 | +| `oms_inventory_inner` | `inner_code`(UNIQUE) | ⚠️ `order_code` 无索引 | +| `oms_inventory_delivery` | `outer_code` | ✅ | +| `oms_inventory_delivery_detail` | `delivery_id`、`product_sn` | ✅ | +| **`oms_inventory_outer`** | 仅 `order_code`,**`outer_code` 无索引** | ❌ **需新增** | +| **`oms_inventory_outer_detail`** | 仅主键 | ❌ **需新增** | +| `oms_receivable_bill` | `receivable_bill_code`(UNIQUE)、`order_code`、`inventory_code` | ✅ | +| `oms_payable_bill` | `payable_bill_code`(UNIQUE)、`order_code`、`inventory_code` | ✅ | +| `oms_receipt_bill` / `oms_payment_bill` | 单号 UNIQUE | ✅ | +| `oms_invoice_bill` / `oms_ticket_bill` | 单号 UNIQUE | ✅ | +| 收/付/开票/收票 计划与明细表 | `receivable_bill_id`、`payable_bill_id`、`*_plan_id`、`*_bill_code` 均有索引 | ✅ | +| `oms_purchase_order` | 仅 `purchase_no`(UNIQUE) | ⚠️ `vendor_id`/`status`/时间无索引 | +| `oms_purchase_order_item` | `purchase_id` | ✅ | +| `oms_purchase_order_map` | 仅主键 | ⚠️ `order_id`/`purchase_id` 无索引 | +| `oms_stock_info` | 仅主键 | ⚠️ 未纳入本方案 | +| 全部表 | **无任何时间列索引** | ❌ 时间范围不作主过滤键 | + +**补充**:代码中 `oms_receivable_write_off_detail` / `oms_payable_write_off_detail` 两个 domain 实际不参与查询(库中无此表),核销真实链路为 `receipt_detail.write_off_id` / `payment_detail.write_off_id`;这两列**无索引**,因此工具**不支持按核销单号反查**(已规避)。 + +--- + +## 三、设计原则 + +1. **面向问题,而非面向表**:一个工具回答一类业务问题,避免"一表一工具"导致工具数膨胀、Agent 选错率与 schema token 上升。 +2. **点查不翻页、范围必翻页**:入参为单号/SN 精确值时结果有界,一次返回;入参为条件/聚合时强制游标分页。 +3. **过滤键必须命中索引**:每个工具的必填入口键都对应一个实测存在的索引;无索引的维度不开放为入口。 +4. **消除 N+1**:一律"先查主表取键集合 → `in (...)` 批量查子表 → 内存分组"。 +5. **权限前置**:`handle` 第一行做菜单权限校验,再按页面口径回填行级权限过滤。 +6. **只读**:不开启事务、不写库、不触发业务副作用。 +7. **口径显式**:聚合口径、字段含义写入 `metadata`,避免 Agent 把口径差异误判为"数据缺失"。 + +### 3.8 与现有 MCP 工具的一致性基线(实现前必须先明确) + +本方案**不是另起一套,而是完全沿用现有工具的范式**。现有两个工具:[ProjectOrderInfoToolProvider.java](../ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/ProjectOrderInfoToolProvider.java)、[ProductInfoToolProvider.java](../ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/ProductInfoToolProvider.java),公共基类 [AbstractMcpToolProvider.java](../ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/AbstractMcpToolProvider.java)。 + +| 维度 | 现有实现 | 本方案 | +|---|---|---| +| 注册方式 | `@Component` + `extends AbstractMcpToolProvider`;[ToolInitializer.java](../ruoyi-sip/src/main/java/com/ruoyi/sip/llm/ToolInitializer.java) 启动时扫描 `List` 自动注册 | **一致**:只新增工具类,**不改** `ToolInitializer` / `McpToolRegistry` / `McpService` / `McpController` | +| 工具命名 | `snake_case`(`project_order_info`、`product_info`) | 一致 | +| 工具描述 | `getToolDescription()` 返回中文一句话 | 一致;分页工具在描述末尾追加 Agent 翻页指令(见 5.6) | +| 入参 Schema | `objectSchema(properties, required...)` + `stringProperty("中文说明")`,`additionalProperties=false` | 一致;基类扩展 `integerProperty` / `arrayProperty` / `enumProperty`(中文说明) | +| 返回结构 | `response(metadata, query, data)`;`data.total` + `data.items`;items 字段为**英文驼峰** | 一致;仅新增 `data.page_info` | +| 字段注释 | `metadata.item_fields` 中文键值对(见 `buildItemFieldMetadata()`) | 一致;每个工具必须提供完整字段字典(**附录 A**) | +| 字典翻译 | `DictUtils.getDictLabel(dictType, code)` | 一致;枚举类字段用 `XxxEnum#getValue()`,两类来源在附录 A.13 区分 | +| 批量加载 | 先查主表取编码集合 → `in (...)` 批量 → `groupingBy` 组装(`ProjectOrderInfoToolProvider#loadShipmentSummaryMap` 即此写法) | 一致 | +| 日期格式 | `DateUtil.format(value, "yyyy-MM-dd")` / `DateUtils.YYYY_MM_DD_HH_MM_SS` | 一致 | +| 空值处理 | 固定字段集全量输出(空值输出 null 或空串) | 一致(**不采用**"空值字段不输出"的裁剪方式,避免模型误判字段缺失) | +| 错误 | `throw new RuntimeException("英文消息")`,由 `McpController#toMcpError` 映射 | 一致,不新增错误类型 | + +### 3.9 对现有写法的有意偏离(共 4 处,均有理由) + +| # | 偏离 | 理由 | +|---|---|---| +| 1 | 新增分页(`data.page_info` + 游标) | 现有工具无分页能力,`project_order_info` 按时间范围查询可能返回超大结果集。**`data.total` 键予以保留以兼容现有约定**:默认 `null`,`include_total=true` 时填真实值 | +| 2 | 新增菜单/行级权限校验 | 现有工具依赖 Service 层 `@DataScope`;但仓储与采购的**行级权限在 Controller 层**(`IInventoryAuthService`),直调 Service 会越权,必须显式对齐 | +| 3 | 不使用动态列 | 现有 `project_order_info` 用 `softwareCode1/2/3…` + `dynamic_field_rules` 表达明细行,对模型不友好;新工具统一用**嵌套数组**(`items[].subItems[]`) | +| 4 | 新增 `metadata.dict_fields` / `aggregation_rule` | 用于向 Agent 声明字典取值来源与聚合口径,减少"口径误解被当成数据缺失" | + +--- + +## 四、工具清单(13 个,最终版) + +> 划分原则:**① 标识符点查**(有界,一次拿全,不分页)→ **② 聚合**(默认 `mode=SUMMARY` 一次算完)→ **③ 列表/范围查询**(游标分页)。 +> **版本演进**:v6 拟定的 `purchase_order_detail`、`finance_bill_detail` 已**合并**进 `purchase_list` / `finance_list`(用 `code_list` + `include_detail=true` 表达点查),工具数由 11 收敛为 9;v8 纳入主数据工具 `master_data_list`(`warehouse_list` 扩到 7 个 entity)→ 10 个;v9 纳入 `project_list` 与 `cross_domain_aggregate` → 12 个;**v13 纳入 `approval_list`(审批待办/已办)→ 13 个**。 + +### 4.1 A 类:标识符点查(不分页)3 个 + +| # | 工具名 | 必填标识符 | 返回 | 数据链路 | 索引支撑 | +|---|---|---|---|---|---| +| 1 | `inventory_sn_trace` | `product_sn_list`(≤50) / `inner_code_list`(≤20) / `outer_code_list`(≤20) 三选一 | SN 明细:库存状态、入库价/出库价(**含税口径待确认**,见 15.8)、税率、所属入库单/出库单/合同号、仓库 | `oms_inventory_info` 直查 | `product_sn`(UK)、`inner_code`、`outer_code` ✅ | +| 2 | `inventory_flow` | `outer_code` 或 `order_code` | **6 分组**:`inner`、`outer`、`outerDetails`、`deliveries`、`snDetails`、**`stock`(新增·备货状态)** | 入库单 + 出库单(±明细) + 发货单(±明细) + SN + **`oms_stock_info`** | 依赖 P0-1/P0-2;`stock` 按 `order_code`(小表) | +| 3 | `finance_order_position` | `order_code` | **14 分组**:receivable、receiptPlans、invoicePlans、receipts、receiptWriteOffs、invoices、payable、paymentPlans、ticketPlans、payments、tickets、paymentWriteOffs、ticketWriteOffs、**`charge`(新增·计收)** | 应收/应付 + 计划/明细/核销 + 收付票单 + **`oms_finance_charge`** | 各单号 UNIQUE、`order_code`、**`oms_finance_charge.order_code`(UK)** ✅ | + +> 子列表(如某订单的收款单条数很多)超单页上限时,返回该子列表的 `sub_cursor` 局部游标,仅供该子列表翻页。 + +### 4.2 B 类:聚合(`mode=SUMMARY` 默认,`LIST` 可选)3 个 + +| # | 工具名 | `group_by` 可选值 | 度量 | 数据来源 | +|---|---|---|---|---| +| 4 | `inventory_stock_aggregate` | `PRODUCT` / `WAREHOUSE` / `PRODUCT_WAREHOUSE` / **`STATUS`** / **`TIME_MONTH`** / **`TIME_QUARTER`** / `NONE` | `in_stock_qty`、`out_stock_qty`、`inner_amount`、`outer_amount` | `oms_inventory_info` | +| 5 | `purchase_arrival_aggregate` | `ORDER`(采购单号) / **`VENDOR`** / **`PRODUCT`** / **`STATUS`** / **`TIME_MONTH`** / `NONE` | `purchase_qty`、`inner_qty`、`pending_qty`、`arrival_rate`、**`arrival_delay_days`**、`amount_total`、`tax_total` | `oms_purchase_order` ⋈ `oms_purchase_order_item` | +| 6 | `finance_balance_aggregate` | `ORDER` / **`PARTNER`** / **`STATUS`** / **`TIME_MONTH`** / `NONE` | 5 流余额(应收/收款/开票/应付/付款/收票)+ **`overdue_days`** | `oms_receivable_bill` / `oms_payable_bill` | + +> 分页粒度选择理由:`oms_inventory_info` 索引顺序为 `(product_code, id)`,以产品为页边界可沿用同一条索引;按"产品+仓库"组合分页需跨索引排序,成本更高。 +> **重要**:聚合工具必须同时具备"汇总模式"与"明细模式",否则全局统计只能靠 Agent 翻页累加,会退化为多次全表扫描且可能被 `max_pages` 截断。详见 **第十三章**。 + +### 4.3 C 类:列表 / 范围查询(游标分页)5 个 + +| # | 工具名 | `entity` 可选值 | 过滤维度 | 明细 | +|---|---|---|---|---| +| 7 | `warehouse_list` | `INNER` / `OUTER` / `DELIVERY` / **`ORDER_DELIVERY`(manage 域发货单,含签收)** / `STOCK` / `SN` / **`RECALL`(撤回历史)** | 单号、状态、时间范围、仓库、产品、合同号 | `include_detail=true` 返回明细(来源见下表) | +| 8 | `purchase_list` | `ORDER` / `ITEM` / `HISTORY` / `ORDER_BIND` | 单号、状态、审批/确认状态、时间范围、供应商、产品 | `include_detail=true` 返回明细行 | +| 9 | `finance_list` | `RECEIVABLE` / `PAYABLE` / `RECEIPT` / `PAYMENT` / `INVOICE` / `TICKET` / `CHARGE` / **`ATTACHMENT`(v13 新增,仅元数据)** | 单号、状态、审批状态、时间范围、合作伙伴 | `include_detail=true` 返回计划/明细/核销 | +| 10 | **`master_data_list`** | `PARTNER` / `CUSTOMER` / `AGENT` / `VENDOR` / `PRODUCT` / `USER` / **`WAREHOUSE`(v10 新增)** / **`COMPANY`(v10 新增)** | `code_list`(**批量,≤200**)、名称模糊、类型/状态 | 无明细(主数据) | +| 11 | **`approval_list`(v13 新增)** | **`TODO`(待办)/ `DONE`(已办)** | 审批人(默认当前登录人)、`process_key`、业务主键 `business_key`、时间范围 | 无明细(`TODO` 返回当前节点;`DONE` 返回审批意见与结果) | + +> **`approval_list` 的价值**:回答"我还有哪些单要审""现在卡在谁那儿""**为什么被驳回**""审批耗了多久"。数据来自 `bu_todo`(61) 与 `bu_todo_completed`(5,876) 两张**业务表**,**不需要读 Flowable 的 `act_*`**(实测两表已冗余 `business_key`/`process_key`/`task_name`/`approve_user_name`/`apply_time`/`approve_opinion`/`approve_status`)。已覆盖流程:`order_approve_online/offline`、`purchase_order_online`、`finance_payment`、`fianance_ticket`、`order_reback`、`outer_reback`。 + +> `master_data_list` 的作用:解决"Agent 拿到 `partner_code`/`vendor_code`/`product_code` 却查不到名称"的关联实体缺口(原 `purchase_list(entity=VENDOR)` 已并入本工具的 `VENDOR`,避免重复)。 + +**列表工具的护栏**: + +1. `entity=SN` 走**大表**(`oms_inventory_info` 6.5 万行)→ 必须给出 `product_sn_list` / `inner_code_list` / `outer_code_list` / `product_code_list` 之一,否则返回 `INVALID_PARAMS`; +2. 其余 entity 所在表当前均 < 1000 行 → 允许状态/时间/合作伙伴维度直接过滤(依据 14.4 的**表规模分级**规则),并在 `metadata` 注明"该表当前规模小,增长后需补索引"(见 P2); +3. `include_detail` 默认 `false`(列表只要表头,省 token);需要完整明细树时置 `true`; +4. 单据点查用 `code_list` + `include_detail=true`。 + +**各 entity 的明细来源(v10 修正:入库明细以实测数据为准)** + +| entity | 明细子表 | 说明 | +|---|---|---| +| `INNER` | **`oms_inventory_info`(按 `inner_code`)** | **实测:入库明细实际落在 SN 表**——`oms_inventory_info` 中 579/580 张入库单有 SN 明细(样例 `R-20250917001` 有 500 条 SN)。`oms_inventory_inner_detail` 表虽定义存在,但**实测仅 1 行(未启用)**,不采用 | +| `OUTER` | `oms_inventory_outer_detail` | 按仓库拆分的出库数量 | +| `DELIVERY` | `oms_inventory_delivery_detail` | 仓储域发货 SN 明细 | +| `ORDER_DELIVERY` | **`delivery_list`** | manage 域发货 SN 明细(`delivery_id` + `serial_number`),**必须过滤 `deleted_at is null`**。另:本 entity 的 `orderId` 指向 **manage 域合同表 `order_info`**(实测 355/355 全部命中),**必须 join `order_info` 才能输出合同编号/客户/代理商**(且需 `trim()`,见 15.14) | +| `STOCK` / `SN` / `RECALL` | 无 | — | + +> 另有 `oms_inventory_inner_maintenance`(维保入库,当前 0 行)**不纳入**本轮范围。 + +> ⚠️ **口径提醒(v10 实测发现)**:`oms_inventory_info.order_code` 在 SN **未出库时为空**——实测 64,823 行中 15,532 行为空,且该数量**恰好等于在库数量**(`inventory_status='0'`)。因此**不能用 `inventory_info.order_code` 反查"在库货物属于哪个订单"**,必须经 `inner_code` → `oms_inventory_inner.order_code`。 + +### 4.4 D 类:扩展能力(v9 新增)2 个 + +| # | 工具名 | 用途 | 详见 | +|---|---|---|---| +| 12 | **`project_list`** | 项目 / 项目产品 / 项目进度 / POC / 报价单 / **manage 域合同与合同明细(v11 新增)**(entity 参数化) | **16.1**(含字段字典与索引) | +| 13 | **`cross_domain_aggregate`** | **受限跨域透视**:"客户 × 产品 × 月"等组合分析(维度/度量白名单 + 单链路约束) | **16.2**(含白名单、链路与护栏) | + +> 这两个工具的字段字典直接写在第十六章对应小节(避免与附录 A 重复)。附录 A 收录其余 11 个工具的字段字典(含 v13 新增的 `approval_list`,见 A.15)。 + +### 4.5 统一响应契约(对齐现有工具,仅新增 `page_info`) + +完全沿用现有 `AbstractMcpToolProvider#response(metadata, query, data)`;字段注释一律放 `metadata.item_fields`(与现有工具写法一致): + +``` +{ + "metadata": { + "tool": "...", // 工具名(与现有工具一致) + "description": "...", // 中文说明 + "query_fields": { "<入参>": "<中文注释>" }, // 入参字段注释 + "data_fields": { "total": "...", "items": "...", "page_info": "..." }, + "item_fields": { "<返回字段>": "<中文注释>" }, // ★ 字段注释(附录 A 为权威来源) + "dict_fields": { "<返回字段>": "<字典类型或枚举类>" }, + "aggregation_rule": { ... } // 仅聚合类工具提供 + }, + "query": { ...规范化后的入参回显... }, + "data": { "total": null, "items": [ ... ], "page_info": { ... } } +} +``` + +- `data.total`:**保留现有约定**;默认 `null`(不额外 count),`include_total=true` 时填真实值,超 `count_cap` 时并置 `total_count_capped=true`; +- `metadata.item_fields` **必填**:每个工具都要有完整中文字段注释(对齐现有 `buildItemFieldMetadata()` 的做法,内容取附录 A); +- `metadata.dict_fields`:声明哪些字段做了翻译、来源是字典表还是枚举类,便于 Agent 理解取值; +- 错误契约:沿用 `McpErrorUtils` 的 `INVALID_PARAMS` / `METHOD_NOT_FOUND` / `AUTH_ERROR` / `INTERNAL_ERROR`,**不新增错误码**。 + +--- + +## 五、分页协议(核心) + +### 5.1 为什么用游标而非页码 + +| 方案 | 问题 | +|---|---| +| 只返回前 N 条 + `truncated` 标记 | Agent 无法继续取,**数据缺失**(且无补救手段) | +| `OFFSET n LIMIT m` 页码分页 | 翻页期间数据增删会导致**漏行或重复行**;深分页性能随 offset 线性退化 | +| **游标(keyset)分页** | 按不可变排序键推进,**不重不漏**,深分页性能恒定 ✅ | + +### 5.2 入参(通用) + +| 参数 | 默认 | 说明 | +|---|---|---| +| `page_size` | 20 | 聚合类上限 **200**,明细类上限 **100** | +| `cursor` | — | 上轮返回的 `next_cursor`,首轮不传;与 `page` 互斥 | +| `page` | — | 兼容用页码(内部转 OFFSET,**仅在数据不变时稳定**,不推荐) | +| `include_total` | `false` | 置 `true` 时执行 count,受 `count_cap=50000` 限制 | + +### 5.3 返回(`data.page_info`) + +```json +{ + "returned": 20, + "page_size": 20, + "has_more": true, + "next_cursor": "", + "sort_by": "outer_code,id", + "page_no": 3, + "total_count": null, + "total_count_capped": false, + "truncated_by_bytes": false +} +``` + +### 5.4 游标编码(无状态、可校验) + +`base64url({ "v":1, "t":"tool_name", "k":[排序键值...], "f":"filterHash", "p":pageNo })` + +- **`k`**:最后一行/组的排序键值(游标推进依据); +- **`f`**:入参过滤条件 + **当前用户权限指纹**(授权仓库集合 / 供应商集合)的哈希。校验不一致直接报错"cursor 与当前过滤条件或权限不匹配,请从第一页重新开始",防止串用游标导致漏数; +- **`p`**:页码,用于 `max_pages` 保护(聚合默认 20 页、明细默认 50 页,超限提示收窄条件); +- 每页以 `limit+1` 探测 `has_more`,**不额外执行 count**; +- 单页响应超 ~200KB 时自动下调 `page_size` 并置 `truncated_by_bytes=true`(换页而非丢数据)。 + +### 5.5 各工具固定排序键 + +| 工具 | 排序键(`sort_by`) | +|---|---| +| `inventory_sn_trace` | 随入参:`product_sn,id` / `inner_code,id` / `outer_code,id` | +| `inventory_stock_aggregate` | 随 `group_by`:`product_code` / `warehouse_id,product_code` / `inventory_status,product_code` / `time_bucket,product_code` | +| `purchase_arrival_aggregate` | `purchase_no` / `vendor_id,purchase_no` / `product_code,purchase_no` / `time_bucket,purchase_no` | +| `finance_balance_aggregate` | `order_code` / `partner_code,order_code` / `time_bucket,order_code` | +| `warehouse_list` | `inner_code,id`(INNER) / `outer_code,id`(OUTER) / `outer_code,id`(DELIVERY) / **`delivery_code,id`(ORDER_DELIVERY)** / `order_code,id`(STOCK) / `product_sn,id`(SN) / **`order_code,version_code`(RECALL)** | +| `purchase_list` | `purchase_no,id`(ORDER/ITEM) / `order_id,purchase_id`(ORDER_BIND) / `purchase_no,id`(HISTORY) | +| `finance_list` | `,id` / `order_code,id`(CHARGE) | +| **`master_data_list`** | 各自主键:`partner_code` / `customer_code` / `agent_code` / `vendor_code` / `product_code` / `user_id` / `warehouse_code` / `company_code` | +| **`approval_list`(v13)** | `apply_time desc, id`(TODO)/ `approve_time desc, id`(DONE) | + +> 时间维度统一以 **`time_bucket`** 作为分组别名(`TIME_MONTH` → `2026-01`,`TIME_QUARTER` → `2026Q1`),实现上用**区间下推**而非列函数(见 14.4 技术注意)。 + +SQL 形态(MySQL 8 支持行构造器,但**为索引友好改用显式比较**): + +```sql +-- 单值过滤:纯索引游标(推荐路径) +where outer_code = :oc and id > :lastId order by id limit :n+1; +-- 多值 IN:跨多段索引区间,会产生 filesort(可接受代价,见 5.6) +where product_code in (:list) order by product_code, id limit :n+1; +``` + +### 5.6 Agent 侧循环指令(写入 `description`) + +> 分页查询工具。若返回 `page_info.has_more` 为 `true`,**必须**携带 `page_info.next_cursor` 再次调用本工具,重复直到 `has_more` 为 `false`,否则结果不完整。不要用 `page` 参数替代 `cursor`。 + +### 5.7 已知局限(诚实声明) + +1. **多值 IN + 游标会产生 filesort**:故明细类 `page_size ≤ 100`、IN 长度 ≤ 50,并优先引导 Agent 使用单号/SN 精确查询。 +2. **不提供快照一致性**:keyset 保证不重不漏,但翻页期间新增数据可能出现在后续页。严格快照需 `and update_time <= :as_of`,而**全部表的时间列无索引**,代价过高,本版不做。 +3. **聚合跨页口径**:聚合结果随上游数据变化,`page_info` 中的 `page_no` 仅代表游标推进次序,不代表固定快照。 + +> **补充**:全局统计**不应由 Agent 翻页累加**(会退化为多次全表扫描且可能被 `max_pages` 截断),应使用聚合工具的 `mode=SUMMARY` 一次算完,详见 **第十三章**。 + +--- + +## 六、需要新增的索引 + +> **v15 实测更新**:在 `oms_test` 核对 `information_schema` 后发现 **P0-1 / P0-2 / P1-1 / P1-2 四枚索引已存在**(此前巡检查漏),无需再建;仅 **P1-3 覆盖索引**缺失,已在 `oms_test` 执行并实测:建索引 1.3s、按产品聚合 **141.8ms → 66.6ms**、`EXPLAIN` 显示 `Using index`(免回表)。生产库执行前请用 [mcp-tools-index-ddl.sql](./mcp-tools-index-ddl.sql) 的核对语句确认。 + +### 6.1 必加(P0) + +| 序号 | 表 | 索引名 | 列 | 服务的工具 | 理由 | +|---|---|---|---|---|---| +| P0-1 | `oms_inventory_outer` | `idx_outer_code` | `outer_code` | `inventory_flow` | 出库单号是该工具主入口,当前**仅 `order_code` 有索引**,按单号查会全表扫描 | +| P0-2 | `oms_inventory_outer_detail` | `idx_outer_code` | `outer_code` | `inventory_flow` | 明细批量查询依赖它;当前**仅主键**,不补索引则新增的 `listByOuterCodeList` 反而成为性能陷阱 | + +```sql +ALTER TABLE oms_inventory_outer + ADD INDEX idx_outer_code (outer_code), ALGORITHM=INPLACE, LOCK=NONE; +ALTER TABLE oms_inventory_outer_detail + ADD INDEX idx_outer_code (outer_code), ALGORITHM=INPLACE, LOCK=NONE; +``` + +### 6.2 建议加(P1,防数据增长后退化) + +| 序号 | 表 | 索引名 | 列 | 理由 | +|---|---|---|---|---| +| P1-1 | `oms_inventory_inner` | `idx_order_code` | `order_code` | `inventory_flow` 以 `order_code` 为入口时走 `selectOmsInventoryInnerByOrderCodeList`;该列**当前无索引** | +| P1-2 | `oms_purchase_order` | `idx_vendor_id` | `vendor_id` | 采购聚合/明细若要按供应商收窄;当前仅 `purchase_no`(UK) | +| P1-3 | `oms_inventory_info` | `idx_pc_status_amt` | `(product_code, inventory_status, inner_price, outer_price)` | **覆盖索引**:聚合可完全走索引、免回表(实测 count 14.2ms → 含 sum 需回表 36.7ms)。**仅对这张线性增长的大表加**,小表不值得(应收表 627 行全表聚合仅 21.3ms)。需权衡索引体积与入库写入放大 | + +```sql +-- P1-3 覆盖索引(需权衡写入开销,建议 DBA 评估后执行) +ALTER TABLE oms_inventory_info + ADD INDEX idx_pc_status_amt (product_code, inventory_status, inner_price, outer_price), + ALGORITHM=INPLACE, LOCK=NONE; +``` + +> **注意**:加 P1-3 前须确认字段长度不超 InnoDB 索引上限(`product_code`/`inventory_status` 为 `varchar(255)` utf8mb4,四列合计约 2.1KB < 3072B,可行)。 + +```sql +ALTER TABLE oms_inventory_inner + ADD INDEX idx_order_code (order_code), ALGORITHM=INPLACE, LOCK=NONE; +ALTER TABLE oms_purchase_order + ADD INDEX idx_vendor_id (vendor_id), ALGORITHM=INPLACE, LOCK=NONE; +``` + +### 6.3 可选(P2,仅在启用对应能力时添加) + +| 序号 | 表 | 索引名 | 列 | 启用条件 | +|---|---|---|---|---| +| P2-1 | `oms_purchase_order_map` | `idx_order_id` | `order_id` | 若要支持"按订单号反查采购单"(当前**不支持**该入口) | +| P2-2 | `oms_purchase_order_map` | `idx_purchase_id` | `purchase_id` | 同上 | +| P2-3 | ~~`oms_inventory_info.idx_warehouse_id`~~ | — | — | **已被 P2-7 取代**(`(warehouse_id, inventory_status)` 组合更适用) | +| P2-4 | ~~`oms_stock_info.idx_order_code`~~ | — | — | **已并入 P2-8** | +| P2-5 | `oms_receivable_receipt_detail` | `idx_write_off_id` | `write_off_id` | 若要支持按核销单号反查(当前**不支持**) | +| P2-6 | `oms_payable_payment_detail` | `idx_write_off_id` | `write_off_id` | 同上 | +| P2-7 | `oms_inventory_info` | `idx_wh_status` | `(warehouse_id, inventory_status)` | 若要支持 `warehouse_list(entity=SN)` **仅按仓库+状态**查询(大表,无此索引必全表扫描) | +| P2-8 | `oms_stock_info` | `idx_order_code` | `order_code` | 备货范围查询(`warehouse_list(entity=STOCK)`、`inventory_flow.stock`);当前 526 行 | +| P2-9 | `oms_finance_charge` | `idx_charge_status` | `charge_status` | 计收状态维度统计;点查已可用 `order_code`(UK) | +| P2-10 | `oms_purchase_order` | `idx_status_date` | `(status, purchase_date)` | 采购范围查询与时间维度;当前 941 行 | +| P2-11 | `oms_receivable_bill` / `oms_payable_bill` / `oms_receipt_bill` / `oms_payment_bill` / `oms_invoice_bill` / `oms_ticket_bill` | `idx_partner_code` / `idx_vendor_code` | `partner_code` / `vendor_code` | `finance_balance_aggregate(group_by=PARTNER)` 与按合作伙伴列表查询;当前均 < 700 行 | +| P2-12 | `oms_inventory_info` | `idx_create_time` | `create_time` | `group_by=TIME_MONTH/TIME_QUARTER` 时间维度(**大表**);小表无需 | +| P2-13 | `oms_inventory_info` | `idx_wh_pc` | `(warehouse_id, product_code)` | `group_by=PRODUCT_WAREHOUSE` 的 **LIST** 模式游标对齐(否则 filesort,见 15.2) | +| P2-14 | `oms_inventory_info` | `idx_status_pc` | `(inventory_status, product_code)` | `group_by=STATUS` 的 **LIST** 模式游标对齐 | +| P2-15 | `oms_inventory_inner` | `idx_purchase_no` | `purchase_no` | `metrics=ARRIVAL_DELAY_DAYS` 需按采购单号 join,当前无索引(见 15.3) | +| P2-16 | `order_delivery` | `idx_status_created` | `(delivery_status, created_at)` | manage 域发货单按状态/时间范围查询;当前 355 行 | +| P2-17 | `delivery_list` | `idx_delivery_deleted` | `(delivery_id, deleted_at)` | manage 域发货 SN 明细的软删除过滤(现只能靠 `idx_delivery_id` 后再过滤);当前 32,718 行 | +| P2-18 | `project_order_info_recall` | `idx_order_code` | `order_code` | 撤回历史按合同号查询;当前仅主键(40 行) | +| P2-19 | ~~`oms_inventory_inner_detail`~~ | — | — | **不采用**:该表实测仅 1 行(未启用),入库明细以 `oms_inventory_info`(按 `inner_code`)为准,故无需索引 | +| P2-20 | `product_info` | `idx_vendor_code` | `vendor_code` | `master_data_list(entity=PRODUCT)` 按制造商过滤;当前 341 行 | +| P2-21 | `project_poc_info` | `idx_project_id` | `project_id` | `project_list(entity=POC)` 按项目查询;当前**仅主键**(998 行) | +| P2-22 | `oms_quotation` / `oms_quotation_product_info` | `idx_quotation_code` / `idx_quotation_id` | `quotation_code` / `quotation_id` | `project_list(entity=QUOTATION)`;当前均**仅主键**(0 行,启用后加) | +| P2-23 | `project_product_info` | `idx_product_bom_code` | `product_bom_code` | `cross_domain_aggregate` 的 `PRODUCT` 维度与"跨项目按产品查询";当前仅 `idx_project_id` | +| P2-24 | `project_info` | `idx_partner_code` | `partner_code` | `cross_domain_aggregate` 的 `PARTNER` 维度(`project_info` 现有 `customer_code`/`agent_code` 索引,缺 `partner_code`) | +| P2-25 | `project_order_info` | `idx_approve_time` | `approve_time` | `cross_domain_aggregate` 的 `MONTH`/`QUARTER` 时间维度(`order_code`/`partner_code`/`project_id` 已有索引) | +| P2-26 | `bu_todo_completed` | `idx_approve_user_time` | `(approve_user, approve_time)` | `approval_list(entity=DONE)` 按"审批人 + 时间"翻页;当前 5,876 行仅 `idx_business_key`,全表扫尚可,**增长后需补** | + +> 以上 P2 的触发条件统一为:**对应表行数增长到 10 万+**(或该查询已成为高频热点)。当前除 `oms_inventory_info`、`oms_inventory_delivery_detail` 外全部 < 1000 行,**暂不执行**。 + +### 6.4 明确"不能加"的索引 + +| 表.列 | 原因 | +|---|---| +| `oms_inventory_delivery.order_code` | **该列不存在**(由 `oms_inventory_outer` join 得出),建索引会直接失败;按订单号查发货必须经 `outer_code` 关联 | +| `oms_inventory_outer.warehouse_id` | **该列不存在**(仓库在 `oms_inventory_outer_detail` 上) | + +> **时间列索引策略(v7 调整)**:v5 曾"撤销时间范围过滤",因其无索引会全表扫描。v7 依据**表规模分级**(14.4)改为:**小表**(<1000 行)允许时间范围过滤(全表扫描 <10ms,无需索引);**大表**(`oms_inventory_info` 等)的时间维度聚合需先加 `create_time` 索引(P2-12),或仅接受"带索引键收窄后 + 时间二次过滤"。 + +### 6.5 索引上线注意 + +1. 先在生产/正式测试库执行 `SHOW INDEX FROM ` 复核本方案的"现有索引"结论(本文结论基于 `oms_test`); +2. 使用 `ALGORITHM=INPLACE, LOCK=NONE` 在线加索引,建议低峰执行; +3. 加完用 `EXPLAIN` 验证目标语句 `type` 非 `ALL`; +4. 加索引属 DDL,**不改字段、不改业务逻辑**。 + +--- + +## 七、需要新增的只读 SQL + +| # | 位置 | 内容 | 目的 | +|---|---|---|---| +| 1 | `InventoryOuterDetailMapper.java/.xml` | `listByOuterCodeList(List)`:`outer_code in (...)` | 消除出库明细 N+1(依赖 P0-2 索引) | +| 2 | `OmsInventoryDeliveryDetailMapper.java/.xml` | `listByDeliveryIdList(List)`:`delivery_id in (...)` | 消除发货明细 N+1(`idx_delivery_id` 已具备) | +| 3 | `InventoryInfoMapper.java/.xml` | `aggregateStock(List productCodes, String groupBy, String lastKey, int limit, ...)`:按 `group_by` 动态分组 + 游标 + `order by` | `inventory_stock_aggregate` 的 SUMMARY/LIST 两种模式 | +| 4 | `InventoryInfoMapper.java/.xml` | `aggregateStockByWarehouse(List productCodes)`:`group by product_code, warehouse_id` | 上者的组内仓库拆分 | +| 5 | `OmsPurchaseOrderMapper.java/.xml` | `aggregateArrival(String groupBy, String lastKey, int limit, ...)`:`oms_purchase_order` ⋈ `item` 动态分组聚合 | `purchase_arrival_aggregate` | +| 6 | `OmsReceivableBillMapper.java/.xml` + `OmsPayableBillMapper.java/.xml` | 按 `order_code` / `partner_code` / `status` / `time_bucket` 动态分组的余额聚合 | `finance_balance_aggregate` | +| 7 | `OmsStockInfoMapper.java/.xml` | 新增 `list(OmsStockInfo)` 带 `order_code` / `stock_status` / `create_time` 范围条件(**当前仅 `queryAll` 且无索引条件**) | `warehouse_list(entity=STOCK)`、`inventory_flow.stock` | +| 8 | `OmsFinanceChargeMapper.java/.xml` | 复用现有 `selectOmsFinanceChargeList`;如需按计收状态/时间聚合,追加 `` 条件 | `finance_list(entity=CHARGE)` 与 `finance_order_position.charge` | +| 9 | `OmsPurchaseOrderHistoryMapper.java/.xml` | 新增按 `purchase_no` / 时间范围查询历史(现仅按 `purchase_id` 单值) | `purchase_list(entity=HISTORY)` | +| 10 | `VendorInfoMapper.java/.xml` | 复用 `selectVendorInfoList`(支持 `vendorCodeList` / `vendorNameList` 批量) | `master_data_list(entity=VENDOR)` | +| 11 | `InventoryOuterMapper.xml` / `OmsInventoryInnerMapper.xml` / `InventoryDeliveryMapper.xml` / 财务各表 | 追加**时间范围与状态**的 `` 条件(**仅小表启用**,见 6.4 时间列索引策略) | 三域列表工具的范围过滤 | +| 12 | `OrderDeliveryMapper.java/.xml` | 新增按 `code_list` / `status_list` / 时间范围查询,并强制 `deleted_at is null` | `warehouse_list(entity=ORDER_DELIVERY)`(manage 域发货单,含签收) | +| 13 | `DeliveryListMapper.java/.xml` | 新增按 `delivery_id in (...)` 批量查 SN,带 `deleted_at is null` | 同上的 SN 明细 | +| 14 | `OmsInventoryInnerDetailMapper.java/.xml` | 新增按 `inner_code in (...)` 查询 | `warehouse_list(entity=INNER, include_detail=true)` | +| 15 | 主数据 Mapper(`PartnerInfoMapper` / `CustomerInfoMapper` / `AgentInfoMapper` / `ProductInfoMapper` / `VendorInfoMapper` / `SysUserMapper`) | 复用现有 `selectXxxList`;为 `PRODUCT` 增加 `productCodeList` 批量条件 | `master_data_list`(含"批量编码翻译"能力) | +| 16 | `ProjectOrderInfoRecallMapper.java/.xml` | 新增按 `order_code` 查询 | `warehouse_list(entity=RECALL)` 撤回历史 | +| 17 | `ProjectInfoMapper` / `ProjectProductInfoMapper` / `ProjectWorkProgressMapper` / `ProjectPocInfoMapper` / `QuotationMapper`(+`QuotationProductInfoMapper`) | 新增/复用 list 查询,补 `project_id in (...)` / `code_list` / 时间范围条件 | `project_list`(POC/报价表当前仅主键,需配套 P2-21/P2-22) | +| 18 | **新增 `CrossDomainAggregateMapper.java/.xml`** | 按**链路**各一个分组聚合 select:`SALES` / `PURCHASE` / `STOCK` / `FINANCE_AR` / `FINANCE_AP`(维度动态、时间区间下推) | `cross_domain_aggregate` | +| 19 | 各聚合 Mapper | 为时间维度追加**区间下推**条件(`>= begin and < end`),禁止 `date_format()` | 所有聚合与透视工具的时间维度 | +| 20 | `SysUserMapper` | 复用按 `user_id in (...)` / 部门查询 | `master_data_list(entity=USER)` | +| 21 | `OrderInfoMapper.java/.xml` | 新增按 `order_code in (...)`(**`trim()` 对齐**)/ `order_type` / `status` / 时间范围查询,**默认 `deleted_at is null`** | `project_list(entity=CONTRACT)`;并供 `warehouse_list(entity=ORDER_DELIVERY)` 补全合同信息 | +| 22 | `OrderListMapper.java/.xml` | 新增按 `order_id in (...)` 批量查询,**默认 `deleted_at is null`** | `project_list(entity=CONTRACT_PRODUCT)` | +| 23 | **新增 `BuTodoMapper.java/.xml`** | `listTodo(approveUser, processKeyList, businessKey, timeRange)` 与 `listDone(...)`:分别查 `bu_todo` / `bu_todo_completed`,按 `apply_time` / `approve_time` 倒序 + 游标分页 | `approval_list(entity=TODO/DONE)` | +| 24 | `OmsFinAttachmentMapper.java/.xml` | 新增按 `related_bill_id in (...)` + `related_bill_type` 查询,**过滤 `del_flag='0'`**(只返回元数据列,不含 `file_path` 内容) | `finance_list(entity=ATTACHMENT)` | +| 25 | `OmsReceivableBillMapper` + `OmsReceivableReceiptDetailMapper` | 新增**时点重算**聚合:`Σ应收(create_time ≤ T)` 与 `Σ已收(receipt_time ≤ T)`(支持 `as_of_date`) | `finance_balance_aggregate(as_of_date=…)` 的历史时点余额 | + +> 其余查询全部复用现有 `in (...)` 批量方法,不新增。 + +--- + +## 八、权限对齐规则 + +现有权限是**异构**的,必须逐个工具对齐其对应页面入口。 + +### 8.1 行级权限(数据范围) + +| 数据域 | 机制 | 实现要求 | +|---|---|---| +| 仓储(出入库/发货/库存/备货/SN) | `IInventoryAuthService`:`authAll()` / `currentVendor()` / `authWarehouse()` / `authProductCode()`;部分在 Controller 拼、部分在 Service 拼;发货另带 `@DataScope("t8")` | 工具内**按对应 Controller 的拼法**回填。**不要照抄** `OmsInventoryInnerServiceImpl` 只取 `currentVendor().get(0)` 第一家的既有缺陷 | +| 采购(采购单/明细/历史/供应商/绑定) | 行级 `authVendorCodeList`(Controller 层由 `currentVendor()` 生成) | `purchase_list(entity=ORDER/ITEM/HISTORY)` 回填 `authVendorCodeList`;`entity=VENDOR` 直接用 `currentVendor()` 结果集 | +| 财务 | **无行级权限**,仅菜单权限 | 只做菜单校验 | +| 订单(对比参考) | `selectProjectOrderInfoList` 自带 `@DataScope("t5")` + `authSql` | 走 service 即自动生效 | + +### 8.2 菜单权限(`isPermitted`)—— 逐个工具/entity 对照来源 + +| 工具 / entity | 权限串来源(实现时从该 Controller 的 `@RequiresPermissions` 抄取) | 已知值 | +|---|---|---| +| `inventory_sn_trace` / `inventory_stock_aggregate` | `InventoryInfoController`、`VueInventoryInfoController` | 待抄取 | +| `inventory_flow` | `InventoryOuterController`、`VueDeliveryController` | 待抄取 | +| `warehouse_list`(`INNER`/`OUTER`/`DELIVERY`/`STOCK`/`SN`) | `OmsInventoryInnerController`、`InventoryOuterController`、`VueDeliveryController`、`OmsStockInfoController`、`InventoryInfoController` | 待抄取 | +| `warehouse_list`(`ORDER_DELIVERY`) | manage 域发货单对应 Controller(`OrderDeliveryController` 及 `VueDeliveryController` 同路径) | 待抄取 | +| `warehouse_list`(`RECALL`) | `ProjectOrderInfoController`(撤回/版本相关接口) | 待抄取 | +| `purchase_arrival_aggregate` / `purchase_list`(`ORDER`/`ITEM`/`ORDER_BIND`) | `OmsPurchaseOrderController` | **`sip:purchaseorder:list`** ✅ | +| `purchase_list`(`HISTORY`) | `OmsPurchaseOrderController` 历史接口 | 待抄取 | +| `master_data_list`(`PARTNER` / `CUSTOMER` / `AGENT`) | `PartnerInfoController` / `CustomerInfoController` / `AgentInfoController`(含 `Vue*` 版本) | 待抄取 | +| `master_data_list`(`VENDOR`) | `VendorInfoController`、`VueVendorInfoController` | 待抄取 | +| `master_data_list`(`PRODUCT`) | `ProductInfoController`、`VueProductInfoController` | 待抄取 | +| `master_data_list`(`USER`) | `SysUserController`(系统用户) | 待抄取 | +| `master_data_list`(`WAREHOUSE`) | `OmsWarehouseInfoController` | 待抄取 | +| `master_data_list`(`COMPANY`) | `OmsCompanyInfoController` | 待抄取 | +| `project_list`(`PROJECT`/`PROJECT_PRODUCT`/`PROGRESS`/`POC`) | `ProjectInfoController`、`ProjectOrderInfoController`(含 `Vue*` 版本) | 待抄取 | +| `project_list`(`QUOTATION`) | `QuotationController`(含 `Vue*` 版本) | 待抄取 | +| `project_list`(`CONTRACT`/`CONTRACT_PRODUCT`) | manage 域合同对应 Controller(`OrderInfoController` / `VueOrderInfoController` 等) | 待抄取 | +| `cross_domain_aggregate` | **按链路做最小权限校验**:需同时具备所访问链路的菜单权限(`SALES` 需订单/项目查看权限;`FINANCE_AR`/`FINANCE_AP` 需对应财务权限) | 待抄取 | +| `finance_order_position` / `finance_balance_aggregate` | `OmsReceivableBillController`、`OmsPayableBillController` | **`finance:receivable:list`** ✅,应付待抄取 | +| `finance_list`(`RECEIPT`/`PAYMENT`/`INVOICE`/`TICKET`) | `OmsReceiptBillController`、`OmsPaymentBillController`、`OmsInvoiceBillController`、`OmsTicketBillController` | 待抄取 | +| `finance_list`(`CHARGE`) | `OmsFinanceChargeController` | 待抄取 | +| `finance_list`(`ATTACHMENT`) | `OmsFinAttachmentController`(或财务单据 Controller 的附件接口) | 待抄取 | +| `approval_list`(`TODO`/`DONE`) | 待办/已办接口对应 Controller(`OmsPurchaseOrderController` 的 approveList/approvedList、财务付款审批接口、`ApprovalTaskController` 等) | 待抄取 | + +### 8.3 统一要求 + +1. `handle` 第一行做 `isPermitted` 校验,不通过返回 **`AUTH_ERROR`**(**不要返回空列表**,否则 Agent 会误判为"无数据"); +2. 行级权限指纹并入游标 `filterHash`(见 5.4),避免权限变化导致游标错位; +3. "权限不足"与"确实无数据"必须在响应中可区分:前者走 `error`,后者 `data.items = []`。 + +--- + +## 九、聚合口径定义 + +| 工具 | 度量 | 口径 | +|---|---|---| +| `inventory_stock_aggregate` | `in_stock_qty` / `out_stock_qty` | `count(*) where inventory_status='0'` / `='1'` | +| | `inner_amount` / `outer_amount` | `sum(inner_price)` / `sum(outer_price)`,**NULL 计 0** | +| | 未税金额(如返回) | 按明细税率换算;**`tax_rate` 为空时按 0 计**(实测存在 NULL) | +| `purchase_arrival_aggregate` | `purchase_qty` / `inner_qty` / `pending_qty` | `oms_purchase_order_item.quantity` / `inner_quantity` / 两者差值 | +| | `arrival_rate` | `inner_qty / purchase_qty`,HALF_UP 保留 2 位;分母为 0 时返回 0 | +| | `amount_total` / `tax_total` | `sum(amount_total)` / `sum(tax_total)` | +| `finance_balance_aggregate` | 应收侧 4 值 / 应付侧 4 值 | 直接 `sum` 冗余列:`unreceived_amount`、`uninvoiced_amount`、`unpaid_payment_amount`、`unreceived_ticket_amount`。**不重算**,避免口径偏差与额外开销 | + +**v7 新增度量口径** + +| 工具 | 度量 | 口径 | +|---|---|---| +| `purchase_arrival_aggregate` | `arrival_delay_days` | `datediff(入库时间, item.delivery_date)` 的均值;未入库的不计(或按参数 `include_pending_delay=true` 用今天计算);分母 0 返回 null | +| `finance_balance_aggregate` | `overdue_days` | `datediff(今天, plan_receipt_date)`(仅当 `unreceived_amount > 0`);`< 0` 返回 0 表示未到期 | +| 财务类 | `PARTNER` 维度 | 应收按 `partner_code`(客户),应付按 `vendor_code`(制造商);两者需在 `metadata` 注明维度主体差异 | + +**v7 新增维度口径** + +| `group_by` | 分组键 | 说明 | +|---|---|---| +| `STATUS` | 各表状态列 | 返回状态编码 + `*Name`(枚举翻译) | +| `TIME_MONTH` | `time_bucket = yyyy-MM` | **区间下推**实现:`create_time >= '2026-01-01' and create_time < '2026-02-01'`,禁止 `date_format(create_time,...)` | +| `TIME_QUARTER` | `time_bucket = yyyyQn` | 同上,按季度区间 | +| `PARTNER` | `partner_code` / `vendor_code` | 见上表维度主体差异 | +| `VENDOR` | `vendor_id` | 采购侧制造商(依赖 P1-2) | +| `PRODUCT` | `product_code` | 采购侧按产品 | + +> 时间维度的默认区间:未传 `time_range` 时取**近 12 个月**,避免无界扫描;区间跨度上限 36 个月。 + +**v13 新增口径** + +| 工具 | 项 | 口径 | +|---|---|---| +| `finance_balance_aggregate` | `group_by=OVERDUE_BUCKET`(账龄分桶) | 基于 `datediff(今天, plan_receipt_date)` 分桶:**`0-30` / `31-60` / `61-90` / `90+`**;**仅统计 `unreceived_amount > 0` 的行**;边界左闭右闭(`=30` 入 `0-30`);同时返回 `in_bucket_qty`、`in_bucket_amount` | +| `finance_balance_aggregate` | `as_of_date`(历史时点余额) | **重算口径**:`应收(≤T) = Σ receivable_bill.total_price_with_tax where create_time ≤ T`;`已收(≤T) = Σ receipt_detail.receipt_amount where receipt_time ≤ T`;时点未收 = 两者差。**与冗余列"当前值"口径不同**,`metadata` 必须标注 `basis: RECALCULATED@`,且未传 `as_of_date` 时仍用冗余列(`basis: CURRENT`) | + +上述口径必须写入返回的 `metadata.aggregation_rule`。 + +--- + +## 十、实施步骤 + +1. **索引 DDL**:执行 P0(必须)+ P1(建议),并按 **16.4 的执行方案**(预检 → 分步 → 验证 → 可回滚)推进;P2 暂不执行(按表规模触发); +2. **新增只读 SQL**:第七节 25 项; +3. **公共能力**:在 `llm/tools/support` 增加分页/游标/入参解析/权限回填的轻量基类(继承 `AbstractMcpToolProvider`),含 `page_size` 上限、`limit+1` 探测、游标编解码与 `filterHash` 校验、`max_pages` 保护、`mode`/`group_by`/`metrics`/`entity`/`dimensions` 枚举校验与**类型规范化**(varchar 强制字符串)、**参数冲突校验**(见 15.5)、**表白名单**(见 15.12)、**从库路由开关**(见 16.6)、**时间区间配置**(见 16.5); +4. **实现 13 个工具**:A 类 3 个(含 `stock`/`charge` 分组)→ B 类 3 个(SUMMARY 优先,再补 LIST;含 `OVERDUE_BUCKET` 与 `as_of_date`)→ C 类 5 个(`entity` 参数化,含 `master_data_list` 与 **`approval_list`**)→ D 类 2 个(`project_list`、`cross_domain_aggregate`),继承基类 + `@Component` 自动注册(无需改 `ToolInitializer`); +5. **RAG 路由(16.7)与 D 类同期上线**:`ToolRetriever` / `ToolRouter` + `tools/list` 的 `query`/`detail` 支持,否则 13 个工具的 schema token 不可接受; +6. **逐层验收**:每完成一类即按第十一节验证,并记录 P95 延迟、返回字节数、schema token 三个指标。 + +### 10.1 建议的实施批次(可按需截断) + +| 批次 | 内容 | 价值 | +|---|---|---| +| 第 1 批 | 索引 P0 + 只读 SQL 1/2 + 公共基类 + A 类 3 个工具 | 覆盖"单号追溯"与"订单财务/货流全景" | +| 第 2 批 | B 类 3 个(含 SUMMARY/Top-N/时间维度) | 覆盖"统计分析",避免 Agent 翻页 | +| 第 3 批 | C 类 5 个(列表/范围查询 + 主数据批量翻译 + **审批待办/已办**) | 补齐"本月有哪些/多少""编码→名称""我还有哪些单要审" | +| 第 4 批 | **RAG 路由(16.7)** | 控制 schema token、降低选错率 | +| 第 5 批 | **D 类 2 个(项目/POC/报价、受限跨域透视)** | 补齐项目域与组合分析 | +| 第 6 批 | P1 索引(含覆盖索引)+ 性能调优 + 压测(见 15.13) | 大规模下提速与容量验证 | + +--- + +## 十一、验收标准 + +### 11.1 正确性 +1. **不重不漏**:同一过滤条件下,逐页拉完的结果集与一次性全量取的结果**逐行比对一致**; +2. **翻页稳定性**:翻页过程中并发插入/删除若干行,结果集不出现重复行; +3. **游标防误用**:篡改 cursor 中的过滤条件/权限指纹 → 必须报错,而非返回错数据; +4. **口径一致**:聚合结果与页面同条件展示一致(金额、数量、税率为 NULL 的兜底); +5. **端到端**:以真实单号(发货 775、出库/采购/应收各一条)跑完整翻页,`has_more=false` 后条目数与页面一致。 + +### 11.2 性能 +6. 每个游标过滤键 `EXPLAIN` 结果 `type` 非 `ALL`; +7. `inventory_sn_trace` 在 50 个 SN 下目标 **P95 < 300ms**; +8. 记录各工具 P95 延迟与返回字节数(作为基线入库)。 + +### 11.3 保护机制 +9. `max_pages` 超限、`include_total` 超 `count_cap`、单页字节超限,三条路径均触发预期行为。 + +### 11.4 安全 +10. 无权限用户调用 → 返回 `AUTH_ERROR`(非空列表); +11. 供应商/仓库行级权限生效:越权数据不可见。 + +--- + +## 十二、遗留与待确认 + +| # | 事项 | 说明 | +|---|---|---| +| 1 | 生产库量级 | 本文量级来自 `oms_test`(`oms_inventory_info` 6.5 万)。若生产为百万级,保持 `product_sn_list ≤ 50`、禁止仅按 `product_code`/`warehouse_id` 查 SN,并触发 P2-7/P2-12 索引 | +| 2 | 生产索引复核 | 执行 `SHOW INDEX FROM
` 复核 2.5 节结论(索引结构理论上与测试库一致,但需确认) | +| 3 | ~~聚合分页粒度~~ | **已定**:库存聚合以**产品**为页、仓库作组内嵌套;采购按 `purchase_no`;财务按 `order_code`。按"产品+仓库"行粒度分页需重新评估跨索引排序成本 | +| 4 | `include_total` 默认值 | 当前默认 `false`(避免百万级 count);若业务更关心总数可改为 `true` + 提高 `count_cap` | +| 5 | 菜单权限串 | 财务/采购部分工具与 entity 的精确 `@RequiresPermissions` 需实现时逐个从 Controller 抄取(对照表见 8.2) | +| 6 | P0/P1 索引 DDL 执行窗口 | 需 DBA 在低峰期执行,P1-3 覆盖索引须先评估写入放大 | +| 7 | 时间维度默认区间 | 当前定义为**近 12 个月**、跨度上限 36 个月;若业务需要更长历史,需同时补大表时间索引(P2-12)并放宽上限 | +| 8 | 从库路由(可选) | `slave` 数据源当前 `enabled=false`;若要启用统计查询走从库,需 DBA 确认延迟与可用性 | +| 9 | **"在库"口径** | **未配置化(2026-09-23 回滚)**:曾尝试以 `mcp.inventory.stock-basis` 配置化,经确认**不引入配置机制**,已全部回滚。当前口径写死在 [InventoryInfoMapper.xml](../ruoyi-sip/src/main/resources/mapper/inventory/InventoryInfoMapper.xml) 的 `inventory_status='0'`,并在工具 `metadata` 中声明"**未扣除已发货占用**,业务待确认";业务确认后需改代码 | +| 10 | **价格含税口径** | **未配置化(2026-09-23 回滚)**:同上,不引入配置。当前仅在 `inventory_stock_aggregate` / `inventory_sn_trace` 的 `metadata` 中声明"是否含税未经业务确认,不做换算",工具不做任何含税/未税换算 | +| 11 | **备份表白名单** | 库中存在 `oms_inventory_info_copy1`(42,836 行) 等大量备份表,实现时必须以**白名单**登记可访问表(见 15.12) | +| 12 | ~~项目进度 / POC / 报价~~ | **已纳入(见 16.1)**:`project_list` 工具,entity = `PROJECT`/`PROJECT_PRODUCT`/`PROGRESS`/`POC`/`QUOTATION`/**`CONTRACT`**/**`CONTRACT_PRODUCT`** | +| 13 | **RAG 工具路由** | 13 个工具下**必须实现**(见 16.7),否则每轮 schema token 不可接受;需 20 条中文问题做命中率验收 | +| 14 | **跨域透视成本** | `cross_domain_aggregate` 的 `SALES` 链路为 3 表 join + 分组,属**兜底能力**;须在生产数据上验证 P95,必要时补 P2-23/24/25 | +| 15 | **两套订单模型的关系** | manage 域 `order_info`(366) 与项目域 `project_order_info`(809) **仅 330/366 可按 `order_code` 对齐**;二者业务关系(新旧两代?两套视角?)**需业务确认**,并确认"合同编号"以哪张表为准 | +| 16 | **`order_type` 取值含义** | 实测为 `zq`/`dls`(非注释的 1/2),需业务确认 `zq`=直签、`dls`=代理商 | +| 17 | ~~是否纳入审批待办/已办~~ | **已纳入(v13)**:新增工具 `approval_list`(entity = `TODO`/`DONE`),工具数 12 → 13;数据源 `bu_todo`/`bu_todo_completed`,**无需碰 Flowable `act_*`**(见 A.15) | +| 18 | ~~是否纳入账龄分桶~~ | **已纳入(v13)**:`finance_balance_aggregate` 增加 `group_by=OVERDUE_BUCKET`(`0-30`/`31-60`/`61-90`/`90+`),零成本(见 9 章 v13 新增口径) | +| 19 | ~~是否需要财务历史时点余额~~ | **已纳入(v13)**:`finance_balance_aggregate` 增加 `as_of_date`,按明细重算时点余额;**口径与冗余列不同**,`metadata.basis` 标注 `RECALCULATED@` vs `CURRENT` | +| 20 | **`fianance_ticket` 拼写** | `process_key` 存在源码级拼写错误(少一个 `n`),匹配必须按原样,勿"修正" | + +--- + +## 十三、分析统计场景优化(v5,实测驱动) + +### 13.1 问题:分析统计会退化为"Agent 驱动的多次全表扫描" + +原设计只提供"游标分页明细",没有"一次算完"的能力。当 Agent 需要全局统计(如"某产品总在库量""所有订单未收款合计")时,只能翻页累加,后果: + +1. **扫描量一点没省**:实测分页第 1 页 **34.4ms** ≈ 全局汇总 **38.1ms** —— 分页对聚合并不减少扫描; +2. **往返与 token 放大 N 倍**:5 页 = 5 次请求 + 5 份 schema; +3. **可能拿不到全量**:明细模式 6.5 万行 ≈ **648 页**(page_size=100),必被 `max_pages` 截断 → **与"数据不缺失"目标直接冲突**; +4. **回表成本**:`count(*)` 走覆盖索引 **14.2ms**,含 `sum(inner_price/outer_price)` 后 **36.7ms**(2.6×),因现有索引不含金额列。 + +### 13.2 优化 1(收益最大,零 DDL):聚合工具新增 `mode=SUMMARY` + +聚合类 3 个工具(#4/#5/#6)新增参数: + +| 参数 | 取值 | 说明 | +|---|---|---| +| `mode` | `SUMMARY`(默认)/ `LIST` | SUMMARY=一次算完并返回汇总,**不分页**;LIST=游标分页返回完整分组明细 | +| `group_by` | 库存:`NONE` / `PRODUCT` / `WAREHOUSE` / `PRODUCT_WAREHOUSE` / `STATUS` / `TIME_MONTH` / `TIME_QUARTER`;采购:`NONE` / `ORDER` / `VENDOR` / `PRODUCT` / `STATUS` / `TIME_MONTH`;财务:`NONE` / `ORDER` / `PARTNER` / `STATUS` / `TIME_MONTH` | 分析维度;`NONE` 只返回一行总计。口径见第九章 | +| `top_n` | int,默认 10,上限 100 | SUMMARY 下按度量取前 N,由 DB 完成(`order by desc limit N`) | +| `metrics` | 数组,如 `["QTY","AMOUNT","ARRIVAL_RATE","ARRIVAL_DELAY_DAYS","OVERDUE_DAYS"]` | 只计算需要的度量,减少回表列 | +| `include_summary` | bool | LIST 模式下附带一次全局总计(1 行),便于同时拿到"总量 + 明细" | + +**SUMMARY 实现**:单条 SQL 完成全量聚合: + +```sql +-- group_by=NONE:1 行总计(实测约 38ms @6.5 万行) +select count(*) total_qty, + sum(case when inventory_status = '0' then 1 else 0 end) in_stock_qty, + sum(inner_price) inner_amount +from oms_inventory_info +where <过滤条件>; + +-- group_by=PRODUCT + top_n=10:排序取前 10 由 DB 完成 +select product_code, + count(*) total_qty, + sum(case when inventory_status = '0' then 1 else 0 end) in_stock_qty, + sum(inner_price) inner_amount +from oms_inventory_info +where <过滤条件> +group by product_code +order by total_qty desc +limit 10; +``` + +**收益**:把"N 页 × 全表扫描"变为"**1 次全表扫描**",结果完整且不受 `max_pages` 影响。 + +### 13.3 优化 2(零 DDL):Top-N 取代全量明细 + +分析统计的绝大多数问题是"最大的 / 最差的 / 占比 Top-N",用 `top_n` + 排序交给 MySQL,只返回 N 行。只有当需求明确是"逐行导出 / 对账"时才用 `LIST` 翻页。 + +**护栏**:`LIST` 模式对聚合类工具**必须给出范围**(如 `product_code_list`、`order_code_list`),否则返回 `INVALID_PARAMS` 并提示改用 `SUMMARY`,避免 Agent 无意识触发 648 页翻页。 + +### 13.4 优化 3(需确认):覆盖索引,让"1 次全表扫描"也变快 + +- 现状:`idx_product_code` 只能覆盖 `count(*)`(实测 `Using index`,14.2ms);`sum(inner_price/outer_price)` 需回表(36.7ms)。 +- 建议:**仅对 `oms_inventory_info` 加覆盖索引** `(product_code, inventory_status, inner_price, outer_price)`(见索引 **P1-3**)。 +- 原则:**只对线性增长的大表加**;小表不加(应收表 627 行全表聚合仅 21.3ms,加索引反而增加写入开销)。 +- 代价:索引体积 + 入库写 SN 时的写放大,需 DBA 评估。 + +### 13.5 优化 4(零 DDL):数据类型严格对齐,防止索引退化 + +实测对比: + +| 写法 | EXPLAIN | 结果 | +|---|---|---| +| `where inner_code = 'R-20250917001'` | `key=idx_code`,`rows=const` | 精确定位 ✅ | +| `where inner_code = 0`(数字) | `key=idx_code`,`rows=None`,`Extra=Using where` | **索引退化为逐行过滤** ❌ | +| `where product_code = '9801H0BC'` | `rows=const` | 精确定位 ✅ | +| `where product_code = 9801`(数字) | `rows=None`,`Extra=Using where` | **退化** ❌ | + +强制规则(在工具内做类型规范化,**不交给模型自由传类型**): +- `varchar` 列一律传**字符串**:编码类、`inventory_status`(实测为 `varchar(255)`,必须传 `'0'`/`'1'`); +- `int` 列传 **int**:如 `warehouse_id`; +- 禁止在 `where` 中对列做函数或类型转换。 + +### 13.6 优化 5(零 DDL):组内嵌套改用 IN 收窄 + +实测仓库拆分查询出现 `Using temporary`。改为"先取本页产品码 → `where product_code in (本页产品码)` 查仓库拆分",把临时表规模限制在一页之内(实测单产品 21.8ms)。 + +### 13.7 优化 6:查询超时 + 并发限流(防止拖垮库) + +- 实测配置 Druid `maxActive=20`(`ruoyi-admin/src/main/resources/application-dev.yml`),Agent 循环/并行调用会占满连接池。 +- 措施:① 聚合 SQL 设置执行超时(MySQL 8 支持 `/*+ MAX_EXECUTION_TIME(3000) */` 或 JDBC `setQueryTimeout`),超时返回明确错误;② MCP 层对同一 bot 限流(如 60 次/分);③ **工具内不做并行查询**(沿用现有串行写法);④ 保留 `max_pages` 兜底。 + +### 13.8 优化 7(可选):统计类查询路由从库 + +`application-dev.yml` 已有 `slave` 数据源占位(`enabled=false`)。分析类聚合可考虑路由只读从库,避免影响主库。 +**限制**:主从延迟 → **财务金额不可走从库**;库存/汇总类可。需 DBA 确认从库可用性与延迟。 + +### 13.9 优化 8:向 Agent 声明"支持的统计维度清单" + +在 `metadata` 中声明允许的 `group_by` / `metrics` 枚举,清单外维度**明确不支持**,避免 Agent 用明细工具硬凑而触发全表扫描。 + +### 13.10 仍然存在的边界(诚实声明) + +1. SUMMARY 本身仍是 **1 次 O(N) 索引扫描**;不做预聚合表则无法做到亚秒级(当前 6.5 万行 ~38ms,百万级预计 ~0.6s,可接受); +2. **不支持跨表任意维度**(如"客户 × 产品 × 月份");那需要通用 SQL 能力,出于安全与性能不开放; +3. 若要亚秒级 + 固定维度统计,需**预聚合表 + 可靠定时任务**,属新增功能须单独评估;`oms_finance_operate_report` 的教训是:**必须有可靠的定时刷新机制,不能依赖手工触发接口**; +4. `include_total=true` 在大表上仍是额外 count 开销,故默认关闭。 + +### 13.11 优化后的调用形态对照 + +| Agent 的问题 | 优化前 | 优化后 | +|---|---|---| +| "某产品还有多少库存" | 翻页累加,约 5 次调用 | **1 次** `mode=SUMMARY, group_by=NONE` | +| "库存最多的 10 个产品" | 翻完 94 组后在模型侧排序 | **1 次** `group_by=PRODUCT, top_n=10` | +| "本月采购到货率" | 翻页累加或模型计算 | **1 次** `group_by=ORDER, mode=SUMMARY`(含 `arrivalRate`) | +| "这些订单还欠多少钱" | 多页累加 | **1 次** `group_by=ORDER, mode=SUMMARY` 或 `order_code_list` 精确查 | +| "导出全部 SN 明细" | 648 页(被截断) | `mode=LIST` 且必须给范围;否则明确拒绝并提示收窄条件 | + +--- + +## 十四、覆盖度缺口分析与完善(v6 分析 / v7 已补全) + +> 本章回答三个问题:统计维度覆盖了多少?三大域是否完整?当前是否算"最优"? +> +> **v7 状态:本章识别出的缺口已全部并入最终方案**——A 档(时间/状态/伙伴/负责人维度 + 时效度量)、B 档(`warehouse_list`/`purchase_list`/`finance_list` 三个列表工具,并把 v6 的 `purchase_order_detail`/`finance_bill_detail` 合并进去)、C 档(`inventory_flow.stock` 备货分组、`finance_order_position.charge` 计收分组)、D 档(P2 条件索引)均已落地。下面的矩阵保留作为**缺口审计记录与后续回归依据**。 + +### 14.1 三大域覆盖度矩阵 + +**仓储域(7 个对象 / v11 已全部覆盖)** + +| 对象 / 表 | v6 判定 | 现状(v11) | +|---|---|---| +| 入库单 `oms_inventory_inner` | ✅ | ✅ `warehouse_list(INNER)` + `inventory_flow` | +| 出库单 `oms_inventory_outer(+_detail)` | ✅ | ✅ `warehouse_list(OUTER)` + `inventory_flow` | +| 发货单 `oms_inventory_delivery(+_detail)` | ✅ | ✅ `warehouse_list(DELIVERY)`;另有 manage 域 `ORDER_DELIVERY`(含签收) | +| SN 条码明细 `oms_inventory_info` | ✅ | ✅ `inventory_sn_trace` / `inventory_stock_aggregate` | +| 备货状态 `oms_stock_info` | ❌ | ✅ **v7 闭合**:`warehouse_list(STOCK)` + `inventory_flow.stock` | +| 仓库主数据 `oms_warehouse_info` | ❌ | ✅ **v10 闭合**:`master_data_list(WAREHOUSE)` | +| 单据范围查询(按状态/时间) | ❌ | ✅ **v7 闭合**:`warehouse_list` 支持状态/时间范围过滤 | + +**采购域(6 个对象 / v11 覆盖 5)** + +| 对象 / 表 | v6 判定 | 现状(v11) | +|---|---|---| +| 采购单 `oms_purchase_order(+_item)` | ✅ | ✅ `purchase_list(ORDER/ITEM)` + `purchase_arrival_aggregate` | +| 采购单范围查询 | ❌ | ✅ **v7 闭合**:`purchase_list` 支持状态/时间/供应商 | +| 供应商主数据 `oms_vendor_info` | ❌ | ✅ **v8 闭合**:`master_data_list(VENDOR)` | +| 采购-订单绑定 `oms_purchase_order_map` | ❌ | ✅ **v8 闭合**:`purchase_list(ORDER_BIND)` | +| 采购历史版本 | ❌ | ✅ **v8 闭合**:`purchase_list(HISTORY)` | +| **采购审批待办 / 已办** | ❌ | **❌ 仍缺 → 见 16.9(实测可低成本闭合)** | + +**财务域(8 个对象 / v11 覆盖 6)** + +| 对象 / 表 | v6 判定 | 现状(v11) | +|---|---|---| +| 应收 / 应付 / 收款 / 付款 / 开票 / 收票 | ✅ | ✅ 单号点查 + 订单全景 + 5 流余额汇总 | +| 计划表 / 明细表 / 核销表 | ✅ | ✅ `finance_order_position` / `finance_list` | +| 计收 `oms_finance_charge` | ❌ | ✅ **v7 闭合**:`finance_list(CHARGE)` + `finance_order_position.charge` | +| 财务单据范围查询 | ❌ | ✅ **v7 闭合**:`finance_list` 支持状态/时间/合作伙伴 | +| 财务运营报表 `oms_finance_operate_report` | ❌(刻意) | **❌ 仍缺(刻意剔除物化表)→ 见 16.9:可用"按明细重算"补历史时点余额** | +| 财务附件 `oms_fin_attachment` | ❌ | **⚠️ 可闭合为"附件元数据"→ 见 16.9** | + +### 14.2 统计维度覆盖矩阵 + +| 维度 | v6 判定 | 现状(v11) | +|---|---|---| +| 产品 `PRODUCT` / 仓库 `WAREHOUSE` / 采购单 `ORDER` / 订单 `ORDER` / 无维度 `NONE` | ✅ | ✅ | +| 时间维度(月/季) | ❌ | ✅ **v7 闭合**:`TIME_MONTH` / `TIME_QUARTER`(区间下推) | +| 合作伙伴/客户 `PARTNER` | ❌ | ✅ **v7 闭合**(LIST 需 P2-11) | +| 状态分布 `STATUS` | ❌ | ✅ **v7 闭合**(LIST 需 P2-14) | +| 及时率 / 超期 | ❌ | ✅ **v7 闭合**:`ARRIVAL_DELAY_DAYS` / `OVERDUE_DAYS` | +| 负责人/销售 `OWNER` | ❌ | **⛔ 主动移除**(无索引 + 业务价值未确认)→ 见 16.9 | +| **账龄分桶(0-30/31-60/61-90/90+)** | — | **❌ 仍缺 → 见 16.9(零成本可补:`group_by=OVERDUE_BUCKET`)** | + +**结论(更新)**:统计维度覆盖 **v11 约 90%**;仅剩 **账龄分桶**(可零成本补)与 **OWNER**(主动移除)两项。 + +### 14.3 最严重的缺口不是"统计维度",而是"列表能力"整体缺失 + +当前 8 个工具中,**所有单据类查询都强制"按单号点查"**(`outer_code` / `purchase_no_list` / `bill_code_list`)。直接后果是这些**最基础的问题无法回答**: + +- "本月有哪些采购单?" / "哪些采购单还没入库?" +- "这个月发货了多少单?" / "哪些出库单还没确认?" +- "本月开了多少票、收了多少款?" + +这类问题在业务里出现频率极高,而当前方案要么拒绝、要么逼 Agent 用 SN/明细工具硬凑(必然触发大表扫描)。**这是比统计维度更优先要补的缺口。** + +### 14.4 实测修正:我此前"无索引=不支持"的规则过严 + +实测行数分布推翻了"一刀切": + +| 规模档 | 表 | 全表扫描代价 | 结论 | +|---|---|---|---| +| 大表(线性增长) | `oms_inventory_info` 64,823;`oms_inventory_delivery_detail` 49,015 | 数十~数百 ms | **必须**严守索引约束 | +| 小表(<1000 行) | 采购 941/951、出库 734/750、入库 580、发货 756、应收 627、应付 583、备货 526、计收 287、历史 121/142、供应商 17、仓库 14 | **<10 ms** | **可以**直接支持按状态/时间/伙伴过滤,无需索引 | + +**方案修正**:把"无索引 = 不支持该入口"改为**按表规模分级**: +- 大表:只用索引列做入口(维持原约束); +- 小表:允许状态/时间/伙伴维度的范围查询与统计(接受全表扫描),并标注"该表当前规模小,若增长需补索引"。 + +这一条修正同时解开了 14.1、14.2、14.3 的多数缺口。 + +> **技术注意**:时间维度聚合若写成 `date_format(create_time,'%Y-%m')`,**对列做函数会使索引失效**;正确做法是按区间下推(`create_time >= '2026-01-01' and create_time < '2026-02-01'`),未来若为大表加时间索引才有效。 + +### 14.5 是否算"最优"?——分两个层面回答 + +| 层面 | 评价 | 依据 | +|---|---|---| +| **性能与实现质量** | **接近该架构下的上限** | 过滤键实测对齐索引、游标分页不重不漏、SUMMARY 一次算完、类型对齐防索引退化、超时限流;均有实测支撑 | +| **业务覆盖完备性** | 原为**不完备**(统计维度约 60%、列表能力缺失);**v7 已补全至 ~95%** | 通过 A/B/C/D 四档补全:时间/状态/伙伴/负责人维度、三域列表查询、计收、备货、供应商主数据、采购-订单绑定、采购历史 | + +**并且"完备"与"轻量"本质冲突**:工具数越多,schema token 与模型选错率越高(当前 8 个已接近无路由时的上限)。因此不存在绝对最优,只有**按实际提问分布做取舍**。 + +### 14.6 完善建议(按性价比分三档) + +**A 档 · 零新增工具(只扩参数枚举)→ 建议立即纳入** + +| 项 | 做法 | +|---|---| +| 时间维度 | 聚合类 `group_by` 增加 `TIME_MONTH` / `TIME_QUARTER`(区间下推实现) | +| 状态分布 | 增加 `group_by=STATUS` | +| ~~负责人维度~~ | **已移除**:`OWNER` 无索引支撑且业务价值未确认,见 15.2 | +| 及时率/超期 | 度量增加 `OVERDUE_DAYS`(计划收款日 vs 今天)、`ARRIVAL_DELAY`(交货日 vs 实际入库日) | + +**B 档 · 补"域内列表查询"(补基础能力,建议每域 1 个,工具数 8 → 11)** + +| 工具 | 覆盖 | 过滤维度(分页) | +|---|---|---| +| `warehouse_list` | `entity = DELIVERY / OUTER / INNER / STOCK` | 单号、状态、时间范围、仓库、产品、合同号 | +| `purchase_list` | `entity = PURCHASE_ORDER / VENDOR / ORDER_BIND / HISTORY` | 单号、状态、审批/确认状态、时间范围、供应商 | +| `finance_list` | `entity = RECEIVABLE / PAYABLE / RECEIPT / PAYMENT / INVOICE / TICKET / CHARGE` | 单号、状态、审批状态、时间范围、合作伙伴 | + +> 用一个工具 + `entity` 参数而非每表一个工具,是为了在补全能力的同时把工具数增长压到最小。代价是单工具 schema 稍复杂。 + +**C 档 · 零新增工具,把高价值点查塞进现有工具** + +| 项 | 做法 | 支撑 | +|---|---|---| +| 计收 | `finance_order_position` 增加 `charge` 分组(计收状态、收入/成本/毛利) | `oms_finance_charge.order_code` 有**唯一索引** ✅ | +| 备货 | `inventory_flow` 增加 `stock` 分组(备货状态、一次备齐) | 526 行,按 `order_code` 扫可接受 | +| 采购-订单绑定 | `purchase_list` 的 `ORDER_BIND`(若要开,需 P2-1/P2-2 索引) | `oms_purchase_order_map` 仅主键 | + +**D 档 · 索引补充(仅当对应表增长时)** + +| 表 | 建议索引 | 触发条件 | +|---|---|---| +| `oms_stock_info` | `idx_order_code(order_code)` | 行数 > 10 万 | +| `oms_finance_charge` | `idx_charge_status(charge_status)` | 行数 > 10 万 | +| `oms_purchase_order` | `idx_status_date(status, purchase_date)` | 行数 > 10 万 | +| `oms_inventory_outer/inner/delivery` | `idx_create_time(create_time)` | 行数 > 10 万(当前 580~756 行,**不必加**) | + +### 14.7 补全后的代价与取舍(v7 已决策) + +| 选择 | 工具数 | 覆盖 | token / 选错率 | +|---|---|---|---| +| 原 v5 | 8 | 统计维度 60%、无列表能力 | 低 | +| v6 拟定的 A+B+C | 11 | 三大域基本完整 | 中 | +| **v7 采用** | **9** | 三大域覆盖完整(除审批待办、附件、运营报表);统计维度 ~95% | **低**(把 A/B/C 三档合并进 9 个工具,且用 `entity`/`group_by` 参数化而非新增工具) | +| **v8 最终** | **10** | 三域 + **签收** + **撤回历史** + **主数据(批量编码翻译)** | **低**(仅新增 1 个主数据工具,机制复用) | + +**v7 决策**:采纳 **A + B + C + D** 全部四档,但通过 **①合并 v6 的 `purchase_order_detail`/`finance_bill_detail` 进 `purchase_list`/`finance_list`**、**②用参数枚举表达维度与单据类型** 两个手段,把工具数从 11 压回 **9**,在"覆盖完整"与"轻量"之间取得平衡。 + +### 14.8 明确不覆盖的范围(v9 更新) + +1. 采购/财务**审批待办与已办**(`bu_todo` 相关,属流程域); +2. **财务附件与文件内容**; +3. **财务运营报表物化表**(已否决,见 2.1); +4. ~~跨域任意维度~~ → **已改为"受限透视"**:仅白名单维度与单链路度量,见 16.2;**真正的任意 SQL / 无白名单组合仍不开放**; +5. ~~项目进度/POC/报价~~ → **已纳入** `project_list`,见 16.1; +6. 任何**写操作**(新增/修改/删除/审批/撤回/红冲)。 + +--- + +## 十五、规格待定项定义(v8 补全) + +> 本章专门消除"实现时必然产生歧义"的规格空白。**上一版(v7)有 9 处未定义,本章逐条定义。** + +### 15.1 时间维度依据字段(原文只说"支持 TIME_MONTH",未说基于哪个字段) + +| 工具 | 默认时间字段 | 可切换 | 索引依赖 | +|---|---|---|---| +| `inventory_stock_aggregate` | `oms_inventory_info.create_time` | — | P2-12(否则 1 次全表扫描) | +| `purchase_arrival_aggregate` | `oms_purchase_order.purchase_date`(业务口径) | `time_field=CREATE_TIME` | P2-10 | +| `finance_balance_aggregate` | 各单 `create_time`(记账口径) | `time_field=PLAN_DATE` → 应收取 `plan_receipt_date`、应付取 `plan_payment_date` | P2-11 系列 | + +- 默认区间:**近 12 个月**;跨度上限 **36 个月**;跨月/季一律用**区间下推**(`>= 月初 and < 下月初`),**禁止 `date_format(create_time,...)`**(会使索引失效)。 + +### 15.2 维度可用性矩阵(v8 修正:解决"声称支持但无索引支撑"的矛盾) + +区分两种模式:**SUMMARY 只需 1 次扫描,维度不受索引限制;LIST 的分页游标必须与索引顺序一致。** + +| `group_by` | SUMMARY | LIST(游标分页) | 依赖索引 | +|---|---|---|---| +| `NONE` | ✅ | —(不分页) | — | +| `PRODUCT` | ✅ | ✅ | `oms_inventory_info.idx_product_code` | +| `WAREHOUSE` | ✅ | ✅ | 库存大表需 **P2-7** | +| `PRODUCT_WAREHOUSE` | ✅ | ⚠️ 需 **P2-13** `(warehouse_id, product_code)`,否则 filesort | P2-13 | +| `STATUS` | ✅(1 次全表扫描,实测 ~40ms @6.5 万行) | ⚠️ 需 **P2-14** `(inventory_status, product_code)` | P2-14 | +| `TIME_MONTH` / `TIME_QUARTER` | ✅ | ⚠️ 需 P2-12 / P2-10 | P2-12 / P2-10 | +| `ORDER` | ✅ | ✅ | 各表 `order_code` 索引 | +| `VENDOR` | ✅ | ✅ | **P1-2** | +| `PARTNER` | ✅ | ⚠️ 需 **P2-11** | P2-11 | +| **`OVERDUE_BUCKET`(v13 新增)** | ✅ | ⚠️ **仅 SUMMARY**(分桶后行数固定 ≤4,无需 LIST 分页) | — | + +**统一规则**: +1. **LIST 模式下,若该维度无索引支撑 → 不静默 filesort,而是返回 `INVALID_PARAMS` 并提示"该维度请使用 `mode=SUMMARY`"**(避免深分页把库拖垮); +2. **移除 `OWNER` 维度**:`oms_purchase_order.owner_name` 无索引且业务价值未确认(v7 的 A 档曾声称支持,属方案自相矛盾,此处更正)。 + +### 15.3 `arrival_delay_days` 语义与成本(原文未定义) + +- **定义**:对每条采购明细行,`delay = datediff(该明细首次入库时间, item.delivery_date)`;采购单维度取 **`max(delay)`**(最晚到货);未入库的明细不计入;无入库记录返回 `null`。 +- **首次入库时间来源**:`oms_inventory_inner`(按 `purchase_no` 取 `min(create_time)`)按 `product_code` 与 `oms_inventory_inner_detail` 对齐。 +- **成本与前提**:`oms_inventory_inner.purchase_no` **无索引** → 需 **P2-15**;**默认不计算**(仅当 `metrics` 显式包含 `ARRIVAL_DELAY_DAYS` 时才发起该 join),并在 `metadata` 标注该度量的额外成本。 + +### 15.4 `sub_cursor` 协议(原文只提名字,未定义用法)—— ✅ 已实现 + +| 项 | 约定 | +|---|---| +| 入参回传 | `sub_list`(子列表名)+ `sub_cursor`(字符串);`sub_cursor` 与主 `cursor` **互斥**;首次翻页无游标时须给 `sub_parent`(父实体标识),`sub_parent` 也随游标携带,续页时无需再传 | +| 返回 | `data` = `{sub_list, sub_parent, total(该子列表总条数), items(本页), sub_page_info}`;`sub_page_info` 结构与 `page_info` 完全一致,`sort_by` 为该子表的排序键 | +| 编码 | 同主游标格式,但 `t = ":"`;`f` 中额外包含主实体标识(`inventory_flow` 为 `outer_code`,`finance_order_position` 为账单号);`k[0]` = 续页偏移量 | +| 返回游标位置 | 主响应截断时 `data.truncated_sub_lists[]` 给出 `{list, parent, total, returned, next_cursor}`,调用方**直接回传** `next_cursor` 即可续页(不需要自己算偏移) | +| 上限 | 子列表 `page_size ≤ 100`;`max_pages ≤ **200**`(**原文为 20,实测不足**:20×100=2000 行 < 实测单出库单 2682 条 SN 明细,会导致"截断 + 游标也取不完"的数据缺失;改为 200 后单父实体最多可取 20,000 行) | +| 幂等性 | 主实体不变时同一 `sub_cursor` 可重复调用且结果稳定(实现上按子表主键 `id` 升序定序后切片;部分子表 SQL 无 `order by`,故**在内存中按 id 统一定序**,保证"截断点 == 续页起点") | +| 覆盖范围 | `inventory_flow`:`outerDetails` / `snDetails` / `deliveries`;`finance_order_position`:`receiptPlans` / `receiptDetails` / `invoicePlans` / `paymentPlans` / `paymentDetails` / `ticketPlans` | +| 明确不覆盖 | 嵌套二级子列表(如 `deliveries[].productSns`)不单独提供游标;如需按物流单追溯 SN,请用 `warehouse_list(entity=SN)` 或 `inventory_flow` 的 `snDetails` 子列表 | + +### 15.5 `metrics` 全枚举 与 参数冲突规则(原文只给示例) + +**metrics 全枚举** + +| 域 | 取值 | +|---|---| +| 库存 | `QTY`、`IN_STOCK_QTY`、`OUT_STOCK_QTY`、`INNER_AMOUNT`、`OUTER_AMOUNT` | +| 采购 | `PURCHASE_QTY`、`INNER_QTY`、`PENDING_QTY`、`ARRIVAL_RATE`、`ARRIVAL_DELAY_DAYS`、`AMOUNT_TOTAL`、`TAX_TOTAL` | +| 财务 | `RECEIVABLE_*`、`RECEIVED_*`、`UNRECEIVED_*`、`INVOICED_*`、`UNINVOICED_*`、`PAYABLE_*`、`PAID_*`、`UNPAID_*`、`TICKETED_*`、`UNTICKETED_*`、`OVERDUE_DAYS`(`*` ∈ `WITH_TAX` / `WITHOUT_TAX` / `TAX`) | + +**参数冲突规则(一律返回 `INVALID_PARAMS` 并回显允许值,不静默忽略)** + +| 冲突组合 | 处理 | +|---|---| +| `mode=SUMMARY` + `cursor` / `page_size` | 报错(SUMMARY 不分页) | +| `mode=LIST` + `top_n` | 报错(`top_n` 仅 SUMMARY 可用) | +| `cursor` + `page` | 报错 | +| `mode=LIST` 且聚合工具未给范围 | 报错并提示改用 `SUMMARY` | +| `metrics` 含未定义值 | 报错并回显允许枚举 | +| `include_detail=false` + `code_list`(列表工具) | 允许(等价于按单号查表头) | +| `group_by=OVERDUE_BUCKET` + `mode=LIST` | 报错(分桶行数固定 ≤4,只支持 SUMMARY) | +| `as_of_date` 早于最早单据日期 | 允许,返回 0 并在 `metadata` 标注 | + +### 15.6 `include_zero` 判定标准(原文未定义) + +- **定义**:分组内**全部**所请求 `metrics` 的值均为 `0` 或 `null` → 视为"全零行"; +- 默认 `include_zero=false` → 过滤掉全零行;置 `true` 则返回; +- 判定基于**本次请求的 `metrics` 集合**,而非固定字段集,避免语义歧义。 + +### 15.7 "在库"口径(⚠️ **待业务确认**) + +| 候选 | 定义 | 风险 | +|---|---|---| +| **A(当前默认)** | `inventory_status='0'` 的全部 SN 视为在库 | 若业务含"已被发货单占用未出库"的占用量,则会**高估在库** | +| B | 在 A 基础上排除已被发货单占用但未出库的 SN(需 join `oms_inventory_delivery(_detail)` / `delivery_list`) | 成本更高,需确认占用判定规则 | + +- **当前处理**:默认按 A,并在 `metadata.aggregation_rule` 明确写出"在库 = `inventory_status='0'`,**未扣除已发货占用**"; +- 待业务确认后若改为 B,需同步补索引并重新评估性能。 + +### 15.8 含税 / 未税口径(⚠️ 待确认清单) + +| 字段 | 实测现状 | 处理 | +|---|---|---| +| `oms_inventory_info.inner_price` / `outer_price` | 列注释仅"入库价 / 出库价",**无"含税"字样** | 标注**待确认**,`metadata` 中**不得**写"含税"(v7 曾自行断言为含税,此处更正) | +| `oms_inventory_inner_detail.inner_price` | 列注释明确"入库单价(**含税**)" | 可作为入库明细的含税口径依据 | +| 采购 / 财务金额 | `total_price_with_tax` / `..._without_tax` 字段名自解释 | 直接采用 | + +**原则**:字段名未自解释的,一律标注待确认,**禁止在 `metadata` 中自行断言口径**。 + +### 15.9 多币种策略 + +- 采购 / 财务均有 `currency` 字段(实测固定人民币); +- **不自动换算**,按原币返回;一次聚合跨多种币种时返回 `metadata.mixed_currency=true` 并提示按币种分组查看; +- 不引入汇率表(属新增功能,超出范围)。 + +### 15.10 大小写与排序规则(实测发现,原方案未考虑) + +- 实测:`oms_inventory_info` / `oms_inventory_outer` / `oms_receivable_bill` / `oms_inventory_delivery` 等表的 collation 均为 **`utf8mb4_unicode_ci`** → **大小写与重音不敏感**,即 `where outer_code = 'c-xxx'` 会匹配到 `C-XXX`; +- **策略**:编码类过滤保持现状(与页面行为一致),但在 `metadata` 声明"编码匹配不区分大小写";如需严格区分,仅对单号精确点查提供 `exact_case=true` → SQL 使用 `where binary outer_code = :code`。 + +### 15.11 运行参数与可观测性(原文缺失)—— 超时/限流 ✅ 已实现 + +| 项 | 约定 | +|---|---| +| 查询超时 | ✅ 聚合类 **3000ms**、列表/点查类 **5000ms**。实现:`McpService` 在调用工具前按工具名(`*_aggregate` → 3s,其余 5s)写入 `McpQueryTimeout` 线程上下文,`McpQueryTimeoutInterceptor`(MyBatis `StatementHandler.prepare` 插件)读取后 `Statement.setQueryTimeout(n)`;未设置上下文的普通页面/报表 SQL **不受影响**。超时由驱动 `KILL QUERY` 中止并映射为 **`-32003 query_timeout`** | +| 限流 | ✅ 同一机器人 **60 次/分**(滑动窗口)。**实现偏离原文**:项目未引入 Redis,故落为**进程内** `McpRateLimiter`(key = `X-Bot-Id`,无凭证时退回绑定用户/匿名;命中返回 **`-32002 rate_limit_error`**)。多实例部署时为单实例口径,若需全局精确限流须改 Redis | +| 日志埋点 | ⏳ 未实现(`tool`/`entity`/`mode`/`duration_ms`/`rows`/`page_no`/`has_more`/`truncated_by_bytes`/`filter_hash`) | +| schema 版本 | `metadata.schema_version = 1`(以代码常量为准;破坏性变更时递增) | +| 类型规范化 | `varchar` 一律 `String.valueOf()` 传入;`int` 一律 `Integer/Long`(见 13.5 实测) | + +### 15.12 备份表与白名单(防误用,实测发现风险) + +实测 `oms_test` 存在大量备份/历史表,**必须显式排除**,任何工具不得指向: + +`oms_inventory_info_copy1`(**42,836 行**)、`delivery_list_0618`(11,659)、`product_info_260916`、`product_info_20260904bak`、`project_info_20260904bak`、`project_info_0707`、`project_order_info_1028` / `_0627` / `_bak1`、`project_product_info_bak` / `_0708` / `_0627`、`order_info_0707` / `_0708` / `_bak`、`oms_purchase_order_1211`、`oms_payable_bill_copy1`、`oms_finance_operate_report*` 等。 + +**另有两张"看起来能用但已确认不采用"的表(v10 补充)**: + +| 表 | 行数 | 处理 | +|---|---|---| +| `vendor_info` | 5 | **明确不采用**:供应商主数据**只用 `oms_vendor_info`**(17 行,字段含账期/银行/省市);不因查不到编码而回退此表 | +| `oms_inventory_inner_detail` | 1 | **不采用**:定义上是入库产品行,但实测仅 1 行(未启用);**入库明细以 `oms_inventory_info`(按 `inner_code`)为准** | + +**实现要求**:工具内以**表白名单**方式声明可访问表(而非黑名单),新增 entity 必须显式登记;`oms_inventory_info_copy1` 行数与正式表同量级,误用后果严重。 + +### 15.13 测试数据与压测方案(原文缺失) + +- **边界数据**:部分入库的采购单、多仓发货的出库单、已签收/未签收的 manage 域发货单、超期未收的应收单、已撤回的发货单、`tax_rate` 为 NULL 的 SN、软删除(`deleted_at` 非空)的发货明细; +- **压测用例**:`inventory_sn_trace`(50 SN)、`warehouse_list(entity=SN)`(连续翻页 1 万行)、三个聚合工具的 `SUMMARY`(`group_by=NONE` 与 `top_n=10`),逐一记录 P95; +- **并发验证**:模拟 3 个并发 bot 调用,确认 Druid `maxActive=20` 不被占满、限流与超时按预期触发。 + +### 15.14 编码与枚举的实测异常(v11 新增) + +**① 编码字段存在脏数据(前导制表符)** +- 实测:`order_info.order_code` 有 **14 行**以制表符 `\t` 开头(`project_order_info` 为 0 行); +- 影响:直接 `=` 或 `join` 会漏关联;且 `utf8mb4_unicode_ci` 下部分控制字符被折叠,导致**同一条件下 `EXISTS` 与 `LEFT JOIN` 结果不一致**(实测 `EXISTS` 命中 330 行,而 `LEFT JOIN` 取前 5 行均为 null); +- 处理:**所有编码比对与 join 一律 `trim()`**;返回结果中的编码也 `trim()` 后输出,避免 Agent 看到脏值。 + +**② 枚举实际值与列注释不符** +- `order_info.order_type` 列注释为"1-直签合同,2-代理商合同",**实测实际值是 `zq`(205) 与 `dls`(161)**(推测 `zq`=直签、`dls`=代理商,**需业务确认**); +- 处理:`orderTypeName` **按实际值 `zq`/`dls` 映射**,不得按注释的 1/2;并在 `metadata` 标注"取值与列注释不一致,已按实测值映射"; +- `order_info.status` 实测 0(345)/1(21),与注释"0-有效,1-无效"一致 ✅;且 `status=0` 的行数**恰等于** `deleted_at is null` 的 345 行 → 两者语义重合,**工具按 `deleted_at is null` 过滤即可**。 + +**③ 两套订单模型只能部分对齐**:`order_info` 去空白后仅 **330/366** 能命中 `project_order_info` → 必须容忍关联不到(详见 16.1)。 + +**④ 通用规则(并入 15.5 的类型规范化)**:所有 `varchar` 编码的**入参与出参统一 `trim()`**;发现脏数据不得静默忽略,需在 `metadata.data_quality` 提示(如 `{ "trimmed_codes": 14 }`)。 + +**⑤ 实测补充(v14,实现期发现,务必遵守)** + +| 项 | 实测结论 | 处理 | +|---|---|---| +| **MySQL `TRIM()` 不去制表符** | `trim(order_code) in ('ZGXV-20250716SCS001')` 命中 **0** 行,而 `trim(replace(order_code,'\t',''))` 命中 1 行 | `order_info` 的编码比对/游标/排序**三处统一**用 `trim(replace(order_code,'\t',''))`;Java 侧出参用 `trim()` 即可 | +| **`order_delivery.delivery_status` 取值为拼音缩写** | 实测 `qs`(324)/`yf`(31),**不是**列注释的 1/2/3 | 翻译按实测值:`qs`=已签收、`yf`=已发货,并保留数字 1/2/3 兜底 | +| **`purchase_order_map.order_id` 指向 `project_order_info`** | 实测 1116/1123 命中 `project_order_info`,仅 28 条巧合命中 `order_info` | 该表的绑定关系 join `project_order_info.id`;**注意与 `order_delivery.order_id`(→`order_info`)是两条不同链路** | +| **`oms_inventory_info` 无 `purchase_no` 列** | 列清单中不存在(Java 实体字段为派生值) | SN 的采购单号经 `inner_code` 关联 `oms_inventory_inner.purchase_no` 批量补齐,不在 SN 表直取 | +| **`oms_receivable_bill` 无 `plan_receipt_date` 列** | 该列在 `oms_receivable_receipt_plan` | 账龄分桶经 `last_receipt_plan_id` 关联收款计划取 `plan_receipt_date`;无计划的行归入 `NO_PLAN` 桶 | +| **`/mcp` 响应头 `Transfer-Encoding: chunked` 重复(既有问题)** | `McpController` 手工 `setHeader("Transfer-Encoding","chunked")` 后 Tomcat 再补一次 → 出现两个同名头,**curl 会丢弃响应体**(Python `http.client` 正常) | **v15 已修复**:移除手工设置那一行;修复后 `curl` 可直接调试 `/mcp`(实测 HTTP 200 + 正常响应体) | +| **`java.time.LocalDateTime` 序列化缺失(v15 实测)** | SQL 以 `Map` 返回时 `DATETIME` 列可能是 `LocalDateTime`(如 `order_delivery.sign_time`),`McpController` 的裸 `ObjectMapper` 未注册 jsr310 → `InvalidDefinitionException`,且被外层 `catch` 吞掉 → **HTTP 200 + 空响应体**(极难排查) | **v15 已修复**:`McpController` 注册 java.time 序列化器(日期 `yyyy-MM-dd`、时间 `yyyy-MM-dd HH:mm:ss`,与工具约定一致);同时把外层 catch 改为**回写 error 响应**,不再静默吞异常 | +| **空列表导致 `where col in ()`(v15 实测)** | `inventory_sn_trace` 对**未提供**的入口也发起查询 → `in (...)` 空列表是**非法 SQL**(`SQLSyntaxErrorException`) | **v15 已修复**:Java 侧仅对非空列表发起查询(同时减少无用查询) | +| **SQL 引用不存在的列(v15 实测)** | 3 处:`oms_inventory_outer.receivable_bill_code`(列不存在)、`oms_payable_bill.vendor_name`(只有 `vendor_code`)、`project_order_info.project_code/project_name`(在 `project_info` 上) | **v15 已修复**:分别删除该列、改经 `oms_vendor_info` 关联取名称、改用 `project_info` 的列。并新增**系统性校验脚本**:解析新增 select 的全部 `别名.列` 与 `information_schema` 比对(77 条语句 → 最终 0 处不存在列) | + +--- + +## 十六、扩展覆盖与工程完善(v9) + +> 本章补齐上一版的 3 类遗留:**① 覆盖缺口**(跨域任意维度、项目进度/POC/报价);**② 业务口径从"待确认"变为"可配置化落地"**;**③ 工程项**(索引 DDL 执行方案、时间区间策略、从库路由、RAG 工具路由)。 + +### 16.1 新增覆盖 A:项目 / POC / 进度 / 报价 / manage 域合同 → 新工具 `project_list` + +**为什么单独成工具**:这属于"项目域",与仓储/采购/财务的单据结构差异大;塞进 `master_data_list` 会让 schema 更混乱。 + +**入参**:`entity`(必填)、`code_list`、`project_id_list`、`time_range`、`name_like`、`stage_list`、`include_detail`、`page_size`、`cursor` + +| entity | 主表 | 关键返回字段 | 索引 | +|---|---|---|---| +| `PROJECT` | `project_info`(2,212) | `projectCode`/`projectName`、`customerCode`/`customerName`、`agentCode`代表处、`partnerCode`/`partnerName`代理商、`industryType`行业、`bgProperty`BG、`projectStage`项目阶段、`projectGraspDegree`把握度、`estimatedAmount`预计金额、`currencyType`、`estimatedOrderTime`/`estimatedDeliverTime`、`competitor`竞争对手、`countryProduct`是否国产、`jointTrial`是否会审、`poc`、`hzSupportUser`、`operateInstitution`、`keyProblem`、`projectDesc` | `unq_idx(project_code)`、`idx_customer_code`、`idx_agent_code` ✅;按 `partner_code` 需 **P2-24** | +| `PROJECT_PRODUCT` | `project_product_info`(2,809) | `productBomCode`、`model`、`productCode`、`productDesc`、`quantity`、`cataloguePrice`目录价、`catalogueAllPrice`目录总价、`price`/`allPrice`、`guidanceDiscount`、`discount`、`taxRate` | `idx_project_id` ✅;按产品编码跨项目查询需 **P2-23** | +| `PROGRESS` | `project_work_progress`(7) | `projectId`、`workContent`变更内容、`workUser`更新人、`workTime`更新时间 | `idx_project_id` ✅ | +| `POC` | `project_poc_info`(998) + `_detail`(2) | `projectId`、`serverConfig`、`terminalConfig`、`operateSystem`、`vdiVersion`、`processPerson`/`processPhone`研发、`handlePerson`/`handlePhone`现场、`hzInterfacePerson`/`hzInterfacePhone`、`startDate`、`h3cPerson`、`planFinishTime`/`realFinishTime`;明细 `testProgress`测试进展 | 仅 PRIMARY → 需 **P2-21** `(project_id)` | +| `QUOTATION` | `oms_quotation`(0) + `oms_quotation_product_info`(0) | `quotationCode`报价单号、`quotationName`、`quotationAmount`/`discountAmount`、`quotationStatus`、`agentCode`、`amountType`币种、`customerName`;明细产品行同 `PROJECT_PRODUCT` 结构 | 仅 PRIMARY → 需 **P2-22**(两张表各 1 条) | +| **`CONTRACT`(v11 新增)** | **`order_info`**(366) | **manage 域合同**:`orderCode`合同编号、`versionCode`版本号、`projectCode`关联项目编号、`orderName`合同名称、`customerCode`/`customerName`/`customerAddress`/`customerContact`/`customerPhone`/`customerEmail`、`orderType`/`orderTypeName`合同类型、`orderAgentCode`代表处编码、`bgType`BG属性、`orderPartnerCode`代理商编码、`orderDate`合同签订日期、`status`/`statusName`合同状态、`industryType`一级行业、`customerPostcode`、`remark`、`createdAt`/`updatedAt`;**默认过滤 `deleted_at is null`** | `uk_order_code(order_code, version_code)`(UK) ✅ 可按合同号查 | +| **`CONTRACT_PRODUCT`(v11 新增)** | **`order_list`**(854) | `orderId`合同ID、`productCode`BOM编码、`quantity`数量、`price`单价、`discount`折扣、`amount`总价、`remark`、`createdAt`/`updatedAt`、`status`数据状态;**默认过滤 `deleted_at is null`** | `idx_order_id`、`idx_product_code` ✅ | + +> **两套订单/合同模型的关系(v11 实测,务必按此实现)** +> +> | 体系 | 主表 | 明细 | 发货 | 说明 | +> |---|---|---|---|---| +> | **manage 域(销售/交付侧)** | `order_info`(366) | `order_list`(854) | `order_delivery`(355) + `delivery_list`(32,718) | `order_delivery.order_id` **355/355 指向 `order_info`** | +> | 项目域 | `project_order_info`(809) | `project_product_info`(2,809) | — | 由现有 `project_order_info` 工具与 `project_list` 覆盖 | +> +> 1. **禁止按 `id` 跨体系关联**:`order_delivery.order_id` 只能关联 `order_info.id`(按 id 关联 `project_order_info` 会有 242 条"假命中",属数值巧合); +> ⚠️ **v14 补充**:`oms_purchase_order_map.order_id` 恰好相反——实测 **1116/1123 指向 `project_order_info.id`**(仅 28 条巧合命中 `order_info`),故 `purchase_list(entity=ORDER_BIND)` 按 `project_order_info` join。**两张表的 `order_id` 语义不同,切勿套用同一条链路。** +> 2. **`order_code` 只能"部分对齐"**:去空白后 `order_info` 中 **330/366** 能命中 `project_order_info`,**36 行命中不了** → 工具必须**容忍关联不到**,`metadata` 中标注"该合同在项目域无对应记录",**不得报错也不得臆造**; +> 3. 对齐时**必须 `trim()`**:`order_info` 有 **14 行** `order_code` 带前导制表符(脏数据),不 trim 会漏关联(见 15.14)。 + +> **价值**:补齐"按合同号点查订单/项目""这个项目的 POC 到哪一步了""报价单有哪些产品"等此前完全无法回答的问题(`project_order_info` 现有工具仅支持按创建时间范围查询)。 + +### 16.2 新增覆盖 B:跨域任意维度(客户 × 产品 × 月)→ 新工具 `cross_domain_aggregate` + +**先说结论**:**真正的"任意维度"不可能安全实现**(等价于开放任意 SQL,性能与安全都不可控)。因此本方案提供的是**受限透视(restricted pivot)**:维度与度量来自**白名单**,且**度量必须属于同一条数据链路**。 + +**入参**:`dimensions`(1–3 个,白名单)、`metrics`(白名单)、`time_range`(**必填**,≤36 个月)、`time_granularity`(`MONTH`/`QUARTER`)、`top_n`(默认 20,上限 200)、`filters` + +**维度白名单**:`PARTNER`(客户/进货商)、`CUSTOMER`、`AGENT`(代表处)、`PRODUCT`、`PROJECT`、`WAREHOUSE`、`MONTH`、`QUARTER` + +**度量白名单与数据链路(关键:同链路才可混合)** + +| 链路 | 事实表 / 连接路径 | 可用度量 | +|---|---|---| +| `SALES` | `project_product_info` ⋈ `project_order_info`(→`project_id`、`order_code`、`partner_code`)⋈ `project_info`(→`customer_code`、`agent_code`) | `SALES_AMOUNT_WITH_TAX`、`SALES_AMOUNT_WITHOUT_TAX`、`SALES_QTY` | +| `PURCHASE` | `oms_purchase_order_item` ⋈ `oms_purchase_order` | `PURCHASE_QTY`、`PURCHASE_AMOUNT`、`PURCHASE_TAX` | +| `STOCK` | `oms_inventory_info` | `IN_STOCK_QTY`、`OUT_STOCK_QTY`、`INNER_AMOUNT` | +| `FINANCE_AR` | `oms_receivable_bill` | `RECEIVABLE_WITH_TAX`、`RECEIVED_WITH_TAX`、`UNRECEIVED_WITH_TAX`、`INVOICED_WITH_TAX`、`UNINVOICED_WITH_TAX` | +| `FINANCE_AP` | `oms_payable_bill` | `PAYABLE_WITH_TAX`、`PAID_WITH_TAX`、`UNPAID_WITH_TAX`、`TICKETED_WITH_TAX`、`UNTICKETED_WITH_TAX` | + +**典型问题映射**: +- "客户 × 产品 × 月 的销售额" → `dimensions=[CUSTOMER, PRODUCT, MONTH]`、`metrics=[SALES_AMOUNT_WITH_TAX]`、链路 `SALES`; +- "代表处 × 月 的未收款" → `dimensions=[AGENT, MONTH]`、`metrics=[UNRECEIVED_WITH_TAX]`、链路 `FINANCE_AR`。 + +**护栏(必须)**: +1. **`time_range` 必填**(否则拒绝),跨度 ≤ 36 个月 → 保证走时间区间下推而非全表; +2. 至少一个维度**有索引支撑**;否则要求 `allow_full_scan=true` 显式确认(默认拒绝); +3. 度量**跨链路混用直接报错**(如同时要 `SALES_AMOUNT_WITH_TAX` + `UNRECEIVED_WITH_TAX`);如需,须分两次调用; +4. 分组数上限 `max_groups=1000`,超出报错并提示收窄; +5. **`mode=SUMMARY`(默认)** 返回 Top-N + 全局合计;`mode=LIST` 走游标分页,且维度必须与索引顺序一致(沿用 15.2 规则); +6. 结果必须带 `metadata.lineage`(说明本结果来自哪条链路与哪些表),避免 Agent 误读口径。 + +**成本提示**:`SALES` 链路是 **3 表 join + 分组**,是本方案最重的查询;实测规模下预计 **数百 ms 级**,在百万行级需要 `project_order_info(order_code)`(已有)与 `project_product_info(project_id)`(已有)支撑;时间维度需 **P2-25**。文档中必须标注"该工具为**兜底能力**,优先使用域内专用工具"。 + +### 16.3 业务口径:从"待确认"改为"可配置化落地"(解决 15.7 / 15.8 阻塞) + +> ⚠️ **2026-09-23 状态更新:本节设计已作废(不予采用)**。曾按本节实现 `mcp.inventory.stock-basis` / `price-basis` 配置项(application.yml + `@ConfigurationProperties` + 3 个聚合查询的 `` 分支 + 工具参数覆盖),经确认**不引入配置机制,已全部回滚**。当前实际状态:口径**写死在 SQL 与 metadata 声明中**,业务确认后需改代码(见第十二章遗留 9/10)。本节以下内容仅作为历史设计记录保留。 + +原则:**口径不确定时不阻塞编码,而是做成配置项 + 参数 + 校验 SQL**,业务确认后改配置即可,**不改代码**。 + +| 口径 | 配置项(application.yml) | 工具参数(可选覆盖) | 元数据行为 | 业务校验 SQL(可直接执行) | +|---|---|---|---|---| +| 在库(15.7) | `mcp.inventory.stock-basis: IN_STOCK_ONLY`(默认) / `EXCLUDE_DELIVERED` | `stock_basis` | `metadata.aggregation_rule` 写明当前口径与"是否扣除已发货占用" | ① `select count(*) from oms_inventory_info where inventory_status='0'`;② 扣除占用:`... and product_sn not in (select d.product_sn from oms_inventory_delivery_detail d join oms_inventory_delivery m on m.id=d.delivery_id where m.delivery_status in ('0','1'))` → 两者差值即"占用量",据此判断口径 | +| 价格含税(15.8) | `mcp.inventory.price-basis: UNKNOWN`(默认) / `WITH_TAX` / `WITHOUT_TAX` | — | 仅当配置为 `WITH_TAX`/`WITHOUT_TAX` 时,元数据才声明含税/未税;`UNKNOWN` 时字段注释只写"入库价/出库价" | 取同一 SN 与采购单明细比对:`select i.product_sn, i.inner_price, pi.amount_total/pi.quantity as po_unit_price, pi.tax_rate from oms_inventory_info i join oms_purchase_order_item pi on pi.product_code=i.product_code limit 20` → 若 `inner_price ≈ po_unit_price` 则为含税,若 `inner_price ≈ po_unit_price/(1+tax_rate)` 则为未税 | + +> 这两条把"业务确认"从**阻塞项**降级为**配置项**,可立即进入编码;上线前由业务跑一次校验 SQL 决定配置值。 + +### 16.4 索引 DDL 执行方案(执行项 1) + +**执行顺序与窗口** + +| 阶段 | 内容 | 窗口 | 影响 | +|---|---|---|---| +| 预检 | 在生产/正式测试库执行 `SHOW INDEX FROM
` 复核 2.5 节结论;确认无同名索引 | 任意 | 只读 | +| 第 1 步 | P0 两条(`oms_inventory_outer.outer_code`、`oms_inventory_outer_detail.outer_code`) | 低峰 | 表小(734/750 行),秒级 | +| 第 2 步 | P1-1、P1-2(`oms_inventory_inner.order_code`、`oms_purchase_order.vendor_id`) | 低峰 | 表小,秒级 | +| 第 3 步 | **P1-3 覆盖索引**(`oms_inventory_info` 6.5 万行) | 低峰,避开入库高峰 | 在线 DDL,`ALGORITHM=INPLACE, LOCK=NONE`;**需评估写入放大** | +| 第 4 步 | P2 系列 | 按触发条件(表 > 10 万行)再执行 | — | + +**统一 DDL 形态(含在线参数)** + +```sql +ALTER TABLE
ADD INDEX (), ALGORITHM=INPLACE, LOCK=NONE; +``` + +**体积与耗时估算方法**(执行前评估,不靠猜) +- 索引体积 ≈ `行数 × (键长 + 主键长 + 约 15 字节开销)`。P1-3 键长约 `product_code(≤255, 实测样例 8~12 字符) + inventory_status(1) + 2×decimal(10,2)` ≈ 60–80 字节 → 6.5 万行约 **5–8 MB**;百万行约 **80–120 MB**(需与 DBA 确认磁盘)。 +- 在线加索引耗时 ≈ 全表扫描 + 排序一次的量级;`oms_inventory_info` 单次全表聚合实测 ~40ms,故预计 **秒级~分钟级**。 + +**验证与回滚** +- 验证:`SHOW INDEX FROM
WHERE Key_name=''` + 对目标语句 `EXPLAIN` 确认 `key` 命中且 `type` 非 `ALL`; +- 回滚:`ALTER TABLE
DROP INDEX , ALGORITHM=INPLACE, LOCK=NONE;`(随时可执行)。 + +### 16.5 时间维度区间的可配置策略(执行项 2) + +| 配置项 | 默认 | 说明 | +|---|---|---| +| `mcp.aggregate.time-range.default-months` | `12` | 未传 `time_range` 时的默认窗口 | +| `mcp.aggregate.time-range.max-months` | `36` | 请求上限,超出报 `INVALID_PARAMS` | +| `mcp.aggregate.time-range.large-window-threshold` | `24` | 超过该值且涉及**大表**时,要求显式 `allow_large_scan=true` | + +**结论**:**不放开默认区间**。若业务确需 3 年以上历史,两条路径①先加 P2-12/P2-10 时间索引(推荐)②在加了索引前以 `allow_large_scan=true` 承担扫描成本。 + +### 16.6 从库路由实现方案(执行项 3) + +**项目已具备能力(实测确认)**:`DynamicDataSource`、`DataSourceType`、`@DataSource`、`DataSourceAspect`、`DynamicDataSourceContextHolder`;`application-dev.yml` 中 `slave.enabled=false`(默认关闭)。 + +**实现方式(推荐:工具内显式切换,而非依赖 AOP)** + +- 原因:`@DataSource` 是 AOP 注解,只在 **Spring Bean 方法调用**上生效;MCP 工具直接调 Service/Mapper,用注解易失效; +- 做法:在公共基类中按配置决定是否切换: + +``` +if (readonlyRouteEnabled(toolName)) { + DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.SLAVE); + try { ...执行查询... } + finally { DynamicDataSourceContextHolder.clearDataSourceType(); } // 必须清理,线程池复用会串库 +} +``` + +**路由白/黑名单(关键约束)** + +| 允许走从库 | 禁止走从库 | +|---|---| +| `inventory_sn_trace`、`inventory_flow`、`inventory_stock_aggregate`、`warehouse_list`、`purchase_*`、`project_list`、`master_data_list` | **所有财务类工具**(`finance_*`、`finance_order_position`、`finance_balance_aggregate`、`cross_domain_aggregate` 的 `FINANCE_*` 链路)—— 金额不允许受主从延迟影响 | + +- 配置:`mcp.datasource.readonly-route.tools: <逗号分隔白名单>`,`enabled: false` 默认全走主库; +- 前置条件:需 DBA 提供可用从库并确认**延迟指标**(建议延迟 > 1s 时自动降级回主库,通过定时探活实现); +- 收益/代价:把"统计类聚合"从主库剥离;代价是**读到的数据可能滞后**,需在 `metadata` 标注 `data_source: SLAVE`。 + +### 16.7 RAG 工具路由(补上 `prompt.md` 未实现部分) + +**现状问题**:`tools/list` 全量下发 12 个工具(10 新 + 2 现),每轮 schema token 高、模型选错率上升。 + +**实现方案(新增 3 个类,放在 `com.ruoyi.sip.llm`)** + +| 类 | 职责 | +|---|---| +| `ToolEmbedding` | `toolName` / `description` / `embedding(double[])` | +| `ToolRetriever` | `@PostConstruct` 时从 `McpToolRegistry.list()` 建内存索引;提供 `retrieve(query, topK)` | +| `ToolRouter` | 接收用户问题 → 调 `retriever` → 返回候选工具集(**不返回全量**,默认 topK = 5) | + +**中文场景下的向量化(关键,不能用英文分词照搬)** +- 分词:中文用**字符 2-gram**(`"库存汇总"` → 库存/存汇/汇总)+ 英文/编码按空格与驼峰切分; +- 加权:**关键词命中加权**(同义词表)+ TF-IDF 余弦相似度;不依赖外部 embedding 服务(满足 `prompt.md` 的"保证可运行"要求); +- 同义词表**配置化**(`mcp.router.synonyms`,映射"词 → 工具名"),便于新增工具时零改码: + - 库存/存货/在库/结存/备货 → `inventory_stock_aggregate`、`inventory_sn_trace` + - 发货/物流/签收 → `warehouse_list(entity=ORDER_DELIVERY)` + - 欠款/未收/未付/账龄/超期 → `finance_balance_aggregate` + - 项目/立项/POC/会审/报价 → `project_list` + - 主数据/编码名称/客户/供应商 → `master_data_list` + +**接入点(向后兼容,不破坏现有客户端)** + +| 位置 | 行为 | +|---|---| +| `tools/list` | 若请求带 `params.query` → 只返回 topK;**不带则仍返回全量**(现有客户端不受影响) | +| `tools/list` | 新增 `params.detail=false` → 只返回 `name` + `description`(精简 schema),需要完整 `inputSchema` 时置 `true` | +| `tools/call` | 未指定 `name` 时 → 自动路由;若 top1 分数 < `min-score`(默认 0.15)→ **返回候选列表并报 `INVALID_PARAMS`**,而不是乱选 | +| 新增 `tools/route` | 只做路由不做执行,便于调试与观测 | + +**质量要求**:每个工具的 `description` 必须含"领域词 + 业务词 + 别名"(如 `inventory_stock_aggregate` 描述需出现"库存、存货、在库、结存"),否则检索命中率上不去。 + +**验收**:准备 20 条中文真实问题,要求**路由命中率 ≥ 80%**,且 `tools/list` 带 `query` 时返回的 schema token 下降 ≥ 50%。 + +### 16.7.1 路由配置示例(含中文 2-gram 同义词表) + +> 放在 `application.yml`,**新增工具只改配置、不改代码**。值支持两种写法:`tool_name` 或 `tool_name#entity`;后者会在路由结果里作为 `suggested_args` 返回(例如命中"签收" → `warehouse_list` + `entity=ORDER_DELIVERY`)。 + +```yaml +# ===== MCP 工具路由(RAG)配置 ===== +mcp: + router: + enabled: true + top-k: 5 # 只返回候选工具,不返回全量 + min-score: 0.15 # 低于该分数不硬选:返回候选并报 INVALID_PARAMS + + # ---------- 检索算法参数 ---------- + text: + char-ngram: 2 # 中文按字符 2-gram 切分:"库存汇总" -> 库存/存汇/汇总 + lowercase: true # 英文/编码统一小写(编码实际为 utf8mb4_unicode_ci,大小写不敏感) + split-camel: true # 驼峰切分:inventoryStockAggregate -> inventory/stock/aggregate + token-min-length: 2 + + weight: # 命中加权(乘到 TF-IDF 得分上) + alias: 3.0 # tool-aliases 命中(人工别名,最可信) + synonym: 2.5 # synonyms 命中 + tool-name: 2.0 # 工具名命中 + description: 1.0 # 描述 TF-IDF 基础权重 + + # ---------- 停止词(不参与向量化) ---------- + stopwords: [的, 了, 和, 与, 及, 或者, 是, 在, 有, 我, 你, 他, 请, 帮, 帮我, 查, 查询, 看, + 看一下, 多少, 几个, 哪些, 什么, 怎么, 如何, 以及, the, a, an, of, for, to, and] + + # ---------- 工具别名(tool -> 词,直接拼进该工具的检索文本) ---------- + tool-aliases: + project_order_info: [订单, 合同, 合同编号, 项目订单, 订单台账, 下单, 订单状态, 归档] + product_info: [产品, 物料, 型号, 产品编码, 目录价, 指导折扣] + inventory_sn_trace: [序列号, SN, 条码, 机身码, 单件, 追溯, 在库, 已出库, 入库价, 出库价] + inventory_flow: [货流, 流转, 单据链, 这条货走到哪, 入出库关联, 物流轨迹, 备货状态] + finance_order_position: [一单到底, 全链路, 收付票, 这单钱到哪一步, 核销情况, 计收, 毛利] + inventory_stock_aggregate: [库存, 存货, 在库, 结存, 库存量, 库存汇总, 库存排行, 占用] + purchase_arrival_aggregate: [采购汇总, 到货率, 到货及时率, 未入库, 在途采购, 采购金额] + finance_balance_aggregate: [欠款, 未收, 未付, 余额, 账龄, 超期, 应收未收, 应付未付, 未开票, 未收票] + warehouse_list: [入库单, 出库单, 发货单, 物流, 签收, 撤单, 撤回记录, 备货] + purchase_list: [采购单, 采购订单, 采购明细, 供应商, 制造商, 采购变更, 采购历史] + finance_list: [应收单, 应付单, 收款单, 付款单, 发票, 收票, 核销单, 计收单, 单据明细] + master_data_list: [主数据, 编码转名称, 名称对照, 客户, 进货商, 代理商, 代表处, 办事处, 系统用户] + project_list: [项目, 立项, 项目进度, POC, 试点, 会审, 报价, 报价单, 项目清单, 把握度] + cross_domain_aggregate: [交叉分析, 透视, 组合分析, 客户产品, 按客户按产品, 按代表处, 多维] + + # ---------- 同义词表(用户口语 -> 工具/实体,用于加权) ---------- + synonyms: + # ===== 仓储 ===== + 库存: [inventory_stock_aggregate, inventory_sn_trace] + 存货: [inventory_stock_aggregate] + 在库: [inventory_stock_aggregate, inventory_sn_trace] + 结存: [inventory_stock_aggregate] + 占用量: [inventory_stock_aggregate] + 条码: [inventory_sn_trace] + 序列号: [inventory_sn_trace] + SN: [inventory_sn_trace] + 扫码: [inventory_sn_trace] + 入库单: [warehouse_list#INNER, inventory_flow] + 到货入库: [warehouse_list#INNER, purchase_arrival_aggregate] + 出库单: [warehouse_list#OUTER, inventory_flow] + 发货单: [warehouse_list#DELIVERY, inventory_flow] + 签收: [warehouse_list#ORDER_DELIVERY] + 收货: [warehouse_list#ORDER_DELIVERY] + 物流: [warehouse_list#ORDER_DELIVERY, warehouse_list#DELIVERY] + 快递单号: [warehouse_list#ORDER_DELIVERY] + 撤回: [warehouse_list#RECALL, warehouse_list#DELIVERY] + 撤单: [warehouse_list#RECALL] + 备货: [warehouse_list#STOCK, inventory_flow] + 货流: [inventory_flow] + 流转: [inventory_flow, warehouse_list] + + # ===== 采购 ===== + 采购单: [purchase_list#ORDER, purchase_arrival_aggregate] + 采购订单: [purchase_list#ORDER] + 采购明细: [purchase_list#ITEM] + 采购变更: [purchase_list#HISTORY] + 采购历史: [purchase_list#HISTORY] + 到货率: [purchase_arrival_aggregate] + 到货及时: [purchase_arrival_aggregate] + 未入库: [purchase_arrival_aggregate, purchase_list#ORDER] + 在途采购: [purchase_arrival_aggregate] + 供应商: [master_data_list#VENDOR, purchase_list#ORDER] + 制造商: [master_data_list#VENDOR, purchase_list#ORDER] + 账期: [master_data_list#VENDOR] + + # ===== 财务 ===== + 应收: [finance_list#RECEIVABLE, finance_balance_aggregate] + 应付: [finance_list#PAYABLE, finance_balance_aggregate] + 收款: [finance_list#RECEIPT] + 回款: [finance_list#RECEIPT, finance_balance_aggregate] + 付款: [finance_list#PAYMENT] + 开票: [finance_list#INVOICE] + 发票: [finance_list#INVOICE] + 收票: [finance_list#TICKET] + 核销: [finance_list#RECEIPT, finance_list#PAYMENT, finance_order_position] + 计收: [finance_list#CHARGE, finance_order_position] + 毛利: [finance_order_position, finance_list#CHARGE] + 欠款: [finance_balance_aggregate] + 未收: [finance_balance_aggregate] + 未付: [finance_balance_aggregate] + 账龄: [finance_balance_aggregate] + 超期: [finance_balance_aggregate] + 未开票: [finance_balance_aggregate] + 未收票: [finance_balance_aggregate] + + # ===== 项目 / 报价 ===== + 项目: [project_list#PROJECT] + 立项: [project_list#PROJECT] + 项目进度: [project_list#PROGRESS] + 把握度: [project_list#PROJECT] + 试点: [project_list#POC] + POC: [project_list#POC] + 会审: [project_list#PROJECT, project_list#POC] + 报价: [project_list#QUOTATION] + 报价单: [project_list#QUOTATION] + + # ===== 主数据 ===== + 客户: [master_data_list#CUSTOMER, master_data_list#PARTNER] + 进货商: [master_data_list#PARTNER] + 代理商: [master_data_list#PARTNER] + 代表处: [master_data_list#AGENT] + 办事处: [master_data_list#AGENT] + 产品: [master_data_list#PRODUCT, product_info] + 型号: [master_data_list#PRODUCT, product_info] + 编码转名称: [master_data_list] + 名称对照: [master_data_list] + 系统用户: [master_data_list#USER] + + # ===== 跨域 / 组合分析 ===== + 透视: [cross_domain_aggregate] + 组合分析: [cross_domain_aggregate] + 按客户按产品: [cross_domain_aggregate] + 交叉分析: [cross_domain_aggregate] + 多维: [cross_domain_aggregate] + + # ===== 订单(现有工具) ===== + 订单: [project_order_info] + 合同: [project_order_info] + 合同编号: [project_order_info] + + # ---------- 路由回归用例(自动化验收:命中率 ≥ 80%) ---------- + test-cases: + - { q: 这批货走到哪了,出库了吗, expect: inventory_flow } + - { q: 这个SN现在在库还是已经出库, expect: inventory_sn_trace } + - { q: 某产品还有多少库存在哪个仓库, expect: inventory_stock_aggregate } + - { q: 这个月有哪些采购单还没入库, expect: purchase_list#ORDER } + - { q: 上个月采购到货率怎么样, expect: purchase_arrival_aggregate } + - { q: 哪些订单还欠钱,账龄超过90天的, expect: finance_balance_aggregate } + - { q: 这单收了多少款、开了多少票、计收了没, expect: finance_order_position } + - { q: 本月开了多少发票、收了多少款, expect: finance_list#INVOICE } + - { q: 这个月发货单有哪些没签收, expect: warehouse_list#ORDER_DELIVERY } + - { q: 这个项目的POC到哪一步了, expect: project_list#POC } + - { q: 报价单有哪些产品, expect: project_list#QUOTATION } + - { q: 这些客户编码分别叫什么名字, expect: master_data_list#CUSTOMER } + - { q: 按客户和产品统计每个月的销售额, expect: cross_domain_aggregate } + - { q: 合同 ZGXV-20260313GDS001 的订单信息, expect: project_order_info } + - { q: 9801H0BC 这个产品的目录价是多少, expect: master_data_list#PRODUCT } +``` + +**配置消费方式(Java 侧绑定)** + +```java +@Component +@ConfigurationProperties(prefix = "mcp.router") +public class McpRouterProperties { + private boolean enabled = true; + private int topK = 5; + private double minScore = 0.15; + private Text text = new Text(); + private Map weight = new HashMap<>(); + private List stopwords = new ArrayList<>(); + private Map> toolAliases = new HashMap<>(); + private Map> synonyms = new HashMap<>(); + private List testCases = new ArrayList<>(); + // getter/setter 省略;RuoYi 常用写法,与 @ConfigurationProperties 一致 +} +``` + +**检索流程**:`query` → 去停止词 → 字符 2-gram + 英文分词 → 与「工具名 + 描述 + `tool-aliases`」的 TF-IDF 向量做余弦 → 叠加 `synonyms` 命中加权(按 `weight.synonym`)→ 取 topK;`tool#entity` 命中时在结果中附 `suggested_args`。 + +**冲突与兜底规则** +1. 一个词命中多个工具时按累计权重排序,取 topK(默认 5); +2. top1 分数 `< min-score` → **不硬选**,返回候选列表并报 `INVALID_PARAMS`; +3. `#entity` 只作**参数建议**,不替代 `cross_domain_aggregate` 等工具的必填参数校验(仍需工具自身护栏拦截)。 + +**维护约定**:新增工具时,只需在 `tool-aliases` 与 `synonyms` 各补一行,并加 1–2 条 `test-cases`;回归用例随 CI 跑,命中率跌破 80% 即告警。 + +### 16.9 剩余缺口的影响评估与闭合方案(v12 分析 / v13 已全部采纳) + +> **v13 状态**:#1 审批待办/已办、#2 账龄分桶、#3 财务历史时点余额 **已全部纳入**(见 4.3、9 章、A.15);#4 附件元数据亦已纳入(`finance_list(entity=ATTACHMENT)`)。下表保留作为**决策依据与实现口径**。 + +**先纠正一处会误导的地方**:14.1 / 14.2 的 ❌ 是 **v6 快照**;v7–v11 已闭合其中 **13 项**,加上 v13 的 4 项,当前**三大域已无实质缺口**。 + +| # | 剩余缺口 | 不补的影响 | 闭合方案(实测) | 成本 | 建议 | +|---|---|---|---|---|---| +| 1 | **审批待办 / 已办**(`bu_todo` 61、`bu_todo_completed` 5,876) | 答不了"我还有哪些单要审""现在卡在谁那儿""为什么被驳回""审批耗了多久"。现有 `approve_status`/`approve_node` 只给**状态与节点**,**给不出审批人与意见** | **实测发现:两张业务表已冗余所需字段,无需碰 Flowable 的 `act_*`(2.2 万行、结构风险)**:`bu_todo` 有 `business_key`(业务主键)、`process_key`、`task_name`(节点)、`approve_user_name`(审批人)、`apply_user_name`(发起人)、`apply_time`;`bu_todo_completed` 另有 `approve_time`、**`approve_opinion`(审批意见)**、`approve_status`(**3=通过 / 2=驳回**)、`all_approve_user_name`。已覆盖流程:`order_approve_online/offline`、`purchase_order_online`、`finance_payment`、`fianance_ticket`(原文拼写)、`order_reback`、`outer_reback` —— 正好覆盖**采购、财务付款/收票、仓储撤回**。建议新增工具 **`approval_list`**(entity = `TODO` / `DONE`),按 `approve_user`(当前登录人) + `process_key` + `business_key` 过滤,游标分页 | **低**(索引:`bu_todo` 仅主键但仅 61 行;`bu_todo_completed` 有 `idx_business_key`,5,876 行全表扫亦可接受) | **建议纳入** | +| 2 | **账龄分桶**(0-30 / 31-60 / 61-90 / 90+) | "超 90 天未收有多少"无法直接统计;只有 `OVERDUE_DAYS` 度量,Agent 得自己绕 | `finance_balance_aggregate` 增加 `group_by=OVERDUE_BUCKET`(基于 `plan_receipt_date` 与今天的差额分桶;仅对 `unreceived_amount > 0` 的行计数) | **零** | **建议纳入** | +| 3 | **财务历史时点余额**(原运营报表能力) | 答不了"上月末未收款 vs 本月"的对比;`finance_balance_aggregate` 只给**当前值**(冗余列不支持时间点回溯) | **可按明细重算**:`应收总额(截至T) = Σ receivable_bill.total_price_with_tax where create_time ≤ T`;`已收(截至T) = Σ receipt_detail.receipt_amount where receipt_time ≤ T` → 时点未收 = 两者差。参数 `as_of_date`;**须在 metadata 标注"重算口径,与冗余列当前值口径不同"** | **中**(`receipt_detail.receipt_time` 无索引,大表需补 P2) | 视业务是否需要"历史时点" | +| 4 | **财务附件** | 答不了"这笔付款的凭证/发票影像是什么" | 只提供**附件元数据**:`fileName`/`fileSize`/`fileType`/`priceWithTax`/`relatedBillType` + 关联单据;**不提供文件内容与下载**(MCP 返回文本,不做二进制传输)。实测需 **过滤 `del_flag='0'`**(80/82 有效),且 `related_bill_type` 实际值是 **`payment`(59) / `ticket`(23)**,与列注释写的 `PAYMENT_BILL_RECEIPT` 等**不符**,翻译须按实测值 | **低** | 可选 | + +**另有 2 项不是缺口,而是主动决策**: + +| 项 | 说明 | 将来若需要 | +|---|---|---| +| `OWNER`(负责人/销售维度) | 因无索引 + 业务价值未确认而移除 | 补 `oms_purchase_order.owner_name`、`project_order_info.duty_name` 索引后即可放开,成本低 | +| 报表导出(Excel) | MCP 只返回 JSON,不产出文件 | 可先支持 `format=csv_text`(返回可粘贴的文本表格);真正的文件导出另立项 | + +**其余"不做"项的影响**(已在 14.8 声明):审计日志(`project_operate_log` 3,834 行)→ 答不了"谁改过这单",如需要也可低成本只读;维保入库(0 行)无影响。 + +**影响分级结论** + +| 级别 | 项 | 说明 | +|---|---|---| +| **影响大、建议补** | #1 审批待办/已办、#2 账龄分桶 | 分别是"业务办理"与"财务催收"的高频问法,且**成本都低**;#1 会让工具数 12 → 13(有 RAG 路由后 token 可控) | +| **影响中、需业务确认** | #3 历史时点余额 | 取决于是否需要"期末对比";可按明细重算实现,但属**不同口径** | +| **影响小 / 主动不做** | #4 附件元数据、`OWNER`、报表导出、审计日志 | 均为边缘场景 | + +### 16.8 工具清单最终收敛(v13:13 个) + +| 类 | 数量 | 工具 | +|---|---|---| +| A 标识符点查 | 3 | `inventory_sn_trace`、`inventory_flow`、`finance_order_position` | +| B 聚合(SUMMARY 默认) | 3 | `inventory_stock_aggregate`、`purchase_arrival_aggregate`、`finance_balance_aggregate` | +| C 列表 / 范围 | 5 | `warehouse_list`、`purchase_list`、`finance_list`、`master_data_list`、**`approval_list`(v13)** | +| D 扩展 | 2 | `project_list`、`cross_domain_aggregate` | + +**token 控制**:**13 个工具下,RAG 路由(16.7)从"建议"升级为"必须"**;否则每轮 schema 体积不可接受。落地顺序上,`tools/list` 的 `query` 过滤与精简描述应**与 D 类工具同期上线**。 + +--- + +## 附录 A、字段字典(字段注释) + +> 说明:本附录是**字段注释的唯一权威来源**,实现时须逐字段同步到 `metadata.item_fields`(中文),写法对齐现有 `ProjectOrderInfoToolProvider#buildItemFieldMetadata()`。 +> 命名约定:入参 `snake_case`,返回 `camelCase`。 +> 取值翻译分两类来源,已逐字段标注:**①字典表**(`DictUtils.getDictLabel(dictType, code)`);**②Java 枚举**(`XxxEnum#getValue()`)。 +> 本附录字段均取自实际 domain / Mapper XML;凡未逐值核实的一律标注"待同步",不臆造。 + +### A.0 通用入参(所有分页工具共有) + +| 入参 | 类型 | 必填 | 中文注释 | +|---|---|---|---| +| `page_size` | int | 否 | 每页条数;聚合类默认 20 / 上限 200,明细类默认 20 / 上限 100 | +| `cursor` | string | 否 | 上一页返回的 `next_cursor`;首页不传;与 `page` 互斥 | +| `page` | int | 否 | 页码(兼容用,内部转 OFFSET,仅数据不变时稳定,不推荐) | +| `include_total` | bool | 否 | 是否统计总条数;默认 false;true 时受 `count_cap=50000` 限制 | + +### A.1 `inventory_sn_trace`(SN 明细·点查) + +**入参** + +| 入参 | 类型 | 必填 | 中文注释 | +|---|---|---|---| +| `product_sn_list` | array<string> | 三选一 | 产品序列号/条码列表,≤50(命中唯一索引 `unq_idx_sn`) | +| `inner_code_list` | array<string> | 三选一 | 入库单号列表,≤20 | +| `outer_code_list` | array<string> | 三选一 | 出库单号列表,≤20 | +| `inventory_status` | string | 否 | 库存状态:0=在库,1=出库 | +| `warehouse_id` | int | 否 | 仓库ID(低选择性,须与上述条件并用) | + +**返回 items**(主表 `oms_inventory_info`) + +| 返回字段 | 类型 | 中文注释 | 来源列 | 翻译 | +|---|---|---|---|---| +| `productSn` | string | 产品序列号/条码 | `product_sn` | — | +| `productCode` | string | 产品BOM编码 | `product_code` | — | +| `model` | string | 产品型号 | 关联 `product_info.model` | — | +| `productDesc` | string | 产品描述 | 关联 `product_info.description` | — | +| `inventoryStatus` | string | 库存状态编码 | `inventory_status` | — | +| `inventoryStatusName` | string | 库存状态名称 | — | **枚举** `InventoryInfo.InventoryStatusEnum`:0=入库,1=出库 | +| `innerCode` | string | 入库单号 | `inner_code` | — | +| `outerCode` | string | 出库单号 | `outer_code` | — | +| `orderCode` | string | 合同编号 | `order_code` | **注意:SN 未出库时为空**(实测为空的行数恰等于在库数量),按订单查在库货须经 `inner_code` → `oms_inventory_inner.order_code` | +| `purchaseNo` | string | 采购单号 | `purchase_no` | — | +| `warehouseId` | int | 仓库ID | `warehouse_id` | — | +| `warehouseName` | string | 仓库名称 | 关联 `oms_warehouse_info.warehouse_name` | — | +| `innerPrice` | decimal | 入库价(**含税口径待确认**,见 15.8) | `inner_price` | — | +| `outerPrice` | decimal | 出库价(**含税口径待确认**,见 15.8) | `outer_price` | — | +| `taxRate` | decimal | 税率(**实测存在 NULL**,未税换算按 0 兜底) | `tax_rate` | — | +| `payableBillCode` | string | 对应应付单号 | `payable_bill_code` | — | +| `createTime` | datetime | 创建时间 | `create_time` | — | +| `updateTime` | datetime | 更新时间 | `update_time` | — | + +### A.2 `inventory_flow`(单据流转链·点查) + +**入参**:`outer_code` 或 `order_code`(二选一,必填) + +**返回**:`items[0]` 为一条链,含 5 个分组: + +**① `inner`(入库单,`oms_inventory_inner`)** + +| 返回字段 | 中文注释 | 来源列 | +|---|---|---| +| `innerCode` | 入库单号 | `inner_code` | +| `purchaseNo` | 采购单号 | `purchase_no` | +| `productCode` / `productType` / `model` | 产品BOM编码 / 产品类型 / 型号 | `product_code` / `product_type` / `model` | +| `quantity` | 入库数量 | `quantity` | +| `vendorCode` / `vendorName` | 制造商编码 / 名称 | `vendor_code` / 关联 `oms_vendor_info` | +| `warehouseId` / `warehouseName` / `warehouseType` | 仓库ID / 名称 / 类型 | `warehouse_id` / 关联 / `warehouse_type` | +| `totalAmount` / `taxRate` / `taxTotal` | 入库含税总额 / 税率 / 税额 | `total_amount` / `tax_rate` / `tax_total` | +| `orderCode` | 合同编号 | `order_code` | +| `createTime` | 入库时间 | `create_time` | + +**② `outer`(出库单,`oms_inventory_outer`)** + +| 返回字段 | 中文注释 | 来源列 | +|---|---|---| +| `outerCode` | 出库单号 | `outer_code` | +| `orderCode` | 合同编号 | `order_code` | +| `productCode` / `model` | 产品BOM编码 / 型号 | `product_code` / `model` | +| `quantity` | 应发数量 | `quantity` | +| `deliveryTime` | 发货时间 | `delivery_time` | +| `outerStatus` / `outerStatusName` | 出库状态编码 / 名称 | `outer_status` / **枚举** `InventoryOuter.OuterStatusEnum`:1=待确认,2=已确认,3=已接收,4=已退回 | +| `deliveryStatus` / `deliveryStatusName` | 发货状态编码 / 名称 | `delivery_status` / **枚举** `InventoryOuter.DeliveryStatusEnum`:0=未发货,1=部分发货,2=全部发货,3=已撤回 | +| `receivableBillCode` | 对应应收单号 | `receivable_bill_code` | +| `createTime` | 创建时间 | `create_time` | + +**③ `outerDetails`(出库明细,`oms_inventory_outer_detail`)** + +| 返回字段 | 中文注释 | 来源列 | +|---|---|---| +| `outerCode` | 出库单号 | `outer_code` | +| `warehouseId` / `warehouseName` | 仓库ID / 名称 | `warehouse_id` / 关联 | +| `quantity` | 出库数量 | `quantity` | +| `outerStatus` | 出库状态 | `outer_status` | + +**④ `deliveries`(发货单,`oms_inventory_delivery` + `_detail`)** + +| 返回字段 | 中文注释 | 来源列 / 翻译 | +|---|---|---| +| `outerCode` | 出库单号 | `outer_code` | +| `warehouseId` / `warehouseName` | 仓库ID / 名称 | `warehouse_id` / 关联 | +| `logisticsCompany` / `logisticsCode` | 物流公司 / 物流单号 | `logistics_company` / `logistics_code` | +| `deliveryType` / `deliveryTypeName` | 发货方式 / 名称 | `delivery_type`:1=快递,2=物流,3=自提 | +| `deliveryTime` | 发货时间 | `delivery_time` | +| `deliveryStatus` / `deliveryStatusName` | 发货状态编码 / 名称 | `delivery_status` / **枚举** `InventoryDelivery.DeliveryStatusEnum`:0=待发货,1=已发货,2=撤回 | +| `approveStatus` | 撤回审批状态 | `approve_status` | +| `quantity` | 发货数量 | `quantity` | +| `createByName` | 发货人 | `create_by` 关联 `sys_user` | +| `detailCount` | SN 明细条数 | `oms_inventory_delivery_detail` 计数 | +| `productSns` | SN 列表 | `oms_inventory_delivery_detail.product_sn` | + +**⑤ `snDetails`(SN 明细,`oms_inventory_info`)**:字段同 A.1。 + +**⑥ `stock`(备货状态,`oms_stock_info`,v7 新增)** + +| 返回字段 | 中文注释 | 来源列 / 翻译 | +|---|---|---| +| `orderCode` | 订单编码(合同编号) | `order_code` | +| `stockStatus` / `stockStatusName` | 备货状态编码 / 名称 | `stock_status` / **枚举** `OmsStockInfo`:0=未备货,1=已备货 | +| `onceInStock` | 是否一次备齐 | `once_in_stock` | +| `createTime` | 创建时间 | `create_time` | +| `deliveryTime` | 要求到货时间 | 关联 `project_order_info.delivery_time` | +| `projectCode` / `projectName` | 项目编号 / 名称 | 关联 `project_order_info` → `project_info` | +| `allQuantity` | 应备货总量 | 关联 `project_product_info` 汇总(非本表列) | +| `notifier` / `notifierPhone` / `notifierAddress` | 通知人 / 电话 / 地址 | 关联 `project_order_info` | + +### A.3 `finance_order_position`(订单财务全景·点查) + +**入参**:`order_code`(必填) + +| 分组 | 返回字段(中文注释) | 来源表 | +|---|---|---| +| `receivable` | `receivableBillCode`应收单号、`orderCode`合同编号、`inventoryCode`出库/入库单号、`partnerCode`/`partnerName`客户编码/名称、`productType`/`productCode`产品类型/编码、`totalPriceWithTax`含税总价、`totalPriceWithoutTax`未税总价、`taxRate`税率、`taxAmount`税额、`receivedAmount`已收款金额、`unreceivedAmount`未收款金额、`invoicedAmount`已开票金额、`uninvoicedAmount`未开票金额、`projectCode`/`projectName`项目编号/名称 | `oms_receivable_bill` | +| `receiptPlans` | `planReceiptDate`计划收款日期、`planAmount`计划收款金额、`planRate`计划收款比例 | `oms_receivable_receipt_plan` | +| `invoicePlans` | 计划开票日期/金额/比例(**字段名待同步** `OmsReceivableInvoicePlan`) | `oms_receivable_invoice_plan` | +| `receipts` | `receiptBillCode`收款单号、`receiptStatus`/`receiptStatusName`收款状态(**枚举** `OmsReceiptBill.ReceiptStatusEnum`:-1=已退款,1=未付款,2=已付款,3=未退款)、`actualReceiptTime`实际收款时间、`totalPriceWithTax`含税金额、`writeOffAmount`核销金额、`partnerName`进货商、`receiptMethod`收款方式 | `oms_receipt_bill` | +| `receiptWriteOffs` | `writeOffCode`核销单号、`writeOffType`核销方式(AUTO=自动/USER=人工)、`receiptBillCode`收款单号、`receivableBillCode`应收单号、`writeOffAmount`核销含税金额、`writeOffAmountWithoutTax`核销未税金额、`writeOffTaxAmount`核销税额、`writeOffTime`核销时间 | `oms_receivable_write_off` | +| `invoices` | `invoiceBillCode`开票单号、`invoiceStatus`/`invoiceStatusName`开票状态(**枚举** `OmsInvoiceBill.InvoiceStatusEnum`:-1=已红冲,1=未开票,2=已开票,3=未红冲)、`actualInvoiceTime`实际开票时间、`invoicePriceWithTax`开票含税金额、`partnerName`客户、`approveStatus`审批状态 | `oms_invoice_bill` | +| `payable` | `payableBillCode`应付单号、`orderCode`合同编号、`inventoryCode`入库/出库单号、`vendorCode`/`vendorName`制造商编码/名称、`totalPriceWithTax`含税总价、`totalPriceWithoutTax`未税总价、`taxRate`税率、`taxAmount`税额、`paidPaymentAmount`已付款金额、`unpaidPaymentAmount`未付款金额、`receivedTicketAmount`已收票金额、`unreceivedTicketAmount`未收票金额、`planPaymentDate`计划付款日期、`planTicketDate`计划收票日期 | `oms_payable_bill` | +| `paymentPlans` / `ticketPlans` | `planPaymentDate`/`planAmount`/`planRate`、`planTicketDate`/`planAmount`/`planRate` | `oms_payable_payment_plan` / `oms_payable_ticket_plan` | +| `payments` | `paymentBillCode`付款单号、`paymentStatus`/`paymentStatusName`付款状态(**待同步** `OmsPaymentBill.PaymentStatusEnum`)、`actualPaymentTime`实际付款时间、`totalPriceWithTax`含税金额、`writeOffAmount`核销金额、`payType`付款类型(INNER_PAY/OUTER_PAY)、`preResidueAmount`预付单剩余额度 | `oms_payment_bill` | +| `tickets` | `ticketBillCode`收票单号、`ticketStatus`/`ticketStatusName`收票状态(**枚举** `OmsTicketBill.TicketStatusEnum`:-1=已红冲,1=未收票,2=已收票,3=未红冲)、`actualTicketTime`实际收票时间、`totalPriceWithTax`含税金额、`taxRate`税率 | `oms_ticket_bill` | +| `paymentWriteOffs` / `ticketWriteOffs` | 同 `receiptWriteOffs` 结构(`writeOffCode`/`writeOffType`/金额三件套/`writeOffTime`) | `oms_payable_write_off` / `oms_payable_ticket_write_off` | +| **`charge`(v7 新增)** | `orderCode`合同编号、`chargeStatus`/`chargeStatusName`计收状态(**枚举** `OmsFinanceCharge.ChargeStatusEnum`:0=等待收款,1=可申请计收,2=已申请计收,3=已完成计收)、`bizChargeDate`业务计收时间、`financeChargeDate`财务计收时间、`incomeWithTaxTotal`/`incomeWithoutTaxTotal`收入含税/未税、`costSoftwareWithTax`/`WithoutTax`软件成本、`costHardwareWithTax`/`WithoutTax`硬件成本、`costSoftwareMaintWithTax`/`WithoutTax`软件维保成本、`costHardwareMaintWithTax`/`WithoutTax`硬件维保成本、`costProvinceServiceWithTax`/`WithoutTax`省服务成本、`costOtherWithTax`/`WithoutTax`其他成本、`grossProfit`毛利(派生)、`grossProfitRate`毛利率(派生)、`allCostWithoutTax`成本合计(派生)、`orderChannel`下单通路、`supplier`供货商、`partnerCode`/`partnerName`进货商 | `oms_finance_charge`(`projectCode`/`projectName` 为关联字段,表内**无** `project_code` 列) | + +> 子列表(`receipts`/`payments`/`invoices`/`tickets`/`writeOffs`)超单页上限时返回该子列表的 `sub_cursor`,仅供该子列表翻页。 + +### A.4 `inventory_stock_aggregate`(库存汇总·分页) + +**入参**:`product_code_list`(≤20,可选,用于收窄)、`inventory_status`(可选)、`include_warehouse_breakdown`(bool,默认 true) + +**返回 items**(主表 `oms_inventory_info` 聚合) + +| 返回字段 | 类型 | 中文注释 | 口径 | +|---|---|---|---| +| `productCode` | string | 产品BOM编码 | `product_code` | +| `productName` | string | 产品名称 | 关联 `product_info.product_name` | +| `inStockQty` | long | 在库数量 | `count(*) where inventory_status='0'` | +| `outStockQty` | long | 已出库数量 | `count(*) where inventory_status='1'` | +| `innerAmount` | decimal | 入库金额合计(含税) | `sum(inner_price)`,NULL 计 0 | +| `outerAmount` | decimal | 出库金额合计(含税) | `sum(outer_price)`,NULL 计 0 | +| `warehouses[]` | array | 仓库拆分 | 见下 | + +`warehouses[]` 子项:`warehouseId`仓库ID、`warehouseName`仓库名称、`inStockQty`该仓在库数量、`outStockQty`该仓已出库数量。 + +### A.5 `purchase_arrival_aggregate`(采购到货汇总·分页) + +**入参**:`purchase_no_list`(≤20,可选)、`status`/`approve_status`/`confirm_status`(可选)、`vendor_id`(可选,**依赖 P1-2 索引**) + +**返回 items**(`oms_purchase_order` ⋈ `oms_purchase_order_item`) + +| 返回字段 | 类型 | 中文注释 | 来源 / 翻译 | +|---|---|---|---| +| `purchaseNo` | string | 采购单号 | `purchase_no` | +| `buyerName` | string | 采购方名称 | `buyer_name` | +| `vendorId` / `vendorName` | long / string | 制造商ID / 名称 | `vendor_id` / 关联 `oms_vendor_info.vendor_name` | +| `warehouseId` / `warehouseName` | long / string | 入库仓库ID / 名称 | `warehouse_id` / 关联(**注意 resultMap 未映射,需工具内自行补齐**) | +| `purchaserName` | string | 采购员 | `purchaser_name` | +| `ownerName` | string | 汇智负责人 | `owner_name` | +| `purchaseDate` | date | 采购日期 | `purchase_date` | +| `status` / `statusName` | string | 采购状态编码 / 名称 | `status`:0=待入库,1=部分入库,2=已完成 | +| `approveStatus` / `approveStatusName` | string | 审批状态编码 / 名称 | `approve_status`:0=草稿,1=审批中,2=已通过,3=驳回 | +| `confirmStatus` / `confirmStatusName` | string | 供应商确认状态编码 / 名称 | `confirm_status`:0=待确认,1=已确认,2=已驳回 | +| `payMethod` / `payMethodName` | string | 付款方式 / 名称 | `pay_method`:0=入库付款,1=出库付款 | +| `flowType` | string | 线上/线下 | `flow_type`:online/offline | +| `currency` | string | 币别 | `currency` | +| `totalAmount` | decimal | 采购含税总金额 | `total_amount` | +| `purchaseQty` | decimal | 采购数量合计 | `sum(item.quantity)` | +| `innerQty` | decimal | 已入库数量合计 | `sum(item.inner_quantity)` | +| `pendingQty` | decimal | 未入库数量合计 | `purchaseQty - innerQty` | +| `arrivalRate` | decimal | 到货率(%) | `innerQty / purchaseQty`,HALF_UP 2 位;分母 0 返回 0 | +| `amountTotal` / `taxTotal` | decimal | 明细含税金额合计 / 税额合计 | `sum(item.amount_total)` / `sum(item.tax_total)` | + +### A.6 `finance_balance_aggregate`(财务余额聚合:SUMMARY 默认 / LIST 分页) + +**入参**:`order_code_list`(≤20,可选)、`include_zero`(bool,默认 false,是否返回全零行) + +**返回 items**(`oms_receivable_bill` / `oms_payable_bill` 按 `order_code` 聚合) + +| 返回字段 | 类型 | 中文注释 | 口径(直接 sum 冗余列,不重算) | +|---|---|---|---| +| `orderCode` | string | 合同编号 | `order_code` | +| `projectCode` / `projectName` | string | 项目编号 / 名称 | 关联 `project_order_info` → `project_info` | +| `receivableWithTax` | decimal | 应收含税总额 | `sum(total_price_with_tax)` | +| `receivableWithoutTax` | decimal | 应收未税总额 | `sum(total_price_without_tax)` | +| `receivableTax` | decimal | 应收税额 | `sum(tax_amount)` | +| `receivedWithTax` | decimal | 已收含税金额 | `sum(received_amount)` | +| `unreceivedWithTax` | decimal | 未收含税金额 | `sum(unreceived_amount)` | +| `invoicedWithTax` | decimal | 已开票金额 | `sum(invoiced_amount)` | +| `uninvoicedWithTax` | decimal | 未开票金额 | `sum(uninvoiced_amount)` | +| `payableWithTax` | decimal | 应付含税总额 | `sum(total_price_with_tax)` | +| `payableWithoutTax` | decimal | 应付未税总额 | `sum(total_price_without_tax)` | +| `payableTax` | decimal | 应付税额 | `sum(tax_amount)` | +| `paidWithTax` | decimal | 已付含税金额 | `sum(paid_payment_amount)` | +| `unpaidWithTax` | decimal | 未付含税金额 | `sum(unpaid_payment_amount)` | +| `ticketedWithTax` | decimal | 已收票金额 | `sum(received_ticket_amount)` | +| `unticketedWithTax` | decimal | 未收票金额 | `sum(unreceived_ticket_amount)` | +| **`overdueBucket`(v13)** | string | 账龄分桶:`0-30`/`31-60`/`61-90`/`90+` | `bucket(datediff(今天, plan_receipt_date))`,仅 `group_by=OVERDUE_BUCKET` 时返回 | +| **`inBucketQty` / `inBucketAmount`(v13)** | long / decimal | 该桶内的单据数 / 未收金额合计 | 按桶聚合 | +| **`asOfDate` / `basis`(v13)** | date / string | 时点日期 / 口径标记 | 传 `as_of_date` 时为 `RECALCULATED@`(按明细重算);否则 `CURRENT`(冗余列当前值) | + +### A.7 `purchase_order_detail`(采购单明细·分页) + +> **v7 变更**:该能力已合并入 `purchase_list`,调用方式为 `entity=ORDER` + `code_list=[purchase_no...]` + `include_detail=true`。本节字段字典继续作为 `purchase_list(entity=ORDER)` 的权威来源。 + +**入参**:`purchase_no_list`(≤20,必填)、`status`/`approve_status`/`confirm_status`(可选) + +**返回 items[0](单头,`oms_purchase_order`)** + +`purchaseNo`采购单号、`buyerName`/`buyerAddress`采购方名称/地址、`vendorId`/`vendorCode`/`vendorName`/`vendorAddress`制造商ID/编码/名称/地址、`warehouseId`/`warehouseName`入库仓库、`purchaserName`/`purchaserMobile`采购员/手机、`ownerName`汇智负责人、`payMethod`/`payMethodName`付款方式、`currency`币别、`totalAmount`含税总金额、`taxRate`税率、`status`/`statusName`采购状态、`approveStatus`/`approveStatusName`审批状态、`approveTime`审批时间、`approveNode`当前审批节点、`confirmStatus`/`confirmStatusName`供应商确认状态、`purchaseDate`采购日期、`flowType`线上线下、`isVirtual`是否虚拟单、`version`版本号、`productCode`/`productModel`(查询条件回显)。 + +**返回 items[0].items(明细,`oms_purchase_order_item`)** + +| 返回字段 | 中文注释 | 来源列 | +|---|---|---| +| `purchaseId` | 采购单ID | `purchase_id` | +| `productCode` / `productModel` / `productType` / `productDescription` | 产品编码 / 型号 / 类型 / 描述 | `product_code` / 关联 `product_info` | +| `quantity` | 采购数量 | `quantity` | +| `innerQuantity` | 已入库数量 | `inner_quantity` | +| `pendingQuantity` | 未入库数量 | `quantity - inner_quantity` | +| `price` | 单价 | `price` | +| `taxRate` | 税率(%) | `tax_rate` | +| `taxTotal` | 税额 | `tax_total` | +| `amountTotal` | 含税金额 | `amount_total` | +| `deliveryDate` | 交货日期 | `delivery_date` | + +### A.8 `finance_bill_detail`(财务单据明细·分页) + +> **v7 变更**:该能力已合并入 `finance_list`,调用方式为 `entity=` + `code_list=[bill_code...]` + `include_detail=true`。本节字段字典继续作为 `finance_list` 的权威来源。 + +**入参**:`bill_type`(必填,枚举 `RECEIVABLE`/`PAYABLE`/`RECEIPT`/`PAYMENT`/`INVOICE`/`TICKET`)+ `bill_code_list`(≤20,必填) + +**单头字段(按 `bill_type` 选用)** + +| bill_type | 单头字段(中文注释) | 主表 | +|---|---|---| +| `RECEIVABLE` | 同 A.3 的 `receivable` 分组全部字段 | `oms_receivable_bill` | +| `PAYABLE` | 同 A.3 的 `payable` 分组全部字段 | `oms_payable_bill` | +| `RECEIPT` | 同 A.3 的 `receipts` 分组全部字段 + `receiptBillType`收款单类型、`remainingAmount`剩余金额、`receiptAccountName`/`receiptBankNumber`收款账户 | `oms_receipt_bill` | +| `PAYMENT` | 同 A.3 的 `payments` 分组全部字段 + `paymentBillType`付款单类型、`paymentMethod`付款方式、`refundedAmount`/`remainingRefundAmount`退款金额、`payableBillCode`关联应付单号 | `oms_payment_bill` | +| `INVOICE` | 同 A.3 的 `invoices` 分组全部字段 + `invoiceType`票据类型、`invoiceBillType`开票单类型、`partnerCode`客户编码 | `oms_invoice_bill` | +| `TICKET` | 同 A.3 的 `tickets` 分组全部字段 + `ticketType`票据类型、`ticketBillType`收票单类型、`vendorCode`/`vendorName`制造商 | `oms_ticket_bill` | + +**明细/计划子表** + +| bill_type | 子表与关键字段 | +|---|---| +| `RECEIVABLE` | `receiptPlans`(`planReceiptDate`/`planAmount`/`planRate`)、`receiptDetails`(`receiptTime`收款时间、`receiptAmount`收款金额、`receiptRate`比例、`receiptBillCode`收款单号、`receivableDetailType`类型:1=正常收款/2=预收核销/3=退款、`receiptAmountWithoutTax`/`receiptAmountTax`)、`invoiceDetails`(`invoiceTime`/`invoiceAmount`/`invoiceRate`/`invoiceBillCode`/`receivableDetailType`:1=正常开票/3=红冲) | +| `PAYABLE` | `paymentPlans`、`paymentDetails`(`paymentTime`/`paymentAmount`/`paymentRate`/`paymentBillCode`/`payableDetailType`)、`ticketPlans`、`ticketDetails`(`actualTicketTime`/`paymentAmount`/`ticketBillCode`/`paymentAmountTax`) | +| `RECEIPT` | 经 `oms_receivable_receipt_detail` 关联的应收单号与核销金额;核销经 `write_off_id` → `oms_receivable_write_off` | +| `PAYMENT` | 经 `oms_payable_payment_detail` 关联的应付单号与核销金额;核销经 `write_off_id` → `oms_payable_write_off` | +| `INVOICE` | `oms_receivable_invoice_detail`(应收单关联)、`oms_receivable_invoice_detail_item`(开票商品行:`productCode`/`productName`/`productModel`/`quantity`/`price`/`allPrice`/`taxAmount`/`taxRate`) | +| `TICKET` | 经 `oms_payable_ticket_detail` 关联的应付单号与核销金额;核销经 `write_off_id` → `oms_payable_ticket_write_off` | + +### A.9 `warehouse_list`(仓储列表 / 范围查询,v7 新增) + +**通用入参**:`entity`(必填)、`code_list`、`status_list`、`time_range`(`begin`/`end`)、`warehouse_id_list`、`product_code_list`、`order_code`、`include_detail`、`page_size`、`cursor` + +| entity | 主表 | 返回字段 | +|---|---|---| +| `INNER` | `oms_inventory_inner` | 同 A.2① 字段 + `createByName`入库人、`remark`备注;**明细来自 `oms_inventory_info`(按 `inner_code`)**——`oms_inventory_inner_detail` 实测仅 1 行、不采用(见 4.3) | +| `OUTER` | `oms_inventory_outer` | 同 A.2② 字段 + `contactPerson`/`contactPhone`/`contactAddress`、`deliveryTimeType`、`versionCode` | +| `DELIVERY` | `oms_inventory_delivery` | 同 A.2④ 字段(SN 列表在 `include_detail=true` 时返回) | +| **`ORDER_DELIVERY`(v8 新增)** | **`order_delivery`** | `deliveryCode`发货单号(唯一)、`orderId`关联合同ID、`deliveryDate`发货日期、`deliveryType`发货方式(1=快递,2=物流,3=自提)、`logisticsCompany`物流公司、`logisticsCode`物流单号、`receiverName`/`receiverPhone`/`receiverAddress`收货人/电话/地址、`deliveryStatus`/`deliveryStatusName`发货状态(**1=待发货,2=已发货,3=已签收**)、**`signTime`签收时间**、`remark`备注、`createdAt`/`updatedAt`;**v11 新增 join 字段(`orderId` → `order_info`)**:`orderCode`合同编号、`orderName`合同名称、`customerCode`/`customerName`客户、`orderAgentCode`代表处、`orderPartnerCode`代理商;明细 `include_detail=true` → `delivery_list` 的 `productCode`/`serialNumber`(**过滤 `deleted_at is null`**) | +| `STOCK` | `oms_stock_info` | 同 A.2⑥ 字段 | +| `SN` | `oms_inventory_info` | 同 A.1 字段(**必须给出索引键**,见 4.3 护栏) | +| **`RECALL`(v8 新增)** | **`project_order_info_recall`** | `orderCode`合同编号、`versionCode`版本号、`operationVersion`操作版本、`createTime`更新时间、`createBy`创建人ID(撤回历史追溯) | + +> 提示:`order_delivery` 表存在软删除列 `deleted_at` 与 `status`(数据状态),工具内必须**默认过滤 `deleted_at is null`**;`customer_info` **没有** `partner_code` 列,不能与 `partner_info` 直接关联。 + +`include_detail=true` 时追加:`INNER` → `oms_inventory_inner_detail` 产品行(见 4.3);`OUTER` → `outerDetails`(同 A.2③);`DELIVERY` → `productSns`(同 A.2④);`ORDER_DELIVERY` → `delivery_list` 的 SN;`STOCK` / `SN` / `RECALL` → 无明细。 + +### A.10 `purchase_list`(采购列表 / 范围查询,v7 新增) + +**通用入参**:`entity`(必填)、`code_list`、`status_list`、`approve_status_list`、`confirm_status_list`、`time_range`、`vendor_id`/`vendor_code_list`、`product_code_list`、`include_detail`、`page_size`、`cursor` + +| entity | 主表 | 返回字段 | +|---|---|---| +| `ORDER` | `oms_purchase_order` | 同 A.7 单头字段(`include_detail=true` 时附 A.7 明细行) | +| `ITEM` | `oms_purchase_order_item` | 同 A.7 明细字段 + `purchaseNo` 采购单号 | +| `ORDER_BIND` | `oms_purchase_order_map` | `orderId`订单ID、`purchaseId`采购单ID、`productCode`产品编码、`bindNum`绑定数量(**需 P2-1/P2-2 索引**) | +| `HISTORY` | `oms_purchase_order_history`(+`_item_history`) | `purchaseId`原始采购单ID、`purchaseNo`、`version`版本号、`status`/`approveStatus`/`confirmStatus`(含 Name)、`totalAmount`含税金额、`vendorName`、`ownerName`、`purchaserName`、`warehouseId`、`flowType`、`isVirtual`、`createTime`/`updateTime`;明细:`productCode`/`quantity`/`price`/`taxRate`/`taxTotal`/`amountTotal`/`deliveryDate`/`innerStatus` | + +> **v8 变更**:原 `entity=VENDOR`(供应商主数据)已移入 `master_data_list(entity=VENDOR)`(见 A.12),避免与主数据工具重复。 + +### A.11 `finance_list`(财务列表 / 范围查询,v7 新增) + +**通用入参**:`entity`(必填)、`code_list`、`status_list`、`approve_status_list`、`time_range`、`partner_code_list`/`vendor_code_list`、`order_code`、`include_detail`、`page_size`、`cursor` + +| entity | 主表 | 返回字段 | +|---|---|---| +| `RECEIVABLE` | `oms_receivable_bill` | 同 A.3 的 `receivable` 组 | +| `PAYABLE` | `oms_payable_bill` | 同 A.3 的 `payable` 组 | +| `RECEIPT` | `oms_receipt_bill` | 同 A.3 的 `receipts` 组(+ A.8 中 `RECEIPT` 的补充字段) | +| `PAYMENT` | `oms_payment_bill` | 同 A.3 的 `payments` 组(+ A.8 中 `PAYMENT` 的补充字段) | +| `INVOICE` | `oms_invoice_bill` | 同 A.3 的 `invoices` 组(+ A.8 中 `INVOICE` 的补充字段) | +| `TICKET` | `oms_ticket_bill` | 同 A.3 的 `tickets` 组(+ A.8 中 `TICKET` 的补充字段) | +| `CHARGE` | `oms_finance_charge` | 同 A.3 的 `charge` 组 | +| **`ATTACHMENT`(v13 新增)** | `oms_fin_attachment`(82) | **仅元数据**:`fileName`原始文件名、`fileSize`文件大小(字节)、`fileType`MIME类型、`relatedBillId`关联单据ID、`relatedBillType`单据类型(**实测取值 `payment`(59)/`ticket`(23),与列注释不符**)、`priceWithTax`/`priceWithoutTax`附件金额、`createBy`/`createTime`、`remark`;**必须过滤 `del_flag='0'`**(80/82 有效);**不返回 `filePath` 与文件内容**(不做下载) | + +`include_detail=true` 时按 entity 返回对应计划 / 明细 / 核销子表(字段见 A.8 的"明细/计划子表")。 + +### A.12 `master_data_list`(主数据查询 / 批量编码翻译,v8 新增) + +**通用入参**:`entity`(必填)、`code_list`(**批量,≤200**)、`name_like`、`status_list`、`page_size`、`cursor` + +| entity | 主表 | 返回字段 | +|---|---|---| +| `PARTNER` | `partner_info` | `partnerCode`进货商编码、`partnerName`进货商名称、`level`进货商等级(字典 `identify_level`)、`systemUserId`绑定系统用户ID | +| `CUSTOMER` | `customer_info` | `customerCode`客户编码、`customerName`客户名称(**注意:本表无 `partner_code` 列,不能与 `PARTNER` 直接关联**) | +| `AGENT` | `agent_info` | `agentCode`办事处编码、`agentName`办事处名称、`province`所在省、`city`所在市 | +| `VENDOR` | `oms_vendor_info` | 同原 `purchase_list(entity=VENDOR)` 字段:`vendorId`/`vendorCode`/`vendorName`/`vendorAddress`/`vendorUser`/`vendorPhone`/`vendorEmail`、`vendorStatus`/`vendorStatusName`合作状态(0=正常合作,1=暂停合作)、`warehouseId`/`warehouseName`/`ownWarehouseId`、`payType`/`payConfigDay`付款方式与账期、`payName`/`payBankNumber`/`payBankOpenAddress`/`bankNumber`、`socialCredit`、`province`/`city`/`generatedAddress` | +| `PRODUCT` | `product_info` | `productCode`、`productName`、`model`型号、`type`/`typeName`产品类型、`vendorCode`/`vendorName`厂商、`hzCode`、`cataloguePrice`目录价、`guidanceDiscount`指导折扣、`availableCount`可用库存、`cumulativeCount`累计出货 | +| `USER` | `sys_user` | `userId`、`loginName`登录名、`userName`姓名、`deptId`/`deptName`部门、`email`、`phonenumber`手机 | +| **`WAREHOUSE`(v10 新增)** | `oms_warehouse_info`(14 行) | `warehouseId`仓库ID、`warehouseCode`仓库编码、`warehouseName`仓库名称、`warehouseType`/`warehouseTypeName`仓库类型(**0=实体仓,1=虚拟仓**)、`warehouseStatus`/`warehouseStatusName`状态(**0=正常,1=停用**)、`address`详细地址、`managerName`管理员、`managerPhone`管理员电话、`managerEmail`管理员邮箱、`remark`备注。**默认只返回正常仓**(与页面 `selectOmsWarehouseInfoList` 行为一致),需含停用仓时置 `include_disabled=true`(走 `listAll`) | +| **`COMPANY`(v10 新增)** | `oms_company_info`(当前 0 行) | `id`、`companyCode`公司编码、`companyName`公司名称、`companyUser`联系用户、`companyEmail`联系邮箱、`companyPhone`联系电话、`companyAddress`公司地址、`payName`账户名称、`payBankNumber`银行卡号、`payBankOpenAddress`银行开户行、`bankNumber`银行行号、`socialCredit`统一社会信用代码(**己方主体信息**,用于合同/财务场景) | + +> **`VENDOR` 只取 `oms_vendor_info`**:库中另有一张同名近似的 `vendor_info`(5 行,仅含 P001/P002 且字段为精简版),**经确认为历史/冗余表,明确不纳入**(已加入 15.12 排除清单)。若出现 `oms_vendor_info` 查不到的编码,按"无此厂商"处理,**不得回退查 `vendor_info`**。 + +**核心用途**:**批量编码 → 名称翻译**。Agent 从其他工具拿到 `partnerCode` / `vendorCode` / `productCode` 列表后,**一次调用**取回名称与属性,避免逐个查询(此前是缺口:现有 `product_info` 工具只支持模糊单值匹配)。 + +**索引支撑**:`partner_info.idx_partner_code`、`customer_info.idx_code(customer_code)`、`agent_info.idx_agent_code`、`product_info.uk_product_code(product_code,hz_code)`;`oms_vendor_info`(17 行)、`sys_user`(119 行) 可全表扫。按厂商过滤需 **P2-20**。 + +### A.13 字典与枚举取值汇总 + +**① 字典表(`DictUtils.getDictLabel`)** + +| 字典类型 `dictType` | 用途 | 状态 | +|---|---|---| +| `order_status` | 订单状态 | 现有工具已在用 | +| `bg_type` | BG 属性 | 现有工具已在用 | +| `bg_hysy` / `bg_yys` | 行业(运营商/非运营商) | 现有工具已在用 | +| `project_stage` | 项目阶段 | 现有工具已在用 | +| `currency_type` | 币种 | 现有工具已在用 | +| `identify_level` | 进货商类型 | 现有工具已在用 | + +**② Java 枚举(本轮已逐值核实)** + +| 枚举 | 取值 | +|---|---| +| `InventoryInfo.InventoryStatusEnum` | 0=入库,1=出库 | +| `InventoryOuter.OuterStatusEnum` | 1=待确认,2=已确认,3=已接收,4=已退回 | +| `InventoryOuter.DeliveryStatusEnum` | 0=未发货,1=部分发货,2=全部发货,3=已撤回 | +| `InventoryDelivery.DeliveryStatusEnum` | 0=待发货,1=已发货,2=撤回 | +| `InventoryDelivery.deliveryType` | 1=快递,2=物流,3=自提 | +| `OmsWarehouseInfo.WarehouseTypeEnum` | 0=实体仓,1=虚拟仓 | +| `OmsWarehouseInfo.WarehouseStatusEnum` | 0=正常,1=停用 | +| `OmsPurchaseOrder` status / approveStatus / confirmStatus / payMethod / flowType | 见 A.5 逐行 | +| `OmsReceiptBill.ReceiptStatusEnum` | -1=已退款,1=未付款,2=已付款,3=未退款 | +| `OmsInvoiceBill.InvoiceStatusEnum` | -1=已红冲,1=未开票,2=已开票,3=未红冲 | +| `OmsTicketBill.TicketStatusEnum` | -1=已红冲,1=未收票,2=已收票,3=未红冲 | +| `OmsReceivableReceiptDetail.receivableDetailType` | 1=正常收款,2=预收核销,3=退款 | +| `OmsReceivableInvoiceDetail.receivableDetailType` | 1=正常开票,3=红冲 | +| `OmsReceivableWriteOff.writeOffType` / `OmsPayableWriteOff.writeOffType` | AUTO=自动核销,USER=人工核销 | +| `OmsPaymentBill.payType` | INNER_PAY / OUTER_PAY | +| `OmsStockInfo.stockStatus` | 0=未备货,1=已备货(v7 新增) | +| `OmsFinanceCharge.ChargeStatusEnum` | 0=等待收款,1=可申请计收,2=已申请计收,3=已完成计收(v7 新增) | +| `VendorInfo.vendorStatus` | 0=正常合作,1=暂停合作(v7 新增) | + +**③ 待实现时从枚举类补齐(本轮未逐值核实,不得臆造)** + +`OmsPaymentBill.PaymentStatusEnum`、`OmsPayablePaymentDetail.PayableDetailTypeEnum`、`OmsPayableTicketDetail.PayableDetailTypeEnum`、`OmsPayableTicketWriteOff.writeOffType`、`OmsInvoiceBill.invoiceBillType`、`OmsReceiptBill.receiptBillType`、`OmsPaymentBill.paymentBillType`、`OmsTicketBill.ticketBillType`、`OmsReceivableInvoicePlan` 字段名。 + +### A.13 字段注释的落地要求 + +1. 每个工具的 `metadata.item_fields` 内容**必须取自本附录**,不允许遗漏或改写含义; +2. 嵌套结构(`outerDetails` / `deliveries` / `snDetails` / `warehouses` / 各 `subItems`)在 `metadata` 中以下划线分隔的扁平键声明,例如 `deliveries[].logisticsCode` = "物流单号"; +3. 枚举/字典字段一律**成对输出**:`xxx`(编码)+ `xxxName`(名称),并在 `metadata.dict_fields` 声明来源; +4. 金额字段统一 `decimal`,日期统一 `yyyy-MM-dd`,时间统一 `yyyy-MM-dd HH:mm:ss`(与现有工具一致)。 + +### A.15 `approval_list`(审批待办 / 已办,v13 新增) + +**入参**:`entity`(必填:`TODO` / `DONE`)、`approve_user`(**默认当前登录用户ID**)、`process_key_list`、`business_key_list`、`time_range`、`page_size`、`cursor` + +**`entity=TODO`(`bu_todo`,61 行)** + +| 返回字段 | 中文注释 | 来源列 | +|---|---|---| +| `todoId` | 流程ID | `todo_id` | +| `processInstanceId` | 流程实例ID | `process_instance_id` | +| `taskId` | 任务ID | `task_id` | +| `businessKey` | 业务主键(合同编号 / 采购单号等) | `business_key` | +| `processKey` | 流程KEY | `process_key` | +| `processName` | 流程名称 | `process_name` | +| `taskName` | **当前节点名称** | `task_name` | +| `approveUser` / `approveUserName` | 审批人ID / 姓名 | `approve_user` / `approve_user_name` | +| `applyUserName` | 发起人姓名 | `apply_user_name` | +| `applyTime` | 发起时间 | `apply_time` | +| `formKey` | 节点表单KEY | `form_key` | +| `extendField1/2/3` | 扩展字段 | `extend_field1/2/3` | + +**`entity=DONE`(`bu_todo_completed`,5,876 行)**:包含 TODO 的全部字段,另有: + +| 返回字段 | 中文注释 | 来源列 | +|---|---|---| +| `approveTime` | 审批时间 | `approve_time` | +| `approveOpinion` | **审批意见** | `approve_opinion` | +| `approveStatus` / `approveStatusName` | 审批结果(**3=通过,2=驳回**) | `approve_status` | +| `allApproveUserName` | 所有审批人 | `all_approve_user_name` | + +**已覆盖的流程(实测 `process_key` 分布)** + +| `process_key` | 含义 | 待办 | 已办 | +|---|---|---|---| +| `order_approve_online` | 订单审批(线上) | 37 | 2,978 | +| `order_approve_offline` | 订单审批(线下) | 6 | 1,967 | +| `purchase_order_online` | **采购单审批** | 15 | 530 | +| `finance_payment` | **付款审批** | 3 | 429 | +| `fianance_ticket` | **收票审批** | — | 47 | +| `order_reback` | **订单撤回** | — | 29 | +| `outer_reback` | **出库撤回** | — | 18 | + +> ⚠️ **两处实测异常,勿按常规推断**:① `fianance_ticket` 是源码/数据里的**拼写错误**(少一个 `n`),匹配时**必须按原样**;② `approve_status` 的语义方向与常见枚举相反(**3 才是通过、2 是驳回**)。 + +--- + +## 十七、变更记录 + +| 版本 | 主要变化 | +|---|---| +| **v16(补齐 15.4 与 15.11 两处"设计有、代码无")** | **① 局部游标 `sub_cursor` 落地**(此前只有 `truncated_sub_lists` 截断标注、无从续页 → 子列表超限即数据缺失):新增支持类 `McpSubPage`(协议/校验/切片/`sub_page_info`),`inventory_flow`(`outerDetails`/`snDetails`/`deliveries`)与 `finance_order_position`(`receiptPlans`/`receiptDetails`/`invoicePlans`/`paymentPlans`/`paymentDetails`/`ticketPlans`)各自实现 `sub_list` + `sub_parent` + `sub_cursor` + `page_size`;`truncated_sub_lists` 由"路径字符串"升级为 `{list, parent, total, returned, next_cursor}`,调用方可直接回传续页。**实测修正 3 处**:**(a)`max_pages` 由 20 提到 200**——20×100=2000 行 < 实测单出库单 2682 条 SN 明细,原值会导致"截断 + 游标也取不完";**(b)子列表统一在内存按主键 `id` 升序定序**——部分子表 SQL 无 `order by`,否则跨调用会重复/漏数据;**(c)修复"从 `sub_parent` 起翻时下一页游标丢父实体"缺陷**(`McpSubPage.page` 现接收已解析的 parent)。**② 查询超时 + 限流落地**:新增 `McpQueryTimeout`(线程上下文,`*_aggregate` 3s / 其余 5s)与 `McpQueryTimeoutInterceptor`(MyBatis `StatementHandler.prepare` 插件 → `setQueryTimeout`,项目自定义了 `SqlSessionFactory`,故显式 `addInterceptor`);新增 `McpRateLimiter`(**进程内**滑动窗口 60 次/分,**偏离原文的 Redis 方案,因项目未引入 Redis**);错误码新增 **`-32002 rate_limit_error`** 与 **`-32003 query_timeout`**。**实测验证**:`C-20260715001`(2682 条 SN 明细)主调 500 条 + 22 页 = **2682 条、漏 0/重复 0**;从 `sub_parent` 起翻 27 页同样取满 2682;财务 `receiptPlans` 3 页 6 条不重不漏;7 项游标错误路径(未知子列表名/缺 `sub_parent`/与 `cursor` 互斥/跨子列表串用/父实体不存在/`page_size` 超限)均返回 `-32602`;限流**第 61 次**准确触发 `-32002`;超时用"表写锁制造阻塞"实测聚合 3.3s、非聚合 5.8s 均返回 `-32003`。 | +| v1 | 按表划分 10 个工具(`inventory_*` / `purchase_*` / `finance_*`),提出批量预加载、限流、字段裁剪 | +| v2 | 前置验证后修正:① 否决 `oms_finance_operate_report` 物化表数据源;② 过滤键改为对齐实测索引;③ 撤销无索引支撑的时间范围过滤;④ 补库存数量聚合能力 | +| v3 | ① 工具改为"面向问题",收敛为 8 个;② 引入游标分页协议与 Agent 翻页指令;③ 新增 3 个聚合工具与口径定义;④ 输出索引清单(P0/P1/P2/不可加)并完成列存在性验证;⑤ 补错误契约、权限指纹、验收指标与遗留项 | +| v4 | ① 新增 3.8「与现有 MCP 工具的一致性基线」与 3.9「有意偏离(4 处)」;② 响应契约对齐现有工具(保留 `data.total`,仅新增 `page_info`,新增 `dict_fields`);③ 新增**附录 A 字段字典**:8 个工具的逐字段中文注释、来源列、枚举取值;④ 明确"字典表 vs Java 枚举"两类翻译来源,并列出待同步项,杜绝臆造 | +| v5 | ① 实测证实"分析统计会退化为 Agent 驱动的多次全表扫描"(分页 34.4ms ≈ 全局汇总 38.1ms;6.5 万行 ≈ 648 页必被截断);② 新增**第十三章 分析统计场景优化**:`mode=SUMMARY` 一次算完、Top-N、覆盖索引、类型对齐防索引退化、IN 收窄、超时限流、从库路由、统计维度清单;③ 索引新增 **P1-3 覆盖索引**;④ 明确 SUMMARY 仍为 1 次 O(N) 的边界与"预聚合表需可靠定时任务"的教训 | +| v6 | ① 新增**第十四章 覆盖度缺口分析与完善**:三域覆盖度矩阵、统计维度矩阵、缺口清单;② 指出**最严重缺口是"列表/范围查询整体缺失"**(所有单据只能按单号点查,"本月有哪些采购单/多少票"无法回答);③ **实测修正规则**:除 `oms_inventory_info`/`_delivery_detail` 外全部表 <1000 行,故把"无索引=不支持"改为**按表规模分级**,小表放开状态/时间/伙伴维度;④ 给出 A/B/C/D 四档完善建议;⑤ 明确"性能接近上限、覆盖不完备"的分层结论,并声明"完备 vs 轻量"的本质冲突 | +| **v7** | **把 v6 识别的缺口全部并入**:① 工具由 8 → **9 个**(合并 `purchase_order_detail`/`finance_bill_detail` 进 `purchase_list`/`finance_list`,避免重复功能导致选错);② A 档落地:`group_by` 增 `STATUS`/`TIME_MONTH`/`TIME_QUARTER`/`PARTNER`/`VENDOR`,度量增 `ARRIVAL_DELAY_DAYS`/`OVERDUE_DAYS`;③ B 档落地:新增 `warehouse_list`/`purchase_list`/`finance_list` 三个 `entity` 参数化列表工具,补齐"本月有哪些/多少"类基础问题;④ C 档落地:`inventory_flow` 增 `stock` 备货分组、`finance_order_position` 增 `charge` 计收分组;⑤ D 档落地:P2 条件索引 + 时间列索引策略改为**按表规模分级**;⑥ 权限补齐到"工具 × entity"粒度对照表;⑦ 附录 A 增三个列表工具字段字典与新增枚举;⑧ 新增实施批次划分 | +| **v8** | **用全库表枚举做严格比对(199 张表),修正自相矛盾之处并补齐规格空白**:① **修正 3 处自身错误**——入库明细表由"SN 明细"更正为 `oms_inventory_inner_detail`;撤销对 `oms_inventory_info.inner_price/outer_price` 的"含税"断言改为待确认;`OWNER` 维度因无索引支撑予以移除;② **新增第十五章**逐条定义 9 处规格空白(时间维度字段、维度可用性矩阵、`arrival_delay_days` 语义、`sub_cursor` 协议、`metrics` 全枚举与参数冲突规则、`include_zero` 判定、在库口径、含税口径、多币种、collation 大小写、超时/限流/埋点/schema 版本、备份表白名单、测试与压测);③ **纳入一级缺口**:`order_delivery` + `delivery_list`(manage 域发货单与 SN 明细,**补上"签收"能力**)、`project_order_info_recall`(撤回历史);④ **纳入二级缺口**:新增 `master_data_list`(**批量编码翻译**),工具数 9 → **10**;⑤ P2 索引扩至 20 条;⑥ 新增只读 SQL 扩至 16 项 | +| **v9** | **补齐最后 3 类遗留**:① **覆盖缺口**——新增 `project_list`(项目/项目产品/进度/**POC**/**报价**,5 个 entity)与 `cross_domain_aggregate`(**受限跨域透视**:维度/度量白名单 + **单链路约束** + `time_range` 必填 + 分组上限,实现"客户 × 产品 × 月";真正的任意 SQL 仍不开放),工具数 10 → **12**;② **业务口径去阻塞**——在库口径与价格含税口径改为**配置项 + 工具参数 + 校验 SQL**;③ **工程项落地**——索引 DDL 执行方案、时间区间可配置策略、**从库路由实现方案**(财务类禁止走从库)、**RAG 工具路由**(`ToolEmbedding`/`ToolRetriever`/`ToolRouter` + 中文 2-gram + 同义词配置 + `tools/list` 的 `query`/`detail` 兼容扩展 + `tools/route`);④ P2 索引扩至 25 条,只读 SQL 扩至 20 项 | +| **v10** | **逐表复核后收口,含 2 处实测修正**:① **实测修正 1——入库明细来源**:v8 曾改为 `oms_inventory_inner_detail`,实测该表**仅 1 行(未启用)**,而 `oms_inventory_info` 中 **579/580** 张入库单有 SN 明细 → 入库明细**以 `oms_inventory_info`(按 `inner_code`)为准**;② **实测修正 2——新增口径提醒**:`oms_inventory_info.order_code` 在 SN 未出库时为空(实测空值数 15,532 **恰等于在库数**),**不得用它反查"在库货属于哪个订单"**,须经 `inner_code` → `oms_inventory_inner.order_code`;③ **`master_data_list` 新增 2 个 entity**:`WAREHOUSE`(仓库主数据,默认只返回正常仓,`include_disabled=true` 含停用仓)与 `COMPANY`(己方公司主体 `oms_company_info`);④ **明确排除 `vendor_info`**(5 行冗余表):供应商主数据**只用 `oms_vendor_info`**,查不到不回退;⑤ 排除清单同步补入 `vendor_info` 与 `oms_inventory_inner_detail`;⑥ P2-19 因表不采用而作废 | +| **v11** | **采纳方案 A:补齐 manage 域合同模型 + 3 处实测异常修正**:① **`project_list` 新增 2 个 entity**——`CONTRACT`(`order_info`,366 行,`uk_order_code(order_code,version_code)` 可直接按合同号查)与 `CONTRACT_PRODUCT`(`order_list`,854 行,`idx_order_id`);② **`warehouse_list(entity=ORDER_DELIVERY)` 补全 join**:`orderId` 实测 **355/355 指向 `order_info`**,必须 join 才能输出合同编号/客户/代理商,并明确**禁止按 id 关联 `project_order_info`**(242 条假命中);③ **明确两套订单模型关系**:manage 域与项目域**只能按 `order_code` 部分对齐(330/366)**,工具须容忍关联不到且不得臆造;④ **新增 15.14 实测异常**——`order_info.order_code` 有 **14 行前导制表符脏数据**(比对必须 `trim()`)、`order_type` 实际值为 **`zq`/`dls`**(与列注释 1/2 不符);⑤ 只读 SQL 扩至 22 项 | +| **v12** | **修正"缺口矩阵误导"并评估剩余缺口**:① 14.1/14.2 矩阵补 **"现状(v11)"列**——原 ❌ 是 v6 快照,实际已被 v7–v11 闭合 **13 项**;② 新增 **16.9 剩余缺口的影响评估与闭合方案**;③ **实测推翻一处旧结论**:审批待办/已办**无需读 Flowable `act_*`**;④ 账龄分桶列为**零成本可补**;⑤ 财务历史时点余额给出"按明细重算"方案;⑥ 附件可降级为**元数据查询** | +| **v15(认证态端到端验证完成)** | **用临时机器人凭证(绑定 admin,验证后已删除)对 13 个工具做真实数据 E2E,最终 34/34 用例全部通过**(含 12 个子 entity、分页不重不漏、游标串用防护、错误路径)。过程中发现并修复 **4 类实现缺陷**:① **`java.time.LocalDateTime` 序列化缺失** → `SignTime` 等字段导致 `InvalidDefinitionException`,被 `McpController` 外层 catch 吞掉后表现为 **HTTP 200 + 空响应体**;已注册 java.time 序列化器(日期 `yyyy-MM-dd`、时间 `yyyy-MM-dd HH:mm:ss`)并让该 catch **回写 error 响应**而不再静默;② **未提供的入口仍发起查询 → `where col in ()` 非法 SQL**(`inventory_sn_trace`),已改为仅对非空列表查询;③ **既有 `Transfer-Encoding` 重复响应头**(curl 丢响应体),已移除手工设置;④ **3 处 SQL 引用不存在的列**(`oms_inventory_outer.receivable_bill_code`、`oms_payable_bill.vendor_name`、`project_order_info.project_code/project_name`),已分别删除该列/改经 `oms_vendor_info` 关联/改用 `project_info`。另新增**系统性 SQL 列校验**(77 条新增 select 的 `别名.列` 与 `information_schema` 全量比对 → 0 处不存在列)。**索引实测**:P0-1/P0-2/P1-1/P1-2 **已存在**(此前巡检查漏),仅 P1-3 覆盖索引缺失,已在 `oms_test` 执行并实测 141.8ms→66.6ms(`Using index`)。 | +| **v14(已实现)** | **按 v13 方案完成编码与验证**,实现期共 5 处实测修正:① **`purchase_order_map.order_id` 指向 `project_order_info`(1116/1123),不是 `order_info`**(实测 SQL 验证)——与 `order_delivery.order_id`(→`order_info`,355/355)是**两条不同链路**,方案 16.1 原表述已修正;② **MySQL `TRIM()` 不去除制表符**:`order_info.order_code` 的 14 行脏数据为前导 `\t`,必须用 `trim(replace(order_code,'\t',''))` 才能命中(15.14 补充);③ **`order_delivery.delivery_status` 实测值为 `qs`(324)/`yf`(31)**(拼音缩写),不是列注释的 1/2/3 → 翻译按实测值映射并保留数字兜底(15.14 补充);④ **`oms_receivable_bill` 无 `plan_receipt_date` 列**(账龄分桶经 `last_receipt_plan_id` 关联收款计划实现);⑤ `oms_inventory_info` **确无 `purchase_no` 列**,SN 的采购单号经 `inner_code` 关联 `oms_inventory_inner` 补齐。**验证结论**:编译通过(445 源文件);`tools/list` 返回 **15 个工具**(13 新 + 2 既有);RAG 路由 15 条中文问句 **top3 命中 100%、top1 命中 73%**,`tools/list` 带 `query` 后 schema 字节 **下降 81%**(23621→4586);无凭证调用返回 **AUTH_ERROR(-32001)** 而非空数据;`tools/call` 不传 name 可自动路由。改动规模:74 文件、+2620 行,仅 10 处删除(均为 `McpService` 的等价改写)。**框架层最小改动**:`McpService` 增加 `query`/`detail`/`tools/route`/自动路由(向后兼容),`McpController` 增加 `McpToolException` 错误码映射(原来无法返回 AUTH_ERROR)。 | +| **v13(已实现)** | **三项全部纳入,三大域封版**:① **新增工具 `approval_list`**(entity = `TODO`/`DONE`,工具数 12 → **13**)——数据源 `bu_todo`(61)/`bu_todo_completed`(5,876),**不碰 Flowable `act_*`**;回答"我还有哪些单要审/卡在谁那儿/**为什么被驳回**/审批耗时";已覆盖 `order_approve_*`、`purchase_order_online`、`finance_payment`、`fianance_ticket`、`order_reback`、`outer_reback`;附录 **A.15** 给出完整字段字典与两处实测异常(`fianance_ticket` 拼写错误、`approve_status` 3=通过/2=驳回与常规相反);② **账龄分桶**:`finance_balance_aggregate` 增 `group_by=OVERDUE_BUCKET`(`0-30`/`31-60`/`61-90`/`90+`,仅 `unreceived_amount>0`);③ **财务历史时点余额**:增 `as_of_date`,按明细重算(`Σ应收 create_time≤T − Σ已收 receipt_time≤T`),并以 `metadata.basis = RECALCULATED@ \| CURRENT` 标注口径差异;④ **附件元数据**纳入 `finance_list(entity=ATTACHMENT)`(过滤 `del_flag='0'`,不返回文件内容);⑤ P2 索引增 P2-26(`bu_todo_completed(approve_user, approve_time)`)→ 编号 26 条、有效 23 条;只读 SQL 扩至 **25 项**;⑥ 15.2 维度矩阵与 15.5 冲突规则同步(`OVERDUE_BUCKET` 仅 SUMMARY) | diff --git a/docx/mcp-tools-index-ddl.sql b/docx/mcp-tools-index-ddl.sql new file mode 100644 index 00000000..bcbc01b0 --- /dev/null +++ b/docx/mcp-tools-index-ddl.sql @@ -0,0 +1,70 @@ +-- ===================================================================== +-- MCP 只读数据工具 —— 索引脚本(对应方案第 6 章 / 16.4) +-- 适用:MySQL 8.0+ +-- +-- 【2026-09-23 实测更新】在 oms_test 上核对 information_schema 后发现: +-- • P0-1 oms_inventory_outer.idx_outer_code(outer_code) —— 已存在,无需执行 +-- • P0-2 oms_inventory_outer_detail.idx_outer_code(outer_code) —— 已存在,无需执行 +-- • P1-1 oms_inventory_inner.idx_order_code(order_code) —— 已存在,无需执行 +-- • P1-2 oms_purchase_order.idx_vendor_id(vendor_id) —— 已存在,无需执行 +-- • P1-3 oms_inventory_info 覆盖索引 —— 缺失,已在 oms_test 执行并实测收益 +-- (建索引耗时 1.3s;按产品聚合 141.8ms → 66.6ms;EXPLAIN 显示 Using index,免回表) +-- +-- 因此本脚本默认全部注释,仅作为「生产执行前的核对与补建」清单使用。 +-- 执行前请先跑文末的核对语句,确认目标索引是否缺失。 +-- ===================================================================== + +-- --------------------------------------------------------------------- +-- P0 必加(inventory_flow 出库链路依赖;若缺失将导致按出库单号全表扫描) +-- 实测:oms_test 已存在 +-- --------------------------------------------------------------------- +-- ALTER TABLE oms_inventory_outer ADD INDEX idx_outer_code (outer_code), ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE oms_inventory_outer_detail ADD INDEX idx_outer_code (outer_code), ALGORITHM=INPLACE, LOCK=NONE; + +-- --------------------------------------------------------------------- +-- P1 建议 +-- 实测:P1-1 / P1-2 在 oms_test 已存在 +-- --------------------------------------------------------------------- +-- ALTER TABLE oms_inventory_inner ADD INDEX idx_order_code (order_code), ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE oms_purchase_order ADD INDEX idx_vendor_id (vendor_id), ALGORITHM=INPLACE, LOCK=NONE; + +-- --------------------------------------------------------------------- +-- P1-3 覆盖索引(库存聚合免回表) +-- 实测收益显著;但存在写入放大(每次 insert/update 需维护 4 列索引), +-- 生产库请由 DBA 结合写入量评估后再执行;回滚语句见文末。 +-- oms_test 已于 2026-09-23 执行。 +-- --------------------------------------------------------------------- +-- ALTER TABLE oms_inventory_info +-- ADD INDEX idx_pc_status_amt (product_code, inventory_status, inner_price, outer_price), +-- ALGORITHM=INPLACE, LOCK=NONE; + +-- --------------------------------------------------------------------- +-- P2 按表规模触发(当前数据量下无需执行;表增长到 10 万+ 或成为热点时再补) +-- 完整编号清单见方案 6.3 节(P2-1 ~ P2-26,其中 P2-3/P2-4/P2-19 已作废) +-- --------------------------------------------------------------------- +-- ALTER TABLE oms_purchase_order_map ADD INDEX idx_order_id (order_id), ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE oms_purchase_order_map ADD INDEX idx_purchase_id (purchase_id), ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE oms_inventory_info ADD INDEX idx_warehouse_id (warehouse_id), ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE oms_stock_info ADD INDEX idx_order_code (order_code), ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE oms_receivable_receipt_detail ADD INDEX idx_write_off_id (write_off_id), ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE oms_payable_payment_detail ADD INDEX idx_write_off_id (write_off_id), ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE bu_todo_completed ADD INDEX idx_approve_user_time (approve_user, approve_time), ALGORITHM=INPLACE, LOCK=NONE; + +-- ===================================================================== +-- 执行前核对:列出目标表当前索引(若目标索引已出现则无需再建) +-- ===================================================================== +-- SELECT table_name, index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS cols +-- FROM information_schema.statistics +-- WHERE table_schema = DATABASE() +-- AND table_name IN ('oms_inventory_outer','oms_inventory_outer_detail','oms_inventory_inner', +-- 'oms_purchase_order','oms_inventory_info') +-- GROUP BY table_name, index_name; + +-- ===================================================================== +-- 回滚(仅在需要撤销新增索引时执行) +-- ===================================================================== +-- ALTER TABLE oms_inventory_outer DROP INDEX idx_outer_code, ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE oms_inventory_outer_detail DROP INDEX idx_outer_code, ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE oms_inventory_inner DROP INDEX idx_order_code, ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE oms_purchase_order DROP INDEX idx_vendor_id, ALGORITHM=INPLACE, LOCK=NONE; +-- ALTER TABLE oms_inventory_info DROP INDEX idx_pc_status_amt, ALGORITHM=INPLACE, LOCK=NONE; diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/McpErrorUtils.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/McpErrorUtils.java index fe4d4734..42c9ffd7 100644 --- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/McpErrorUtils.java +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/McpErrorUtils.java @@ -10,6 +10,8 @@ public final class McpErrorUtils { public static final int INVALID_PARAMS = -32602; public static final int INTERNAL_ERROR = -32603; public static final int AUTH_ERROR = -32001; + public static final int RATE_LIMITED = -32002; + public static final int QUERY_TIMEOUT = -32003; public static final int SERVER_ERROR = -32000; private McpErrorUtils() { diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/flowable/mapper/TodoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/flowable/mapper/TodoMapper.java index c19ed2aa..f594a3fb 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/flowable/mapper/TodoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/flowable/mapper/TodoMapper.java @@ -7,6 +7,7 @@ import com.ruoyi.sip.flowable.domain.Todo; import org.apache.ibatis.annotations.Param; import java.util.List; +import java.util.Map; /** * 待办Mapper接口 @@ -90,5 +91,19 @@ public interface TodoMapper List listTodoByBusinessKeyAndProcessKey(@Param("businessKeys") List businessKeyList, @Param("processKeys") List list); + /** + * MCP 只读:待办分页(bu_todo),排序键 apply_time desc,id desc,游标 keyset 推进。 + * + * @param query approveUser / processKeyList / businessKeyList / startTime / endTime / lastTime / lastId / limit + */ + List listTodoPage(Map query); + + /** + * MCP 只读:已办分页(bu_todo_completed),排序键 approve_time desc,id desc,游标 keyset 推进。 + * + * @param query approveUser / processKeyList / businessKeyList / startTime / endTime / lastTime / lastId / limit + */ + List listTodoCompletedPage(Map query); + // List listApprove(WorkOrderStatisticsRequestDto dto); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpController.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpController.java index 02cde0fc..795d8b23 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpController.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpController.java @@ -2,6 +2,11 @@ package com.ruoyi.sip.llm; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.common.utils.McpErrorUtils; import com.ruoyi.common.utils.ShiroUtils; @@ -12,6 +17,10 @@ import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.Map; @@ -26,13 +35,28 @@ public class McpController { public McpController() { objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + + // java.time 支持:SQL 查询以 Map 返回时,DATETIME/DATE 列可能是 LocalDateTime/LocalDate, + // 若不注册序列化器会抛 InvalidDefinitionException,导致响应体为空。 + // 统一按工具约定输出:日期 yyyy-MM-dd,时间 yyyy-MM-dd HH:mm:ss。 + SimpleModule javaTimeModule = new SimpleModule(); + javaTimeModule.addSerializer(LocalDateTime.class, + new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + javaTimeModule.addSerializer(LocalDate.class, + new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + javaTimeModule.addSerializer(LocalTime.class, + new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss"))); + javaTimeModule.addDeserializer(LocalDate.class, + new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + objectMapper.registerModule(javaTimeModule); } @PostMapping(produces = "application/json") public void handleMcpRequest(@RequestBody McpRequest request, HttpServletResponse response) { response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); - response.setHeader("Transfer-Encoding", "chunked"); + // 注意:不要手工设置 Transfer-Encoding,Tomcat 会自行按分块输出, + // 手工设置会导致响应头出现两个同名头,部分客户端(如 curl)会丢弃响应体。 try (OutputStream os = response.getOutputStream()) { McpResponse mcpResponse = new McpResponse(); @@ -55,8 +79,31 @@ public class McpController { os.write(bytes, i, length); os.flush(); } - } catch (IOException e) { + } catch (Exception e) { + // 序列化/写出异常不能静默,否则客户端会收到 HTTP 200 + 空响应体(难以排查)。 + // 尽量回写一个可读的 error 响应。 e.printStackTrace(); + writeErrorQuietly(response, request, e); + } + } + + private void writeErrorQuietly(HttpServletResponse response, McpRequest request, Exception e) { + if (response.isCommitted()) { + return; + } + try { + McpResponse errorResponse = new McpResponse(); + errorResponse.setId(request == null ? null : request.getId()); + errorResponse.setJsonrpc(request == null ? "2.0" : request.getJsonrpc()); + errorResponse.setError(toMcpError(e)); + response.reset(); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + byte[] bytes = objectMapper.writeValueAsBytes(errorResponse); + response.getOutputStream().write(bytes); + response.getOutputStream().flush(); + } catch (Exception ignored) { + // 响应已提交或连接已断开,无法回写,仅保留上面的堆栈日志 } } @@ -79,6 +126,13 @@ public class McpController { String message = e.getMessage() != null ? e.getMessage() : "Internal server error"; String lowerMessage = message.toLowerCase(); + // 工具层显式抛出的错误:按其声明的错误码与类型返回(如 AUTH_ERROR / INVALID_PARAMS) + if (e instanceof com.ruoyi.sip.llm.tools.support.McpToolException) { + com.ruoyi.sip.llm.tools.support.McpToolException toolException = + (com.ruoyi.sip.llm.tools.support.McpToolException) e; + return McpErrorUtils.buildError(toolException.getCode(), message, toolException.getType()); + } + if (lowerMessage.contains("tool name is required") || lowerMessage.contains("invalid") || lowerMessage.contains("missing")) { diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpQueryTimeout.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpQueryTimeout.java new file mode 100644 index 00000000..0e8887e7 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpQueryTimeout.java @@ -0,0 +1,66 @@ +package com.ruoyi.sip.llm; + +import java.sql.SQLTimeoutException; + +/** + * MCP 工具调用的查询超时上下文(线程级)。 + * + *

由 {@link McpService} 在调用工具前 {@link #begin(int)}、调用后 {@link #end()}; + * {@link McpQueryTimeoutInterceptor} 在 JDBC 语句准备阶段读取该值并调用 + * {@link java.sql.Statement#setQueryTimeout(int)},从而给 MCP 触发的 SQL 加上超时保护。

+ * + *

未设置时(例如页面/报表等非 MCP 调用)不生效,对既有行为零影响。

+ */ +public final class McpQueryTimeout { + + /** 聚合类工具超时(秒) */ + public static final int AGGREGATE_SECONDS = 3; + /** 列表 / 点查类工具超时(秒) */ + public static final int DEFAULT_SECONDS = 5; + + private static final ThreadLocal HOLDER = new ThreadLocal<>(); + + private McpQueryTimeout() { + } + + /** + * 按工具类型取超时秒数:聚合类(工具名以 {@code _aggregate} 结尾)3 秒,其余 5 秒。 + */ + public static int secondsFor(String toolName) { + return toolName != null && toolName.endsWith("_aggregate") ? AGGREGATE_SECONDS : DEFAULT_SECONDS; + } + + public static void begin(int seconds) { + HOLDER.set(seconds); + } + + public static Integer currentSeconds() { + return HOLDER.get(); + } + + public static void end() { + HOLDER.remove(); + } + + /** + * 判断异常是否由查询超时引起(逐层查找 cause,兼容 MyBatis/Spring 的异常包装)。 + */ + public static boolean isTimeout(Throwable error) { + Throwable current = error; + int depth = 0; + while (current != null && depth++ < 20) { + if (current instanceof SQLTimeoutException) { + return true; + } + String message = current.getMessage(); + if (message != null) { + String lower = message.toLowerCase(); + if (lower.contains("timeout") || lower.contains("cancel")) { + return true; + } + } + current = current.getCause() == current ? null : current.getCause(); + } + return false; + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpQueryTimeoutInterceptor.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpQueryTimeoutInterceptor.java new file mode 100644 index 00000000..231329e0 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpQueryTimeoutInterceptor.java @@ -0,0 +1,82 @@ +package com.ruoyi.sip.llm; + +import org.apache.ibatis.executor.statement.StatementHandler; +import org.apache.ibatis.plugin.Interceptor; +import org.apache.ibatis.plugin.Intercepts; +import org.apache.ibatis.plugin.Invocation; +import org.apache.ibatis.plugin.Plugin; +import org.apache.ibatis.plugin.Signature; +import org.apache.ibatis.session.SqlSessionFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.sql.Connection; +import java.sql.Statement; +import java.util.List; +import java.util.Properties; + +/** + * MCP 查询超时拦截器:语句准备阶段按 {@link McpQueryTimeout} 的线程上下文设置 JDBC 查询超时。 + * + *

仅当 MCP 工具调用设置了线程上下文时才生效,其它页面/定时任务 SQL 不受影响。 + * 超时由 MySQL 驱动从服务端中止查询并抛 {@link java.sql.SQLTimeoutException}, + * 避免慢 SQL 长期占用 Druid 连接(maxActive=20)。

+ * + *

注册方式:项目自定义了 {@code SqlSessionFactory} Bean(MyBatisConfig),MyBatis 自动配置已退让, + * 故此处显式 {@code addInterceptor},与 PageHelper 的注册方式保持一致。

+ */ +@Component +@Intercepts(@Signature(type = StatementHandler.class, method = "prepare", + args = {Connection.class, Integer.class})) +public class McpQueryTimeoutInterceptor implements Interceptor, InitializingBean { + + @Autowired + private List sqlSessionFactoryList; + + @Override + public void afterPropertiesSet() { + if (sqlSessionFactoryList == null) { + return; + } + for (SqlSessionFactory sqlSessionFactory : sqlSessionFactoryList) { + if (sqlSessionFactory == null) { + continue; + } + boolean registered = false; + for (Interceptor interceptor : sqlSessionFactory.getConfiguration().getInterceptors()) { + if (interceptor instanceof McpQueryTimeoutInterceptor) { + registered = true; + break; + } + } + if (!registered) { + sqlSessionFactory.getConfiguration().addInterceptor(this); + } + } + } + + @Override + public Object intercept(Invocation invocation) throws Throwable { + Object statement = invocation.proceed(); + Integer seconds = McpQueryTimeout.currentSeconds(); + if (seconds != null && seconds > 0 && statement instanceof Statement) { + try { + ((Statement) statement).setQueryTimeout(seconds); + } catch (Exception ignored) { + // 驱动不支持 setQueryTimeout 时不阻断查询(仅失去超时保护) + } + } + return statement; + } + + @Override + public Object plugin(Object target) { + return Plugin.wrap(target, this); + } + + @Override + public void setProperties(Properties properties) { + // 无需外部属性 + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpRateLimiter.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpRateLimiter.java new file mode 100644 index 00000000..5d92f352 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpRateLimiter.java @@ -0,0 +1,108 @@ +package com.ruoyi.sip.llm; + +import com.ruoyi.common.utils.ShiroUtils; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.ArrayDeque; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * MCP 层限流:同一机器人(同 X-Bot-Id,无凭证时按匿名/绑定用户)每 {@value #WINDOW_MS} 毫秒最多 {@value #LIMIT} 次调用。 + * + *

实现为进程内滑动窗口(项目未引入 Redis,故不落地"状态存 Redis"的原始设计), + * 目的是防止 Agent 循环翻页 / 并发调用打满 Druid 连接池(maxActive=20)。 + * 多实例部署时该计数为单实例口径,如需全局精确限流需改为 Redis 计数。

+ */ +@Component +public class McpRateLimiter { + + private static final String HEADER_BOT_ID = "X-Bot-Id"; + /** 窗口内允许的最大调用次数 */ + private static final int LIMIT = 60; + /** 滑动窗口长度(毫秒) */ + private static final long WINDOW_MS = 60_000L; + /** 超过该 key 数量时做一次过期清理,避免长期运行内存增长 */ + private static final int SWEEP_THRESHOLD = 1024; + + private final ConcurrentHashMap> buckets = new ConcurrentHashMap<>(); + + /** + * 按当前请求的机器人身份限流,超限抛 {@code RATE_LIMITED}。 + */ + public void checkCurrent() { + check(resolveKey()); + } + + public void check(String key) { + long now = System.currentTimeMillis(); + ArrayDeque window = buckets.computeIfAbsent(key, ignored -> new ArrayDeque<>()); + synchronized (window) { + while (!window.isEmpty() && now - window.peekFirst() > WINDOW_MS) { + window.pollFirst(); + } + if (window.size() >= LIMIT) { + long retryAfterMs = WINDOW_MS - (now - window.peekFirst()); + long retryAfterSeconds = Math.max(1L, (retryAfterMs + 999L) / 1000L); + throw McpToolException.rateLimited("调用过于频繁:同一机器人每 " + (WINDOW_MS / 1000) + + " 秒最多 " + LIMIT + " 次,请 " + retryAfterSeconds + " 秒后重试(翻页请间隔调用)"); + } + window.addLast(now); + } + if (buckets.size() > SWEEP_THRESHOLD) { + sweep(now); + } + } + + private void sweep(long now) { + for (Iterator>> iterator = buckets.entrySet().iterator(); + iterator.hasNext(); ) { + ArrayDeque window = iterator.next().getValue(); + synchronized (window) { + while (!window.isEmpty() && now - window.peekFirst() > WINDOW_MS) { + window.pollFirst(); + } + if (window.isEmpty()) { + iterator.remove(); + } + } + } + } + + /** + * 限流身份:优先机器人凭证标识(与 McpBotAuthFilter 的 X-Bot-Id 对齐), + * 无凭证时退回绑定的 Shiro 用户,再退回匿名。 + */ + private String resolveKey() { + String botId = currentBotId(); + if (botId != null) { + return "bot:" + botId; + } + try { + if (ShiroUtils.getSysUser() != null && ShiroUtils.getSysUser().getUserId() != null) { + return "user:" + ShiroUtils.getSysUser().getUserId(); + } + } catch (Exception ignored) { + // 无 Shiro 主体(匿名调用)时按匿名计数 + } + return "anonymous"; + } + + private String currentBotId() { + try { + ServletRequestAttributes attributes = + (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes == null) { + return null; + } + String botId = attributes.getRequest().getHeader(HEADER_BOT_ID); + return botId == null || botId.trim().isEmpty() ? null : botId.trim(); + } catch (Exception ignored) { + return null; + } + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpService.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpService.java index bd45c8c7..4dbc5df3 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpService.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/McpService.java @@ -1,6 +1,7 @@ package com.ruoyi.sip.llm; import com.alibaba.fastjson.JSON; +import com.ruoyi.sip.llm.tools.support.McpToolException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -16,6 +17,12 @@ public class McpService { @Autowired private McpToolRegistry registry; + @Autowired + private com.ruoyi.sip.llm.router.ToolRouter toolRouter; + + @Autowired + private McpRateLimiter rateLimiter; + @SuppressWarnings("unchecked") public Object handleRequest(McpRequest request) { String method = request.getMethod(); @@ -45,28 +52,66 @@ public class McpService { } if ("tools/list".equals(method)) { + rateLimiter.checkCurrent(); + String query = asText(params == null ? null : params.get("query")); + boolean detail = params == null || params.get("detail") == null + || !"false".equalsIgnoreCase(asText(params.get("detail"))); + java.util.Set routedNames = toolRouter.filterToolNames(query); + Map result = new HashMap<>(); - List tools = registry.list(); - result.put("tools", tools.stream().map(t -> { + List> toolList = new ArrayList<>(); + for (McpTool tool : registry.list()) { + if (routedNames != null && !routedNames.contains(tool.getName())) { + continue; + } Map tm = new HashMap<>(); - tm.put("name", t.getName()); - tm.put("description", t.getDescription()); - tm.put("inputSchema", t.getInputSchema()); - return tm; - }).collect(Collectors.toList())); + tm.put("name", tool.getName()); + tm.put("description", tool.getDescription()); + if (detail) { + tm.put("inputSchema", tool.getInputSchema()); + } + toolList.add(tm); + } + if (query != null && !query.isEmpty()) { + result.put("routed_by_query", query); + result.put("routed_tool_count", toolList.size()); + result.put("hint", "以上为按 query 召回的工具子集;需要查看全部工具时不要传 query,需要精简 schema 时传 detail=false。"); + } + result.put("tools", toolList); return result; } + if ("tools/route".equals(method)) { + rateLimiter.checkCurrent(); + String query = asText(params == null ? null : params.get("query")); + if (query == null || query.isEmpty()) { + throw new RuntimeException("Invalid params: tools/route 需要 query 参数"); + } + Integer limit = params.get("limit") == null ? null : Integer.valueOf(asText(params.get("limit"))); + return toolRouter.route(query, limit); + } + if ("tools/call".equals(method)) { - String toolName = params != null ? (String) params.get("name") : null; + rateLimiter.checkCurrent(); + String toolName = params != null ? asText(params.get("name")) : null; if (toolName == null || toolName.isEmpty()) { - throw new RuntimeException("Tool name is required"); + String query = asText(params == null ? null : params.get("query")); + if (query != null && !query.isEmpty()) { + toolName = toolRouter.pickToolName(query); + if (toolName == null) { + throw new RuntimeException("Invalid params: 无法从 query 推断出工具(得分低于阈值)," + + "请显式指定 name,或先调用 tools/route 查看候选。候选:" + + JSON.toJSONString(toolRouter.retrieve(query, 5))); + } + } else { + throw new RuntimeException("Tool name is required"); + } } McpTool tool = registry.get(toolName); if (tool != null) { Map args = params != null ? (Map) params.get("arguments") : new HashMap<>(); - return wrapToolResult(tool.getHandler().apply(args)); + return callTool(toolName, tool, args == null ? new HashMap<>() : args); } else { throw new RuntimeException("Tool not found: " + toolName); } @@ -75,6 +120,33 @@ public class McpService { throw new RuntimeException("Method not supported: " + method); } + /** + * 执行工具:设置查询超时上下文(聚合 3s / 其余 5s),并把 JDBC 超时异常翻译为明确的 QUERY_TIMEOUT 错误。 + */ + private Object callTool(String toolName, McpTool tool, Map args) { + int timeoutSeconds = McpQueryTimeout.secondsFor(toolName); + McpQueryTimeout.begin(timeoutSeconds); + try { + return wrapToolResult(tool.getHandler().apply(args)); + } catch (RuntimeException e) { + if (McpQueryTimeout.isTimeout(e)) { + throw McpToolException.timeout("查询超时:工具 " + toolName + " 执行超过 " + timeoutSeconds + + " 秒已被中止,请收窄过滤条件、减少 batch 或改用聚合工具(mode=SUMMARY)"); + } + throw e; + } finally { + McpQueryTimeout.end(); + } + } + + private String asText(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + @SuppressWarnings("unchecked") private Object wrapToolResult(Object rawResult) { if (rawResult instanceof Map) { diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/router/ToolEmbedding.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/router/ToolEmbedding.java new file mode 100644 index 00000000..620b9411 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/router/ToolEmbedding.java @@ -0,0 +1,188 @@ +package com.ruoyi.sip.llm.router; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 轻量文本向量化:中文按字符 2-gram,英文/数字按词切分(驼峰与下划线拆开), + * 不依赖任何外部 embedding 服务,配合关键词加权做余弦匹配。 + */ +public final class ToolEmbedding { + + private static final int CJK_NGRAM = 2; + + private ToolEmbedding() { + } + + /** + * 分词:CJK 连续段取字符 2-gram(长度为 1 时保留单字),ASCII 段按分隔符/驼峰切词。 + */ + public static List tokenize(String text, Set stopwords) { + List tokens = new ArrayList<>(); + if (text == null || text.isEmpty()) { + return tokens; + } + String lower = text.toLowerCase(); + StringBuilder ascii = new StringBuilder(); + StringBuilder cjk = new StringBuilder(); + + for (int i = 0; i < lower.length(); i++) { + char ch = lower.charAt(i); + if (isCjk(ch)) { + flushAscii(ascii, tokens, stopwords); + cjk.append(ch); + } else if (isWordChar(ch)) { + flushCjk(cjk, tokens, stopwords); + ascii.append(ch); + } else { + flushAscii(ascii, tokens, stopwords); + flushCjk(cjk, tokens, stopwords); + } + } + flushAscii(ascii, tokens, stopwords); + flushCjk(cjk, tokens, stopwords); + return tokens; + } + + private static void flushAscii(StringBuilder buffer, List tokens, Set stopwords) { + if (buffer.length() == 0) { + return; + } + String word = buffer.toString(); + buffer.setLength(0); + for (String part : splitCamel(word)) { + if (part.length() >= 2 && !isStopword(part, stopwords)) { + tokens.add(part); + } + } + } + + private static void flushCjk(StringBuilder buffer, List tokens, Set stopwords) { + if (buffer.length() == 0) { + return; + } + String segment = buffer.toString(); + buffer.setLength(0); + if (segment.length() == 1) { + addToken(segment, tokens, stopwords); + return; + } + for (int i = 0; i + CJK_NGRAM <= segment.length(); i++) { + addToken(segment.substring(i, i + CJK_NGRAM), tokens, stopwords); + } + } + + private static void addToken(String token, List tokens, Set stopwords) { + if (token != null && !token.isEmpty() && !isStopword(token, stopwords)) { + tokens.add(token); + } + } + + private static boolean isStopword(String token, Set stopwords) { + return stopwords != null && stopwords.contains(token); + } + + /** + * 拆驼峰与下划线:inventoryStockAggregate -> inventory, stock, aggregate + */ + private static List splitCamel(String word) { + List parts = new ArrayList<>(); + if (word == null || word.isEmpty()) { + return parts; + } + for (String chunk : word.split("[_\\-]+")) { + if (chunk.isEmpty()) { + continue; + } + StringBuilder current = new StringBuilder(); + for (int i = 0; i < chunk.length(); i++) { + char ch = chunk.charAt(i); + if (i > 0 && Character.isUpperCase(ch) && current.length() > 0 + && Character.isLowerCase(chunk.charAt(i - 1))) { + parts.add(current.toString().toLowerCase()); + current.setLength(0); + } + current.append(ch); + } + if (current.length() > 0) { + parts.add(current.toString().toLowerCase()); + } + } + return parts; + } + + private static boolean isCjk(char ch) { + return ch >= 0x4E00 && ch <= 0x9FFF; + } + + private static boolean isWordChar(char ch) { + return Character.isLetterOrDigit(ch); + } + + /** + * 词频向量(去重计数)。 + */ + public static Map termFrequency(List tokens) { + Map vector = new LinkedHashMap<>(); + if (tokens == null) { + return vector; + } + for (String token : tokens) { + vector.merge(token, 1.0, Double::sum); + } + return vector; + } + + /** + * 余弦相似度。 + */ + public static double cosine(Map left, Map right) { + if (left == null || right == null || left.isEmpty() || right.isEmpty()) { + return 0.0; + } + Map small = left.size() <= right.size() ? left : right; + Map large = small == left ? right : left; + double dot = 0.0; + for (Map.Entry entry : small.entrySet()) { + Double other = large.get(entry.getKey()); + if (other != null) { + dot += entry.getValue() * other; + } + } + if (dot == 0.0) { + return 0.0; + } + return dot / (norm(left) * norm(right)); + } + + private static double norm(Map vector) { + double sum = 0.0; + for (Double value : vector.values()) { + sum += value * value; + } + return Math.sqrt(sum); + } + + /** + * 去重后的词集合,便于做"命中率"类打分。 + */ + public static Set distinctTokens(String text, Set stopwords) { + return new LinkedHashSet<>(tokenize(text, stopwords)); + } + + /** + * 便捷方法:把文本转成词频向量。 + */ + public static Map vectorize(String text, Set stopwords) { + Map vector = new HashMap<>(); + for (String token : distinctTokens(text, stopwords)) { + vector.put(token, 1.0); + } + return vector; + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/router/ToolRouter.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/router/ToolRouter.java new file mode 100644 index 00000000..3f1eba61 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/router/ToolRouter.java @@ -0,0 +1,327 @@ +package com.ruoyi.sip.llm.router; + +import com.ruoyi.sip.llm.McpTool; +import com.ruoyi.sip.llm.McpToolRegistry; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.yaml.snakeyaml.Yaml; + +import javax.annotation.PostConstruct; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 工具路由(RAG):用中文 2-gram + 关键词加权做工具召回,避免每轮把全部工具的 schema 下发。 + *

配置来自 classpath:mcp-tool-router.yml,新增工具只需补别名与同义词,无需改代码。

+ */ +@Component +public class ToolRouter { + + private static final double WEIGHT_ALIAS = 3.0; + private static final double WEIGHT_SYNONYM = 2.5; + private static final double WEIGHT_TOOL_NAME = 2.0; + private static final double WEIGHT_DESCRIPTION = 1.0; + + @Autowired + private McpToolRegistry registry; + + private boolean enabled = true; + private int topK = 5; + private double minScore = 0.15; + private Set stopwords = new LinkedHashSet<>(); + private final Map> toolAliases = new LinkedHashMap<>(); + private final Map> synonyms = new LinkedHashMap<>(); + + /** 每个工具的词 -> 权重(取最大值),由别名/同义词/工具名/描述合成 */ + private final Map> toolTermWeights = new LinkedHashMap<>(); + /** 同义词 -> 工具(含 #entity 建议) */ + private final Map> synonymTools = new LinkedHashMap<>(); + + @PostConstruct + public void init() { + loadConfig(); + rebuildIndex(); + } + + @SuppressWarnings("unchecked") + private void loadConfig() { + ClassPathResource resource = new ClassPathResource("mcp-tool-router.yml"); + if (!resource.exists()) { + return; + } + try (InputStream input = resource.getInputStream()) { + Map root = new Yaml().load(input); + if (root == null) { + return; + } + Map router = asMap(root.get("router")); + if (router.isEmpty()) { + router = root; + } + enabled = asBoolean(router.get("enabled"), true); + topK = asInt(router.get("top-k"), 5); + minScore = asDouble(router.get("min-score"), 0.15); + stopwords = new LinkedHashSet<>(asStringList(router.get("stopwords"))); + toolAliases.putAll(asStringListMap(router.get("tool-aliases"))); + synonyms.putAll(asStringListMap(router.get("synonyms"))); + } catch (Exception ex) { + // 配置异常时退化为"仅用工具名与描述",不影响主流程 + enabled = true; + } + } + + @SuppressWarnings("unchecked") + private Map asMap(Object value) { + if (value instanceof Map) { + return (Map) value; + } + return Collections.emptyMap(); + } + + @SuppressWarnings("unchecked") + private Map> asStringListMap(Object value) { + Map> result = new LinkedHashMap<>(); + if (value instanceof Map) { + for (Map.Entry entry : ((Map) value).entrySet()) { + result.put(String.valueOf(entry.getKey()), asStringList(entry.getValue())); + } + } + return result; + } + + @SuppressWarnings("unchecked") + private List asStringList(Object value) { + List result = new ArrayList<>(); + if (value instanceof List) { + for (Object item : (List) value) { + if (item != null) { + result.add(String.valueOf(item)); + } + } + } else if (value != null) { + result.add(String.valueOf(value)); + } + return result; + } + + private boolean asBoolean(Object value, boolean defaultValue) { + if (value == null) { + return defaultValue; + } + return Boolean.parseBoolean(String.valueOf(value)); + } + + private int asInt(Object value, int defaultValue) { + try { + return value == null ? defaultValue : Integer.parseInt(String.valueOf(value)); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + private double asDouble(Object value, double defaultValue) { + try { + return value == null ? defaultValue : Double.parseDouble(String.valueOf(value)); + } catch (NumberFormatException ex) { + return defaultValue; + } + } + + /** + * 构建索引:把别名、同义词、工具名、描述合成到每个工具的加权词表。 + */ + public synchronized void rebuildIndex() { + toolTermWeights.clear(); + synonymTools.clear(); + + for (Map.Entry> entry : synonyms.entrySet()) { + String term = entry.getKey(); + for (String toolRef : entry.getValue()) { + String toolName = toolRef.contains("#") ? toolRef.substring(0, toolRef.indexOf('#')) : toolRef; + synonymTools.computeIfAbsent(term, key -> new ArrayList<>()).add(toolRef); + mergeTerm(toolName, term, WEIGHT_SYNONYM); + for (String token : ToolEmbedding.distinctTokens(term, stopwords)) { + mergeTerm(toolName, token, WEIGHT_SYNONYM); + } + } + } + + for (McpTool tool : registry.list()) { + String name = tool.getName(); + mergeTerm(name, name, WEIGHT_TOOL_NAME); + for (String token : ToolEmbedding.distinctTokens(name, stopwords)) { + mergeTerm(name, token, WEIGHT_TOOL_NAME); + } + for (String token : ToolEmbedding.distinctTokens(tool.getDescription(), stopwords)) { + mergeTerm(name, token, WEIGHT_DESCRIPTION); + } + for (String alias : toolAliases.getOrDefault(name, Collections.emptyList())) { + mergeTerm(name, alias, WEIGHT_ALIAS); + for (String token : ToolEmbedding.distinctTokens(alias, stopwords)) { + mergeTerm(name, token, WEIGHT_ALIAS); + } + } + } + } + + private void mergeTerm(String toolName, String term, double weight) { + if (toolName == null || term == null || term.trim().isEmpty()) { + return; + } + Map weights = toolTermWeights.computeIfAbsent(toolName, key -> new LinkedHashMap<>()); + weights.merge(term.trim().toLowerCase(), weight, Math::max); + } + + /** + * 召回候选工具(按得分降序)。 + */ + public List> retrieve(String query, Integer limit) { + List> result = new ArrayList<>(); + if (query == null || query.trim().isEmpty()) { + return result; + } + Set queryTokens = ToolEmbedding.distinctTokens(query, stopwords); + int querySize = queryTokens.isEmpty() ? 1 : queryTokens.size(); + double maxWeight = WEIGHT_ALIAS; + + List> scored = new ArrayList<>(); + for (Map.Entry> entry : toolTermWeights.entrySet()) { + String toolName = entry.getKey(); + if (registry.get(toolName) == null) { + continue; + } + double matchedWeight = 0.0; + List matchedTerms = new ArrayList<>(); + for (String token : queryTokens) { + Double weight = entry.getValue().get(token); + if (weight != null) { + matchedWeight += weight; + matchedTerms.add(token); + } + } + // 同义词整串命中额外加权 + String lowered = query.toLowerCase(); + for (String synonymTerm : synonymTools.keySet()) { + if (lowered.contains(synonymTerm.toLowerCase())) { + for (String toolRef : synonymTools.get(synonymTerm)) { + String refTool = toolRef.contains("#") ? toolRef.substring(0, toolRef.indexOf('#')) : toolRef; + if (refTool.equals(toolName)) { + matchedWeight += WEIGHT_SYNONYM; + matchedTerms.add(synonymTerm); + } + } + } + } + if (matchedWeight <= 0) { + continue; + } + double score = matchedWeight / (maxWeight * querySize); + Map item = new LinkedHashMap<>(); + item.put("name", toolName); + item.put("score", Math.round(score * 10000.0) / 10000.0); + item.put("matched_terms", matchedTerms); + scored.add(item); + } + + scored.sort(Comparator.comparingDouble((Map item) -> (Double) item.get("score")).reversed()); + int size = limit == null || limit <= 0 ? topK : limit; + for (int i = 0; i < scored.size() && i < size; i++) { + result.add(scored.get(i)); + } + return result; + } + + /** + * 自动路由:低于阈值不硬选,返回 candidates 供模型确认。 + */ + public Map route(String query, Integer limit) { + List> candidates = retrieve(query, limit); + Map result = new LinkedHashMap<>(); + result.put("query", query); + result.put("candidates", candidates); + result.put("min_score", minScore); + if (candidates.isEmpty() || ((Double) candidates.get(0).get("score")) < minScore) { + result.put("selected_tool", null); + result.put("hint", "未找到得分足够高的工具,请补充更具体的业务词(如\"库存\"\"采购单\"\"应收\"\"签收\"),或直接指定工具名调用 tools/call"); + return result; + } + Map best = candidates.get(0); + result.put("selected_tool", best.get("name")); + result.put("selected_score", best.get("score")); + + List suggestions = new ArrayList<>(); + String lowered = query.toLowerCase(); + for (Map.Entry> entry : synonymTools.entrySet()) { + if (!lowered.contains(entry.getKey().toLowerCase())) { + continue; + } + for (String ref : entry.getValue()) { + String refTool = ref.contains("#") ? ref.substring(0, ref.indexOf('#')) : ref; + int index = ref.indexOf('#'); + if (refTool.equals(best.get("name")) && index > 0) { + suggestions.add("entity=" + ref.substring(index + 1)); + } + } + } + if (!suggestions.isEmpty()) { + Map suggestedArgs = new LinkedHashMap<>(); + suggestedArgs.put("suggested_args", suggestions); + result.putAll(suggestedArgs); + } + return result; + } + + /** + * tools/call 未指定工具时使用:返回最佳工具名;低于阈值返回 null(由调用方报错提示)。 + */ + public String pickToolName(String query) { + List> candidates = retrieve(query, 1); + if (candidates.isEmpty()) { + return null; + } + Map best = candidates.get(0); + if (((Double) best.get("score")) < minScore) { + return null; + } + return (String) best.get("name"); + } + + /** + * tools/list 时按 query 过滤;未启用或未给 query 时返回 null 表示"不过滤"。 + */ + public Set filterToolNames(String query) { + if (!enabled || query == null || query.trim().isEmpty()) { + return null; + } + List> candidates = retrieve(query, topK); + if (candidates.isEmpty()) { + throw McpToolException.invalidParams("未找到与 query 匹配的工具,请换个说法或去掉 query 查看全部工具"); + } + Set names = new LinkedHashSet<>(); + for (Map item : candidates) { + names.add((String) item.get("name")); + } + return names; + } + + public boolean isEnabled() { + return enabled; + } + + public double getMinScore() { + return minScore; + } + + public int getTopK() { + return topK; + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/ApprovalListToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/ApprovalListToolProvider.java new file mode 100644 index 00000000..accb7a83 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/ApprovalListToolProvider.java @@ -0,0 +1,316 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.sip.flowable.domain.Todo; +import com.ruoyi.sip.flowable.mapper.TodoMapper; +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpCursor; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpPages; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpTimeRange; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * MCP 工具 approval_list(审批待办 / 已办,游标分页)。 + * + *

方案依据:docx/mcp-data-tools-plan.md 第 4.3、5、15.14、16.9 与附录 A.15。

+ * + *

回答的问题:"我还有哪些单要审""现在卡在谁那儿""为什么被驳回""审批耗了多久"。数据来自业务表 + * {@code bu_todo}(待办)与 {@code bu_todo_completed}(已办),不读 Flowable 的 act_* 表。

+ * + *

权限串来源:{@code TodoController} 的 /flow/todo/list、/flow/completed/list 未加 + * {@code @RequiresPermissions}(仅登录),因此按方案取审批入口页面的权限组合(任意一个满足): + * {@code OmsPurchaseOrderController#approveList} / {@code #approvedList}=sip:purchaseorder:list、 + * {@code OmsPaymentBillController#approveList}=finance:payment:approve、{@code #approvedList}=finance:payment:approve + * 与 {@code #list}=finance:payment:list。

+ */ +@Component +public class ApprovalListToolProvider extends AbstractMcpQueryToolProvider { + + private static final Set ENTITIES = new LinkedHashSet<>(Arrays.asList("TODO", "DONE")); + + private static final int MAX_PROCESS_KEY = 20; + private static final int DEFAULT_MONTHS = 12; + private static final int MAX_MONTHS = 36; + private static final long COUNT_CAP = 50000L; + + /** 实测已覆盖的 process_key(含源码拼写错误 fianance_ticket,匹配必须按原样)。 */ + private static final String[] PROCESS_KEYS = new String[]{ + "order_approve_online", "order_approve_offline", "purchase_order_online", + "finance_payment", "fianance_ticket", "order_reback", "outer_reback"}; + + @Autowired + private TodoMapper todoMapper; + + @Override + protected String getToolName() { + return "approval_list"; + } + + @Override + protected String getToolDescription() { + return "审批待办 / 已办查询(只读)。entity=TODO 我待审批的单据(bu_todo)/ DONE 我已审批的历史(bu_todo_completed," + + "含审批意见与审批结果)。默认按当前登录用户过滤审批人(approve_user=ALL 表示不按审批人过滤)。" + + "可回答:我还有哪些单要审 / 现在卡在谁那儿 / 为什么被驳回 / 审批耗了多久。" + + "注意:approve_status 语义为 3=通过、2=驳回、1=待审批(与常规枚举相反)。" + + "分页查询工具:若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 继续调用直到 " + + "has_more 为 false,否则数据不完整。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("entity", enumProperty("查询实体(必填):TODO=待办(bu_todo,我还没审的)/ " + + "DONE=已办(bu_todo_completed,我已审过的,含审批意见)", "TODO", "DONE")); + properties.put("approve_user", stringProperty("审批人(bu_todo.approve_user,用户ID字符串);" + + "不传=默认当前登录用户ID;传 ALL=不按审批人过滤(可看全部人的待办/已办)")); + properties.put("process_key_list", arrayProperty("流程KEY列表,≤" + MAX_PROCESS_KEY + ";实测已覆盖:" + + String.join(" / ", PROCESS_KEYS))); + properties.put("business_key_list", arrayProperty("业务主键列表(合同编号 / 采购单号等),≤" + + McpPages.MAX_CODE_LIST)); + properties.put("time_range", stringProperty("时间区间,格式 start~end(yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss);" + + "entity=TODO 基于 apply_time(发起时间),entity=DONE 基于 approve_time(审批时间);" + + "未传时默认近 " + DEFAULT_MONTHS + " 个月,跨度上限 " + MAX_MONTHS + " 个月")); + properties.put("time_range_start", stringProperty("时间区间开始(可选,与 time_range 二选一)")); + properties.put("time_range_end", stringProperty("时间区间结束(可选,与 time_range 二选一)")); + properties.put("include_total", boolProperty("是否统计总条数,默认 false;true 时受 count_cap=" + COUNT_CAP + + " 限制,超出置 total_count_capped=true")); + withPagingProperties(properties, McpPages.MAX_LIST_PAGE_SIZE, McpPages.DEFAULT_PAGE_SIZE); + return objectSchema(properties, "entity"); + } + + @Override + protected Object handle(Map params) { + String entity = McpParams.requireEnum(params, "entity", ENTITIES); + // 权限来源见类注释(TodoController 无权限注解,取审批入口页面权限,任意一个满足即放行) + McpAuth.requireAnyPermission("sip:purchaseorder:list", "finance:payment:approve", "finance:payment:list"); + + String approveUser = resolveApproveUser(params); + List processKeyList = McpParams.codeList(params, "process_key_list", MAX_PROCESS_KEY); + List businessKeyList = McpParams.codeList(params, "business_key_list", McpPages.MAX_CODE_LIST); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + int pageSize = McpPages.pageSize(params, McpPages.DEFAULT_PAGE_SIZE, McpPages.MAX_LIST_PAGE_SIZE); + boolean includeTotal = McpParams.boolVal(params, "include_total", false); + + String filterHash = McpParams.filterHash(getToolName(), entity, approveUser, + sortedJoin(processKeyList), sortedJoin(businessKeyList), McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + int pageNo = cursor == null ? 1 : cursor.getP(); + + Map q = new HashMap<>(); + q.put("approveUser", approveUser); + q.put("processKeyList", processKeyList); + q.put("businessKeyList", businessKeyList); + q.put("startTime", range.getStart()); + q.put("endTime", range.getEnd()); + q.put("limit", pageSize + 1); + if (cursor != null) { + q.put("lastTime", McpParams.parseDate(cursor.key(0), "cursor")); + q.put("lastId", cursor.longKey(1)); + } + + boolean todo = "TODO".equals(entity); + List rows = todo ? todoMapper.listTodoPage(q) : todoMapper.listTodoCompletedPage(q); + boolean hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + + List> items = new ArrayList<>(); + List lastSortKeys = null; + for (Todo row : page) { + items.add(todo ? toTodoItem(row) : toDoneItem(row)); + } + if (!page.isEmpty()) { + Todo last = page.get(page.size() - 1); + Date lastTime = todo ? last.getApplyTime() : last.getApproveTime(); + lastSortKeys = keysOf(McpFormat.dateTime(lastTime), last.getId()); + } + + Long total = null; + boolean totalCapped = false; + if (includeTotal) { + Map countQuery = new HashMap<>(q); + countQuery.remove("lastTime"); + countQuery.remove("lastId"); + countQuery.put("limit", COUNT_CAP + 1); + List countRows = todo ? todoMapper.listTodoPage(countQuery) : todoMapper.listTodoCompletedPage(countQuery); + long counted = countRows == null ? 0 : countRows.size(); + totalCapped = counted > COUNT_CAP; + total = totalCapped ? COUNT_CAP : counted; + } + + Map pageInfo = McpPages.pageInfo(items.size(), pageSize, hasMore, + hasMore ? McpCursor.encode(getToolName(), filterHash, pageNo + 1, lastSortKeys) : null, + todo ? "apply_time desc,id" : "approve_time desc,id", pageNo, total, totalCapped, false); + + Map query = new LinkedHashMap<>(); + query.put("entity", entity); + query.put("approve_user", approveUser == null ? "ALL" : approveUser); + query.put("process_key_list", processKeyList); + query.put("business_key_list", businessKeyList); + query.put("time_field", todo ? "apply_time" : "approve_time"); + query.put("time_range", McpParams.describe(range)); + query.put("page_size", pageSize); + query.put("page_no", pageNo); + query.put("include_total", includeTotal); + + return response(buildMetadata(entity), query, listData(items, pageInfo, total)); + } + + // ------------------------------------------------------------------ 行映射 + + private Map toTodoItem(Todo row) { + Map item = new LinkedHashMap<>(); + item.put("todoId", row.getTodoId()); + item.put("processInstanceId", row.getProcessInstanceId()); + item.put("taskId", row.getTaskId()); + item.put("businessKey", McpFormat.trim(row.getBusinessKey())); + item.put("processKey", row.getProcessKey()); + item.put("processName", row.getProcessName()); + item.put("taskName", row.getTaskName()); + item.put("approveUser", McpFormat.trim(row.getApproveUser())); + item.put("approveUserName", row.getApproveUserName()); + item.put("applyUserName", row.getApplyUserName()); + item.put("applyTime", McpFormat.dateTime(row.getApplyTime())); + item.put("formKey", row.getFormKey()); + item.put("extendField1", row.getExtendField1()); + item.put("extendField2", row.getExtendField2()); + item.put("extendField3", row.getExtendField3()); + return item; + } + + private Map toDoneItem(Todo row) { + Map item = toTodoItem(row); + item.put("approveTime", McpFormat.dateTime(row.getApproveTime())); + item.put("approveOpinion", row.getApproveOpinion()); + item.put("approveStatus", row.getApproveStatus()); + item.put("approveStatusName", approveStatusName(row.getApproveStatus())); + item.put("allApproveUserName", row.getAllApproveUserName()); + return item; + } + + /** + * ⚠️ 实测语义与常规相反:3=通过、2=驳回、1=待审批(附录 A.15)。 + */ + private String approveStatusName(Integer status) { + if (status == null) { + return null; + } + switch (status) { + case 3: + return "通过"; + case 2: + return "驳回"; + case 1: + return "待审批"; + default: + return String.valueOf(status); + } + } + + // ------------------------------------------------------------------ 辅助 + + private String resolveApproveUser(Map params) { + String value = McpParams.str(params, "approve_user"); + if (value == null) { + Long currentUserId = McpAuth.currentUserId(); + if (currentUserId == null) { + throw McpToolException.authError("无法识别当前登录用户,请显式传入 approve_user(用户ID)或 ALL"); + } + return String.valueOf(currentUserId); + } + if ("ALL".equalsIgnoreCase(value)) { + return null; + } + return value; + } + + private String sortedJoin(List values) { + if (values == null || values.isEmpty()) { + return ""; + } + return String.join(",", new TreeSet<>(values)); + } + + private Map buildMetadata(String entity) { + Map metadata = metadata("审批待办 / 已办(entity=" + entity + ");排序键 " + + ("TODO".equals(entity) ? "apply_time desc,id" : "approve_time desc,id") + + ",游标 keyset 分页;数据来自业务表 " + + ("TODO".equals(entity) ? "bu_todo" : "bu_todo_completed") + ",不读 Flowable act_* 表。"); + metadata.put("query_fields", mapOf( + "entity", "TODO=待办 / DONE=已办", + "approve_user", "审批人(bu_todo.approve_user,用户ID);默认当前登录用户ID,ALL=不过滤审批人", + "process_key_list", "流程KEY列表,≤" + MAX_PROCESS_KEY, + "business_key_list", "业务主键(合同编号/采购单号等)列表,≤" + McpPages.MAX_CODE_LIST, + "time_range", "start~end;TODO 基于 apply_time,DONE 基于 approve_time;默认近 " + DEFAULT_MONTHS + + " 个月、上限 " + MAX_MONTHS + " 个月", + "include_total", "是否统计总数(默认 false,上限 " + COUNT_CAP + ")", + "page_size", "每页条数(默认 " + McpPages.DEFAULT_PAGE_SIZE + ",上限 " + McpPages.MAX_LIST_PAGE_SIZE + ")", + "cursor", "游标:回传上次的 page_info.next_cursor")); + metadata.put("data_fields", mapOf( + "total", "include_total=true 时为真实命中条数,否则为当前页条数", + "items", "待办/已办列表", + "page_info", "分页信息:returned/page_size/has_more/next_cursor/sort_by/page_no/total_count/total_count_capped")); + metadata.put("item_fields", itemFields()); + metadata.put("process_key_coverage", mapOf( + "order_approve_online", "订单审批(线上)", + "order_approve_offline", "订单审批(线下)", + "purchase_order_online", "采购单审批", + "finance_payment", "付款审批", + "fianance_ticket", "收票审批(⚠️ 源码/数据里的拼写错误,少一个 n,匹配必须按原样,不要写成 finance_ticket)", + "order_reback", "订单撤回", + "outer_reback", "出库撤回")); + metadata.put("approve_status_semantics", "⚠️ 实测 approve_status 语义与常见枚举相反:3=通过、2=驳回、1=待审批;" + + "approveStatusName 按此口径翻译。"); + metadata.put("usage_questions", "该工具回答:我还有哪些单要审 / 现在卡在谁那儿(task_name 当前节点 + approve_user_name)" + + "/ 为什么被驳回(DONE 的 approve_opinion + approve_status=2)/ 审批耗了多久(DONE 的 approve_time - apply_time)。"); + metadata.put("notes", "① entity=DONE 的条目可能同一 todo_id 出现多条(bu_todo_completed 中 todo_id 不唯一)," + + "排序键以 id 作为唯一项;② bu_todo / bu_todo_completed 实测没有 business_id / business_name / " + + "project_code 列(实体里的同名字段为非常规冗余字段),故未回显业务名称与项目编号," + + "如需业务名称请用 business_key 调用其它工具(如 project_list(entity=CONTRACT));" + + "③ 时间条件为区间下推(>= start and < end),不使用 date_format;" + + "④ 默认时间区间为近 12 个月,超过 36 个月会报错。"); + metadata.put("permission_source", "TodoController 的 /flow/todo/list、/flow/completed/list 无 @RequiresPermissions" + + "(仅登录);取审批入口权限组合(任意一个满足):sip:purchaseorder:list" + + "(OmsPurchaseOrderController#approveList / #approvedList)、finance:payment:approve" + + "(OmsPaymentBillController#approveList / #approvedList)、finance:payment:list(OmsPaymentBillController#list)"); + return metadata; + } + + private Map itemFields() { + return mapOf( + "todoId", "流程ID(bu_todo.todo_id,Flowable 任务ID)", + "processInstanceId", "流程实例ID", + "taskId", "任务ID", + "businessKey", "业务主键(合同编号 / 采购单号等)", + "processKey", "流程KEY(见 metadata.process_key_coverage)", + "processName", "流程名称", + "taskName", "当前节点名称(TODO 为待审节点;DONE 为已审节点)", + "approveUser", "审批人ID(bu_todo.approve_user)", + "approveUserName", "审批人姓名", + "allApproveUserName", "所有审批人姓名(仅 DONE)", + "applyUserName", "发起人姓名", + "applyTime", "发起时间", + "approveTime", "审批时间(仅 DONE)", + "approveOpinion", "审批意见(仅 DONE,可用于回答“为什么被驳回”)", + "approveStatus", "审批结果编码(仅 DONE):3=通过、2=驳回、1=待审批", + "approveStatusName", "审批结果名称(仅 DONE)", + "formKey", "节点表单KEY", + "extendField1", "扩展字段1", + "extendField2", "扩展字段2", + "extendField3", "扩展字段3"); + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/CrossDomainAggregateToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/CrossDomainAggregateToolProvider.java new file mode 100644 index 00000000..ceac3a94 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/CrossDomainAggregateToolProvider.java @@ -0,0 +1,674 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpTimeRange; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.CrossDomainAggregateMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * MCP 工具 {@code cross_domain_aggregate}(受限跨域透视,只读)。 + * + *

方案依据:{@code docx/mcp-data-tools-plan.md} 第 4.4(D 类第 13 个工具)/ 第 7 章第 18 项 / 第 9 章(聚合口径)/ + * 第 13 章(一次算完,禁止 Agent 翻页累加)/ 第 15 章(时间区间下推、类型规范化、include_zero 判定)/ 16.2(白名单与护栏)。

+ * + *

★能力边界(16.2 原文:真正的“任意维度”等价于开放任意 SQL,安全与性能都不可控,故只做受限透视):

+ *
    + *
  1. 维度与度量均来自白名单,超出白名单直接 {@code INVALID_PARAMS} 并回显允许值;
  2. + *
  3. 单链路约束:一次请求的 metrics 必须属于同一条链路(SALES / PURCHASE / STOCK / FINANCE_AR / FINANCE_AP), + * 跨链路混用直接报错并说明各 metric 的链路归属;
  4. + *
  5. {@code time_range} 必填(本工具不默认近 12 个月),跨度上限 36 个月,且一律区间下推 + * ({@code >= start and < end}),禁止 {@code date_format()} 作为过滤条件;
  6. + *
  7. {@code group_limit} 默认 200 / 上限 1000,{@code top_n} 默认 100 / 上限 1000,超出直接报错并提示收窄;
  8. + *
  9. 不开放任意 SQL:维度表达式与排序度量在 XML 内以 {@code } 映射为固定表达式,模型无法传入任何 SQL 片段。
  10. + *
+ * + *

权限来源(按链路做最小权限校验,逐个从 Controller 的 {@code @RequiresPermissions} 抄取):

+ *
    + *
  • SALES → {@code ProjectOrderInfoController}({@code project:order:list} 见其 list 接口、{@code project:order:view})与 + * {@code ProjectInfoController}({@code sip:project:list}、{@code sip:project:view});
  • + *
  • PURCHASE → {@code OmsPurchaseOrderController#list} 的 {@code sip:purchaseorder:list}(已实测抄取);
  • + *
  • STOCK → {@code InventoryInfoController#view} 的 {@code inventory:info:view} + * (该 Controller 的 list 接口权限注解已被注释掉,故取 view 接口的权限串;与既有 inventory_stock_aggregate 一致);
  • + *
  • FINANCE_AR → {@code OmsReceivableBillController#list} 的 {@code finance:receivable:list}(已实测抄取);
  • + *
  • FINANCE_AP → {@code OmsPayableBillController#list} 的 {@code finance:payable:list}(已实测抄取)。
  • + *
+ * + *

数据链路与表清单见 {@code CrossDomainAggregateMapper} 与其 XML(本工具自包含,未改动任何既有 Mapper/工具类)。

+ */ +@Component +public class CrossDomainAggregateToolProvider extends AbstractMcpQueryToolProvider { + + /** 维度全局白名单(16.2) */ + private static final List DIMENSION_WHITELIST = Collections.unmodifiableList( + Arrays.asList("PARTNER", "CUSTOMER", "AGENT", "PRODUCT", "PROJECT", "WAREHOUSE", "MONTH", "QUARTER")); + + private static final int MAX_DIMENSIONS = 3; + private static final int MAX_TIME_MONTHS = 36; + private static final int DEFAULT_GROUP_LIMIT = 200; + private static final int MAX_GROUP_LIMIT = 1000; + private static final int DEFAULT_TOP_N = 100; + private static final int MAX_TOP_N = 1000; + private static final int MAX_METRICS = 16; + + /** metric → 链路(顺序即回显顺序) */ + private static final Map METRIC_LINK; + + /** metric → 响应字段名(驼峰,与域内专用工具命名保持一致) */ + private static final Map METRIC_ALIAS; + + /** 链路 → 该链路支持的维度(16.2 + 实表列核实结论) */ + private static final Map> LINK_DIMENSIONS; + + /** 链路 → 涉及的表清单(写入 data.lineage 与 metadata.lineage) */ + private static final Map> LINK_TABLES; + + /** 链路 → 时间字段(区间下推依据,方案 15.1) */ + private static final Map LINK_TIME_FIELD; + + static { + Map metricLink = new LinkedHashMap<>(); + metricLink.put("SALES_AMOUNT_WITH_TAX", "SALES"); + metricLink.put("SALES_AMOUNT_WITHOUT_TAX", "SALES"); + metricLink.put("PRODUCT_QTY", "SALES"); + metricLink.put("PURCHASE_QTY", "PURCHASE"); + metricLink.put("PURCHASE_AMOUNT_WITH_TAX", "PURCHASE"); + metricLink.put("PURCHASE_TAX", "PURCHASE"); + metricLink.put("IN_STOCK_QTY", "STOCK"); + metricLink.put("OUT_STOCK_QTY", "STOCK"); + metricLink.put("RECEIVABLE_WITH_TAX", "FINANCE_AR"); + metricLink.put("RECEIVED_WITH_TAX", "FINANCE_AR"); + metricLink.put("UNRECEIVED_WITH_TAX", "FINANCE_AR"); + metricLink.put("UNINVOICED_WITH_TAX", "FINANCE_AR"); + metricLink.put("PAYABLE_WITH_TAX", "FINANCE_AP"); + metricLink.put("PAID_WITH_TAX", "FINANCE_AP"); + metricLink.put("UNPAID_WITH_TAX", "FINANCE_AP"); + metricLink.put("UNTICKETED_WITH_TAX", "FINANCE_AP"); + METRIC_LINK = Collections.unmodifiableMap(metricLink); + + Map alias = new LinkedHashMap<>(); + alias.put("SALES_AMOUNT_WITH_TAX", "salesAmountWithTax"); + alias.put("SALES_AMOUNT_WITHOUT_TAX", "salesAmountWithoutTax"); + alias.put("PRODUCT_QTY", "productQty"); + alias.put("PURCHASE_QTY", "purchaseQty"); + alias.put("PURCHASE_AMOUNT_WITH_TAX", "purchaseAmountWithTax"); + alias.put("PURCHASE_TAX", "purchaseTax"); + alias.put("IN_STOCK_QTY", "inStockQty"); + alias.put("OUT_STOCK_QTY", "outStockQty"); + alias.put("RECEIVABLE_WITH_TAX", "receivableWithTax"); + alias.put("RECEIVED_WITH_TAX", "receivedWithTax"); + alias.put("UNRECEIVED_WITH_TAX", "unreceivedWithTax"); + alias.put("UNINVOICED_WITH_TAX", "uninvoicedWithTax"); + alias.put("PAYABLE_WITH_TAX", "payableWithTax"); + alias.put("PAID_WITH_TAX", "paidWithTax"); + alias.put("UNPAID_WITH_TAX", "unpaidWithTax"); + alias.put("UNTICKETED_WITH_TAX", "unticketedWithTax"); + METRIC_ALIAS = Collections.unmodifiableMap(alias); + + Map> dims = new LinkedHashMap<>(); + dims.put("SALES", Collections.unmodifiableList( + Arrays.asList("CUSTOMER", "AGENT", "PRODUCT", "PROJECT", "MONTH", "QUARTER"))); + dims.put("PURCHASE", Collections.unmodifiableList( + Arrays.asList("PRODUCT", "PARTNER", "MONTH", "QUARTER"))); + dims.put("STOCK", Collections.unmodifiableList( + Arrays.asList("PRODUCT", "WAREHOUSE", "MONTH", "QUARTER"))); + dims.put("FINANCE_AR", Collections.unmodifiableList( + Arrays.asList("CUSTOMER", "MONTH", "QUARTER"))); + dims.put("FINANCE_AP", Collections.unmodifiableList( + Arrays.asList("PARTNER", "MONTH", "QUARTER"))); + LINK_DIMENSIONS = Collections.unmodifiableMap(dims); + + Map> tables = new LinkedHashMap<>(); + tables.put("SALES", Collections.unmodifiableList(Arrays.asList( + "project_product_info", "project_order_info", "project_info", + "product_info", "customer_info", "agent_info"))); + tables.put("PURCHASE", Collections.unmodifiableList(Arrays.asList( + "oms_purchase_order_item", "oms_purchase_order", "oms_vendor_info", "product_info"))); + tables.put("STOCK", Collections.unmodifiableList(Arrays.asList( + "oms_inventory_info", "product_info", "oms_warehouse_info"))); + tables.put("FINANCE_AR", Collections.unmodifiableList(Arrays.asList("oms_receivable_bill"))); + tables.put("FINANCE_AP", Collections.unmodifiableList(Arrays.asList("oms_payable_bill"))); + LINK_TABLES = Collections.unmodifiableMap(tables); + + Map timeField = new LinkedHashMap<>(); + timeField.put("SALES", "project_order_info.create_time"); + timeField.put("PURCHASE", "oms_purchase_order.purchase_date"); + timeField.put("STOCK", "oms_inventory_info.create_time"); + timeField.put("FINANCE_AR", "oms_receivable_bill.create_time"); + timeField.put("FINANCE_AP", "oms_payable_bill.create_time"); + LINK_TIME_FIELD = Collections.unmodifiableMap(timeField); + } + + @Autowired + private CrossDomainAggregateMapper crossDomainAggregateMapper; + + @Override + protected String getToolName() { + return "cross_domain_aggregate"; + } + + @Override + protected String getToolDescription() { + return "受限跨域透视(只读,兜底能力):按 dimensions(1~3 个白名单维度,如 CUSTOMER/PRODUCT/MONTH)" + + "对同一条数据链路的 metrics 做一次分组聚合,返回 Top-N 分组 + 全局分组数,不需要翻页累加。" + + "链路与度量必须同源(SALES/PURCHASE/STOCK/FINANCE_AR/FINANCE_AP),跨链路混用会被拒绝;" + + "time_range 必填(≤36 个月),group_limit 默认 200/上限 1000,top_n 默认 100/上限 1000。" + + "无法表达的维度组合请改用域内专用工具(如 project_list / finance_list / warehouse_list)。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("dimensions", arrayProperty("维度(必填,1~3 个,白名单):CUSTOMER 客户 / AGENT 代表处 / PRODUCT 产品 / " + + "PROJECT 项目 / PARTNER 合作伙伴(采购侧=制造商、应付侧=制造商)/ WAREHOUSE 仓库 / MONTH 月(yyyy-MM)/ " + + "QUARTER 季度(yyyyQn)。各链路可用维度:SALES=" + LINK_DIMENSIONS.get("SALES") + + ",PURCHASE=" + LINK_DIMENSIONS.get("PURCHASE") + + ",STOCK=" + LINK_DIMENSIONS.get("STOCK") + + ",FINANCE_AR=" + LINK_DIMENSIONS.get("FINANCE_AR") + + ",FINANCE_AP=" + LINK_DIMENSIONS.get("FINANCE_AP"))); + properties.put("metrics", arrayProperty("度量(必填,白名单,且必须属于同一条链路):" + + "SALES=SALES_AMOUNT_WITH_TAX/SALES_AMOUNT_WITHOUT_TAX/PRODUCT_QTY;" + + "PURCHASE=PURCHASE_QTY/PURCHASE_AMOUNT_WITH_TAX/PURCHASE_TAX;" + + "STOCK=IN_STOCK_QTY/OUT_STOCK_QTY;" + + "FINANCE_AR=RECEIVABLE_WITH_TAX/RECEIVED_WITH_TAX/UNRECEIVED_WITH_TAX/UNINVOICED_WITH_TAX;" + + "FINANCE_AP=PAYABLE_WITH_TAX/PAID_WITH_TAX/UNPAID_WITH_TAX/UNTICKETED_WITH_TAX")); + properties.put("time_range", stringProperty("时间区间(必填,本工具不提供默认区间):格式 \"开始~结束\"(yyyy-MM-dd)," + + "如 2026-01-01~2026-06-30;跨度上限 " + MAX_TIME_MONTHS + " 个月;一律区间下推")); + properties.put("group_limit", intProperty("分组数上限(默认 " + DEFAULT_GROUP_LIMIT + ",上限 " + MAX_GROUP_LIMIT + + "):超出会报错,请缩小 time_range 或减少维度")); + properties.put("top_n", intProperty("按本次第一个 metric 降序取前 N 组(默认 " + DEFAULT_TOP_N + ",上限 " + MAX_TOP_N + ")")); + properties.put("include_zero", boolProperty("是否返回全零分组(默认 false):本次请求的全部 metrics 均为 0/null 的分组会被过滤")); + return objectSchema(properties, "dimensions", "metrics", "time_range"); + } + + @Override + protected Object handle(Map params) { + List dimensions = parseDimensions(params); + List metrics = parseMetrics(params); + String link = resolveLink(metrics); + // 权限:按链路做最小权限校验(权限串来源见类注释)——尽可能早做,权限不足返回 AUTH_ERROR,不返回空列表 + McpAuth.requireAnyPermission(permissionsOf(link)); + assertDimensionsSupported(link, dimensions); + + // 时间区间:必填(不能默认近 12 个月),跨度上限 36 个月 + McpTimeRange timeRange = McpParams.timeRange(params, "time_range", 0, MAX_TIME_MONTHS); + if (timeRange.getStart() == null || timeRange.getEnd() == null) { + throw McpToolException.invalidParams("time_range 为必填且必须同时给出起止时间,格式 \"开始~结束\"(yyyy-MM-dd,如 2026-01-01~2026-06-30);" + + "本工具不提供默认区间,也不接受开放区间(原因:链路时间列无索引,无界扫描会拖垮库,见方案 16.2 护栏 1)"); + } + + int groupLimit = McpParams.intVal(params, "group_limit", DEFAULT_GROUP_LIMIT); + if (groupLimit < 1 || groupLimit > MAX_GROUP_LIMIT) { + throw McpToolException.invalidParams("group_limit 取值非法:" + groupLimit + ",允许范围 1~" + MAX_GROUP_LIMIT + + ";超出请缩小 time_range 或减少维度"); + } + int topN = McpParams.intVal(params, "top_n", DEFAULT_TOP_N); + if (topN < 1 || topN > MAX_TOP_N) { + throw McpToolException.invalidParams("top_n 取值非法:" + topN + ",允许范围 1~" + MAX_TOP_N); + } + boolean includeZero = McpParams.boolVal(params, "include_zero", false); + + String d0 = dimensions.get(0); + String d1 = dimensions.size() > 1 ? dimensions.get(1) : null; + String d2 = dimensions.size() > 2 ? dimensions.get(2) : null; + String orderMetric = metrics.get(0); + + // 多取 1 组用于探测是否被 group_limit 截断 + List> rows = query(link, d0, d1, d2, orderMetric, + timeRange.getStart(), timeRange.getEnd(), groupLimit + 1); + boolean groupTruncated = rows.size() > groupLimit; + List> limited = groupTruncated + ? new ArrayList<>(rows.subList(0, groupLimit)) + : rows; + + // include_zero=false 时按本次请求的 metrics 集合过滤全零行(方案 15.6) + List> items = new ArrayList<>(); + for (Map row : limited) { + Map item = toItem(dimensions, metrics, row); + if (!includeZero && isAllZero(item, metrics)) { + continue; + } + items.add(item); + } + boolean topTruncated = items.size() > topN; + if (topTruncated) { + items = new ArrayList<>(items.subList(0, topN)); + } + + Map query = new LinkedHashMap<>(); + query.put("link", link); + query.put("dimensions", dimensions); + query.put("metrics", metrics); + query.put("time_range", McpParams.describe(timeRange)); + query.put("time_field", LINK_TIME_FIELD.get(link)); + query.put("group_limit", groupLimit); + query.put("top_n", topN); + query.put("include_zero", includeZero); + + Map data = new LinkedHashMap<>(); + data.put("items", items); + data.put("groupCount", items.size()); + data.put("truncated", groupTruncated || topTruncated); + data.put("lineage", lineage(link, dimensions)); + + return response(buildMetadata(), query, data); + } + + // ------------------------------------------------------------------ 入参校验 + + private List parseDimensions(Map params) { + List raw = McpParams.codeList(params, "dimensions", MAX_DIMENSIONS); + if (raw.isEmpty()) { + throw McpToolException.invalidParams("缺少必填参数:dimensions(1~" + MAX_DIMENSIONS + " 个),允许值:" + + DIMENSION_WHITELIST + ";示例 [\"CUSTOMER\",\"PRODUCT\",\"MONTH\"]"); + } + List result = new ArrayList<>(); + for (String value : raw) { + String dim = value.toUpperCase(Locale.ROOT); + if (!DIMENSION_WHITELIST.contains(dim)) { + throw McpToolException.invalidParams("dimensions 取值非法:" + value + ",允许值:" + DIMENSION_WHITELIST + + "(本工具不开放任意维度,等价于任意 SQL,见方案 16.2)"); + } + if (result.contains(dim)) { + throw McpToolException.invalidParams("dimensions 不允许重复:" + dim + ",当前:" + raw); + } + result.add(dim); + } + return result; + } + + private List parseMetrics(Map params) { + List raw = McpParams.codeList(params, "metrics", MAX_METRICS); + if (raw.isEmpty()) { + throw McpToolException.invalidParams("缺少必填参数:metrics,允许值(按链路分组):" + describeMetricWhitelist()); + } + List result = new ArrayList<>(); + for (String value : raw) { + String metric = value.toUpperCase(Locale.ROOT); + if (!METRIC_LINK.containsKey(metric)) { + throw McpToolException.invalidParams("metrics 取值非法:" + value + ",允许值(按链路分组):" + describeMetricWhitelist()); + } + if (result.contains(metric)) { + throw McpToolException.invalidParams("metrics 不允许重复:" + metric + ",当前:" + raw); + } + result.add(metric); + } + return result; + } + + private String resolveLink(List metrics) { + Set links = new LinkedHashSet<>(); + for (String metric : metrics) { + links.add(METRIC_LINK.get(metric)); + } + if (links.size() > 1) { + StringBuilder detail = new StringBuilder(); + for (String metric : metrics) { + if (detail.length() > 0) { + detail.append("、"); + } + detail.append(metric).append("→").append(METRIC_LINK.get(metric)); + } + throw McpToolException.invalidParams("metrics 跨链路混用:" + detail + + "。一条请求只能命中一条数据链路(本工具为受限透视,不做跨表 join 拼装);" + + "如需同时看销售与未收款,请分两次调用。各链路可用度量:" + describeMetricWhitelist()); + } + return links.iterator().next(); + } + + private void assertDimensionsSupported(String link, List dimensions) { + List supported = LINK_DIMENSIONS.get(link); + List unsupported = new ArrayList<>(); + for (String dim : dimensions) { + if (!supported.contains(dim)) { + unsupported.add(dim); + } + } + if (!unsupported.isEmpty()) { + throw McpToolException.invalidParams("链路 " + link + " 不支持维度 " + unsupported + + ";该链路可用维度:" + supported + "(链路与可用维度对应关系:" + describeLinkDimensions() + ")"); + } + } + + // ------------------------------------------------------------------ 查询与组装 + + private List> query(String link, String d0, String d1, String d2, String orderMetric, + Date startTime, Date endTime, int limit) { + List> rows; + if ("SALES".equals(link)) { + rows = crossDomainAggregateMapper.selectSalesAggregate(d0, d1, d2, orderMetric, startTime, endTime, limit); + } else if ("PURCHASE".equals(link)) { + rows = crossDomainAggregateMapper.selectPurchaseAggregate(d0, d1, d2, orderMetric, startTime, endTime, limit); + } else if ("STOCK".equals(link)) { + rows = crossDomainAggregateMapper.selectStockAggregate(d0, d1, d2, orderMetric, startTime, endTime, limit); + } else if ("FINANCE_AR".equals(link)) { + rows = crossDomainAggregateMapper.selectReceivableAggregate(d0, d1, d2, orderMetric, startTime, endTime, limit); + } else { + rows = crossDomainAggregateMapper.selectPayableAggregate(d0, d1, d2, orderMetric, startTime, endTime, limit); + } + return rows == null ? Collections.>emptyList() : rows; + } + + private Map toItem(List dimensions, List metrics, Map row) { + Map item = new LinkedHashMap<>(); + Map dimensionMap = new LinkedHashMap<>(); + + for (int i = 0; i < dimensions.size(); i++) { + String dim = dimensions.get(i); + String codeKey = "d" + i; + String nameKey = "d" + i + "Name"; + String code = McpFormat.trimToNull(stringValue(row.get(codeKey))); + String name = McpFormat.trimToNull(stringValue(row.get(nameKey))); + dimensionMap.put(dim.toLowerCase(Locale.ROOT), code); + + if ("MONTH".equals(dim) || "QUARTER".equals(dim)) { + item.put(dim.toLowerCase(Locale.ROOT), code); + } else if ("WAREHOUSE".equals(dim)) { + item.put("warehouseId", longVal(row.get(codeKey))); + item.put("warehouseName", name); + } else { + item.put(dim.toLowerCase(Locale.ROOT) + "Code", code); + item.put(dim.toLowerCase(Locale.ROOT) + "Name", name); + } + } + + item.put("dimensionMap", dimensionMap); + for (String metric : metrics) { + String alias = METRIC_ALIAS.get(metric); + item.put(alias, decimal(row.get(alias))); + } + item.put("rowQty", longVal(row.get("rowQty"))); + return item; + } + + /** + * include_zero=false 时判定“全零行”:本次请求的全部 metrics 均为 0 或 null(方案 15.6)。 + */ + private boolean isAllZero(Map item, List metrics) { + for (String metric : metrics) { + BigDecimal value = decimal(item.get(METRIC_ALIAS.get(metric))); + if (value.compareTo(BigDecimal.ZERO) != 0) { + return false; + } + } + return true; + } + + private Map lineage(String link, List dimensions) { + Map lineage = new LinkedHashMap<>(); + lineage.put("link", link); + lineage.put("tables", LINK_TABLES.get(link)); + lineage.put("time_field", LINK_TIME_FIELD.get(link)); + Map dimensionFields = new LinkedHashMap<>(); + for (String dim : dimensions) { + dimensionFields.put(dim.toLowerCase(Locale.ROOT), dimensionFieldOf(link, dim)); + } + lineage.put("dimension_fields", dimensionFields); + lineage.put("join_note", joinNoteOf(link)); + return lineage; + } + + private String dimensionFieldOf(String link, String dim) { + if ("SALES".equals(link)) { + if ("CUSTOMER".equals(dim)) { + return "project_info.customer_code(名称 customer_info.customer_name)"; + } + if ("AGENT".equals(dim)) { + return "project_info.agent_code(名称 agent_info.agent_name)"; + } + if ("PRODUCT".equals(dim)) { + return "project_product_info.product_bom_code(名称 product_info.product_name)"; + } + if ("PROJECT".equals(dim)) { + return "project_info.project_code(名称 project_info.project_name)"; + } + return "project_order_info.create_time(" + ("MONTH".equals(dim) ? "yyyy-MM" : "yyyyQn") + ")"; + } + if ("PURCHASE".equals(link)) { + if ("PRODUCT".equals(dim)) { + return "oms_purchase_order_item.product_code(名称 product_info.product_name)"; + } + if ("PARTNER".equals(dim)) { + return "oms_vendor_info.vendor_code(经 oms_purchase_order.vendor_id 关联;名称 oms_vendor_info.vendor_name)"; + } + return "oms_purchase_order.purchase_date(" + ("MONTH".equals(dim) ? "yyyy-MM" : "yyyyQn") + ")"; + } + if ("STOCK".equals(link)) { + if ("PRODUCT".equals(dim)) { + return "oms_inventory_info.product_code(名称 product_info.product_name)"; + } + if ("WAREHOUSE".equals(dim)) { + return "oms_inventory_info.warehouse_id(名称 oms_warehouse_info.warehouse_name)"; + } + return "oms_inventory_info.create_time(" + ("MONTH".equals(dim) ? "yyyy-MM" : "yyyyQn") + ")"; + } + if ("FINANCE_AR".equals(link)) { + if ("CUSTOMER".equals(dim)) { + return "oms_receivable_bill.partner_code(名称取冗余列 partner_name)"; + } + return "oms_receivable_bill.create_time(" + ("MONTH".equals(dim) ? "yyyy-MM" : "yyyyQn") + ")"; + } + if ("PARTNER".equals(dim)) { + return "oms_payable_bill.vendor_code(名称取冗余列 vendor_name;★此处 partner 语义为制造商)"; + } + return "oms_payable_bill.create_time(" + ("MONTH".equals(dim) ? "yyyy-MM" : "yyyyQn") + ")"; + } + + private String joinNoteOf(String link) { + if ("SALES".equals(link)) { + return "project_product_info ⋈ project_order_info(project_id)⋈ project_info(project_id):" + + "project_info 关联沿用既有 SQL(ProjectOrderInfoMapper.selectProjectOrderInfoRelationVo)的 project_id 主键关联;" + + "★任务描述建议按 order_code 对齐,但 project_info.order_code 可能为空会导致大量丢关联,故采用 project_id(已在回报中标注为偏离项)"; + } + if ("PURCHASE".equals(link)) { + return "oms_purchase_order_item ⋈ oms_purchase_order(i.purchase_id = o.id);" + + "名称仅在请求对应维度时 left join(product_info / oms_vendor_info)"; + } + if ("STOCK".equals(link)) { + return "oms_inventory_info 单表;名称仅在请求对应维度时 left join(product_info / oms_warehouse_info)"; + } + return "单表聚合,金额直接 sum 冗余列,不重算、不关联明细表"; + } + + private String[] permissionsOf(String link) { + if ("SALES".equals(link)) { + return new String[]{"project:order:list", "project:order:view", "sip:project:list", "sip:project:view"}; + } + if ("PURCHASE".equals(link)) { + return new String[]{"sip:purchaseorder:list"}; + } + if ("STOCK".equals(link)) { + return new String[]{"inventory:info:view"}; + } + if ("FINANCE_AR".equals(link)) { + return new String[]{"finance:receivable:list"}; + } + return new String[]{"finance:payable:list"}; + } + + // ------------------------------------------------------------------ metadata + + private Map buildMetadata() { + Map metadata = metadata("受限跨域透视:按 dimensions(1~3 个白名单维度)对同一条链路的 metrics 做一次分组聚合," + + "返回 Top-N 分组 + 分组数 + 链路血缘;链路为 SALES / PURCHASE / STOCK / FINANCE_AR / FINANCE_AP,度量不可跨链路混用。"); + + metadata.put("query_fields", mapOf( + "dimensions", "维度(必填,1~3 个,白名单):" + DIMENSION_WHITELIST, + "metrics", "度量(必填,必须同属一条链路):" + describeMetricWhitelist(), + "time_range", "时间区间(必填)\"开始~结束\"(yyyy-MM-dd),跨度上限 " + MAX_TIME_MONTHS + " 个月", + "group_limit", "分组数上限(默认 " + DEFAULT_GROUP_LIMIT + ",上限 " + MAX_GROUP_LIMIT + ")", + "top_n", "按第一个 metric 降序取前 N 组(默认 " + DEFAULT_TOP_N + ",上限 " + MAX_TOP_N + ")", + "include_zero", "是否返回全零分组(默认 false,按本次 metrics 集合判定)")); + metadata.put("dimensions", mapData( + "whitelist", DIMENSION_WHITELIST, + "by_link", LINK_DIMENSIONS, + "time_bucket_format", "MONTH → yyyy-MM(如 2026-01);QUARTER → yyyyQn(如 2026Q1)")); + metadata.put("metrics", mapData( + "by_link", metricWhitelistByLink(), + "response_fields", METRIC_ALIAS)); + metadata.put("data_fields", mapOf( + "items", "分组结果,每行 = 一个维度组合 + 各 metric 值 + rowQty(参与聚合的原始行数)", + "groupCount", "本次返回的分组数(已按 include_zero / top_n 处理)", + "truncated", "是否被截断:true 表示命中 group_limit(分组过多)或 top_n(按第一个 metric 取前 N)", + "lineage", "本次命中的链路、涉及表清单、时间字段、各维度的来源字段与关联说明")); + metadata.put("item_fields", mapOf( + "dimensionMap", "维度键→维度值(键为维度名小写,如 customer/product/month,值为编码或时间桶;时间桶为字符串)", + "customerCode/customerName", "客户编码/名称(维度 CUSTOMER)", + "agentCode/agentName", "代表处编码/名称(维度 AGENT,SALES)", + "productCode/productName", "产品编码/名称(维度 PRODUCT)", + "projectCode/projectName", "项目编码/名称(维度 PROJECT,SALES)", + "partnerCode/partnerName", "合作伙伴编码/名称(维度 PARTNER:采购侧=制造商、应付侧=制造商)", + "warehouseId/warehouseName", "仓库ID/名称(维度 WAREHOUSE,STOCK)", + "month", "月份桶 yyyy-MM(维度 MONTH)", + "quarter", "季度桶 yyyyQn(维度 QUARTER)", + "salesAmountWithTax", "SALES:含税销售额(Σ project_product_info.all_price)", + "salesAmountWithoutTax", "SALES:未税销售额(Σ all_price/(1+税率),税率>1 视为百分数)", + "productQty", "SALES:产品数量(Σ project_product_info.quantity)", + "purchaseQty", "PURCHASE:采购数量(Σ oms_purchase_order_item.quantity)", + "purchaseAmountWithTax", "PURCHASE:含税金额(Σ item.amount_total)", + "purchaseTax", "PURCHASE:税额(Σ item.tax_total)", + "inStockQty", "STOCK:在库数量(count inventory_status='0')", + "outStockQty", "STOCK:已出库数量(count inventory_status='1')", + "receivableWithTax", "FINANCE_AR:应收含税总额(Σ total_price_with_tax)", + "receivedWithTax", "FINANCE_AR:已收含税金额(Σ received_amount)", + "unreceivedWithTax", "FINANCE_AR:未收含税金额(Σ unreceived_amount)", + "uninvoicedWithTax", "FINANCE_AR:未开票金额(Σ uninvoiced_amount)", + "payableWithTax", "FINANCE_AP:应付含税总额(Σ total_price_with_tax)", + "paidWithTax", "FINANCE_AP:已付含税金额(Σ paid_payment_amount)", + "unpaidWithTax", "FINANCE_AP:未付含税金额(Σ unpaid_payment_amount)", + "unticketedWithTax", "FINANCE_AP:未收票金额(Σ unreceived_ticket_amount)", + "rowQty", "该分组参与聚合的原始行数(count(*))")); + metadata.put("aggregation_rule", mapOf( + "sales_with_tax", "SALES_AMOUNT_WITH_TAX = Σ project_product_info.all_price;与既有 ProjectOrderInfoToolProvider 一致," + + "all_price 视为含税总价(列注释仅“总价”,含税口径与既有工具保持同源)", + "sales_without_tax", "SALES_AMOUNT_WITHOUT_TAX = Σ all_price/(1+tax_rate);tax_rate>1 视为百分数(与既有 normalizePercentTaxRate 口径一致)," + + "tax_rate 为 NULL 按 0 计(等价于未税=含税)", + "purchase", "PURCHASE_QTY = Σ item.quantity;PURCHASE_AMOUNT_WITH_TAX = Σ item.amount_total(列注释“含税金额”);" + + "PURCHASE_TAX = Σ item.tax_total(列注释“税额”)", + "stock", "IN_STOCK_QTY = count(inventory_status='0');OUT_STOCK_QTY = count(inventory_status='1');" + + "在库口径按方案 15.7 默认 A(inventory_status='0' 即视为在库,★未扣除“已发货未出库”的占用)", + "finance", "FINANCE_AR / FINANCE_AP 金额一律直接 sum 账单主表冗余列(total_price_with_tax / received_amount / " + + "unreceived_amount / uninvoiced_amount / paid_payment_amount / unpaid_payment_amount / unreceived_ticket_amount)," + + "★不重算、不换算、与页面展示一致(方案第九章)", + "null_as_zero", "所有 sum 均以 ifnull(col,0) 参与,NULL 计 0", + "currency", "金额按原币返回,不自动换算", + "time_pushdown", "时间条件一律区间下推(>= start and < end);date_format / concat 仅用于分组与输出表达式")); + metadata.put("lineage", mapData( + "description", "每条响应在 data.lineage 中回传本次命中的链路名、涉及表清单、时间字段与各维度来源字段,避免误读口径(方案 16.2 护栏 6)", + "by_link", LINK_TABLES, + "join_notes", joinNoteOfAll())); + metadata.put("guardrails", mapOf( + "time_range_required", "time_range 必填且必须同时给出起止(不接受开放区间、不默认近 12 个月),跨度上限 36 个月;" + + "原因:链路时间列均无索引,无界扫描会拖垮库", + "single_link", "metrics 必须属于同一条链路,跨链路混用直接 INVALID_PARAMS 并回显各自的链路归属", + "whitelist_only", "dimensions / metrics 均走白名单,且维度必须被该链路支持;超出白名单一律报错,不静默忽略", + "group_limit", "group_limit 默认 " + DEFAULT_GROUP_LIMIT + "、上限 " + MAX_GROUP_LIMIT + ";超出报错并提示缩小 time_range 或减少维度", + "top_n", "top_n 默认 " + DEFAULT_TOP_N + "、上限 " + MAX_TOP_N + ",按第一个 metric 降序", + "no_arbitrary_sql", "★不开放任意 SQL:维度表达式与排序度量在 XML 内以 映射为固定表达式,模型无法注入任何 SQL 片段;" + + "表清单固定为 " + LINK_TABLES, + "table_whitelist", "仅访问 McpTables.ALLOWED 内的表;严禁备份/历史表(如 project_product_info_bak、oms_purchase_order_1211、" + + "oms_payable_bill_copy1、oms_inventory_info_copy1)", + "explicit_time_field", "STOCK 链路时间字段显式取 oms_inventory_info.create_time(方案 15.1 默认口径),已在 lineage.time_field 声明;" + + "如需按出库时间统计请使用库存/出库明细工具", + "unsupported_dimension", "FINANCE_AR / FINANCE_AP 不支持 PROJECT 维度:账单主表无 project 列," + + "经 order_code 关联 project_order_info 存在一对多(多版本)导致金额放大的风险,故不提供(宁缺不错)", + "prefer_domain_tools", "本工具为兜底能力(SALES 链路为 3 表 join + 分组,是最重的查询),能用域内专用工具表达的请优先使用" + + "(project_list / finance_list / warehouse_list / 各 *_aggregate)")); + metadata.put("permission_source", "按链路最小权限校验:" + permissionsOf("SALES")[0] + " / " + + permissionsOf("SALES")[1] + " / " + permissionsOf("SALES")[2] + " / " + permissionsOf("SALES")[3] + "(SALES,来源 ProjectOrderInfoController 与 ProjectInfoController);" + + permissionsOf("PURCHASE")[0] + "(PURCHASE,来源 OmsPurchaseOrderController#list);" + + permissionsOf("STOCK")[0] + "(STOCK,来源 InventoryInfoController#view,其 list 注解被注释);" + + permissionsOf("FINANCE_AR")[0] + "(FINANCE_AR,来源 OmsReceivableBillController#list);" + + permissionsOf("FINANCE_AP")[0] + "(FINANCE_AP,来源 OmsPayableBillController#list)"); + metadata.put("notes", "时间桶 MONTH → yyyy-MM、QUARTER → yyyyQn;编码类字段入参/出参统一 trim(库中存在前导制表符脏数据,见方案 15.14);" + + "编码匹配不区分大小写(utf8mb4_unicode_ci);include_zero=false 时按本次请求的 metrics 集合过滤全零行;" + + "truncated=true 说明结果被 group_limit 或 top_n 截断,请收窄 time_range 或减少维度后重试。"); + return metadata; + } + + private Map metricWhitelistByLink() { + Map> grouped = new LinkedHashMap<>(); + for (Map.Entry entry : METRIC_LINK.entrySet()) { + List list = grouped.get(entry.getValue()); + if (list == null) { + list = new ArrayList<>(); + grouped.put(entry.getValue(), list); + } + list.add(entry.getKey()); + } + Map result = new LinkedHashMap<>(); + for (Map.Entry> entry : grouped.entrySet()) { + result.put(entry.getKey(), entry.getValue()); + } + return result; + } + + private String describeMetricWhitelist() { + return metricWhitelistByLink().toString(); + } + + private String describeLinkDimensions() { + return LINK_DIMENSIONS.toString(); + } + + private Map joinNoteOfAll() { + Map result = new LinkedHashMap<>(); + for (String link : LINK_DIMENSIONS.keySet()) { + result.put(link, joinNoteOf(link)); + } + return result; + } + + // ------------------------------------------------------------------ 辅助 + + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value); + } + + private BigDecimal decimal(Object value) { + if (value == null) { + return BigDecimal.ZERO; + } + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof Number) { + return new BigDecimal(value.toString()); + } + try { + return new BigDecimal(String.valueOf(value).trim()); + } catch (NumberFormatException ex) { + return BigDecimal.ZERO; + } + } + + private Long longVal(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.valueOf(String.valueOf(value).trim()); + } catch (NumberFormatException ex) { + return null; + } + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceBalanceAggregateToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceBalanceAggregateToolProvider.java new file mode 100644 index 00000000..921cffec --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceBalanceAggregateToolProvider.java @@ -0,0 +1,617 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpCursor; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpPages; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpTimeRange; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.OmsPayableBillMapper; +import com.ruoyi.sip.mapper.OmsReceivableBillMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * MCP 工具 finance_balance_aggregate(财务余额聚合:SUMMARY 默认 / LIST 分页)。 + * + *

度量固定 8 个:应收侧 receivableWithTax / receivedWithTax / unreceivedWithTax / uninvoicedWithTax; + * 应付侧 payableWithTax / paidWithTax / unpaidWithTax / unticketedWithTax。 + * 未传 as_of_date 时直接取账单主表冗余列求和(不重算,basis=CURRENT); + * 传入 as_of_date 时按明细重算(basis=RECALCULATED@<date>,两种口径不可混用)。

+ * + *

方案依据:docx/mcp-data-tools-plan.md 第 4.2 / 5 / 7 / 8 / 9 / 13 / 15 章与附录 A.6(含 v13 的 OVERDUE_BUCKET 与 as_of_date)。

+ *

权限来源(从 Controller 的 {@code @RequiresPermissions} 抄取): + * {@code OmsReceivableBillController#list} = {@code finance:receivable:list}; + * {@code OmsPayableBillController#list} = {@code finance:payable:list};两者"任意一个满足"即放行。

+ */ +@Component +public class FinanceBalanceAggregateToolProvider extends AbstractMcpQueryToolProvider { + + private static final Set MODES = new LinkedHashSet<>(Arrays.asList("SUMMARY", "LIST")); + private static final Set GROUP_BYS = new LinkedHashSet<>( + Arrays.asList("NONE", "ORDER", "PARTNER", "TIME_MONTH", "OVERDUE_BUCKET")); + private static final int DEFAULT_TIME_MONTHS = 12; + private static final int MAX_TIME_MONTHS = 36; + private static final int DEFAULT_TOP_N = 10; + private static final int MAX_TOP_N = 100; + private static final long COUNT_CAP = 50000L; + /** 账龄分桶行数固定(4 桶 + NO_PLAN),取一个安全上限 */ + private static final int OVERDUE_BUCKET_LIMIT = 10; + + @Autowired + private OmsReceivableBillMapper omsReceivableBillMapper; + + @Autowired + private OmsPayableBillMapper omsPayableBillMapper; + + @Override + protected String getToolName() { + return "finance_balance_aggregate"; + } + + @Override + protected String getToolDescription() { + return "财务余额聚合(只读)。mode=SUMMARY(默认)按 group_by 一次算完,mode=LIST 按 order_code 游标分页。" + + "固定度量:receivableWithTax 应收含税、receivedWithTax 已收、unreceivedWithTax 未收、uninvoicedWithTax 未开票、" + + "payableWithTax 应付含税、paidWithTax 已付、unpaidWithTax 未付、unticketedWithTax 未收票。" + + "group_by=OVERDUE_BUCKET 返回账龄分桶(0-30/31-60/61-90/90+,仅未收款行);as_of_date 为历史时点重算口径。" + + "分页查询时:若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 继续调用直到 has_more 为 false,否则数据不完整。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("mode", enumProperty("模式:SUMMARY 汇总(默认,不分页)/ LIST 明细(按 order_code 游标分页)", "SUMMARY", "LIST")); + properties.put("group_by", enumProperty("SUMMARY 分组维度:NONE 总计(默认)/ ORDER 合同编号 / PARTNER 合作伙伴 / " + + "TIME_MONTH 月份 / OVERDUE_BUCKET 账龄分桶(仅 SUMMARY)", + "NONE", "ORDER", "PARTNER", "TIME_MONTH", "OVERDUE_BUCKET")); + properties.put("order_code_list", arrayProperty("合同编号列表,≤" + McpPages.MAX_CODE_LIST + ";mode=LIST 时与 partner_code_list / vendor_code_list 至少提供一个")); + properties.put("partner_code_list", arrayProperty("客户编码列表(应收侧 partner_code),≤" + McpPages.MAX_CODE_LIST)); + properties.put("vendor_code_list", arrayProperty("制造商编码列表(应付侧 vendor_code),≤" + McpPages.MAX_CODE_LIST)); + properties.put("time_range", stringProperty("时间区间,格式 \"开始~结束\"(yyyy-MM-dd);应收侧基于 oms_receivable_bill.create_time、" + + "应付侧基于 oms_payable_bill.create_time;未传时默认近 " + DEFAULT_TIME_MONTHS + " 个月,跨度上限 " + MAX_TIME_MONTHS + " 个月")); + properties.put("time_range_start", stringProperty("时间区间开始(可选,与 time_range 二选一)")); + properties.put("time_range_end", stringProperty("时间区间结束(可选,与 time_range 二选一)")); + properties.put("as_of_date", stringProperty("历史时点日期(yyyy-MM-dd,可选):按明细重算该时点余额," + + "仅支持 mode=SUMMARY + group_by=NONE")); + properties.put("top_n", intProperty("SUMMARY 返回前 N 组(默认 " + DEFAULT_TOP_N + ",上限 " + MAX_TOP_N + ");仅 SUMMARY 可用;" + + "group_by=TIME_MONTH 时忽略(返回区间内全部月份)")); + properties.put("include_total", boolProperty("是否统计总组数(默认 false);LIST 时受 count_cap=" + COUNT_CAP + " 限制")); + return withPagingProperties(properties, McpPages.MAX_AGG_PAGE_SIZE, McpPages.DEFAULT_PAGE_SIZE); + } + + @Override + protected Object handle(Map params) { + // 权限串来源:OmsReceivableBillController#list / OmsPayableBillController#list + McpAuth.requireAnyPermission("finance:receivable:list", "finance:payable:list"); + + String mode = McpParams.enumVal(params, "mode", MODES, "SUMMARY"); + String groupBy = McpParams.enumVal(params, "group_by", GROUP_BYS, + "LIST".equals(mode) ? "ORDER" : "NONE"); + List orderCodeList = McpParams.codeList(params, "order_code_list", McpPages.MAX_CODE_LIST); + List partnerCodeList = McpParams.codeList(params, "partner_code_list", McpPages.MAX_CODE_LIST); + List vendorCodeList = McpParams.codeList(params, "vendor_code_list", McpPages.MAX_CODE_LIST); + McpTimeRange timeRange = McpParams.timeRange(params, "time_range", DEFAULT_TIME_MONTHS, MAX_TIME_MONTHS); + Date asOfDate = McpParams.date(params, "as_of_date"); + Integer topN = McpParams.intVal(params, "top_n", DEFAULT_TOP_N); + if (topN < 1 || topN > MAX_TOP_N) { + throw McpToolException.invalidParams("top_n 取值非法:" + topN + ",允许范围 1~" + MAX_TOP_N); + } + boolean includeTotal = McpParams.boolVal(params, "include_total", false); + String basis = asOfDate == null ? "CURRENT" : "RECALCULATED@" + McpFormat.date(asOfDate); + + if ("LIST".equals(mode)) { + McpParams.reject(McpParams.has(params, "top_n"), "mode=LIST 不支持 top_n(top_n 仅 SUMMARY 可用)"); + if (!"ORDER".equals(groupBy)) { + throw McpToolException.invalidParams("mode=LIST 仅支持 group_by=ORDER(排序键 order_code);" + + "如需 PARTNER / TIME_MONTH / OVERDUE_BUCKET 维度请使用 mode=SUMMARY"); + } + if (asOfDate != null) { + throw McpToolException.invalidParams("as_of_date 为按明细重算的单行总计口径,仅支持 mode=SUMMARY + group_by=NONE"); + } + if (orderCodeList.isEmpty() && partnerCodeList.isEmpty() && vendorCodeList.isEmpty()) { + throw McpToolException.invalidParams("mode=LIST 需要提供 order_code_list 或 partner_code_list / vendor_code_list 以收窄范围," + + "避免全表扫描;如需全局统计请使用 mode=SUMMARY"); + } + } else { + McpParams.reject(McpParams.has(params, "cursor"), "mode=SUMMARY 不支持 cursor(SUMMARY 不分页)"); + McpParams.reject(McpParams.has(params, "page_size"), "mode=SUMMARY 不支持 page_size,请改用 top_n"); + if (asOfDate != null && !"NONE".equals(groupBy)) { + throw McpToolException.invalidParams("as_of_date 为按明细重算的单行总计口径,仅支持 group_by=NONE"); + } + } + if ("OVERDUE_BUCKET".equals(groupBy)) { + if ("LIST".equals(mode)) { + throw McpToolException.invalidParams("group_by=OVERDUE_BUCKET 仅支持 mode=SUMMARY(分桶行数固定,无需翻页)"); + } + if (asOfDate != null) { + throw McpToolException.invalidParams("group_by=OVERDUE_BUCKET 不支持 as_of_date(账龄以当前日期与计划收款日期计算)"); + } + } + + Map query = new LinkedHashMap<>(); + query.put("mode", mode); + query.put("group_by", groupBy); + query.put("order_code_list", orderCodeList); + query.put("partner_code_list", partnerCodeList); + query.put("vendor_code_list", vendorCodeList); + query.put("time_range", McpParams.describe(timeRange)); + query.put("as_of_date", McpFormat.date(asOfDate)); + query.put("basis", basis); + query.put("top_n", "SUMMARY".equals(mode) ? topN : null); + query.put("include_total", includeTotal); + + if ("SUMMARY".equals(mode)) { + return handleSummary(groupBy, orderCodeList, partnerCodeList, vendorCodeList, timeRange, + asOfDate, basis, topN, includeTotal, query); + } + return handleList(params, orderCodeList, partnerCodeList, vendorCodeList, timeRange, basis, + includeTotal, query); + } + + // ------------------------------------------------------------------ SUMMARY + + private Object handleSummary(String groupBy, + List orderCodeList, + List partnerCodeList, + List vendorCodeList, + McpTimeRange timeRange, + Date asOfDate, + String basis, + int topN, + boolean includeTotal, + Map query) { + if (asOfDate != null) { + return handleRecalculated(asOfDate, basis, includeTotal, query); + } + if ("OVERDUE_BUCKET".equals(groupBy)) { + return handleOverdueBucket(orderCodeList, partnerCodeList, timeRange, basis, includeTotal, query); + } + + // group_by=ORDER/PARTNER 取 top_n;TIME_MONTH 取全部月份;NONE 单行 + int limit = "NONE".equals(groupBy) ? 1 + : ("TIME_MONTH".equals(groupBy) ? MAX_TOP_N : topN); + List> receivableRows = safe(omsReceivableBillMapper.selectReceivableBalanceSummary( + groupBy, orderCodeList, partnerCodeList, timeRange.getStart(), timeRange.getEnd(), 0, null, limit)); + List> payableRows = safe(omsPayableBillMapper.selectPayableBalanceSummary( + groupBy, orderCodeList, vendorCodeList, timeRange.getStart(), timeRange.getEnd(), 0, null, limit)); + + List> items = new ArrayList<>(); + if ("NONE".equals(groupBy)) { + Map item = metricsItem("groupKey", "TOTAL"); + if (!receivableRows.isEmpty()) { + applyReceivable(item, receivableRows.get(0)); + } + if (!payableRows.isEmpty()) { + applyPayable(item, payableRows.get(0)); + } + items.add(item); + } else if ("TIME_MONTH".equals(groupBy)) { + Map> merged = new LinkedHashMap<>(); + for (Map row : receivableRows) { + mergeRow(merged, "timeBucket", row, true); + } + for (Map row : payableRows) { + mergeRow(merged, "timeBucket", row, false); + } + items.addAll(merged.values()); + items.sort(Comparator.comparing(item -> String.valueOf(item.get("timeBucket")))); + } else if ("PARTNER".equals(groupBy)) { + // 应收侧主体为 partner_code(客户),应付侧主体为 vendor_code(制造商),主体不同不可合并 + for (Map row : receivableRows) { + Map item = metricsItem("partnerCode", str(row.get("groupKey"))); + item.put("subject", "RECEIVABLE"); + item.put("partnerName", str(row.get("groupName"))); + applyReceivable(item, row); + clearPayable(item); + items.add(item); + } + for (Map row : payableRows) { + Map item = metricsItem("vendorCode", str(row.get("groupKey"))); + item.put("subject", "PAYABLE"); + item.put("vendorName", str(row.get("groupName"))); + applyPayable(item, row); + clearReceivable(item); + items.add(item); + } + } else { + Map> merged = new LinkedHashMap<>(); + for (Map row : receivableRows) { + mergeRow(merged, "orderCode", row, true); + } + for (Map row : payableRows) { + mergeRow(merged, "orderCode", row, false); + } + items.addAll(merged.values()); + items.sort((left, right) -> combinedTotal(right).compareTo(combinedTotal(left))); + if (items.size() > topN) { + items = new ArrayList<>(items.subList(0, topN)); + } + } + + for (Map item : items) { + item.put("basis", basis); + } + + Long total = includeTotal ? (long) items.size() : null; + Map pageInfo = McpPages.pageInfo(items.size(), limit, false, null, + sortByFor("SUMMARY", groupBy), 1, total, false, false); + Map data = listData(items, pageInfo, total); + data.put("basis", basis); + data.put("as_of_date", null); + return response(buildMetadata(), query, data); + } + + private Object handleOverdueBucket(List orderCodeList, + List partnerCodeList, + McpTimeRange timeRange, + String basis, + boolean includeTotal, + Map query) { + List> rows = safe(omsReceivableBillMapper.selectReceivableBalanceSummary( + "OVERDUE_BUCKET", orderCodeList, partnerCodeList, timeRange.getStart(), timeRange.getEnd(), + 0, null, OVERDUE_BUCKET_LIMIT)); + + List> items = new ArrayList<>(); + for (Map row : rows) { + Map item = new LinkedHashMap<>(); + item.put("overdueBucket", str(row.get("groupKey"))); + item.put("inBucketQty", longVal(row.get("itemQty"))); + item.put("inBucketAmount", dec(row.get("unreceivedWithTax"))); + item.put("receivableWithTax", dec(row.get("receivableWithTax"))); + item.put("receivedWithTax", dec(row.get("receivedWithTax"))); + item.put("unreceivedWithTax", dec(row.get("unreceivedWithTax"))); + item.put("uninvoicedWithTax", dec(row.get("uninvoicedWithTax"))); + // 账龄分桶只针对应收未收款,应付侧度量不适用 + item.put("payableWithTax", null); + item.put("paidWithTax", null); + item.put("unpaidWithTax", null); + item.put("unticketedWithTax", null); + item.put("basis", basis); + items.add(item); + } + + Long total = includeTotal ? (long) items.size() : null; + Map pageInfo = McpPages.pageInfo(items.size(), OVERDUE_BUCKET_LIMIT, false, null, + sortByFor("SUMMARY", "OVERDUE_BUCKET"), 1, total, false, false); + Map data = listData(items, pageInfo, total); + data.put("basis", basis); + data.put("as_of_date", null); + return response(buildMetadata(), query, data); + } + + private Object handleRecalculated(Date asOfDate, + String basis, + boolean includeTotal, + Map query) { + Map receivableAsOf = omsReceivableBillMapper.selectReceivableAsOf(asOfDate); + Map payableAsOf = omsPayableBillMapper.selectPayableAsOf(asOfDate); + + BigDecimal receivableTotal = dec(receivableAsOf == null ? null : receivableAsOf.get("totalWithTax")); + BigDecimal receivableSettled = dec(receivableAsOf == null ? null : receivableAsOf.get("settledAmount")); + BigDecimal payableTotal = dec(payableAsOf == null ? null : payableAsOf.get("totalWithTax")); + BigDecimal payableSettled = dec(payableAsOf == null ? null : payableAsOf.get("settledAmount")); + + Map item = new LinkedHashMap<>(); + item.put("asOfDate", McpFormat.date(asOfDate)); + item.put("basis", basis); + item.put("receivableWithTax", receivableTotal); + item.put("receivedWithTax", receivableSettled); + item.put("unreceivedWithTax", receivableTotal.subtract(receivableSettled)); + // 重算口径只有"应收/已收"两个来源表,开票与收票无对应明细,故为 null + item.put("uninvoicedWithTax", null); + item.put("payableWithTax", payableTotal); + item.put("paidWithTax", payableSettled); + item.put("unpaidWithTax", payableTotal.subtract(payableSettled)); + item.put("unticketedWithTax", null); + + List> items = new ArrayList<>(); + items.add(item); + + Long total = includeTotal ? 1L : null; + Map pageInfo = McpPages.pageInfo(items.size(), 1, false, null, + sortByFor("SUMMARY", "NONE"), 1, total, false, false); + Map data = listData(items, pageInfo, total); + data.put("basis", basis); + data.put("as_of_date", McpFormat.date(asOfDate)); + return response(buildMetadata(), query, data); + } + + // ------------------------------------------------------------------ LIST + + private Object handleList(Map params, + List orderCodeList, + List partnerCodeList, + List vendorCodeList, + McpTimeRange timeRange, + String basis, + boolean includeTotal, + Map query) { + int pageSize = McpPages.pageSize(params, McpPages.DEFAULT_PAGE_SIZE, McpPages.MAX_AGG_PAGE_SIZE); + String filterHash = McpParams.filterHash(getToolName(), "LIST", "ORDER", sortedJoin(orderCodeList), + sortedJoin(partnerCodeList), sortedJoin(vendorCodeList), McpParams.describe(timeRange), basis); + McpCursor cursor = McpPages.cursor(params, getToolName(), filterHash, McpPages.DEFAULT_AGG_MAX_PAGES); + int pageNo = cursor == null ? 1 : cursor.getP(); + String lastGroupKey = cursor == null ? null : cursor.key(0); + query.put("page_size", pageSize); + query.put("page_no", pageNo); + + int fetch = pageSize + 1; + // 两侧各自取 keyset 的前 fetch 条:并集的最小 fetch 条必然落在两侧各自的最小 fetch 条之内 + List> receivableRows = safe(omsReceivableBillMapper.selectReceivableBalanceSummary( + "ORDER", orderCodeList, partnerCodeList, timeRange.getStart(), timeRange.getEnd(), 1, lastGroupKey, fetch)); + List> payableRows = safe(omsPayableBillMapper.selectPayableBalanceSummary( + "ORDER", orderCodeList, vendorCodeList, timeRange.getStart(), timeRange.getEnd(), 1, lastGroupKey, fetch)); + + Map> merged = new LinkedHashMap<>(); + for (Map row : receivableRows) { + mergeRow(merged, "orderCode", row, true); + } + for (Map row : payableRows) { + mergeRow(merged, "orderCode", row, false); + } + List> all = new ArrayList<>(merged.values()); + // 与库端 utf8mb4_unicode_ci(大小写不敏感)的 order_code 排序保持一致,避免游标错位 + all.sort(Comparator.comparing(item -> String.valueOf(item.get("orderCode")), String.CASE_INSENSITIVE_ORDER)); + for (Map item : all) { + item.put("basis", basis); + } + + boolean hasMore = all.size() > pageSize; + List> page = McpPages.trim(all, pageSize); + String nextCursor = null; + if (hasMore && !page.isEmpty()) { + nextCursor = McpCursor.encode(getToolName(), filterHash, pageNo + 1, + keysOf(page.get(page.size() - 1).get("orderCode"))); + } + + Long total = null; + boolean capped = false; + if (includeTotal) { + int countLimit = (int) Math.min(Integer.MAX_VALUE, COUNT_CAP + 1); + List> allReceivable = safe(omsReceivableBillMapper.selectReceivableBalanceSummary( + "ORDER", orderCodeList, partnerCodeList, timeRange.getStart(), timeRange.getEnd(), 1, null, countLimit)); + List> allPayable = safe(omsPayableBillMapper.selectPayableBalanceSummary( + "ORDER", orderCodeList, vendorCodeList, timeRange.getStart(), timeRange.getEnd(), 1, null, countLimit)); + LinkedHashSet keys = new LinkedHashSet<>(); + for (Map row : allReceivable) { + keys.add(str(row.get("groupKey"))); + } + for (Map row : allPayable) { + keys.add(str(row.get("groupKey"))); + } + capped = keys.size() > COUNT_CAP; + total = capped ? COUNT_CAP : (long) keys.size(); + } + + Map pageInfo = McpPages.pageInfo(page.size(), pageSize, hasMore, nextCursor, + "order_code", pageNo, total, capped, false); + Map data = listData(page, pageInfo, total); + data.put("basis", basis); + data.put("as_of_date", null); + return response(buildMetadata(), query, data); + } + + // ------------------------------------------------------------------ 行合并 + + private Map metricsItem(String keyName, Object keyValue) { + Map item = new LinkedHashMap<>(); + if (keyName != null) { + item.put(keyName, keyValue); + } + item.put("receivableWithTax", BigDecimal.ZERO); + item.put("receivedWithTax", BigDecimal.ZERO); + item.put("unreceivedWithTax", BigDecimal.ZERO); + item.put("uninvoicedWithTax", BigDecimal.ZERO); + item.put("payableWithTax", BigDecimal.ZERO); + item.put("paidWithTax", BigDecimal.ZERO); + item.put("unpaidWithTax", BigDecimal.ZERO); + item.put("unticketedWithTax", BigDecimal.ZERO); + item.put("receivableBillQty", 0L); + item.put("payableBillQty", 0L); + return item; + } + + private void mergeRow(Map> merged, String keyName, Map row, boolean receivable) { + String key = str(row.get("groupKey")); + if (key == null) { + return; + } + Map item = merged.get(key); + if (item == null) { + item = metricsItem(keyName, key); + merged.put(key, item); + } + if (receivable) { + item.put("receivableName", str(row.get("groupName"))); + applyReceivable(item, row); + } else { + item.put("payableName", str(row.get("groupName"))); + applyPayable(item, row); + } + } + + private void applyReceivable(Map item, Map row) { + item.put("receivableWithTax", dec(row.get("receivableWithTax"))); + item.put("receivedWithTax", dec(row.get("receivedWithTax"))); + item.put("unreceivedWithTax", dec(row.get("unreceivedWithTax"))); + item.put("uninvoicedWithTax", dec(row.get("uninvoicedWithTax"))); + item.put("receivableBillQty", longVal(row.get("itemQty"))); + } + + private void applyPayable(Map item, Map row) { + item.put("payableWithTax", dec(row.get("payableWithTax"))); + item.put("paidWithTax", dec(row.get("paidWithTax"))); + item.put("unpaidWithTax", dec(row.get("unpaidWithTax"))); + item.put("unticketedWithTax", dec(row.get("unticketedWithTax"))); + item.put("payableBillQty", longVal(row.get("itemQty"))); + } + + private void clearPayable(Map item) { + item.put("payableWithTax", null); + item.put("paidWithTax", null); + item.put("unpaidWithTax", null); + item.put("unticketedWithTax", null); + item.put("payableBillQty", null); + } + + private void clearReceivable(Map item) { + item.put("receivableWithTax", null); + item.put("receivedWithTax", null); + item.put("unreceivedWithTax", null); + item.put("uninvoicedWithTax", null); + item.put("receivableBillQty", null); + } + + private BigDecimal combinedTotal(Map item) { + return dec(item.get("receivableWithTax")).add(dec(item.get("payableWithTax"))); + } + + // ------------------------------------------------------------------ 辅助 + + private List> safe(List> rows) { + return rows == null ? Collections.emptyList() : rows; + } + + private String str(Object value) { + return value == null ? null : McpFormat.trimToNull(String.valueOf(value)); + } + + private BigDecimal dec(Object value) { + if (value == null) { + return BigDecimal.ZERO; + } + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof Number) { + return new BigDecimal(value.toString()); + } + try { + return new BigDecimal(String.valueOf(value).trim()); + } catch (NumberFormatException ex) { + return BigDecimal.ZERO; + } + } + + private Long longVal(Object value) { + if (value == null) { + return 0L; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.valueOf(String.valueOf(value).trim()); + } catch (NumberFormatException ex) { + return 0L; + } + } + + private String sortedJoin(List values) { + if (values == null || values.isEmpty()) { + return ""; + } + List sorted = new ArrayList<>(new TreeSet<>(values)); + return String.join(",", sorted); + } + + private String sortByFor(String mode, String groupBy) { + if ("LIST".equals(mode)) { + return "order_code"; + } + switch (groupBy) { + case "ORDER": + return "order_code(按两侧含税总额合计 desc)"; + case "PARTNER": + return "partner_code(应收)/ vendor_code(应付),各自按含税总额 desc"; + case "TIME_MONTH": + return "time_bucket asc(yyyy-MM)"; + case "OVERDUE_BUCKET": + return "overdueBucket(0-30 / 31-60 / 61-90 / 90+ / NO_PLAN)"; + default: + return "groupKey(TOTAL 单行)"; + } + } + + private Map buildMetadata() { + Map metadata = metadata("财务余额聚合:应收侧取 oms_receivable_bill、应付侧取 oms_payable_bill;" + + "SUMMARY 一次算完(可选 top_n),LIST 按 order_code 游标分页。"); + metadata.put("query_fields", mapOf( + "mode", "SUMMARY 汇总(默认)/ LIST 明细(按 order_code 分页)", + "group_by", "NONE/ORDER/PARTNER/TIME_MONTH/OVERDUE_BUCKET(OVERDUE_BUCKET 仅 SUMMARY)", + "order_code_list", "合同编号列表(≤200)", + "partner_code_list", "客户编码列表(应收侧)", + "vendor_code_list", "制造商编码列表(应付侧)", + "time_range", "时间区间 start~end;应收侧基于 oms_receivable_bill.create_time,应付侧基于 oms_payable_bill.create_time;默认近12个月、上限36个月", + "as_of_date", "历史时点日期(仅 SUMMARY + group_by=NONE),按明细重算", + "top_n", "SUMMARY 前 N 组(默认10,上限100;TIME_MONTH 忽略)", + "include_total", "是否统计总组数(默认false,上限50000)", + "page_size", "LIST 每页条数(默认20,上限200)", + "cursor", "LIST 游标:回传上次的 page_info.next_cursor")); + metadata.put("data_fields", mapOf( + "total", "组数(include_total=true 时为真实值)", + "items", "分组结果(NONE 为单行总计)", + "page_info", "分页信息:returned/page_size/has_more/next_cursor/sort_by/page_no/total_count", + "basis", "口径标记:CURRENT(冗余列当前值)或 RECALCULATED@<日期>(按明细重算)", + "as_of_date", "重算口径下的时点日期,否则为 null")); + metadata.put("item_fields", mapOf( + "groupKey/orderCode", "分组键(ORDER 维度为合同编号)", + "timeBucket", "月份桶 yyyy-MM(group_by=TIME_MONTH)", + "partnerCode/partnerName", "客户编码/名称(PARTNER 维度的应收侧,subject=RECEIVABLE)", + "vendorCode/vendorName", "制造商编码/名称(PARTNER 维度的应付侧,subject=PAYABLE)", + "subject", "PARTNER 维度行主体:RECEIVABLE(应收/客户)或 PAYABLE(应付/制造商)", + "overdueBucket", "账龄分桶:0-30 / 31-60 / 61-90 / 90+ / NO_PLAN(未设置收款计划)", + "inBucketQty", "该桶内单据数(仅 unreceived_amount > 0 的行)", + "inBucketAmount", "该桶内未收款金额合计", + "receivableWithTax", "应收含税总额(Σ oms_receivable_bill.total_price_with_tax)", + "receivedWithTax", "已收含税金额(Σ received_amount)", + "unreceivedWithTax", "未收含税金额(Σ unreceived_amount)", + "uninvoicedWithTax", "未开票金额(Σ uninvoiced_amount)", + "payableWithTax", "应付含税总额(Σ oms_payable_bill.total_price_with_tax)", + "paidWithTax", "已付含税金额(Σ paid_payment_amount)", + "unpaidWithTax", "未付含税金额(Σ unpaid_payment_amount)", + "unticketedWithTax", "未收票金额(Σ unreceived_ticket_amount)", + "receivableBillQty/payableBillQty", "参与聚合的应收/应付单数", + "asOfDate/basis", "时点日期 / 口径标记(CURRENT 或 RECALCULATED@<日期>)")); + metadata.put("aggregation_rule", mapOf( + "basis_current", "basis=CURRENT:所有金额直接 sum 账单主表冗余列(unreceived_amount / uninvoiced_amount / " + + "unpaid_payment_amount / unreceived_ticket_amount 等),★不重算,与页面展示一致", + "basis_recalculated", "basis=RECALCULATED@:应收(≤T)=Σ oms_receivable_bill.total_price_with_tax where create_time ≤ T;" + + "已收(≤T)=Σ oms_receivable_receipt_detail.receipt_amount where receipt_time ≤ T;时点未收=两者差;" + + "应付侧同理(oms_payable_bill.create_time 与 oms_payable_payment_detail.payment_time)", + "basis_mixing", "★两种口径不可混用:CURRENT 为『当前值』,RECALCULATED 为『历史时点按明细重算值』,数值天然不同;" + + "重算口径下 uninvoicedWithTax / unticketedWithTax 无对应明细来源,返回 null", + "overdue_bucket", "账龄分桶=datediff(今天, plan_receipt_date)(plan_receipt_date 取自 last_receipt_plan_id 关联的收款计划):" + + "0-30(≤30,含未到期) / 31-60 / 61-90 / 90+(>90),边界左闭右闭;仅统计 unreceived_amount > 0 的行;" + + "plan_receipt_date 为空的应收单无法计算账龄,单独归入 NO_PLAN", + "time_field", "时间维度字段:应收=oms_receivable_bill.create_time,应付=oms_payable_bill.create_time(记账口径);" + + "时间条件一律区间下推(>= start and < end),不使用 date_format 作为过滤条件", + "partner_dimension", "group_by=PARTNER 的维度主体在两侧不同:应收按 partner_code(客户)、应付按 vendor_code(制造商)," + + "故不合并为同一行,以 subject 区分", + "order_top_n", "group_by=ORDER 时两侧各自按含税总额降序取 top_n 后合并,再按两侧合计降序截断 top_n;" + + "需要完整订单清单请改用 mode=LIST", + "null_as_zero", "冗余列金额为 NULL 时按 0 参与求和", + "currency", "金额按原币返回,不自动换算")); + metadata.put("notes", "mode=LIST 必须给出 order_code_list 或 partner_code_list / vendor_code_list;" + + "mode=LIST 的 order_code 并集由应收、应付两侧各取 keyset 前 page_size+1 条后合并得到(不重不漏)。" + + "若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 继续调用直到 has_more 为 false,否则数据不完整。" + + "编码匹配不区分大小写(utf8mb4_unicode_ci)。"); + return metadata; + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceListToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceListToolProvider.java new file mode 100644 index 00000000..eddef294 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceListToolProvider.java @@ -0,0 +1,1468 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.sip.domain.OmsFinAttachment; +import com.ruoyi.sip.domain.OmsFinanceCharge; +import com.ruoyi.sip.domain.OmsInvoiceBill; +import com.ruoyi.sip.domain.OmsPayableBill; +import com.ruoyi.sip.domain.OmsPayablePaymentDetail; +import com.ruoyi.sip.domain.OmsPayablePaymentPlan; +import com.ruoyi.sip.domain.OmsPayableTicketPlan; +import com.ruoyi.sip.domain.OmsPaymentBill; +import com.ruoyi.sip.domain.OmsReceiptBill; +import com.ruoyi.sip.domain.OmsReceivableBill; +import com.ruoyi.sip.domain.OmsReceivableInvoicePlan; +import com.ruoyi.sip.domain.OmsReceivableReceiptDetail; +import com.ruoyi.sip.domain.OmsReceivableReceiptPlan; +import com.ruoyi.sip.domain.OmsTicketBill; +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpCursor; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpPages; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpTimeRange; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.OmsFinAttachmentMapper; +import com.ruoyi.sip.mapper.OmsFinanceChargeMapper; +import com.ruoyi.sip.mapper.OmsInvoiceBillMapper; +import com.ruoyi.sip.mapper.OmsPayableBillMapper; +import com.ruoyi.sip.mapper.OmsPayablePaymentDetailMapper; +import com.ruoyi.sip.mapper.OmsPayablePaymentPlanMapper; +import com.ruoyi.sip.mapper.OmsPayableTicketPlanMapper; +import com.ruoyi.sip.mapper.OmsPaymentBillMapper; +import com.ruoyi.sip.mapper.OmsReceiptBillMapper; +import com.ruoyi.sip.mapper.OmsReceivableBillMapper; +import com.ruoyi.sip.mapper.OmsReceivableInvoicePlanMapper; +import com.ruoyi.sip.mapper.OmsReceivableReceiptDetailMapper; +import com.ruoyi.sip.mapper.OmsReceivableReceiptPlanMapper; +import com.ruoyi.sip.mapper.OmsTicketBillMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * MCP 工具 finance_list(财务域列表 / 范围查询,游标分页,entity 参数化)。 + * + *

entity:RECEIVABLE 应收单 / PAYABLE 应付单 / RECEIPT 收款单 / PAYMENT 付款单 / INVOICE 开票单 / + * TICKET 收票单 / CHARGE 财务计收 / ATTACHMENT 附件(仅元数据,不返回 file_path)。

+ * + *

方案依据:docx/mcp-data-tools-plan.md 第 4.3 / 5 / 7 / 8 / 9 / 15 章与附录 A.8 / A.11(v13 附件与财务时点相关约定)。

+ *

权限来源(逐个 entity 从对应 Controller 的 {@code @RequiresPermissions} 抄取): + * RECEIVABLE→{@code OmsReceivableBillController#list}=finance:receivable:list; + * PAYABLE→{@code OmsPayableBillController#list}=finance:payable:list; + * RECEIPT→{@code OmsReceiptBillController#list} 无权限注解(取应收/收付款相关菜单权限,待确认); + * PAYMENT→{@code OmsPaymentBillController#list}=finance:payment:list; + * INVOICE→{@code OmsInvoiceBillController#list}=sip:invoiceBill:list; + * TICKET→{@code OmsTicketBillController#list}=sip:ticketBill:list; + * CHARGE→{@code OmsFinanceChargeController#list}=finance:charge:list; + * ATTACHMENT→{@code OmsFinAttachmentController} 无权限注解(取财务域菜单权限,待确认)。

+ */ +@Component +public class FinanceListToolProvider extends AbstractMcpQueryToolProvider { + + private static final Set ENTITIES = new LinkedHashSet<>(Arrays.asList( + "RECEIVABLE", "PAYABLE", "RECEIPT", "PAYMENT", "INVOICE", "TICKET", "CHARGE", "ATTACHMENT")); + + private static final long COUNT_CAP = 50000L; + private static final int DEFAULT_MONTHS = 12; + private static final int MAX_MONTHS = 36; + private static final Set ATTACHMENT_TYPES = new LinkedHashSet<>( + Arrays.asList("payment", "ticket", "receipt", "invoice")); + + @Autowired + private OmsReceivableBillMapper omsReceivableBillMapper; + + @Autowired + private OmsPayableBillMapper omsPayableBillMapper; + + @Autowired + private OmsReceiptBillMapper omsReceiptBillMapper; + + @Autowired + private OmsPaymentBillMapper omsPaymentBillMapper; + + @Autowired + private OmsInvoiceBillMapper omsInvoiceBillMapper; + + @Autowired + private OmsTicketBillMapper omsTicketBillMapper; + + @Autowired + private OmsFinanceChargeMapper omsFinanceChargeMapper; + + @Autowired + private OmsFinAttachmentMapper omsFinAttachmentMapper; + + @Autowired + private OmsReceivableReceiptPlanMapper omsReceivableReceiptPlanMapper; + + @Autowired + private OmsReceivableReceiptDetailMapper omsReceivableReceiptDetailMapper; + + @Autowired + private OmsReceivableInvoicePlanMapper omsReceivableInvoicePlanMapper; + + @Autowired + private OmsPayablePaymentPlanMapper omsPayablePaymentPlanMapper; + + @Autowired + private OmsPayablePaymentDetailMapper omsPayablePaymentDetailMapper; + + @Autowired + private OmsPayableTicketPlanMapper omsPayableTicketPlanMapper; + + @Override + protected String getToolName() { + return "finance_list"; + } + + @Override + protected String getToolDescription() { + return "财务域列表 / 范围查询(entity 参数化):应收单(RECEIVABLE)、应付单(PAYABLE)、收款单(RECEIPT)、付款单(PAYMENT)、" + + "开票单(INVOICE)、收票单(TICKET)、财务计收(CHARGE)、财务附件元数据(ATTACHMENT,不返回文件路径)。" + + "支持按单号列表 / 合同编号 / 客户或制造商编码 / 状态 / 审批状态 / 时间范围过滤,游标分页。" + + "分页查询时:若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 继续调用直到 has_more 为 false,否则数据不完整。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("entity", enumProperty("查询实体(必填):RECEIVABLE 应收单 / PAYABLE 应付单 / RECEIPT 收款单 / PAYMENT 付款单 / " + + "INVOICE 开票单 / TICKET 收票单 / CHARGE 财务计收 / ATTACHMENT 附件元数据", + "RECEIVABLE", "PAYABLE", "RECEIPT", "PAYMENT", "INVOICE", "TICKET", "CHARGE", "ATTACHMENT")); + properties.put("bill_code_list", arrayProperty("单号列表,≤" + McpPages.MAX_CODE_LIST + "(各 entity 的单号列见 metadata;" + + "entity=ATTACHMENT 时为关联单据ID(数字))")); + properties.put("order_code", stringProperty("合同编号(RECEIVABLE / PAYABLE / RECEIPT / PAYMENT / CHARGE 支持;INVOICE / TICKET / ATTACHMENT 无该列)")); + properties.put("partner_code_list", arrayProperty("客户编码列表(RECEIVABLE / RECEIPT / INVOICE 支持),≤" + McpPages.MAX_CODE_LIST)); + properties.put("vendor_code_list", arrayProperty("制造商编码列表(PAYABLE / PAYMENT / TICKET 支持),≤" + McpPages.MAX_CODE_LIST)); + properties.put("status", stringProperty("状态(RECEIPT=收款状态 / PAYMENT=付款状态 / INVOICE=开票状态 / TICKET=收票状态 / CHARGE=计收状态;" + + "RECEIVABLE / PAYABLE / ATTACHMENT 无状态列)")); + properties.put("approve_status", stringProperty("审批状态(RECEIPT / PAYMENT / INVOICE / TICKET 支持)")); + properties.put("time_range", stringProperty("时间区间,格式 \"开始~结束\"(yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss);" + + "各 entity 时间字段:RECEIVABLE/PAYABLE=create_time、RECEIPT=receipt_time、PAYMENT=payment_time、" + + "INVOICE=invoice_time、TICKET=ticket_time、CHARGE=biz_charge_date、ATTACHMENT=create_time(仅显式传入时生效);" + + "未传时默认近 " + DEFAULT_MONTHS + " 个月,跨度上限 " + MAX_MONTHS + " 个月")); + properties.put("time_range_start", stringProperty("时间区间开始(可选,与 time_range 二选一)")); + properties.put("time_range_end", stringProperty("时间区间结束(可选,与 time_range 二选一)")); + properties.put("related_bill_type", enumProperty("关联单据类型(仅 entity=ATTACHMENT):payment 付款单 / ticket 收票单 / " + + "receipt 应收-收款单 / invoice 应收-收票单(按实测列值,与列注释不同)", + "payment", "ticket", "receipt", "invoice")); + properties.put("include_detail", boolProperty("是否返回明细(默认 false);RECEIVABLE → 收款计划/收款明细/开票计划,PAYABLE → 付款计划/付款明细/收票计划;其余 entity 无明细")); + properties.put("include_total", boolProperty("是否统计总条数(默认 false);true 时受 count_cap=" + COUNT_CAP + " 限制")); + withPagingProperties(properties, McpPages.MAX_LIST_PAGE_SIZE, McpPages.DEFAULT_PAGE_SIZE); + return objectSchema(properties, "entity"); + } + + @Override + protected Object handle(Map params) { + String entity = McpParams.requireEnum(params, "entity", ENTITIES); + McpAuth.requireAnyPermission(permissionsOf(entity)); + + int pageSize = McpPages.pageSize(params, McpPages.DEFAULT_PAGE_SIZE, McpPages.MAX_LIST_PAGE_SIZE); + boolean includeDetail = McpParams.boolVal(params, "include_detail", false); + boolean includeTotal = McpParams.boolVal(params, "include_total", false); + + EntityResult result; + switch (entity) { + case "RECEIVABLE": + result = runReceivable(params, pageSize, includeDetail, includeTotal); + break; + case "PAYABLE": + result = runPayable(params, pageSize, includeDetail, includeTotal); + break; + case "RECEIPT": + result = runReceipt(params, pageSize, includeTotal); + break; + case "PAYMENT": + result = runPayment(params, pageSize, includeTotal); + break; + case "INVOICE": + result = runInvoice(params, pageSize, includeTotal); + break; + case "TICKET": + result = runTicket(params, pageSize, includeTotal); + break; + case "CHARGE": + result = runCharge(params, pageSize, includeTotal); + break; + default: + result = runAttachment(params, pageSize, includeTotal); + break; + } + + Map pageInfo = McpPages.pageInfo(result.items.size(), pageSize, result.hasMore, + result.hasMore ? McpCursor.encode(getToolName(), result.filterHash, result.pageNo + 1, result.lastSortKeys) : null, + result.sortBy, result.pageNo, result.total, result.totalCapped, false); + Map data = listData(result.items, pageInfo, result.total); + return response(buildMetadata(entity), queryEcho(entity, params, pageSize, includeDetail, includeTotal), data); + } + + // ------------------------------------------------------------------ entity 分支 + + private EntityResult runReceivable(Map params, int pageSize, boolean includeDetail, boolean includeTotal) { + List billCodes = McpParams.codeList(params, "bill_code_list", McpPages.MAX_CODE_LIST); + String orderCode = McpParams.str(params, "order_code"); + List partnerCodes = McpParams.codeList(params, "partner_code_list", McpPages.MAX_CODE_LIST); + List vendorCodes = McpParams.codeList(params, "vendor_code_list", McpPages.MAX_CODE_LIST); + rejectIfPresent(!vendorCodes.isEmpty(), "entity=RECEIVABLE 不支持 vendor_code_list(该表无 vendor_code 列),请改用 partner_code_list"); + rejectIfPresent(McpParams.has(params, "status"), "entity=RECEIVABLE 不支持 status(oms_receivable_bill 无状态列)"); + rejectIfPresent(McpParams.has(params, "approve_status"), "entity=RECEIVABLE 不支持 approve_status(oms_receivable_bill 无审批状态列)"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + + EntityResult result = new EntityResult("receivable_bill_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "RECEIVABLE", sortedJoin(billCodes), orderCode, + sortedJoin(partnerCodes), McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + List rows = omsReceivableBillMapper.listReceivableBillPage(billCodes, + orderCode == null ? Collections.emptyList() : Collections.singletonList(orderCode), partnerCodes, + range.getStart(), range.getEnd(), cursor == null ? null : cursor.key(0), + cursor == null ? null : cursor.longKey(1), pageSize + 1); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + + Map>> planMap = new LinkedHashMap<>(); + Map>> detailMap = new LinkedHashMap<>(); + Map>> invoicePlanMap = new LinkedHashMap<>(); + if (includeDetail) { + List ids = new ArrayList<>(); + for (OmsReceivableBill row : page) { + if (row.getId() != null) { + ids.add(row.getId()); + } + } + if (!ids.isEmpty()) { + List plans = omsReceivableReceiptPlanMapper.listByReceivableBillIdList(ids); + if (plans != null) { + for (OmsReceivableReceiptPlan plan : plans) { + if (plan.getReceivableBillId() != null) { + planMap.computeIfAbsent(plan.getReceivableBillId(), key -> new ArrayList<>()).add(toReceiptPlan(plan)); + } + } + } + OmsReceivableReceiptDetail detailQuery = new OmsReceivableReceiptDetail(); + detailQuery.setReceivableBillIdList(ids); + List details = omsReceivableReceiptDetailMapper.list(detailQuery); + if (details != null) { + for (OmsReceivableReceiptDetail detail : details) { + if (detail.getReceivableBillId() != null) { + detailMap.computeIfAbsent(detail.getReceivableBillId(), key -> new ArrayList<>()).add(toReceiptDetail(detail)); + } + } + } + List invoicePlans = omsReceivableInvoicePlanMapper.listByReceivableBillIdList(ids); + if (invoicePlans != null) { + for (OmsReceivableInvoicePlan plan : invoicePlans) { + if (plan.getReceivableBillId() != null) { + invoicePlanMap.computeIfAbsent(plan.getReceivableBillId(), key -> new ArrayList<>()).add(toInvoicePlan(plan)); + } + } + } + } + } + + for (OmsReceivableBill row : page) { + Map item = toReceivableItem(row); + if (includeDetail && row.getId() != null) { + item.put("receiptPlans", planMap.getOrDefault(row.getId(), Collections.emptyList())); + item.put("receiptDetails", detailMap.getOrDefault(row.getId(), Collections.emptyList())); + item.put("invoicePlans", invoicePlanMap.getOrDefault(row.getId(), Collections.emptyList())); + } + result.items.add(item); + } + if (!page.isEmpty()) { + OmsReceivableBill last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getReceivableBillCode(), last.getId()); + } + if (includeTotal) { + applyTotal(result, omsReceivableBillMapper.countReceivableBillPage(billCodes, + orderCode == null ? Collections.emptyList() : Collections.singletonList(orderCode), partnerCodes, + range.getStart(), range.getEnd())); + } + return result; + } + + private EntityResult runPayable(Map params, int pageSize, boolean includeDetail, boolean includeTotal) { + List billCodes = McpParams.codeList(params, "bill_code_list", McpPages.MAX_CODE_LIST); + String orderCode = McpParams.str(params, "order_code"); + List partnerCodes = McpParams.codeList(params, "partner_code_list", McpPages.MAX_CODE_LIST); + List vendorCodes = McpParams.codeList(params, "vendor_code_list", McpPages.MAX_CODE_LIST); + rejectIfPresent(!partnerCodes.isEmpty(), "entity=PAYABLE 不支持 partner_code_list(该表无 partner_code 列),请改用 vendor_code_list"); + rejectIfPresent(McpParams.has(params, "status"), "entity=PAYABLE 不支持 status(oms_payable_bill 无状态列)"); + rejectIfPresent(McpParams.has(params, "approve_status"), "entity=PAYABLE 不支持 approve_status(oms_payable_bill 无审批状态列)"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + + EntityResult result = new EntityResult("payable_bill_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "PAYABLE", sortedJoin(billCodes), orderCode, + sortedJoin(vendorCodes), McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + List orderCodes = orderCode == null ? Collections.emptyList() : Collections.singletonList(orderCode); + List rows = omsPayableBillMapper.listPayableBillPage(billCodes, orderCodes, vendorCodes, + range.getStart(), range.getEnd(), cursor == null ? null : cursor.key(0), + cursor == null ? null : cursor.longKey(1), pageSize + 1); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + + Map>> planMap = new LinkedHashMap<>(); + Map>> detailMap = new LinkedHashMap<>(); + Map>> ticketPlanMap = new LinkedHashMap<>(); + if (includeDetail) { + List ids = new ArrayList<>(); + for (OmsPayableBill row : page) { + if (row.getId() != null) { + ids.add(row.getId()); + } + } + if (!ids.isEmpty()) { + List plans = omsPayablePaymentPlanMapper.listDetailByPayableBillIdList(ids); + if (plans != null) { + for (OmsPayablePaymentPlan plan : plans) { + if (plan.getPayableBillId() != null) { + planMap.computeIfAbsent(plan.getPayableBillId(), key -> new ArrayList<>()).add(toPaymentPlan(plan)); + } + } + } + OmsPayablePaymentDetail detailQuery = new OmsPayablePaymentDetail(); + detailQuery.setPayableBillIdList(ids); + List details = omsPayablePaymentDetailMapper.list(detailQuery); + if (details != null) { + for (OmsPayablePaymentDetail detail : details) { + if (detail.getPayableBillId() != null) { + detailMap.computeIfAbsent(detail.getPayableBillId(), key -> new ArrayList<>()).add(toPaymentDetail(detail)); + } + } + } + List ticketPlans = omsPayableTicketPlanMapper.listByPayableBillIdList(ids); + if (ticketPlans != null) { + for (OmsPayableTicketPlan plan : ticketPlans) { + if (plan.getPayableBillId() != null) { + ticketPlanMap.computeIfAbsent(plan.getPayableBillId(), key -> new ArrayList<>()).add(toTicketPlan(plan)); + } + } + } + } + } + + for (OmsPayableBill row : page) { + Map item = toPayableItem(row); + if (includeDetail && row.getId() != null) { + item.put("paymentPlans", planMap.getOrDefault(row.getId(), Collections.emptyList())); + item.put("paymentDetails", detailMap.getOrDefault(row.getId(), Collections.emptyList())); + item.put("ticketPlans", ticketPlanMap.getOrDefault(row.getId(), Collections.emptyList())); + } + result.items.add(item); + } + if (!page.isEmpty()) { + OmsPayableBill last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getPayableBillCode(), last.getId()); + } + if (includeTotal) { + applyTotal(result, omsPayableBillMapper.countPayableBillPage(billCodes, orderCodes, vendorCodes, + range.getStart(), range.getEnd())); + } + return result; + } + + private EntityResult runReceipt(Map params, int pageSize, boolean includeTotal) { + List billCodes = McpParams.codeList(params, "bill_code_list", McpPages.MAX_CODE_LIST); + String orderCode = McpParams.str(params, "order_code"); + List partnerCodes = McpParams.codeList(params, "partner_code_list", McpPages.MAX_CODE_LIST); + List vendorCodes = McpParams.codeList(params, "vendor_code_list", McpPages.MAX_CODE_LIST); + rejectIfPresent(!vendorCodes.isEmpty(), "entity=RECEIPT 不支持 vendor_code_list(该表无 vendor_code 列)"); + String status = McpParams.str(params, "status"); + String approveStatus = McpParams.str(params, "approve_status"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + + EntityResult result = new EntityResult("receipt_bill_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "RECEIPT", sortedJoin(billCodes), orderCode, + sortedJoin(partnerCodes), status, approveStatus, McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + List rows = omsReceiptBillMapper.listReceiptBillPage(billCodes, orderCode, partnerCodes, + status, approveStatus, range.getStart(), range.getEnd(), + cursor == null ? null : cursor.key(0), cursor == null ? null : cursor.longKey(1), pageSize + 1); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (OmsReceiptBill row : page) { + result.items.add(toReceiptItem(row)); + } + if (!page.isEmpty()) { + OmsReceiptBill last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getReceiptBillCode(), last.getId()); + } + if (includeTotal) { + applyTotal(result, omsReceiptBillMapper.countReceiptBillPage(billCodes, orderCode, partnerCodes, + status, approveStatus, range.getStart(), range.getEnd())); + } + return result; + } + + private EntityResult runPayment(Map params, int pageSize, boolean includeTotal) { + List billCodes = McpParams.codeList(params, "bill_code_list", McpPages.MAX_CODE_LIST); + String orderCode = McpParams.str(params, "order_code"); + List partnerCodes = McpParams.codeList(params, "partner_code_list", McpPages.MAX_CODE_LIST); + List vendorCodes = McpParams.codeList(params, "vendor_code_list", McpPages.MAX_CODE_LIST); + rejectIfPresent(!partnerCodes.isEmpty(), "entity=PAYMENT 不支持 partner_code_list(该表无 partner_code 列),请改用 vendor_code_list"); + String status = McpParams.str(params, "status"); + String approveStatus = McpParams.str(params, "approve_status"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + + EntityResult result = new EntityResult("payment_bill_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "PAYMENT", sortedJoin(billCodes), orderCode, + sortedJoin(vendorCodes), status, approveStatus, McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + List rows = omsPaymentBillMapper.listPaymentBillPage(billCodes, orderCode, vendorCodes, + status, approveStatus, range.getStart(), range.getEnd(), + cursor == null ? null : cursor.key(0), cursor == null ? null : cursor.longKey(1), pageSize + 1); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (OmsPaymentBill row : page) { + result.items.add(toPaymentItem(row)); + } + if (!page.isEmpty()) { + OmsPaymentBill last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getPaymentBillCode(), last.getId()); + } + if (includeTotal) { + applyTotal(result, omsPaymentBillMapper.countPaymentBillPage(billCodes, orderCode, vendorCodes, + status, approveStatus, range.getStart(), range.getEnd())); + } + return result; + } + + private EntityResult runInvoice(Map params, int pageSize, boolean includeTotal) { + List billCodes = McpParams.codeList(params, "bill_code_list", McpPages.MAX_CODE_LIST); + String orderCode = McpParams.str(params, "order_code"); + List partnerCodes = McpParams.codeList(params, "partner_code_list", McpPages.MAX_CODE_LIST); + List vendorCodes = McpParams.codeList(params, "vendor_code_list", McpPages.MAX_CODE_LIST); + rejectIfPresent(orderCode != null, "entity=INVOICE 不支持 order_code(oms_invoice_bill 无 order_code 列),请改用 bill_code_list 或先查应收单"); + rejectIfPresent(!vendorCodes.isEmpty(), "entity=INVOICE 不支持 vendor_code_list(该表无 vendor_code 列)"); + String status = McpParams.str(params, "status"); + String approveStatus = McpParams.str(params, "approve_status"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + + EntityResult result = new EntityResult("invoice_bill_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "INVOICE", sortedJoin(billCodes), + sortedJoin(partnerCodes), status, approveStatus, McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + List rows = omsInvoiceBillMapper.listInvoiceBillPage(billCodes, partnerCodes, status, approveStatus, + range.getStart(), range.getEnd(), + cursor == null ? null : cursor.key(0), cursor == null ? null : cursor.longKey(1), pageSize + 1); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (OmsInvoiceBill row : page) { + result.items.add(toInvoiceItem(row)); + } + if (!page.isEmpty()) { + OmsInvoiceBill last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getInvoiceBillCode(), last.getId()); + } + if (includeTotal) { + applyTotal(result, omsInvoiceBillMapper.countInvoiceBillPage(billCodes, partnerCodes, status, approveStatus, + range.getStart(), range.getEnd())); + } + return result; + } + + private EntityResult runTicket(Map params, int pageSize, boolean includeTotal) { + List billCodes = McpParams.codeList(params, "bill_code_list", McpPages.MAX_CODE_LIST); + String orderCode = McpParams.str(params, "order_code"); + List partnerCodes = McpParams.codeList(params, "partner_code_list", McpPages.MAX_CODE_LIST); + List vendorCodes = McpParams.codeList(params, "vendor_code_list", McpPages.MAX_CODE_LIST); + rejectIfPresent(orderCode != null, "entity=TICKET 不支持 order_code(oms_ticket_bill 无 order_code 列),请改用 bill_code_list 或先查应付单"); + rejectIfPresent(!partnerCodes.isEmpty(), "entity=TICKET 不支持 partner_code_list(该表无 partner_code 列),请改用 vendor_code_list"); + String status = McpParams.str(params, "status"); + String approveStatus = McpParams.str(params, "approve_status"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + + EntityResult result = new EntityResult("ticket_bill_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "TICKET", sortedJoin(billCodes), + sortedJoin(vendorCodes), status, approveStatus, McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + List rows = omsTicketBillMapper.listTicketBillPage(billCodes, vendorCodes, status, approveStatus, + range.getStart(), range.getEnd(), + cursor == null ? null : cursor.key(0), cursor == null ? null : cursor.longKey(1), pageSize + 1); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (OmsTicketBill row : page) { + result.items.add(toTicketItem(row)); + } + if (!page.isEmpty()) { + OmsTicketBill last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getTicketBillCode(), last.getId()); + } + if (includeTotal) { + applyTotal(result, omsTicketBillMapper.countTicketBillPage(billCodes, vendorCodes, status, approveStatus, + range.getStart(), range.getEnd())); + } + return result; + } + + private EntityResult runCharge(Map params, int pageSize, boolean includeTotal) { + List billCodes = McpParams.codeList(params, "bill_code_list", McpPages.MAX_CODE_LIST); + String orderCode = McpParams.str(params, "order_code"); + List partnerCodes = McpParams.codeList(params, "partner_code_list", McpPages.MAX_CODE_LIST); + List vendorCodes = McpParams.codeList(params, "vendor_code_list", McpPages.MAX_CODE_LIST); + rejectIfPresent(!partnerCodes.isEmpty(), "entity=CHARGE 不支持 partner_code_list(oms_finance_charge 无 partner_code 列)"); + rejectIfPresent(!vendorCodes.isEmpty(), "entity=CHARGE 不支持 vendor_code_list(oms_finance_charge 无 vendor_code 列)"); + rejectIfPresent(McpParams.has(params, "approve_status"), "entity=CHARGE 不支持 approve_status(oms_finance_charge 无审批状态列)"); + String status = McpParams.str(params, "status"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + + // CHARGE 的"单号"即订单号:bill_code_list 与 order_code 合并为 order_code 列表 + LinkedHashSet merged = new LinkedHashSet<>(billCodes); + if (orderCode != null) { + merged.add(orderCode); + } + List orderCodes = new ArrayList<>(merged); + + EntityResult result = new EntityResult("order_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "CHARGE", sortedJoin(orderCodes), status, + McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + List rows = omsFinanceChargeMapper.listFinanceChargePage(orderCodes, status, + range.getStart(), range.getEnd(), + cursor == null ? null : cursor.key(0), cursor == null ? null : cursor.longKey(1), pageSize + 1); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (OmsFinanceCharge row : page) { + result.items.add(toChargeItem(row)); + } + if (!page.isEmpty()) { + OmsFinanceCharge last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getOrderCode(), last.getId()); + } + if (includeTotal) { + applyTotal(result, omsFinanceChargeMapper.countFinanceChargePage(orderCodes, status, + range.getStart(), range.getEnd())); + } + return result; + } + + private EntityResult runAttachment(Map params, int pageSize, boolean includeTotal) { + List rawIds = McpParams.codeList(params, "bill_code_list", McpPages.MAX_CODE_LIST); + List relatedBillIds = new ArrayList<>(); + for (String raw : rawIds) { + try { + relatedBillIds.add(Long.valueOf(raw)); + } catch (NumberFormatException ex) { + throw McpToolException.invalidParams("entity=ATTACHMENT 的 bill_code_list 必须为关联单据ID(数字),当前值:" + raw); + } + } + String relatedBillType = McpParams.str(params, "related_bill_type"); + if (relatedBillType != null && !ATTACHMENT_TYPES.contains(relatedBillType.toLowerCase())) { + throw McpToolException.invalidParams("related_bill_type 取值非法:" + relatedBillType + + ",允许值(实测列值):" + ATTACHMENT_TYPES); + } + if (relatedBillType != null) { + relatedBillType = relatedBillType.toLowerCase(); + } + rejectIfPresent(McpParams.has(params, "order_code"), "entity=ATTACHMENT 不支持 order_code(oms_fin_attachment 无该列),请改用 bill_code_list(关联单据ID)"); + rejectIfPresent(McpParams.has(params, "status"), "entity=ATTACHMENT 不支持 status"); + rejectIfPresent(McpParams.has(params, "approve_status"), "entity=ATTACHMENT 不支持 approve_status"); + rejectIfPresent(!McpParams.codeList(params, "partner_code_list", McpPages.MAX_CODE_LIST).isEmpty(), + "entity=ATTACHMENT 不支持 partner_code_list"); + rejectIfPresent(!McpParams.codeList(params, "vendor_code_list", McpPages.MAX_CODE_LIST).isEmpty(), + "entity=ATTACHMENT 不支持 vendor_code_list"); + McpParams.requireAny(params, "bill_code_list", "related_bill_type"); + + // 附件表仅 82 行;时间区间仅在显式传入时生效(不给默认区间,避免漏附件) + McpTimeRange range; + if (McpParams.has(params, "time_range") || McpParams.has(params, "time_range_start") + || McpParams.has(params, "time_range_end")) { + range = McpParams.timeRange(params, "time_range", 0, MAX_MONTHS); + } else { + range = McpTimeRange.of(null, null); + } + + EntityResult result = new EntityResult("related_bill_id,id"); + result.filterHash = McpParams.filterHash(getToolName(), "ATTACHMENT", sortedJoin(rawIds), + relatedBillType, McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + List rows = omsFinAttachmentMapper.listAttachmentPage(relatedBillIds, relatedBillType, + range.getStart(), range.getEnd(), + cursor == null ? null : cursor.longKey(0), cursor == null ? null : cursor.longKey(1), pageSize + 1); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (OmsFinAttachment row : page) { + result.items.add(toAttachmentItem(row)); + } + if (!page.isEmpty()) { + OmsFinAttachment last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getRelatedBillId(), last.getId()); + } + if (includeTotal) { + applyTotal(result, omsFinAttachmentMapper.countAttachmentPage(relatedBillIds, relatedBillType, + range.getStart(), range.getEnd())); + } + return result; + } + + // ------------------------------------------------------------------ 行映射 + + private Map toReceivableItem(OmsReceivableBill row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("receivableBillCode", McpFormat.trim(row.getReceivableBillCode())); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("inventoryCode", McpFormat.trim(row.getInventoryCode())); + item.put("partnerCode", McpFormat.trim(row.getPartnerCode())); + item.put("partnerName", row.getPartnerName()); + item.put("productType", row.getProductType()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("totalPriceWithTax", row.getTotalPriceWithTax()); + item.put("totalPriceWithoutTax", row.getTotalPriceWithoutTax()); + item.put("taxRate", row.getTaxRate()); + item.put("taxAmount", row.getTaxAmount()); + item.put("receivedAmount", row.getReceivedAmount()); + item.put("unreceivedAmount", row.getUnreceivedAmount()); + item.put("invoicedAmount", row.getInvoicedAmount()); + item.put("uninvoicedAmount", row.getUninvoicedAmount()); + item.put("planAmount", row.getPlanAmount()); + item.put("planReceiptDate", McpFormat.date(row.getPlanReceiptDate())); + item.put("planInvoiceAmount", row.getPlanInvoiceAmount()); + item.put("planInvoiceDate", McpFormat.date(row.getPlanInvoiceDate())); + item.put("projectCode", row.getProjectCode()); + item.put("projectName", row.getProjectName()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + item.put("remark", row.getRemark()); + return item; + } + + private Map toPayableItem(OmsPayableBill row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("payableBillCode", McpFormat.trim(row.getPayableBillCode())); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("inventoryCode", McpFormat.trim(row.getInventoryCode())); + item.put("vendorCode", McpFormat.trim(row.getVendorCode())); + item.put("vendorName", row.getVendorName()); + item.put("productType", row.getProductType()); + item.put("productLevel2Type", row.getProductLevel2Type()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("totalPriceWithTax", row.getTotalPriceWithTax()); + item.put("totalPriceWithoutTax", row.getTotalPriceWithoutTax()); + item.put("taxRate", row.getTaxRate()); + item.put("taxAmount", row.getTaxAmount()); + item.put("paidPaymentAmount", row.getPaidPaymentAmount()); + item.put("unpaidPaymentAmount", row.getUnpaidPaymentAmount()); + item.put("receivedTicketAmount", row.getReceivedTicketAmount()); + item.put("unreceivedTicketAmount", row.getUnreceivedTicketAmount()); + item.put("planPaymentDate", McpFormat.date(row.getPlanPaymentDate())); + item.put("planAmount", row.getPlanAmount()); + item.put("planTicketDate", McpFormat.date(row.getPlanTicketDate())); + item.put("planTicketAmount", row.getPlanTicketAmount()); + item.put("projectCode", row.getProjectCode()); + item.put("projectName", row.getProjectName()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + item.put("remark", row.getRemark()); + return item; + } + + private Map toReceiptItem(OmsReceiptBill row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("receiptBillCode", McpFormat.trim(row.getReceiptBillCode())); + item.put("receiptBillType", row.getReceiptBillType()); + item.put("receiptBillTypeName", receiptBillTypeName(row.getReceiptBillType())); + item.put("receiptTime", McpFormat.dateTime(row.getReceiptTime())); + item.put("actualReceiptTime", McpFormat.dateTime(row.getActualReceiptTime())); + item.put("partnerCode", McpFormat.trim(row.getPartnerCode())); + item.put("partnerName", row.getPartnerName()); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("remainingAmount", row.getRemainingAmount()); + item.put("totalPriceWithTax", row.getTotalPriceWithTax()); + item.put("totalPriceWithoutTax", row.getTotalPriceWithoutTax()); + item.put("taxAmount", row.getTaxAmount()); + item.put("receiptStatus", row.getReceiptStatus()); + item.put("receiptStatusName", receiptStatusName(row.getReceiptStatus())); + item.put("approveStatus", row.getApproveStatus()); + item.put("approveTime", McpFormat.dateTime(row.getApproveTime())); + item.put("receiptMethod", row.getReceiptMethod()); + item.put("receiptAccountName", row.getReceiptAccountName()); + item.put("receiptBankNumber", row.getReceiptBankNumber()); + item.put("receiptBankOpenAddress", row.getReceiptBankOpenAddress()); + item.put("bankNumber", row.getBankNumber()); + item.put("refundStatus", row.getRefundStatus()); + item.put("projectCode", row.getProjectCode()); + item.put("projectName", row.getProjectName()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + item.put("remark", row.getRemark()); + return item; + } + + private Map toPaymentItem(OmsPaymentBill row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("paymentBillCode", McpFormat.trim(row.getPaymentBillCode())); + item.put("paymentBillType", row.getPaymentBillType()); + item.put("paymentBillTypeName", paymentBillTypeName(row.getPaymentBillType())); + item.put("paymentTime", McpFormat.dateTime(row.getPaymentTime())); + item.put("actualPaymentTime", McpFormat.dateTime(row.getActualPaymentTime())); + item.put("vendorCode", McpFormat.trim(row.getVendorCode())); + item.put("vendorName", row.getVendorName()); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("projectCode", row.getProjectCode()); + item.put("projectName", row.getProjectName()); + item.put("totalPriceWithTax", row.getTotalPriceWithTax()); + item.put("totalPriceWithoutTax", row.getTotalPriceWithoutTax()); + item.put("taxAmount", row.getTaxAmount()); + item.put("paymentStatus", row.getPaymentStatus()); + item.put("paymentStatusName", paymentStatusName(row.getPaymentStatus())); + item.put("approveStatus", row.getApproveStatus()); + item.put("approveTime", McpFormat.dateTime(row.getApproveTime())); + item.put("paymentMethod", row.getPaymentMethod()); + item.put("payName", row.getPayName()); + item.put("payBankNumber", row.getPayBankNumber()); + item.put("payBankOpenAddress", row.getPayBankOpenAddress()); + item.put("bankNumber", row.getBankNumber()); + item.put("preResidueAmount", row.getPreResidueAmount()); + item.put("refundStatus", row.getRefundStatus()); + item.put("payType", row.getPayType()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + item.put("remark", row.getRemark()); + return item; + } + + private Map toInvoiceItem(OmsInvoiceBill row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("invoiceBillCode", McpFormat.trim(row.getInvoiceBillCode())); + item.put("invoiceType", row.getInvoiceType()); + item.put("invoiceBillType", row.getInvoiceBillType()); + item.put("invoiceBillTypeName", invoiceBillTypeName(row.getInvoiceBillType())); + item.put("invoiceTime", McpFormat.dateTime(row.getInvoiceTime())); + item.put("actualInvoiceTime", McpFormat.dateTime(row.getActualInvoiceTime())); + item.put("partnerCode", McpFormat.trim(row.getPartnerCode())); + item.put("partnerName", row.getPartnerName()); + item.put("totalPriceWithTax", row.getTotalPriceWithTax()); + item.put("totalPriceWithoutTax", row.getTotalPriceWithoutTax()); + item.put("taxRate", row.getTaxRate()); + item.put("taxAmount", row.getTaxAmount()); + item.put("invoiceStatus", row.getInvoiceStatus()); + item.put("invoiceStatusName", invoiceStatusName(row.getInvoiceStatus())); + item.put("approveStatus", row.getApproveStatus()); + item.put("approveTime", McpFormat.dateTime(row.getApproveTime())); + item.put("refundStatus", row.getRefundStatus()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + item.put("remark", row.getRemark()); + return item; + } + + private Map toTicketItem(OmsTicketBill row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("ticketBillCode", McpFormat.trim(row.getTicketBillCode())); + item.put("ticketType", row.getTicketType()); + item.put("ticketBillType", row.getTicketBillType()); + item.put("ticketBillTypeName", ticketBillTypeName(row.getTicketBillType())); + item.put("ticketTime", McpFormat.dateTime(row.getTicketTime())); + item.put("vendorTicketTime", McpFormat.dateTime(row.getVendorTicketTime())); + item.put("actualTicketTime", McpFormat.dateTime(row.getActualTicketTime())); + item.put("vendorCode", McpFormat.trim(row.getVendorCode())); + item.put("vendorName", row.getVendorName()); + item.put("totalPriceWithTax", row.getTotalPriceWithTax()); + item.put("totalPriceWithoutTax", row.getTotalPriceWithoutTax()); + item.put("taxRate", row.getTaxRate()); + item.put("taxAmount", row.getTaxAmount()); + item.put("ticketStatus", row.getTicketStatus()); + item.put("ticketStatusName", ticketStatusName(row.getTicketStatus())); + item.put("approveStatus", row.getApproveStatus()); + item.put("approveTime", McpFormat.dateTime(row.getApproveTime())); + item.put("refundStatus", row.getRefundStatus()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + item.put("remark", row.getRemark()); + return item; + } + + private Map toChargeItem(OmsFinanceCharge row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("chargeStatus", row.getChargeStatus()); + item.put("chargeStatusName", chargeStatusName(row.getChargeStatus())); + item.put("bizChargeDate", McpFormat.date(row.getBizChargeDate())); + item.put("financeChargeDate", McpFormat.date(row.getFinanceChargeDate())); + item.put("incomeWithTaxTotal", row.getIncomeWithTaxTotal()); + item.put("incomeWithoutTaxTotal", row.getIncomeWithoutTaxTotal()); + item.put("costSoftwareWithTax", row.getCostSoftwareWithTax()); + item.put("costSoftwareWithoutTax", row.getCostSoftwareWithoutTax()); + item.put("costHardwareWithTax", row.getCostHardwareWithTax()); + item.put("costHardwareWithoutTax", row.getCostHardwareWithoutTax()); + item.put("costSoftwareMaintWithTax", row.getCostSoftwareMaintWithTax()); + item.put("costSoftwareMaintWithoutTax", row.getCostSoftwareMaintWithoutTax()); + item.put("costHardwareMaintWithTax", row.getCostHardwareMaintWithTax()); + item.put("costHardwareMaintWithoutTax", row.getCostHardwareMaintWithoutTax()); + item.put("costProvinceServiceWithTax", row.getCostProvinceServiceWithTax()); + item.put("costProvinceServiceWithoutTax", row.getCostProvinceServiceWithoutTax()); + item.put("costOtherWithTax", row.getCostOtherWithTax()); + item.put("costOtherWithoutTax", row.getCostOtherWithoutTax()); + item.put("allCostWithoutTax", row.getAllCostWithoutTax()); + item.put("grossProfit", row.getGrossProfit()); + item.put("grossProfitRate", row.getGrossProfitRate()); + item.put("orderChannel", row.getOrderChannel()); + item.put("supplier", row.getSupplier()); + item.put("partnerCode", McpFormat.trim(row.getPartnerCode())); + item.put("partnerName", row.getPartnerName()); + item.put("projectCode", row.getProjectCode()); + item.put("projectName", row.getProjectName()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + item.put("remark", row.getRemark()); + return item; + } + + /** + * 附件仅返回元数据:★绝不返回 filePath / 文件内容。 + */ + private Map toAttachmentItem(OmsFinAttachment row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("fileName", row.getFileName()); + item.put("fileSize", row.getFileSize()); + item.put("fileType", row.getFileType()); + item.put("priceWithTax", row.getPriceWithTax()); + item.put("priceWithoutTax", row.getPriceWithoutTax()); + item.put("taxAmount", row.getTaxAmount()); + item.put("relatedBillId", row.getRelatedBillId()); + item.put("relatedBillType", row.getRelatedBillType()); + item.put("relatedBillTypeName", relatedBillTypeName(row.getRelatedBillType())); + item.put("createByName", row.getCreateByName()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("remark", row.getRemark()); + return item; + } + + private Map toReceiptPlan(OmsReceivableReceiptPlan row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("planReceiptDate", McpFormat.date(row.getPlanReceiptDate())); + item.put("planAmount", row.getPlanAmount()); + item.put("planRate", row.getPlanRate()); + item.put("remark", row.getRemark()); + return item; + } + + private Map toReceiptDetail(OmsReceivableReceiptDetail row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("receiptPlanId", row.getReceiptPlanId()); + item.put("receiptTime", McpFormat.dateTime(row.getReceiptTime())); + item.put("receiptAmount", row.getReceiptAmount()); + item.put("receiptAmountWithoutTax", row.getReceiptAmountWithoutTax()); + item.put("receiptAmountTax", row.getReceiptAmountTax()); + item.put("receiptRate", row.getReceiptRate()); + item.put("receiptBillCode", McpFormat.trim(row.getReceiptBillCode())); + item.put("receivableDetailType", row.getReceivableDetailType()); + item.put("receivableDetailTypeName", receivableDetailTypeName(row.getReceivableDetailType())); + item.put("receiptStatus", row.getReceiptStatus()); + item.put("actualReceiptTime", McpFormat.dateTime(row.getActualReceiptTime())); + item.put("writeOffId", row.getWriteOffId()); + item.put("remark", row.getRemark()); + return item; + } + + private Map toInvoicePlan(OmsReceivableInvoicePlan row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("planInvoiceDate", McpFormat.date(row.getPlanInvoiceDate())); + item.put("planAmount", row.getPlanAmount()); + item.put("planRate", row.getPlanRate()); + item.put("remark", row.getRemark()); + return item; + } + + private Map toPaymentPlan(OmsPayablePaymentPlan row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("planPaymentDate", McpFormat.date(row.getPlanPaymentDate())); + item.put("planAmount", row.getPlanAmount()); + item.put("planRate", row.getPlanRate()); + return item; + } + + private Map toPaymentDetail(OmsPayablePaymentDetail row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("paymentPlanId", row.getPaymentPlanId()); + item.put("paymentTime", McpFormat.dateTime(row.getPaymentTime())); + item.put("paymentAmount", row.getPaymentAmount()); + item.put("paymentAmountWithoutTax", row.getPaymentAmountWithoutTax()); + item.put("paymentAmountTax", row.getPaymentAmountTax()); + item.put("paymentRate", row.getPaymentRate()); + item.put("paymentBillCode", McpFormat.trim(row.getPaymentBillCode())); + item.put("payableDetailType", row.getPayableDetailType()); + item.put("payableDetailTypeName", payableDetailTypeName(row.getPayableDetailType())); + item.put("paymentStatus", row.getPaymentStatus()); + item.put("actualPaymentTime", McpFormat.dateTime(row.getActualPaymentTime())); + item.put("writeOffId", row.getWriteOffId()); + item.put("remark", row.getRemark()); + return item; + } + + private Map toTicketPlan(OmsPayableTicketPlan row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("planTicketDate", McpFormat.date(row.getPlanTicketDate())); + item.put("planAmount", row.getPlanAmount()); + item.put("planRate", row.getPlanRate()); + return item; + } + + // ------------------------------------------------------------------ 枚举翻译 + + private String receiptBillTypeName(String code) { + if (code == null) { + return null; + } + for (OmsReceiptBill.ReceiptBillTypeEnum value : OmsReceiptBill.ReceiptBillTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String receiptStatusName(String code) { + if (code == null) { + return null; + } + for (OmsReceiptBill.ReceiptStatusEnum value : OmsReceiptBill.ReceiptStatusEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String paymentBillTypeName(String code) { + if (code == null) { + return null; + } + for (OmsPaymentBill.PaymentBillTypeEnum value : OmsPaymentBill.PaymentBillTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String paymentStatusName(String code) { + if (code == null) { + return null; + } + for (OmsPaymentBill.PaymentStatusEnum value : OmsPaymentBill.PaymentStatusEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String invoiceBillTypeName(String code) { + if (code == null) { + return null; + } + for (OmsInvoiceBill.InvoiceBillTypeEnum value : OmsInvoiceBill.InvoiceBillTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String invoiceStatusName(String code) { + if (code == null) { + return null; + } + for (OmsInvoiceBill.InvoiceStatusEnum value : OmsInvoiceBill.InvoiceStatusEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String ticketBillTypeName(String code) { + if (code == null) { + return null; + } + for (OmsTicketBill.TicketBillTypeEnum value : OmsTicketBill.TicketBillTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String ticketStatusName(String code) { + if (code == null) { + return null; + } + for (OmsTicketBill.TicketStatusEnum value : OmsTicketBill.TicketStatusEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String chargeStatusName(String code) { + if (code == null) { + return null; + } + for (OmsFinanceCharge.ChargeStatusEnum value : OmsFinanceCharge.ChargeStatusEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + /** + * 附件单据类型翻译:按实测列值(payment/ticket/receipt/invoice)映射,与列注释不同。 + */ + private String relatedBillTypeName(String code) { + if (code == null) { + return null; + } + for (OmsFinAttachment.RelatedBillTypeEnum value : OmsFinAttachment.RelatedBillTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String receivableDetailTypeName(String code) { + if (code == null) { + return null; + } + for (OmsReceivableReceiptDetail.ReceivableDetailTypeEnum value + : OmsReceivableReceiptDetail.ReceivableDetailTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String payableDetailTypeName(String code) { + if (code == null) { + return null; + } + for (OmsPayablePaymentDetail.PayableDetailTypeEnum value + : OmsPayablePaymentDetail.PayableDetailTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + // ------------------------------------------------------------------ 辅助 + + private void rejectIfPresent(boolean present, String message) { + if (present) { + throw McpToolException.invalidParams(message); + } + } + + private void applyTotal(EntityResult result, long counted) { + result.totalCapped = counted > COUNT_CAP; + result.total = result.totalCapped ? COUNT_CAP : counted; + } + + private String sortedJoin(List values) { + if (values == null || values.isEmpty()) { + return ""; + } + List sorted = new ArrayList<>(new java.util.TreeSet<>(values)); + return String.join(",", sorted); + } + + private Map queryEcho(String entity, Map params, int pageSize, + boolean includeDetail, boolean includeTotal) { + Map query = new LinkedHashMap<>(); + query.put("entity", entity); + query.put("page_size", pageSize); + query.put("cursor", McpParams.str(params, "cursor")); + query.put("include_detail", includeDetail); + query.put("include_total", includeTotal); + return query; + } + + private String[] permissionsOf(String entity) { + switch (entity) { + case "RECEIVABLE": + return new String[]{"finance:receivable:list", "finance:receivable:view"}; + case "PAYABLE": + return new String[]{"finance:payable:list", "finance:payable:view"}; + case "RECEIPT": + return new String[]{"finance:receivable:list", "finance:receipt:generate", "finance:payment:list"}; + case "PAYMENT": + return new String[]{"finance:payment:list", "finance:payment:view"}; + case "INVOICE": + return new String[]{"sip:invoiceBill:list", "sip:invoiceBill:query"}; + case "TICKET": + return new String[]{"sip:ticketBill:list", "sip:ticketBill:query"}; + case "CHARGE": + return new String[]{"finance:charge:list", "finance:charge:query"}; + default: + return new String[]{"finance:receivable:list", "finance:payable:list", + "finance:payment:list", "finance:charge:list"}; + } + } + + private String permissionSource(String entity) { + switch (entity) { + case "RECEIVABLE": + return "OmsReceivableBillController#list:finance:receivable:list(已实测抄取)"; + case "PAYABLE": + return "OmsPayableBillController#list:finance:payable:list(已实测抄取)"; + case "RECEIPT": + return "OmsReceiptBillController#list 无 @RequiresPermissions(已实测);取应收/收付款相关菜单权限(待确认)"; + case "PAYMENT": + return "OmsPaymentBillController#list:finance:payment:list(已实测抄取)"; + case "INVOICE": + return "OmsInvoiceBillController#list:sip:invoiceBill:list(已实测抄取)"; + case "TICKET": + return "OmsTicketBillController#list:sip:ticketBill:list(已实测抄取)"; + case "CHARGE": + return "OmsFinanceChargeController#list:finance:charge:list(已实测抄取)"; + default: + return "OmsFinAttachmentController 无 @RequiresPermissions(已实测);取财务域菜单权限(待确认)"; + } + } + + private Map buildMetadata(String entity) { + Map metadata = metadata("财务域列表 / 范围查询(entity=" + entity + "):游标分页,排序键见 sort_by。"); + metadata.put("entity", entity); + metadata.put("entities", new ArrayList<>(ENTITIES)); + metadata.put("entity_fields", mapData( + "RECEIVABLE", "应收单 oms_receivable_bill(明细:收款计划 + 收款明细 + 开票计划)", + "PAYABLE", "应付单 oms_payable_bill(明细:付款计划 + 付款明细 + 收票计划)", + "RECEIPT", "收款单 oms_receipt_bill", + "PAYMENT", "付款单 oms_payment_bill", + "INVOICE", "开票单 oms_invoice_bill", + "TICKET", "收票单 oms_ticket_bill", + "CHARGE", "财务计收 oms_finance_charge(其『单号』即 order_code)", + "ATTACHMENT", "财务附件 oms_fin_attachment(仅元数据,强制 del_flag='0',不返回 file_path)" + )); + metadata.put("sort_by", sortByOf(entity)); + metadata.put("time_field", timeFieldOf(entity)); + metadata.put("data_fields", mapData( + "total", "include_total=true 时为真实条数(否则为当前页条数),受 count_cap=" + COUNT_CAP + " 限制", + "items", "列表数据", + "page_info", "分页信息;has_more=true 时必须回传 next_cursor" + )); + metadata.put("item_fields", itemFieldsOf(entity)); + metadata.put("dict_fields", dictFieldsOf(entity)); + metadata.put("guardrails", mapData( + "partner_vendor_time", "仅给 partner_code_list / vendor_code_list 而未给时间范围时,工具会强制使用默认时间区间(近 " + + DEFAULT_MONTHS + " 个月)——因 oms_receivable_bill.partner_code / oms_payable_bill.vendor_code 无索引(见方案 P2-11)," + + "避免无界扫描;如需更长历史请显式传 time_range 并联系 DBA 补索引", + "unsupported_params", "各 entity 不支持的过滤参数(如 RECEIVABLE 的 status、INVOICE 的 order_code)一律报 INVALID_PARAMS,不静默忽略", + "attachment_no_content", "entity=ATTACHMENT 只返回元数据(fileName/fileSize/fileType/金额/关联单据/创建人)," + + "★绝不返回 file_path 与文件内容;related_bill_type 按实测列值 payment/ticket/receipt/invoice 映射", + "recursive_detail_limit", "include_detail=true 只对当页账单做批量明细加载(一次 IN 查询,非逐行 N+1)" + )); + metadata.put("notes", "时间条件一律区间下推(>= start and < end),不使用 date_format 作为过滤条件;" + + "默认区间近 " + DEFAULT_MONTHS + " 个月,跨度上限 " + MAX_MONTHS + " 个月(ATTACHMENT 仅在显式传入时生效);" + + "编码类字段入参/出参统一 trim,编码匹配不区分大小写(utf8mb4_unicode_ci);" + + "若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 继续调用直到 has_more 为 false,否则数据不完整。"); + metadata.put("permission_source", permissionSource(entity)); + return metadata; + } + + private String sortByOf(String entity) { + switch (entity) { + case "RECEIVABLE": + return "receivable_bill_code,id"; + case "PAYABLE": + return "payable_bill_code,id"; + case "RECEIPT": + return "receipt_bill_code,id"; + case "PAYMENT": + return "payment_bill_code,id"; + case "INVOICE": + return "invoice_bill_code,id"; + case "TICKET": + return "ticket_bill_code,id"; + case "CHARGE": + return "order_code,id"; + default: + return "related_bill_id,id"; + } + } + + private String timeFieldOf(String entity) { + switch (entity) { + case "RECEIVABLE": + return "oms_receivable_bill.create_time(记账口径)"; + case "PAYABLE": + return "oms_payable_bill.create_time(记账口径)"; + case "RECEIPT": + return "oms_receipt_bill.receipt_time"; + case "PAYMENT": + return "oms_payment_bill.payment_time"; + case "INVOICE": + return "oms_invoice_bill.invoice_time"; + case "TICKET": + return "oms_ticket_bill.ticket_time"; + case "CHARGE": + return "oms_finance_charge.biz_charge_date"; + default: + return "oms_fin_attachment.create_time(仅显式传入时生效,默认不限时间)"; + } + } + + private Map dictFieldsOf(String entity) { + switch (entity) { + case "RECEIVABLE": + return mapData( + "receiptDetails[].receivableDetailTypeName", "枚举 OmsReceivableReceiptDetail.ReceivableDetailTypeEnum(1申请收款/2预收核销/3申请退款)"); + case "PAYABLE": + return mapData( + "paymentDetails[].payableDetailTypeName", "枚举 OmsPayablePaymentDetail.PayableDetailTypeEnum(1申请付款/2预付核销/3退款/4反核销/5核销)"); + case "RECEIPT": + return mapData( + "receiptStatusName", "枚举 OmsReceiptBill.ReceiptStatusEnum(-1已退款/1未付款/2已付款/3未退款)", + "receiptBillTypeName", "枚举 OmsReceiptBill.ReceiptBillTypeEnum(FROM_RECEIVABLE 应付单生成/PRE_RECEIPT 预付单/REFUND 退款单)"); + case "PAYMENT": + return mapData( + "paymentStatusName", "枚举 OmsPaymentBill.PaymentStatusEnum(-1已退款/1未付款/2已付款/3未退款)", + "paymentBillTypeName", "枚举 OmsPaymentBill.PaymentBillTypeEnum(FROM_PAYABLE 应付单生成/PRE_PAYMENT 预付单/REFUND 退款单)", + "payType", "制造商付款方式(来自 oms_vendor_info.pay_type)"); + case "INVOICE": + return mapData( + "invoiceStatusName", "枚举 OmsInvoiceBill.InvoiceStatusEnum(-1已红冲/1未开票/2已开票/3未红冲)", + "invoiceBillTypeName", "枚举 OmsInvoiceBill.InvoiceBillTypeEnum(FROM_RECEIVABLE 应收单生成/RED_RUSH 红冲单)"); + case "TICKET": + return mapData( + "ticketStatusName", "枚举 OmsTicketBill.TicketStatusEnum(-1已红冲/1未收票/2已收票/3未红冲)", + "ticketBillTypeName", "枚举 OmsTicketBill.TicketBillTypeEnum(FROM_PAYABLE 应付单生成/RED_RUSH 红冲单)"); + case "CHARGE": + return mapData( + "chargeStatusName", "枚举 OmsFinanceCharge.ChargeStatusEnum(0等待收款/1商务可申请计收/2财务已申请计收/3已完成计收)"); + default: + return mapData( + "relatedBillTypeName", "枚举 OmsFinAttachment.RelatedBillTypeEnum,★按实测列值映射:payment=付款单 / " + + "ticket=收票单 / receipt=应收-收款单 / invoice=应收-收票单(与列注释不同)", + "taxAmount", "派生字段(priceWithTax - priceWithoutTax),非库列"); + } + } + + private Map itemFieldsOf(String entity) { + switch (entity) { + case "RECEIVABLE": + return mapData( + "id", "应收单主键", + "receivableBillCode", "应收单编号", + "orderCode", "合同编号", + "inventoryCode", "出库/入库单号", + "partnerCode/partnerName", "客户编码/名称", + "productType", "产品类型", + "productCode", "产品编码", + "totalPriceWithTax", "含税总价", + "totalPriceWithoutTax", "未税总价", + "taxRate", "税率", + "taxAmount", "税额", + "receivedAmount", "已收款金额(冗余列)", + "unreceivedAmount", "未收款金额(冗余列)", + "invoicedAmount", "已开票金额(冗余列)", + "uninvoicedAmount", "未开票金额(冗余列)", + "planAmount/planReceiptDate", "最新收款计划金额/计划收款日期(来自 last_receipt_plan_id)", + "planInvoiceAmount/planInvoiceDate", "最新开票计划金额/计划开票日期(来自 last_invoice_plan_id)", + "projectCode/projectName", "项目编号/名称(经 order_code 关联 project_order_info → project_info)", + "createTime/updateTime", "创建时间/更新时间", + "remark", "备注", + "receiptPlans[]", "(include_detail=true)收款计划:planReceiptDate 计划收款日期 / planAmount 计划金额 / planRate 比例", + "receiptDetails[]", "(include_detail=true)收款明细:receiptTime 收款时间 / receiptAmount 收款金额 / " + + "receiptAmountWithoutTax 未税金额 / receiptAmountTax 税额 / receiptBillCode 收款单号 / " + + "receivableDetailType 类型 / receiptStatus 收款单状态 / actualReceiptTime 实际收款时间 / writeOffId 核销单ID", + "invoicePlans[]", "(include_detail=true)开票计划:planInvoiceDate 计划开票日期 / planAmount 计划金额 / planRate 比例"); + case "PAYABLE": + return mapData( + "id", "应付单主键", + "payableBillCode", "应付单编号", + "orderCode", "合同编号", + "inventoryCode", "入库/出库单号", + "vendorCode/vendorName", "制造商编码/名称", + "productType/productLevel2Type", "产品类型/二级产品类型(二级类型取自 product_info.level2_type)", + "productCode", "产品编码", + "totalPriceWithTax", "含税总价", + "totalPriceWithoutTax", "未税总价", + "taxRate/taxAmount", "税率/税额", + "paidPaymentAmount", "已付款金额(冗余列)", + "unpaidPaymentAmount", "未付款金额(冗余列)", + "receivedTicketAmount", "已收票金额(冗余列)", + "unreceivedTicketAmount", "未收票金额(冗余列)", + "planPaymentDate/planAmount", "最新付款计划日期/金额(来自 last_payment_plan_id)", + "planTicketDate/planTicketAmount", "最新收票计划日期/金额(来自 last_ticket_plan_id)", + "projectCode/projectName", "项目编号/名称", + "createTime/updateTime", "创建时间/更新时间", + "remark", "备注", + "paymentPlans[]", "(include_detail=true)付款计划:planPaymentDate 计划付款日期 / planAmount 计划金额 / planRate 比例", + "paymentDetails[]", "(include_detail=true)付款明细:paymentTime 付款时间 / paymentAmount 付款金额 / " + + "paymentAmountWithoutTax 未税金额 / paymentAmountTax 税额 / paymentBillCode 付款单号 / " + + "payableDetailType 类型 / paymentStatus 付款单状态 / actualPaymentTime 实际付款时间 / writeOffId 核销单ID", + "ticketPlans[]", "(include_detail=true)收票计划:planTicketDate 计划收票日期 / planAmount 计划金额 / planRate 比例"); + case "RECEIPT": + return mapData( + "id", "收款单主键", + "receiptBillCode", "收款单编号", + "receiptBillType/receiptBillTypeName", "收款单类型编码/名称(FROM_RECEIVABLE/PRE_RECEIPT/REFUND)", + "receiptTime", "预计收款时间", + "actualReceiptTime", "实际收款时间", + "partnerCode/partnerName", "进货商(客户)编码/名称", + "orderCode", "合同编号", + "remainingAmount", "剩余金额", + "totalPriceWithTax/totalPriceWithoutTax/taxAmount", "含税总价/未税总价/税额", + "receiptStatus/receiptStatusName", "收款状态编码/名称(-1已退款/1未付款/2已付款/3未退款)", + "approveStatus/approveTime", "审批状态/审批时间", + "receiptMethod", "收款方式", + "receiptAccountName/receiptBankNumber/receiptBankOpenAddress/bankNumber", "收款账户名称/银行卡号/开户行/银行行号", + "refundStatus", "退款状态", + "projectCode/projectName", "项目编号/名称", + "createTime/updateTime", "创建时间/更新时间", + "remark", "备注"); + case "PAYMENT": + return mapData( + "id", "付款单主键", + "paymentBillCode", "付款单编号", + "paymentBillType/paymentBillTypeName", "付款单类型编码/名称(FROM_PAYABLE/PRE_PAYMENT/REFUND)", + "paymentTime", "预计付款时间", + "actualPaymentTime", "实际付款时间", + "vendorCode/vendorName", "制造商编码/名称", + "orderCode", "合同编号", + "projectCode/projectName", "项目编号/名称", + "totalPriceWithTax/totalPriceWithoutTax/taxAmount", "含税总价/未税总价/税额", + "paymentStatus/paymentStatusName", "付款状态编码/名称(-1已退款/1未付款/2已付款/3未退款)", + "approveStatus/approveTime", "审批状态/审批时间", + "paymentMethod", "支付方式", + "payName/payBankNumber/payBankOpenAddress/bankNumber", "账户名称/银行账号/开户行/银行行号", + "preResidueAmount", "预付单剩余额度", + "refundStatus", "退款状态", + "payType", "制造商付款方式(oms_vendor_info.pay_type)", + "createTime/updateTime", "创建时间/更新时间", + "remark", "备注"); + case "INVOICE": + return mapData( + "id", "开票单主键", + "invoiceBillCode", "开票单编号", + "invoiceType", "票据类型", + "invoiceBillType/invoiceBillTypeName", "开票单类型编码/名称(FROM_RECEIVABLE/RED_RUSH)", + "invoiceTime", "预计开票时间", + "actualInvoiceTime", "实际开票时间", + "partnerCode/partnerName", "客户编码/名称", + "totalPriceWithTax/totalPriceWithoutTax", "含税总价/未税总价", + "taxRate", "税率", + "taxAmount", "税额(派生:含税总价 - 未税总价)", + "invoiceStatus/invoiceStatusName", "开票状态编码/名称(-1已红冲/1未开票/2已开票/3未红冲)", + "approveStatus/approveTime", "审批状态/审批时间", + "refundStatus", "退款状态", + "createTime/updateTime", "创建时间/更新时间", + "remark", "备注"); + case "TICKET": + return mapData( + "id", "收票单主键", + "ticketBillCode", "收票单编号", + "ticketType", "票据类型", + "ticketBillType/ticketBillTypeName", "收票单类型编码/名称(FROM_PAYABLE/RED_RUSH)", + "ticketTime", "预计收票时间", + "vendorTicketTime", "制造商开票时间", + "actualTicketTime", "实际收票时间", + "vendorCode/vendorName", "制造商编码/名称", + "totalPriceWithTax/totalPriceWithoutTax", "含税总价/未税总价", + "taxRate", "税率", + "taxAmount", "税额(派生:含税总价 - 未税总价)", + "ticketStatus/ticketStatusName", "收票状态编码/名称(-1已红冲/1未收票/2已收票/3未红冲)", + "approveStatus/approveTime", "审批状态/审批时间", + "refundStatus", "退款状态", + "createTime/updateTime", "创建时间/更新时间", + "remark", "备注"); + case "CHARGE": + return mapData( + "id", "计收主键", + "orderCode", "合同编号(订单号,本表唯一键)", + "chargeStatus/chargeStatusName", "计收状态编码/名称(0等待收款/1商务可申请计收/2财务已申请计收/3已完成计收)", + "bizChargeDate", "业务计收时间", + "financeChargeDate", "财务计收时间", + "incomeWithTaxTotal/incomeWithoutTaxTotal", "含税收入/未税收入", + "costSoftwareWithTax/WithoutTax", "软件成本含税/未税", + "costHardwareWithTax/WithoutTax", "硬件成本含税/未税", + "costSoftwareMaintWithTax/WithoutTax", "软件维保成本含税/未税", + "costHardwareMaintWithTax/WithoutTax", "硬件维保成本含税/未税", + "costProvinceServiceWithTax/WithoutTax", "省代服务成本含税/未税", + "costOtherWithTax/WithoutTax", "其它成本含税/未税", + "allCostWithoutTax", "成本合计(派生:未税成本各项之和)", + "grossProfit", "毛利(派生:未税收入 - 成本合计)", + "grossProfitRate", "毛利率(派生:毛利 / 成本合计 * 100,HALF_UP 4 位)", + "orderChannel", "下单通路", + "supplier", "供货商", + "partnerCode/partnerName", "进货商编码/名称", + "projectCode/projectName", "项目编号/名称", + "createTime/updateTime", "创建时间/更新时间", + "remark", "备注"); + default: + return mapData( + "id", "附件主键", + "fileName", "原始文件名", + "fileSize", "文件大小(字节)", + "fileType", "文件MIME类型", + "priceWithTax/priceWithoutTax", "附件金额含税/未税", + "taxAmount", "派生字段(含税 - 未税),非库列", + "relatedBillId", "关联单据ID", + "relatedBillType/relatedBillTypeName", "关联单据类型编码/名称(payment/ticket/receipt/invoice)", + "createByName", "创建人姓名(create_by 关联 sys_user.user_name)", + "createTime", "创建时间", + "remark", "备注", + "filePath", "★不返回(文件存储路径与内容一律不下发)"); + } + } + + /** entity 查询结果载体 */ + private static class EntityResult { + private final List> items = new ArrayList<>(); + private final String sortBy; + private String filterHash; + private int pageNo = 1; + private boolean hasMore; + private List lastSortKeys; + private Long total; + private boolean totalCapped; + + private EntityResult(String sortBy) { + this.sortBy = sortBy; + } + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceOrderPositionToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceOrderPositionToolProvider.java new file mode 100644 index 00000000..870f2da0 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/FinanceOrderPositionToolProvider.java @@ -0,0 +1,901 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.sip.domain.OmsFinanceCharge; +import com.ruoyi.sip.domain.OmsPayableBill; +import com.ruoyi.sip.domain.OmsPayablePaymentDetail; +import com.ruoyi.sip.domain.OmsPayablePaymentPlan; +import com.ruoyi.sip.domain.OmsPayableTicketPlan; +import com.ruoyi.sip.domain.OmsReceivableBill; +import com.ruoyi.sip.domain.OmsReceivableInvoicePlan; +import com.ruoyi.sip.domain.OmsReceivableReceiptDetail; +import com.ruoyi.sip.domain.OmsReceivableReceiptPlan; +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpSubPage; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.OmsFinanceChargeMapper; +import com.ruoyi.sip.mapper.OmsPayableBillMapper; +import com.ruoyi.sip.mapper.OmsPayablePaymentDetailMapper; +import com.ruoyi.sip.mapper.OmsPayablePaymentPlanMapper; +import com.ruoyi.sip.mapper.OmsPayableTicketPlanMapper; +import com.ruoyi.sip.mapper.OmsReceivableBillMapper; +import com.ruoyi.sip.mapper.OmsReceivableInvoicePlanMapper; +import com.ruoyi.sip.mapper.OmsReceivableReceiptDetailMapper; +import com.ruoyi.sip.mapper.OmsReceivableReceiptPlanMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * MCP 工具 finance_order_position(订单财务全景·标识符点查,不分页但有界)。 + * + *

数据链路:应收单 → 收款计划 → 收款明细(含核销 writeOffId)→ 开票计划;应付单 → 付款计划 → 付款明细 → 收票计划; + * 并以 order_code 取计收(oms_finance_charge)。

+ * + *

方案依据:docx/mcp-data-tools-plan.md 第 4.1 / 5 / 7 / 8 / 9 / 15 章与附录 A.3。

+ *

权限来源(从 Controller 的 {@code @RequiresPermissions} 抄取): + * {@code OmsReceivableBillController#list} = {@code finance:receivable:list}; + * {@code OmsPayableBillController#list} = {@code finance:payable:list};两者"任意一个满足"即放行。

+ */ +@Component +public class FinanceOrderPositionToolProvider extends AbstractMcpQueryToolProvider { + + /** 单次最多展开的订单数(防越权放大扫描) */ + private static final int MAX_ORDERS = 20; + /** 入口单据(应收/应付)查询条数上界 */ + private static final int ENTRY_LIMIT = 501; + /** 子列表(计划/明细/核销)单次返回上限 */ + private static final int SUB_LIST_LIMIT = McpSubPage.TRUNCATE_LIMIT; + /** 可局部翻页的子列表名 */ + private static final Set SUB_LISTS = new LinkedHashSet<>(java.util.Arrays.asList( + "receiptPlans", "receiptDetails", "invoicePlans", "paymentPlans", "paymentDetails", "ticketPlans")); + private static final String SUB_LIST_NAMES = + "receiptPlans,receiptDetails,invoicePlans,paymentPlans,paymentDetails,ticketPlans"; + + @Autowired + private OmsReceivableBillMapper omsReceivableBillMapper; + + @Autowired + private OmsPayableBillMapper omsPayableBillMapper; + + @Autowired + private OmsReceivableReceiptPlanMapper omsReceivableReceiptPlanMapper; + + @Autowired + private OmsReceivableReceiptDetailMapper omsReceivableReceiptDetailMapper; + + @Autowired + private OmsReceivableInvoicePlanMapper omsReceivableInvoicePlanMapper; + + @Autowired + private OmsPayablePaymentPlanMapper omsPayablePaymentPlanMapper; + + @Autowired + private OmsPayablePaymentDetailMapper omsPayablePaymentDetailMapper; + + @Autowired + private OmsPayableTicketPlanMapper omsPayableTicketPlanMapper; + + @Autowired + private OmsFinanceChargeMapper omsFinanceChargeMapper; + + @Override + protected String getToolName() { + return "finance_order_position"; + } + + @Override + protected String getToolDescription() { + return "财务订单全景点查(只读,不分页但有界):按合同编号 / 应收单号 / 应付单号(至少一个)返回该订单的" + + "应收链(应收单 + 收款计划 + 收款明细含核销 + 开票计划)、应付链(应付单 + 付款计划 + 付款明细 + 收票计划)" + + "与计收数据(计收状态、含税/未税收入、各项成本、毛利)。金额汇总取自账单主表冗余列,不重算;" + + "子列表超 " + SUB_LIST_LIMIT + " 条时截断并在 data.truncated_sub_lists 标注," + + "再用 sub_list + sub_cursor 对该子列表继续翻页(见 data.sub_page_info),直到 has_more=false,否则结果不完整。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("order_code", stringProperty("合同编号(订单号),应收/应付/计收的主关联键")); + properties.put("receivable_bill_code", stringProperty("应收单号(可按单号反查所属订单)")); + properties.put("payable_bill_code", stringProperty("应付单号(可按单号反查所属订单)")); + properties.put("include_detail", boolProperty("是否返回计划/明细/核销子列表,默认 true;false 时仅返回账单主表")); + properties.put("include_charge", boolProperty("是否返回计收数据(oms_finance_charge),默认 true")); + McpSubPage.withProperties(properties, SUB_LIST_NAMES); + return objectSchema(properties); + } + + @Override + protected Object handle(Map params) { + McpAuth.requireAnyPermission("finance:receivable:list", "finance:payable:list"); + + // 局部游标翻页:只返回指定子列表的一页,不需要主入口参数 + if (McpSubPage.requested(params)) { + return handleSubListPage(params); + } + + McpParams.requireAny(params, "order_code", "receivable_bill_code", "payable_bill_code"); + + String orderCode = McpParams.str(params, "order_code"); + String receivableBillCode = McpParams.str(params, "receivable_bill_code"); + String payableBillCode = McpParams.str(params, "payable_bill_code"); + boolean includeDetail = McpParams.boolVal(params, "include_detail", true); + boolean includeCharge = McpParams.boolVal(params, "include_charge", true); + + // 入口单据:按单号反查所属订单号 + List entryReceivableCodes = receivableBillCode == null + ? Collections.emptyList() : Collections.singletonList(receivableBillCode); + List entryPayableCodes = payableBillCode == null + ? Collections.emptyList() : Collections.singletonList(payableBillCode); + + LinkedHashSet orderCodes = new LinkedHashSet<>(); + if (orderCode != null) { + orderCodes.add(orderCode); + } + if (!entryReceivableCodes.isEmpty()) { + for (OmsReceivableBill bill : loadReceivableBills(entryReceivableCodes, null)) { + String code = McpFormat.trimToNull(bill.getOrderCode()); + if (code != null) { + orderCodes.add(code); + } + } + } + if (!entryPayableCodes.isEmpty()) { + for (OmsPayableBill bill : loadPayableBills(entryPayableCodes, null)) { + String code = McpFormat.trimToNull(bill.getOrderCode()); + if (code != null) { + orderCodes.add(code); + } + } + } + if (orderCodes.size() > MAX_ORDERS) { + throw McpToolException.invalidParams("命中订单数过多(" + orderCodes.size() + "),单次上限 " + MAX_ORDERS + + ",请改为按 order_code 精确点查"); + } + + // 无订单号可归组时(账单缺 order_code):退化为按单号直接点查 + List scopes = new ArrayList<>(); + if (!orderCodes.isEmpty()) { + for (String code : orderCodes) { + scopes.add(new Scope(code, null, null)); + } + } else { + scopes.add(new Scope(null, entryReceivableCodes, entryPayableCodes)); + } + + List> truncated = new ArrayList<>(); + List> items = new ArrayList<>(); + for (Scope scope : scopes) { + items.add(buildItem(scope, includeDetail, includeCharge, truncated)); + } + + Map query = new LinkedHashMap<>(); + query.put("order_code", orderCode); + query.put("receivable_bill_code", receivableBillCode); + query.put("payable_bill_code", payableBillCode); + query.put("include_detail", includeDetail); + query.put("include_charge", includeCharge); + query.put("resolved_order_codes", new ArrayList<>(orderCodes)); + + Map data = new LinkedHashMap<>(); + data.put("total", items.size()); + data.put("items", items); + data.put("truncated_sub_lists", truncated); + + return response(buildMetadata(), query, data); + } + + // ------------------------------------------------------------------ 单订单组装 + + private Map buildItem(Scope scope, boolean includeDetail, boolean includeCharge, + List> truncated) { + List receivables = loadReceivableBills(scope.receivableBillCodes, scope.orderCode); + List payables = loadPayableBills(scope.payableBillCodes, scope.orderCode); + + Map item = new LinkedHashMap<>(); + item.put("orderCode", scope.orderCode); + item.put("receivable", buildReceivableGroup(receivables, includeDetail, truncated)); + item.put("payable", buildPayableGroup(payables, includeDetail, truncated)); + item.put("charge", includeCharge && scope.orderCode != null ? loadCharge(scope.orderCode) : null); + return item; + } + + private Map buildReceivableGroup(List bills, + boolean includeDetail, + List> truncated) { + BigDecimal totalPriceWithTax = BigDecimal.ZERO; + BigDecimal receivedAmount = BigDecimal.ZERO; + BigDecimal unreceivedAmount = BigDecimal.ZERO; + BigDecimal invoicedAmount = BigDecimal.ZERO; + BigDecimal uninvoicedAmount = BigDecimal.ZERO; + + List billIds = new ArrayList<>(); + for (OmsReceivableBill bill : bills) { + totalPriceWithTax = add(totalPriceWithTax, bill.getTotalPriceWithTax()); + receivedAmount = add(receivedAmount, bill.getReceivedAmount()); + unreceivedAmount = add(unreceivedAmount, bill.getUnreceivedAmount()); + invoicedAmount = add(invoicedAmount, bill.getInvoicedAmount()); + uninvoicedAmount = add(uninvoicedAmount, bill.getUninvoicedAmount()); + if (bill.getId() != null) { + billIds.add(bill.getId()); + } + } + + Map>> planMap = new LinkedHashMap<>(); + Map>> detailMap = new LinkedHashMap<>(); + Map>> invoicePlanMap = new LinkedHashMap<>(); + if (includeDetail && !billIds.isEmpty()) { + List plans = omsReceivableReceiptPlanMapper.listByReceivableBillIdList(billIds); + if (plans != null) { + for (OmsReceivableReceiptPlan plan : plans) { + if (plan.getReceivableBillId() == null) { + continue; + } + planMap.computeIfAbsent(plan.getReceivableBillId(), key -> new ArrayList<>()).add(toReceiptPlan(plan)); + } + } + OmsReceivableReceiptDetail query = new OmsReceivableReceiptDetail(); + query.setReceivableBillIdList(billIds); + List details = omsReceivableReceiptDetailMapper.list(query); + if (details != null) { + for (OmsReceivableReceiptDetail detail : details) { + if (detail.getReceivableBillId() == null) { + continue; + } + detailMap.computeIfAbsent(detail.getReceivableBillId(), key -> new ArrayList<>()).add(toReceiptDetail(detail)); + } + } + List invoicePlans = omsReceivableInvoicePlanMapper.listByReceivableBillIdList(billIds); + if (invoicePlans != null) { + for (OmsReceivableInvoicePlan plan : invoicePlans) { + if (plan.getReceivableBillId() == null) { + continue; + } + invoicePlanMap.computeIfAbsent(plan.getReceivableBillId(), key -> new ArrayList<>()).add(toInvoicePlan(plan)); + } + } + } + + List> billItems = new ArrayList<>(); + for (OmsReceivableBill bill : bills) { + Map billItem = toReceivableBill(bill); + if (includeDetail) { + Long id = bill.getId(); + String parent = McpFormat.trim(bill.getReceivableBillCode()); + billItem.put("receiptPlans", truncate("receiptPlans", parent, + id == null ? Collections.emptyList() : planMap.getOrDefault(id, Collections.emptyList()), truncated)); + billItem.put("receiptDetails", truncate("receiptDetails", parent, + id == null ? Collections.emptyList() : detailMap.getOrDefault(id, Collections.emptyList()), truncated)); + billItem.put("invoicePlans", truncate("invoicePlans", parent, + id == null ? Collections.emptyList() : invoicePlanMap.getOrDefault(id, Collections.emptyList()), truncated)); + } + billItems.add(billItem); + } + + Map group = new LinkedHashMap<>(); + group.put("billCount", billItems.size()); + group.put("totalPriceWithTax", totalPriceWithTax); + group.put("receivedAmount", receivedAmount); + group.put("unreceivedAmount", unreceivedAmount); + group.put("invoicedAmount", invoicedAmount); + group.put("uninvoicedAmount", uninvoicedAmount); + group.put("bills", billItems); + return group; + } + + private Map buildPayableGroup(List bills, + boolean includeDetail, + List> truncated) { + BigDecimal totalPriceWithTax = BigDecimal.ZERO; + BigDecimal paidPaymentAmount = BigDecimal.ZERO; + BigDecimal unpaidPaymentAmount = BigDecimal.ZERO; + BigDecimal receivedTicketAmount = BigDecimal.ZERO; + BigDecimal unreceivedTicketAmount = BigDecimal.ZERO; + + List billIds = new ArrayList<>(); + for (OmsPayableBill bill : bills) { + totalPriceWithTax = add(totalPriceWithTax, bill.getTotalPriceWithTax()); + paidPaymentAmount = add(paidPaymentAmount, bill.getPaidPaymentAmount()); + unpaidPaymentAmount = add(unpaidPaymentAmount, bill.getUnpaidPaymentAmount()); + receivedTicketAmount = add(receivedTicketAmount, bill.getReceivedTicketAmount()); + unreceivedTicketAmount = add(unreceivedTicketAmount, bill.getUnreceivedTicketAmount()); + if (bill.getId() != null) { + billIds.add(bill.getId()); + } + } + + Map>> planMap = new LinkedHashMap<>(); + Map>> detailMap = new LinkedHashMap<>(); + Map>> ticketPlanMap = new LinkedHashMap<>(); + if (includeDetail && !billIds.isEmpty()) { + List plans = omsPayablePaymentPlanMapper.listDetailByPayableBillIdList(billIds); + if (plans != null) { + for (OmsPayablePaymentPlan plan : plans) { + if (plan.getPayableBillId() == null) { + continue; + } + planMap.computeIfAbsent(plan.getPayableBillId(), key -> new ArrayList<>()).add(toPaymentPlan(plan)); + } + } + OmsPayablePaymentDetail query = new OmsPayablePaymentDetail(); + query.setPayableBillIdList(billIds); + List details = omsPayablePaymentDetailMapper.list(query); + if (details != null) { + for (OmsPayablePaymentDetail detail : details) { + if (detail.getPayableBillId() == null) { + continue; + } + detailMap.computeIfAbsent(detail.getPayableBillId(), key -> new ArrayList<>()).add(toPaymentDetail(detail)); + } + } + List ticketPlans = omsPayableTicketPlanMapper.listByPayableBillIdList(billIds); + if (ticketPlans != null) { + for (OmsPayableTicketPlan plan : ticketPlans) { + if (plan.getPayableBillId() == null) { + continue; + } + ticketPlanMap.computeIfAbsent(plan.getPayableBillId(), key -> new ArrayList<>()).add(toTicketPlan(plan)); + } + } + } + + List> billItems = new ArrayList<>(); + for (OmsPayableBill bill : bills) { + Map billItem = toPayableBill(bill); + if (includeDetail) { + Long id = bill.getId(); + String parent = McpFormat.trim(bill.getPayableBillCode()); + billItem.put("paymentPlans", truncate("paymentPlans", parent, + id == null ? Collections.emptyList() : planMap.getOrDefault(id, Collections.emptyList()), truncated)); + billItem.put("paymentDetails", truncate("paymentDetails", parent, + id == null ? Collections.emptyList() : detailMap.getOrDefault(id, Collections.emptyList()), truncated)); + billItem.put("ticketPlans", truncate("ticketPlans", parent, + id == null ? Collections.emptyList() : ticketPlanMap.getOrDefault(id, Collections.emptyList()), truncated)); + } + billItems.add(billItem); + } + + Map group = new LinkedHashMap<>(); + group.put("billCount", billItems.size()); + group.put("totalPriceWithTax", totalPriceWithTax); + group.put("paidPaymentAmount", paidPaymentAmount); + group.put("unpaidPaymentAmount", unpaidPaymentAmount); + group.put("receivedTicketAmount", receivedTicketAmount); + group.put("unreceivedTicketAmount", unreceivedTicketAmount); + group.put("bills", billItems); + return group; + } + + private Map loadCharge(String orderCode) { + OmsFinanceCharge query = new OmsFinanceCharge(); + query.setOrderCode(orderCode); + List rows = omsFinanceChargeMapper.selectOmsFinanceChargeList(query); + if (rows == null || rows.isEmpty()) { + return null; + } + OmsFinanceCharge row = rows.get(0); + Map charge = new LinkedHashMap<>(); + charge.put("orderCode", McpFormat.trim(row.getOrderCode())); + charge.put("chargeStatus", row.getChargeStatus()); + charge.put("chargeStatusName", chargeStatusName(row.getChargeStatus())); + charge.put("bizChargeDate", McpFormat.date(row.getBizChargeDate())); + charge.put("financeChargeDate", McpFormat.date(row.getFinanceChargeDate())); + charge.put("incomeWithTaxTotal", row.getIncomeWithTaxTotal()); + charge.put("incomeWithoutTaxTotal", row.getIncomeWithoutTaxTotal()); + charge.put("allCostWithoutTax", row.getAllCostWithoutTax()); + charge.put("grossProfit", row.getGrossProfit()); + charge.put("grossProfitRate", row.getGrossProfitRate()); + charge.put("orderChannel", row.getOrderChannel()); + charge.put("supplier", row.getSupplier()); + charge.put("partnerCode", McpFormat.trim(row.getPartnerCode())); + charge.put("partnerName", row.getPartnerName()); + charge.put("projectCode", row.getProjectCode()); + charge.put("projectName", row.getProjectName()); + return charge; + } + + // ------------------------------------------------------------------ 行映射 + + private Map toReceivableBill(OmsReceivableBill row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("receivableBillCode", McpFormat.trim(row.getReceivableBillCode())); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("inventoryCode", McpFormat.trim(row.getInventoryCode())); + item.put("partnerCode", McpFormat.trim(row.getPartnerCode())); + item.put("partnerName", row.getPartnerName()); + item.put("productType", row.getProductType()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("totalPriceWithTax", row.getTotalPriceWithTax()); + item.put("totalPriceWithoutTax", row.getTotalPriceWithoutTax()); + item.put("taxRate", row.getTaxRate()); + item.put("taxAmount", row.getTaxAmount()); + item.put("receivedAmount", row.getReceivedAmount()); + item.put("unreceivedAmount", row.getUnreceivedAmount()); + item.put("invoicedAmount", row.getInvoicedAmount()); + item.put("uninvoicedAmount", row.getUninvoicedAmount()); + item.put("planAmount", row.getPlanAmount()); + item.put("planReceiptDate", McpFormat.date(row.getPlanReceiptDate())); + item.put("planInvoiceAmount", row.getPlanInvoiceAmount()); + item.put("planInvoiceDate", McpFormat.date(row.getPlanInvoiceDate())); + item.put("projectCode", row.getProjectCode()); + item.put("projectName", row.getProjectName()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + item.put("remark", row.getRemark()); + return item; + } + + private Map toPayableBill(OmsPayableBill row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("payableBillCode", McpFormat.trim(row.getPayableBillCode())); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("inventoryCode", McpFormat.trim(row.getInventoryCode())); + item.put("vendorCode", McpFormat.trim(row.getVendorCode())); + item.put("vendorName", row.getVendorName()); + item.put("productType", row.getProductType()); + item.put("productLevel2Type", row.getProductLevel2Type()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("totalPriceWithTax", row.getTotalPriceWithTax()); + item.put("totalPriceWithoutTax", row.getTotalPriceWithoutTax()); + item.put("taxRate", row.getTaxRate()); + item.put("taxAmount", row.getTaxAmount()); + item.put("paidPaymentAmount", row.getPaidPaymentAmount()); + item.put("unpaidPaymentAmount", row.getUnpaidPaymentAmount()); + item.put("receivedTicketAmount", row.getReceivedTicketAmount()); + item.put("unreceivedTicketAmount", row.getUnreceivedTicketAmount()); + item.put("planPaymentDate", McpFormat.date(row.getPlanPaymentDate())); + item.put("planAmount", row.getPlanAmount()); + item.put("planTicketDate", McpFormat.date(row.getPlanTicketDate())); + item.put("planTicketAmount", row.getPlanTicketAmount()); + item.put("projectCode", row.getProjectCode()); + item.put("projectName", row.getProjectName()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + item.put("remark", row.getRemark()); + return item; + } + + private Map toReceiptPlan(OmsReceivableReceiptPlan row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("planReceiptDate", McpFormat.date(row.getPlanReceiptDate())); + item.put("planAmount", row.getPlanAmount()); + item.put("planRate", row.getPlanRate()); + item.put("remark", row.getRemark()); + return item; + } + + private Map toInvoicePlan(OmsReceivableInvoicePlan row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("planInvoiceDate", McpFormat.date(row.getPlanInvoiceDate())); + item.put("planAmount", row.getPlanAmount()); + item.put("planRate", row.getPlanRate()); + item.put("remark", row.getRemark()); + return item; + } + + private Map toReceiptDetail(OmsReceivableReceiptDetail row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("receiptPlanId", row.getReceiptPlanId()); + item.put("receiptTime", McpFormat.dateTime(row.getReceiptTime())); + item.put("receiptAmount", row.getReceiptAmount()); + item.put("receiptAmountWithoutTax", row.getReceiptAmountWithoutTax()); + item.put("receiptAmountTax", row.getReceiptAmountTax()); + item.put("receiptRate", row.getReceiptRate()); + item.put("receiptBillCode", McpFormat.trim(row.getReceiptBillCode())); + item.put("receivableDetailType", row.getReceivableDetailType()); + item.put("receivableDetailTypeName", receivableDetailTypeName(row.getReceivableDetailType())); + item.put("receiptStatus", row.getReceiptStatus()); + item.put("actualReceiptTime", McpFormat.dateTime(row.getActualReceiptTime())); + item.put("writeOffId", row.getWriteOffId()); + item.put("remark", row.getRemark()); + return item; + } + + private Map toPaymentPlan(OmsPayablePaymentPlan row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("planPaymentDate", McpFormat.date(row.getPlanPaymentDate())); + item.put("planAmount", row.getPlanAmount()); + item.put("planRate", row.getPlanRate()); + return item; + } + + private Map toPaymentDetail(OmsPayablePaymentDetail row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("paymentPlanId", row.getPaymentPlanId()); + item.put("paymentTime", McpFormat.dateTime(row.getPaymentTime())); + item.put("paymentAmount", row.getPaymentAmount()); + item.put("paymentAmountWithoutTax", row.getPaymentAmountWithoutTax()); + item.put("paymentAmountTax", row.getPaymentAmountTax()); + item.put("paymentRate", row.getPaymentRate()); + item.put("paymentBillCode", McpFormat.trim(row.getPaymentBillCode())); + item.put("payableDetailType", row.getPayableDetailType()); + item.put("payableDetailTypeName", payableDetailTypeName(row.getPayableDetailType())); + item.put("paymentStatus", row.getPaymentStatus()); + item.put("actualPaymentTime", McpFormat.dateTime(row.getActualPaymentTime())); + item.put("writeOffId", row.getWriteOffId()); + item.put("remark", row.getRemark()); + return item; + } + + private Map toTicketPlan(OmsPayableTicketPlan row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("planTicketDate", McpFormat.date(row.getPlanTicketDate())); + item.put("planAmount", row.getPlanAmount()); + item.put("planRate", row.getPlanRate()); + return item; + } + + // ------------------------------------------------------------------ 局部游标翻页 + + /** + * 局部游标翻页:只加载并返回某一张账单下某个子列表的一页。 + */ + private Object handleSubListPage(Map params) { + String subList = McpParams.requireValue(params, "sub_list", SUB_LISTS); + McpSubPage.SubCursor state = McpSubPage.resolve(params, getToolName(), subList, SUB_LIST_NAMES, + subListSortKey(subList)); + String parent = state.getParent() != null + ? state.getParent() : McpParams.requireStr(params, "sub_parent"); + int pageSize = McpSubPage.pageSize(params); + + List> rows = McpSubPage.sortedMapsById(loadSubListRows(subList, parent)); + McpSubPage.SubPageResult> page = McpSubPage.page(state, parent, rows, pageSize); + + Map query = new LinkedHashMap<>(); + query.put("sub_list", subList); + query.put("sub_parent", parent); + query.put("page_size", pageSize); + + Map data = new LinkedHashMap<>(); + data.put("sub_list", subList); + data.put("sub_parent", parent); + data.put("total", rows.size()); + data.put("items", page.getItems()); + data.put("sub_page_info", page.getPageInfo()); + return response(buildSubListMetadata(subList), query, data); + } + + private List> loadSubListRows(String subList, String parent) { + List> rows = new ArrayList<>(); + if (isReceivableSubList(subList)) { + List billIds = receivableBillIds(parent); + if ("receiptPlans".equals(subList)) { + for (OmsReceivableReceiptPlan row : safeRows( + omsReceivableReceiptPlanMapper.listByReceivableBillIdList(billIds))) { + rows.add(toReceiptPlan(row)); + } + } else if ("receiptDetails".equals(subList)) { + OmsReceivableReceiptDetail query = new OmsReceivableReceiptDetail(); + query.setReceivableBillIdList(billIds); + for (OmsReceivableReceiptDetail row : safeRows(omsReceivableReceiptDetailMapper.list(query))) { + rows.add(toReceiptDetail(row)); + } + } else { + for (OmsReceivableInvoicePlan row : safeRows( + omsReceivableInvoicePlanMapper.listByReceivableBillIdList(billIds))) { + rows.add(toInvoicePlan(row)); + } + } + return rows; + } + + List billIds = payableBillIds(parent); + if ("paymentPlans".equals(subList)) { + for (OmsPayablePaymentPlan row : safeRows( + omsPayablePaymentPlanMapper.listDetailByPayableBillIdList(billIds))) { + rows.add(toPaymentPlan(row)); + } + } else if ("paymentDetails".equals(subList)) { + OmsPayablePaymentDetail query = new OmsPayablePaymentDetail(); + query.setPayableBillIdList(billIds); + for (OmsPayablePaymentDetail row : safeRows(omsPayablePaymentDetailMapper.list(query))) { + rows.add(toPaymentDetail(row)); + } + } else { + for (OmsPayableTicketPlan row : safeRows(omsPayableTicketPlanMapper.listByPayableBillIdList(billIds))) { + rows.add(toTicketPlan(row)); + } + } + return rows; + } + + private List receivableBillIds(String parent) { + List billIds = new ArrayList<>(); + for (OmsReceivableBill bill : loadReceivableBills(Collections.singletonList(parent), null)) { + if (bill.getId() != null) { + billIds.add(bill.getId()); + } + } + if (billIds.isEmpty()) { + throw McpToolException.invalidParams("未找到应收单:" + parent + + "(sub_parent 取值应取自 data.truncated_sub_lists[].parent 或应收单号)"); + } + return billIds; + } + + private List payableBillIds(String parent) { + List billIds = new ArrayList<>(); + for (OmsPayableBill bill : loadPayableBills(Collections.singletonList(parent), null)) { + if (bill.getId() != null) { + billIds.add(bill.getId()); + } + } + if (billIds.isEmpty()) { + throw McpToolException.invalidParams("未找到应付单:" + parent + + "(sub_parent 取值应取自 data.truncated_sub_lists[].parent 或应付单号)"); + } + return billIds; + } + + private boolean isReceivableSubList(String subList) { + return "receiptPlans".equals(subList) || "receiptDetails".equals(subList) || "invoicePlans".equals(subList); + } + + private String subListSortKey(String subList) { + if ("receiptPlans".equals(subList)) { + return "oms_receivable_receipt_plan.id"; + } + if ("receiptDetails".equals(subList)) { + return "oms_receivable_receipt_detail.id"; + } + if ("invoicePlans".equals(subList)) { + return "oms_receivable_invoice_plan.id"; + } + if ("paymentPlans".equals(subList)) { + return "oms_payable_payment_plan.id"; + } + if ("paymentDetails".equals(subList)) { + return "oms_payable_payment_detail.id"; + } + return "oms_payable_ticket_plan.id"; + } + + private List safeRows(List rows) { + return rows == null ? Collections.emptyList() : rows; + } + + private Map buildSubListMetadata(String subList) { + Map metadata = metadata("finance_order_position 的局部游标翻页:只返回某一张账单下 " + + subList + " 子列表的一页(按 id 升序,与主调用截断点严格衔接)"); + metadata.put("query_fields", mapData( + "sub_list", "要翻页的子列表名,取值 " + SUB_LIST_NAMES, + "sub_parent", "父实体标识(应收单号 receivableBillCode / 应付单号 payableBillCode),首次翻页必填", + "sub_cursor", "局部游标,原样回传上次的 data.sub_page_info.next_cursor;已携带 sub_parent,与 cursor 互斥", + "page_size", "每页条数,默认 " + McpSubPage.DEFAULT_PAGE_SIZE + ",上限 " + McpSubPage.MAX_PAGE_SIZE + )); + metadata.put("data_fields", mapData( + "sub_list", "本次翻页的子列表名", + "sub_parent", "父实体标识(账单号)", + "total", "★该子列表的总条数(不是订单数、也不是账单数)", + "items", "本页数据,字段语义与主调用 items[].receivable.bills[].[] / payable.bills[].[] 一致", + "sub_page_info", "分页信封,结构同 page_info:returned / page_size / has_more / next_cursor / sort_by / page_no / total_count" + )); + metadata.put("item_fields", subListItemFields(subList)); + metadata.put("notes", "主调用中每个子列表最多返回 " + SUB_LIST_LIMIT + " 条,超出时 data.truncated_sub_lists 会给出" + + " {list, parent, total, returned, next_cursor};用 sub_list + 该 next_cursor 作为 sub_cursor 继续翻页," + + "直到 sub_page_info.has_more=false,否则数据不完整。首次翻页(无 sub_cursor)需同时传 sub_parent;" + + "局部游标最多翻 " + McpSubPage.MAX_PAGES + " 页。本工具子列表按 id 升序返回。"); + return metadata; + } + + private Map subListItemFields(String subList) { + if ("receiptPlans".equals(subList) || "paymentPlans".equals(subList)) { + return mapData( + "id", "主键(排序键)", + "planReceiptDate/planPaymentDate", "计划收款/付款日期", + "planAmount", "计划金额", + "planRate", "比例", + "remark", "备注" + ); + } + if ("invoicePlans".equals(subList) || "ticketPlans".equals(subList)) { + return mapData( + "id", "主键(排序键)", + "planInvoiceDate/planTicketDate", "计划开票/收票日期", + "planAmount", "计划金额", + "planRate", "比例", + "remark", "备注" + ); + } + if ("receiptDetails".equals(subList)) { + return mapData( + "id", "主键(排序键)", + "receiptPlanId", "收款计划ID", + "receiptTime", "收款时间", + "receiptAmount", "收款金额", + "receiptAmountWithoutTax", "未税金额", + "receiptAmountTax", "税额", + "receiptBillCode", "收款单号", + "receivableDetailType", "明细类型(1申请收款/2预收核销/3申请退款)", + "receiptStatus", "收款单状态", + "actualReceiptTime", "实际收款时间", + "writeOffId", "核销单ID" + ); + } + return mapData( + "id", "主键(排序键)", + "paymentPlanId", "付款计划ID", + "paymentTime", "付款时间", + "paymentAmount", "付款金额", + "paymentAmountWithoutTax", "未税金额", + "paymentAmountTax", "税额", + "paymentBillCode", "付款单号", + "payableDetailType", "明细类型(1申请付款/2预付核销/3退款/4反核销/5核销)", + "paymentStatus", "付款单状态", + "actualPaymentTime", "实际付款时间", + "writeOffId", "核销单ID" + ); + } + + // ------------------------------------------------------------------ 辅助 + + private List loadReceivableBills(List billCodes, String orderCode) { + List orderCodes = orderCode == null ? Collections.emptyList() : Collections.singletonList(orderCode); + List rows = omsReceivableBillMapper.listReceivableBillPage(billCodes, orderCodes, null, + null, null, null, null, ENTRY_LIMIT); + return rows == null ? Collections.emptyList() : rows; + } + + private List loadPayableBills(List billCodes, String orderCode) { + List orderCodes = orderCode == null ? Collections.emptyList() : Collections.singletonList(orderCode); + List rows = omsPayableBillMapper.listPayableBillPage(billCodes, orderCodes, null, + null, null, null, null, ENTRY_LIMIT); + return rows == null ? Collections.emptyList() : rows; + } + + /** + * 子列表截断:超出上限时只返回前 SUB_LIST_LIMIT 条,并登记 {list, parent, total, returned, next_cursor}, + * 调用方可用 sub_list + next_cursor 继续翻页(避免数据缺失)。 + */ + private List> truncate(String subList, String parent, + List> rows, + List> truncated) { + // 先按主键定序,保证"截断点 == 续页起点",避免跨调用重复/漏数据 + List> sorted = McpSubPage.sortedMapsById(rows); + if (sorted.isEmpty()) { + return Collections.emptyList(); + } + if (sorted.size() > SUB_LIST_LIMIT) { + truncated.add(McpSubPage.truncatedItem(getToolName(), subList, parent, SUB_LIST_LIMIT, sorted.size())); + return new ArrayList<>(sorted.subList(0, SUB_LIST_LIMIT)); + } + return sorted; + } + + private BigDecimal add(BigDecimal base, BigDecimal value) { + return value == null ? base : base.add(value); + } + + private String chargeStatusName(String code) { + if (code == null) { + return null; + } + for (OmsFinanceCharge.ChargeStatusEnum value : OmsFinanceCharge.ChargeStatusEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String receivableDetailTypeName(String code) { + if (code == null) { + return null; + } + for (OmsReceivableReceiptDetail.ReceivableDetailTypeEnum value + : OmsReceivableReceiptDetail.ReceivableDetailTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private String payableDetailTypeName(String code) { + if (code == null) { + return null; + } + for (OmsPayablePaymentDetail.PayableDetailTypeEnum value + : OmsPayablePaymentDetail.PayableDetailTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value.getDesc(); + } + } + return code; + } + + private Map buildMetadata() { + Map metadata = metadata("财务订单全景点查:按 order_code / receivable_bill_code / payable_bill_code 关联" + + "应收链(应收单→收款计划→收款明细→开票计划)、应付链(应付单→付款计划→付款明细→收票计划)与计收数据。"); + metadata.put("query_fields", mapData( + "order_code", "合同编号(订单号)", + "receivable_bill_code", "应收单号(按单号反查所属订单)", + "payable_bill_code", "应付单号(按单号反查所属订单)", + "include_detail", "是否返回计划/明细/核销子列表(默认 true)", + "include_charge", "是否返回计收数据(默认 true)", + "sub_list", "可选:子列表名(" + SUB_LIST_NAMES + "),传该参数表示只翻该子列表的一页", + "sub_parent", "可选:父实体标识(应收单号 / 应付单号),首次翻转子列表时必填", + "sub_cursor", "可选:局部游标,原样回传 data.sub_page_info.next_cursor;与 cursor 互斥", + "page_size", "可选:子列表每页条数,默认 " + McpSubPage.DEFAULT_PAGE_SIZE + ",上限 " + McpSubPage.MAX_PAGE_SIZE + )); + metadata.put("data_fields", mapData( + "total", "返回的订单数", + "items", "每个订单一行:orderCode / receivable / payable / charge", + "truncated_sub_lists", "被子列表上限(" + SUB_LIST_LIMIT + ")截断的条目:{list, parent, total, returned, next_cursor};" + + "用 sub_list + next_cursor 作为 sub_cursor 继续翻页,直到 has_more=false,否则数据不完整" + )); + metadata.put("item_fields", mapData( + "orderCode", "合同编号(账单缺 order_code 时可能为 null,此时按单号直接返回)", + "receivable", "应收侧:billCount 应收单数、totalPriceWithTax 含税总额、receivedAmount 已收、unreceivedAmount 未收、" + + "invoicedAmount 已开票、uninvoicedAmount 未开票、bills[] 应收单列表", + "receivable.bills[].receivableBillCode", "应收单号", + "receivable.bills[].inventoryCode", "出库/入库单号", + "receivable.bills[].partnerCode/partnerName", "客户编码/名称", + "receivable.bills[].receiptPlans[]", "收款计划:planReceiptDate 计划收款日期 / planAmount 计划金额 / planRate 比例", + "receivable.bills[].receiptDetails[]", "收款明细:receiptTime 收款时间 / receiptAmount 收款金额 / receiptAmountWithoutTax 未税 / " + + "receiptAmountTax 税额 / receiptBillCode 收款单号 / receivableDetailType(1申请收款/2预收核销/3申请退款) / " + + "receiptStatus 收款单状态 / actualReceiptTime 实际收款时间 / writeOffId 核销单ID", + "receivable.bills[].invoicePlans[]", "开票计划:planInvoiceDate 计划开票日期 / planAmount 计划金额 / planRate 比例", + "payable", "应付侧:billCount 应付单数、totalPriceWithTax 含税总额、paidPaymentAmount 已付、unpaidPaymentAmount 未付、" + + "receivedTicketAmount 已收票、unreceivedTicketAmount 未收票、bills[] 应付单列表", + "payable.bills[].payableBillCode", "应付单号", + "payable.bills[].vendorCode/vendorName", "制造商编码/名称", + "payable.bills[].paymentPlans[]", "付款计划:planPaymentDate 计划付款日期 / planAmount 计划金额 / planRate 比例", + "payable.bills[].paymentDetails[]", "付款明细:paymentTime 付款时间 / paymentAmount 付款金额 / paymentAmountWithoutTax 未税 / " + + "paymentAmountTax 税额 / paymentBillCode 付款单号 / payableDetailType(1申请付款/2预付核销/3退款/4反核销/5核销) / " + + "paymentStatus 付款单状态 / actualPaymentTime 实际付款时间 / writeOffId 核销单ID", + "payable.bills[].ticketPlans[]", "收票计划:planTicketDate 计划收票日期 / planAmount 计划金额 / planRate 比例", + "charge", "计收数据(无计收记录时为 null):chargeStatus/chargeStatusName 计收状态、incomeWithTaxTotal 含税收入、" + + "incomeWithoutTaxTotal 未税收入、allCostWithoutTax 成本合计、grossProfit 毛利、grossProfitRate 毛利率" + )); + metadata.put("dict_fields", mapData( + "charge.chargeStatusName", "枚举 OmsFinanceCharge.ChargeStatusEnum(0等待收款/1商务可申请计收/2财务已申请计收/3已完成计收)", + "receivable.bills[].receiptDetails[].receivableDetailTypeName", "枚举 OmsReceivableReceiptDetail.ReceivableDetailTypeEnum(1申请收款/2预收核销/3申请退款)", + "payable.bills[].paymentDetails[].payableDetailTypeName", "枚举 OmsPayablePaymentDetail.PayableDetailTypeEnum(1申请付款/2预付核销/3退款/4反核销/5核销)" + )); + metadata.put("aggregation_rule", mapData( + "amount_basis", "★金额一致性:receivable/payable 的汇总(含税总额、已收/未收、已开票/未开票、已付/未付、已收票/未收票)" + + "一律直接取账单主表(oms_receivable_bill / oms_payable_bill)的冗余列求和,不重算;" + + "计划/明细子列表仅作展示与追溯,其金额合计不参与汇总,两者口径可能不一致(明细可能缺少未核销部分或未回填)", + "charge_derived", "charge 的 allCostWithoutTax / grossProfit / grossProfitRate 为 OmsFinanceCharge 类内计算属性(非库列)", + "sub_list_limit", "每个子列表最多返回 " + SUB_LIST_LIMIT + " 条,超出时截断并在 data.truncated_sub_lists 标注" + + " {list, parent, total, returned, next_cursor},可用 sub_list + sub_cursor 继续翻页(按 id 升序,不重不漏)", + "bounded", "本工具不分页但有界:入口单据最多 " + ENTRY_LIMIT + " 条,订单最多 " + MAX_ORDERS + " 个" + )); + metadata.put("notes", "入口为 receivable_bill_code / payable_bill_code 时会先反查所属 order_code,再返回该订单的完整链路;" + + "若账单本身没有 order_code,则退化为按单号直接返回(orderCode=null)。" + + "金额匹配不区分大小写(utf8mb4_unicode_ci),编码类字段入参/出参统一 trim;返回编码不区分大小写。"); + return metadata; + } + + /** 一次点查的作用域:优先按 order_code 归组,无订单号时按单号直接查。 */ + private static class Scope { + private final String orderCode; + private final List receivableBillCodes; + private final List payableBillCodes; + + private Scope(String orderCode, List receivableBillCodes, List payableBillCodes) { + this.orderCode = orderCode; + this.receivableBillCodes = receivableBillCodes == null ? Collections.emptyList() : receivableBillCodes; + this.payableBillCodes = payableBillCodes == null ? Collections.emptyList() : payableBillCodes; + } + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventoryFlowToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventoryFlowToolProvider.java new file mode 100644 index 00000000..b32f7548 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventoryFlowToolProvider.java @@ -0,0 +1,776 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.sip.domain.InventoryDelivery; +import com.ruoyi.sip.domain.InventoryInfo; +import com.ruoyi.sip.domain.InventoryOuter; +import com.ruoyi.sip.domain.InventoryOuterDetail; +import com.ruoyi.sip.domain.OmsInventoryDeliveryDetail; +import com.ruoyi.sip.domain.OmsInventoryInner; +import com.ruoyi.sip.domain.OmsStockInfo; +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpSubPage; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.InventoryDeliveryMapper; +import com.ruoyi.sip.mapper.InventoryInfoMapper; +import com.ruoyi.sip.mapper.InventoryOuterDetailMapper; +import com.ruoyi.sip.mapper.InventoryOuterMapper; +import com.ruoyi.sip.mapper.OmsInventoryDeliveryDetailMapper; +import com.ruoyi.sip.mapper.OmsInventoryInnerMapper; +import com.ruoyi.sip.mapper.OmsStockInfoMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * MCP 工具 inventory_flow(单据流转链点查,不分页,有界)。 + *

权限来源:InventoryOuterController({@code @RequiresPermissions("inventory:outer:list")})、 + * InventoryDeliveryController({@code inventory:delivery:list"})。

+ */ +@Component +public class InventoryFlowToolProvider extends AbstractMcpQueryToolProvider { + + /** 单个出库单下的子列表(snDetails / deliveries)单次返回上限;超出时给出 sub_cursor 续页 */ + private static final int MAX_SUB_LIST = McpSubPage.TRUNCATE_LIMIT; + /** order_code 入口可能命中多条出库单,超过该值时不再逐条做"发货反查"兜底,避免 N+1 */ + private static final int MAX_FALLBACK_OUTERS = 100; + /** 可局部翻页的子列表名 */ + private static final Set SUB_LISTS = new LinkedHashSet<>( + java.util.Arrays.asList("outerDetails", "snDetails", "deliveries")); + private static final String SUB_LIST_NAMES = "outerDetails,snDetails,deliveries"; + + @Autowired + private InventoryOuterMapper inventoryOuterMapper; + + @Autowired + private InventoryOuterDetailMapper inventoryOuterDetailMapper; + + @Autowired + private InventoryInfoMapper inventoryInfoMapper; + + @Autowired + private OmsInventoryInnerMapper omsInventoryInnerMapper; + + @Autowired + private InventoryDeliveryMapper inventoryDeliveryMapper; + + @Autowired + private OmsInventoryDeliveryDetailMapper omsInventoryDeliveryDetailMapper; + + @Autowired + private OmsStockInfoMapper omsStockInfoMapper; + + @Override + protected String getToolName() { + return "inventory_flow"; + } + + @Override + protected String getToolDescription() { + return "按出库单号或合同编号点查完整单据流转链:出库单 + 出库明细(仓库拆分)+ SN 明细 + 入库信息 + 发货单(含 SN 明细)+ 备货状态。" + + "本工具不分页;子列表单次最多返回 " + MAX_SUB_LIST + " 条,超出时在 data.truncated_sub_lists 标注并给出 next_cursor," + + "再用 sub_list + sub_cursor 对该子列表继续翻页(见 data.sub_page_info),直到 has_more=false,否则结果不完整。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("outer_code", stringProperty("出库单号(与 order_code 二选一)")); + properties.put("order_code", stringProperty("合同编号(与 outer_code 二选一;可能命中多条出库单)")); + properties.put("include_detail", boolProperty("是否返回明细(出库明细/SN 明细/入库/发货/备货),默认 true")); + McpSubPage.withProperties(properties, SUB_LIST_NAMES); + return objectSchema(properties); + } + + @Override + protected Object handle(Map params) { + McpAuth.requireAnyPermission("inventory:outer:list", "inventory:outer:view", + "inventory:delivery:list", "inventory:delivery:view"); + + // 局部游标翻页:只返回指定子列表的一页,不需要主入口参数 + if (McpSubPage.requested(params)) { + return handleSubListPage(params); + } + + McpParams.requireAny(params, "outer_code", "order_code"); + String outerCode = McpParams.str(params, "outer_code"); + String orderCode = McpParams.str(params, "order_code"); + boolean includeDetail = McpParams.boolVal(params, "include_detail", true); + + InventoryOuter outerQuery = new InventoryOuter(); + if (outerCode != null) { + outerQuery.setOuterCodeList(Collections.singletonList(outerCode)); + } else { + outerQuery.setOrderCodeList(Collections.singletonList(orderCode)); + } + List outers = safeList(inventoryOuterMapper.selectInventoryOuterList(outerQuery)); + + List outerCodes = new ArrayList<>(); + for (InventoryOuter outer : outers) { + String code = McpFormat.trimToNull(outer.getOuterCode()); + if (code != null && !outerCodes.contains(code)) { + outerCodes.add(code); + } + } + + Map> detailMap = includeDetail + ? groupDetailsByOuter(outerCodes) : Collections.emptyMap(); + Map> snDetailMap = includeDetail + ? loadSnDetails(outers, outerCodes) : Collections.emptyMap(); + Map innerMap = includeDetail + ? loadInnerMap(snDetailMap) : Collections.emptyMap(); + Map> deliveryMap = includeDetail + ? groupDeliveriesByOuter(outerCodes) : Collections.emptyMap(); + Map> deliverySnMap = includeDetail + ? loadDeliverySnMap(deliveryMap) : Collections.emptyMap(); + Map> stockMap = includeDetail + ? loadStockMap(outers, orderCode) : Collections.emptyMap(); + + List> items = new ArrayList<>(); + List> truncatedSubLists = new ArrayList<>(); + + for (InventoryOuter outer : outers) { + String code = McpFormat.trimToNull(outer.getOuterCode()); + List details = sortById(includeDetail + ? detailMap.getOrDefault(code, Collections.emptyList()) : Collections.emptyList(), + InventoryOuterDetail::getId); + List sns = sortById(includeDetail + ? snDetailMap.getOrDefault(code, Collections.emptyList()) : Collections.emptyList(), + InventoryInfo::getId); + List deliveries = sortById(includeDetail + ? deliveryMap.getOrDefault(code, Collections.emptyList()) : Collections.emptyList(), + InventoryDelivery::getId); + + List snPage = sns; + if (sns.size() > MAX_SUB_LIST) { + snPage = new ArrayList<>(sns.subList(0, MAX_SUB_LIST)); + truncatedSubLists.add(McpSubPage.truncatedItem(getToolName(), "snDetails", code, + MAX_SUB_LIST, sns.size())); + } + List deliveryPage = deliveries; + if (deliveries.size() > MAX_SUB_LIST) { + deliveryPage = new ArrayList<>(deliveries.subList(0, MAX_SUB_LIST)); + truncatedSubLists.add(McpSubPage.truncatedItem(getToolName(), "deliveries", code, + MAX_SUB_LIST, deliveries.size())); + } + + Map item = new LinkedHashMap<>(); + item.put("outerCode", code); + item.put("orderCode", McpFormat.trim(outer.getOrderCode())); + item.put("projectCode", outer.getProjectCode()); + item.put("projectName", outer.getProjectName()); + item.put("productCode", McpFormat.trim(outer.getProductCode())); + item.put("model", outer.getModel()); + item.put("quantity", outer.getQuantity()); + item.put("outerStatus", outer.getOuterStatus()); + item.put("outerStatusName", outerStatusName(outer.getOuterStatus())); + item.put("deliveryStatus", outer.getDeliveryStatus()); + item.put("deliveryStatusName", outerDeliveryStatusName(outer.getDeliveryStatus())); + item.put("deliveryTime", McpFormat.date(outer.getDeliveryTime())); + item.put("contactPerson", outer.getContactPerson()); + item.put("contactPhone", outer.getContactPhone()); + item.put("contactAddress", outer.getContactAddress()); + item.put("createByName", outer.getCreateByName()); + item.put("createTime", McpFormat.dateTime(outer.getCreateTime())); + item.put("warehouseId", firstWarehouseId(details)); + item.put("outerDetails", toDetailItems(details)); + item.put("snDetails", toSnItems(snPage)); + item.put("inners", toInnerItems(snPage, innerMap)); + item.put("deliveries", toDeliveryItems(deliveryPage, deliverySnMap)); + item.put("stock", toStockItem(outer.getOrderCode(), orderCode, stockMap)); + items.add(item); + } + + Map query = mapData( + "outer_code", outerCode, + "order_code", orderCode, + "include_detail", includeDetail + ); + + Map data = mapData( + "total", items.size(), + "items", items + ); + if (!truncatedSubLists.isEmpty()) { + data.put("truncated_sub_lists", truncatedSubLists); + } + return response(buildMetadata(), query, data); + } + + /** + * 局部游标翻页:只加载并返回某一个出库单下的某个子列表的一页。 + */ + private Object handleSubListPage(Map params) { + String subList = McpParams.requireValue(params, "sub_list", SUB_LISTS); + McpSubPage.SubCursor state = McpSubPage.resolve(params, getToolName(), subList, SUB_LIST_NAMES, + subListSortKey(subList)); + String requestedParent = state.getParent() != null + ? state.getParent() : McpParams.requireStr(params, "sub_parent"); + int pageSize = McpSubPage.pageSize(params); + + InventoryOuter outerQuery = new InventoryOuter(); + outerQuery.setOuterCodeList(Collections.singletonList(requestedParent)); + List outers = safeList(inventoryOuterMapper.selectInventoryOuterList(outerQuery)); + if (outers.isEmpty()) { + throw McpToolException.invalidParams("未找到出库单:" + requestedParent + + "(sub_parent 取值应取自 data.truncated_sub_lists[].parent 或该出库单的 outerCode)"); + } + // 库内 collation 大小写不敏感,统一以实际查出的编码作为父实体标识,避免后续分组取不到 + String parent = McpFormat.trimToNull(outers.get(0).getOuterCode()); + if (parent == null) { + throw McpToolException.serverError("出库单 " + requestedParent + " 的 outer_code 为空,无法翻转子列表"); + } + + List> rows; + if ("outerDetails".equals(subList)) { + List details = sortById( + groupDetailsByOuter(Collections.singletonList(parent)).getOrDefault(parent, Collections.emptyList()), + InventoryOuterDetail::getId); + rows = toDetailItems(details); + } else if ("snDetails".equals(subList)) { + List sns = sortById( + loadSnDetails(outers, Collections.singletonList(parent)).getOrDefault(parent, Collections.emptyList()), + InventoryInfo::getId); + rows = toSnItems(sns); + } else { + Map> deliveryMap = groupDeliveriesByOuter(Collections.singletonList(parent)); + List deliveries = sortById( + deliveryMap.getOrDefault(parent, Collections.emptyList()), InventoryDelivery::getId); + rows = toDeliveryItems(deliveries, loadDeliverySnMap(deliveryMap)); + } + + McpSubPage.SubPageResult> page = McpSubPage.page(state, parent, rows, pageSize); + + Map query = mapData( + "sub_list", subList, + "sub_parent", parent, + "page_size", pageSize + ); + Map data = mapData( + "sub_list", subList, + "sub_parent", parent, + "total", rows.size(), + "items", page.getItems(), + "sub_page_info", page.getPageInfo() + ); + return response(buildSubListMetadata(subList), query, data); + } + + private String subListSortKey(String subList) { + if ("outerDetails".equals(subList)) { + return "oms_inventory_outer_detail.outer_code, id"; + } + if ("snDetails".equals(subList)) { + return "oms_inventory_info.id"; + } + return "oms_inventory_delivery.id"; + } + + private Map> groupDetailsByOuter(List outerCodes) { + Map> result = new LinkedHashMap<>(); + if (outerCodes.isEmpty()) { + return result; + } + List rows = inventoryOuterDetailMapper.listByOuterCodeList(outerCodes); + if (rows == null) { + return result; + } + for (InventoryOuterDetail row : rows) { + String code = McpFormat.trimToNull(row.getOuterCode()); + if (code != null) { + result.computeIfAbsent(code, k -> new ArrayList<>()).add(row); + } + } + return result; + } + + private Map> loadSnDetails(List outers, List outerCodes) { + Map> result = new LinkedHashMap<>(); + if (outerCodes.isEmpty()) { + return result; + } + List rows = inventoryInfoMapper.selectInventoryInfoByOuterCodeList(outerCodes); + if (rows != null) { + for (InventoryInfo row : rows) { + String code = McpFormat.trimToNull(row.getOuterCode()); + if (code != null) { + result.computeIfAbsent(code, k -> new ArrayList<>()).add(row); + } + } + } + // 撤回后 inventory_info.outer_code 会被清空 → 按发货记录(SN 明细)反查兜底 + if (outers.size() <= MAX_FALLBACK_OUTERS) { + for (InventoryOuter outer : outers) { + String code = McpFormat.trimToNull(outer.getOuterCode()); + if (code == null || result.containsKey(code)) { + continue; + } + List fallback = inventoryInfoMapper.listByOuterCodeViaDelivery(code); + if (fallback != null && !fallback.isEmpty()) { + result.put(code, new ArrayList<>(fallback)); + } + } + } + return result; + } + + private Map loadInnerMap(Map> snDetailMap) { + Map result = new LinkedHashMap<>(); + Set innerCodes = new LinkedHashSet<>(); + for (List rows : snDetailMap.values()) { + for (InventoryInfo row : rows) { + String innerCode = McpFormat.trimToNull(row.getInnerCode()); + if (innerCode != null) { + innerCodes.add(innerCode); + } + } + } + if (innerCodes.isEmpty()) { + return result; + } + List inners = omsInventoryInnerMapper + .selectOmsInventoryInnerByInnerCodeList(new ArrayList<>(innerCodes)); + if (inners != null) { + for (OmsInventoryInner inner : inners) { + String code = McpFormat.trimToNull(inner.getInnerCode()); + if (code != null) { + result.putIfAbsent(code, inner); + } + } + } + return result; + } + + private Map> groupDeliveriesByOuter(List outerCodes) { + Map> result = new LinkedHashMap<>(); + if (outerCodes.isEmpty()) { + return result; + } + List rows = inventoryDeliveryMapper.listByOuterCodeList(outerCodes); + if (rows == null) { + return result; + } + for (InventoryDelivery row : rows) { + String code = McpFormat.trimToNull(row.getOuterCode()); + if (code != null) { + result.computeIfAbsent(code, k -> new ArrayList<>()).add(row); + } + } + return result; + } + + private Map> loadDeliverySnMap(Map> deliveryMap) { + Map> result = new LinkedHashMap<>(); + List deliveryIds = new ArrayList<>(); + for (List rows : deliveryMap.values()) { + for (InventoryDelivery row : rows) { + if (row.getId() != null && !deliveryIds.contains(row.getId())) { + deliveryIds.add(row.getId()); + } + } + } + if (deliveryIds.isEmpty()) { + return result; + } + List details = omsInventoryDeliveryDetailMapper.listByDeliveryIdList(deliveryIds); + if (details != null) { + for (OmsInventoryDeliveryDetail detail : details) { + if (detail.getDeliveryId() == null) { + continue; + } + result.computeIfAbsent(detail.getDeliveryId(), k -> new ArrayList<>()) + .add(McpFormat.trim(detail.getProductSn())); + } + } + return result; + } + + private Map> loadStockMap(List outers, String orderCode) { + Map> result = new LinkedHashMap<>(); + Set orderCodes = new LinkedHashSet<>(); + if (orderCode != null) { + orderCodes.add(orderCode); + } + for (InventoryOuter outer : outers) { + String code = McpFormat.trimToNull(outer.getOrderCode()); + if (code != null) { + orderCodes.add(code); + } + } + for (String code : orderCodes) { + OmsStockInfo condition = new OmsStockInfo(); + condition.setOrderCode(code); + List rows = omsStockInfoMapper.queryAll(condition); + if (rows != null && !rows.isEmpty()) { + result.put(code, rows); + } + } + return result; + } + + private Object firstWarehouseId(List details) { + for (InventoryOuterDetail detail : details) { + if (detail.getWarehouseId() != null) { + return detail.getWarehouseId(); + } + } + return null; + } + + private List> toDetailItems(List details) { + List> items = new ArrayList<>(); + for (InventoryOuterDetail detail : details) { + Map item = new LinkedHashMap<>(); + item.put("outerCode", McpFormat.trim(detail.getOuterCode())); + item.put("warehouseId", detail.getWarehouseId()); + item.put("warehouseName", detail.getWarehouseName()); + item.put("quantity", detail.getQuantity()); + item.put("outerStatus", detail.getOuterStatus()); + item.put("productCode", McpFormat.trim(detail.getProductCode())); + item.put("model", detail.getModel()); + items.add(item); + } + return items; + } + + private List> toSnItems(List rows) { + List> items = new ArrayList<>(); + for (InventoryInfo row : rows) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("productSn", McpFormat.trim(row.getProductSn())); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("model", row.getModel()); + item.put("inventoryStatus", row.getInventoryStatus()); + item.put("inventoryStatusName", inventoryStatusName(row.getInventoryStatus())); + item.put("innerCode", McpFormat.trim(row.getInnerCode())); + item.put("outerCode", McpFormat.trim(row.getOuterCode())); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("warehouseId", row.getWarehouseId()); + item.put("warehouseName", row.getWarehouseName()); + item.put("innerPrice", row.getInnerPrice()); + item.put("outerPrice", row.getOuterPrice()); + item.put("taxRate", row.getTaxRate()); + items.add(item); + } + return items; + } + + private List> toInnerItems(List snRows, Map innerMap) { + List> items = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (InventoryInfo row : snRows) { + String innerCode = McpFormat.trimToNull(row.getInnerCode()); + if (innerCode == null || !seen.add(innerCode)) { + continue; + } + OmsInventoryInner inner = innerMap.get(innerCode); + Map item = new LinkedHashMap<>(); + item.put("innerCode", innerCode); + item.put("purchaseNo", inner == null ? null : McpFormat.trim(inner.getPurchaseNo())); + item.put("vendorName", inner == null ? null : inner.getVendorName()); + item.put("productCode", inner == null ? null : McpFormat.trim(inner.getProductCode())); + item.put("quantity", inner == null ? null : inner.getQuantity()); + item.put("createTime", inner == null ? null : McpFormat.dateTime(inner.getCreateTime())); + items.add(item); + } + return items; + } + + private List> toDeliveryItems(List rows, Map> deliverySnMap) { + List> items = new ArrayList<>(); + for (InventoryDelivery row : rows) { + Map item = new LinkedHashMap<>(); + item.put("deliveryId", row.getId()); + item.put("outerCode", McpFormat.trim(row.getOuterCode())); + item.put("warehouseId", row.getWarehouseId()); + item.put("warehouseName", row.getWarehouseName()); + item.put("logisticsCompany", row.getLogisticsCompany()); + item.put("logisticsCode", row.getLogisticsCode()); + item.put("deliveryType", row.getDeliveryType()); + item.put("deliveryTypeName", deliveryTypeName(row.getDeliveryType())); + item.put("deliveryStatus", row.getDeliveryStatus()); + item.put("deliveryStatusName", deliveryStatusName(row.getDeliveryStatus())); + item.put("deliveryTime", McpFormat.date(row.getDeliveryTime())); + item.put("receiverAddress", row.getNotifierAddress()); + item.put("productSns", deliverySnMap.getOrDefault(row.getId(), Collections.emptyList())); + items.add(item); + } + return items; + } + + private Object toStockItem(String outerOrderCode, String queryOrderCode, + Map> stockMap) { + String orderCode = McpFormat.trimToNull(queryOrderCode) != null + ? McpFormat.trimToNull(queryOrderCode) : McpFormat.trimToNull(outerOrderCode); + List rows = orderCode == null + ? Collections.emptyList() : stockMap.getOrDefault(orderCode, Collections.emptyList()); + if (rows.isEmpty()) { + return null; + } + OmsStockInfo stock = rows.get(0); + Map item = new LinkedHashMap<>(); + item.put("orderCode", McpFormat.trim(stock.getOrderCode())); + item.put("stockStatus", stock.getStockStatus()); + item.put("stockStatusName", stockStatusName(stock.getStockStatus())); + item.put("onceInStock", stock.getOnceInStock()); + item.put("allQuantity", stock.getAllQuantity()); + item.put("deliveryTime", McpFormat.date(stock.getDeliveryTime())); + return item; + } + + private String outerStatusName(String status) { + if (status == null) { + return null; + } + for (InventoryOuter.OuterStatusEnum value : InventoryOuter.OuterStatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getDesc(); + } + } + return status; + } + + private String outerDeliveryStatusName(String status) { + if (status == null) { + return null; + } + for (InventoryOuter.DeliveryStatusEnum value : InventoryOuter.DeliveryStatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getDesc(); + } + } + return status; + } + + private String deliveryStatusName(String status) { + if (status == null) { + return null; + } + for (InventoryDelivery.DeliveryStatusEnum value : InventoryDelivery.DeliveryStatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getDesc(); + } + } + return status; + } + + private String deliveryTypeName(String type) { + if (type == null) { + return null; + } + if ("1".equals(type)) { + return "快递"; + } + if ("2".equals(type)) { + return "物流"; + } + if ("3".equals(type)) { + return "自提"; + } + return type; + } + + private String stockStatusName(String status) { + if (status == null) { + return null; + } + if ("0".equals(status)) { + return "未备货"; + } + if ("1".equals(status)) { + return "已备货"; + } + return status; + } + + private String inventoryStatusName(String status) { + if (status == null) { + return null; + } + for (InventoryInfo.InventoryStatusEnum value : InventoryInfo.InventoryStatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getDesc(); + } + } + return status; + } + + private List safeList(List list) { + return list == null ? Collections.emptyList() : list; + } + + /** + * 按主键 id 升序稳定排序:保证主调用的截断点与局部游标的续页起点严格衔接(部分子表 SQL 无 order by)。 + */ + private static List sortById(List rows, java.util.function.Function idGetter) { + if (rows == null || rows.size() <= 1) { + return rows == null ? Collections.emptyList() : rows; + } + List copy = new ArrayList<>(rows); + copy.sort(java.util.Comparator.comparing(idGetter, + java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))); + return copy; + } + + private Map buildMetadata() { + Map metadata = metadata("按出库单号/合同编号点查完整单据流转链(不分页,子列表有上界)"); + metadata.put("query_fields", mapData( + "outer_code", "出库单号(与 order_code 二选一)", + "order_code", "合同编号(与 outer_code 二选一)", + "include_detail", "是否返回明细,默认 true", + "sub_list", "可选:子列表名(" + SUB_LIST_NAMES + "),传该参数表示只翻该子列表的一页", + "sub_parent", "可选:父实体标识(出库单号),首次翻转子列表时必填", + "sub_cursor", "可选:局部游标,原样回传 data.sub_page_info.next_cursor;与 cursor 互斥", + "page_size", "可选:子列表每页条数,默认 " + McpSubPage.DEFAULT_PAGE_SIZE + ",上限 " + McpSubPage.MAX_PAGE_SIZE + )); + metadata.put("data_fields", mapData( + "total", "命中的出库单数", + "items", "每个出库单一条流转链", + "truncated_sub_lists", "被子列表上限(" + MAX_SUB_LIST + ")截断的条目:{list, parent, total, returned, next_cursor};" + + "用 sub_list + next_cursor 作为 sub_cursor 继续翻页,直到 has_more=false,否则数据不完整" + )); + metadata.put("item_fields", mapData( + "outerCode", "出库单号", + "orderCode", "合同编号", + "projectCode", "项目编号", + "projectName", "项目名称", + "productCode", "产品BOM编码", + "model", "产品型号", + "quantity", "应发数量", + "outerStatus", "出库状态编码", + "outerStatusName", "出库状态名称(1=待确认,2=已确认,3=已接收,4=已退回)", + "deliveryStatus", "发货状态编码", + "deliveryStatusName", "发货状态名称(0=未发货,1=部分发货,2=全部发货,3=已撤回)", + "deliveryTime", "发货时间", + "contactPerson", "联系人", + "contactPhone", "联系电话", + "contactAddress", "联系地址", + "createByName", "创建人", + "createTime", "创建时间", + "warehouseId", "仓库ID(取自出库明细)", + "outerDetails", "出库明细(仓库拆分)", + "outerDetails[].warehouseId", "仓库ID", + "outerDetails[].warehouseName", "仓库名称", + "outerDetails[].quantity", "出库数量", + "outerDetails[].outerStatus", "出库状态", + "snDetails", "该出库单下的 SN 明细", + "snDetails[].productSn", "产品序列号", + "snDetails[].inventoryStatus", "库存状态(0=入库,1=出库)", + "snDetails[].innerCode", "入库单号", + "snDetails[].innerPrice", "入库价(是否含税待确认)", + "snDetails[].outerPrice", "出库价(是否含税待确认)", + "inners", "入库信息", + "inners[].innerCode", "入库单号", + "inners[].purchaseNo", "采购单号", + "inners[].vendorName", "厂商名称", + "inners[].createTime", "入库时间", + "deliveries", "发货单列表", + "deliveries[].deliveryId", "发货单ID", + "deliveries[].logisticsCompany", "物流公司", + "deliveries[].logisticsCode", "物流单号", + "deliveries[].deliveryStatus", "发货状态编码", + "deliveries[].deliveryStatusName", "发货状态名称(0=待发货,1=已发货,2=撤回)", + "deliveries[].deliveryTime", "发货时间", + "deliveries[].receiverAddress", "收货地址", + "deliveries[].productSns", "发货 SN 明细", + "stock", "备货状态", + "stock.stockStatus", "备货状态编码(0=未备货,1=已备货)", + "stock.stockStatusName", "备货状态名称", + "stock.onceInStock", "是否一次备齐", + "stock.allQuantity", "应备货总量", + "stock.deliveryTime", "要求到货时间" + )); + metadata.put("dict_fields", mapData( + "outerStatus", "InventoryOuter.OuterStatusEnum", + "deliveryStatus(outer)", "InventoryOuter.DeliveryStatusEnum", + "deliveryStatus(delivery)", "InventoryDelivery.DeliveryStatusEnum", + "stock.stockStatus", "OmsStockInfo(0=未备货,1=已备货)" + )); + metadata.put("notes", "撤回后 inventory_info.outer_code 会被清空,故 SN 明细优先按 outer_code 查询," + + "查不到时再按发货记录(oms_inventory_delivery + _detail)反查兜底(listByOuterCodeViaDelivery)。" + + "子列表(outerDetails / snDetails / deliveries)单次最多 " + MAX_SUB_LIST + " 条," + + "超出时用 data.truncated_sub_lists[].next_cursor 配合 sub_list 继续翻页(局部游标,最多 " + + McpSubPage.MAX_PAGES + " 页),不要让结果停留在截断状态。"); + return metadata; + } + + private Map buildSubListMetadata(String subList) { + Map metadata = metadata("inventory_flow 的局部游标翻页:只返回某一个出库单下 " + subList + " 子列表的一页"); + metadata.put("query_fields", mapData( + "sub_list", "要翻页的子列表名,取值 " + SUB_LIST_NAMES, + "sub_parent", "父实体标识(出库单号 outer_code),首次翻页必填", + "sub_cursor", "局部游标,原样回传上次的 data.sub_page_info.next_cursor;已携带 sub_parent,与 cursor 互斥", + "page_size", "每页条数,默认 " + McpSubPage.DEFAULT_PAGE_SIZE + ",上限 " + McpSubPage.MAX_PAGE_SIZE + )); + metadata.put("data_fields", mapData( + "sub_list", "本次翻页的子列表名", + "sub_parent", "父实体标识(出库单号)", + "total", "★该子列表的总条数(不是出库单数)", + "items", "本页数据,字段语义与主调用的 items[].[] 完全一致", + "sub_page_info", "分页信封,结构同 page_info:returned / page_size / has_more / next_cursor / sort_by / page_no / total_count" + )); + metadata.put("item_fields", subListItemFields(subList)); + metadata.put("notes", "本工具不分页但有界:子列表在主调用中最多返回 " + MAX_SUB_LIST + " 条," + + "超出时 data.truncated_sub_lists 会给出 {list, parent, total, returned, next_cursor}," + + "用 sub_list + 该 next_cursor 作为 sub_cursor 继续翻页,直到 sub_page_info.has_more=false,否则数据不完整。" + + "首次翻页(无 sub_cursor)需同时传 sub_parent。局部游标最多翻 " + McpSubPage.MAX_PAGES + " 页。"); + return metadata; + } + + private Map subListItemFields(String subList) { + if ("outerDetails".equals(subList)) { + return mapData( + "outerCode", "出库单号", + "warehouseId", "仓库ID", + "warehouseName", "仓库名称", + "quantity", "出库数量", + "outerStatus", "出库状态", + "productCode", "产品BOM编码", + "model", "产品型号" + ); + } + if ("snDetails".equals(subList)) { + return mapData( + "id", "SN 记录主键(排序键)", + "productSn", "产品序列号", + "productCode", "产品BOM编码", + "model", "产品型号", + "inventoryStatus", "库存状态编码(0=入库,1=出库)", + "inventoryStatusName", "库存状态名称", + "innerCode", "入库单号", + "outerCode", "出库单号", + "orderCode", "合同编号", + "warehouseId", "仓库ID", + "warehouseName", "仓库名称", + "innerPrice", "入库价(是否含税待确认)", + "outerPrice", "出库价(是否含税待确认)", + "taxRate", "税率" + ); + } + return mapData( + "deliveryId", "发货单ID(排序键)", + "outerCode", "出库单号", + "warehouseId", "仓库ID", + "warehouseName", "仓库名称", + "logisticsCompany", "物流公司", + "logisticsCode", "物流单号", + "deliveryType", "发货方式编码(1=快递,2=物流,3=自提)", + "deliveryTypeName", "发货方式名称", + "deliveryStatus", "发货状态编码(0=待发货,1=已发货,2=撤回)", + "deliveryStatusName", "发货状态名称", + "deliveryTime", "发货时间", + "receiverAddress", "收货地址", + "productSns", "该发货单的 SN 明细" + ); + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventorySnTraceToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventorySnTraceToolProvider.java new file mode 100644 index 00000000..6e6bb3d2 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventorySnTraceToolProvider.java @@ -0,0 +1,274 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.sip.domain.InventoryInfo; +import com.ruoyi.sip.domain.OmsInventoryInner; +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.InventoryDeliveryMapper; +import com.ruoyi.sip.mapper.InventoryInfoMapper; +import com.ruoyi.sip.mapper.OmsInventoryInnerMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * MCP 工具 inventory_sn_trace(标识符点查,不分页,有界)。 + *

权限来源:InventoryInfoController({@code @RequiresPermissions("inventory:info:view")})。

+ */ +@Component +public class InventorySnTraceToolProvider extends AbstractMcpQueryToolProvider { + + private static final int MAX_SN = 50; + private static final int MAX_CODE = 20; + private static final int DEFAULT_LIMIT = 50; + private static final int MAX_LIMIT = 200; + + @Autowired + private InventoryInfoMapper inventoryInfoMapper; + + @Autowired + private OmsInventoryInnerMapper omsInventoryInnerMapper; + + @Autowired + private InventoryDeliveryMapper inventoryDeliveryMapper; + + @Override + protected String getToolName() { + return "inventory_sn_trace"; + } + + @Override + protected String getToolDescription() { + return "按产品序列号/入库单号/出库单号/合同编号点查库存明细(SN 级):库存状态、入库价/出库价、税率、所属入库单/出库单/合同号、仓库。" + + "本工具不分页但结果有上界(limit,默认 50、上限 200);若返回 truncated=true 请收窄查询范围。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("product_sn_list", arrayProperty("产品序列号/条码列表,最多 " + MAX_SN + + " 个;命中唯一索引 unq_idx_sn,最优先的入口")); + properties.put("inner_code_list", arrayProperty("入库单号列表,最多 " + MAX_CODE + " 个")); + properties.put("outer_code_list", arrayProperty("出库单号列表,最多 " + MAX_CODE + " 个")); + properties.put("order_code", stringProperty("合同编号(仅能查到已出库的 SN;按订单查在库货请改用 inner_code_list)")); + properties.put("limit", intProperty("返回条数上限,默认 " + DEFAULT_LIMIT + ",上限 " + MAX_LIMIT)); + return objectSchema(properties); + } + + @Override + protected Object handle(Map params) { + McpAuth.requireAnyPermission("inventory:info:view"); + + McpParams.requireAny(params, "product_sn_list", "inner_code_list", "outer_code_list", "order_code"); + List productSns = McpParams.codeList(params, "product_sn_list", MAX_SN); + List innerCodes = McpParams.codeList(params, "inner_code_list", MAX_CODE); + List outerCodes = McpParams.codeList(params, "outer_code_list", MAX_CODE); + String orderCode = McpParams.str(params, "order_code"); + int limit = McpParams.intVal(params, "limit", DEFAULT_LIMIT); + if (limit <= 0 || limit > MAX_LIMIT) { + throw McpToolException.invalidParams("limit 必须在 1 ~ " + MAX_LIMIT + " 之间,当前值:" + limit); + } + + // 多入口取并集(按 id 去重,保持首次出现顺序) + // 注意:未提供的入口必须跳过查询,否则 `where col in ()` 是非法 SQL(MySQL 语法错误)。 + Map merged = new LinkedHashMap<>(); + if (!productSns.isEmpty()) { + merge(merged, inventoryInfoMapper.listSnTraceByProductSnList(productSns, limit)); + } + if (!innerCodes.isEmpty()) { + merge(merged, inventoryInfoMapper.listSnTraceByInnerCodeList(innerCodes, limit)); + } + if (!outerCodes.isEmpty()) { + merge(merged, inventoryInfoMapper.listSnTraceByOuterCodeList(outerCodes, limit)); + } + if (orderCode != null) { + merge(merged, inventoryInfoMapper.listSnTraceByOrderCode(Collections.singletonList(orderCode), limit)); + } + + List rows = new ArrayList<>(merged.values()); + boolean truncated = false; + if (rows.size() > limit) { + rows = new ArrayList<>(rows.subList(0, limit)); + truncated = true; + } + + // 采购单号:purchase_no 不是 oms_inventory_info 的列(见方案 2.3/2.4), + // 需按 SN 的 inner_code 关联 oms_inventory_inner.purchase_no 批量补齐(一次查询,非逐行)。 + Map purchaseNoMap = loadPurchaseNoMap(rows); + + // 发货/财务审批信息(可选键:为空则不输出) + List snValues = new ArrayList<>(); + for (InventoryInfo row : rows) { + String sn = McpFormat.trimToNull(row.getProductSn()); + if (sn != null) { + snValues.add(sn); + } + } + List approveStatusList = snValues.isEmpty() + ? Collections.emptyList() + : safeList(inventoryDeliveryMapper.selectApproveStatusBySn(snValues)); + + List> items = new ArrayList<>(); + for (InventoryInfo row : rows) { + items.add(toItem(row, purchaseNoMap, approveStatusList)); + } + + Map query = mapData( + "product_sn_list", productSns, + "inner_code_list", innerCodes, + "outer_code_list", outerCodes, + "order_code", orderCode, + "limit", limit + ); + + Map metadata = buildMetadata(); + Map data = mapData( + "total", items.size(), + "items", items, + "truncated", truncated + ); + return response(metadata, query, data); + } + + private void merge(Map target, List rows) { + if (rows == null) { + return; + } + for (InventoryInfo row : rows) { + if (row == null || row.getId() == null) { + continue; + } + target.putIfAbsent(row.getId(), row); + } + } + + private List safeList(List list) { + return list == null ? Collections.emptyList() : list; + } + + private Map loadPurchaseNoMap(List rows) { + Set innerCodes = new LinkedHashSet<>(); + for (InventoryInfo row : rows) { + String innerCode = McpFormat.trimToNull(row.getInnerCode()); + if (innerCode != null) { + innerCodes.add(innerCode); + } + } + if (innerCodes.isEmpty()) { + return Collections.emptyMap(); + } + List inners = omsInventoryInnerMapper + .selectOmsInventoryInnerByInnerCodeList(new ArrayList<>(innerCodes)); + Map result = new LinkedHashMap<>(); + if (inners != null) { + for (OmsInventoryInner inner : inners) { + if (inner == null) { + continue; + } + String innerCode = McpFormat.trimToNull(inner.getInnerCode()); + if (innerCode != null) { + result.putIfAbsent(innerCode, McpFormat.trimToNull(inner.getPurchaseNo())); + } + } + } + return result; + } + + private Map toItem(InventoryInfo row, + Map purchaseNoMap, + List approveStatusList) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("productSn", McpFormat.trim(row.getProductSn())); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("model", row.getModel()); + item.put("productDesc", row.getProductDesc()); + item.put("inventoryStatus", row.getInventoryStatus()); + item.put("inventoryStatusName", inventoryStatusName(row.getInventoryStatus())); + item.put("innerCode", McpFormat.trim(row.getInnerCode())); + item.put("outerCode", McpFormat.trim(row.getOuterCode())); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("purchaseNo", purchaseNoMap.get(McpFormat.trim(row.getInnerCode()))); + item.put("warehouseId", row.getWarehouseId()); + item.put("warehouseName", row.getWarehouseName()); + item.put("innerPrice", row.getInnerPrice()); + item.put("outerPrice", row.getOuterPrice()); + item.put("taxRate", row.getTaxRate()); + item.put("payableBillCode", McpFormat.trim(row.getPayableBillCode())); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + if (!approveStatusList.isEmpty()) { + item.put("deliveryInfo", mapData("approveStatusList", approveStatusList)); + } + return item; + } + + private String inventoryStatusName(String status) { + if (status == null) { + return null; + } + for (InventoryInfo.InventoryStatusEnum value : InventoryInfo.InventoryStatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getDesc(); + } + } + return status; + } + + private Map buildMetadata() { + Map metadata = metadata("按产品序列号/入库单号/出库单号/合同编号点查库存明细(不分页,有上界)"); + metadata.put("query_fields", mapData( + "product_sn_list", "产品序列号/条码列表,≤" + MAX_SN, + "inner_code_list", "入库单号列表,≤" + MAX_CODE, + "outer_code_list", "出库单号列表,≤" + MAX_CODE, + "order_code", "合同编号", + "limit", "返回上限,默认 " + DEFAULT_LIMIT + " / 上限 " + MAX_LIMIT + )); + metadata.put("data_fields", mapData( + "total", "返回条数", + "items", "库存明细列表", + "truncated", "是否因 limit 截断(true 时请收窄查询范围)" + )); + metadata.put("item_fields", mapData( + "id", "库存明细主键", + "productSn", "产品序列号/条码", + "productCode", "产品BOM编码", + "model", "产品型号", + "productDesc", "产品描述", + "inventoryStatus", "库存状态编码", + "inventoryStatusName", "库存状态名称(0=入库,1=出库)", + "innerCode", "入库单号", + "outerCode", "出库单号", + "orderCode", "合同编号(SN 未出库时为空)", + "purchaseNo", "采购单号(由 inner_code 关联 oms_inventory_inner 得到)", + "warehouseId", "仓库ID", + "warehouseName", "仓库名称", + "innerPrice", "入库价(是否含税待业务确认)", + "outerPrice", "出库价(是否含税待业务确认)", + "taxRate", "税率(实测存在 NULL,原值输出,未做未税/含税换算)", + "payableBillCode", "对应应付单号", + "createTime", "创建时间", + "updateTime", "更新时间", + "deliveryInfo", "该 SN 集合关联的财务单据审批状态(approveStatusList:1=审批中,2=审批通过);为空时不输出该键" + )); + metadata.put("dict_fields", mapData( + "inventoryStatus", "InventoryInfo.InventoryStatusEnum(0=入库,1=出库)" + )); + metadata.put("aggregation_rule", "1) orderCode 在 SN 未出库时为空,按订单查在库货请改用 inner_code;" + + "2) innerPrice/outerPrice 是否含税未经业务确认,不做含税/未税换算;" + + "3) inventoryStatus 取值 0=入库、1=出库;" + + "4) 编码匹配不区分大小写(utf8mb4_unicode_ci)。"); + metadata.put("data_quality", "编码类字段入参/出参统一 trim;tax_rate 实测存在 NULL,原值输出。"); + return metadata; + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventoryStockAggregateToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventoryStockAggregateToolProvider.java new file mode 100644 index 00000000..054daa32 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/InventoryStockAggregateToolProvider.java @@ -0,0 +1,376 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.sip.domain.InventoryInfo; +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpCursor; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpPages; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.InventoryInfoMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * MCP 工具 inventory_stock_aggregate(聚合,默认 mode=SUMMARY)。 + *

权限来源:InventoryInfoController({@code @RequiresPermissions("inventory:info:view")})。

+ */ +@Component +public class InventoryStockAggregateToolProvider extends AbstractMcpQueryToolProvider { + + private static final Set MODES = new HashSet<>(Arrays.asList("SUMMARY", "LIST")); + private static final Set GROUP_BY = new HashSet<>(Arrays.asList("NONE", "PRODUCT", "WAREHOUSE", "STATUS")); + /** LIST 模式允许的 group_by:其余维度缺少索引支撑(方案 15.2),只能走 SUMMARY */ + private static final Set LIST_GROUP_BY = new HashSet<>(Arrays.asList("NONE", "PRODUCT")); + + private static final int MAX_PRODUCT_CODE = 20; + private static final int DEFAULT_TOP_N = 10; + private static final int MAX_TOP_N = 100; + private static final long COUNT_CAP = 50000L; + + @Autowired + private InventoryInfoMapper inventoryInfoMapper; + + @Override + protected String getToolName() { + return "inventory_stock_aggregate"; + } + + @Override + protected String getToolDescription() { + return "库存汇总统计:按产品/仓库/状态/整体聚合在库数量、已出库数量、总数量、入库价合计、出库价合计。" + + "默认 mode=SUMMARY(一次算完全量,按数量倒序取 top_n,不翻页);mode=LIST 按产品分页(游标键 product_code,每条产品嵌套仓库拆分)。" + + "分页查询时:若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 再次调用本工具,重复直到 has_more 为 false," + + "否则结果不完整;不要用其它参数代替 cursor。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("mode", enumProperty("聚合模式,默认 SUMMARY(一次算完,不分页)/ LIST(按产品游标分页)", "SUMMARY", "LIST")); + properties.put("group_by", enumProperty("分组的维度:NONE=整体一行(默认)/ PRODUCT=按产品 / WAREHOUSE=按仓库 / STATUS=按库存状态", + "NONE", "PRODUCT", "WAREHOUSE", "STATUS")); + properties.put("product_code_list", arrayProperty("产品编码列表,最多 " + MAX_PRODUCT_CODE + + " 个;用于收窄范围;mode=LIST 时必填")); + properties.put("warehouse_id", intProperty("仓库ID(可选)")); + properties.put("top_n", intProperty("SUMMARY 模式下返回的分组数上限,默认 " + DEFAULT_TOP_N + "、上限 " + MAX_TOP_N)); + properties.put("include_zero", boolProperty("是否包含全部度量均为 0/null 的分组,默认 false")); + properties.put("include_total", boolProperty("LIST 模式是否统计产品分组总数,默认 false(受 count_cap=" + COUNT_CAP + " 限制)")); + withPagingProperties(properties, McpPages.MAX_AGG_PAGE_SIZE, McpPages.DEFAULT_PAGE_SIZE); + return objectSchema(properties); + } + + @Override + protected Object handle(Map params) { + McpAuth.requireAnyPermission("inventory:info:view"); + + String mode = McpParams.enumVal(params, "mode", MODES, "SUMMARY"); + String groupBy = McpParams.enumVal(params, "group_by", GROUP_BY, "NONE"); + List productCodes = McpParams.codeList(params, "product_code_list", MAX_PRODUCT_CODE); + Long warehouseId = longVal(params, "warehouse_id"); + boolean includeZero = McpParams.boolVal(params, "include_zero", false); + boolean includeTotal = McpParams.boolVal(params, "include_total", false); + + McpParams.reject("SUMMARY".equals(mode) && (McpParams.has(params, "cursor") || McpParams.has(params, "page_size")), + "mode=SUMMARY 不分页,请勿传 cursor / page_size;需要分页请使用 mode=LIST"); + McpParams.reject("LIST".equals(mode) && McpParams.has(params, "top_n"), + "top_n 仅在 mode=SUMMARY 下可用,请移除该参数或改用 mode=SUMMARY"); + if ("LIST".equals(mode)) { + McpParams.reject(productCodes.isEmpty(), + "mode=LIST 必须提供 product_code_list(最多 " + MAX_PRODUCT_CODE + " 个),以避免大表全量分页"); + McpParams.reject(!LIST_GROUP_BY.contains(groupBy), + "group_by=" + groupBy + " 在 mode=LIST 下缺少索引支撑,请改用 mode=SUMMARY"); + } + + if ("SUMMARY".equals(mode)) { + return handleSummary(params, groupBy, productCodes, warehouseId, includeZero); + } + return handleList(params, groupBy, productCodes, warehouseId, includeZero, includeTotal); + } + + private Object handleSummary(Map params, + String groupBy, + List productCodes, + Long warehouseId, + boolean includeZero) { + int topN = McpParams.intVal(params, "top_n", DEFAULT_TOP_N); + if (topN <= 0 || topN > MAX_TOP_N) { + throw McpToolException.invalidParams("top_n 必须在 1 ~ " + MAX_TOP_N + " 之间,当前值:" + topN); + } + List> rows = inventoryInfoMapper.selectInventoryStockSummary( + groupBy, productCodes.isEmpty() ? null : productCodes, warehouseId, includeZero, topN); + + List> items = new ArrayList<>(); + if (rows != null) { + for (Map row : rows) { + if (row == null) { + continue; + } + if (!includeZero && isAllZero(row)) { + continue; + } + items.add(toSummaryItem(groupBy, row)); + } + } + + Map query = mapData( + "mode", "SUMMARY", + "group_by", groupBy, + "product_code_list", productCodes, + "warehouse_id", warehouseId, + "top_n", topN, + "include_zero", includeZero + ); + Map data = mapData( + "total", items.size(), + "items", items + ); + return response(buildMetadata("SUMMARY"), query, data); + } + + private Object handleList(Map params, + String groupBy, + List productCodes, + Long warehouseId, + boolean includeZero, + boolean includeTotal) { + int pageSize = McpPages.pageSize(params, McpPages.DEFAULT_PAGE_SIZE, McpPages.MAX_AGG_PAGE_SIZE); + String filterHash = McpParams.filterHash(getToolName(), "LIST", groupBy, String.join(",", productCodes), + warehouseId == null ? "" : String.valueOf(warehouseId), String.valueOf(includeZero)); + McpCursor cursor = McpPages.cursor(params, getToolName(), filterHash, McpPages.DEFAULT_AGG_MAX_PAGES); + int pageNo = cursor == null ? 1 : cursor.getP(); + String cursorKey = cursor == null ? null : cursor.key(0); + + List> rows = inventoryInfoMapper.selectStockGroupPage( + productCodes, warehouseId, cursorKey, includeZero, pageSize + 1); + boolean hasMore = McpPages.hasMore(rows, pageSize); + List> page = McpPages.trim(rows, pageSize); + + // 先取本页产品码,再批量取仓库拆分(禁止逐行查询) + List pageProductCodes = new ArrayList<>(); + for (Map row : page) { + String code = str(row.get("productCode")); + if (code != null) { + pageProductCodes.add(code); + } + } + Map>> warehouseMap = loadWarehouseBreakdown(pageProductCodes, warehouseId); + + List> items = new ArrayList<>(); + String lastProductCode = null; + for (Map row : page) { + String code = str(row.get("productCode")); + lastProductCode = code; + Map item = new LinkedHashMap<>(); + item.put("productCode", code); + putMetrics(item, row); + item.put("warehouses", warehouseMap.getOrDefault(code, Collections.emptyList())); + items.add(item); + } + + Long total = null; + boolean totalCapped = false; + if (includeTotal) { + long counted = inventoryInfoMapper.countStockGroups(productCodes, warehouseId); + totalCapped = counted > COUNT_CAP; + total = totalCapped ? COUNT_CAP : counted; + } + + String nextCursor = hasMore + ? McpCursor.encode(getToolName(), filterHash, pageNo + 1, keysOf(lastProductCode)) + : null; + Map pageInfo = McpPages.pageInfo(items.size(), pageSize, hasMore, nextCursor, + "product_code", pageNo, total, totalCapped, false); + + Map query = mapData( + "mode", "LIST", + "group_by", groupBy, + "product_code_list", productCodes, + "warehouse_id", warehouseId, + "include_zero", includeZero, + "include_total", includeTotal, + "page_size", pageSize, + "cursor", McpParams.str(params, "cursor") + ); + Map data = listData(items, pageInfo, total); + return response(buildMetadata("LIST"), query, data); + } + + private Map>> loadWarehouseBreakdown(List productCodes, Long warehouseId) { + Map>> result = new LinkedHashMap<>(); + if (productCodes.isEmpty()) { + return result; + } + List> rows = inventoryInfoMapper.selectStockByProductWarehouse(productCodes, warehouseId); + if (rows == null) { + return result; + } + for (Map row : rows) { + if (row == null) { + continue; + } + String code = str(row.get("productCode")); + if (code == null) { + continue; + } + Map warehouse = new LinkedHashMap<>(); + warehouse.put("warehouseId", row.get("warehouseId")); + warehouse.put("warehouseName", row.get("warehouseName")); + warehouse.put("inStockQty", toLong(row.get("inStockQty"))); + warehouse.put("outStockQty", toLong(row.get("outStockQty"))); + result.computeIfAbsent(code, k -> new ArrayList<>()).add(warehouse); + } + return result; + } + + private Map toSummaryItem(String groupBy, Map row) { + Map item = new LinkedHashMap<>(); + Object groupKey = row.get("groupKey"); + if ("PRODUCT".equals(groupBy)) { + item.put("groupKey", str(groupKey)); + item.put("productCode", str(groupKey)); + } else if ("WAREHOUSE".equals(groupBy)) { + item.put("groupKey", groupKey); + item.put("warehouseId", groupKey); + item.put("warehouseName", row.get("groupName")); + } else if ("STATUS".equals(groupBy)) { + String status = str(groupKey); + item.put("groupKey", status); + item.put("inventoryStatus", status); + item.put("inventoryStatusName", inventoryStatusName(status)); + } + putMetrics(item, row); + return item; + } + + private void putMetrics(Map item, Map row) { + item.put("inStockQty", toLong(row.get("inStockQty"))); + item.put("outStockQty", toLong(row.get("outStockQty"))); + item.put("totalQty", toLong(row.get("totalQty"))); + item.put("innerAmount", amount(row.get("innerAmount"))); + item.put("outerAmount", amount(row.get("outerAmount"))); + } + + /** include_zero 判定:本次请求的全部度量均为 0/null。 */ + private boolean isAllZero(Map row) { + return toLong(row.get("inStockQty")) == 0L + && toLong(row.get("outStockQty")) == 0L + && toLong(row.get("totalQty")) == 0L + && BigDecimal.ZERO.compareTo(amount(row.get("innerAmount"))) == 0 + && BigDecimal.ZERO.compareTo(amount(row.get("outerAmount"))) == 0; + } + + private long toLong(Object value) { + if (value == null) { + return 0L; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return new BigDecimal(String.valueOf(value)).longValue(); + } catch (NumberFormatException ex) { + return 0L; + } + } + + private BigDecimal amount(Object value) { + if (value == null) { + return BigDecimal.ZERO; + } + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof Number) { + return new BigDecimal(value.toString()); + } + try { + return new BigDecimal(String.valueOf(value)); + } catch (NumberFormatException ex) { + return BigDecimal.ZERO; + } + } + + private String str(Object value) { + return value == null ? null : McpFormat.trimToNull(String.valueOf(value)); + } + + private Long longVal(Map params, String key) { + String value = McpParams.str(params, key); + if (value == null) { + return null; + } + try { + return Long.valueOf(value); + } catch (NumberFormatException ex) { + throw McpToolException.invalidParams("参数 " + key + " 必须为整数,当前值:" + value); + } + } + + private String inventoryStatusName(String status) { + if (status == null) { + return null; + } + for (InventoryInfo.InventoryStatusEnum value : InventoryInfo.InventoryStatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getDesc(); + } + } + return status; + } + + private Map buildMetadata(String mode) { + Map metadata = metadata("库存汇总聚合(mode=" + mode + ")"); + metadata.put("query_fields", mapData( + "mode", "SUMMARY(默认,一次算完不分页)/ LIST(按产品游标分页)", + "group_by", "NONE(默认)/ PRODUCT / WAREHOUSE / STATUS", + "product_code_list", "产品编码列表,≤" + MAX_PRODUCT_CODE + "(LIST 必填)", + "warehouse_id", "仓库ID(可选)", + "top_n", "SUMMARY 分组数上限,默认 " + DEFAULT_TOP_N + " / 上限 " + MAX_TOP_N, + "include_zero", "是否包含全零分组,默认 false", + "include_total", "LIST 是否统计产品分组总数,默认 false", + "page_size", "LIST 每页条数,默认 " + McpPages.DEFAULT_PAGE_SIZE + " / 上限 " + McpPages.MAX_AGG_PAGE_SIZE, + "cursor", "LIST 分页游标,原样回传 page_info.next_cursor" + )); + metadata.put("data_fields", mapData( + "total", "返回分组数", + "items", "分组聚合结果;LIST 模式每条产品含 warehouses[] 仓库拆分", + "page_info", "LIST 模式分页信息" + )); + metadata.put("item_fields", mapData( + "productCode", "产品BOM编码(LIST 及 group_by=PRODUCT)", + "warehouseId", "仓库ID(group_by=WAREHOUSE)", + "warehouseName", "仓库名称(group_by=WAREHOUSE)", + "inventoryStatus", "库存状态编码(group_by=STATUS)", + "inventoryStatusName", "库存状态名称(0=入库,1=出库)", + "inStockQty", "在库数量(inventory_status='0')", + "outStockQty", "已出库数量(inventory_status='1')", + "totalQty", "总数量", + "innerAmount", "入库价合计(sum(inner_price),NULL 计 0)", + "outerAmount", "出库价合计(sum(outer_price),NULL 计 0)", + "warehouses", "该产品的仓库拆分数组(LIST)", + "warehouses[].warehouseId", "仓库ID", + "warehouses[].warehouseName", "仓库名称", + "warehouses[].inStockQty", "该仓在库数量", + "warehouses[].outStockQty", "该仓已出库数量" + )); + metadata.put("aggregation_rule", "在库 = inventory_status='0'(**未扣除已发货占用**,业务待确认,见方案 15.7);" + + "已出库 = inventory_status='1';innerAmount/outerAmount 为 sum(inner_price)/sum(outer_price),NULL 计 0;" + + "inner_price/outer_price 是否含税未经业务确认,不做换算。" + + "include_zero 判定:按本次请求的 metrics 集合(inStockQty/outStockQty/totalQty/innerAmount/outerAmount)全为 0/null 视为全零行。" + + "SUMMARY 为一次全量聚合(不受翻页影响,upstream 数据变化会导致结果变化)。"); + metadata.put("notes", "LIST 模式仅支持 group_by=NONE/PRODUCT(按产品游标分页);WAREHOUSE/STATUS 等维度在 LIST 下缺少索引支撑,请改用 mode=SUMMARY(方案 15.2)。"); + return metadata; + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/MasterDataListToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/MasterDataListToolProvider.java new file mode 100644 index 00000000..d10dc16f --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/MasterDataListToolProvider.java @@ -0,0 +1,950 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.sip.domain.AgentInfo; +import com.ruoyi.sip.domain.CustomerInfo; +import com.ruoyi.sip.domain.OmsCompanyInfo; +import com.ruoyi.sip.domain.OmsWarehouseInfo; +import com.ruoyi.sip.domain.PartnerInfo; +import com.ruoyi.sip.domain.ProductInfo; +import com.ruoyi.sip.domain.VendorInfo; +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpCursor; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpPages; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.AgentInfoMapper; +import com.ruoyi.sip.mapper.CustomerInfoMapper; +import com.ruoyi.sip.mapper.OmsCompanyInfoMapper; +import com.ruoyi.sip.mapper.OmsWarehouseInfoMapper; +import com.ruoyi.sip.mapper.PartnerInfoMapper; +import com.ruoyi.sip.mapper.ProductInfoMapper; +import com.ruoyi.sip.mapper.VendorInfoMapper; +import com.ruoyi.system.mapper.SysUserMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * MCP 工具 master_data_list(主数据列表 / 批量编码→名称翻译,游标分页,entity 参数化)。 + * + *

方案依据:docx/mcp-data-tools-plan.md 第 4.3、5、7(第 10/15/20 条)、8.2、15.12、附录 A.12。

+ * + *

核心用途:把其它工具返回的 partnerCode / customerCode / agentCode / vendorCode / productCode / userId / + * warehouseCode / companyCode 一次批量翻译成名称与属性。

+ * + *

权限串来源(逐个 entity 从对应 Controller 的 {@code @RequiresPermissions} 抄取): + * PARTNER→PartnerInfoController#list、CUSTOMER→CustomerInfoController#list、AGENT→AgentInfoController#list、 + * VENDOR→VendorInfoController#list、PRODUCT→ProductInfoController#list(VueProductInfoController#list 同串)、 + * USER→SysUserController#list、WAREHOUSE→OmsWarehouseInfoController#list、COMPANY→OmsCompanyInfoController#list。

+ * + *

实现约定:PARTNER/CUSTOMER/AGENT/VENDOR/PRODUCT/COMPANY 走新增的 SQL 游标分页(limit+1 探测); + * USER(sys_user,约 119 行)与 WAREHOUSE(oms_warehouse_info,14 行)按方案复用既有查询后在内存中做 keyset 分页 + * (两张表规模极小,方案允许优先复用既有方法)。

+ */ +@Component +public class MasterDataListToolProvider extends AbstractMcpQueryToolProvider { + + private static final Set ENTITIES = new LinkedHashSet<>(Arrays.asList( + "PARTNER", "CUSTOMER", "AGENT", "VENDOR", "PRODUCT", "USER", "WAREHOUSE", "COMPANY")); + + private static final long COUNT_CAP = 50000L; + + @Autowired + private PartnerInfoMapper partnerInfoMapper; + + @Autowired + private CustomerInfoMapper customerInfoMapper; + + @Autowired + private AgentInfoMapper agentInfoMapper; + + @Autowired + private VendorInfoMapper vendorInfoMapper; + + @Autowired + private ProductInfoMapper productInfoMapper; + + @Autowired + private OmsWarehouseInfoMapper omsWarehouseInfoMapper; + + @Autowired + private OmsCompanyInfoMapper omsCompanyInfoMapper; + + @Autowired + private SysUserMapper sysUserMapper; + + @Override + protected String getToolName() { + return "master_data_list"; + } + + @Override + protected String getToolDescription() { + return "主数据列表 / 批量编码→名称翻译(只读)。entity=PARTNER 进货商 / CUSTOMER 客户 / AGENT 代表处 / " + + "VENDOR 制造商 / PRODUCT 产品 / USER 系统用户 / WAREHOUSE 仓库 / COMPANY 己方公司主体。" + + "典型用途:把其它工具返回的编码(partnerCode/vendorCode/productCode/userId/warehouseCode 等)" + + "放到 code_list 中一次性取回名称与属性,避免逐个查询;也支持名称模糊、状态过滤。" + + "分页查询工具:若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 继续调用直到 " + + "has_more 为 false,否则数据不完整。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("entity", enumProperty("查询实体(必填):PARTNER=进货商 / CUSTOMER=客户 / AGENT=代表处 / " + + "VENDOR=制造商 / PRODUCT=产品 / USER=系统用户 / WAREHOUSE=仓库 / COMPANY=己方公司主体", + "PARTNER", "CUSTOMER", "AGENT", "VENDOR", "PRODUCT", "USER", "WAREHOUSE", "COMPANY")); + properties.put("code_list", arrayProperty("编码列表,≤" + McpPages.MAX_CODE_LIST + + ":PARTNER→partner_code / CUSTOMER→customer_code / AGENT→agent_code / VENDOR→vendor_code / " + + "PRODUCT→product_code / USER→user_id(数字)/ WAREHOUSE→warehouse_code / COMPANY→company_code。" + + "非空时走批量 in 查询(不逐条查询),这是本工具的核心用途")); + properties.put("name_keyword", stringProperty("名称模糊匹配(各 entity 的名称列:partner_name/customer_name/" + + "agent_name/vendor_name/product_name/user_name/warehouse_name/company_name)")); + properties.put("status", intProperty("状态过滤:PARTNER/CUSTOMER/AGENT=数据状态(0有效);PRODUCT=产品状态(0有效);" + + "VENDOR=合作状态(0正常合作/1暂停合作,见 VendorStatusEnum);WAREHOUSE=仓库状态(0正常/1停用);" + + "USER=系统用户状态(0正常/1停用)")); + properties.put("type", stringProperty("产品一级类型(仅 entity=PRODUCT):1软件/2硬件/3服务/11软件维保/22硬件维保/70省代服务/99其它" + + "(枚举 ProductInfo.ProductTypeEnum)")); + properties.put("include_disabled", boolProperty("是否包含停用数据,默认 false(仅 entity=WAREHOUSE 生效:" + + "默认只返回正常仓,true 时含停用仓,走 OmsWarehouseInfoMapper.listAll)")); + properties.put("include_total", boolProperty("是否统计总条数,默认 false;true 时受 count_cap=" + COUNT_CAP + + " 限制,超出置 total_count_capped=true")); + withPagingProperties(properties, McpPages.MAX_LIST_PAGE_SIZE, McpPages.DEFAULT_PAGE_SIZE); + return objectSchema(properties, "entity"); + } + + @Override + protected Object handle(Map params) { + String entity = McpParams.requireEnum(params, "entity", ENTITIES); + McpAuth.requireAnyPermission(permissionsOf(entity)); + + int pageSize = McpPages.pageSize(params, McpPages.DEFAULT_PAGE_SIZE, McpPages.MAX_LIST_PAGE_SIZE); + List codeList = McpParams.codeList(params, "code_list", McpPages.MAX_CODE_LIST); + String nameKeyword = McpParams.str(params, "name_keyword"); + Integer status = McpParams.intVal(params, "status"); + String type = "PRODUCT".equals(entity) ? McpParams.str(params, "type") : null; + boolean includeDisabled = McpParams.boolVal(params, "include_disabled", false); + boolean includeTotal = McpParams.boolVal(params, "include_total", false); + + // 过滤条件指纹:cursor 与当前条件不一致时直接报错,避免串用游标漏数(方案 5.4) + String filterHash = McpParams.filterHash(getToolName(), entity, sortedJoin(codeList), nameKeyword, + status == null ? null : String.valueOf(status), type, String.valueOf(includeDisabled)); + McpCursor cursor = McpPages.cursor(params, getToolName(), filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + int pageNo = cursor == null ? 1 : cursor.getP(); + + EntityResult result = runEntity(entity, codeList, nameKeyword, status, type, includeDisabled, includeTotal, + pageSize, cursor); + + Map pageInfo = McpPages.pageInfo(result.items.size(), pageSize, result.hasMore, + result.hasMore ? McpCursor.encode(getToolName(), filterHash, pageNo + 1, result.lastSortKeys) : null, + result.sortBy, pageNo, result.total, result.totalCapped, false); + + Map data = listData(result.items, pageInfo, result.total); + return response(buildMetadata(entity), + queryEcho(entity, codeList, nameKeyword, status, type, includeDisabled, includeTotal, pageSize, pageNo), + data); + } + + // ------------------------------------------------------------------ entity 分发 + + private EntityResult runEntity(String entity, List codeList, String nameKeyword, Integer status, + String type, boolean includeDisabled, boolean includeTotal, int pageSize, + McpCursor cursor) { + switch (entity) { + case "PARTNER": + return runPartner(codeList, nameKeyword, status, includeTotal, pageSize, cursor); + case "CUSTOMER": + return runCustomer(codeList, nameKeyword, status, includeTotal, pageSize, cursor); + case "AGENT": + return runAgent(codeList, nameKeyword, status, includeTotal, pageSize, cursor); + case "VENDOR": + return runVendor(codeList, nameKeyword, status, includeTotal, pageSize, cursor); + case "PRODUCT": + return runProduct(codeList, nameKeyword, status, type, includeTotal, pageSize, cursor); + case "USER": + return runUser(codeList, nameKeyword, status, includeTotal, pageSize, cursor); + case "WAREHOUSE": + return runWarehouse(codeList, nameKeyword, status, includeDisabled, includeTotal, pageSize, cursor); + case "COMPANY": + return runCompany(codeList, nameKeyword, includeTotal, pageSize, cursor); + default: + throw McpToolException.invalidParams("不支持的 entity:" + entity); + } + } + + private EntityResult runPartner(List codeList, String nameKeyword, Integer status, boolean includeTotal, + int pageSize, McpCursor cursor) { + Map q = new HashMap<>(); + q.put("codeList", codeList); + q.put("nameKeyword", nameKeyword); + q.put("status", status); + q.put("limit", pageSize + 1); + putCodeCursor(q, cursor); + + List rows = partnerInfoMapper.listPartnerMasterPage(q); + EntityResult result = new EntityResult("partner_code,id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (PartnerInfo row : page) { + result.items.add(toPartnerItem(row)); + } + if (!page.isEmpty()) { + PartnerInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(McpFormat.trim(last.getPartnerCode()), last.getId()); + } + if (includeTotal) { + applyTotal(result, partnerInfoMapper.listPartnerMasterPage(countQuery(q, "lastCode", "lastId")).size()); + } + return result; + } + + private EntityResult runCustomer(List codeList, String nameKeyword, Integer status, boolean includeTotal, + int pageSize, McpCursor cursor) { + Map q = new HashMap<>(); + q.put("codeList", codeList); + q.put("nameKeyword", nameKeyword); + q.put("status", status); + q.put("limit", pageSize + 1); + putCodeCursor(q, cursor); + + List rows = customerInfoMapper.listCustomerMasterPage(q); + EntityResult result = new EntityResult("customer_code,id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (CustomerInfo row : page) { + result.items.add(toCustomerItem(row)); + } + if (!page.isEmpty()) { + CustomerInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(McpFormat.trim(last.getCustomerCode()), last.getId()); + } + if (includeTotal) { + applyTotal(result, customerInfoMapper.listCustomerMasterPage(countQuery(q, "lastCode", "lastId")).size()); + } + return result; + } + + private EntityResult runAgent(List codeList, String nameKeyword, Integer status, boolean includeTotal, + int pageSize, McpCursor cursor) { + Map q = new HashMap<>(); + q.put("codeList", codeList); + q.put("nameKeyword", nameKeyword); + q.put("status", status); + q.put("limit", pageSize + 1); + putCodeCursor(q, cursor); + + List rows = agentInfoMapper.listAgentMasterPage(q); + EntityResult result = new EntityResult("agent_code,id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (AgentInfo row : page) { + result.items.add(toAgentItem(row)); + } + if (!page.isEmpty()) { + AgentInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(McpFormat.trim(last.getAgentCode()), last.getId()); + } + if (includeTotal) { + applyTotal(result, agentInfoMapper.listAgentMasterPage(countQuery(q, "lastCode", "lastId")).size()); + } + return result; + } + + private EntityResult runVendor(List codeList, String nameKeyword, Integer status, boolean includeTotal, + int pageSize, McpCursor cursor) { + Map q = new HashMap<>(); + q.put("codeList", codeList); + q.put("nameKeyword", nameKeyword); + q.put("status", status == null ? null : String.valueOf(status)); + q.put("limit", pageSize + 1); + putCodeCursor(q, cursor); + + List rows = vendorInfoMapper.listVendorMasterPage(q); + EntityResult result = new EntityResult("vendor_code,vendor_id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (VendorInfo row : page) { + result.items.add(toVendorItem(row)); + } + if (!page.isEmpty()) { + VendorInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(McpFormat.trim(last.getVendorCode()), last.getVendorId()); + } + if (includeTotal) { + applyTotal(result, vendorInfoMapper.listVendorMasterPage(countQuery(q, "lastCode", "lastId")).size()); + } + return result; + } + + private EntityResult runProduct(List codeList, String nameKeyword, Integer status, String type, + boolean includeTotal, int pageSize, McpCursor cursor) { + Map q = new HashMap<>(); + q.put("codeList", codeList); + q.put("nameKeyword", nameKeyword); + q.put("status", status); + q.put("type", type); + q.put("limit", pageSize + 1); + putCodeCursor(q, cursor); + + List rows = productInfoMapper.listProductMasterPage(q); + EntityResult result = new EntityResult("product_code,id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (ProductInfo row : page) { + result.items.add(toProductItem(row)); + } + if (!page.isEmpty()) { + ProductInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(McpFormat.trim(last.getProductCode()), last.getId()); + } + if (includeTotal) { + applyTotal(result, productInfoMapper.listProductMasterPage(countQuery(q, "lastCode", "lastId")).size()); + } + return result; + } + + private EntityResult runCompany(List codeList, String nameKeyword, boolean includeTotal, int pageSize, + McpCursor cursor) { + Map q = new HashMap<>(); + q.put("codeList", codeList); + q.put("nameKeyword", nameKeyword); + q.put("limit", pageSize + 1); + putCodeCursor(q, cursor); + + List rows = omsCompanyInfoMapper.listCompanyMasterPage(q); + EntityResult result = new EntityResult("company_code,id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (OmsCompanyInfo row : page) { + result.items.add(toCompanyItem(row)); + } + if (!page.isEmpty()) { + OmsCompanyInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(McpFormat.trim(last.getCompanyCode()), last.getId()); + } + if (includeTotal) { + applyTotal(result, omsCompanyInfoMapper.listCompanyMasterPage(countQuery(q, "lastCode", "lastId")).size()); + } + return result; + } + + /** + * USER:复用 SysUserMapper.selectUserList(sys_user 约 119 行,方案允许全表扫),按 user_id 在内存中 keyset 分页。 + */ + private EntityResult runUser(List codeList, String nameKeyword, Integer status, boolean includeTotal, + int pageSize, McpCursor cursor) { + SysUser probe = new SysUser(); + if (!codeList.isEmpty()) { + List userIds = new ArrayList<>(); + for (String code : codeList) { + try { + userIds.add(Long.valueOf(code)); + } catch (NumberFormatException ex) { + throw McpToolException.invalidParams("entity=USER 的 code_list 仅支持数字用户ID,当前值:" + code); + } + } + probe.setUserIdList(userIds); + } + if (nameKeyword != null) { + probe.setUserName(nameKeyword); + } + if (status != null) { + probe.setStatus(String.valueOf(status)); + } + List all = sysUserMapper.selectUserList(probe); + if (all == null) { + all = Collections.emptyList(); + } + List filtered = new ArrayList<>(); + for (SysUser user : all) { + if (user == null || user.getUserId() == null) { + continue; + } + if (cursor != null) { + Long lastId = cursor.longKey(0); + if (lastId != null && user.getUserId() <= lastId) { + continue; + } + } + filtered.add(user); + } + filtered.sort(Comparator.comparing(SysUser::getUserId)); + + EntityResult result = new EntityResult("user_id"); + result.hasMore = McpPages.hasMore(filtered, pageSize); + List page = McpPages.trim(filtered, pageSize); + for (SysUser user : page) { + result.items.add(toUserItem(user)); + } + if (!page.isEmpty()) { + result.lastSortKeys = keysOf(page.get(page.size() - 1).getUserId()); + } + if (includeTotal) { + applyTotal(result, filtered.size()); + } + return result; + } + + /** + * WAREHOUSE:include_disabled=false 用 selectOmsWarehouseInfoList(仅正常仓),true 用 listAll(含停用仓); + * oms_warehouse_info 仅 14 行,按 warehouse_code,id 在内存中 keyset 分页。 + */ + private EntityResult runWarehouse(List codeList, String nameKeyword, Integer status, boolean includeDisabled, + boolean includeTotal, int pageSize, McpCursor cursor) { + OmsWarehouseInfo probe = new OmsWarehouseInfo(); + if (nameKeyword != null) { + probe.setWarehouseName(nameKeyword); + } + List all = includeDisabled + ? omsWarehouseInfoMapper.listAll(probe) + : omsWarehouseInfoMapper.selectOmsWarehouseInfoList(probe); + if (all == null) { + all = Collections.emptyList(); + } + String statusText = status == null ? null : String.valueOf(status); + String lastCode = cursor == null ? null : cursor.key(0); + Long lastId = cursor == null ? null : cursor.longKey(1); + + List filtered = new ArrayList<>(); + for (OmsWarehouseInfo warehouse : all) { + if (warehouse == null) { + continue; + } + String code = McpFormat.trim(warehouse.getWarehouseCode()); + if (code == null) { + code = ""; + } + if (!codeList.isEmpty() && !codeList.contains(code)) { + continue; + } + if (statusText != null && !statusText.equals(McpFormat.trim(warehouse.getWarehouseStatus()))) { + continue; + } + if (lastId != null) { + String lastCodeText = lastCode == null ? "" : lastCode; + int compare = code.compareTo(lastCodeText); + if (compare < 0 || (compare == 0 && (warehouse.getId() == null || warehouse.getId() <= lastId))) { + continue; + } + } + filtered.add(warehouse); + } + filtered.sort(Comparator + .comparing((OmsWarehouseInfo item) -> item.getWarehouseCode() == null ? "" + : item.getWarehouseCode().trim()) + .thenComparing(item -> item.getId() == null ? 0L : item.getId())); + + EntityResult result = new EntityResult("warehouse_code,id"); + result.hasMore = McpPages.hasMore(filtered, pageSize); + List page = McpPages.trim(filtered, pageSize); + for (OmsWarehouseInfo warehouse : page) { + result.items.add(toWarehouseItem(warehouse)); + } + if (!page.isEmpty()) { + OmsWarehouseInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(McpFormat.trim(last.getWarehouseCode()), last.getId()); + } + if (includeTotal) { + applyTotal(result, filtered.size()); + } + return result; + } + + // ------------------------------------------------------------------ 行映射 + + private Map toPartnerItem(PartnerInfo row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("partnerCode", McpFormat.trim(row.getPartnerCode())); + item.put("partnerName", row.getPartnerName()); + item.put("province", row.getProvince()); + item.put("city", row.getCity()); + item.put("address", row.getAddress()); + item.put("contactPerson", row.getContactPerson()); + item.put("contactPhone", row.getContactPhone()); + item.put("contactEmail", row.getContactEmail()); + item.put("level", row.getLevel()); + item.put("systemUserId", McpFormat.trim(row.getSystemUserId())); + item.put("systemUserName", row.getSystemUserName()); + item.put("status", row.getStatus()); + item.put("payName", row.getPayName()); + item.put("payBankNumber", row.getPayBankNumber()); + item.put("payBankOpenAddress", row.getPayBankOpenAddress()); + item.put("bankNumber", row.getBankNumber()); + item.put("socialCredit", row.getSocialCredit()); + item.put("createAt", McpFormat.dateTime(row.getCreateAt())); + return item; + } + + private Map toCustomerItem(CustomerInfo row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("customerCode", McpFormat.trim(row.getCustomerCode())); + item.put("customerName", row.getCustomerName()); + item.put("customerPostcode", row.getCustomerPostcode()); + item.put("province", row.getProvince()); + item.put("city", row.getCity()); + item.put("address", row.getAddress()); + item.put("contactPerson", row.getContactPerson()); + item.put("contactPhone", row.getContactPhone()); + item.put("contactEmail", row.getContactEmail()); + item.put("bgProperty", row.getBgProperty()); + item.put("industryType", row.getIndustryType()); + item.put("status", row.getStatus()); + item.put("createAt", McpFormat.dateTime(row.getCreateAt())); + item.put("updateAt", McpFormat.dateTime(row.getUpdateAt())); + return item; + } + + private Map toAgentItem(AgentInfo row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("agentCode", McpFormat.trim(row.getAgentCode())); + item.put("agentName", row.getAgentName()); + item.put("province", row.getProvince()); + item.put("city", row.getCity()); + item.put("address", row.getAddress()); + item.put("contactPerson", row.getContactPerson()); + item.put("contactPhone", row.getContactPhone()); + item.put("contactEmail", row.getContactEmail()); + item.put("status", row.getStatus()); + item.put("createAt", McpFormat.dateTime(row.getCreateAt())); + return item; + } + + private Map toVendorItem(VendorInfo row) { + Map item = new LinkedHashMap<>(); + item.put("vendorId", row.getVendorId()); + item.put("vendorCode", McpFormat.trim(row.getVendorCode())); + item.put("vendorName", row.getVendorName()); + item.put("vendorAddress", row.getVendorAddress()); + item.put("vendorUser", row.getVendorUser()); + item.put("vendorEmail", row.getVendorEmail()); + item.put("vendorPhone", row.getVendorPhone()); + item.put("vendorStatus", row.getVendorStatus()); + item.put("vendorStatusName", vendorStatusName(row.getVendorStatus())); + item.put("warehouseId", McpFormat.trim(row.getWarehouseId())); + item.put("warehouseName", row.getWarehouseName()); + item.put("ownWarehouseId", row.getOwnWarehouseId()); + item.put("payType", row.getPayType()); + item.put("payTypeName", payTypeName(row.getPayType())); + item.put("payConfigDay", row.getPayConfigDay()); + item.put("payName", row.getPayName()); + item.put("payBankNumber", row.getPayBankNumber()); + item.put("payBankOpenAddress", row.getPayBankOpenAddress()); + item.put("bankNumber", row.getBankNumber()); + item.put("socialCredit", row.getSocialCredit()); + item.put("province", row.getProvince()); + item.put("city", row.getCity()); + item.put("generatedAddress", row.getGeneratedAddress()); + return item; + } + + private Map toProductItem(ProductInfo row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("hzCode", row.getHzCode()); + item.put("productName", row.getProductName()); + item.put("model", row.getModel()); + item.put("type", row.getType()); + item.put("typeName", productTypeName(row.getType())); + item.put("level2Type", row.getLevel2Type()); + item.put("level2TypeName", productLevel2TypeName(row.getType(), row.getLevel2Type())); + item.put("value", row.getValue()); + item.put("cataloguePrice", row.getCataloguePrice()); + item.put("guidanceDiscount", row.getGuidanceDiscount()); + item.put("vendorCode", McpFormat.trim(row.getVendorCode())); + item.put("vendorName", row.getVendorName()); + item.put("availableCount", row.getAvailableCount()); + item.put("cumulativeCount", row.getCumulativeCount()); + item.put("description", row.getDescription()); + item.put("remark", row.getRemark()); + item.put("createdAt", McpFormat.dateTime(row.getCreatedAt())); + item.put("updatedAt", McpFormat.dateTime(row.getUpdatedAt())); + return item; + } + + private Map toUserItem(SysUser row) { + Map item = new LinkedHashMap<>(); + item.put("userId", row.getUserId()); + item.put("loginName", row.getLoginName()); + item.put("userName", row.getUserName()); + item.put("deptId", row.getDeptId()); + item.put("deptName", row.getDept() == null ? null : row.getDept().getDeptName()); + item.put("email", row.getEmail()); + item.put("phonenumber", row.getPhonenumber()); + item.put("status", row.getStatus()); + return item; + } + + private Map toWarehouseItem(OmsWarehouseInfo row) { + Map item = new LinkedHashMap<>(); + item.put("warehouseId", row.getId()); + item.put("warehouseCode", McpFormat.trim(row.getWarehouseCode())); + item.put("warehouseName", row.getWarehouseName()); + item.put("warehouseType", row.getWarehouseType()); + item.put("warehouseTypeName", warehouseTypeName(row.getWarehouseType())); + item.put("warehouseStatus", row.getWarehouseStatus()); + item.put("warehouseStatusName", warehouseStatusName(row.getWarehouseStatus())); + item.put("address", row.getAddress()); + item.put("managerName", row.getManagerName()); + item.put("managerPhone", row.getManagerPhone()); + item.put("managerEmail", row.getManagerEmail()); + item.put("remark", row.getRemark()); + return item; + } + + private Map toCompanyItem(OmsCompanyInfo row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("companyCode", McpFormat.trim(row.getCompanyCode())); + item.put("companyName", row.getCompanyName()); + item.put("companyUser", row.getCompanyUser()); + item.put("companyEmail", row.getCompanyEmail()); + item.put("companyPhone", row.getCompanyPhone()); + item.put("companyAddress", row.getCompanyAddress()); + item.put("payName", row.getPayName()); + item.put("payBankNumber", row.getPayBankNumber()); + item.put("payBankOpenAddress", row.getPayBankOpenAddress()); + item.put("bankNumber", row.getBankNumber()); + item.put("socialCredit", row.getSocialCredit()); + return item; + } + + // ------------------------------------------------------------------ 字典翻译 + + private String productTypeName(String type) { + if (type == null) { + return null; + } + for (ProductInfo.ProductTypeEnum value : ProductInfo.ProductTypeEnum.values()) { + if (value.getType().equals(type.trim())) { + return value.getDesc(); + } + } + return type; + } + + /** + * 二级类型需结合一级类型选择枚举:1/11 软件类、2/22 硬件类、3/70 服务类。 + */ + private String productLevel2TypeName(String type, String level2Type) { + if (type == null || level2Type == null) { + return level2Type == null ? null : level2Type; + } + String level = level2Type.trim(); + if ("1".equals(type.trim()) || "11".equals(type.trim())) { + for (ProductInfo.ProductTypeLevel2SoftwareEnum value : ProductInfo.ProductTypeLevel2SoftwareEnum.values()) { + if (value.getType().equals(level)) { + return value.getDesc(); + } + } + } else if ("2".equals(type.trim()) || "22".equals(type.trim())) { + for (ProductInfo.ProductTypeLevel2HardwareEnum value : ProductInfo.ProductTypeLevel2HardwareEnum.values()) { + if (value.getType().equals(level)) { + return value.getDesc(); + } + } + } else if ("3".equals(type.trim()) || "70".equals(type.trim())) { + for (ProductInfo.ProductTypeLevel2ServiceEnum value : ProductInfo.ProductTypeLevel2ServiceEnum.values()) { + if (value.getType().equals(level)) { + return value.getDesc(); + } + } + } + return level; + } + + private String vendorStatusName(String status) { + if (status == null) { + return null; + } + // 注意:VendorInfo.VendorStatusEnum 的 value 是编码、code 是中文描述(既有类命名如此) + for (VendorInfo.VendorStatusEnum value : VendorInfo.VendorStatusEnum.values()) { + if (value.getValue().equals(status.trim())) { + return value.getCode(); + } + } + return status; + } + + private String payTypeName(Integer payType) { + if (payType == null) { + return null; + } + for (VendorInfo.PayTypeEnum value : VendorInfo.PayTypeEnum.values()) { + if (value.getCode().equals(payType)) { + return value.getDesc(); + } + } + return String.valueOf(payType); + } + + private String warehouseStatusName(String status) { + if (status == null) { + return null; + } + for (OmsWarehouseInfo.WarehouseStatusEnum value : OmsWarehouseInfo.WarehouseStatusEnum.values()) { + if (value.getValue().equals(status.trim())) { + return value.getDesc(); + } + } + return status; + } + + private String warehouseTypeName(String type) { + if (type == null) { + return null; + } + // 注意:OmsWarehouseInfo.WarehouseTypeEnum 的 value 是编码、code 是中文描述(既有类命名如此) + for (OmsWarehouseInfo.WarehouseTypeEnum value : OmsWarehouseInfo.WarehouseTypeEnum.values()) { + if (value.getValue().equals(type.trim())) { + return value.getCode(); + } + } + return type; + } + + // ------------------------------------------------------------------ 辅助 + + private void putCodeCursor(Map q, McpCursor cursor) { + if (cursor != null) { + q.put("lastCode", cursor.key(0)); + q.put("lastId", cursor.longKey(1)); + } + } + + private Map countQuery(Map q, String... cursorKeys) { + Map copy = new HashMap<>(q); + if (cursorKeys != null) { + for (String key : cursorKeys) { + copy.remove(key); + } + } + copy.put("limit", COUNT_CAP + 1); + return copy; + } + + private void applyTotal(EntityResult result, long counted) { + result.totalCapped = counted > COUNT_CAP; + result.total = result.totalCapped ? COUNT_CAP : counted; + } + + private String sortedJoin(List values) { + if (values == null || values.isEmpty()) { + return ""; + } + return String.join(",", new TreeSet<>(values)); + } + + private Map queryEcho(String entity, List codeList, String nameKeyword, Integer status, + String type, boolean includeDisabled, boolean includeTotal, int pageSize, + int pageNo) { + Map query = new LinkedHashMap<>(); + query.put("entity", entity); + query.put("code_list", codeList); + query.put("name_keyword", nameKeyword); + query.put("status", status); + query.put("type", type); + query.put("include_disabled", includeDisabled); + query.put("include_total", includeTotal); + query.put("page_size", pageSize); + query.put("page_no", pageNo); + return query; + } + + private Map buildMetadata(String entity) { + Map metadata = metadata("主数据列表 / 批量编码→名称翻译(entity=" + entity + + ");排序键为各 entity 主编码 + 唯一键,游标分页(不重不漏)。"); + metadata.put("entity_fields", mapData( + "PARTNER", "进货商 partner_info(权限来源 PartnerInfoController#list)", + "CUSTOMER", "客户 customer_info(CustomerInfoController#list)", + "AGENT", "代表处 agent_info(AgentInfoController#list)", + "VENDOR", "制造商 oms_vendor_info(VendorInfoController#list;注意不采用冗余表 vendor_info)", + "PRODUCT", "产品 product_info(ProductInfoController#list)", + "USER", "系统用户 sys_user(SysUserController#list)", + "WAREHOUSE", "仓库 oms_warehouse_info(OmsWarehouseInfoController#list)", + "COMPANY", "己方公司主体 oms_company_info(OmsCompanyInfoController#list,当前 0 行)")); + metadata.put("data_fields", mapData( + "total", "include_total=true 时为命中总条数(否则为当前页条数),受 count_cap=" + COUNT_CAP + " 限制", + "items", "主数据列表", + "page_info", "分页信息;has_more=true 时必须回传 next_cursor")); + metadata.put("item_fields", itemFields()); + metadata.put("enum_fields", mapData( + "PRODUCT.type / typeName", "枚举 ProductInfo.ProductTypeEnum(1软件/2硬件/3服务/11软件维保/22硬件维保/70省代服务/99其它)", + "PRODUCT.level2Type / level2TypeName", "枚举 ProductInfo.ProductTypeLevel2{Software|Hardware|Service}Enum(按一级类型选择)", + "VENDOR.vendorStatus / vendorStatusName", "枚举 VendorInfo.VendorStatusEnum(0正常合作/1暂停合作)", + "VENDOR.payType / payTypeName", "枚举 VendorInfo.PayTypeEnum(0入库付款/1出库付款)", + "WAREHOUSE.warehouseType / warehouseTypeName", "枚举 OmsWarehouseInfo.WarehouseTypeEnum(0实体仓/1虚拟仓)", + "WAREHOUSE.warehouseStatus / warehouseStatusName", "枚举 OmsWarehouseInfo.WarehouseStatusEnum(0正常/1停用)", + "PARTNER.level", "字典 identify_level(本工具只回显编码,未做字典翻译,字典值待确认)", + "USER.status", "字符状态:0正常/1停用")); + metadata.put("typical_usage", "把其它工具返回的编码批量翻译成名称:把 partnerCode/vendorCode/productCode/" + + "userId/warehouseCode/companyCode 放进 code_list 一次取回(走 in 批量查询,不逐条查询)。"); + metadata.put("notes", "编码类字段入参与出参统一 trim;USER 与 WAREHOUSE 复用既有查询后在内存中做 keyset 分页" + + "(sys_user 约 119 行、oms_warehouse_info 14 行);include_disabled 仅对 WAREHOUSE 生效;" + + "编码匹配不区分大小写(utf8mb4_unicode_ci 排序规则)。"); + metadata.put("permission_source", permissionSource(entity)); + return metadata; + } + + private Map itemFields() { + return mapData( + "id", "主键(PARTNER/CUSTOMER/AGENT/PRODUCT/COMPANY)", + "partnerCode", "进货商编码(PARTNER)", + "partnerName", "进货商名称(PARTNER)", + "customerCode", "客户编码(CUSTOMER)", + "customerName", "客户名称(CUSTOMER)", + "customerPostcode", "客户邮编(CUSTOMER)", + "agentCode", "代表处编码(AGENT)", + "agentName", "代表处名称(AGENT)", + "vendorId", "制造商ID(VENDOR)", + "vendorCode", "制造商编码(VENDOR;PRODUCT 中为所属制造商编码)", + "vendorName", "制造商名称(VENDOR;PRODUCT 中为所属制造商名称)", + "vendorAddress", "生产地址(VENDOR)", + "vendorUser", "制造商联系人(VENDOR)", + "vendorEmail", "制造商邮箱(VENDOR)", + "vendorPhone", "制造商电话(VENDOR)", + "vendorStatus", "合作状态编码(0正常合作/1暂停合作)(VENDOR)", + "vendorStatusName", "合作状态名称(VENDOR)", + "warehouseId", "绑定仓库ID(VENDOR)/ 仓库ID(WAREHOUSE)", + "warehouseName", "绑定仓库名称(VENDOR)/ 仓库名称(WAREHOUSE)", + "ownWarehouseId", "自有仓库ID(逗号分隔字符串)(VENDOR)", + "payType", "付款方式编码(0入库付款/1出库付款)(VENDOR)", + "payTypeName", "付款方式名称(VENDOR)", + "payConfigDay", "付款账期天数(VENDOR)", + "generatedAddress", "生成地址(VENDOR)", + "productCode", "产品编码(PRODUCT)", + "hzCode", "华智编码(PRODUCT)", + "productName", "产品名称(PRODUCT)", + "model", "产品型号(PRODUCT)", + "type", "产品一级类型编码(PRODUCT)", + "typeName", "产品一级类型名称(PRODUCT)", + "level2Type", "产品二级类型编码(PRODUCT)", + "level2TypeName", "产品二级类型名称(PRODUCT)", + "value", "产品属性值(PRODUCT)", + "cataloguePrice", "目录单价(PRODUCT)", + "guidanceDiscount", "指导折扣(PRODUCT)", + "availableCount", "实时可用库存(PRODUCT)", + "cumulativeCount", "累计发货(PRODUCT)", + "description", "产品描述(PRODUCT)", + "userId", "用户ID(USER)", + "loginName", "登录名(USER)", + "userName", "姓名(USER)", + "deptId", "部门ID(USER)", + "deptName", "部门名称(USER)", + "email", "邮箱(USER)", + "phonenumber", "手机号(USER)", + "warehouseCode", "仓库编码(WAREHOUSE)", + "warehouseType", "仓库类型编码(0实体仓/1虚拟仓)(WAREHOUSE)", + "warehouseTypeName", "仓库类型名称(WAREHOUSE)", + "warehouseStatus", "仓库状态编码(0正常/1停用)(WAREHOUSE)", + "warehouseStatusName", "仓库状态名称(WAREHOUSE)", + "managerName", "仓库管理员(WAREHOUSE)", + "managerPhone", "仓库管理员电话(WAREHOUSE)", + "managerEmail", "仓库管理员邮箱(WAREHOUSE)", + "companyCode", "公司编码(COMPANY)", + "companyName", "公司名称(COMPANY)", + "companyUser", "联系用户(COMPANY)", + "companyEmail", "联系邮箱(COMPANY)", + "companyPhone", "联系电话(COMPANY)", + "companyAddress", "公司地址(COMPANY)", + "province", "所在省", + "city", "所在市", + "address", "详细地址", + "contactPerson", "联系人", + "contactPhone", "联系电话", + "contactEmail", "联系邮箱", + "bgProperty", "BG属性(CUSTOMER)", + "industryType", "所属行业(CUSTOMER)", + "level", "进货商认证级别编码(PARTNER,字典 identify_level)", + "systemUserId", "绑定系统用户ID(PARTNER)", + "systemUserName", "绑定系统用户姓名(PARTNER)", + "status", "数据状态(PARTNER/CUSTOMER/AGENT:0有效;USER:0正常/1停用;PRODUCT 的 status 仅作入参过滤,不回显)", + "payName", "账户名称(PARTNER/VENDOR/COMPANY 己方账户信息)", + "payBankNumber", "银行卡号(PARTNER/VENDOR/COMPANY)", + "payBankOpenAddress", "银行开户行(PARTNER/VENDOR/COMPANY)", + "bankNumber", "银行行号(PARTNER/VENDOR/COMPANY)", + "socialCredit", "统一社会信用代码(PARTNER/VENDOR/COMPANY)", + "remark", "备注", + "createAt", "创建时间", + "updateAt", "更新时间", + "createdAt", "创建时间(PRODUCT)", + "updatedAt", "更新时间(PRODUCT)"); + } + + private String permissionSource(String entity) { + switch (entity) { + case "PARTNER": + return "PartnerInfoController#list:system:partner:list(已抄取)"; + case "CUSTOMER": + return "CustomerInfoController#list:system:customer:list(已抄取)"; + case "AGENT": + return "AgentInfoController#list:system:agent:list(已抄取)"; + case "VENDOR": + return "VendorInfoController#list:system:vendor:list(已抄取)"; + case "PRODUCT": + return "ProductInfoController#list / VueProductInfoController#list:system:product:list(已抄取)"; + case "USER": + return "SysUserController#list:system:user:list(已抄取)"; + case "WAREHOUSE": + return "OmsWarehouseInfoController#list:warehouse:info:list(已抄取)"; + case "COMPANY": + return "OmsCompanyInfoController#list:sip:companyInfo:list(已抄取)"; + default: + return "未确定(待确认)"; + } + } + + private String[] permissionsOf(String entity) { + switch (entity) { + case "PARTNER": + return new String[]{"system:partner:list"}; + case "CUSTOMER": + return new String[]{"system:customer:list"}; + case "AGENT": + return new String[]{"system:agent:list"}; + case "VENDOR": + return new String[]{"system:vendor:list"}; + case "PRODUCT": + return new String[]{"system:product:list"}; + case "USER": + return new String[]{"system:user:list"}; + case "WAREHOUSE": + return new String[]{"warehouse:info:list"}; + case "COMPANY": + return new String[]{"sip:companyInfo:list"}; + default: + return new String[0]; + } + } + + /** entity 查询结果载体 */ + private static class EntityResult { + private final List> items = new ArrayList<>(); + private final String sortBy; + private boolean hasMore; + private List lastSortKeys = new ArrayList<>(); + private Long total; + private boolean totalCapped; + + private EntityResult(String sortBy) { + this.sortBy = sortBy; + } + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/ProjectListToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/ProjectListToolProvider.java new file mode 100644 index 00000000..e7c9abaa --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/ProjectListToolProvider.java @@ -0,0 +1,1170 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.sip.domain.OrderInfo; +import com.ruoyi.sip.domain.OrderList; +import com.ruoyi.sip.domain.ProjectInfo; +import com.ruoyi.sip.domain.ProjectPocInfo; +import com.ruoyi.sip.domain.ProjectPocInfoDetail; +import com.ruoyi.sip.domain.ProjectProductInfo; +import com.ruoyi.sip.domain.ProjectWorkProgress; +import com.ruoyi.sip.domain.Quotation; +import com.ruoyi.sip.domain.QuotationProductInfo; +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpCursor; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpPages; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpTimeRange; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.OrderInfoMapper; +import com.ruoyi.sip.mapper.ProjectInfoMapper; +import com.ruoyi.sip.mapper.ProjectPocInfoMapper; +import com.ruoyi.sip.mapper.ProjectProductInfoMapper; +import com.ruoyi.sip.mapper.ProjectWorkProgressMapper; +import com.ruoyi.sip.mapper.QuotationMapper; +import com.ruoyi.sip.mapper.QuotationProductInfoMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * MCP 工具 project_list(项目 / 项目产品 / 进度 / POC / 报价单 / manage 域合同与合同明细,游标分页,entity 参数化)。 + * + *

方案依据:docx/mcp-data-tools-plan.md 第 4.4、5、7(第 17/21/22 条)、8.2、15.14、16.1 与附录 A.12 写法。

+ * + *

权限串来源(逐个 entity 从对应 Controller 的 {@code @RequiresPermissions} 抄取): + * PROJECT/PROJECT_PRODUCT/PROGRESS/POC→ProjectInfoController#list 与 VueProjectInfoController#list(sip:project:list)、 + * QUOTATION→QuotationController#list(sip:quotation:list)、 + * CONTRACT/CONTRACT_PRODUCT→OrderInfoController#list(manage:order:list)。

+ */ +@Component +public class ProjectListToolProvider extends AbstractMcpQueryToolProvider { + + private static final Set ENTITIES = new LinkedHashSet<>(Arrays.asList( + "PROJECT", "PROJECT_PRODUCT", "PROGRESS", "POC", "QUOTATION", "CONTRACT", "CONTRACT_PRODUCT")); + + private static final int DEFAULT_MONTHS = 12; + private static final int MAX_MONTHS = 36; + private static final long COUNT_CAP = 50000L; + + @Autowired + private ProjectInfoMapper projectInfoMapper; + + @Autowired + private ProjectProductInfoMapper projectProductInfoMapper; + + @Autowired + private ProjectWorkProgressMapper projectWorkProgressMapper; + + @Autowired + private ProjectPocInfoMapper projectPocInfoMapper; + + @Autowired + private QuotationMapper quotationMapper; + + @Autowired + private QuotationProductInfoMapper quotationProductInfoMapper; + + @Autowired + private OrderInfoMapper orderInfoMapper; + + @Override + protected String getToolName() { + return "project_list"; + } + + @Override + protected String getToolDescription() { + return "项目域列表 / 范围查询(只读)。entity=PROJECT 项目 / PROJECT_PRODUCT 项目产品 / PROGRESS 项目进度 / " + + "POC POC测试 / QUOTATION 报价单 / CONTRACT manage 域合同 / CONTRACT_PRODUCT 合同明细。" + + "支持按项目编号、合同编号、客户、代表处、名称模糊、时间区间过滤;include_detail=true 时返回明细。" + + "注意:manage 域合同(order_info)与项目域(project_order_info)只能按 order_code 部分对齐," + + "禁止按 id 跨体系关联,关联不到不会报错。" + + "分页查询工具:若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 继续调用直到 " + + "has_more 为 false,否则数据不完整。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("entity", enumProperty("查询实体(必填):PROJECT=项目 / PROJECT_PRODUCT=项目产品 / " + + "PROGRESS=项目进度 / POC=POC测试 / QUOTATION=报价单 / CONTRACT=manage 域合同 / CONTRACT_PRODUCT=合同明细", + "PROJECT", "PROJECT_PRODUCT", "PROGRESS", "POC", "QUOTATION", "CONTRACT", "CONTRACT_PRODUCT")); + properties.put("code_list", arrayProperty("编码列表,≤" + McpPages.MAX_CODE_LIST + ":" + + "PROJECT→project_code(项目编号)/ PROJECT_PRODUCT、PROGRESS、POC→项目编号(等价 project_code_list)/ " + + "QUOTATION→quotation_code(报价单号)/ CONTRACT→order_code(合同编号,按 trim 对齐)/ " + + "CONTRACT_PRODUCT→合同ID(数字)")); + properties.put("order_code_list", arrayProperty("合同编号列表,≤" + McpPages.MAX_CODE_LIST + + ":CONTRACT 直接过滤;CONTRACT_PRODUCT 先查 order_info 得到合同ID")); + properties.put("project_code_list", arrayProperty("项目编号列表,≤" + McpPages.MAX_CODE_LIST + + ";用于 PROJECT / PROJECT_PRODUCT / PROGRESS / POC")); + properties.put("customer_code_list", arrayProperty("客户编码列表,≤" + McpPages.MAX_CODE_LIST + + ";用于 PROJECT / CONTRACT")); + properties.put("agent_code_list", arrayProperty("代表处(办事处)编码列表,≤" + McpPages.MAX_CODE_LIST + + ";用于 PROJECT / QUOTATION / CONTRACT")); + properties.put("product_code_list", arrayProperty("产品编码列表,≤" + McpPages.MAX_CODE_LIST + + ";PROJECT_PRODUCT 按 product_bom_code 匹配,CONTRACT_PRODUCT 按 product_code 匹配")); + properties.put("name_keyword", stringProperty("名称模糊匹配:PROJECT→project_name / QUOTATION→quotation_name / " + + "CONTRACT→order_name;其它 entity 不支持")); + properties.put("time_range", stringProperty("时间区间,格式 start~end(yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss);" + + "时间字段:PROJECT=create_time、PROGRESS=work_time(可选)、QUOTATION=create_time、CONTRACT=order_date;" + + "默认近 " + DEFAULT_MONTHS + " 个月,跨度上限 " + MAX_MONTHS + " 个月;" + + "PROJECT_PRODUCT / POC / CONTRACT_PRODUCT 所在表无本次查询使用的时间字段,传入会报错")); + properties.put("include_detail", boolProperty("是否返回明细,默认 false:CONTRACT 附 order_list 合同清单、" + + "QUOTATION 附报价产品行、POC 附测试进展明细;其它 entity 无明细")); + properties.put("include_total", boolProperty("是否统计总条数,默认 false;true 时受 count_cap=" + COUNT_CAP + + " 限制,超出置 total_count_capped=true")); + withPagingProperties(properties, McpPages.MAX_LIST_PAGE_SIZE, McpPages.DEFAULT_PAGE_SIZE); + return objectSchema(properties, "entity"); + } + + @Override + protected Object handle(Map params) { + String entity = McpParams.requireEnum(params, "entity", ENTITIES); + McpAuth.requireAnyPermission(permissionsOf(entity)); + + int pageSize = McpPages.pageSize(params, McpPages.DEFAULT_PAGE_SIZE, McpPages.MAX_LIST_PAGE_SIZE); + List codeList = McpParams.codeList(params, "code_list", McpPages.MAX_CODE_LIST); + List orderCodeList = McpParams.codeList(params, "order_code_list", McpPages.MAX_CODE_LIST); + List projectCodeList = McpParams.codeList(params, "project_code_list", McpPages.MAX_CODE_LIST); + List customerCodeList = McpParams.codeList(params, "customer_code_list", McpPages.MAX_CODE_LIST); + List agentCodeList = McpParams.codeList(params, "agent_code_list", McpPages.MAX_CODE_LIST); + List productCodeList = McpParams.codeList(params, "product_code_list", McpPages.MAX_CODE_LIST); + String nameKeyword = McpParams.str(params, "name_keyword"); + boolean includeDetail = McpParams.boolVal(params, "include_detail", false); + boolean includeTotal = McpParams.boolVal(params, "include_total", false); + McpTimeRange range = resolveTimeRange(entity, params); + + String filterHash = McpParams.filterHash(getToolName(), entity, sortedJoin(codeList), sortedJoin(orderCodeList), + sortedJoin(projectCodeList), sortedJoin(customerCodeList), sortedJoin(agentCodeList), + sortedJoin(productCodeList), nameKeyword, McpParams.describe(range), String.valueOf(includeDetail)); + McpCursor cursor = McpPages.cursor(params, getToolName(), filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + int pageNo = cursor == null ? 1 : cursor.getP(); + + EntityResult result = runEntity(entity, codeList, orderCodeList, projectCodeList, customerCodeList, + agentCodeList, productCodeList, nameKeyword, range, includeDetail, includeTotal, pageSize, cursor); + + Map pageInfo = McpPages.pageInfo(result.items.size(), pageSize, result.hasMore, + result.hasMore ? McpCursor.encode(getToolName(), filterHash, pageNo + 1, result.lastSortKeys) : null, + result.sortBy, pageNo, result.total, result.totalCapped, false); + + Map data = listData(result.items, pageInfo, result.total); + Map query = new LinkedHashMap<>(); + query.put("entity", entity); + query.put("code_list", codeList); + query.put("order_code_list", orderCodeList); + query.put("project_code_list", projectCodeList); + query.put("customer_code_list", customerCodeList); + query.put("agent_code_list", agentCodeList); + query.put("product_code_list", productCodeList); + query.put("name_keyword", nameKeyword); + query.put("time_field", timeFieldOf(entity)); + query.put("time_range", McpParams.describe(range)); + query.put("include_detail", includeDetail); + query.put("include_total", includeTotal); + query.put("page_size", pageSize); + query.put("page_no", pageNo); + return response(buildMetadata(entity), query, data); + } + + // ------------------------------------------------------------------ entity 分发 + + private EntityResult runEntity(String entity, List codeList, List orderCodeList, + List projectCodeList, List customerCodeList, + List agentCodeList, List productCodeList, String nameKeyword, + McpTimeRange range, boolean includeDetail, boolean includeTotal, int pageSize, + McpCursor cursor) { + switch (entity) { + case "PROJECT": + return runProject(codeList, customerCodeList, agentCodeList, nameKeyword, range, includeTotal, + pageSize, cursor); + case "PROJECT_PRODUCT": + return runProjectProduct(codeList, projectCodeList, productCodeList, includeTotal, pageSize, cursor); + case "PROGRESS": + return runProgress(codeList, projectCodeList, range, includeTotal, pageSize, cursor); + case "POC": + return runPoc(codeList, projectCodeList, includeDetail, includeTotal, pageSize, cursor); + case "QUOTATION": + return runQuotation(codeList, agentCodeList, nameKeyword, range, includeDetail, includeTotal, + pageSize, cursor); + case "CONTRACT": + return runContract(codeList, orderCodeList, customerCodeList, agentCodeList, nameKeyword, range, + includeDetail, includeTotal, pageSize, cursor); + case "CONTRACT_PRODUCT": + return runContractProduct(codeList, orderCodeList, productCodeList, includeTotal, pageSize, cursor); + default: + throw McpToolException.invalidParams("不支持的 entity:" + entity); + } + } + + private EntityResult runProject(List codeList, List customerCodeList, List agentCodeList, + String nameKeyword, McpTimeRange range, boolean includeTotal, int pageSize, + McpCursor cursor) { + Map q = new HashMap<>(); + q.put("codeList", codeList); + q.put("nameKeyword", nameKeyword); + q.put("customerCodeList", customerCodeList); + q.put("agentCodeList", agentCodeList); + q.put("startTime", range.getStart()); + q.put("endTime", range.getEnd()); + q.put("limit", pageSize + 1); + putCodeCursor(q, cursor); + + List rows = projectInfoMapper.listProjectPage(q); + EntityResult result = new EntityResult("project_code,id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (ProjectInfo row : page) { + result.items.add(toProjectItem(row)); + } + if (!page.isEmpty()) { + ProjectInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(McpFormat.trim(last.getProjectCode()), last.getId()); + } + if (includeTotal) { + applyTotal(result, count(projectInfoMapper.listProjectPage(countQuery(q, "lastCode", "lastId")))); + } + return result; + } + + private EntityResult runProjectProduct(List codeList, List projectCodeList, + List productCodeList, boolean includeTotal, int pageSize, + McpCursor cursor) { + List projectCodes = merge(projectCodeList, codeList); + McpParams.reject(projectCodes.isEmpty(), "entity=PROJECT_PRODUCT 必须给出 project_code_list 或 code_list(项目编码)," + + "否则会退化为全表扫描"); + List projectIds = resolveProjectIds(projectCodes); + if (projectIds.isEmpty()) { + return EntityResult.empty("project_id,id"); + } + Map q = new HashMap<>(); + q.put("projectIdList", projectIds); + q.put("productCodeList", productCodeList); + q.put("limit", pageSize + 1); + if (cursor != null) { + q.put("lastProjectId", cursor.longKey(0)); + q.put("lastId", cursor.longKey(1)); + } + + List rows = projectProductInfoMapper.listProjectProductPage(q); + EntityResult result = new EntityResult("project_id,id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (ProjectProductInfo row : page) { + result.items.add(toProjectProductItem(row)); + } + if (!page.isEmpty()) { + ProjectProductInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getProjectId(), last.getId()); + } + if (includeTotal) { + applyTotal(result, + count(projectProductInfoMapper.listProjectProductPage(countQuery(q, "lastProjectId", "lastId")))); + } + return result; + } + + /** + * PROGRESS:project_work_progress 仅 7 行,复用既有 selectProjectWorkProgressListByProjectId + * 后在内存中按 project_id,id 做 keyset 分页(方案允许小表复用既有方法)。 + */ + private EntityResult runProgress(List codeList, List projectCodeList, McpTimeRange range, + boolean includeTotal, int pageSize, McpCursor cursor) { + List projectCodes = merge(projectCodeList, codeList); + McpParams.reject(projectCodes.isEmpty(), "entity=PROGRESS 必须给出 project_code_list 或 code_list(项目编码)," + + "否则会退化为全表扫描"); + List projectIds = resolveProjectIds(projectCodes); + if (projectIds.isEmpty()) { + return EntityResult.empty("project_id,id"); + } + List all = projectWorkProgressMapper.selectProjectWorkProgressListByProjectId(projectIds); + if (all == null) { + all = Collections.emptyList(); + } + Long lastProjectId = cursor == null ? null : cursor.longKey(0); + Long lastId = cursor == null ? null : cursor.longKey(1); + + List filtered = new ArrayList<>(); + for (ProjectWorkProgress row : all) { + if (row == null || row.getProjectId() == null || row.getId() == null) { + continue; + } + if (!range.isEmpty()) { + Date workTime = row.getWorkTime(); + if (workTime == null) { + continue; + } + if (range.getStart() != null && workTime.before(range.getStart())) { + continue; + } + if (range.getEnd() != null && !workTime.before(range.getEnd())) { + continue; + } + } + if (lastProjectId != null && (row.getProjectId() < lastProjectId + || (row.getProjectId().equals(lastProjectId) && (lastId == null || row.getId() <= lastId)))) { + continue; + } + filtered.add(row); + } + filtered.sort(Comparator.comparing(ProjectWorkProgress::getProjectId) + .thenComparing(ProjectWorkProgress::getId)); + + EntityResult result = new EntityResult("project_id,id"); + result.hasMore = McpPages.hasMore(filtered, pageSize); + List page = McpPages.trim(filtered, pageSize); + for (ProjectWorkProgress row : page) { + result.items.add(toProgressItem(row)); + } + if (!page.isEmpty()) { + ProjectWorkProgress last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getProjectId(), last.getId()); + } + if (includeTotal) { + applyTotal(result, filtered.size()); + } + return result; + } + + private EntityResult runPoc(List codeList, List projectCodeList, boolean includeDetail, + boolean includeTotal, int pageSize, McpCursor cursor) { + List projectCodes = merge(projectCodeList, codeList); + McpParams.reject(projectCodes.isEmpty(), "entity=POC 必须给出 project_code_list 或 code_list(项目编码)," + + "否则会退化为全表扫描"); + List projectIds = resolveProjectIds(projectCodes); + if (projectIds.isEmpty()) { + return EntityResult.empty("project_id,id"); + } + Map q = new HashMap<>(); + q.put("projectIdList", projectIds); + q.put("limit", pageSize + 1); + if (cursor != null) { + q.put("lastProjectId", cursor.longKey(0)); + q.put("lastId", cursor.longKey(1)); + } + + List rows = projectPocInfoMapper.listProjectPocPage(q); + EntityResult result = new EntityResult("project_id,id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + List pocIds = new ArrayList<>(); + for (ProjectPocInfo row : page) { + result.items.add(toPocItem(row)); + if (row.getId() != null) { + pocIds.add(row.getId()); + } + } + if (!page.isEmpty()) { + ProjectPocInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getProjectId(), last.getId()); + } + if (includeDetail && !pocIds.isEmpty()) { + Map>> detailMap = new LinkedHashMap<>(); + List details = projectPocInfoMapper.listPocDetailByPocIdList(pocIds); + if (details != null) { + for (ProjectPocInfoDetail detail : details) { + if (detail.getPocId() != null) { + detailMap.computeIfAbsent(detail.getPocId(), key -> new ArrayList<>()).add(toPocDetailItem(detail)); + } + } + } + for (Map item : result.items) { + Object id = item.get("pocId"); + Long key = id instanceof Number ? ((Number) id).longValue() : null; + item.put("testProgressDetails", + key == null ? Collections.emptyList() : detailMap.getOrDefault(key, Collections.emptyList())); + } + } + if (includeTotal) { + applyTotal(result, count(projectPocInfoMapper.listProjectPocPage(countQuery(q, "lastProjectId", "lastId")))); + } + return result; + } + + private EntityResult runQuotation(List codeList, List agentCodeList, String nameKeyword, + McpTimeRange range, boolean includeDetail, boolean includeTotal, int pageSize, + McpCursor cursor) { + Map q = new HashMap<>(); + q.put("codeList", codeList); + q.put("nameKeyword", nameKeyword); + q.put("agentCodeList", agentCodeList); + q.put("startTime", range.getStart()); + q.put("endTime", range.getEnd()); + q.put("limit", pageSize + 1); + putCodeCursor(q, cursor); + + List rows = quotationMapper.listQuotationPage(q); + EntityResult result = new EntityResult("quotation_code,id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + List quotationIds = new ArrayList<>(); + for (Quotation row : page) { + result.items.add(toQuotationItem(row)); + if (row.getId() != null) { + quotationIds.add(row.getId()); + } + } + if (!page.isEmpty()) { + Quotation last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(McpFormat.trim(last.getQuotationCode()), last.getId()); + } + if (includeDetail && !quotationIds.isEmpty()) { + Map>> detailMap = new LinkedHashMap<>(); + List details = quotationProductInfoMapper.listByQuotationId(quotationIds); + if (details != null) { + for (QuotationProductInfo detail : details) { + if (detail.getQuotationId() != null) { + detailMap.computeIfAbsent(detail.getQuotationId(), key -> new ArrayList<>()) + .add(toQuotationProductItem(detail)); + } + } + } + for (Map item : result.items) { + Object id = item.get("quotationId"); + Integer key = id instanceof Number ? ((Number) id).intValue() : null; + item.put("products", key == null ? Collections.emptyList() + : detailMap.getOrDefault(key, Collections.emptyList())); + } + } + if (includeTotal) { + applyTotal(result, count(quotationMapper.listQuotationPage(countQuery(q, "lastCode", "lastId")))); + } + return result; + } + + private EntityResult runContract(List codeList, List orderCodeList, + List customerCodeList, List agentCodeList, String nameKeyword, + McpTimeRange range, boolean includeDetail, boolean includeTotal, int pageSize, + McpCursor cursor) { + List orderCodes = merge(orderCodeList, codeList); + Map q = new HashMap<>(); + q.put("codeList", orderCodes); + q.put("nameKeyword", nameKeyword); + q.put("customerCodeList", customerCodeList); + q.put("agentCodeList", agentCodeList); + q.put("startTime", range.getStart()); + q.put("endTime", range.getEnd()); + q.put("limit", pageSize + 1); + if (cursor != null) { + q.put("lastCode", cursor.key(0)); + q.put("lastVersion", cursor.longKey(1)); + q.put("lastId", cursor.longKey(2)); + } + + List rows = orderInfoMapper.listContractPage(q); + EntityResult result = new EntityResult("order_code,version_code,id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + List orderIds = new ArrayList<>(); + for (OrderInfo row : page) { + result.items.add(toContractItem(row)); + if (row.getId() != null) { + orderIds.add(row.getId()); + } + } + if (!page.isEmpty()) { + OrderInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(McpFormat.trim(last.getOrderCode()), versionCodeAsLong(last.getVersionCode()), + last.getId()); + } + // 与项目域对齐(只能按 trim(order_code) 部分对齐,容忍关联不到,不报错也不臆造) + markProjectDomainAlignment(result.items, page); + if (includeDetail && !orderIds.isEmpty()) { + Map>> detailMap = new LinkedHashMap<>(); + List details = orderInfoMapper.listOrderListByOrderIdList(orderIds); + if (details != null) { + for (OrderList detail : details) { + if (detail.getOrderId() != null) { + detailMap.computeIfAbsent(detail.getOrderId(), key -> new ArrayList<>()) + .add(toContractProductItem(detail)); + } + } + } + for (Map item : result.items) { + Object id = item.get("orderId"); + Long key = id instanceof Number ? ((Number) id).longValue() : null; + item.put("orderItems", key == null ? Collections.emptyList() + : detailMap.getOrDefault(key, Collections.emptyList())); + } + } + if (includeTotal) { + applyTotal(result, count(orderInfoMapper.listContractPage( + countQuery(q, "lastCode", "lastVersion", "lastId")))); + } + return result; + } + + private EntityResult runContractProduct(List codeList, List orderCodeList, + List productCodeList, boolean includeTotal, int pageSize, + McpCursor cursor) { + List orderIds = new ArrayList<>(); + if (!orderCodeList.isEmpty()) { + orderIds.addAll(resolveOrderIds(orderCodeList)); + } + if (!codeList.isEmpty()) { + for (String code : codeList) { + try { + orderIds.add(Long.valueOf(code)); + } catch (NumberFormatException ex) { + throw McpToolException.invalidParams("entity=CONTRACT_PRODUCT 的 code_list 仅支持合同ID(数字)," + + "如要按合同编号查询请用 order_code_list;当前值:" + code); + } + } + } + McpParams.reject(orderIds.isEmpty() && productCodeList.isEmpty(), + "entity=CONTRACT_PRODUCT 必须给出 order_code_list(合同编号)或 code_list(合同ID)或 product_code_list," + + "否则会退化为全表扫描"); + + Map q = new HashMap<>(); + q.put("orderIdList", orderIds); + q.put("productCodeList", productCodeList); + q.put("limit", pageSize + 1); + if (cursor != null) { + q.put("lastOrderId", cursor.longKey(0)); + q.put("lastId", cursor.longKey(1)); + } + + List rows = orderInfoMapper.listContractProductPage(q); + EntityResult result = new EntityResult("order_id,id"); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (OrderList row : page) { + result.items.add(toContractProductItem(row)); + } + if (!page.isEmpty()) { + OrderList last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getOrderId(), last.getId()); + } + if (includeTotal) { + applyTotal(result, + count(orderInfoMapper.listContractProductPage(countQuery(q, "lastOrderId", "lastId")))); + } + return result; + } + + // ------------------------------------------------------------------ 关联解析 + + private List resolveProjectIds(List projectCodes) { + if (projectCodes == null || projectCodes.isEmpty()) { + return Collections.emptyList(); + } + Map q = new HashMap<>(); + q.put("codeList", projectCodes); + q.put("limit", projectCodes.size() + 1); + List rows = projectInfoMapper.listProjectPage(q); + List ids = new ArrayList<>(); + if (rows != null) { + for (ProjectInfo row : rows) { + if (row.getId() != null) { + ids.add(row.getId()); + } + } + } + return ids; + } + + private List resolveOrderIds(List orderCodes) { + if (orderCodes == null || orderCodes.isEmpty()) { + return Collections.emptyList(); + } + Map q = new HashMap<>(); + q.put("codeList", orderCodes); + q.put("limit", orderCodes.size() + 1); + List rows = orderInfoMapper.listContractPage(q); + List ids = new ArrayList<>(); + if (rows != null) { + for (OrderInfo row : rows) { + if (row.getId() != null) { + ids.add(row.getId()); + } + } + } + return ids; + } + + /** + * 标记每条合同在项目域(project_order_info)是否有对应记录:按 trim(order_code) 部分对齐, + * 关联不到只标记 false,不报错也不臆造(方案 16.1)。 + */ + private void markProjectDomainAlignment(List> items, List page) { + if (items.isEmpty() || page.isEmpty()) { + return; + } + List codes = new ArrayList<>(); + for (OrderInfo row : page) { + String code = McpFormat.trim(row.getOrderCode()); + if (code != null) { + codes.add(code); + } + } + Set matched = new LinkedHashSet<>(); + if (!codes.isEmpty()) { + List projects = projectInfoMapper.selectProjectInfoByOrderCodeList(codes); + if (projects != null) { + for (ProjectInfo project : projects) { + if (project.getOrderCode() != null) { + matched.add(project.getOrderCode().trim()); + } + } + } + } + for (Map item : items) { + Object code = item.get("orderCode"); + item.put("projectDomainMatched", code != null && matched.contains(String.valueOf(code))); + } + } + + // ------------------------------------------------------------------ 行映射 + + private Map toProjectItem(ProjectInfo row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("projectCode", McpFormat.trim(row.getProjectCode())); + item.put("projectName", row.getProjectName()); + item.put("bgProperty", row.getBgProperty()); + item.put("industryType", row.getIndustryType()); + item.put("agentCode", McpFormat.trim(row.getAgentCode())); + item.put("agentName", row.getAgentName()); + item.put("projectStage", row.getProjectStage()); + item.put("constructionType", row.getConstructionType()); + item.put("projectGraspDegree", row.getProjectGraspDegree()); + item.put("hzSupportUser", row.getHzSupportUser()); + item.put("hzSupportUserName", row.getHzSupportUserName()); + item.put("customerCode", McpFormat.trim(row.getCustomerCode())); + item.put("customerName", row.getCustomerName()); + item.put("customerUserName", row.getCustomerUserName()); + item.put("customerPhone", row.getCustomerPhone()); + item.put("operateInstitution", row.getOperateInstitution()); + item.put("h3cPerson", row.getH3cPerson()); + item.put("h3cPhone", row.getH3cPhone()); + item.put("partnerCode", McpFormat.trim(row.getPartnerCode())); + item.put("partnerName", row.getPartnerName()); + item.put("partnerUserName", row.getPartnerUserName()); + item.put("contactWay", row.getContactWay()); + item.put("estimatedAmount", row.getEstimatedAmount()); + item.put("currencyType", row.getCurrencyType()); + item.put("estimatedOrderTime", McpFormat.date(row.getEstimatedOrderTime())); + item.put("estimatedDeliverTime", McpFormat.date(row.getEstimatedDeliverTime())); + item.put("competitor", row.getCompetitor()); + item.put("countryProduct", row.getCountryProduct()); + item.put("keyProblem", row.getKeyProblem()); + item.put("projectDesc", row.getProjectDesc()); + item.put("poc", row.getPoc()); + item.put("jointTrial", row.getJointTrial()); + item.put("createBy", row.getCreateBy()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + return item; + } + + private Map toProjectProductItem(ProjectProductInfo row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("projectId", row.getProjectId()); + item.put("productBomCode", McpFormat.trim(row.getProductBomCode())); + item.put("productName", row.getProductName()); + item.put("model", row.getModel()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("productDesc", row.getProductDesc()); + item.put("type", row.getType()); + item.put("level2Type", row.getLevel2Type()); + item.put("vendorCode", McpFormat.trim(row.getVendorCode())); + item.put("vendorName", row.getVendorName()); + item.put("quantity", row.getQuantity()); + item.put("cataloguePrice", row.getCataloguePrice()); + item.put("catalogueAllPrice", row.getCatalogueAllPrice()); + item.put("price", row.getPrice()); + item.put("allPrice", row.getAllPrice()); + item.put("guidanceDiscount", row.getGuidanceDiscount()); + item.put("discount", row.getDiscount()); + item.put("taxRate", row.getTaxRate()); + return item; + } + + private Map toProgressItem(ProjectWorkProgress row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("projectId", row.getProjectId()); + item.put("workContent", row.getWorkContent()); + item.put("workUser", row.getWorkUser()); + item.put("userName", row.getUserName()); + item.put("workTime", McpFormat.dateTime(row.getWorkTime())); + return item; + } + + private Map toPocItem(ProjectPocInfo row) { + Map item = new LinkedHashMap<>(); + item.put("pocId", row.getId()); + item.put("projectId", row.getProjectId()); + item.put("serverConfig", row.getServerConfig()); + item.put("terminalConfig", row.getTerminalConfig()); + item.put("operateSystem", row.getOperateSystem()); + item.put("vdiVersion", row.getVdiVersion()); + item.put("processPerson", row.getProcessPerson()); + item.put("processPhone", row.getProcessPhone()); + item.put("handlePerson", row.getHandlePerson()); + item.put("handlePhone", row.getHandlePhone()); + item.put("hzInterfacePerson", row.getHzInterfacePerson()); + item.put("hzInterfacePhone", row.getHzInterfacePhone()); + item.put("startDate", McpFormat.date(row.getStartDate())); + item.put("h3cPerson", row.getH3cPerson()); + item.put("h3cPhone", row.getH3cPhone()); + item.put("planFinishTime", McpFormat.date(row.getPlanFinishTime())); + item.put("realFinishTime", McpFormat.date(row.getRealFinishTime())); + item.put("createBy", row.getCreateBy()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + return item; + } + + private Map toPocDetailItem(ProjectPocInfoDetail row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("pocId", row.getPocId()); + item.put("testProgress", row.getTestProgress()); + item.put("createByName", row.getCreateByName()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + return item; + } + + private Map toQuotationItem(Quotation row) { + Map item = new LinkedHashMap<>(); + item.put("quotationId", row.getId()); + item.put("quotationCode", McpFormat.trim(row.getQuotationCode())); + item.put("quotationName", row.getQuotationName()); + item.put("quotationAmount", row.getQuotationAmount()); + item.put("discountAmount", row.getDiscountAmount()); + item.put("quotationStatus", row.getQuotationStatus()); + item.put("quotationStatusName", quotationStatusName(row.getQuotationStatus())); + item.put("agentCode", McpFormat.trim(row.getAgentCode())); + item.put("amountType", row.getAmountType()); + item.put("customerName", row.getCustomerName()); + item.put("createBy", row.getCreateBy()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + item.put("remark", row.getRemark()); + return item; + } + + private Map toQuotationProductItem(QuotationProductInfo row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("quotationId", row.getQuotationId()); + item.put("productBomCode", McpFormat.trim(row.getProductBomCode())); + item.put("model", row.getModel()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("productDesc", row.getProductDesc()); + item.put("type", row.getType()); + item.put("quantity", row.getQuantity()); + item.put("cataloguePrice", row.getCataloguePrice()); + item.put("catalogueAllPrice", row.getCatalogueAllPrice()); + item.put("price", row.getPrice()); + item.put("allPrice", row.getAllPrice()); + item.put("guidanceDiscount", row.getGuidanceDiscount()); + item.put("discount", row.getDiscount()); + item.put("taxRate", row.getTaxRate()); + item.put("remark", row.getRemark()); + return item; + } + + private Map toContractItem(OrderInfo row) { + Map item = new LinkedHashMap<>(); + item.put("orderId", row.getId()); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("versionCode", versionCodeAsLong(row.getVersionCode())); + item.put("projectCode", McpFormat.trim(row.getProjectCode())); + item.put("orderName", row.getOrderName()); + item.put("customerCode", McpFormat.trim(row.getCustomerCode())); + item.put("customerName", row.getCustomerName()); + item.put("customerAddress", row.getCustomerAddress()); + item.put("customerContact", row.getCustomerContact()); + item.put("customerPhone", row.getCustomerPhone()); + item.put("customerEmail", row.getCustomerEmail()); + item.put("customerPostcode", row.getCustomerPostcode()); + item.put("industryType", row.getIndustryType()); + item.put("bgType", row.getBgType()); + item.put("orderType", row.getOrderType()); + item.put("orderTypeName", orderTypeName(row.getOrderType())); + item.put("orderAgentCode", McpFormat.trim(row.getOrderAgentCode())); + item.put("orderAgentName", row.getOrderAgentName()); + item.put("orderPartnerCode", McpFormat.trim(row.getOrderPartnerCode())); + item.put("orderPartnerName", row.getOrderPartnerName()); + item.put("orderDate", McpFormat.date(row.getOrderDate())); + item.put("status", row.getStatus()); + item.put("statusName", contractStatusName(row.getStatus())); + item.put("remark", row.getRemark()); + item.put("createBy", row.getCreateBy()); + item.put("createdAt", McpFormat.dateTime(row.getCreatedAt())); + item.put("updatedAt", McpFormat.dateTime(row.getUpdatedAt())); + return item; + } + + private Map toContractProductItem(OrderList row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("orderId", row.getOrderId()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("productName", row.getProductName()); + item.put("quantity", row.getQuantity()); + item.put("price", row.getPrice()); + item.put("discount", row.getDiscount()); + item.put("amount", row.getAmount()); + item.put("remark", row.getRemark()); + item.put("createdAt", McpFormat.dateTime(row.getCreatedAt())); + item.put("updatedAt", McpFormat.dateTime(row.getUpdatedAt())); + return item; + } + + // ------------------------------------------------------------------ 字典翻译 + + /** + * ⚠️ order_info.order_type 实测取值是 zq(205) / dls(161),与列注释“1直签/2代理商”不符, + * 按实测值映射(zq=直签合同、dls=代理商合同,含义待业务确认)。 + */ + private String orderTypeName(String orderType) { + if (orderType == null) { + return null; + } + String value = orderType.trim(); + if ("zq".equalsIgnoreCase(value)) { + return "直签合同"; + } + if ("dls".equalsIgnoreCase(value)) { + return "代理商合同"; + } + return value; + } + + private String contractStatusName(Long status) { + if (status == null) { + return null; + } + if (status == 0L) { + return "有效"; + } + if (status == 1L) { + return "无效"; + } + return String.valueOf(status); + } + + private String quotationStatusName(String status) { + if (status == null) { + return null; + } + for (Quotation.QuotationStatusEnum value : Quotation.QuotationStatusEnum.values()) { + if (value.getCode().equals(status.trim())) { + return value.getValue(); + } + } + return status; + } + + private Long versionCodeAsLong(String versionCode) { + if (versionCode == null || versionCode.trim().isEmpty()) { + return 0L; + } + try { + return Long.valueOf(versionCode.trim()); + } catch (NumberFormatException ex) { + return 0L; + } + } + + // ------------------------------------------------------------------ 辅助 + + private McpTimeRange resolveTimeRange(String entity, Map params) { + if ("PROJECT".equals(entity) || "QUOTATION".equals(entity) || "CONTRACT".equals(entity)) { + return McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + } + if ("PROGRESS".equals(entity)) { + // 仅 7 行的小表:不设默认区间,传入时才按 work_time 过滤 + return McpParams.timeRange(params, "time_range", 0, MAX_MONTHS); + } + boolean provided = McpParams.has(params, "time_range") || McpParams.has(params, "time_range_start") + || McpParams.has(params, "time_range_end"); + if (provided) { + throw McpToolException.invalidParams("entity=" + entity + " 不支持 time_range(该表无本次查询使用的时间字段)"); + } + return McpTimeRange.of(null, null); + } + + private String timeFieldOf(String entity) { + switch (entity) { + case "PROJECT": + case "QUOTATION": + return "create_time"; + case "CONTRACT": + return "order_date"; + case "PROGRESS": + return "work_time"; + default: + return null; + } + } + + private List merge(List primary, List secondary) { + Set merged = new LinkedHashSet<>(); + if (primary != null) { + merged.addAll(primary); + } + if (secondary != null) { + merged.addAll(secondary); + } + return new ArrayList<>(merged); + } + + private void putCodeCursor(Map q, McpCursor cursor) { + if (cursor != null) { + q.put("lastCode", cursor.key(0)); + q.put("lastId", cursor.longKey(1)); + } + } + + private Map countQuery(Map q, String... cursorKeys) { + Map copy = new HashMap<>(q); + if (cursorKeys != null) { + for (String key : cursorKeys) { + copy.remove(key); + } + } + copy.put("limit", COUNT_CAP + 1); + return copy; + } + + private long count(List rows) { + return rows == null ? 0L : rows.size(); + } + + private void applyTotal(EntityResult result, long counted) { + result.totalCapped = counted > COUNT_CAP; + result.total = result.totalCapped ? COUNT_CAP : counted; + } + + private String sortedJoin(List values) { + if (values == null || values.isEmpty()) { + return ""; + } + return String.join(",", new TreeSet<>(values)); + } + + private Map buildMetadata(String entity) { + Map metadata = metadata("项目域列表 / 范围查询(entity=" + entity + ");游标分页,排序键见 sort_by。"); + metadata.put("entity_fields", mapData( + "PROJECT", "项目 project_info(2212 行;时间字段 create_time;排序键 project_code,id)", + "PROJECT_PRODUCT", "项目产品 project_product_info(2809 行;排序键 project_id,id;必须给项目范围)", + "PROGRESS", "项目进度 project_work_progress(7 行;时间字段 work_time(可选);排序键 project_id,id;必须给项目范围)", + "POC", "POC测试 project_poc_info(998 行,明细 project_poc_info_detail 2 行;排序键 project_id,id;必须给项目范围)", + "QUOTATION", "报价单 oms_quotation(当前 0 行,明细 oms_quotation_product_info 当前 0 行;时间字段 create_time;排序键 quotation_code,id)", + "CONTRACT", "manage 域合同 order_info(366 行,其中 21 行软删除,默认过滤 deleted_at is null;时间字段 order_date;排序键 order_code,version_code,id)", + "CONTRACT_PRODUCT", "合同明细 order_list(854 行,53 行软删除,默认过滤 deleted_at is null;排序键 order_id,id;必须给合同ID或产品编码)")); + metadata.put("data_fields", mapData( + "total", "include_total=true 时为真实命中条数(否则为当前页条数),受 count_cap=" + COUNT_CAP + " 限制", + "items", "结果列表", + "page_info", "分页信息;has_more=true 时必须回传 next_cursor")); + metadata.put("item_fields", itemFields()); + metadata.put("enum_fields", mapData( + "CONTRACT.orderType / orderTypeName", "⚠️ 按实测值映射:zq=直签合同、dls=代理商合同;" + + "列注释写“1直签/2代理商”与实测不符(order_type 实测为 varchar 值 zq/dls)," + + "取值与列注释不一致,已按实测值映射,含义待业务确认", + "CONTRACT.status / statusName", "字典语义:0=有效、1=无效(与列注释一致)", + "QUOTATION.quotationStatus / quotationStatusName", "枚举 Quotation.QuotationStatusEnum(0未绑定/1已绑定)", + "PROJECT.projectStage", "字典 project_stage(本工具只回显编码,未做字典翻译,字典值待确认)", + "PROJECT.bgProperty", "字典 bg_type(只回显编码,待确认)", + "PROJECT.operateInstitution", "字典 operate_institution(只回显编码,待确认)", + "PROJECT.countryProduct", "0=否 / 1=是", + "PROJECT.jointTrial", "0=未会审 / 1=已会审")); + metadata.put("relation_notes", mapData( + "两套订单模型", "manage 域 order_info/order_list(销售交付侧)与项目域 project_order_info/project_product_info " + + "是两套模型:只能用 trim(order_code) 部分对齐(实测去空白后 330/366 命中)," + + "禁止按 id 跨体系关联(order_delivery.order_id 只能关联 order_info.id)", + "对齐容错", "CONTRACT 每条返回 projectDomainMatched 标记是否在项目域有对应记录;关联不到只标记 false," + + "不报错也不臆造(36/366 对不上)", + "trim 规则", "order_info.order_code 有 14 行带前导制表符脏数据:按单号查询、join 与输出一律 trim", + "软删除", "CONTRACT/CONTRACT_PRODUCT 默认过滤 deleted_at is null(order_list 中 53 行软删除)", + "项目产品口径", "project_product_info 的 product_bom_code 关联 product_info.product_code;" + + "PROJECT_PRODUCT 的 product_code 列是产品代码,与 product_bom_code 不同,过滤按 product_bom_code")); + metadata.put("permission_source", permissionSource(entity)); + metadata.put("notes", "时间条件为区间下推(>= start and < end),不使用 date_format;" + + "编码类入参与出参统一 trim;多值 IN 会产生 filesort,建议配合 page_size 与精确编码使用;" + + "include_total=true 通过 limit=50001 的 page 查询统计,超上限置 total_count_capped=true。"); + return metadata; + } + + private Map itemFields() { + return mapOf( + "id", "主键(PROJECT/PROJECT_PRODUCT/PROGRESS;CONTRACT_PRODUCT 为明细ID)", + "projectId", "项目ID(PROJECT_PRODUCT/PROGRESS/POC)", + "projectCode", "项目编号(PROJECT;CONTRACT 中为合同关联的项目编号)", + "projectName", "项目名称(PROJECT)", + "bgProperty", "BG属性(PROJECT)", + "industryType", "一级行业(PROJECT;CONTRACT 同名列)", + "agentCode", "代表处编码(PROJECT)", + "agentName", "代表处名称(PROJECT)", + "projectStage", "项目阶段(PROJECT,字典 project_stage)", + "constructionType", "建设类型(PROJECT)", + "projectGraspDegree", "项目把握度(PROJECT)", + "hzSupportUser", "汇智支撑人员ID(PROJECT)", + "hzSupportUserName", "汇智支撑人员姓名(PROJECT)", + "customerCode", "客户编码(PROJECT/CONTRACT)", + "customerName", "客户/最终客户名称(PROJECT/CONTRACT)", + "customerUserName", "客户联系人(PROJECT)", + "customerPhone", "客户电话(PROJECT/CONTRACT)", + "operateInstitution", "运作机构(PROJECT,字典 operate_institution)", + "h3cPerson", "新华三/H3C 联系人(PROJECT/POC)", + "h3cPhone", "新华三/H3C 联系电话(PROJECT/POC)", + "partnerCode", "代理商编码(PROJECT)", + "partnerName", "代理商名称(PROJECT)", + "partnerUserName", "代理商联系人(PROJECT)", + "contactWay", "代理商联系方式(PROJECT)", + "estimatedAmount", "预计金额(PROJECT)", + "currencyType", "币种(PROJECT)", + "estimatedOrderTime", "预计下单时间(PROJECT)", + "estimatedDeliverTime", "预计发货时间(PROJECT)", + "competitor", "竞争对手(PROJECT)", + "countryProduct", "是否国产(PROJECT,0否/1是)", + "keyProblem", "关键技术问题(PROJECT)", + "projectDesc", "项目描述(PROJECT)", + "poc", "POC测试标记(PROJECT)", + "jointTrial", "是否已会审(PROJECT,0未会审/1已会审)", + "createBy", "创建人(PROJECT/POC/QUOTATION/CONTRACT)", + "createTime", "创建时间(PROJECT)", + "updateTime", "更新时间(PROJECT/QUOTATION)", + "productBomCode", "产品BOM编码(PROJECT_PRODUCT/QUOTATION 明细)", + "productCode", "产品代码(PROJECT_PRODUCT)/ BOM编码(CONTRACT_PRODUCT)", + "productName", "产品名称(PROJECT_PRODUCT/CONTRACT_PRODUCT)", + "model", "产品型号(PROJECT_PRODUCT/QUOTATION 明细)", + "productDesc", "产品描述(PROJECT_PRODUCT/QUOTATION 明细)", + "type", "产品类型编码(PROJECT_PRODUCT/QUOTATION 明细)", + "level2Type", "产品二级类型编码(PROJECT_PRODUCT)", + "vendorCode", "厂商编码(PROJECT_PRODUCT)", + "vendorName", "厂商名称(PROJECT_PRODUCT)", + "quantity", "数量(PROJECT_PRODUCT/CONTRACT_PRODUCT/QUOTATION 明细)", + "cataloguePrice", "目录单价(PROJECT_PRODUCT/QUOTATION 明细)", + "catalogueAllPrice", "目录总价(PROJECT_PRODUCT/QUOTATION 明细)", + "price", "单价(PROJECT_PRODUCT/CONTRACT_PRODUCT/QUOTATION 明细)", + "allPrice", "总价(PROJECT_PRODUCT/QUOTATION 明细)", + "guidanceDiscount", "指导折扣(PROJECT_PRODUCT/QUOTATION 明细)", + "discount", "折扣(PROJECT_PRODUCT/CONTRACT_PRODUCT/QUOTATION 明细)", + "taxRate", "税率(PROJECT_PRODUCT/QUOTATION 明细)", + "workContent", "变更内容(PROGRESS)", + "workUser", "更新人ID(PROGRESS)", + "userName", "更新人姓名(PROGRESS)", + "workTime", "更新时间(PROGRESS 的时间字段)", + "pocId", "POC记录ID(POC)", + "serverConfig", "服务器配置(POC)", + "terminalConfig", "终端配置(POC)", + "operateSystem", "操作系统(POC)", + "vdiVersion", "云桌面版本(POC)", + "processPerson", "研发人员(POC)", + "processPhone", "研发联系电话(POC)", + "handlePerson", "现场处理人员(POC)", + "handlePhone", "现场联系电话(POC)", + "hzInterfacePerson", "汇智接口人(POC)", + "hzInterfacePhone", "汇智联系电话(POC)", + "startDate", "启动时间(POC)", + "planFinishTime", "预计完成时间(POC)", + "realFinishTime", "实际完成时间(POC)", + "testProgressDetails", "测试进展明细(POC,include_detail=true;含 testProgress 测试进展 / createByName / createTime)", + "quotationId", "报价单ID(QUOTATION)", + "quotationCode", "报价单号(QUOTATION)", + "quotationName", "报价单名称(QUOTATION)", + "quotationAmount", "报价金额(QUOTATION)", + "discountAmount", "折后金额(QUOTATION)", + "quotationStatus", "报价单状态编码(0未绑定/1已绑定)(QUOTATION)", + "quotationStatusName", "报价单状态名称(QUOTATION)", + "amountType", "币种(QUOTATION)", + "products", "报价产品行(QUOTATION,include_detail=true)", + "orderId", "合同ID(CONTRACT/CONTRACT_PRODUCT)", + "orderCode", "合同编号(CONTRACT)", + "versionCode", "合同版本号(CONTRACT)", + "orderName", "合同名称(CONTRACT)", + "customerAddress", "客户地址(CONTRACT)", + "customerContact", "客户联系人(CONTRACT)", + "customerEmail", "客户邮箱(CONTRACT)", + "customerPostcode", "客户邮编(CONTRACT)", + "bgType", "BG属性(CONTRACT)", + "orderType", "合同类型编码(CONTRACT,实测 zq/dls)", + "orderTypeName", "合同类型名称(CONTRACT,按实测值映射,含义待业务确认)", + "orderAgentCode", "代表处编码(CONTRACT)", + "orderAgentName", "代表处名称(CONTRACT)", + "orderPartnerCode", "代理商编码(CONTRACT)", + "orderPartnerName", "代理商名称(CONTRACT)", + "orderDate", "合同签订日期(CONTRACT 的时间字段)", + "status", "合同状态编码(0有效/1无效)(CONTRACT)", + "statusName", "合同状态名称(CONTRACT)", + "createdAt", "创建时间(CONTRACT/CONTRACT_PRODUCT)", + "updatedAt", "更新时间(CONTRACT/CONTRACT_PRODUCT)", + "projectDomainMatched", "该合同在项目域(project_order_info)是否有对应记录(CONTRACT;按 trim(order_code) 对齐,关联不到为 false)", + "orderItems", "合同清单明细(CONTRACT,include_detail=true)", + "amount", "明细总价(CONTRACT_PRODUCT)", + "remark", "备注"); + } + + private String permissionSource(String entity) { + switch (entity) { + case "PROJECT": + case "PROJECT_PRODUCT": + case "PROGRESS": + case "POC": + return "ProjectInfoController / VueProjectInfoController:sip:project:list(已抄取);" + + "待确认:项目产品/进度/POC 无独立 Controller,取项目页面权限"; + case "QUOTATION": + return "QuotationController#list:sip:quotation:list(已抄取)"; + case "CONTRACT": + case "CONTRACT_PRODUCT": + return "OrderInfoController#list:manage:order:list(已抄取)"; + default: + return "未确定(待确认)"; + } + } + + private String[] permissionsOf(String entity) { + switch (entity) { + case "PROJECT": + case "PROJECT_PRODUCT": + case "PROGRESS": + case "POC": + return new String[]{"sip:project:list"}; + case "QUOTATION": + return new String[]{"sip:quotation:list"}; + case "CONTRACT": + case "CONTRACT_PRODUCT": + return new String[]{"manage:order:list"}; + default: + return new String[0]; + } + } + + /** entity 查询结果载体 */ + private static class EntityResult { + private final List> items = new ArrayList<>(); + private final String sortBy; + private boolean hasMore; + private List lastSortKeys = new ArrayList<>(); + private Long total; + private boolean totalCapped; + + private EntityResult(String sortBy) { + this.sortBy = sortBy; + } + + private static EntityResult empty(String sortBy) { + EntityResult result = new EntityResult(sortBy); + result.total = 0L; + return result; + } + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/PurchaseArrivalAggregateToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/PurchaseArrivalAggregateToolProvider.java new file mode 100644 index 00000000..3e3bbae3 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/PurchaseArrivalAggregateToolProvider.java @@ -0,0 +1,456 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.sip.domain.OmsPurchaseOrder; +import com.ruoyi.sip.domain.OmsPurchaseOrderItem; +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpCursor; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpPages; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpTimeRange; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.OmsPurchaseOrderMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * 采购到货汇总 MCP 工具(只读):mode=SUMMARY(默认,一次算完)或 mode=LIST(按 purchase_no 游标分页)。 + * + *

方案依据:docx/mcp-data-tools-plan.md 第 4.2 / 5 / 9 / 13 / 15 章与附录 A.5。

+ *

权限来源:{@code OmsPurchaseOrderController#list} 的 {@code @RequiresPermissions("sip:purchaseorder:list")}(已实测抄取)。

+ */ +@Component +public class PurchaseArrivalAggregateToolProvider extends AbstractMcpQueryToolProvider { + + private static final Set MODES = new LinkedHashSet<>(Arrays.asList("SUMMARY", "LIST")); + private static final Set GROUP_BYS = new LinkedHashSet<>( + Arrays.asList("NONE", "ORDER", "VENDOR", "PRODUCT", "STATUS", "TIME_MONTH")); + private static final int DEFAULT_TIME_MONTHS = 12; + private static final int MAX_TIME_MONTHS = 36; + private static final int DEFAULT_TOP_N = 10; + private static final int MAX_TOP_N = 100; + private static final int COUNT_CAP = 50000; + + @Autowired + private OmsPurchaseOrderMapper omsPurchaseOrderMapper; + + @Override + protected String getToolName() { + return "purchase_arrival_aggregate"; + } + + @Override + protected String getToolDescription() { + return "采购到货汇总(只读)。mode=SUMMARY(默认)按 group_by 分组一次算完,mode=LIST 按 purchase_no 游标分页。" + + "返回固定度量:purchaseQty 采购数量、innerQty 已入库数量、pendingQty 未入库数量、arrivalRate 到货率(%)、amountWithTax 含税金额、taxTotal 税额。" + + "分页查询工具:若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 继续调用直到 has_more 为 false,否则数据不完整。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("mode", enumProperty("模式:SUMMARY 汇总(默认,不分页)/ LIST 明细(按 purchase_no 分页)", "SUMMARY", "LIST")); + properties.put("group_by", enumProperty("SUMMARY 分组维度:NONE 总计(默认)/ ORDER 采购单号 / VENDOR 供应商 / PRODUCT 产品 / STATUS 采购状态 / TIME_MONTH 月份", + "NONE", "ORDER", "VENDOR", "PRODUCT", "STATUS", "TIME_MONTH")); + properties.put("purchase_no_list", arrayProperty("采购单号列表(≤200);mode=LIST 时与 vendor_code_list 至少提供一个")); + properties.put("vendor_code_list", arrayProperty("制造商编码列表(≤200)")); + properties.put("time_range", stringProperty("时间区间,格式 start~end(yyyy-MM-dd)。基于 purchase_date;默认近 12 个月,跨度上限 36 个月")); + properties.put("top_n", intProperty("SUMMARY 返回前 N 组(默认 10,上限 100);仅 SUMMARY 可用")); + properties.put("include_total", boolProperty("是否统计总条数(默认 false);LIST 时受 count_cap=50000 限制")); + return withPagingProperties(properties, McpPages.MAX_AGG_PAGE_SIZE, McpPages.DEFAULT_PAGE_SIZE); + } + + @Override + protected Object handle(Map params) { + // 权限串来源:OmsPurchaseOrderController#list 的 @RequiresPermissions("sip:purchaseorder:list") + McpAuth.requireAnyPermission("sip:purchaseorder:list"); + + String mode = McpParams.enumVal(params, "mode", MODES, "SUMMARY"); + String groupBy = McpParams.enumVal(params, "group_by", GROUP_BYS, "NONE"); + List purchaseNoList = McpParams.codeList(params, "purchase_no_list", McpPages.MAX_CODE_LIST); + List vendorCodeList = McpParams.codeList(params, "vendor_code_list", McpPages.MAX_CODE_LIST); + McpTimeRange timeRange = McpParams.timeRange(params, "time_range", DEFAULT_TIME_MONTHS, MAX_TIME_MONTHS); + boolean includeTotal = McpParams.boolVal(params, "include_total", false); + + boolean scopeRestricted = !inventoryAuthAll(); + List authVendorCodes = scopeRestricted ? inventoryAuthVendorCodes() : Collections.emptyList(); + + if ("SUMMARY".equals(mode)) { + McpParams.reject(McpParams.has(params, "cursor"), "mode=SUMMARY 不支持 cursor(SUMMARY 不分页)"); + McpParams.reject(McpParams.has(params, "page_size"), "mode=SUMMARY 不支持 page_size,请改用 top_n"); + } else { + McpParams.reject(McpParams.has(params, "top_n"), "mode=LIST 不支持 top_n(top_n 仅 SUMMARY 可用)"); + if (purchaseNoList.isEmpty() && vendorCodeList.isEmpty()) { + throw McpToolException.invalidParams("mode=LIST 需要提供 purchase_no_list 或 vendor_code_list 以收窄范围,避免全表扫描;" + + "如需全局统计请使用 mode=SUMMARY"); + } + } + + Integer topN = McpParams.intVal(params, "top_n", DEFAULT_TOP_N); + if (topN < 1 || topN > MAX_TOP_N) { + throw McpToolException.invalidParams("top_n 取值非法:" + topN + ",允许范围 1~" + MAX_TOP_N); + } + + String filterHash = McpParams.filterHash( + mode, + groupBy, + sortedJoin(purchaseNoList), + sortedJoin(vendorCodeList), + McpParams.describe(timeRange), + String.valueOf(topN), + sortedJoin(authVendorCodes)); + + Map query = new LinkedHashMap<>(); + query.put("mode", mode); + query.put("group_by", groupBy); + query.put("purchase_no_list", purchaseNoList); + query.put("vendor_code_list", vendorCodeList); + query.put("time_range", McpParams.describe(timeRange)); + query.put("top_n", "SUMMARY".equals(mode) ? topN : null); + query.put("include_total", includeTotal); + + if (scopeRestricted && authVendorCodes.isEmpty()) { + Map pageInfo = McpPages.pageInfo(0, McpPages.DEFAULT_PAGE_SIZE, false, null, + sortByFor(mode, groupBy), 1, null, false, false); + return response(buildMetadata(), query, listData(Collections.emptyList(), pageInfo, null)); + } + + return "SUMMARY".equals(mode) + ? handleSummary(groupBy, purchaseNoList, vendorCodeList, authVendorCodes, timeRange, topN, includeTotal, query) + : handleList(params, purchaseNoList, vendorCodeList, authVendorCodes, timeRange, includeTotal, filterHash, query); + } + + // ------------------------------------------------------------------ SUMMARY + + private Object handleSummary(String groupBy, List purchaseNoList, + List vendorCodeList, List authVendorCodes, McpTimeRange timeRange, + Integer topN, boolean includeTotal, Map query) { + int limit = "NONE".equals(groupBy) ? 1 : topN; + List> rows = omsPurchaseOrderMapper.selectPurchaseArrivalSummary(groupBy, purchaseNoList, + vendorCodeList, authVendorCodes, timeRange.getStart(), timeRange.getEnd(), limit); + if (rows == null) { + rows = Collections.emptyList(); + } + + List> items = new ArrayList<>(); + for (Map row : rows) { + items.add(toSummaryItem(groupBy, row)); + } + + Long total = includeTotal ? (long) items.size() : null; + Map pageInfo = McpPages.pageInfo(items.size(), limit, false, null, + sortByFor("SUMMARY", groupBy), 1, total, false, false); + return response(buildMetadata(), query, listData(items, pageInfo, total)); + } + + private Map toSummaryItem(String groupBy, Map row) { + Map item = new LinkedHashMap<>(); + switch (groupBy) { + case "ORDER": + item.put("purchaseNo", McpFormat.trimToNull(stringValue(row.get("groupKey")))); + break; + case "VENDOR": + item.put("vendorId", row.get("groupKey")); + item.put("vendorCode", McpFormat.trimToNull(stringValue(row.get("vendorCode")))); + item.put("vendorName", stringValue(row.get("vendorName"))); + break; + case "PRODUCT": + item.put("productCode", McpFormat.trimToNull(stringValue(row.get("groupKey")))); + break; + case "STATUS": + Integer status = intValue(row.get("groupKey")); + item.put("status", status); + item.put("statusName", statusName(status)); + break; + case "TIME_MONTH": + item.put("timeBucket", stringValue(row.get("groupKey"))); + break; + default: + break; + } + appendMetrics(item, row); + return item; + } + + // ------------------------------------------------------------------ LIST + + private Object handleList(Map params, List purchaseNoList, List vendorCodeList, + List authVendorCodes, McpTimeRange timeRange, boolean includeTotal, + String filterHash, Map query) { + int pageSize = McpPages.pageSize(params, McpPages.DEFAULT_PAGE_SIZE, McpPages.MAX_AGG_PAGE_SIZE); + McpCursor cursor = McpPages.cursor(params, getToolName(), filterHash, McpPages.DEFAULT_AGG_MAX_PAGES); + int pageNo = cursor == null ? 1 : cursor.getP(); + query.put("page_size", pageSize); + query.put("page_no", pageNo); + + String lastPurchaseNo = cursor == null ? null : cursor.key(0); + Long lastId = cursor == null ? null : cursor.longKey(1); + + List rows = omsPurchaseOrderMapper.selectPurchaseOrderPage(purchaseNoList, vendorCodeList, + authVendorCodes, null, null, null, null, Collections.emptyList(), + timeRange.getStart(), timeRange.getEnd(), lastPurchaseNo, lastId, pageSize + 1); + boolean hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + + Map metricsMap = loadMetrics(page); + List> items = new ArrayList<>(); + for (OmsPurchaseOrder order : page) { + items.add(toListItem(order, metricsMap)); + } + + OmsPurchaseOrder last = page.isEmpty() ? null : page.get(page.size() - 1); + String nextCursor = hasMore + ? McpCursor.encode(getToolName(), filterHash, pageNo + 1, + keysOf(last == null ? null : McpFormat.trim(last.getPurchaseNo()), last == null ? null : last.getId())) + : null; + + Long total = null; + boolean capped = false; + if (includeTotal) { + List all = omsPurchaseOrderMapper.selectPurchaseOrderPage(purchaseNoList, vendorCodeList, + authVendorCodes, null, null, null, null, Collections.emptyList(), + timeRange.getStart(), timeRange.getEnd(), null, null, COUNT_CAP + 1); + capped = all.size() > COUNT_CAP; + total = capped ? (long) COUNT_CAP : (long) all.size(); + } + + Map pageInfo = McpPages.pageInfo(items.size(), pageSize, hasMore, nextCursor, + "purchase_no,id", pageNo, total, capped, false); + return response(buildMetadata(), query, listData(items, pageInfo, total)); + } + + private Map loadMetrics(List page) { + List purchaseNos = new ArrayList<>(); + for (OmsPurchaseOrder order : page) { + String no = McpFormat.trim(order.getPurchaseNo()); + if (no != null) { + purchaseNos.add(no); + } + } + Map result = new LinkedHashMap<>(); + if (purchaseNos.isEmpty()) { + return result; + } + List itemList = omsPurchaseOrderMapper.listItemByCodeList(purchaseNos); + if (itemList == null) { + return result; + } + for (OmsPurchaseOrderItem detail : itemList) { + String no = McpFormat.trim(detail.getPurchaseNo()); + if (no == null) { + continue; + } + BigDecimal[] sums = result.computeIfAbsent(no, key -> new BigDecimal[]{ + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO}); + sums[0] = sums[0].add(zeroIfNull(detail.getQuantity())); + sums[1] = sums[1].add(zeroIfNull(detail.getInnerQuantity())); + sums[2] = sums[2].add(zeroIfNull(detail.getAmountTotal())); + sums[3] = sums[3].add(zeroIfNull(detail.getTaxTotal())); + } + return result; + } + + private Map toListItem(OmsPurchaseOrder order, Map metricsMap) { + String no = McpFormat.trim(order.getPurchaseNo()); + BigDecimal[] sums = metricsMap.get(no); + if (sums == null) { + sums = new BigDecimal[]{BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO}; + } + + Map item = new LinkedHashMap<>(); + item.put("purchaseNo", no); + item.put("buyerName", order.getBuyerName()); + item.put("vendorId", order.getVendorId()); + item.put("vendorCode", McpFormat.trim(order.getVendorCode())); + item.put("vendorName", order.getVendorName()); + item.put("warehouseId", order.getWarehouseId()); + item.put("warehouseName", order.getWarehouseName()); + item.put("purchaserName", order.getPurchaserName()); + item.put("ownerName", order.getOwnerName()); + item.put("purchaseDate", McpFormat.date(order.getPurchaseDate())); + item.put("status", order.getStatus()); + item.put("statusName", statusName(order.getStatus())); + appendMetrics(item, sums[0], sums[1], sums[2], sums[3]); + return item; + } + + // ------------------------------------------------------------------ 度量 + + private void appendMetrics(Map item, Map row) { + appendMetrics(item, + toBigDecimal(row.get("purchaseQty")), + toBigDecimal(row.get("innerQty")), + toBigDecimal(row.get("amountWithTax")), + toBigDecimal(row.get("taxTotal"))); + } + + private void appendMetrics(Map item, BigDecimal purchaseQty, BigDecimal innerQty, + BigDecimal amountWithTax, BigDecimal taxTotal) { + BigDecimal pending = purchaseQty.subtract(innerQty); + if (pending.compareTo(BigDecimal.ZERO) < 0) { + pending = BigDecimal.ZERO; + } + item.put("purchaseQty", purchaseQty); + item.put("innerQty", innerQty); + item.put("pendingQty", pending); + item.put("arrivalRate", arrivalRate(innerQty, purchaseQty)); + item.put("amountWithTax", amountWithTax); + item.put("taxTotal", taxTotal); + } + + /** + * 到货率(%):innerQty / purchaseQty,HALF_UP 保留 2 位;分母为 0 返回 0。 + */ + private BigDecimal arrivalRate(BigDecimal innerQty, BigDecimal purchaseQty) { + if (purchaseQty == null || purchaseQty.compareTo(BigDecimal.ZERO) == 0) { + return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP); + } + return innerQty.multiply(new BigDecimal("100")) + .divide(purchaseQty, 2, RoundingMode.HALF_UP); + } + + // ------------------------------------------------------------------ 辅助 + + private String sortByFor(String mode, String groupBy) { + if ("LIST".equals(mode)) { + return "purchase_no,id"; + } + switch (groupBy) { + case "TIME_MONTH": + return "timeBucket asc"; + case "NONE": + return "groupKey asc"; + case "ORDER": + return "purchase_no (purchaseQty desc)"; + case "VENDOR": + return "vendor_id (purchaseQty desc)"; + case "PRODUCT": + return "product_code (purchaseQty desc)"; + case "STATUS": + return "status (purchaseQty desc)"; + default: + return "purchaseQty desc"; + } + } + + private String sortedJoin(List values) { + if (values == null || values.isEmpty()) { + return ""; + } + List sorted = new ArrayList<>(new TreeSet<>(values)); + return String.join(",", sorted); + } + + private BigDecimal toBigDecimal(Object value) { + if (value == null) { + return BigDecimal.ZERO; + } + if (value instanceof BigDecimal) { + return (BigDecimal) value; + } + if (value instanceof Number) { + return new BigDecimal(value.toString()); + } + try { + return new BigDecimal(String.valueOf(value)); + } catch (NumberFormatException ex) { + return BigDecimal.ZERO; + } + } + + private BigDecimal zeroIfNull(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + private Integer intValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return Integer.valueOf(String.valueOf(value).trim()); + } catch (NumberFormatException ex) { + return null; + } + } + + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value); + } + + private String statusName(Integer status) { + if (status == null) { + return null; + } + for (OmsPurchaseOrder.StatusEnum value : OmsPurchaseOrder.StatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getValue(); + } + } + return String.valueOf(status); + } + + private Map buildMetadata() { + Map metadata = metadata("采购到货汇总:oms_purchase_order ⋈ oms_purchase_order_item,按 group_by 动态分组(SUMMARY)或按 purchase_no 游标分页(LIST)。"); + metadata.put("query_fields", mapOf( + "mode", "SUMMARY 汇总(默认)/ LIST 明细分页", + "group_by", "SUMMARY 分组维度:NONE/ORDER/VENDOR/PRODUCT/STATUS/TIME_MONTH", + "purchase_no_list", "采购单号列表(≤200)", + "vendor_code_list", "制造商编码列表(≤200)", + "time_range", "时间区间 start~end,基于 purchase_date;默认近12个月、上限36个月", + "top_n", "SUMMARY 前 N 组(默认10,上限100)", + "include_total", "是否统计总数(默认false,上限50000)", + "page_size", "LIST 每页条数(默认20,上限200)", + "cursor", "LIST 游标:回传上次的 page_info.next_cursor")); + metadata.put("data_fields", mapOf( + "total", "命中条数(include_total=true 时为真实值)", + "items", "分组结果列表", + "page_info", "分页信息:returned/page_size/has_more/next_cursor/sort_by/page_no/total_count")); + metadata.put("item_fields", mapOf( + "purchaseNo", "采购单号(group_by=ORDER 或 LIST)", + "vendorId", "制造商ID(group_by=VENDOR 或 LIST)", + "vendorCode", "制造商编码", + "vendorName", "供应商名称", + "productCode", "产品BOM编码(group_by=PRODUCT)", + "status", "采购状态编码(group_by=STATUS 或 LIST;0待入库/1部分入库/2已完成)", + "statusName", "采购状态名称", + "timeBucket", "时间桶 yyyy-MM(group_by=TIME_MONTH)", + "buyerName", "采购方名称(LIST)", + "warehouseId", "入库仓库ID(LIST)", + "warehouseName", "入库仓库名称(LIST)", + "purchaserName", "采购员(LIST)", + "ownerName", "汇智负责人(LIST)", + "purchaseDate", "采购日期(LIST)", + "purchaseQty", "采购数量(Σ item.quantity)", + "innerQty", "已入库数量(Σ item.inner_quantity)", + "pendingQty", "未入库数量(purchaseQty - innerQty,下限0)", + "arrivalRate", "到货率(%,innerQty/purchaseQty,HALF_UP 保留2位;分母0返回0)", + "amountWithTax", "含税金额(Σ item.amount_total)", + "taxTotal", "税额(Σ item.tax_total)")); + metadata.put("aggregation_rule", mapOf( + "arrivalRate", "arrivalRate = innerQty / purchaseQty * 100,HALF_UP 保留 2 位百分比数值;分母为 0 时返回 0", + "partial_inner", "“部分入库”按 oms_purchase_order.status = 1 判定(0待入库/1部分入库/2已完成)", + "pendingQty", "未入库数量 = 采购数量 - 已入库数量(下限 0)", + "time_field", "时间维度字段为 oms_purchase_order.purchase_date;时间条件一律区间下推(>= start and < end),不使用 date_format", + "group_scope", "度量在采购明细粒度求和;group_by=NONE 返回单行总计;TIME_MONTH 以 purchase_date 月份为桶", + "currency", "金额按原币返回,不自动换算")); + metadata.put("data_scope", "回填 authVendorCodeList(= IInventoryAuthService.currentVendor() 的 vendor_code,与 OmsPurchaseOrderController 拼法一致)"); + metadata.put("notes", "mode=LIST 必须给出 purchase_no_list 或 vendor_code_list;若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 继续调用直到 has_more 为 false,否则数据不完整。"); + return metadata; + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/PurchaseListToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/PurchaseListToolProvider.java new file mode 100644 index 00000000..ebcc490f --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/PurchaseListToolProvider.java @@ -0,0 +1,841 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.common.enums.ApproveStatusEnum; +import com.ruoyi.sip.domain.OmsPurchaseOrder; +import com.ruoyi.sip.domain.OmsPurchaseOrderHistory; +import com.ruoyi.sip.domain.OmsPurchaseOrderItem; +import com.ruoyi.sip.domain.OmsPurchaseOrderItemHistory; +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpCursor; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpPages; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpTimeRange; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.OmsPurchaseOrderHistoryMapper; +import com.ruoyi.sip.mapper.OmsPurchaseOrderMapMapper; +import com.ruoyi.sip.mapper.OmsPurchaseOrderMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; + +/** + * 采购域列表 / 范围查询 MCP 工具(只读,游标分页)。 + * + *

entity 参数化:ORDER(采购单主表)/ ITEM(采购明细)/ HISTORY(采购单历史)/ ORDER_BIND(采购-订单绑定)。 + * 方案依据:docx/mcp-data-tools-plan.md 第 4.3 / 5 / 7 / 8 / 9 / 15 章与附录 A.7 / A.10。

+ * + *

权限来源:{@code OmsPurchaseOrderController#list} 的 {@code @RequiresPermissions("sip:purchaseorder:list")}(已实测抄取)。

+ */ +@Component +public class PurchaseListToolProvider extends AbstractMcpQueryToolProvider { + + private static final Set ENTITIES = new LinkedHashSet<>( + Arrays.asList("ORDER", "ITEM", "HISTORY", "ORDER_BIND")); + private static final Set APPROVE_STATUS_VALUES = new LinkedHashSet<>( + Arrays.asList("0", "1", "2", "3")); + private static final Set CONFIRM_STATUS_VALUES = new LinkedHashSet<>( + Arrays.asList("0", "1", "2")); + private static final Set FLOW_TYPE_VALUES = new LinkedHashSet<>( + Arrays.asList("online", "offline")); + private static final int DEFAULT_TIME_MONTHS = 12; + private static final int MAX_TIME_MONTHS = 36; + private static final int COUNT_CAP = 50000; + private static final int MAX_STATUS = 2; + + @Autowired + private OmsPurchaseOrderMapper omsPurchaseOrderMapper; + + @Autowired + private OmsPurchaseOrderMapMapper omsPurchaseOrderMapMapper; + + @Autowired + private OmsPurchaseOrderHistoryMapper omsPurchaseOrderHistoryMapper; + + @Override + protected String getToolName() { + return "purchase_list"; + } + + @Override + protected String getToolDescription() { + return "采购域列表/范围查询(只读)。entity=ORDER 采购单、ITEM 采购明细、HISTORY 采购单历史、ORDER_BIND 采购-订单绑定。" + + "支持按单号/供应商/状态/审批状态/确认状态/流程类型/产品/采购日期范围过滤,游标分页。" + + "分页查询工具:若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 继续调用直到 has_more 为 false,否则数据不完整。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("entity", enumProperty("查询对象:ORDER 采购单 / ITEM 采购明细 / HISTORY 采购单历史 / ORDER_BIND 采购-订单绑定", + "ORDER", "ITEM", "HISTORY", "ORDER_BIND")); + properties.put("code_list", arrayProperty("单号/编码列表(≤200)。ORDER/ITEM/HISTORY 传采购单号 purchase_no;ORDER_BIND 传订单号 order_code")); + properties.put("order_code_list", arrayProperty("订单号(合同编号)列表(≤200),仅 entity=ORDER_BIND 支持;entity=ORDER 请改用 entity=ORDER_BIND")); + properties.put("vendor_code_list", arrayProperty("制造商编码列表(≤200),用于收窄供应商范围")); + properties.put("status", intProperty("采购状态:0=待入库,1=部分入库,2=已完成")); + properties.put("approve_status", stringProperty("审批状态:0=草稿/待提交,1=审批中/待审核,2=已通过,3=驳回")); + properties.put("confirm_status", stringProperty("供应商确认状态:0=待确认,1=已确认,2=已驳回")); + properties.put("flow_type", stringProperty("流程类型:online 线上 / offline 线下")); + properties.put("product_code_list", arrayProperty("产品编码列表(≤200)")); + properties.put("time_range", stringProperty("时间区间,格式 start~end(yyyy-MM-dd)。基于 purchase_date(HISTORY 基于 create_time);默认近 12 个月,跨度上限 36 个月")); + properties.put("include_detail", boolProperty("是否返回明细行(默认 false)。ORDER 附采购明细,HISTORY 附历史明细")); + properties.put("include_total", boolProperty("是否统计总条数(默认 false);true 时受 count_cap=50000 限制,超出置 total_count_capped=true")); + return withPagingProperties(properties, McpPages.MAX_LIST_PAGE_SIZE, McpPages.DEFAULT_PAGE_SIZE); + } + + @Override + protected Object handle(Map params) { + // 权限串来源:OmsPurchaseOrderController#list / item/list / vendor/list 的 @RequiresPermissions("sip:purchaseorder:list") + McpAuth.requireAnyPermission("sip:purchaseorder:list"); + + String entity = McpParams.requireEnum(params, "entity", ENTITIES); + List codeList = McpParams.codeList(params, "code_list", McpPages.MAX_CODE_LIST); + List orderCodeList = McpParams.codeList(params, "order_code_list", McpPages.MAX_CODE_LIST); + List vendorCodeList = McpParams.codeList(params, "vendor_code_list", McpPages.MAX_CODE_LIST); + List productCodeList = McpParams.codeList(params, "product_code_list", McpPages.MAX_CODE_LIST); + + if (!"ORDER_BIND".equals(entity) && !orderCodeList.isEmpty()) { + throw McpToolException.invalidParams("order_code_list 仅支持 entity=ORDER_BIND;" + + "order_id/purchase_id 无索引,禁止以订单号作为 entity=ORDER 的过滤入口,请改用 entity=ORDER_BIND"); + } + + Integer status = McpParams.intVal(params, "status"); + if (status != null && (status < 0 || status > MAX_STATUS)) { + throw McpToolException.invalidParams("status 取值非法:" + status + ",允许值:0=待入库,1=部分入库,2=已完成"); + } + String approveStatus = McpParams.enumVal(params, "approve_status", APPROVE_STATUS_VALUES, null); + String confirmStatus = McpParams.enumVal(params, "confirm_status", CONFIRM_STATUS_VALUES, null); + String flowType = resolveFlowType(params); + McpTimeRange timeRange = McpParams.timeRange(params, "time_range", DEFAULT_TIME_MONTHS, MAX_TIME_MONTHS); + + int pageSize = McpPages.pageSize(params, McpPages.DEFAULT_PAGE_SIZE, McpPages.MAX_LIST_PAGE_SIZE); + boolean includeDetail = McpParams.boolVal(params, "include_detail", false); + boolean includeTotal = McpParams.boolVal(params, "include_total", false); + + boolean scopeRestricted = !inventoryAuthAll(); + List authVendorCodes = scopeRestricted ? inventoryAuthVendorCodes() : Collections.emptyList(); + + String filterHash = McpParams.filterHash( + entity, + sortedJoin(codeList), + sortedJoin(orderCodeList), + sortedJoin(vendorCodeList), + sortedJoin(productCodeList), + status == null ? null : String.valueOf(status), + approveStatus, + confirmStatus, + flowType, + McpParams.describe(timeRange), + String.valueOf(includeDetail), + sortedJoin(authVendorCodes)); + McpCursor cursor = McpPages.cursor(params, getToolName(), filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + int pageNo = cursor == null ? 1 : cursor.getP(); + + Map query = buildQuery(entity, codeList, orderCodeList, vendorCodeList, productCodeList, + status, approveStatus, confirmStatus, flowType, timeRange, pageSize, includeDetail, includeTotal, pageNo); + + // 数据范围收窄为"无任何授权供应商":与 OmsPurchaseOrderController 一致,直接返回空集(不泄露全量数据) + if (scopeRestricted && authVendorCodes.isEmpty() && !"ORDER_BIND".equals(entity)) { + return emptyResponse(query); + } + + switch (entity) { + case "ORDER": + return handleOrder(codeList, vendorCodeList, authVendorCodes, status, approveStatus, confirmStatus, + flowType, productCodeList, timeRange, pageSize, includeDetail, includeTotal, cursor, pageNo, filterHash, query); + case "ITEM": + return handleItem(codeList, vendorCodeList, authVendorCodes, status, approveStatus, confirmStatus, + flowType, productCodeList, timeRange, pageSize, includeTotal, cursor, pageNo, filterHash, query); + case "HISTORY": + return handleHistory(codeList, status, approveStatus, confirmStatus, flowType, timeRange, + pageSize, includeDetail, includeTotal, cursor, pageNo, filterHash, query); + default: + return handleOrderBind(codeList, orderCodeList, timeRange, pageSize, includeTotal, cursor, pageNo, filterHash, query); + } + } + + // ------------------------------------------------------------------ entity 分支 + + private Object handleOrder(List codeList, List vendorCodeList, List authVendorCodes, + Integer status, String approveStatus, String confirmStatus, String flowType, + List productCodeList, McpTimeRange timeRange, int pageSize, boolean includeDetail, + boolean includeTotal, McpCursor cursor, int pageNo, String filterHash, Map query) { + String lastPurchaseNo = cursor == null ? null : cursor.key(0); + Long lastId = cursor == null ? null : cursor.longKey(1); + + List rows = omsPurchaseOrderMapper.selectPurchaseOrderPage(codeList, vendorCodeList, + authVendorCodes, status, approveStatus, confirmStatus, flowType, productCodeList, + timeRange.getStart(), timeRange.getEnd(), lastPurchaseNo, lastId, pageSize + 1); + boolean hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + + Map> detailMap = includeDetail ? loadOrderDetails(page) : Collections.emptyMap(); + List> items = new ArrayList<>(); + for (OmsPurchaseOrder order : page) { + List details = includeDetail + ? detailMap.getOrDefault(McpFormat.trim(order.getPurchaseNo()), Collections.emptyList()) + : null; + items.add(toOrderItem(order, details)); + } + + String nextCursor = nextCursor(hasMore, page.isEmpty() ? null : page.get(page.size() - 1).getPurchaseNo(), + page.isEmpty() ? null : page.get(page.size() - 1).getId(), pageNo, filterHash); + CountResult count = includeTotal + ? countOrders(codeList, vendorCodeList, authVendorCodes, status, approveStatus, confirmStatus, flowType, productCodeList, timeRange) + : null; + Map pageInfo = McpPages.pageInfo(items.size(), pageSize, hasMore, nextCursor, + "purchase_no,id", pageNo, count == null ? null : count.total, count != null && count.capped, false); + return response(buildMetadata(), query, listData(items, pageInfo, count == null ? null : count.total)); + } + + private Object handleItem(List codeList, List vendorCodeList, List authVendorCodes, + Integer status, String approveStatus, String confirmStatus, String flowType, + List productCodeList, McpTimeRange timeRange, int pageSize, boolean includeTotal, + McpCursor cursor, int pageNo, String filterHash, Map query) { + String lastPurchaseNo = cursor == null ? null : cursor.key(0); + Long lastId = cursor == null ? null : cursor.longKey(1); + + List rows = omsPurchaseOrderMapper.selectPurchaseOrderItemPage(codeList, vendorCodeList, + authVendorCodes, status, approveStatus, confirmStatus, flowType, productCodeList, + timeRange.getStart(), timeRange.getEnd(), lastPurchaseNo, lastId, pageSize + 1); + boolean hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + + List> items = new ArrayList<>(); + for (OmsPurchaseOrderItem row : page) { + items.add(toItemRow(row)); + } + + OmsPurchaseOrderItem last = page.isEmpty() ? null : page.get(page.size() - 1); + String nextCursor = nextCursor(hasMore, last == null ? null : last.getPurchaseNo(), + last == null ? null : last.getId(), pageNo, filterHash); + CountResult count = includeTotal + ? countItems(codeList, vendorCodeList, authVendorCodes, status, approveStatus, confirmStatus, flowType, productCodeList, timeRange) + : null; + Map pageInfo = McpPages.pageInfo(items.size(), pageSize, hasMore, nextCursor, + "purchase_no,id", pageNo, count == null ? null : count.total, count != null && count.capped, false); + return response(buildMetadata(), query, listData(items, pageInfo, count == null ? null : count.total)); + } + + private Object handleHistory(List codeList, Integer status, String approveStatus, String confirmStatus, + String flowType, McpTimeRange timeRange, int pageSize, boolean includeDetail, + boolean includeTotal, McpCursor cursor, int pageNo, String filterHash, Map query) { + String lastPurchaseNo = cursor == null ? null : cursor.key(0); + Long lastId = cursor == null ? null : cursor.longKey(1); + + List rows = omsPurchaseOrderHistoryMapper.listByPurchaseNoList(codeList, status, + approveStatus, confirmStatus, flowType, timeRange.getStart(), timeRange.getEnd(), + lastPurchaseNo, lastId, pageSize + 1); + boolean hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + + Map> detailMap = includeDetail + ? loadHistoryDetails(page) : Collections.emptyMap(); + List> items = new ArrayList<>(); + for (OmsPurchaseOrderHistory history : page) { + List details = includeDetail + ? detailMap.getOrDefault(history.getId(), Collections.emptyList()) : null; + items.add(toHistoryItem(history, details)); + } + + OmsPurchaseOrderHistory last = page.isEmpty() ? null : page.get(page.size() - 1); + String nextCursor = nextCursor(hasMore, last == null ? null : last.getPurchaseNo(), + last == null ? null : last.getId(), pageNo, filterHash); + CountResult count = includeTotal + ? countHistory(codeList, status, approveStatus, confirmStatus, flowType, timeRange) : null; + Map pageInfo = McpPages.pageInfo(items.size(), pageSize, hasMore, nextCursor, + "purchase_no,id", pageNo, count == null ? null : count.total, count != null && count.capped, false); + return response(buildMetadata(), query, listData(items, pageInfo, count == null ? null : count.total)); + } + + private Object handleOrderBind(List codeList, List orderCodeList, McpTimeRange timeRange, + int pageSize, boolean includeTotal, McpCursor cursor, int pageNo, + String filterHash, Map query) { + // ORDER_BIND:code_list 与 order_code_list 均按"订单号"处理 + Set merged = new LinkedHashSet<>(); + merged.addAll(orderCodeList); + merged.addAll(codeList); + List orderCodes = new ArrayList<>(merged); + + Long lastOrderId = cursor == null ? null : cursor.longKey(0); + Long lastPurchaseId = cursor == null ? null : cursor.longKey(1); + Long lastId = cursor == null ? null : cursor.longKey(2); + + List> rows = omsPurchaseOrderMapMapper.listByOrderCodeList(orderCodes, + lastOrderId, lastPurchaseId, lastId, pageSize + 1); + boolean hasMore = McpPages.hasMore(rows, pageSize); + List> page = McpPages.trim(rows, pageSize); + + List> items = new ArrayList<>(); + for (Map row : page) { + Map item = new LinkedHashMap<>(); + item.put("orderId", row.get("orderId")); + item.put("orderCode", McpFormat.trimToNull(row.get("orderCode") == null ? null : String.valueOf(row.get("orderCode")))); + item.put("purchaseId", row.get("purchaseId")); + item.put("purchaseNo", McpFormat.trimToNull(row.get("purchaseNo") == null ? null : String.valueOf(row.get("purchaseNo")))); + item.put("productCode", McpFormat.trimToNull(row.get("productCode") == null ? null : String.valueOf(row.get("productCode")))); + item.put("bindNum", row.get("bindNum")); + items.add(item); + } + + Map last = page.isEmpty() ? null : page.get(page.size() - 1); + List keys = last == null ? null : keysOf(last.get("orderId"), last.get("purchaseId"), last.get("id")); + String nextCursor = hasMore ? McpCursor.encode(getToolName(), filterHash, pageNo + 1, keys) : null; + CountResult count = includeTotal ? countOrderBind(orderCodes) : null; + Map pageInfo = McpPages.pageInfo(items.size(), pageSize, hasMore, nextCursor, + "order_id,purchase_id,id", pageNo, count == null ? null : count.total, count != null && count.capped, false); + return response(buildMetadata(), query, listData(items, pageInfo, count == null ? null : count.total)); + } + + // ------------------------------------------------------------------ 明细批量装载 + + private Map> loadOrderDetails(List page) { + List purchaseNos = new ArrayList<>(); + for (OmsPurchaseOrder order : page) { + String no = McpFormat.trim(order.getPurchaseNo()); + if (no != null) { + purchaseNos.add(no); + } + } + if (purchaseNos.isEmpty()) { + return Collections.emptyMap(); + } + List itemList = omsPurchaseOrderMapper.listItemByCodeList(purchaseNos); + if (itemList == null || itemList.isEmpty()) { + return Collections.emptyMap(); + } + Map> result = new LinkedHashMap<>(); + for (OmsPurchaseOrderItem item : itemList) { + String no = McpFormat.trim(item.getPurchaseNo()); + if (no == null) { + continue; + } + result.computeIfAbsent(no, key -> new ArrayList<>()).add(item); + } + return result; + } + + private Map> loadHistoryDetails(List page) { + List ids = new ArrayList<>(); + for (OmsPurchaseOrderHistory history : page) { + if (history.getId() != null) { + ids.add(history.getId()); + } + } + if (ids.isEmpty()) { + return Collections.emptyMap(); + } + List itemList = omsPurchaseOrderHistoryMapper.listItemByPurchaseHistoryIdList(ids); + if (itemList == null || itemList.isEmpty()) { + return Collections.emptyMap(); + } + Map> result = new LinkedHashMap<>(); + for (OmsPurchaseOrderItemHistory item : itemList) { + if (item.getPurchaseHistoryId() == null) { + continue; + } + result.computeIfAbsent(item.getPurchaseHistoryId(), key -> new ArrayList<>()).add(item); + } + return result; + } + + // ------------------------------------------------------------------ 行 → 输出 + + private Map toOrderItem(OmsPurchaseOrder order, List details) { + Map item = new LinkedHashMap<>(); + item.put("id", order.getId()); + item.put("purchaseNo", McpFormat.trim(order.getPurchaseNo())); + item.put("buyerName", order.getBuyerName()); + item.put("buyerAddress", order.getBuyerAddress()); + item.put("vendorId", order.getVendorId()); + item.put("vendorCode", McpFormat.trim(order.getVendorCode())); + item.put("vendorName", order.getVendorName()); + item.put("vendorAddress", order.getVendorAddress()); + item.put("vendorUser", order.getVendorUser()); + item.put("vendorPhone", order.getVendorPhone()); + item.put("taxRate", order.getTaxRate()); + item.put("currency", order.getCurrency()); + item.put("purchaserId", order.getPurchaserId()); + item.put("purchaserName", order.getPurchaserName()); + item.put("purchaserMobile", order.getPurchaserMobile()); + item.put("purchaserEmail", order.getPurchaserEmail()); + item.put("warehouseId", order.getWarehouseId()); + item.put("warehouseName", order.getWarehouseName()); + item.put("payMethod", order.getPayMethod()); + item.put("payMethodName", payMethodName(order.getPayMethod())); + item.put("ownerId", order.getOwnerId()); + item.put("ownerName", order.getOwnerName()); + item.put("totalAmount", order.getTotalAmount()); + item.put("status", order.getStatus()); + item.put("statusName", statusName(order.getStatus())); + item.put("approveStatus", order.getApproveStatus()); + item.put("approveStatusName", approveStatusName(order.getApproveStatus())); + item.put("approveTime", McpFormat.dateTime(order.getApproveTime())); + item.put("approveNode", order.getApproveNode()); + item.put("confirmStatus", order.getConfirmStatus()); + item.put("confirmStatusName", confirmStatusName(order.getConfirmStatus())); + item.put("purchaseDate", McpFormat.date(order.getPurchaseDate())); + item.put("flowType", order.getFlowType()); + item.put("flowTypeName", flowTypeName(order.getFlowType())); + item.put("isVirtual", order.getIsVirtual()); + item.put("version", order.getVersion()); + item.put("remark", order.getRemark()); + if (details != null) { + List> detailItems = new ArrayList<>(); + for (OmsPurchaseOrderItem detail : details) { + detailItems.add(toItemRow(detail)); + } + item.put("items", detailItems); + } + return item; + } + + private Map toItemRow(OmsPurchaseOrderItem row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("purchaseId", row.getPurchaseId()); + item.put("purchaseNo", McpFormat.trim(row.getPurchaseNo())); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("productModel", row.getProductModel()); + item.put("productType", row.getProductType()); + item.put("productDescription", row.getProductDescription()); + item.put("quantity", row.getQuantity()); + item.put("innerQuantity", row.getInnerQuantity()); + item.put("pendingQuantity", pendingQuantity(row.getQuantity(), row.getInnerQuantity())); + item.put("price", row.getPrice()); + item.put("taxRate", row.getTaxRate()); + item.put("taxTotal", row.getTaxTotal()); + item.put("amountTotal", row.getAmountTotal()); + item.put("deliveryDate", McpFormat.date(row.getDeliveryDate())); + return item; + } + + private Map toHistoryItem(OmsPurchaseOrderHistory history, List details) { + Map item = new LinkedHashMap<>(); + item.put("id", history.getId()); + item.put("purchaseId", history.getPurchaseId()); + item.put("purchaseNo", McpFormat.trim(history.getPurchaseNo())); + item.put("version", history.getVersion()); + item.put("buyerName", history.getBuyerName()); + item.put("buyerAddress", history.getBuyerAddress()); + item.put("vendorId", history.getVendorId()); + item.put("vendorName", history.getVendorName()); + item.put("vendorUser", history.getVendorUser()); + item.put("vendorPhone", history.getVendorPhone()); + item.put("currency", history.getCurrency()); + item.put("purchaserId", history.getPurchaserId()); + item.put("purchaserName", history.getPurchaserName()); + item.put("purchaserMobile", history.getPurchaserMobile()); + item.put("purchaserEmail", history.getPurchaserEmail()); + item.put("warehouseId", history.getWarehouseId()); + item.put("payMethod", history.getPayMethod()); + item.put("payMethodName", payMethodName(history.getPayMethod())); + item.put("ownerId", history.getOwnerId()); + item.put("ownerName", history.getOwnerName()); + item.put("totalAmount", history.getTotalAmount()); + item.put("status", history.getStatus()); + item.put("statusName", statusName(history.getStatus())); + String approveStatus = history.getApproveStatus() == null ? null : String.valueOf(history.getApproveStatus()); + item.put("approveStatus", approveStatus); + item.put("approveStatusName", approveStatusName(approveStatus)); + item.put("approveTime", McpFormat.dateTime(history.getApproveTime())); + item.put("approveNode", history.getApproveNode()); + item.put("confirmStatus", history.getConfirmStatus()); + item.put("confirmStatusName", confirmStatusName(history.getConfirmStatus())); + item.put("flowType", history.getFlowType()); + item.put("flowTypeName", flowTypeName(history.getFlowType())); + item.put("isVirtual", history.getIsVirtual()); + item.put("createTime", McpFormat.dateTime(history.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(history.getUpdateTime())); + item.put("remark", history.getRemark()); + if (details != null) { + List> detailItems = new ArrayList<>(); + for (OmsPurchaseOrderItemHistory detail : details) { + detailItems.add(toHistoryDetailRow(detail)); + } + item.put("items", detailItems); + } + return item; + } + + private Map toHistoryDetailRow(OmsPurchaseOrderItemHistory row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("purchaseHistoryId", row.getPurchaseHistoryId()); + item.put("originalPurchaseId", row.getOriginalPurchaseId()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("productModel", row.getProductModel()); + item.put("productType", row.getProductType()); + item.put("productDescription", row.getProductDescription()); + item.put("quantity", row.getQuantity()); + item.put("price", row.getPrice()); + item.put("taxRate", row.getTaxRate()); + item.put("taxTotal", row.getTaxTotal()); + item.put("amountTotal", row.getAmountTotal()); + item.put("deliveryDate", McpFormat.date(row.getDeliveryDate())); + item.put("innerStatus", row.getInnerStatus()); + return item; + } + + // ------------------------------------------------------------------ 计数(include_total,上限 count_cap) + + private CountResult countOrders(List codeList, List vendorCodeList, List authVendorCodes, + Integer status, String approveStatus, String confirmStatus, String flowType, + List productCodeList, McpTimeRange timeRange) { + List rows = omsPurchaseOrderMapper.selectPurchaseOrderPage(codeList, vendorCodeList, + authVendorCodes, status, approveStatus, confirmStatus, flowType, productCodeList, + timeRange.getStart(), timeRange.getEnd(), null, null, COUNT_CAP + 1); + return CountResult.of(rows.size()); + } + + private CountResult countItems(List codeList, List vendorCodeList, List authVendorCodes, + Integer status, String approveStatus, String confirmStatus, String flowType, + List productCodeList, McpTimeRange timeRange) { + List rows = omsPurchaseOrderMapper.selectPurchaseOrderItemPage(codeList, vendorCodeList, + authVendorCodes, status, approveStatus, confirmStatus, flowType, productCodeList, + timeRange.getStart(), timeRange.getEnd(), null, null, COUNT_CAP + 1); + return CountResult.of(rows.size()); + } + + private CountResult countHistory(List codeList, Integer status, String approveStatus, String confirmStatus, + String flowType, McpTimeRange timeRange) { + List rows = omsPurchaseOrderHistoryMapper.listByPurchaseNoList(codeList, status, + approveStatus, confirmStatus, flowType, timeRange.getStart(), timeRange.getEnd(), null, null, COUNT_CAP + 1); + return CountResult.of(rows.size()); + } + + private CountResult countOrderBind(List orderCodes) { + List> rows = omsPurchaseOrderMapMapper.listByOrderCodeList(orderCodes, null, null, null, COUNT_CAP + 1); + return CountResult.of(rows.size()); + } + + // ------------------------------------------------------------------ 辅助 + + private String resolveFlowType(Map params) { + String value = McpParams.str(params, "flow_type"); + if (value == null) { + return null; + } + String normalized = value.toLowerCase(); + if (!FLOW_TYPE_VALUES.contains(normalized)) { + throw McpToolException.invalidParams("flow_type 取值非法:" + value + ",允许值:" + FLOW_TYPE_VALUES); + } + return normalized; + } + + private String nextCursor(boolean hasMore, String purchaseNo, Long id, int pageNo, String filterHash) { + if (!hasMore) { + return null; + } + return McpCursor.encode(getToolName(), filterHash, pageNo + 1, keysOf(McpFormat.trim(purchaseNo), id)); + } + + private String sortedJoin(List values) { + if (values == null || values.isEmpty()) { + return ""; + } + List sorted = new ArrayList<>(new TreeSet<>(values)); + return String.join(",", sorted); + } + + private BigDecimal pendingQuantity(BigDecimal quantity, BigDecimal innerQuantity) { + BigDecimal purchase = quantity == null ? BigDecimal.ZERO : quantity; + BigDecimal inner = innerQuantity == null ? BigDecimal.ZERO : innerQuantity; + BigDecimal pending = purchase.subtract(inner); + return pending.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : pending; + } + + private String statusName(Integer status) { + if (status == null) { + return null; + } + for (OmsPurchaseOrder.StatusEnum value : OmsPurchaseOrder.StatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getValue(); + } + } + return String.valueOf(status); + } + + private String approveStatusName(String code) { + if (code == null) { + return null; + } + for (ApproveStatusEnum value : ApproveStatusEnum.values()) { + if (value.getCode().equals(code)) { + return value.getValue(); + } + } + return code; + } + + private String confirmStatusName(String code) { + if (code == null) { + return null; + } + for (OmsPurchaseOrder.ConfirmStatusEnum value : OmsPurchaseOrder.ConfirmStatusEnum.values()) { + if (value.getCode().equals(code)) { + return value.getValue(); + } + } + return code; + } + + private String flowTypeName(String code) { + if (code == null) { + return null; + } + for (OmsPurchaseOrder.FlowTypeEnum value : OmsPurchaseOrder.FlowTypeEnum.values()) { + if (value.getCode().equals(code)) { + return value.getValue(); + } + } + return code; + } + + private String payMethodName(String code) { + if (code == null) { + return null; + } + if ("1".equals(code)) { + return "出库付款"; + } + if ("0".equals(code) || "2".equals(code)) { + return "入库付款"; + } + return code; + } + + private Map buildQuery(String entity, List codeList, List orderCodeList, + List vendorCodeList, List productCodeList, Integer status, + String approveStatus, String confirmStatus, String flowType, + McpTimeRange timeRange, int pageSize, boolean includeDetail, + boolean includeTotal, int pageNo) { + Map query = new LinkedHashMap<>(); + query.put("entity", entity); + query.put("code_list", codeList); + query.put("order_code_list", orderCodeList); + query.put("vendor_code_list", vendorCodeList); + query.put("product_code_list", productCodeList); + query.put("status", status); + query.put("approve_status", approveStatus); + query.put("confirm_status", confirmStatus); + query.put("flow_type", flowType); + query.put("time_range", McpParams.describe(timeRange)); + query.put("page_size", pageSize); + query.put("page_no", pageNo); + query.put("include_detail", includeDetail); + query.put("include_total", includeTotal); + return query; + } + + private Object emptyResponse(Map query) { + Map pageInfo = McpPages.pageInfo(0, McpPages.DEFAULT_PAGE_SIZE, false, null, "purchase_no,id", 1, null, false, false); + return response(buildMetadata(), query, listData(Collections.emptyList(), pageInfo, null)); + } + + private Map buildMetadata() { + Map metadata = metadata("采购域列表/范围查询:entity=ORDER 采购单、ITEM 采购明细、HISTORY 采购单历史、ORDER_BIND 采购-订单绑定;" + + "按 purchase_no,id(ORDER/ITEM/HISTORY)或 order_id,purchase_id,id(ORDER_BIND)游标分页。"); + metadata.put("query_fields", mapOf( + "entity", "查询对象:ORDER/ITEM/HISTORY/ORDER_BIND", + "code_list", "单号列表:ORDER/ITEM/HISTORY 传 purchase_no,ORDER_BIND 传 order_code", + "order_code_list", "订单号(合同编号)列表,仅 entity=ORDER_BIND 支持", + "vendor_code_list", "制造商编码列表", + "status", "采购状态(0待入库/1部分入库/2已完成)", + "approve_status", "审批状态(0草稿/1审批中/2已通过/3驳回)", + "confirm_status", "供应商确认状态(0待确认/1已确认/2已驳回)", + "flow_type", "流程类型(online线上/offline线下)", + "product_code_list", "产品编码列表", + "time_range", "时间区间 start~end;ORDER/ITEM 基于 purchase_date,HISTORY 基于 create_time;默认近12个月、上限36个月", + "include_detail", "是否返回明细行(默认false)", + "include_total", "是否统计总数(默认false,上限50000)", + "page_size", "每页条数(默认20,上限100)", + "cursor", "游标:回传上次的 page_info.next_cursor")); + metadata.put("data_fields", mapOf( + "total", "命中条数(include_total=true 时为真实值,否则为本页条数)", + "items", "结果列表", + "page_info", "分页信息:returned/page_size/has_more/next_cursor/sort_by/page_no/total_count/total_count_capped")); + metadata.put("item_fields", buildItemFieldMetadata()); + metadata.put("dict_fields", mapOf( + "statusName", "枚举 OmsPurchaseOrder.StatusEnum(0待入库/1部分入库/2已完成)", + "approveStatusName", "枚举 com.ruoyi.common.enums.ApproveStatusEnum(0待提交/1待审核/2审批完成/3已驳回;本工具入参文档写作 草稿/审批中/已通过/驳回)", + "confirmStatusName", "枚举 OmsPurchaseOrder.ConfirmStatusEnum(0待确认/1已确认/2已驳回)", + "flowTypeName", "枚举 OmsPurchaseOrder.FlowTypeEnum(online线上/offline线下)", + "payMethodName", "字典编码映射(0/2=入库付款,1=出库付款,来源 OmsPurchaseOrder#payMethod 的 readConverterExp)")); + metadata.put("sort_by", mapOf( + "ORDER", "purchase_no,id", + "ITEM", "purchase_no,id", + "HISTORY", "purchase_no,id", + "ORDER_BIND", "order_id,purchase_id,id")); + metadata.put("aggregation_rule", mapOf( + "order_bind_scope", "entity=ORDER_BIND 的表 oms_purchase_order_map 无 order_id 索引,仅适用于小数据量(实测 0/少量行)", + "order_filter_guard", "禁止用 order_code_list 过滤 entity=ORDER(order_id/purchase_id 无索引会全表扫描),请改用 entity=ORDER_BIND", + "del_flag", "按 del_flag 过滤逻辑删除(del_flag=0 或 is null 视为有效)", + "time_pushdown", "时间条件一律区间下推(>= start and < end),不使用 date_format", + "case_insensitive", "编码匹配不区分大小写(utf8mb4_unicode_ci),返回编码统一 trim")); + metadata.put("data_scope", mapOf( + "order_item_history", "回填 authVendorCodeList(= IInventoryAuthService.currentVendor() 的 vendor_code,与 OmsPurchaseOrderController 拼法一致)", + "no_scope", "ORDER_BIND 无供应商维度数据范围")); + metadata.put("notes", "若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 继续调用直到 has_more 为 false,否则数据不完整。"); + return metadata; + } + + private Map buildItemFieldMetadata() { + Map fields = new LinkedHashMap<>(); + + Map orderFields = new LinkedHashMap<>(); + orderFields.put("id", "采购单ID"); + orderFields.put("purchaseNo", "采购单号"); + orderFields.put("buyerName", "采购方名称"); + orderFields.put("buyerAddress", "采购方地址"); + orderFields.put("vendorId", "制造商ID"); + orderFields.put("vendorCode", "制造商编码"); + orderFields.put("vendorName", "供应商名称"); + orderFields.put("vendorAddress", "供应商地址"); + orderFields.put("vendorUser", "供应商联系人"); + orderFields.put("vendorPhone", "供应商电话"); + orderFields.put("taxRate", "税率(%,取明细税率最大值)"); + orderFields.put("currency", "币别"); + orderFields.put("purchaserId", "采购员ID"); + orderFields.put("purchaserName", "采购员姓名"); + orderFields.put("purchaserMobile", "采购员电话"); + orderFields.put("purchaserEmail", "采购员邮箱"); + orderFields.put("warehouseId", "入库仓库ID"); + orderFields.put("warehouseName", "入库仓库名称"); + orderFields.put("payMethod", "付款方式编码(0/2入库付款,1出库付款)"); + orderFields.put("payMethodName", "付款方式名称"); + orderFields.put("ownerId", "汇智负责人ID"); + orderFields.put("ownerName", "汇智负责人姓名"); + orderFields.put("totalAmount", "含税总金额"); + orderFields.put("status", "采购状态编码(0待入库/1部分入库/2已完成)"); + orderFields.put("statusName", "采购状态名称"); + orderFields.put("approveStatus", "审批状态编码(0草稿/1审批中/2已通过/3驳回)"); + orderFields.put("approveStatusName", "审批状态名称"); + orderFields.put("approveTime", "审批时间"); + orderFields.put("approveNode", "当前审批节点"); + orderFields.put("confirmStatus", "供应商确认状态编码(0待确认/1已确认/2已驳回)"); + orderFields.put("confirmStatusName", "供应商确认状态名称"); + orderFields.put("purchaseDate", "采购日期"); + orderFields.put("flowType", "流程类型(online/offline)"); + orderFields.put("flowTypeName", "流程类型名称"); + orderFields.put("isVirtual", "是否虚拟采购单"); + orderFields.put("version", "版本号"); + orderFields.put("remark", "备注"); + orderFields.put("items", "include_detail=true 时的采购明细行数组(见 ITEM 字段)"); + fields.put("ORDER", orderFields); + + Map itemFields = new LinkedHashMap<>(); + itemFields.put("id", "明细ID"); + itemFields.put("purchaseId", "采购单ID"); + itemFields.put("purchaseNo", "采购单号"); + itemFields.put("productCode", "产品BOM编码"); + itemFields.put("productModel", "产品型号"); + itemFields.put("productType", "产品类型"); + itemFields.put("productDescription", "产品描述"); + itemFields.put("quantity", "采购数量"); + itemFields.put("innerQuantity", "已入库数量"); + itemFields.put("pendingQuantity", "未入库数量(quantity - inner_quantity,下限0)"); + itemFields.put("price", "单价"); + itemFields.put("taxRate", "税率(%)"); + itemFields.put("taxTotal", "税额"); + itemFields.put("amountTotal", "含税金额"); + itemFields.put("deliveryDate", "交货日期"); + fields.put("ITEM", itemFields); + + Map bindFields = new LinkedHashMap<>(); + bindFields.put("orderId", "订单ID(project_order_info.id)"); + bindFields.put("orderCode", "订单号/合同编号"); + bindFields.put("purchaseId", "采购单ID"); + bindFields.put("purchaseNo", "采购单号"); + bindFields.put("productCode", "产品编码"); + bindFields.put("bindNum", "绑定数量"); + fields.put("ORDER_BIND", bindFields); + + Map historyFields = new LinkedHashMap<>(); + historyFields.put("id", "历史记录ID"); + historyFields.put("purchaseId", "原始采购单ID"); + historyFields.put("purchaseNo", "采购单号"); + historyFields.put("version", "版本号"); + historyFields.put("buyerName", "采购方名称"); + historyFields.put("buyerAddress", "采购方地址"); + historyFields.put("vendorId", "制造商ID"); + historyFields.put("vendorName", "供应商名称"); + historyFields.put("vendorUser", "供应商联系人"); + historyFields.put("vendorPhone", "供应商电话"); + historyFields.put("currency", "币别"); + historyFields.put("purchaserId", "采购员ID"); + historyFields.put("purchaserName", "采购员姓名"); + historyFields.put("purchaserMobile", "采购员电话"); + historyFields.put("purchaserEmail", "采购员邮箱"); + historyFields.put("warehouseId", "入库仓库ID"); + historyFields.put("payMethod", "付款方式编码"); + historyFields.put("payMethodName", "付款方式名称"); + historyFields.put("ownerId", "汇智负责人ID"); + historyFields.put("ownerName", "汇智负责人姓名"); + historyFields.put("totalAmount", "含税总金额"); + historyFields.put("status", "采购状态编码"); + historyFields.put("statusName", "采购状态名称"); + historyFields.put("approveStatus", "审批状态编码"); + historyFields.put("approveStatusName", "审批状态名称"); + historyFields.put("approveTime", "审批时间"); + historyFields.put("approveNode", "当前审批节点"); + historyFields.put("confirmStatus", "确认状态编码"); + historyFields.put("confirmStatusName", "确认状态名称"); + historyFields.put("flowType", "流程类型"); + historyFields.put("flowTypeName", "流程类型名称"); + historyFields.put("isVirtual", "是否虚拟采购单"); + historyFields.put("createTime", "创建时间(HISTORY 的时间维度字段)"); + historyFields.put("updateTime", "更新时间"); + historyFields.put("remark", "备注"); + historyFields.put("items", "include_detail=true 时的历史明细行数组"); + fields.put("HISTORY", historyFields); + + Map historyDetailFields = new LinkedHashMap<>(); + historyDetailFields.put("id", "历史明细ID"); + historyDetailFields.put("purchaseHistoryId", "关联采购单历史ID"); + historyDetailFields.put("originalPurchaseId", "原始采购单ID"); + historyDetailFields.put("productCode", "产品BOM编码"); + historyDetailFields.put("productModel", "产品型号"); + historyDetailFields.put("productType", "产品类型"); + historyDetailFields.put("productDescription", "产品描述"); + historyDetailFields.put("quantity", "采购数量"); + historyDetailFields.put("price", "单价"); + historyDetailFields.put("taxRate", "税率(%)"); + historyDetailFields.put("taxTotal", "税额"); + historyDetailFields.put("amountTotal", "含税金额"); + historyDetailFields.put("deliveryDate", "交货日期"); + historyDetailFields.put("innerStatus", "入库状态(0待入库,1已完成)"); + fields.put("HISTORY_DETAIL", historyDetailFields); + + return fields; + } + + private static class CountResult { + private final long total; + private final boolean capped; + + private CountResult(long total, boolean capped) { + this.total = total; + this.capped = capped; + } + + private static CountResult of(int size) { + if (size > COUNT_CAP) { + return new CountResult(COUNT_CAP, true); + } + return new CountResult(size, false); + } + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/WarehouseListToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/WarehouseListToolProvider.java new file mode 100644 index 00000000..3d14d694 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/WarehouseListToolProvider.java @@ -0,0 +1,950 @@ +package com.ruoyi.sip.llm.tools; + +import com.ruoyi.sip.domain.DeliveryList; +import com.ruoyi.sip.domain.InventoryDelivery; +import com.ruoyi.sip.domain.InventoryInfo; +import com.ruoyi.sip.domain.InventoryOuter; +import com.ruoyi.sip.domain.InventoryOuterDetail; +import com.ruoyi.sip.domain.OmsInventoryDeliveryDetail; +import com.ruoyi.sip.domain.OmsInventoryInner; +import com.ruoyi.sip.domain.OmsStockInfo; +import com.ruoyi.sip.domain.ProjectOrderInfoRecall; +import com.ruoyi.sip.llm.tools.support.AbstractMcpQueryToolProvider; +import com.ruoyi.sip.llm.tools.support.McpAuth; +import com.ruoyi.sip.llm.tools.support.McpCursor; +import com.ruoyi.sip.llm.tools.support.McpFormat; +import com.ruoyi.sip.llm.tools.support.McpPages; +import com.ruoyi.sip.llm.tools.support.McpParams; +import com.ruoyi.sip.llm.tools.support.McpTimeRange; +import com.ruoyi.sip.llm.tools.support.McpToolException; +import com.ruoyi.sip.mapper.DeliveryListMapper; +import com.ruoyi.sip.mapper.InventoryDeliveryMapper; +import com.ruoyi.sip.mapper.InventoryInfoMapper; +import com.ruoyi.sip.mapper.InventoryOuterDetailMapper; +import com.ruoyi.sip.mapper.InventoryOuterMapper; +import com.ruoyi.sip.mapper.OmsInventoryDeliveryDetailMapper; +import com.ruoyi.sip.mapper.OmsInventoryInnerMapper; +import com.ruoyi.sip.mapper.OmsStockInfoMapper; +import com.ruoyi.sip.mapper.OrderDeliveryMapper; +import com.ruoyi.sip.mapper.ProjectOrderInfoRecallMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * MCP 工具 warehouse_list(列表 / 范围查询,游标分页,entity 参数化)。 + *

权限来源(逐个 entity 从对应 Controller 的 {@code @RequiresPermissions} 抄取): + * INNER→OmsInventoryInnerController、OUTER→InventoryOuterController、DELIVERY→InventoryDeliveryController(VueDeliveryController)、 + * ORDER_DELIVERY→OrderDeliveryController、STOCK→OmsStockInfoController、SN→InventoryInfoController、 + * RECALL→ProjectOrderInfoController(待确认)。

+ */ +@Component +public class WarehouseListToolProvider extends AbstractMcpQueryToolProvider { + + private static final Set ENTITIES = new HashSet<>(Arrays.asList( + "INNER", "OUTER", "DELIVERY", "ORDER_DELIVERY", "STOCK", "SN", "RECALL")); + + private static final int MAX_SN = 50; + private static final int MAX_CODE = 20; + private static final long COUNT_CAP = 50000L; + private static final int DEFAULT_MONTHS = 12; + private static final int MAX_MONTHS = 36; + + @Autowired + private OmsInventoryInnerMapper omsInventoryInnerMapper; + + @Autowired + private InventoryOuterMapper inventoryOuterMapper; + + @Autowired + private InventoryOuterDetailMapper inventoryOuterDetailMapper; + + @Autowired + private InventoryDeliveryMapper inventoryDeliveryMapper; + + @Autowired + private OmsInventoryDeliveryDetailMapper omsInventoryDeliveryDetailMapper; + + @Autowired + private OrderDeliveryMapper orderDeliveryMapper; + + @Autowired + private DeliveryListMapper deliveryListMapper; + + @Autowired + private OmsStockInfoMapper omsStockInfoMapper; + + @Autowired + private InventoryInfoMapper inventoryInfoMapper; + + @Autowired + private ProjectOrderInfoRecallMapper projectOrderInfoRecallMapper; + + @Override + protected String getToolName() { + return "warehouse_list"; + } + + @Override + protected String getToolDescription() { + return "仓储域列表 / 范围查询(entity 参数化):入库单(INNER)、出库单(OUTER)、仓储发货单(DELIVERY)、" + + "manage 域发货单(ORDER_DELIVERY)、备货(STOCK)、SN 明细(SN)、撤回历史(RECALL)。支持按单号/状态/时间范围等过滤。" + + "分页查询时:若返回 page_info.has_more 为 true,必须携带 page_info.next_cursor 再次调用本工具," + + "重复直到 has_more 为 false,否则结果不完整;不要用其它参数代替 cursor。"; + } + + @Override + protected Map buildInputSchema() { + Map properties = new LinkedHashMap<>(); + properties.put("entity", enumProperty("查询实体(必填):INNER=入库单 / OUTER=出库单 / DELIVERY=仓储发货单 / " + + "ORDER_DELIVERY=manage 域发货单 / STOCK=备货 / SN=SN 明细 / RECALL=撤回历史", + "INNER", "OUTER", "DELIVERY", "ORDER_DELIVERY", "STOCK", "SN", "RECALL")); + properties.put("inner_code_list", arrayProperty("入库单号列表,≤" + MAX_CODE + "(entity=INNER)")); + properties.put("outer_code_list", arrayProperty("出库单号列表,≤" + MAX_CODE + "(entity=OUTER)")); + properties.put("delivery_code_list", arrayProperty("发货单号列表,≤" + MAX_CODE + "(entity=ORDER_DELIVERY)")); + properties.put("order_code_list", arrayProperty("合同编号列表,≤" + MAX_CODE + "(entity=OUTER/ORDER_DELIVERY)")); + properties.put("product_code_list", arrayProperty("产品编码列表,≤" + MAX_CODE + + "(entity=OUTER/DELIVERY/SN)")); + properties.put("product_sn_list", arrayProperty("产品序列号列表,≤" + MAX_SN + "(entity=SN)")); + properties.put("outer_status_list", arrayProperty("出库状态列表(entity=OUTER):1待确认/2已确认/3已接收/4已退回")); + properties.put("purchase_no", stringProperty("采购单号(entity=INNER)")); + properties.put("order_code", stringProperty("合同编号(entity=DELIVERY/STOCK/SN/RECALL)")); + properties.put("outer_code", stringProperty("出库单号(entity=DELIVERY/SN)")); + properties.put("inner_code", stringProperty("入库单号(entity=SN)")); + properties.put("delivery_status", stringProperty("发货状态(entity=DELIVERY:0待发货/1已发货/2撤回;entity=ORDER_DELIVERY:实测取值为拼音缩写 qs=已签收、yf=已发货,同值数字 1/2/3 亦兼容)")); + properties.put("stock_status", stringProperty("备货状态(entity=STOCK):0=未备货,1=已备货")); + properties.put("inventory_status", stringProperty("库存状态(entity=SN):0=入库,1=出库")); + properties.put("time_range", stringProperty("时间范围,格式 \"开始~结束\"(yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss);" + + "各 entity 的时间字段:INNER/OUTER/STOCK/RECALL=create_time、DELIVERY=delivery_time、ORDER_DELIVERY=delivery_date;" + + "未传时默认近 " + DEFAULT_MONTHS + " 个月,跨度上限 " + MAX_MONTHS + " 个月")); + properties.put("time_range_start", stringProperty("时间范围开始(可选,与 time_range 二选一)")); + properties.put("time_range_end", stringProperty("时间范围结束(可选,与 time_range 二选一)")); + properties.put("include_detail", boolProperty("是否返回明细,默认 false(明细来源见 description/metadata)")); + properties.put("include_total", boolProperty("是否统计总条数,默认 false;true 时受 count_cap=" + COUNT_CAP + " 限制")); + withPagingProperties(properties, McpPages.MAX_LIST_PAGE_SIZE, McpPages.DEFAULT_PAGE_SIZE); + return objectSchema(properties, "entity"); + } + + @Override + protected Object handle(Map params) { + String entity = McpParams.requireEnum(params, "entity", ENTITIES); + McpAuth.requireAnyPermission(permissionsOf(entity)); + + int pageSize = McpPages.pageSize(params, McpPages.DEFAULT_PAGE_SIZE, McpPages.MAX_LIST_PAGE_SIZE); + boolean includeDetail = McpParams.boolVal(params, "include_detail", false); + boolean includeTotal = McpParams.boolVal(params, "include_total", false); + + EntityResult result = runEntity(entity, params, pageSize, includeDetail, includeTotal); + + Map pageInfo = McpPages.pageInfo(result.items.size(), pageSize, result.hasMore, + result.hasMore ? McpCursor.encode(getToolName(), result.filterHash, result.pageNo + 1, result.lastSortKeys) : null, + result.sortBy, result.pageNo, result.total, result.totalCapped, false); + + Map data = listData(result.items, pageInfo, result.total); + return response(buildMetadata(entity), queryEcho(entity, params, pageSize, includeDetail, includeTotal), data); + } + + // ------------------------------------------------------------------ entity 分发 + + private EntityResult runEntity(String entity, + Map params, + int pageSize, + boolean includeDetail, + boolean includeTotal) { + switch (entity) { + case "INNER": + return runInner(params, pageSize, includeDetail, includeTotal); + case "OUTER": + return runOuter(params, pageSize, includeDetail, includeTotal); + case "DELIVERY": + return runDelivery(params, pageSize, includeDetail, includeTotal); + case "ORDER_DELIVERY": + return runOrderDelivery(params, pageSize, includeDetail, includeTotal); + case "STOCK": + return runStock(params, pageSize, includeTotal); + case "SN": + return runSn(params, pageSize, includeTotal); + case "RECALL": + return runRecall(params, pageSize, includeTotal); + default: + throw McpToolException.invalidParams("不支持的 entity:" + entity); + } + } + + private EntityResult runInner(Map params, int pageSize, boolean includeDetail, boolean includeTotal) { + List innerCodes = McpParams.codeList(params, "inner_code_list", MAX_CODE); + String purchaseNo = McpParams.str(params, "purchase_no"); + String orderCode = McpParams.str(params, "order_code"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + EntityResult result = new EntityResult("inner_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "INNER", String.join(",", innerCodes), purchaseNo, + orderCode, McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + Map q = new HashMap<>(); + q.put("innerCodeList", innerCodes); + q.put("purchaseNo", purchaseNo); + q.put("orderCode", orderCode); + q.put("startTime", range.getStart()); + q.put("endTime", range.getEnd()); + q.put("limit", pageSize + 1); + putCursor(q, cursor); + + List rows = omsInventoryInnerMapper.listInnerPage(q); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + List pageInnerCodes = new ArrayList<>(); + for (OmsInventoryInner row : page) { + result.items.add(toInnerItem(row)); + String code = McpFormat.trimToNull(row.getInnerCode()); + if (code != null) { + pageInnerCodes.add(code); + } + } + if (!page.isEmpty()) { + OmsInventoryInner last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getInnerCode(), last.getId()); + } + if (includeDetail && !pageInnerCodes.isEmpty()) { + // 明细来源:oms_inventory_info(按 inner_code)——oms_inventory_inner_detail 实测仅 1 行、不采用 + Map>> detailMap = new LinkedHashMap<>(); + List sns = inventoryInfoMapper.selectInventoryInfoByInnerCodeList(pageInnerCodes); + if (sns != null) { + for (InventoryInfo sn : sns) { + String code = McpFormat.trimToNull(sn.getInnerCode()); + if (code != null) { + detailMap.computeIfAbsent(code, k -> new ArrayList<>()).add(toSnItem(sn)); + } + } + } + for (Map item : result.items) { + item.put("snDetails", detailMap.getOrDefault(String.valueOf(item.get("innerCode")), Collections.emptyList())); + } + } + if (includeTotal) { + applyTotal(result, omsInventoryInnerMapper.countInnerPage(q)); + } + return result; + } + + private EntityResult runOuter(Map params, int pageSize, boolean includeDetail, boolean includeTotal) { + List outerCodes = McpParams.codeList(params, "outer_code_list", MAX_CODE); + List orderCodes = McpParams.codeList(params, "order_code_list", MAX_CODE); + List productCodes = McpParams.codeList(params, "product_code_list", MAX_CODE); + List outerStatusList = McpParams.codeList(params, "outer_status_list", MAX_CODE); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + EntityResult result = new EntityResult("outer_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "OUTER", String.join(",", outerCodes), + String.join(",", orderCodes), String.join(",", productCodes), String.join(",", outerStatusList), + McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + Map q = new HashMap<>(); + q.put("outerCodeList", outerCodes); + q.put("orderCodeList", orderCodes); + q.put("productCodeList", productCodes); + q.put("outerStatusList", outerStatusList); + q.put("startTime", range.getStart()); + q.put("endTime", range.getEnd()); + q.put("limit", pageSize + 1); + putCursor(q, cursor); + + List rows = inventoryOuterMapper.listOuterPage(q); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + List pageOuterCodes = new ArrayList<>(); + for (InventoryOuter row : page) { + result.items.add(toOuterItem(row)); + String code = McpFormat.trimToNull(row.getOuterCode()); + if (code != null) { + pageOuterCodes.add(code); + } + } + if (!page.isEmpty()) { + InventoryOuter last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getOuterCode(), last.getId()); + } + if (includeDetail && !pageOuterCodes.isEmpty()) { + Map>> detailMap = new LinkedHashMap<>(); + List details = inventoryOuterDetailMapper.listByOuterCodeList(pageOuterCodes); + if (details != null) { + for (InventoryOuterDetail detail : details) { + String code = McpFormat.trimToNull(detail.getOuterCode()); + if (code != null) { + detailMap.computeIfAbsent(code, k -> new ArrayList<>()).add(toOuterDetailItem(detail)); + } + } + } + for (Map item : result.items) { + item.put("outerDetails", detailMap.getOrDefault(String.valueOf(item.get("outerCode")), Collections.emptyList())); + } + } + if (includeTotal) { + applyTotal(result, inventoryOuterMapper.countOuterPage(q)); + } + return result; + } + + private EntityResult runDelivery(Map params, int pageSize, boolean includeDetail, boolean includeTotal) { + String outerCode = McpParams.str(params, "outer_code"); + String orderCode = McpParams.str(params, "order_code"); + List productCodes = McpParams.codeList(params, "product_code_list", MAX_CODE); + String deliveryStatus = McpParams.str(params, "delivery_status"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + EntityResult result = new EntityResult("outer_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "DELIVERY", outerCode, orderCode, + String.join(",", productCodes), deliveryStatus, McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + Map q = new HashMap<>(); + q.put("outerCode", outerCode); + q.put("orderCode", orderCode); + q.put("productCodeList", productCodes); + q.put("deliveryStatus", deliveryStatus); + q.put("startTime", range.getStart()); + q.put("endTime", range.getEnd()); + q.put("limit", pageSize + 1); + putCursor(q, cursor); + + List rows = inventoryDeliveryMapper.listDeliveryPage(q); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + List deliveryIds = new ArrayList<>(); + for (InventoryDelivery row : page) { + result.items.add(toDeliveryItem(row)); + if (row.getId() != null) { + deliveryIds.add(row.getId()); + } + } + if (!page.isEmpty()) { + InventoryDelivery last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getOuterCode(), last.getId()); + } + if (includeDetail && !deliveryIds.isEmpty()) { + Map> snMap = new LinkedHashMap<>(); + List details = omsInventoryDeliveryDetailMapper.listByDeliveryIdList(deliveryIds); + if (details != null) { + for (OmsInventoryDeliveryDetail detail : details) { + if (detail.getDeliveryId() != null) { + snMap.computeIfAbsent(detail.getDeliveryId(), k -> new ArrayList<>()) + .add(McpFormat.trim(detail.getProductSn())); + } + } + } + for (Map item : result.items) { + Object id = item.get("deliveryId"); + Long key = id instanceof Number ? ((Number) id).longValue() : null; + item.put("productSns", key == null ? Collections.emptyList() + : snMap.getOrDefault(key, Collections.emptyList())); + } + } + if (includeTotal) { + applyTotal(result, inventoryDeliveryMapper.countDeliveryPage(q)); + } + return result; + } + + private EntityResult runOrderDelivery(Map params, int pageSize, boolean includeDetail, boolean includeTotal) { + List deliveryCodes = McpParams.codeList(params, "delivery_code_list", MAX_CODE); + List orderCodes = McpParams.codeList(params, "order_code_list", MAX_CODE); + String deliveryStatus = McpParams.str(params, "delivery_status"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + EntityResult result = new EntityResult("delivery_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "ORDER_DELIVERY", String.join(",", deliveryCodes), + String.join(",", orderCodes), deliveryStatus, McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + Map q = new HashMap<>(); + q.put("deliveryCodeList", deliveryCodes); + q.put("orderCodeList", orderCodes); + q.put("deliveryStatus", deliveryStatus); + q.put("startTime", range.getStart()); + q.put("endTime", range.getEnd()); + q.put("limit", pageSize + 1); + putCursor(q, cursor); + + List> rows = orderDeliveryMapper.listOrderDeliveryPage(q); + result.hasMore = McpPages.hasMore(rows, pageSize); + List> page = McpPages.trim(rows, pageSize); + List deliveryIds = new ArrayList<>(); + for (Map row : page) { + Object id = row.get("id"); + row.put("deliveryStatusName", orderDeliveryStatusName(str(row.get("deliveryStatus")))); + row.put("deliveryTypeName", deliveryTypeName(str(row.get("deliveryType")))); + result.items.add(row); + if (id instanceof Number) { + deliveryIds.add(((Number) id).longValue()); + } + } + if (!page.isEmpty()) { + Map last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(str(last.get("deliveryCode")), last.get("id")); + } + if (includeDetail && !deliveryIds.isEmpty()) { + Map>> detailMap = new LinkedHashMap<>(); + List details = deliveryListMapper.listByDeliveryIdList(deliveryIds); + if (details != null) { + for (DeliveryList detail : details) { + if (detail.getDeliveryId() != null) { + Map item = new LinkedHashMap<>(); + item.put("productCode", McpFormat.trim(detail.getProductCode())); + item.put("serialNumber", McpFormat.trim(detail.getSerialNumber())); + item.put("remark", detail.getRemark()); + detailMap.computeIfAbsent(detail.getDeliveryId(), k -> new ArrayList<>()).add(item); + } + } + } + for (Map item : result.items) { + Object id = item.get("id"); + Long key = id instanceof Number ? ((Number) id).longValue() : null; + item.put("deliveryItems", key == null ? Collections.emptyList() + : detailMap.getOrDefault(key, Collections.emptyList())); + } + } + if (includeTotal) { + applyTotal(result, orderDeliveryMapper.countOrderDeliveryPage(q)); + } + return result; + } + + private EntityResult runStock(Map params, int pageSize, boolean includeTotal) { + String orderCode = McpParams.str(params, "order_code"); + String stockStatus = McpParams.str(params, "stock_status"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + EntityResult result = new EntityResult("order_code,id"); + result.filterHash = McpParams.filterHash(getToolName(), "STOCK", orderCode, stockStatus, McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + Map q = new HashMap<>(); + q.put("orderCode", orderCode); + q.put("stockStatus", stockStatus); + q.put("startTime", range.getStart()); + q.put("endTime", range.getEnd()); + q.put("limit", pageSize + 1); + putCursor(q, cursor); + + List rows = omsStockInfoMapper.listStockPage(q); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (OmsStockInfo row : page) { + result.items.add(toStockItem(row)); + } + if (!page.isEmpty()) { + OmsStockInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getOrderCode(), last.getId()); + } + if (includeTotal) { + applyTotal(result, omsStockInfoMapper.countStockPage(q)); + } + return result; + } + + private EntityResult runSn(Map params, int pageSize, boolean includeTotal) { + // 大表护栏:必须给出索引键之一,禁止无界全表分页(方案 4.3 护栏) + McpParams.requireAny(params, "product_sn_list", "product_code_list", "inner_code", "outer_code"); + List productSns = McpParams.codeList(params, "product_sn_list", MAX_SN); + List productCodes = McpParams.codeList(params, "product_code_list", MAX_CODE); + String inventoryStatus = McpParams.str(params, "inventory_status"); + String innerCode = McpParams.str(params, "inner_code"); + String outerCode = McpParams.str(params, "outer_code"); + EntityResult result = new EntityResult("product_sn,id"); + result.filterHash = McpParams.filterHash(getToolName(), "SN", String.join(",", productSns), + String.join(",", productCodes), inventoryStatus, innerCode, outerCode); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + Map q = new HashMap<>(); + q.put("productSnList", productSns); + q.put("productCodeList", productCodes); + q.put("inventoryStatus", inventoryStatus); + q.put("innerCode", innerCode); + q.put("outerCode", outerCode); + q.put("limit", pageSize + 1); + putCursor(q, cursor); + + List rows = inventoryInfoMapper.listSnPage(q); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (InventoryInfo row : page) { + result.items.add(toSnItem(row)); + } + if (!page.isEmpty()) { + InventoryInfo last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getProductSn(), last.getId()); + } + if (includeTotal) { + applyTotal(result, inventoryInfoMapper.countSnPage(q)); + } + return result; + } + + private EntityResult runRecall(Map params, int pageSize, boolean includeTotal) { + String orderCode = McpParams.str(params, "order_code"); + McpTimeRange range = McpParams.timeRange(params, "time_range", DEFAULT_MONTHS, MAX_MONTHS); + EntityResult result = new EntityResult("order_code,create_time desc,id"); + result.filterHash = McpParams.filterHash(getToolName(), "RECALL", orderCode, McpParams.describe(range)); + McpCursor cursor = McpPages.cursor(params, getToolName(), result.filterHash, McpPages.DEFAULT_LIST_MAX_PAGES); + result.pageNo = cursor == null ? 1 : cursor.getP(); + + Map q = new HashMap<>(); + q.put("orderCode", orderCode); + q.put("startTime", range.getStart()); + q.put("endTime", range.getEnd()); + q.put("limit", pageSize + 1); + if (cursor != null) { + q.put("cursorKey", cursor.key(0)); + q.put("cursorTime", McpParams.parseDate(cursor.key(1), "cursor")); + q.put("cursorId", cursor.longKey(2)); + } + + List rows = projectOrderInfoRecallMapper.listRecallPage(q); + result.hasMore = McpPages.hasMore(rows, pageSize); + List page = McpPages.trim(rows, pageSize); + for (ProjectOrderInfoRecall row : page) { + result.items.add(toRecallItem(row)); + } + if (!page.isEmpty()) { + ProjectOrderInfoRecall last = page.get(page.size() - 1); + result.lastSortKeys = keysOf(last.getOrderCode(), McpFormat.dateTime(last.getCreateTime()), last.getId()); + } + if (includeTotal) { + applyTotal(result, projectOrderInfoRecallMapper.countRecallPage(q)); + } + return result; + } + + // ------------------------------------------------------------------ 行映射 + + private Map toInnerItem(OmsInventoryInner row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("innerCode", McpFormat.trim(row.getInnerCode())); + item.put("purchaseNo", McpFormat.trim(row.getPurchaseNo())); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("productType", row.getProductType()); + item.put("model", row.getModel()); + item.put("quantity", row.getQuantity()); + item.put("vendorCode", McpFormat.trim(row.getVendorCode())); + item.put("vendorName", row.getVendorName()); + item.put("warehouseId", row.getWarehouseId()); + item.put("warehouseName", row.getWarehouseName()); + item.put("warehouseType", row.getWarehouseType()); + item.put("totalAmount", row.getTotalAmount()); + item.put("taxRate", row.getTaxRate()); + item.put("taxTotal", row.getTaxTotal()); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("remark", row.getRemark()); + item.put("createByName", row.getCreateByName()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + return item; + } + + private Map toOuterItem(InventoryOuter row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("outerCode", McpFormat.trim(row.getOuterCode())); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("projectCode", row.getProjectCode()); + item.put("projectName", row.getProjectName()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("model", row.getModel()); + item.put("quantity", row.getQuantity()); + item.put("outerStatus", row.getOuterStatus()); + item.put("outerStatusName", outerStatusName(row.getOuterStatus())); + item.put("deliveryStatus", row.getDeliveryStatus()); + item.put("deliveryStatusName", outerDeliveryStatusName(row.getDeliveryStatus())); + item.put("deliveryTime", McpFormat.date(row.getDeliveryTime())); + item.put("contactPerson", row.getContactPerson()); + item.put("contactPhone", row.getContactPhone()); + item.put("contactAddress", row.getContactAddress()); + item.put("deliveryTimeType", row.getDeliveryTimeType()); + item.put("versionCode", row.getVersionCode()); + item.put("receivableBillCode", McpFormat.trim(row.getReceivableBillCode())); + item.put("createByName", row.getCreateByName()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + return item; + } + + private Map toOuterDetailItem(InventoryOuterDetail row) { + Map item = new LinkedHashMap<>(); + item.put("outerCode", McpFormat.trim(row.getOuterCode())); + item.put("warehouseId", row.getWarehouseId()); + item.put("warehouseName", row.getWarehouseName()); + item.put("quantity", row.getQuantity()); + item.put("outerStatus", row.getOuterStatus()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("model", row.getModel()); + return item; + } + + private Map toDeliveryItem(InventoryDelivery row) { + Map item = new LinkedHashMap<>(); + item.put("deliveryId", row.getId()); + item.put("outerCode", McpFormat.trim(row.getOuterCode())); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("projectCode", row.getProjectCode()); + item.put("projectName", row.getProjectName()); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("model", row.getModel()); + item.put("quantity", row.getQuantity()); + item.put("warehouseId", row.getWarehouseId()); + item.put("warehouseName", row.getWarehouseName()); + item.put("logisticsCompany", row.getLogisticsCompany()); + item.put("logisticsCode", row.getLogisticsCode()); + item.put("deliveryType", row.getDeliveryType()); + item.put("deliveryTypeName", deliveryTypeName(row.getDeliveryType())); + item.put("deliveryStatus", row.getDeliveryStatus()); + item.put("deliveryStatusName", deliveryStatusName(row.getDeliveryStatus())); + item.put("approveStatus", row.getApproveStatus()); + item.put("deliveryTime", McpFormat.date(row.getDeliveryTime())); + item.put("receiverAddress", row.getNotifierAddress()); + item.put("versionCode", row.getVersionCode()); + item.put("createByName", row.getCreateByName()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + return item; + } + + private Map toStockItem(OmsStockInfo row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("stockStatus", row.getStockStatus()); + item.put("stockStatusName", stockStatusName(row.getStockStatus())); + item.put("onceInStock", row.getOnceInStock()); + item.put("allQuantity", row.getAllQuantity()); + item.put("deliveryTime", McpFormat.date(row.getDeliveryTime())); + item.put("projectCode", row.getProjectCode()); + item.put("projectName", row.getProjectName()); + item.put("notifier", row.getNotifier()); + item.put("notifierPhone", row.getNotifierPhone()); + item.put("notifierAddress", row.getNotifierAddress()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + return item; + } + + private Map toSnItem(InventoryInfo row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("productSn", McpFormat.trim(row.getProductSn())); + item.put("productCode", McpFormat.trim(row.getProductCode())); + item.put("model", row.getModel()); + item.put("productDesc", row.getProductDesc()); + item.put("inventoryStatus", row.getInventoryStatus()); + item.put("inventoryStatusName", inventoryStatusName(row.getInventoryStatus())); + item.put("innerCode", McpFormat.trim(row.getInnerCode())); + item.put("outerCode", McpFormat.trim(row.getOuterCode())); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("warehouseId", row.getWarehouseId()); + item.put("warehouseName", row.getWarehouseName()); + item.put("innerPrice", row.getInnerPrice()); + item.put("outerPrice", row.getOuterPrice()); + item.put("taxRate", row.getTaxRate()); + item.put("payableBillCode", McpFormat.trim(row.getPayableBillCode())); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("updateTime", McpFormat.dateTime(row.getUpdateTime())); + return item; + } + + private Map toRecallItem(ProjectOrderInfoRecall row) { + Map item = new LinkedHashMap<>(); + item.put("id", row.getId()); + item.put("orderCode", McpFormat.trim(row.getOrderCode())); + item.put("versionCode", row.getVersionCode()); + item.put("operationVersion", row.getOperationVersion()); + item.put("createTime", McpFormat.dateTime(row.getCreateTime())); + item.put("createBy", row.getCreateBy()); + return item; + } + + // ------------------------------------------------------------------ 辅助 + + private void putCursor(Map target, McpCursor cursor) { + if (cursor != null) { + target.put("cursorKey", cursor.key(0)); + target.put("cursorId", cursor.longKey(1)); + } + } + + private void applyTotal(EntityResult result, long counted) { + result.totalCapped = counted > COUNT_CAP; + result.total = result.totalCapped ? COUNT_CAP : counted; + } + + private Map queryEcho(String entity, Map params, int pageSize, + boolean includeDetail, boolean includeTotal) { + Map query = new LinkedHashMap<>(); + query.put("entity", entity); + query.put("page_size", pageSize); + query.put("cursor", McpParams.str(params, "cursor")); + query.put("include_detail", includeDetail); + query.put("include_total", includeTotal); + return query; + } + + private String str(Object value) { + return value == null ? null : McpFormat.trimToNull(String.valueOf(value)); + } + + private String outerStatusName(String status) { + if (status == null) { + return null; + } + for (InventoryOuter.OuterStatusEnum value : InventoryOuter.OuterStatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getDesc(); + } + } + return status; + } + + private String outerDeliveryStatusName(String status) { + if (status == null) { + return null; + } + for (InventoryOuter.DeliveryStatusEnum value : InventoryOuter.DeliveryStatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getDesc(); + } + } + return status; + } + + private String deliveryStatusName(String status) { + if (status == null) { + return null; + } + for (InventoryDelivery.DeliveryStatusEnum value : InventoryDelivery.DeliveryStatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getDesc(); + } + } + return status; + } + + private String orderDeliveryStatusName(String status) { + if (status == null) { + return null; + } + // 实测该表取值是拼音缩写 qs=已签收、yf=已发货(与列注释的 1/2/3 不一致); + // 同时保留数字取值兜底,避免其它环境按注释存值时无法翻译。 + if ("qs".equalsIgnoreCase(status) || "3".equals(status)) { + return "已签收"; + } + if ("yf".equalsIgnoreCase(status) || "2".equals(status)) { + return "已发货"; + } + if ("wf".equalsIgnoreCase(status) || "1".equals(status)) { + return "未发货"; + } + return status; + } + + private String deliveryTypeName(String type) { + if (type == null) { + return null; + } + if ("1".equals(type)) { + return "快递"; + } + if ("2".equals(type)) { + return "物流"; + } + if ("3".equals(type)) { + return "自提"; + } + return type; + } + + private String stockStatusName(String status) { + if (status == null) { + return null; + } + if ("0".equals(status)) { + return "未备货"; + } + if ("1".equals(status)) { + return "已备货"; + } + return status; + } + + private String inventoryStatusName(String status) { + if (status == null) { + return null; + } + for (InventoryInfo.InventoryStatusEnum value : InventoryInfo.InventoryStatusEnum.values()) { + if (value.getCode().equals(status)) { + return value.getDesc(); + } + } + return status; + } + + private Map buildMetadata(String entity) { + Map metadata = metadata("仓储域列表 / 范围查询(entity=" + entity + ")"); + metadata.put("entity", entity); + metadata.put("entity_fields", mapData( + "INNER", "入库单 oms_inventory_inner(明细来源:oms_inventory_info 按 inner_code)", + "OUTER", "出库单 oms_inventory_outer(明细:oms_inventory_outer_detail)", + "DELIVERY", "仓储发货单 oms_inventory_delivery(明细:oms_inventory_delivery_detail)", + "ORDER_DELIVERY", "manage 域发货单 order_delivery(明细:delivery_list,过滤 deleted_at is null)", + "STOCK", "备货 oms_stock_info", + "SN", "SN 明细 oms_inventory_info", + "RECALL", "撤回历史 project_order_info_recall" + )); + metadata.put("data_fields", mapData( + "total", "include_total=true 时的总条数(否则为当前页条数),受 count_cap=" + COUNT_CAP + " 限制", + "items", "列表数据", + "page_info", "分页信息;has_more=true 时必须回传 next_cursor" + )); + metadata.put("item_fields", itemFields()); + metadata.put("notes", "时间维度默认近 " + DEFAULT_MONTHS + " 个月,跨度上限 " + MAX_MONTHS + " 个月,采用区间下推(>= start and < end);" + + "编码匹配不区分大小写(utf8mb4_unicode_ci);软删除表(order_delivery / delivery_list)统一过滤 deleted_at is null。"); + metadata.put("permission_source", permissionSource(entity)); + return metadata; + } + + private Map itemFields() { + return mapData( + "id", "主键", + "innerCode", "入库单号", + "outerCode", "出库单号", + "deliveryId", "发货单ID(DELIVERY)", + "deliveryCode", "发货单号(ORDER_DELIVERY)", + "orderCode", "合同编号", + "orderName", "合同名称(ORDER_DELIVERY)", + "customerCode", "客户编码(ORDER_DELIVERY)", + "customerName", "客户名称(ORDER_DELIVERY)", + "orderAgentCode", "代表处编码(ORDER_DELIVERY)", + "orderPartnerCode", "代理商编码(ORDER_DELIVERY)", + "projectCode", "项目编号", + "projectName", "项目名称", + "productCode", "产品BOM编码", + "productSn", "产品序列号(SN)", + "model", "产品型号", + "productDesc", "产品描述(SN)", + "productType", "产品类型(INNER)", + "quantity", "数量", + "purchaseNo", "采购单号(INNER)", + "vendorCode", "厂商编码(INNER)", + "vendorName", "厂商名称(INNER)", + "warehouseId", "仓库ID", + "warehouseName", "仓库名称", + "warehouseType", "仓库类型(INNER)", + "totalAmount", "入库含税总额(INNER)", + "taxRate", "税率(INNER 为入库税率;SN 为明细税率,实测存在 NULL)", + "taxTotal", "税额(INNER)", + "outerStatus", "出库状态编码(1待确认/2已确认/3已接收/4已退回)", + "outerStatusName", "出库状态名称", + "deliveryStatus", "发货状态编码(OUTER:0未发货/1部分发货/2全部发货/3已撤回;DELIVERY:0待发货/1已发货/2撤回;ORDER_DELIVERY:1待发货/2已发货/3已签收)", + "deliveryStatusName", "发货状态名称", + "approveStatus", "撤回审批状态(DELIVERY)", + "deliveryType", "发货方式(1快递/2物流/3自提)", + "deliveryTypeName", "发货方式名称", + "deliveryTime", "发货时间 / 要求到货时间", + "signTime", "签收时间(ORDER_DELIVERY)", + "logisticsCompany", "物流公司", + "logisticsCode", "物流单号", + "receiverName", "收货人(ORDER_DELIVERY)", + "receiverPhone", "收货人电话(ORDER_DELIVERY)", + "receiverAddress", "收货地址(ORDER_DELIVERY)", + "contactPerson", "联系人(OUTER)", + "contactPhone", "联系电话(OUTER)", + "contactAddress", "联系地址(OUTER)", + "receivableBillCodeNote", "出库单无“应收单号”列(该字段在库中不存在),如需关联应收请用 finance_list 按 order_code 查询", + "versionCode", "版本号", + "inventoryStatus", "库存状态编码(0=入库,1=出库)(SN)", + "inventoryStatusName", "库存状态名称(SN)", + "innerPrice", "入库价(SN,是否含税待确认)", + "outerPrice", "出库价(SN,是否含税待确认)", + "payableBillCode", "对应应付单号(SN)", + "stockStatus", "备货状态编码(0=未备货,1=已备货)(STOCK)", + "stockStatusName", "备货状态名称(STOCK)", + "onceInStock", "是否一次备齐(STOCK)", + "allQuantity", "应备货总量(STOCK)", + "notifier", "通知人(STOCK)", + "notifierPhone", "通知人电话(STOCK)", + "notifierAddress", "通知人地址(STOCK)", + "operationVersion", "操作版本(RECALL)", + "createByName", "创建人", + "createBy", "创建人ID(RECALL)", + "createTime", "创建时间", + "deliveryItems", "发货 SN 明细(ORDER_DELIVERY,include_detail=true)", + "snDetails", "SN 明细(INNER,include_detail=true)", + "outerDetails", "出库明细(OUTER,include_detail=true)", + "productSns", "发货 SN 列表(DELIVERY,include_detail=true)" + ); + } + + private String permissionSource(String entity) { + switch (entity) { + case "INNER": + return "OmsInventoryInnerController:inventory:inner:list / inventory:inner:view(已抄取)"; + case "OUTER": + return "InventoryOuterController:inventory:outer:list / inventory:outer:view(已抄取)"; + case "DELIVERY": + return "InventoryDeliveryController / VueDeliveryController:inventory:delivery:list / inventory:delivery:view(已抄取)"; + case "ORDER_DELIVERY": + return "OrderDeliveryController:manage:delivery:list / manage:delivery:view(已抄取)"; + case "STOCK": + return "OmsStockInfoController:stock:index:view(该 Controller 的 /list 未加权限注解,取视图权限;待确认)"; + case "SN": + return "InventoryInfoController:inventory:info:view(该 Controller 的 /list 未加权限注解,取视图权限;待确认)"; + case "RECALL": + return "project_order_info_recall 无独立 Controller,取 ProjectOrderInfoController:project:order:list / project:order:view(待确认)"; + default: + return "未确定(待确认)"; + } + } + + private String[] permissionsOf(String entity) { + switch (entity) { + case "INNER": + return new String[]{"inventory:inner:list", "inventory:inner:view"}; + case "OUTER": + return new String[]{"inventory:outer:list", "inventory:outer:view"}; + case "DELIVERY": + return new String[]{"inventory:delivery:list", "inventory:delivery:view"}; + case "ORDER_DELIVERY": + return new String[]{"manage:delivery:list", "manage:delivery:view"}; + case "STOCK": + return new String[]{"stock:index:view"}; + case "SN": + return new String[]{"inventory:info:view"}; + case "RECALL": + return new String[]{"project:order:list", "project:order:view"}; + default: + return new String[0]; + } + } + + /** entity 查询结果载体 */ + private static class EntityResult { + private final List> items = new ArrayList<>(); + private final String sortBy; + private String filterHash; + private int pageNo = 1; + private boolean hasMore; + private List lastSortKeys; + private Long total; + private boolean totalCapped; + + private EntityResult(String sortBy) { + this.sortBy = sortBy; + } + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/AbstractMcpQueryToolProvider.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/AbstractMcpQueryToolProvider.java new file mode 100644 index 00000000..f1ea9a54 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/AbstractMcpQueryToolProvider.java @@ -0,0 +1,184 @@ +package com.ruoyi.sip.llm.tools.support; + +import com.ruoyi.sip.domain.VendorInfo; +import com.ruoyi.sip.service.IInventoryAuthService; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 查询类 MCP 工具的公共基类:统一 schema 属性、分页入参、响应信封、仓储行级权限作用域。 + *

继承 {@link AbstractMcpToolProvider},与现有工具保持同一注册方式(@Component 自动注册)。

+ */ +public abstract class AbstractMcpQueryToolProvider extends AbstractMcpToolProvider { + + protected static final int SCHEMA_VERSION = 1; + + @Autowired(required = false) + protected IInventoryAuthService inventoryAuthService; + + // ------------------------------------------------------------------ schema 属性 + + protected Map intProperty(String description) { + Map property = new HashMap<>(); + property.put("type", "integer"); + property.put("description", description); + return property; + } + + protected Map boolProperty(String description) { + Map property = new HashMap<>(); + property.put("type", "boolean"); + property.put("description", description); + return property; + } + + protected Map enumProperty(String description, String... values) { + Map property = new HashMap<>(); + property.put("type", "string"); + property.put("description", description); + property.put("enum", Arrays.asList(values)); + return property; + } + + protected Map arrayProperty(String description) { + Map property = new HashMap<>(); + property.put("type", "array"); + property.put("description", description); + Map items = new HashMap<>(); + items.put("type", "string"); + property.put("items", items); + return property; + } + + /** + * 为列表类工具统一追加 page_size / cursor 参数说明。 + */ + protected Map withPagingProperties(Map properties, int maxPageSize, int defaultPageSize) { + properties.put("page_size", intProperty("每页条数,默认 " + defaultPageSize + ",上限 " + maxPageSize)); + properties.put("cursor", stringProperty("分页游标:若上次返回 page_info.has_more=true,必须原样回传 page_info.next_cursor 继续取下一页,直到 has_more=false")); + return properties; + } + + // ------------------------------------------------------------------ 响应 + + protected Map metadata(String description) { + Map metadata = new LinkedHashMap<>(); + metadata.put("tool", getToolName()); + metadata.put("schema_version", SCHEMA_VERSION); + metadata.put("description", description); + return metadata; + } + + /** + * 列表/分页工具的统一 data 结构:保留与现有工具一致的 total + items,并新增 page_info。 + */ + protected Map listData(List items, Map pageInfo, Long total) { + Map data = new LinkedHashMap<>(); + data.put("total", total != null ? total : (items == null ? 0 : items.size())); + data.put("items", items == null ? Collections.emptyList() : items); + data.put("page_info", pageInfo); + return data; + } + + protected Map mapData(Object... keyValues) { + Map data = new LinkedHashMap<>(); + if (keyValues != null) { + for (int i = 0; i + 1 < keyValues.length; i += 2) { + data.put(String.valueOf(keyValues[i]), keyValues[i + 1]); + } + } + return data; + } + + // ------------------------------------------------------------------ 仓储行级权限作用域 + + protected boolean inventoryAuthAll() { + return inventoryAuthService == null || inventoryAuthService.authAll(); + } + + protected List inventoryAuthProductCodes() { + if (inventoryAuthService == null) { + return Collections.emptyList(); + } + List codes = inventoryAuthService.authProductCode(); + return codes == null ? Collections.emptyList() : codes; + } + + protected List inventoryAuthWarehouseIds() { + if (inventoryAuthService == null) { + return Collections.emptyList(); + } + List ids = inventoryAuthService.authWarehouse(); + return ids == null ? Collections.emptyList() : ids; + } + + /** + * 当前用户可访问的制造商编码(用于采购侧数据范围)。 + */ + protected List inventoryAuthVendorCodes() { + if (inventoryAuthService == null) { + return Collections.emptyList(); + } + List vendors = inventoryAuthService.currentVendor(); + if (vendors == null || vendors.isEmpty()) { + return Collections.emptyList(); + } + List codes = new ArrayList<>(); + for (VendorInfo vendor : vendors) { + if (vendor != null && vendor.getVendorCode() != null && !vendor.getVendorCode().trim().isEmpty()) { + codes.add(vendor.getVendorCode().trim()); + } + } + return codes; + } + + /** + * 校验并裁剪仓储数据范围:若非全量权限且请求的产品编码超出授权范围,直接拒绝(不返回空数据)。 + */ + protected void assertInventoryProductScope(List requestedProductCodes) { + if (inventoryAuthAll() || requestedProductCodes == null || requestedProductCodes.isEmpty()) { + return; + } + List allowed = inventoryAuthProductCodes(); + if (allowed.isEmpty()) { + throw McpToolException.authError("当前机器人绑定用户未授权任何产品数据范围"); + } + for (String code : requestedProductCodes) { + if (!allowed.contains(code)) { + throw McpToolException.authError("产品编码 " + code + " 超出当前用户的数据范围"); + } + } + } + + // ------------------------------------------------------------------ 辅助 + + protected String joinKeys(Object... keys) { + StringBuilder builder = new StringBuilder(); + if (keys != null) { + for (Object key : keys) { + if (builder.length() > 0) { + builder.append(','); + } + builder.append(key == null ? "" : String.valueOf(key)); + } + } + return builder.toString(); + } + + protected List keysOf(Object... keys) { + List result = new ArrayList<>(); + if (keys != null) { + for (Object key : keys) { + result.add(key == null ? null : String.valueOf(key)); + } + } + return result; + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpAuth.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpAuth.java new file mode 100644 index 00000000..e172b3c9 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpAuth.java @@ -0,0 +1,63 @@ +package com.ruoyi.sip.llm.tools.support; + +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.utils.ShiroUtils; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.subject.Subject; + +/** + * 鉴权与身份:基于 MCP 机器人凭证绑定用户所属的 Shiro 主体。 + *

权限不足时抛 {@link McpToolException#authError(String)},由 McpController 转成 AUTH_ERROR, + * 不返回空列表,避免 Agent 误判为"无数据"。

+ */ +public final class McpAuth { + + private McpAuth() { + } + + public static SysUser currentUser() { + try { + return ShiroUtils.getSysUser(); + } catch (Exception ex) { + return null; + } + } + + public static Long currentUserId() { + SysUser user = currentUser(); + return user == null ? null : user.getUserId(); + } + + public static String currentUserName() { + SysUser user = currentUser(); + return user == null ? null : user.getUserName(); + } + + public static boolean hasPermission(String permission) { + if (permission == null || permission.trim().isEmpty()) { + return true; + } + try { + Subject subject = SecurityUtils.getSubject(); + return subject != null && subject.isPermitted(permission.trim()); + } catch (Exception ex) { + return false; + } + } + + /** + * 具备任意一个权限即放行(用于"一个工具对应多种页面权限"的场景)。 + */ + public static void requireAnyPermission(String... permissions) { + if (permissions == null || permissions.length == 0) { + return; + } + for (String permission : permissions) { + if (hasPermission(permission)) { + return; + } + } + throw McpToolException.authError("当前机器人绑定用户缺少访问该数据的权限,需要以下任一权限:" + + String.join(" / ", permissions)); + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpCursor.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpCursor.java new file mode 100644 index 00000000..84c94e36 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpCursor.java @@ -0,0 +1,119 @@ +package com.ruoyi.sip.llm.tools.support; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.List; + +/** + * 游标分页的游标载体(无状态、可校验)。 + *

编码:base64url(JSON) = { "v":1, "t":工具名, "f":过滤条件哈希, "p":页码, "k":[排序键值...] }

+ */ +public class McpCursor { + + public static final int VERSION = 1; + + private int v = VERSION; + private String t; + private String f; + private int p = 1; + private List k = new ArrayList<>(); + + public int getV() { + return v; + } + + public void setV(int v) { + this.v = v; + } + + public String getT() { + return t; + } + + public void setT(String t) { + this.t = t; + } + + public String getF() { + return f; + } + + public void setF(String f) { + this.f = f; + } + + public int getP() { + return p; + } + + public void setP(int p) { + this.p = p; + } + + public List getK() { + return k; + } + + public void setK(List k) { + this.k = k; + } + + public static String encode(String toolName, String filterHash, int pageNo, List sortKeys) { + McpCursor cursor = new McpCursor(); + cursor.setT(toolName); + cursor.setF(filterHash); + cursor.setP(pageNo); + cursor.setK(sortKeys == null ? Collections.emptyList() : sortKeys); + String json = JSON.toJSONString(cursor); + return Base64.getUrlEncoder().withoutPadding().encodeToString(json.getBytes(StandardCharsets.UTF_8)); + } + + public static McpCursor decode(String encoded) { + if (encoded == null || encoded.trim().isEmpty()) { + return null; + } + try { + byte[] raw = Base64.getUrlDecoder().decode(encoded.trim()); + JSONObject object = JSON.parseObject(new String(raw, StandardCharsets.UTF_8)); + McpCursor cursor = new McpCursor(); + cursor.setV(object.getIntValue("v")); + cursor.setT(object.getString("t")); + cursor.setF(object.getString("f")); + cursor.setP(object.getIntValue("p")); + List keys = new ArrayList<>(); + if (object.getJSONArray("k") != null) { + for (Object item : object.getJSONArray("k")) { + keys.add(item == null ? null : String.valueOf(item)); + } + } + cursor.setK(keys); + return cursor; + } catch (Exception ex) { + throw McpToolException.invalidParams("cursor 无法解析,请从第一页重新开始(不要手工拼接 cursor)"); + } + } + + public String key(int index) { + if (k == null || index >= k.size()) { + return null; + } + return k.get(index); + } + + public Long longKey(int index) { + String value = key(index); + if (value == null || value.trim().isEmpty()) { + return null; + } + try { + return Long.valueOf(value.trim()); + } catch (NumberFormatException ex) { + return null; + } + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpFormat.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpFormat.java new file mode 100644 index 00000000..2ad5813d --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpFormat.java @@ -0,0 +1,46 @@ +package com.ruoyi.sip.llm.tools.support; + +import java.math.BigDecimal; +import java.util.Date; + +import cn.hutool.core.date.DateUtil; + +/** + * 统一的出参格式化:日期/时间/金额/字符串裁剪。 + *

与现有工具保持一致:日期 yyyy-MM-dd,时间 yyyy-MM-dd HH:mm:ss,金额保留原精度。

+ */ +public final class McpFormat { + + private McpFormat() { + } + + public static String date(Date value) { + return value == null ? null : DateUtil.format(value, "yyyy-MM-dd"); + } + + public static String dateTime(Date value) { + return value == null ? null : DateUtil.format(value, "yyyy-MM-dd HH:mm:ss"); + } + + public static BigDecimal decimal(BigDecimal value) { + return value; + } + + /** + * 统一 trim:库中编码字段存在前导制表符等脏数据,入参与出参一律裁剪。 + */ + public static String trim(String value) { + return value == null ? null : value.trim(); + } + + /** + * trim 后为空则返回 null,避免输出 "" 造成歧义。 + */ + public static String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpPages.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpPages.java new file mode 100644 index 00000000..d03740c3 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpPages.java @@ -0,0 +1,121 @@ +package com.ruoyi.sip.llm.tools.support; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 游标分页的统一实现:page_size、cursor 编解码与校验、page_info 信封、limit+1 探测。 + */ +public final class McpPages { + + public static final int DEFAULT_PAGE_SIZE = 20; + /** 聚合类(SUMMARY 一般不翻页)上限 */ + public static final int MAX_AGG_PAGE_SIZE = 200; + /** 明细/列表类上限 */ + public static final int MAX_LIST_PAGE_SIZE = 100; + /** 聚合默认最多翻页数 */ + public static final int DEFAULT_AGG_MAX_PAGES = 20; + /** 明细默认最多翻页数 */ + public static final int DEFAULT_LIST_MAX_PAGES = 50; + /** 编码列表单次上限 */ + public static final int MAX_CODE_LIST = 200; + + private McpPages() { + } + + public static int pageSize(Map params, int defaultValue, int max) { + Integer value = McpParams.intVal(params, "page_size"); + if (value == null) { + return defaultValue; + } + if (value <= 0) { + throw McpToolException.invalidParams("page_size 必须为正整数"); + } + if (value > max) { + throw McpToolException.invalidParams("page_size 超过上限 " + max + ",当前值:" + value); + } + return value; + } + + /** + * 解析并校验游标:工具名、过滤条件指纹必须匹配,且未超过最大页数。 + * + * @return 当前游标(首页返回 null) + */ + public static McpCursor cursor(Map params, String toolName, String filterHash, int maxPages) { + String encoded = McpParams.str(params, "cursor"); + if (encoded == null) { + return null; + } + McpCursor cursor = McpCursor.decode(encoded); + if (cursor == null) { + return null; + } + if (!toolName.equals(cursor.getT())) { + throw McpToolException.invalidParams("cursor 与本工具不匹配(cursor 属于 " + cursor.getT() + + "),请从第一页重新开始"); + } + if (cursor.getF() != null && filterHash != null && !cursor.getF().equals(filterHash)) { + throw McpToolException.invalidParams("cursor 与当前过滤条件不匹配,请从第一页重新开始"); + } + if (maxPages > 0 && cursor.getP() > maxPages) { + throw McpToolException.invalidParams("翻页次数超过上限 " + maxPages + + " 页,请收窄过滤条件,或改用聚合工具(mode=SUMMARY)一次性统计"); + } + return cursor; + } + + public static Map pageInfo(int returned, + int pageSize, + boolean hasMore, + String nextCursor, + String sortBy, + int pageNo, + Long total, + boolean totalCapped, + boolean truncatedByBytes) { + Map info = new LinkedHashMap<>(); + info.put("returned", returned); + info.put("page_size", pageSize); + info.put("has_more", hasMore); + info.put("next_cursor", hasMore ? nextCursor : null); + info.put("sort_by", sortBy); + info.put("page_no", pageNo); + info.put("total_count", total); + info.put("total_count_capped", totalCapped); + info.put("truncated_by_bytes", truncatedByBytes); + return info; + } + + /** + * 探测是否还有下一页:SQL 层取 pageSize + 1 条。 + */ + public static boolean hasMore(List rows, int pageSize) { + return rows != null && rows.size() > pageSize; + } + + /** + * 截断到 pageSize 条(配合 limit+1 探测使用)。 + */ + public static List trim(List rows, int pageSize) { + if (rows == null) { + return java.util.Collections.emptyList(); + } + if (rows.size() <= pageSize) { + return rows; + } + return new java.util.ArrayList<>(rows.subList(0, pageSize)); + } + + /** + * 单页内容过大时下调页大小(仅提示,不丢数据)。 + */ + public static int adjustPageSizeForBytes(int pageSize, int estimatedBytes, int maxBytes) { + if (estimatedBytes <= maxBytes || pageSize <= 1) { + return pageSize; + } + int adjusted = Math.max(1, pageSize / 2); + return adjusted; + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpParams.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpParams.java new file mode 100644 index 00000000..fb36edeb --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpParams.java @@ -0,0 +1,324 @@ +package com.ruoyi.sip.llm.tools.support; + +import cn.hutool.core.date.DateUtil; +import com.ruoyi.common.utils.DateUtils; + +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 入参解析与校验工具:类型规范化、枚举白名单、编码列表、时间区间、参数冲突。 + */ +public final class McpParams { + + private McpParams() { + } + + public static boolean has(Map params, String key) { + if (params == null || key == null) { + return false; + } + Object value = params.get(key); + if (value == null) { + return false; + } + if (value instanceof String) { + return !((String) value).trim().isEmpty(); + } + if (value instanceof java.util.Collection) { + return !((java.util.Collection) value).isEmpty(); + } + return true; + } + + public static String str(Map params, String key) { + if (params == null) { + return null; + } + Object value = params.get(key); + return value == null ? null : McpFormat.trimToNull(String.valueOf(value)); + } + + public static String requireStr(Map params, String key) { + String value = str(params, key); + if (value == null) { + throw McpToolException.invalidParams("缺少必填参数:" + key); + } + return value; + } + + /** + * 编码列表:支持数组,也支持逗号/中文逗号分隔字符串;统一 trim、去重、校验上限。 + */ + public static List codeList(Map params, String key, int maxCount) { + if (params == null) { + return Collections.emptyList(); + } + Object value = params.get(key); + if (value == null) { + return Collections.emptyList(); + } + List raw = new ArrayList<>(); + if (value instanceof java.util.Collection) { + for (Object item : (java.util.Collection) value) { + if (item != null) { + raw.add(String.valueOf(item)); + } + } + } else { + String text = String.valueOf(value); + for (String part : text.split("[,,\\s]+")) { + if (!part.trim().isEmpty()) { + raw.add(part); + } + } + } + Set dedup = new LinkedHashSet<>(); + for (String item : raw) { + String trimmed = McpFormat.trimToNull(item); + if (trimmed != null) { + dedup.add(trimmed); + } + } + if (dedup.size() > maxCount) { + throw McpToolException.invalidParams("参数 " + key + " 数量过多:" + dedup.size() + + ",单次上限 " + maxCount + ",请分批查询"); + } + return new ArrayList<>(dedup); + } + + /** + * 从多个候选键中取第一个非空的编码列表。 + */ + public static List codeListAny(Map params, int maxCount, String... keys) { + if (keys != null) { + for (String key : keys) { + List list = codeList(params, key, maxCount); + if (!list.isEmpty()) { + return list; + } + } + } + return Collections.emptyList(); + } + + public static Integer intVal(Map params, String key) { + String value = str(params, key); + if (value == null) { + return null; + } + try { + return Integer.valueOf(value); + } catch (NumberFormatException ex) { + throw McpToolException.invalidParams("参数 " + key + " 必须为整数,当前值:" + value); + } + } + + public static Integer intVal(Map params, String key, int defaultValue) { + Integer value = intVal(params, key); + return value == null ? defaultValue : value; + } + + public static boolean boolVal(Map params, String key, boolean defaultValue) { + if (params == null || params.get(key) == null) { + return defaultValue; + } + Object value = params.get(key); + if (value instanceof Boolean) { + return (Boolean) value; + } + String text = String.valueOf(value).trim().toLowerCase(); + if ("true".equals(text) || "1".equals(text) || "yes".equals(text) || "是".equals(text)) { + return true; + } + if ("false".equals(text) || "0".equals(text) || "no".equals(text) || "否".equals(text)) { + return false; + } + throw McpToolException.invalidParams("参数 " + key + " 必须为布尔值,当前值:" + value); + } + + /** + * 枚举取值:不在白名单内直接报错并回显允许值(避免静默降级)。 + */ + public static String requireEnum(Map params, String key, Set allowed) { + String value = requireStr(params, key); + String upper = value.toUpperCase(); + if (!allowed.contains(upper)) { + throw McpToolException.invalidParams("参数 " + key + " 取值非法:" + value + ",允许值:" + allowed); + } + return upper; + } + + public static String enumVal(Map params, String key, Set allowed, String defaultValue) { + String value = str(params, key); + if (value == null) { + return defaultValue; + } + return requireEnum(params, key, allowed); + } + + /** + * 枚举取值(区分大小写):用于 camelCase 类枚举值(如子列表名 snDetails),不做大写归一。 + */ + public static String requireValue(Map params, String key, Set allowed) { + String value = requireStr(params, key); + if (!allowed.contains(value)) { + throw McpToolException.invalidParams("参数 " + key + " 取值非法:" + value + ",允许值:" + allowed); + } + return value; + } + + public static Date date(Map params, String key) { + return parseDate(str(params, key), key); + } + + public static Date parseDate(String value, String key) { + if (value == null) { + return null; + } + Date parsed = tryParse(value, DateUtils.YYYY_MM_DD_HH_MM_SS); + if (parsed != null) { + return parsed; + } + parsed = tryParse(value, DateUtils.YYYY_MM_DD); + if (parsed != null) { + return parsed; + } + throw McpToolException.invalidParams("参数 " + key + " 时间格式非法:" + value + + ",支持 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss"); + } + + private static Date tryParse(String value, String pattern) { + try { + return DateUtils.dateTime(pattern, value); + } catch (Exception ex) { + return null; + } + } + + /** + * 时间区间,语义 [start, end): + * 支持 "start~end" 或 "_start" / "_end";日期仅到天时按当日 23:59:59 之后(即次日零点)为右开边界。 + * + * @param defaultMonths 未传时默认回看月数(<=0 表示不限制/不默认) + * @param maxMonths 最大允许跨度月数(<=0 表示不限制) + */ + public static McpTimeRange timeRange(Map params, String key, int defaultMonths, int maxMonths) { + String startText = str(params, key + "_start"); + String endText = str(params, key + "_end"); + String combined = str(params, key); + if (startText == null && endText == null && combined != null && combined.contains("~")) { + String[] parts = combined.split("~", -1); + startText = parts.length > 0 ? McpFormat.trimToNull(parts[0]) : null; + endText = parts.length > 1 ? McpFormat.trimToNull(parts[1]) : null; + } + Date start = parseDate(startText, key + "_start"); + Date end = parseDateInclusiveEnd(endText, key + "_end"); + + Calendar calendar = Calendar.getInstance(); + if (start == null && end == null) { + if (defaultMonths <= 0) { + return new McpTimeRange(null, null); + } + calendar.add(Calendar.MONTH, -defaultMonths); + calendar.set(Calendar.HOUR_OF_DAY, 0); + calendar.set(Calendar.MINUTE, 0); + calendar.set(Calendar.SECOND, 0); + calendar.set(Calendar.MILLISECOND, 0); + start = calendar.getTime(); + } + if (start != null && end != null && !start.before(end)) { + throw McpToolException.invalidParams("时间区间非法:起始时间必须早于结束时间"); + } + if (maxMonths > 0 && start != null && end != null) { + Calendar limit = Calendar.getInstance(); + limit.setTime(start); + limit.add(Calendar.MONTH, maxMonths); + if (end.after(limit.getTime())) { + throw McpToolException.invalidParams("时间区间跨度过大(超过 " + maxMonths + + " 个月),请缩小范围或按需分批查询"); + } + } + return new McpTimeRange(start, end); + } + + /** + * 日期字符串若只到"天",右开边界取次日零点(使 yyyy-MM-dd 的区间对使用者表现为"含当天")。 + */ + private static Date parseDateInclusiveEnd(String value, String key) { + if (value == null) { + return null; + } + Date parsed = parseDate(value, key); + if (value.trim().length() <= 10 && parsed != null) { + Calendar calendar = Calendar.getInstance(); + calendar.setTime(parsed); + calendar.add(Calendar.DAY_OF_MONTH, 1); + return calendar.getTime(); + } + return parsed; + } + + public static void requireAny(Map params, String... keys) { + if (keys != null) { + for (String key : keys) { + if (has(params, key)) { + return; + } + } + } + throw McpToolException.invalidParams("至少需要提供以下参数之一:" + String.join(" / ", keys)); + } + + public static void reject(boolean condition, String message) { + if (condition) { + throw McpToolException.invalidParams(message); + } + } + + public static void rejectConflict(Map params, String keyA, String keyB, String message) { + if (has(params, keyA) && has(params, keyB)) { + throw McpToolException.invalidParams(message); + } + } + + /** + * 过滤条件指纹:用于校验 cursor 与当前过滤条件一致,防止跨条件串用游标。 + */ + public static String filterHash(String... parts) { + StringBuilder builder = new StringBuilder(); + if (parts != null) { + for (String part : parts) { + builder.append(part == null ? "" : part.trim()).append('|'); + } + } + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-1"); + byte[] bytes = digest.digest(builder.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(); + for (byte item : bytes) { + hex.append(String.format("%02x", item)); + } + return hex.substring(0, 12); + } catch (Exception ex) { + return String.valueOf(builder.toString().hashCode()); + } + } + + /** + * 仅用于日志/元数据的时间描述。 + */ + public static String describe(McpTimeRange range) { + if (range == null || range.isEmpty()) { + return "不限"; + } + return DateUtil.format(range.getStart(), "yyyy-MM-dd HH:mm:ss") + " ~ " + + DateUtil.format(range.getEnd(), "yyyy-MM-dd HH:mm:ss"); + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpSubPage.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpSubPage.java new file mode 100644 index 00000000..2ed4dc38 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpSubPage.java @@ -0,0 +1,284 @@ +package com.ruoyi.sip.llm.tools.support; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 局部游标(sub_cursor)协议:供"不分页但有界"的点查工具对**子列表**单独翻页,避免超限后直接截断造成数据缺失。 + * + *

与主游标({@link McpPages#cursor})的关系:

+ *
    + *
  • 调用方传 {@code sub_list}(子列表名)+ {@code sub_cursor}(游标串),两者与主 {@code cursor} 互斥;
  • + *
  • 首次翻页没有 {@code sub_cursor},需传 {@code sub_parent}(父实体标识,如出库单号 / 应收单号)定位子列表;
  • + *
  • 返回 {@code data.sub_page_info},结构与 {@code page_info} 完全一致,{@code sort_by} 为该子表的排序键。
  • + *
+ * + *

游标编码:沿用 {@link McpCursor} 格式,但 {@code t = "<tool>:<sub_list>"}、{@code f = 父实体标识}、 + * {@code k[0] = 下一条记录的偏移量}(子列表按主键升序稳定排序,偏移量即幂等续页位置)。

+ */ +public final class McpSubPage { + + /** 子列表默认页大小 */ + public static final int DEFAULT_PAGE_SIZE = 100; + /** 子列表单页上限 */ + public static final int MAX_PAGE_SIZE = 100; + /** + * 子列表最多翻页数(硬上界,防滥用)。 + *

必须保证"单个父实体的子列表可被完整取完":{@code MAX_PAGES × MAX_PAGE_SIZE = 20000} 行, + * 否则会出现"截断 + 游标也取不完"的数据缺失(实测 C-20260715001 有 2682 条 SN 明细)。

+ */ + public static final int MAX_PAGES = 200; + /** 子列表在主响应中的截断阈值(截断时给出的 next_cursor 从该偏移继续) */ + public static final int TRUNCATE_LIMIT = 500; + + private McpSubPage() { + } + + public static boolean requested(Map params) { + return McpParams.has(params, "sub_list"); + } + + /** + * 追加 sub_list / sub_parent / sub_cursor / page_size 四个入参说明。 + * + * @param subListNames 允许的子列表名(英文逗号分隔,同时作为枚举回显) + */ + public static Map withProperties(Map properties, String subListNames) { + List names = Arrays.asList(subListNames.split(",")); + + Map subListProperty = new LinkedHashMap<>(); + subListProperty.put("type", "string"); + subListProperty.put("description", "局部游标翻页:要翻页的子列表名,取值 " + String.join(" / ", names) + + "。传该参数表示只返回该子列表的一页(而不是整条链路),需同时给出 sub_parent 或 sub_cursor"); + subListProperty.put("enum", names); + + properties.put("sub_list", subListProperty); + + Map subParentProperty = new LinkedHashMap<>(); + subParentProperty.put("type", "string"); + subParentProperty.put("description", "父实体标识,首次翻转子列表时必填(取值见 data.truncated_sub_lists[].parent):" + + "inventory_flow 为出库单号 outer_code,finance_order_position 为应收单号 / 应付单号"); + properties.put("sub_parent", subParentProperty); + + Map subCursorProperty = new LinkedHashMap<>(); + subCursorProperty.put("type", "string"); + subCursorProperty.put("description", "局部游标:原样回传上次返回的 data.sub_page_info.next_cursor 继续取下一页;" + + "游标内已携带父实体标识,无需再传 sub_parent;与 cursor 互斥"); + properties.put("sub_cursor", subCursorProperty); + + Map pageSizeProperty = new LinkedHashMap<>(); + pageSizeProperty.put("type", "string"); + pageSizeProperty.put("description", "子列表每页条数,默认 " + DEFAULT_PAGE_SIZE + ",上限 " + MAX_PAGE_SIZE); + properties.put("page_size", pageSizeProperty); + return properties; + } + + public static int pageSize(Map params) { + Integer value = McpParams.intVal(params, "page_size"); + if (value == null) { + return DEFAULT_PAGE_SIZE; + } + if (value <= 0) { + throw McpToolException.invalidParams("page_size 必须为正整数"); + } + if (value > MAX_PAGE_SIZE) { + throw McpToolException.invalidParams("子列表 page_size 超过上限 " + MAX_PAGE_SIZE + ",当前值:" + value); + } + return value; + } + + /** + * 解析局部游标定位信息。 + * + * @param params 入参(含 sub_cursor / sub_parent) + * @param toolName 工具名(校验游标归属,并参与生成续页游标) + * @param subList 子列表名 + * @param subListNames 允许的子列表名(用于错误提示) + * @param sortKey 该子列表的排序键(写入 sub_page_info.sort_by) + */ + public static SubCursor resolve(Map params, String toolName, String subList, + String subListNames, String sortKey) { + McpParams.rejectConflict(params, "sub_cursor", "cursor", + "sub_cursor 与 cursor 互斥:子列表翻页请只传 sub_list + sub_cursor"); + + String encoded = McpParams.str(params, "sub_cursor"); + if (encoded == null) { + return new SubCursor(toolName, subList, sortKey, null, 0, 1); + } + McpCursor cursor = McpCursor.decode(encoded); + if (!(toolName + ":" + subList).equals(cursor == null ? null : cursor.getT())) { + throw McpToolException.invalidParams("sub_cursor 与本工具/子列表不匹配(允许的子列表:" + subListNames + + "),请从该子列表第一页重新开始,不要手工拼接 sub_cursor"); + } + if (cursor.getP() > MAX_PAGES) { + throw McpToolException.invalidParams("子列表翻页次数超过上限 " + MAX_PAGES + " 页(每页最多 " + + MAX_PAGE_SIZE + " 条),请改小过滤范围,或将 page_size 提到 " + MAX_PAGE_SIZE + " 后再翻"); + } + Long offset = cursor.longKey(0); + if (offset == null || offset < 0) { + throw McpToolException.invalidParams("sub_cursor 内容非法,请从该子列表第一页重新开始"); + } + return new SubCursor(toolName, subList, sortKey, cursor.getF(), offset.intValue(), cursor.getP()); + } + + /** + * 取本页数据并生成 sub_page_info。 + * + * @param parent 已确定的父实体标识(首次翻页由 {@code sub_parent} 解析得到,续页时与游标内一致), + * 用于把标识写入下一页游标,避免"从 sub_parent 起翻时游标丢父实体" + */ + public static SubPageResult page(SubCursor state, String parent, List all, int pageSize) { + int total = all == null ? 0 : all.size(); + List slice = slice(all, state.getOffset(), pageSize); + boolean hasMore = state.getOffset() + slice.size() < total; + String resolvedParent = parent != null ? parent : state.getParent(); + return new SubPageResult<>(slice, pageInfo(state, resolvedParent, slice.size(), pageSize, hasMore, total)); + } + + public static Map pageInfo(SubCursor state, String parent, int returned, int pageSize, + boolean hasMore, int total) { + String nextCursor = hasMore + ? nextCursor(state.getToolName(), state.getSubList(), parent, + state.getOffset() + returned, state.getPageNo() + 1) + : null; + return McpPages.pageInfo(returned, pageSize, hasMore, nextCursor, + state.getSortKey(), state.getPageNo(), (long) total, false, false); + } + + public static String nextCursor(String toolName, String subList, String parent, int nextOffset, int nextPageNo) { + return McpCursor.encode(toolName + ":" + subList, parent, nextPageNo, + Collections.singletonList(String.valueOf(nextOffset))); + } + + public static List slice(List all, int offset, int pageSize) { + if (all == null || all.isEmpty()) { + return Collections.emptyList(); + } + if (offset <= 0 && all.size() <= pageSize) { + return all; + } + if (offset >= all.size()) { + return Collections.emptyList(); + } + int end = Math.min(all.size(), offset + pageSize); + return new ArrayList<>(all.subList(offset, end)); + } + + /** + * 按主键 id 升序稳定排序(用于保证"截断点 == 续页起点"的一致性)。 + *

部分子表查询 SQL 没有 order by,直接按返回顺序切片会导致跨调用重复/漏数据,故统一在内存中按 id 定序。

+ */ + public static List> sortedMapsById(List> rows) { + if (rows == null) { + return Collections.emptyList(); + } + if (rows.size() <= 1) { + return rows; + } + List> copy = new ArrayList<>(rows); + copy.sort((left, right) -> compareNullable( + left == null ? null : left.get("id"), + right == null ? null : right.get("id"))); + return copy; + } + + private static int compareNullable(Object left, Object right) { + if (left == null && right == null) { + return 0; + } + if (left == null) { + return 1; + } + if (right == null) { + return -1; + } + if (left instanceof Number && right instanceof Number) { + return Long.compare(((Number) left).longValue(), ((Number) right).longValue()); + } + return String.valueOf(left).compareTo(String.valueOf(right)); + } + + /** + * 主响应中被子列表上限截断时的标注项:带父实体标识与可直接使用的续页游标。 + */ + public static Map truncatedItem(String toolName, String subList, String parent, + int returned, int total) { + Map item = new LinkedHashMap<>(); + item.put("list", subList); + item.put("parent", parent); + item.put("returned", returned); + item.put("total", total); + item.put("next_cursor", nextCursor(toolName, subList, parent, returned, 2)); + item.put("hint", "用 sub_list=" + subList + " 与上述 next_cursor 作为 sub_cursor 继续取下一页"); + return item; + } + + /** 局部游标的定位状态。 */ + public static final class SubCursor { + + private final String toolName; + private final String subList; + private final String sortKey; + private final String parent; + private final int offset; + private final int pageNo; + + private SubCursor(String toolName, String subList, String sortKey, String parent, int offset, int pageNo) { + this.toolName = toolName; + this.subList = subList; + this.sortKey = sortKey; + this.parent = parent; + this.offset = offset; + this.pageNo = pageNo; + } + + /** 游标中携带的父实体标识(首次翻页为 null,此时需用入参 sub_parent)。 */ + public String getParent() { + return parent; + } + + public int getOffset() { + return offset; + } + + public int getPageNo() { + return pageNo; + } + + public String getToolName() { + return toolName; + } + + public String getSubList() { + return subList; + } + + public String getSortKey() { + return sortKey; + } + } + + /** 子列表的一页数据与分页信封。 */ + public static final class SubPageResult { + + private final List items; + private final Map pageInfo; + + private SubPageResult(List items, Map pageInfo) { + this.items = items; + this.pageInfo = pageInfo; + } + + public List getItems() { + return items; + } + + public Map getPageInfo() { + return pageInfo; + } + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpTables.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpTables.java new file mode 100644 index 00000000..d16176a9 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpTables.java @@ -0,0 +1,61 @@ +package com.ruoyi.sip.llm.tools.support; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * 表白名单:MCP 工具只允许访问下列业务表(其余表、尤其是备份表一律禁止)。 + *

备份表白名单外的示例(严禁访问):oms_inventory_info_copy1、delivery_list_0618、 + * product_info_260916、project_info_20260904bak、oms_purchase_order_1211、oms_payable_bill_copy1 等。

+ */ +public final class McpTables { + + public static final Set ALLOWED = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList( + // 仓储 + "oms_inventory_info", "oms_inventory_inner", "oms_inventory_outer", "oms_inventory_outer_detail", + "oms_inventory_delivery", "oms_inventory_delivery_detail", "oms_stock_info", "oms_warehouse_info", + "order_delivery", "delivery_list", "project_order_info_recall", + // 采购 + "oms_purchase_order", "oms_purchase_order_item", "oms_purchase_order_map", "oms_purchase_order_history", + "oms_purchase_order_item_history", "oms_vendor_info", "project_order_info", + // 财务 + "oms_receivable_bill", "oms_receivable_receipt_plan", "oms_receivable_receipt_detail", + "oms_receivable_invoice_plan", "oms_receivable_invoice_detail", + "oms_payable_bill", "oms_payable_payment_plan", "oms_payable_payment_detail", + "oms_payable_ticket_plan", "oms_payable_ticket_detail", + "oms_receipt_bill", "oms_payment_bill", "oms_invoice_bill", "oms_ticket_bill", + "oms_finance_charge", "oms_fin_attachment", "oms_company_info", + // 项目 / 合同 + "project_info", "project_product_info", "project_work_progress", "project_poc_info", + "oms_quotation", "oms_quotation_product_info", "order_info", "order_list", + // 主数据 + "partner_info", "customer_info", "agent_info", "product_info", "sys_user", + // 审批 + "bu_todo", "bu_todo_completed" + ))); + + /** + * 明确"看起来可用但不采用"的表(方案已确认,禁止回退查询)。 + */ + public static final Set EXCLUDED = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList( + "vendor_info", "oms_inventory_inner_detail", "oms_inventory_inner_maintenance", + "oms_inventory_info_copy1", "delivery_list_0618", "product_info_260916", + "project_info_20260904bak", "oms_purchase_order_1211", "oms_payable_bill_copy1", + "oms_finance_operate_report" + ))); + + private McpTables() { + } + + public static boolean isAllowed(String table) { + return table != null && ALLOWED.contains(table.toLowerCase()); + } + + public static void assertAllowed(String table) { + if (!isAllowed(table)) { + throw McpToolException.serverError("禁止访问表:" + table); + } + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpTimeRange.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpTimeRange.java new file mode 100644 index 00000000..fe241198 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpTimeRange.java @@ -0,0 +1,33 @@ +package com.ruoyi.sip.llm.tools.support; + +import java.util.Date; + +/** + * 时间区间,语义为 [start, end)(左闭右开),用于时间条件下推。 + */ +public class McpTimeRange { + + private final Date start; + private final Date end; + + public McpTimeRange(Date start, Date end) { + this.start = start; + this.end = end; + } + + public static McpTimeRange of(Date start, Date end) { + return new McpTimeRange(start, end); + } + + public Date getStart() { + return start; + } + + public Date getEnd() { + return end; + } + + public boolean isEmpty() { + return start == null && end == null; + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpToolException.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpToolException.java new file mode 100644 index 00000000..7c5aa15a --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/llm/tools/support/McpToolException.java @@ -0,0 +1,46 @@ +package com.ruoyi.sip.llm.tools.support; + +import com.ruoyi.common.utils.McpErrorUtils; + +/** + * MCP 工具异常:携带 JSON-RPC 错误码与错误类型,供 McpController 统一转换为 error 响应。 + */ +public class McpToolException extends RuntimeException { + + private final int code; + private final String type; + + public McpToolException(int code, String type, String message) { + super(message); + this.code = code; + this.type = type; + } + + public int getCode() { + return code; + } + + public String getType() { + return type; + } + + public static McpToolException invalidParams(String message) { + return new McpToolException(McpErrorUtils.INVALID_PARAMS, "invalid_params", message); + } + + public static McpToolException authError(String message) { + return new McpToolException(McpErrorUtils.AUTH_ERROR, "authorization_error", message); + } + + public static McpToolException serverError(String message) { + return new McpToolException(McpErrorUtils.SERVER_ERROR, "server_error", message); + } + + public static McpToolException rateLimited(String message) { + return new McpToolException(McpErrorUtils.RATE_LIMITED, "rate_limit_error", message); + } + + public static McpToolException timeout(String message) { + return new McpToolException(McpErrorUtils.QUERY_TIMEOUT, "query_timeout", message); + } +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/AgentInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/AgentInfoMapper.java index b3d9ebaa..461f43eb 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/AgentInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/AgentInfoMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.sip.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.AgentInfo; /** @@ -61,4 +62,11 @@ public interface AgentInfoMapper public int selectCountByCode(AgentInfo agentInfo); AgentInfo selectAgentInfoByCode(String agentCode); + + /** + * MCP 只读:主数据列表(批量编码 + 名称模糊 + 游标分页,排序键 agent_code,id)。 + * + * @param query codeList / nameKeyword / status / lastCode / lastId / limit + */ + List listAgentMasterPage(Map query); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/CrossDomainAggregateMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/CrossDomainAggregateMapper.java new file mode 100644 index 00000000..06689fe3 --- /dev/null +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/CrossDomainAggregateMapper.java @@ -0,0 +1,109 @@ +package com.ruoyi.sip.mapper; + +import org.apache.ibatis.annotations.Param; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 受限跨域透视(MCP 工具 {@code cross_domain_aggregate})专属只读聚合 Mapper。 + * + *

方案依据:{@code docx/mcp-data-tools-plan.md} 第 7 章第 18 项(新增 CrossDomainAggregateMapper.java/.xml)、 + * 第 9 章(聚合口径)、第 13 章(一次算完,禁止 Agent 翻页累加)、第 15 章(时间区间下推、类型规范化)、16.2(白名单与护栏)。

+ * + *

★本接口只包含 5 条按链路划分的分组聚合 {@code select},无任何写操作; + * 且全部 SQL 均落在本文件对应的 XML 内(不改动任何既有 Mapper 接口/XML),避免与他人改动冲突。

+ * + *

5 条链路与本文件方法的对应关系:

+ *
    + *
  • {@link #selectSalesAggregate} :SALES,{@code project_product_info} ⋈ {@code project_order_info} ⋈ {@code project_info}
  • + *
  • {@link #selectPurchaseAggregate}:PURCHASE,{@code oms_purchase_order_item} ⋈ {@code oms_purchase_order}
  • + *
  • {@link #selectStockAggregate}:STOCK,{@code oms_inventory_info}
  • + *
  • {@link #selectReceivableAggregate}:FINANCE_AR,{@code oms_receivable_bill}(金额直接取冗余列,不重算)
  • + *
  • {@link #selectPayableAggregate}:FINANCE_AP,{@code oms_payable_bill}(同上)
  • + *
+ * + *

通用入参约定(所有方法一致):

+ *
    + *
  • {@code d0}/{@code d1}/{@code d2}:维度键(链路口径的白名单枚举,如 {@code CUSTOMER}/{@code PRODUCT}/{@code MONTH}); + * {@code d1}/{@code d2} 为 {@code null} 表示该位置不分组。维度键在 Java 侧已按白名单校验,XML 内用 {@code } 映射为固定表达式, + * 不接受任何来自模型的原始 SQL 片段。
  • + *
  • {@code orderMetric}:排序度量枚举(按“第一个 metric 降序”),XML 内同样用 {@code } 映射为固定别名,避免字符串拼接注入。
  • + *
  • {@code startTime}/{@code endTime}:时间区间 [start, end),一律做区间下推({@code >= start and < end}), + * 禁止 {@code date_format(...)} 作为过滤条件(见方案 15.1)。
  • + *
  • {@code limit}:分组数上限(调用方传 {@code group_limit + 1} 以探测截断)。
  • + *
+ * + *

返回:{@code List>},每行含 {@code d0/d0Name[、d1/d1Name、d2/d2Name]} + 该链路全部度量列 + {@code rowQty}(参与行数)。

+ */ +public interface CrossDomainAggregateMapper { + + /** + * SALES 链路聚合:{@code project_product_info} ⋈ {@code project_order_info}(project_id)⋈ {@code project_info}(project_id)。 + *

维度:{@code CUSTOMER}/{@code AGENT}/{@code PRODUCT}/{@code PROJECT}/{@code MONTH}/{@code QUARTER}; + * 度量:{@code salesAmountWithTax}、{@code salesAmountWithoutTax}、{@code productQty};时间字段 {@code project_order_info.create_time}。

+ */ + List> selectSalesAggregate(@Param("d0") String d0, + @Param("d1") String d1, + @Param("d2") String d2, + @Param("orderMetric") String orderMetric, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("limit") int limit); + + /** + * PURCHASE 链路聚合:{@code oms_purchase_order_item} ⋈ {@code oms_purchase_order}(purchase_id)。 + *

维度:{@code PRODUCT}/{@code PARTNER}(=制造商,经 {@code oms_purchase_order.vendor_id} → {@code oms_vendor_info})/ + * {@code MONTH}/{@code QUARTER};度量:{@code purchaseQty}、{@code purchaseAmountWithTax}、{@code purchaseTax}; + * 时间字段 {@code oms_purchase_order.purchase_date}。

+ */ + List> selectPurchaseAggregate(@Param("d0") String d0, + @Param("d1") String d1, + @Param("d2") String d2, + @Param("orderMetric") String orderMetric, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("limit") int limit); + + /** + * STOCK 链路聚合:{@code oms_inventory_info} 单表(按需 left join 字典表取名称)。 + *

维度:{@code PRODUCT}/{@code WAREHOUSE}/{@code MONTH}/{@code QUARTER}; + * 度量:{@code inStockQty}、{@code outStockQty};时间字段 {@code oms_inventory_info.create_time}。

+ */ + List> selectStockAggregate(@Param("d0") String d0, + @Param("d1") String d1, + @Param("d2") String d2, + @Param("orderMetric") String orderMetric, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("limit") int limit); + + /** + * FINANCE_AR 链路聚合:{@code oms_receivable_bill}(金额列直接 sum 冗余列,不重算)。 + *

维度:{@code CUSTOMER}({@code partner_code},客户)/{@code MONTH}/{@code QUARTER}; + * 度量:{@code receivableWithTax}、{@code receivedWithTax}、{@code unreceivedWithTax}、{@code uninvoicedWithTax}; + * 时间字段 {@code oms_receivable_bill.create_time}。

+ */ + List> selectReceivableAggregate(@Param("d0") String d0, + @Param("d1") String d1, + @Param("d2") String d2, + @Param("orderMetric") String orderMetric, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("limit") int limit); + + /** + * FINANCE_AP 链路聚合:{@code oms_payable_bill}(金额列直接 sum 冗余列,不重算)。 + *

维度:{@code PARTNER}({@code vendor_code},此处 partner 语义为制造商)/{@code MONTH}/{@code QUARTER}; + * 度量:{@code payableWithTax}、{@code paidWithTax}、{@code unpaidWithTax}、{@code unticketedWithTax}; + * 时间字段 {@code oms_payable_bill.create_time}。

+ */ + List> selectPayableAggregate(@Param("d0") String d0, + @Param("d1") String d1, + @Param("d2") String d2, + @Param("orderMetric") String orderMetric, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("limit") int limit); +} diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/CustomerInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/CustomerInfoMapper.java index 2f32b5d8..0c0550ca 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/CustomerInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/CustomerInfoMapper.java @@ -4,6 +4,7 @@ import com.ruoyi.sip.domain.CustomerInfo; import org.apache.ibatis.annotations.Param; import java.util.List; +import java.util.Map; /** @@ -68,4 +69,11 @@ public interface CustomerInfoMapper int selectCountByName(@Param("name") String name, @Param("excludeId") Long excludeId); + /** + * MCP 只读:主数据列表(批量编码 + 名称模糊 + 游标分页,排序键 customer_code,id)。 + * + * @param query codeList / nameKeyword / status / lastCode / lastId / limit + */ + List listCustomerMasterPage(Map query); + } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/DeliveryListMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/DeliveryListMapper.java index 37767317..375b30aa 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/DeliveryListMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/DeliveryListMapper.java @@ -72,4 +72,9 @@ public interface DeliveryListMapper void deleteDeliveryListByDeliveryIds(String[] strArray); void updateTimeByOrderId(List longs); + + /** + * MCP 只读:按发货单 id 批量查 SN 明细(必须过滤 deleted_at is null)。 + */ + List listByDeliveryIdList(@Param("deliveryIdList") List deliveryIdList); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryDeliveryMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryDeliveryMapper.java index 82d7dd79..a3563f5b 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryDeliveryMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryDeliveryMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.sip.mapper; import java.util.Date; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.InventoryDelivery; import com.ruoyi.sip.dto.ApiDataQueryDto; import com.ruoyi.sip.vo.DeliveryApproveVo; @@ -110,4 +111,20 @@ public interface InventoryDeliveryMapper */ Date selectLastRecallTimeByOuterCode(@Param("outerCode") String outerCode); + // ======================= MCP 只读查询(新增,仅 SELECT) ======================= + + /** + * MCP 只读:按出库单号列表批量查发货单(现有 selectInventoryDeliveryList 仅支持单个 outerCode)。 + */ + List listByOuterCodeList(@Param("outerCodeList") List outerCodeList); + + /** + * MCP warehouse_list(entity=DELIVERY):发货单游标分页(排序键 outer_code,id)。 + */ + List listDeliveryPage(Map params); + + /** + * MCP warehouse_list(entity=DELIVERY):count。 + */ + long countDeliveryPage(Map params); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryInfoMapper.java index 5b03e6bf..4a7e0643 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryInfoMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.sip.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.InventoryInfo; import com.ruoyi.sip.dto.inventory.ProductWarehouseInfo; import org.apache.ibatis.annotations.Param; @@ -102,4 +103,71 @@ public interface InventoryInfoMapper void clearOrderCodeByIds(@Param("idList") List idList); void clearPayableBillCodeByIds(@Param("idList") List idList); + + // ======================= MCP 只读查询(新增,仅 SELECT) ======================= + + /** + * MCP inventory_sn_trace:按产品 SN 列表查库存明细(带 limit 上界,字段含 model/描述/仓库)。 + */ + List listSnTraceByProductSnList(@Param("productSnList") List productSnList, + @Param("limit") int limit); + + /** + * MCP inventory_sn_trace:按入库单号列表查库存明细(带 limit 上界)。 + */ + List listSnTraceByInnerCodeList(@Param("innerCodeList") List innerCodeList, + @Param("limit") int limit); + + /** + * MCP inventory_sn_trace:按出库单号列表查库存明细(带 limit 上界)。 + */ + List listSnTraceByOuterCodeList(@Param("outerCodeList") List outerCodeList, + @Param("limit") int limit); + + /** + * MCP inventory_sn_trace:按合同编号列表反查已出库 SN 明细(带 limit 上界)。 + */ + List listSnTraceByOrderCode(@Param("orderCodeList") List orderCodeList, + @Param("limit") int limit); + + /** + * MCP inventory_stock_aggregate:一次全量聚合(SUMMARY)。 + * groupBy ∈ NONE/PRODUCT/WAREHOUSE/STATUS,按数量倒序取 topN。 + */ + List> selectInventoryStockSummary(@Param("groupBy") String groupBy, + @Param("productCodeList") List productCodeList, + @Param("warehouseId") Long warehouseId, + @Param("includeZero") boolean includeZero, + @Param("topN") Integer topN); + + /** + * MCP inventory_stock_aggregate(LIST):按产品分页聚合(游标键 product_code,升序)。 + */ + List> selectStockGroupPage(@Param("productCodeList") List productCodeList, + @Param("warehouseId") Long warehouseId, + @Param("cursor") String cursor, + @Param("includeZero") boolean includeZero, + @Param("limit") int limit); + + /** + * MCP inventory_stock_aggregate(LIST):按产品码批量取仓库拆分。 + */ + List> selectStockByProductWarehouse(@Param("productCodeList") List productCodeList, + @Param("warehouseId") Long warehouseId); + + /** + * MCP inventory_stock_aggregate(LIST):count(产品分组数,受 count_cap 保护由调用方处理)。 + */ + long countStockGroups(@Param("productCodeList") List productCodeList, + @Param("warehouseId") Long warehouseId); + + /** + * MCP warehouse_list(entity=SN):SN 明细游标分页(排序键 product_sn,id)。 + */ + List listSnPage(Map params); + + /** + * MCP warehouse_list(entity=SN):count(受 count_cap 保护由调用方处理)。 + */ + long countSnPage(Map params); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryOuterDetailMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryOuterDetailMapper.java index 1b352595..d3a875d6 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryOuterDetailMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryOuterDetailMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.sip.mapper; import java.util.List; import com.ruoyi.sip.domain.InventoryOuterDetail; +import org.apache.ibatis.annotations.Param; /** * 出库单详情Mapper接口 @@ -65,4 +66,9 @@ public interface InventoryOuterDetailMapper List listMailDataByOuterId(Long id); + + /** + * MCP 只读:按出库单号批量查出库明细(消除 N+1,依赖 P0-2 索引 idx_outer_code)。 + */ + List listByOuterCodeList(@Param("outerCodeList") List outerCodeList); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryOuterMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryOuterMapper.java index 65b21a3c..ef5659cf 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryOuterMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/InventoryOuterMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.sip.mapper; import java.math.BigDecimal; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.InventoryOuter; import com.ruoyi.sip.domain.VendorInfo; import org.apache.ibatis.annotations.Param; @@ -81,4 +82,16 @@ public interface InventoryOuterMapper * @return 结果 */ int updateDeliveryStatusByOuterCode(@Param("outerCode") String outerCode, @Param("deliveryStatus") String deliveryStatus); + + // ======================= MCP 只读查询(新增,仅 SELECT) ======================= + + /** + * MCP warehouse_list(entity=OUTER):出库单游标分页(排序键 outer_code,id)。 + */ + List listOuterPage(Map params); + + /** + * MCP warehouse_list(entity=OUTER):count。 + */ + long countOuterPage(Map params); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsCompanyInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsCompanyInfoMapper.java index de83a47c..488e2106 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsCompanyInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsCompanyInfoMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.sip.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.OmsCompanyInfo; /** @@ -58,4 +59,11 @@ public interface OmsCompanyInfoMapper * @return 结果 */ public int deleteOmsCompanyInfoByIds(Long[] ids); + + /** + * MCP 只读:主数据列表(批量编码 + 名称模糊 + 游标分页,排序键 company_code,id)。 + * + * @param query codeList / nameKeyword / lastCode / lastId / limit + */ + List listCompanyMasterPage(Map query); } \ No newline at end of file diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsFinAttachmentMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsFinAttachmentMapper.java index 4592a244..cd8887d9 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsFinAttachmentMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsFinAttachmentMapper.java @@ -1,5 +1,6 @@ package com.ruoyi.sip.mapper; +import java.util.Date; import java.util.List; import com.ruoyi.sip.domain.OmsFinAttachment; import org.apache.ibatis.annotations.Param; @@ -63,4 +64,34 @@ public interface OmsFinAttachmentMapper void deleteOmsFinAttachmentByPayment(@Param("list") List paymentBillCodeList,@Param("type") String type); void deleteOmsFinAttachmentByRelationId(Long id); + + /* ==================== MCP 财务只读查询(新增,勿改上面既有语句语义) ==================== */ + + /** + * 【MCP 只读】财务附件游标分页查询(仅元数据,不返回 file_path;强制 del_flag='0';排序键 related_bill_id,id)。 + * + * @param relatedBillIdList 关联单据ID列表(bill_code_list 对 ATTACHMENT 即关联单据ID) + * @param relatedBillType 关联单据类型(payment/ticket/receipt/invoice) + * @param startTime create_time 下界(含,仅在显式传入时间区间时下发) + * @param endTime create_time 上界(不含) + * @param lastRelatedBillId 游标:上一页最后一行的 related_bill_id + * @param lastId 游标:上一页最后一行的 id + * @param limit 取数条数(应为 pageSize + 1) + */ + List listAttachmentPage(@Param("relatedBillIdList") List relatedBillIdList, + @Param("relatedBillType") String relatedBillType, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("lastRelatedBillId") Long lastRelatedBillId, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】财务附件分页过滤条件下的总条数(不翻页)。 + */ + long countAttachmentPage(@Param("relatedBillIdList") List relatedBillIdList, + @Param("relatedBillType") String relatedBillType, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime); + } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsFinanceChargeMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsFinanceChargeMapper.java index 9d81bbb5..38fa2ad4 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsFinanceChargeMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsFinanceChargeMapper.java @@ -1,7 +1,9 @@ package com.ruoyi.sip.mapper; +import java.util.Date; import java.util.List; import com.ruoyi.sip.domain.OmsFinanceCharge; +import org.apache.ibatis.annotations.Param; /** * 财务计收Mapper接口 @@ -65,4 +67,33 @@ public interface OmsFinanceChargeMapper int revoke(OmsFinanceCharge omsFinanceCharge); + /* ==================== MCP 财务只读查询(新增,勿改上面既有语句语义) ==================== */ + + /** + * 【MCP 只读】财务计收游标分页查询(排序键 order_code,id;时间字段 biz_charge_date 区间下推,禁止 date_format)。 + * + * @param orderCodeList 合同编号列表(bill_code_list 对 CHARGE 即订单号;order_code 亦并入此处) + * @param chargeStatus 计收状态(0等待收款/1商务可申请计收/2财务已申请计收/3已完成计收) + * @param startTime biz_charge_date 下界(含) + * @param endTime biz_charge_date 上界(不含) + * @param lastCode 游标:上一页最后一行的 order_code + * @param lastId 游标:上一页最后一行的 id + * @param limit 取数条数(应为 pageSize + 1) + */ + List listFinanceChargePage(@Param("orderCodeList") List orderCodeList, + @Param("chargeStatus") String chargeStatus, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("lastCode") String lastCode, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】财务计收分页过滤条件下的总条数(不翻页)。 + */ + long countFinanceChargePage(@Param("orderCodeList") List orderCodeList, + @Param("chargeStatus") String chargeStatus, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime); + } \ No newline at end of file diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInventoryDeliveryDetailMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInventoryDeliveryDetailMapper.java index 56e991f3..bac3bd34 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInventoryDeliveryDetailMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInventoryDeliveryDetailMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.sip.mapper; import java.util.List; import com.ruoyi.sip.domain.OmsInventoryDeliveryDetail; +import org.apache.ibatis.annotations.Param; /** * 产品库存Mapper接口 @@ -64,4 +65,9 @@ public interface OmsInventoryDeliveryDetailMapper void deleteOmsInventoryDeliveryDetailByDeliveryId(Long id); List selectOmsInventoryDeliveryDetailByDeliveryId(Long id); + + /** + * MCP 只读:按发货单 id 批量查 SN 明细(消除 N+1,idx_delivery_id 已具备)。 + */ + List listByDeliveryIdList(@Param("deliveryIdList") List deliveryIdList); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInventoryInnerMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInventoryInnerMapper.java index d020d574..53146dfc 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInventoryInnerMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInventoryInnerMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.sip.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.OmsInventoryInner; import com.ruoyi.sip.dto.inventory.ProductInventoryInnerDTO; import com.ruoyi.sip.dto.inventory.ProductInventoryQueryDTO; @@ -74,4 +75,16 @@ public interface OmsInventoryInnerMapper List selectOmsInventoryInnerByInnerCodeList(List innerCodeList); List listInventoryInner(ProductInventoryQueryDTO query); + + // ======================= MCP 只读查询(新增,仅 SELECT) ======================= + + /** + * MCP warehouse_list(entity=INNER):入库单游标分页(排序键 inner_code,id,时间过滤走 create_time 区间下推)。 + */ + List listInnerPage(Map params); + + /** + * MCP warehouse_list(entity=INNER):count。 + */ + long countInnerPage(Map params); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInvoiceBillMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInvoiceBillMapper.java index 32063eeb..f3fe31dc 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInvoiceBillMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsInvoiceBillMapper.java @@ -4,6 +4,7 @@ import com.ruoyi.sip.domain.OmsInvoiceBill; import com.ruoyi.sip.flowable.domain.FlowRelationDto; import org.apache.ibatis.annotations.Param; +import java.util.Date; import java.util.List; /** @@ -96,4 +97,29 @@ public interface OmsInvoiceBillMapper void updateReturnWriteOffBatch(List updateBills); + /* ==================== MCP 财务只读查询(新增,勿改上面既有语句语义) ==================== */ + + /** + * 【MCP 只读】开票单游标分页查询(排序键 invoice_bill_code,id;时间字段 invoice_time 区间下推,禁止 date_format)。 + */ + List listInvoiceBillPage(@Param("invoiceBillCodeList") List invoiceBillCodeList, + @Param("partnerCodeList") List partnerCodeList, + @Param("status") String status, + @Param("approveStatus") String approveStatus, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("lastCode") String lastCode, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】开票单分页过滤条件下的总条数(不翻页)。 + */ + long countInvoiceBillPage(@Param("invoiceBillCodeList") List invoiceBillCodeList, + @Param("partnerCodeList") List partnerCodeList, + @Param("status") String status, + @Param("approveStatus") String approveStatus, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime); + } \ No newline at end of file diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPayableBillMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPayableBillMapper.java index 212e5889..dea5e760 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPayableBillMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPayableBillMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.sip.mapper; import java.util.Date; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.OmsPayableBill; import org.apache.ibatis.annotations.Param; @@ -81,4 +82,58 @@ public interface OmsPayableBillMapper String selectPurcahseNoByPayableBillCode(@Param("payableBillCode") String payableBillCode); + /* ==================== MCP 财务只读查询(新增,勿改上面既有语句语义) ==================== */ + + /** + * 【MCP 只读】应付单游标分页查询(排序键 payable_bill_code,id;时间字段 create_time 区间下推,禁止 date_format)。 + * + * @param payableBillCodeList 应付单号列表(bill_code_list) + * @param orderCodeList 合同编号列表 + * @param vendorCodeList 制造商编码列表(vendor_code_list) + * @param startTime create_time 下界(含) + * @param endTime create_time 上界(不含) + * @param lastCode 游标:上一页最后一行的 payable_bill_code + * @param lastId 游标:上一页最后一行的 id + * @param limit 取数条数(应为 pageSize + 1) + */ + List listPayableBillPage(@Param("payableBillCodeList") List payableBillCodeList, + @Param("orderCodeList") List orderCodeList, + @Param("vendorCodeList") List vendorCodeList, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("lastCode") String lastCode, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】应付单分页过滤条件下的总条数(不翻页)。 + */ + long countPayableBillPage(@Param("payableBillCodeList") List payableBillCodeList, + @Param("orderCodeList") List orderCodeList, + @Param("vendorCodeList") List vendorCodeList, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime); + + /** + * 【MCP 只读】应付余额聚合:动态 group_by(NONE / ORDER / PARTNER / TIME_MONTH)。 + *

度量直接取冗余列(sum total_price_with_tax / paid_payment_amount / unpaid_payment_amount / unreceived_ticket_amount),不重算。

+ * + * @return 分组行:[groupKey, groupName, payableWithTax, paidWithTax, unpaidWithTax, unticketedWithTax, itemQty] + */ + List> selectPayableBalanceSummary(@Param("groupBy") String groupBy, + @Param("orderCodeList") List orderCodeList, + @Param("vendorCodeList") List vendorCodeList, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("keyset") Integer keyset, + @Param("lastGroupKey") String lastGroupKey, + @Param("limit") int limit); + + /** + * 【MCP 只读】应付历史时点重算:Σ应付(create_time ≤ T) 与 Σ已付(payment_time ≤ T)。 + * + * @return totalWithTax=Σ oms_payable_bill.total_price_with_tax,settledAmount=Σ oms_payable_payment_detail.payment_amount + */ + Map selectPayableAsOf(@Param("asOfDate") Date asOfDate); + } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPayableTicketPlanMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPayableTicketPlanMapper.java index fedc3bc7..66970646 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPayableTicketPlanMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPayableTicketPlanMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.sip.mapper; import java.util.List; import com.ruoyi.sip.domain.OmsPayableTicketPlan; +import org.apache.ibatis.annotations.Param; /** * 应付单收票计划Mapper接口 @@ -68,4 +69,9 @@ public interface OmsPayableTicketPlanMapper * @return 结果 */ public int deleteByPayableBillId(Long payableBillId); + + /** + * 【MCP 只读】按应付单主键列表批量查询收票计划(消除 N+1)。 + */ + List listByPayableBillIdList(@Param("payableBillIdList") List payableBillIdList); } \ No newline at end of file diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPaymentBillMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPaymentBillMapper.java index 830fb440..78767951 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPaymentBillMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPaymentBillMapper.java @@ -1,5 +1,6 @@ package com.ruoyi.sip.mapper; +import java.util.Date; import java.util.List; import java.util.Map; @@ -104,4 +105,31 @@ public interface OmsPaymentBillMapper List> listRefundSummaryByOriginalBillIds(@Param("originalBillIds") List originalBillIds, @Param("excludeApproveStatus") String excludeApproveStatus); + /* ==================== MCP 财务只读查询(新增,勿改上面既有语句语义) ==================== */ + + /** + * 【MCP 只读】付款单游标分页查询(排序键 payment_bill_code,id;时间字段 payment_time 区间下推,禁止 date_format)。 + */ + List listPaymentBillPage(@Param("paymentBillCodeList") List paymentBillCodeList, + @Param("orderCode") String orderCode, + @Param("vendorCodeList") List vendorCodeList, + @Param("status") String status, + @Param("approveStatus") String approveStatus, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("lastCode") String lastCode, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】付款单分页过滤条件下的总条数(不翻页)。 + */ + long countPaymentBillPage(@Param("paymentBillCodeList") List paymentBillCodeList, + @Param("orderCode") String orderCode, + @Param("vendorCodeList") List vendorCodeList, + @Param("status") String status, + @Param("approveStatus") String approveStatus, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime); + } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderHistoryMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderHistoryMapper.java index 737f1ae7..ef02b81c 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderHistoryMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderHistoryMapper.java @@ -1,6 +1,9 @@ package com.ruoyi.sip.mapper; import com.ruoyi.sip.domain.OmsPurchaseOrderHistory; +import org.apache.ibatis.annotations.Param; + +import java.util.Date; import java.util.List; /** @@ -42,4 +45,36 @@ public interface OmsPurchaseOrderHistoryMapper * @return 采购单明细历史记录集合 */ public List selectOmsPurchaseOrderItemHistoryListByPurchaseHistoryId(Long purchaseHistoryId); + + /** + * 【MCP 只读】采购单历史按游标分页查询(排序键 purchase_no,id;limit+1 探测 has_more)。 + *

历史表无 purchase_date 列,时间条件基于 create_time 区间下推(>= startTime and < endTime),不使用 date_format。

+ * + * @param purchaseNoList 采购单号列表(code_list) + * @param status 采购状态(0待入库 1部分入库 2已完成) + * @param approveStatus 审批状态(0草稿 1审批中 2已通过 3驳回) + * @param confirmStatus 确认状态(0待确认 1已确认 2已驳回) + * @param flowType 流程类型(online/offline) + * @param startTime 创建时间下界(含) + * @param endTime 创建时间上界(不含) + * @param lastPurchaseNo 游标:上一页最后一行的 purchase_no + * @param lastId 游标:上一页最后一行的 id + * @param limit 取数条数(应为 pageSize + 1) + */ + public List listByPurchaseNoList(@Param("purchaseNoList") List purchaseNoList, + @Param("status") Integer status, + @Param("approveStatus") String approveStatus, + @Param("confirmStatus") String confirmStatus, + @Param("flowType") String flowType, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("lastPurchaseNo") String lastPurchaseNo, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】按采购单历史ID批量查询历史明细(消除 N+1)。 + */ + public List listItemByPurchaseHistoryIdList( + @Param("purchaseHistoryIdList") List purchaseHistoryIdList); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderMapMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderMapMapper.java index 7ae9ef80..cb45b3dc 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderMapMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderMapMapper.java @@ -3,6 +3,9 @@ package com.ruoyi.sip.mapper; import com.ruoyi.sip.domain.OmsPurchaseOrderMap; import org.apache.ibatis.annotations.Param; +import java.util.List; +import java.util.Map; + public interface OmsPurchaseOrderMapMapper { OmsPurchaseOrderMap selectByOrderIdAndPurchaseId(@Param("orderId") Long orderId, @@ -14,4 +17,29 @@ public interface OmsPurchaseOrderMapMapper { int updateOmsPurchaseOrderMap(OmsPurchaseOrderMap omsPurchaseOrderMap); int initOmsPurchaseOrderMapBindNum(@Param("orderId") Long orderId); + + /** + * 【MCP 只读】按订单号(合同编号)批量查询采购-订单绑定行,并补齐采购单号与该表的主键排序键。 + *

注意:{@code oms_purchase_order_map.order_id} 实测关联 {@code project_order_info.id} + * (见 {@code OmsPurchaseOrderMapper.listBindOrder} / {@code ProjectOrderInfoMapper}), + * 订单号取自 {@code project_order_info.order_code},并按方案 15.14 对编码 join 做 {@code trim()} 对齐。

+ *

该表 order_id 无索引,仅适用于小数据量(实测 0/少量行)。排序键 order_id,purchase_id,id(id 为唯一兜底,避免同单同采购多产品行在游标处漏行)。

+ * + * @param orderCodeList 订单号(合同编号)列表 + * @param lastOrderId 游标:上一页最后一行的 order_id + * @param lastPurchaseId 游标:上一页最后一行的 purchase_id + * @param lastId 游标:上一页最后一行的 id + * @param limit 取数条数(应为 pageSize + 1) + * @return 行:[orderId, orderCode, purchaseId, purchaseNo, productCode, bindNum] + */ + List> listByOrderCodeList(@Param("orderCodeList") List orderCodeList, + @Param("lastOrderId") Long lastOrderId, + @Param("lastPurchaseId") Long lastPurchaseId, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】按 order_id 批量查询绑定行(辅助方法,无游标)。 + */ + List listByOrderIdList(@Param("orderIdList") List orderIdList); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderMapper.java index 1ccaeedb..8aed5700 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsPurchaseOrderMapper.java @@ -1,6 +1,8 @@ package com.ruoyi.sip.mapper; +import java.util.Date; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.OmsPurchaseOrder; import com.ruoyi.sip.domain.OmsPurchaseOrderItem; import com.ruoyi.sip.dto.inventory.OmsPurchaseOrderItemDto; @@ -119,4 +121,75 @@ public interface OmsPurchaseOrderMapper int recallPurchaseOrder(OmsPurchaseOrder omsPurchaseOrder); + /** + * 【MCP 只读】采购单列表按游标分页查询(排序键 purchase_no,id;limit+1 探测 has_more)。 + *

时间条件基于 purchase_date 区间下推(>= startTime and < endTime),不使用 date_format;逻辑删除过滤 del_flag。

+ * + * @param purchaseNoList 采购单号列表(code_list) + * @param vendorCodeList 用户指定的制造商编码列表(vendor_code_list) + * @param authVendorCodeList 当前用户可访问的制造商编码(行级数据范围) + * @param status 采购状态(0待入库 1部分入库 2已完成) + * @param approveStatus 审批状态(0草稿 1审批中 2已通过 3驳回) + * @param confirmStatus 供应商确认状态(0待确认 1已确认 2已驳回) + * @param flowType 流程类型(online/offline) + * @param productCodeList 产品编码列表(存在性过滤) + * @param startTime 采购日期下界(含) + * @param endTime 采购日期上界(不含) + * @param lastPurchaseNo 游标:上一页最后一行的 purchase_no + * @param lastId 游标:上一页最后一行的 id + * @param limit 取数条数(应为 pageSize + 1) + */ + List selectPurchaseOrderPage(@Param("purchaseNoList") List purchaseNoList, + @Param("vendorCodeList") List vendorCodeList, + @Param("authVendorCodeList") List authVendorCodeList, + @Param("status") Integer status, + @Param("approveStatus") String approveStatus, + @Param("confirmStatus") String confirmStatus, + @Param("flowType") String flowType, + @Param("productCodeList") List productCodeList, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("lastPurchaseNo") String lastPurchaseNo, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】采购明细列表按游标分页查询(排序键 purchase_no,id,purchase_no 来自 oms_purchase_order 关联)。 + */ + List selectPurchaseOrderItemPage(@Param("purchaseNoList") List purchaseNoList, + @Param("vendorCodeList") List vendorCodeList, + @Param("authVendorCodeList") List authVendorCodeList, + @Param("status") Integer status, + @Param("approveStatus") String approveStatus, + @Param("confirmStatus") String confirmStatus, + @Param("flowType") String flowType, + @Param("productCodeList") List productCodeList, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("lastPurchaseNo") String lastPurchaseNo, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】采购到货汇总:{@code oms_purchase_order} ⋈ {@code oms_purchase_order_item} 动态分组聚合。 + *

group_by 取值为 NONE / ORDER / VENDOR / PRODUCT / STATUS / TIME_MONTH; + * 时间条件基于 purchase_date 区间下推(>= startTime and < endTime)。

+ * + * @param groupBy 分组维度 + * @param purchaseNoList 采购单号收窄(可空) + * @param vendorCodeList 制造商编码收窄(可空) + * @param authVendorCodeList 行级数据范围(可空) + * @param startTime 采购日期下界(含) + * @param endTime 采购日期上界(不含) + * @param limit top_n(NONE 时取 1) + * @return 分组行:[groupKey, vendorCode, vendorName, purchaseQty, innerQty, pendingQty, amountWithTax, taxTotal] + */ + List> selectPurchaseArrivalSummary(@Param("groupBy") String groupBy, + @Param("purchaseNoList") List purchaseNoList, + @Param("vendorCodeList") List vendorCodeList, + @Param("authVendorCodeList") List authVendorCodeList, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("limit") int limit); + } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceiptBillMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceiptBillMapper.java index e6e9a86a..69ce8ddf 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceiptBillMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceiptBillMapper.java @@ -4,6 +4,7 @@ import com.ruoyi.sip.domain.OmsReceiptBill; import com.ruoyi.sip.flowable.domain.FlowRelationDto; import org.apache.ibatis.annotations.Param; +import java.util.Date; import java.util.List; /** @@ -62,4 +63,31 @@ public interface OmsReceiptBillMapper { void revoke(OmsReceiptBill omsReceiptBill); + /* ==================== MCP 财务只读查询(新增,勿改上面既有语句语义) ==================== */ + + /** + * 【MCP 只读】收款单游标分页查询(排序键 receipt_bill_code,id;时间字段 receipt_time 区间下推,禁止 date_format)。 + */ + List listReceiptBillPage(@Param("receiptBillCodeList") List receiptBillCodeList, + @Param("orderCode") String orderCode, + @Param("partnerCodeList") List partnerCodeList, + @Param("status") String status, + @Param("approveStatus") String approveStatus, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("lastCode") String lastCode, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】收款单分页过滤条件下的总条数(不翻页)。 + */ + long countReceiptBillPage(@Param("receiptBillCodeList") List receiptBillCodeList, + @Param("orderCode") String orderCode, + @Param("partnerCodeList") List partnerCodeList, + @Param("status") String status, + @Param("approveStatus") String approveStatus, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime); + } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableBillMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableBillMapper.java index fcbfb68e..a1c2e7d7 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableBillMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableBillMapper.java @@ -1,7 +1,10 @@ package com.ruoyi.sip.mapper; +import java.util.Date; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.OmsReceivableBill; +import org.apache.ibatis.annotations.Param; /** * 销售应收单Mapper接口 @@ -79,4 +82,67 @@ public interface OmsReceivableBillMapper List listReceivableBill(OmsReceivableBill queryUpdateAllReceivableBill); + /* ==================== MCP 财务只读查询(新增,勿改上面既有语句语义) ==================== */ + + /** + * 【MCP 只读】应收单游标分页查询(排序键 receivable_bill_code,id;时间字段 create_time 区间下推,禁止 date_format)。 + * + * @param receivableBillCodeList 应收单号列表(bill_code_list) + * @param orderCodeList 合同编号列表(order_code / 由单号反查得到) + * @param partnerCodeList 客户编码列表(partner_code_list) + * @param startTime 创建时间下界(含) + * @param endTime 创建时间上界(不含) + * @param lastCode 游标:上一页最后一行的 receivable_bill_code + * @param lastId 游标:上一页最后一行的 id + * @param limit 取数条数(应为 pageSize + 1) + */ + List listReceivableBillPage(@Param("receivableBillCodeList") List receivableBillCodeList, + @Param("orderCodeList") List orderCodeList, + @Param("partnerCodeList") List partnerCodeList, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("lastCode") String lastCode, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】应收单分页过滤条件下的总条数(不翻页)。 + */ + long countReceivableBillPage(@Param("receivableBillCodeList") List receivableBillCodeList, + @Param("orderCodeList") List orderCodeList, + @Param("partnerCodeList") List partnerCodeList, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime); + + /** + * 【MCP 只读】应收余额聚合:动态 group_by(NONE / ORDER / PARTNER / TIME_MONTH / OVERDUE_BUCKET)。 + *

度量直接取冗余列(sum total_price_with_tax / received_amount / unreceived_amount / uninvoiced_amount),不重算。

+ * + * @param groupBy 分组维度 + * @param orderCodeList 合同编号收窄(可空) + * @param partnerCodeList 客户编码收窄(可空) + * @param startTime create_time 下界(含) + * @param endTime create_time 上界(不含) + * @param keyset 1=按 order_code 升序的 keyset 分页(LIST);其余=按度量降序(SUMMARY / top_n) + * @param lastGroupKey keyset 游标:上一页最后一行的 order_code(仅 keyset=1 生效) + * @param limit top_n(NONE 取 1) + * @return 分组行:[groupKey, groupName, receivableWithTax, receivedWithTax, unreceivedWithTax, uninvoicedWithTax, itemQty] + */ + List> selectReceivableBalanceSummary(@Param("groupBy") String groupBy, + @Param("orderCodeList") List orderCodeList, + @Param("partnerCodeList") List partnerCodeList, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("keyset") Integer keyset, + @Param("lastGroupKey") String lastGroupKey, + @Param("limit") int limit); + + /** + * 【MCP 只读】应收历史时点重算:Σ应收(create_time ≤ T) 与 Σ已收(receipt_time ≤ T)。 + * + * @param asOfDate 时点日期 T + * @return totalWithTax=Σ oms_receivable_bill.total_price_with_tax,settledAmount=Σ oms_receivable_receipt_detail.receipt_amount + */ + Map selectReceivableAsOf(@Param("asOfDate") Date asOfDate); + } \ No newline at end of file diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableInvoicePlanMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableInvoicePlanMapper.java index b91107b8..37a26a46 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableInvoicePlanMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableInvoicePlanMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.sip.mapper; import java.util.List; import com.ruoyi.sip.domain.OmsReceivableInvoicePlan; +import org.apache.ibatis.annotations.Param; /** * 应收单开票计划Mapper接口 @@ -69,4 +70,9 @@ public interface OmsReceivableInvoicePlanMapper */ public int deleteByReceivableBillId(Long receivableBillId); + /** + * 【MCP 只读】按应收单主键列表批量查询开票计划(消除 N+1)。 + */ + List listByReceivableBillIdList(@Param("receivableBillIdList") List receivableBillIdList); + } \ No newline at end of file diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableReceiptPlanMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableReceiptPlanMapper.java index 9c7d8bc7..42532029 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableReceiptPlanMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsReceivableReceiptPlanMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.sip.mapper; import java.util.List; import com.ruoyi.sip.domain.OmsReceivableReceiptPlan; +import org.apache.ibatis.annotations.Param; /** * 应收单收款计划Mapper接口 @@ -76,4 +77,9 @@ public interface OmsReceivableReceiptPlanMapper public int deleteByReceivableBillId(Long receivableBillId); OmsReceivableReceiptPlan firstUnPayPlan(Long id); + + /** + * 【MCP 只读】按应收单主键列表批量查询收款计划(消除 N+1)。 + */ + List listByReceivableBillIdList(@Param("receivableBillIdList") List receivableBillIdList); } \ No newline at end of file diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsStockInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsStockInfoMapper.java index 0d504492..37d218b2 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsStockInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsStockInfoMapper.java @@ -3,6 +3,7 @@ package com.ruoyi.sip.mapper; import com.ruoyi.sip.domain.OmsStockInfo; import java.util.List; +import java.util.Map; /** * @Author ch @@ -46,4 +47,16 @@ public interface OmsStockInfoMapper { */ int batchRemove(String[] ids); + // ======================= MCP 只读查询(新增,仅 SELECT) ======================= + + /** + * MCP warehouse_list(entity=STOCK):备货信息游标分页(排序键 order_code,id)。 + */ + List listStockPage(Map params); + + /** + * MCP warehouse_list(entity=STOCK):count。 + */ + long countStockPage(Map params); + } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsTicketBillMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsTicketBillMapper.java index 123f8e6b..23dafd62 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsTicketBillMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OmsTicketBillMapper.java @@ -1,5 +1,6 @@ package com.ruoyi.sip.mapper; +import java.util.Date; import java.util.List; import com.ruoyi.sip.domain.OmsTicketBill; import org.apache.ibatis.annotations.Param; @@ -72,4 +73,29 @@ public interface OmsTicketBillMapper void revokeTicket(OmsTicketBill updateBill); + /* ==================== MCP 财务只读查询(新增,勿改上面既有语句语义) ==================== */ + + /** + * 【MCP 只读】收票单游标分页查询(排序键 ticket_bill_code,id;时间字段 ticket_time 区间下推,禁止 date_format)。 + */ + List listTicketBillPage(@Param("ticketBillCodeList") List ticketBillCodeList, + @Param("vendorCodeList") List vendorCodeList, + @Param("status") String status, + @Param("approveStatus") String approveStatus, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime, + @Param("lastCode") String lastCode, + @Param("lastId") Long lastId, + @Param("limit") int limit); + + /** + * 【MCP 只读】收票单分页过滤条件下的总条数(不翻页)。 + */ + long countTicketBillPage(@Param("ticketBillCodeList") List ticketBillCodeList, + @Param("vendorCodeList") List vendorCodeList, + @Param("status") String status, + @Param("approveStatus") String approveStatus, + @Param("startTime") Date startTime, + @Param("endTime") Date endTime); + } \ No newline at end of file diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OrderDeliveryMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OrderDeliveryMapper.java index 3929b874..31169ddf 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OrderDeliveryMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OrderDeliveryMapper.java @@ -2,6 +2,7 @@ package com.ruoyi.sip.mapper; import java.util.Date; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.OrderDelivery; /** @@ -61,4 +62,17 @@ public interface OrderDeliveryMapper public int deleteOrderDeliveryByIds(String[] ids); int count(OrderDelivery orderDelivery); + + // ======================= MCP 只读查询(新增,仅 SELECT) ======================= + + /** + * MCP warehouse_list(entity=ORDER_DELIVERY):manage 域发货单游标分页(排序键 delivery_code,id)。 + * 强制 deleted_at is null,left join order_info(trim(order_code) 对齐)补合同/客户信息。 + */ + List> listOrderDeliveryPage(Map params); + + /** + * MCP warehouse_list(entity=ORDER_DELIVERY):count。 + */ + long countOrderDeliveryPage(Map params); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OrderInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OrderInfoMapper.java index cf6f8856..b638b847 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OrderInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/OrderInfoMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.sip.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.sip.domain.MaintenanceRecordsDto; @@ -8,6 +9,7 @@ import com.ruoyi.sip.domain.OrderInfo; import com.ruoyi.sip.domain.OrderList; import com.ruoyi.sip.dto.HomepageQueryDto; import com.ruoyi.sip.dto.StatisticsDetailDto; +import org.apache.ibatis.annotations.Param; /** * 合同档案Mapper接口 @@ -117,4 +119,25 @@ public interface OrderInfoMapper List homePageData(HomepageQueryDto dto); void updateTimeByDeliveryId(List deliveryId); + + /** + * MCP 只读(manage 域合同列表):order_code 批量(trim 对齐)/ 名称模糊 / 客户 / 代理商 / 合同类型 / + * 签订日期区间 + 游标分页,排序键 order_code,version_code,id;默认过滤 deleted_at is null。 + * + * @param query codeList / nameKeyword / customerCodeList / agentCodeList / orderType / status / + * startTime / endTime / lastCode / lastVersion / lastId / limit + */ + List listContractPage(Map query); + + /** + * MCP 只读(合同明细):order_id 批量 + 产品编码批量 + 游标分页,排序键 order_id,id;默认过滤 deleted_at is null。 + * + * @param query orderIdList / productCodeList / lastOrderId / lastId / limit + */ + List listContractProductPage(Map query); + + /** + * MCP 只读:按合同ID批量取合同清单明细(include_detail=true)。 + */ + List listOrderListByOrderIdList(@Param("list") List orderIdList); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/PartnerInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/PartnerInfoMapper.java index a3ff77a5..cfd5c8c9 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/PartnerInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/PartnerInfoMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.sip.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.PartnerInfo; /** @@ -64,4 +65,11 @@ public interface PartnerInfoMapper List listByOrderCode(List orderCodeList); int selectMaxByPrefix(String string); + + /** + * MCP 只读:主数据列表(批量编码 + 名称模糊 + 游标分页,排序键 partner_code,id)。 + * + * @param query codeList / nameKeyword / status / lastCode / lastId / limit + */ + List listPartnerMasterPage(Map query); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProductInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProductInfoMapper.java index b355cb12..9a414010 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProductInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProductInfoMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.sip.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.MaintenanceRecordsDto; import com.ruoyi.sip.domain.ProductInfo; @@ -81,4 +82,11 @@ public interface ProductInfoMapper List listInventory(ProductInfo info); ProductInfo selectProductInfoByBomCode(String productCode); + + /** + * MCP 只读:主数据列表(批量编码 + 名称模糊 + 产品类型 + 游标分页,排序键 product_code,id)。 + * + * @param query codeList / nameKeyword / type / status / lastCode / lastId / limit + */ + List listProductMasterPage(Map query); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectInfoMapper.java index 181ef263..e46e47a4 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectInfoMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.sip.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.common.core.domain.entity.SysUser; import com.ruoyi.sip.domain.ProjectInfo; @@ -77,4 +78,11 @@ public interface ProjectInfoMapper ProjectInfo selectProjectInfoByOrderCode(String orderCode); List selectProjectInfoByOrderCodeList(List orderCodeList); + + /** + * MCP 只读:项目列表(编码批量 + 名称模糊 + 客户/代表处 + 时间区间 + 游标分页,排序键 project_code,id)。 + * + * @param query codeList / nameKeyword / customerCodeList / agentCodeList / startTime / endTime / lastCode / lastId / limit + */ + List listProjectPage(Map query); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectOrderInfoRecallMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectOrderInfoRecallMapper.java index e8e43e3a..2348e05e 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectOrderInfoRecallMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectOrderInfoRecallMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.sip.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.ProjectOrderInfoRecall; import com.ruoyi.sip.dto.ApiDataQueryDto; import com.ruoyi.sip.vo.ProjectOrderInfoRecallVO; @@ -23,4 +24,15 @@ public interface ProjectOrderInfoRecallMapper List getOrderRecallInfo(ApiDataQueryDto dto); + // ======================= MCP 只读查询(新增,仅 SELECT) ======================= + + /** + * MCP warehouse_list(entity=RECALL):撤回历史游标分页(排序键 order_code,create_time desc,id)。 + */ + List listRecallPage(Map params); + + /** + * MCP warehouse_list(entity=RECALL):count。 + */ + long countRecallPage(Map params); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectPocInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectPocInfoMapper.java index 2c9b52dd..d477fbef 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectPocInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectPocInfoMapper.java @@ -1,8 +1,10 @@ package com.ruoyi.sip.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.ProjectPocInfo; import com.ruoyi.sip.domain.ProjectPocInfoDetail; +import org.apache.ibatis.annotations.Param; /** * poc测试信息Mapper接口 @@ -88,4 +90,16 @@ public interface ProjectPocInfoMapper List listByProjectId(List longs); void updateBatchProjectPocInfoDetail(List updateList); + + /** + * MCP 只读:POC 列表(项目ID批量 + 游标分页,排序键 project_id,id)。 + * + * @param query projectIdList / lastProjectId / lastId / limit + */ + List listProjectPocPage(Map query); + + /** + * MCP 只读:按 POC 主键批量取测试进展明细(include_detail=true)。 + */ + List listPocDetailByPocIdList(@Param("list") List pocIdList); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectProductInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectProductInfoMapper.java index 4d655b4a..1857e4de 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectProductInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/ProjectProductInfoMapper.java @@ -1,6 +1,7 @@ package com.ruoyi.sip.mapper; import java.util.List; +import java.util.Map; import com.ruoyi.sip.domain.ProjectProductInfo; import org.apache.ibatis.annotations.Param; @@ -72,4 +73,11 @@ public interface ProjectProductInfoMapper void restoreData(@Param("orderCode")String orderCode, @Param("versionCode")String versionCode,@Param("projectId") Long projectId); int countBakData(@Param("orderCode")String orderCode,@Param("versionCode") String versionCode); + + /** + * MCP 只读分页(新增,仅 SELECT):项目产品列表(项目ID批量 + 产品编码批量 + 游标分页,排序键 project_id,id)。 + * + * @param query projectIdList / productCodeList / lastProjectId / lastId / limit + */ + List listProjectProductPage(Map query); } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/QuotationMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/QuotationMapper.java index e8494a90..ec7078ed 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/QuotationMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/QuotationMapper.java @@ -3,6 +3,7 @@ package com.ruoyi.sip.mapper; import com.ruoyi.sip.domain.Quotation; import java.util.List; +import java.util.Map; /** * @Author makejava @@ -47,4 +48,11 @@ public interface QuotationMapper { */ int batchRemove(Integer[] ids); + /** + * MCP 只读:报价单列表(单号批量 + 名称模糊 + 代表处 + 时间区间 + 游标分页,排序键 quotation_code,id)。 + * + * @param query codeList / nameKeyword / agentCodeList / startTime / endTime / lastCode / lastId / limit + */ + List listQuotationPage(Map query); + } diff --git a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/VendorInfoMapper.java b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/VendorInfoMapper.java index 177be619..7aad5e45 100644 --- a/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/VendorInfoMapper.java +++ b/ruoyi-sip/src/main/java/com/ruoyi/sip/mapper/VendorInfoMapper.java @@ -3,6 +3,7 @@ package com.ruoyi.sip.mapper; import com.ruoyi.sip.domain.VendorInfo; import java.util.List; +import java.util.Map; /** @@ -62,4 +63,12 @@ public interface VendorInfoMapper public int deleteVendorInfoByVendorIds(String[] vendorIds); VendorInfo selectVendorInfoByVendorCode(String vendorCode); + + /** + * MCP 只读:主数据列表(批量编码 + 名称模糊 + 合作状态 + 游标分页,排序键 vendor_code,vendor_id)。 + * 只查 oms_vendor_info,不回退 vendor_info 冗余表。 + * + * @param query codeList / nameKeyword / status / lastCode / lastId / limit + */ + List listVendorMasterPage(Map query); } diff --git a/ruoyi-sip/src/main/resources/mapper/OmsReceiptBill/OmsReceiptBillMapper.xml b/ruoyi-sip/src/main/resources/mapper/OmsReceiptBill/OmsReceiptBillMapper.xml index 8b884875..13a43f79 100644 --- a/ruoyi-sip/src/main/resources/mapper/OmsReceiptBill/OmsReceiptBillMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/OmsReceiptBill/OmsReceiptBillMapper.xml @@ -657,6 +657,66 @@ delete from oms_receivable_receipt_detail where receipt_bill_code=#{code} + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/Quotation/QuotationMapper.xml b/ruoyi-sip/src/main/resources/mapper/Quotation/QuotationMapper.xml index 5899fcdb..faada234 100644 --- a/ruoyi-sip/src/main/resources/mapper/Quotation/QuotationMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/Quotation/QuotationMapper.xml @@ -278,6 +278,35 @@ + + + diff --git a/ruoyi-sip/src/main/resources/mapper/finance/OmsFinAttachmentMapper.xml b/ruoyi-sip/src/main/resources/mapper/finance/OmsFinAttachmentMapper.xml index d6f2c92d..a79a9d86 100644 --- a/ruoyi-sip/src/main/resources/mapper/finance/OmsFinAttachmentMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/finance/OmsFinAttachmentMapper.xml @@ -137,4 +137,50 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/finance/OmsFinanceChargeMapper.xml b/ruoyi-sip/src/main/resources/mapper/finance/OmsFinanceChargeMapper.xml index a0e0180f..ce14b154 100644 --- a/ruoyi-sip/src/main/resources/mapper/finance/OmsFinanceChargeMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/finance/OmsFinanceChargeMapper.xml @@ -443,4 +443,58 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{id} + + + + + + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/finance/OmsPayableBillMapper.xml b/ruoyi-sip/src/main/resources/mapper/finance/OmsPayableBillMapper.xml index ed2a4b76..baa990e9 100644 --- a/ruoyi-sip/src/main/resources/mapper/finance/OmsPayableBillMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/finance/OmsPayableBillMapper.xml @@ -362,5 +362,140 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" inner join oms_inventory_inner as t2 on t1.inner_code = t2.inner_code inner join oms_purchase_order as t3 on t2.purchase_no = t3.purchase_no + + + + + + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/finance/OmsPaymentBillMapper.xml b/ruoyi-sip/src/main/resources/mapper/finance/OmsPaymentBillMapper.xml index 67750043..320a9199 100644 --- a/ruoyi-sip/src/main/resources/mapper/finance/OmsPaymentBillMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/finance/OmsPaymentBillMapper.xml @@ -535,5 +535,67 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" group by original_bill_id + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/flow/TodoMapper.xml b/ruoyi-sip/src/main/resources/mapper/flow/TodoMapper.xml index 2e3df7d7..ee4a8d88 100644 --- a/ruoyi-sip/src/main/resources/mapper/flow/TodoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/flow/TodoMapper.xml @@ -335,4 +335,52 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{item} + + + + and approve_user = #{approveUser} + + and process_key in + #{item} + + + and business_key in + #{item} + + + + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/inventory/InventoryDeliveryMapper.xml b/ruoyi-sip/src/main/resources/mapper/inventory/InventoryDeliveryMapper.xml index 0e3303b9..f280f007 100644 --- a/ruoyi-sip/src/main/resources/mapper/inventory/InventoryDeliveryMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/inventory/InventoryDeliveryMapper.xml @@ -356,4 +356,77 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and t1.delivery_status = '2' + + + + + + + and t1.outer_code = #{outerCode} + and t3.order_code = #{orderCode} + + and t3.product_code in + #{item} + + and t1.delivery_status = #{deliveryStatus} + and t1.delivery_time >= #{startTime} + and t1.delivery_time < #{endTime} + + + + + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/inventory/InventoryInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/inventory/InventoryInfoMapper.xml index dfa985e1..8f9d5d66 100644 --- a/ruoyi-sip/src/main/resources/mapper/inventory/InventoryInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/inventory/InventoryInfoMapper.xml @@ -310,4 +310,183 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/inventory/InventoryOuterDetailMapper.xml b/ruoyi-sip/src/main/resources/mapper/inventory/InventoryOuterDetailMapper.xml index a9365aa4..01c41f7c 100644 --- a/ruoyi-sip/src/main/resources/mapper/inventory/InventoryOuterDetailMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/inventory/InventoryOuterDetailMapper.xml @@ -121,4 +121,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/inventory/InventoryOuterMapper.xml b/ruoyi-sip/src/main/resources/mapper/inventory/InventoryOuterMapper.xml index 5f10a029..68595b63 100644 --- a/ruoyi-sip/src/main/resources/mapper/inventory/InventoryOuterMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/inventory/InventoryOuterMapper.xml @@ -296,4 +296,60 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + and t1.outer_code in + #{item} + + + and t1.order_code in + #{item} + + + and t1.product_code in + #{item} + + + and t1.outer_status in + #{item} + + and t1.create_time >= #{startTime} + and t1.create_time < #{endTime} + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/inventory/OmsInventoryDeliveryDetailMapper.xml b/ruoyi-sip/src/main/resources/mapper/inventory/OmsInventoryDeliveryDetailMapper.xml index ba225e9b..fa4ec995 100644 --- a/ruoyi-sip/src/main/resources/mapper/inventory/OmsInventoryDeliveryDetailMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/inventory/OmsInventoryDeliveryDetailMapper.xml @@ -77,4 +77,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" delete from oms_inventory_delivery_detail where delivery_id =#{id} + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/inventory/OmsInventoryInnerMapper.xml b/ruoyi-sip/src/main/resources/mapper/inventory/OmsInventoryInnerMapper.xml index bb891d7c..a7d075ae 100644 --- a/ruoyi-sip/src/main/resources/mapper/inventory/OmsInventoryInnerMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/inventory/OmsInventoryInnerMapper.xml @@ -226,4 +226,50 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + and t1.inner_code in + #{item} + + and t1.purchase_no = #{purchaseNo} + and t1.order_code = #{orderCode} + and t1.create_time >= #{startTime} + and t1.create_time < #{endTime} + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/manage/CustomerInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/manage/CustomerInfoMapper.xml index 79966b89..99341fb5 100644 --- a/ruoyi-sip/src/main/resources/mapper/manage/CustomerInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/manage/CustomerInfoMapper.xml @@ -163,4 +163,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/manage/DeliveryListMapper.xml b/ruoyi-sip/src/main/resources/mapper/manage/DeliveryListMapper.xml index ecd1649f..d25c7f6c 100644 --- a/ruoyi-sip/src/main/resources/mapper/manage/DeliveryListMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/manage/DeliveryListMapper.xml @@ -163,4 +163,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/manage/OrderDeliveryMapper.xml b/ruoyi-sip/src/main/resources/mapper/manage/OrderDeliveryMapper.xml index 982e6560..621d79ce 100644 --- a/ruoyi-sip/src/main/resources/mapper/manage/OrderDeliveryMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/manage/OrderDeliveryMapper.xml @@ -45,12 +45,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" and t1.logistics_code = #{logisticsCode} and t1.delivery_status = #{deliveryStatus} and t2.order_name like concat('%', #{orderName},'%') - - and t1.id in - - #{id} - - + + and t1.id in + + #{id} + + order by t1.order_id,t1.delivery_date @@ -152,4 +152,70 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - + + + + + and t1.delivery_code in + #{item} + + + and trim(t2.order_code) in + #{item} + + and t1.delivery_status = #{deliveryStatus} + and t1.delivery_date >= #{startTime} + and t1.delivery_date < #{endTime} + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/manage/OrderInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/manage/OrderInfoMapper.xml index d4df3ae7..53b1b208 100644 --- a/ruoyi-sip/src/main/resources/mapper/manage/OrderInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/manage/OrderInfoMapper.xml @@ -169,12 +169,12 @@ select delivery_id from delivery_list where serial_number=#{serialNumber})) - - and t1.id in - - #{id} - - + + and t1.id in + + #{id} + + order by ISNULL(t1.project_code)||t1.project_code='' ,t1.project_code @@ -436,4 +436,85 @@ - + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/sip/CrossDomainAggregateMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/CrossDomainAggregateMapper.xml new file mode 100644 index 00000000..608a6346 --- /dev/null +++ b/ruoyi-sip/src/main/resources/mapper/sip/CrossDomainAggregateMapper.xml @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/sip/OmsCompanyInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/OmsCompanyInfoMapper.xml index 6474c4d4..c69370a5 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/OmsCompanyInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/OmsCompanyInfoMapper.xml @@ -112,4 +112,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/sip/OmsInvoiceBillMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/OmsInvoiceBillMapper.xml index 4c917438..a8548968 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/OmsInvoiceBillMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/OmsInvoiceBillMapper.xml @@ -663,4 +663,59 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" from oms_invoice_bill where invoice_bill_code LIKE CONCAT(#{codePrefix}, '%') + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/sip/OmsPayableTicketPlanMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/OmsPayableTicketPlanMapper.xml index 8978331b..73ab6e6b 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/OmsPayableTicketPlanMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/OmsPayableTicketPlanMapper.xml @@ -169,4 +169,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" delete from oms_payable_ticket_plan where payable_bill_id = #{payableBillId} + + + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderHistoryMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderHistoryMapper.xml index 1ff0e937..488c63bc 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderHistoryMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderHistoryMapper.xml @@ -159,4 +159,43 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where purchase_history_id = #{purchaseHistoryId} + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderMapMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderMapMapper.xml index 98fcc80d..cba71a6b 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderMapMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderMapMapper.xml @@ -38,4 +38,43 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where order_id = #{orderId} + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderMapper.xml index 2676ed19..2b20ade1 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/OmsPurchaseOrderMapper.xml @@ -542,4 +542,156 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{item.price}, #{item.taxRate}, #{item.taxTotal}, #{item.amountTotal}, #{item.deliveryDate}) + + + + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableBillMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableBillMapper.xml index 125c43f0..a5cbd9a8 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableBillMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableBillMapper.xml @@ -242,4 +242,165 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" WHERE id = #{item.id} + + + + + + + + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableInvoicePlanMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableInvoicePlanMapper.xml index 5be94e3f..ff0a0852 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableInvoicePlanMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableInvoicePlanMapper.xml @@ -103,4 +103,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" delete from oms_receivable_invoice_plan where receivable_bill_id = #{receivableBillId} + + + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableReceiptPlanMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableReceiptPlanMapper.xml index 6d83a967..a386bab6 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableReceiptPlanMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/OmsReceivableReceiptPlanMapper.xml @@ -115,4 +115,15 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" delete from oms_receivable_receipt_plan where receivable_bill_id = #{receivableBillId} + + + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/sip/OmsStockInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/OmsStockInfoMapper.xml index ec3c5481..305c9bf1 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/OmsStockInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/OmsStockInfoMapper.xml @@ -153,6 +153,51 @@ + + + + and t1.order_code = #{orderCode} + and t1.stock_status = #{stockStatus} + and t1.create_time >= #{startTime} + and t1.create_time < #{endTime} + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/sip/OmsTicketBillMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/OmsTicketBillMapper.xml index a4d1b5a8..12b857a6 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/OmsTicketBillMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/OmsTicketBillMapper.xml @@ -597,4 +597,61 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" where ticket_bill_code = #{ticketBillCode} + + + + + + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/sip/ProjectInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/ProjectInfoMapper.xml index fcdfd1a7..73db329a 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/ProjectInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/ProjectInfoMapper.xml @@ -499,4 +499,44 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + diff --git a/ruoyi-sip/src/main/resources/mapper/sip/ProjectOrderInfoRecallMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/ProjectOrderInfoRecallMapper.xml index 2a25ba1a..5a93daa6 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/ProjectOrderInfoRecallMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/ProjectOrderInfoRecallMapper.xml @@ -56,4 +56,37 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + and t1.order_code = #{orderCode} + and t1.create_time >= #{startTime} + and t1.create_time < #{endTime} + + + + + + + diff --git a/ruoyi-sip/src/main/resources/mapper/sip/ProjectPocInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/ProjectPocInfoMapper.xml index bc5afc9a..8f0ec8ba 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/ProjectPocInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/ProjectPocInfoMapper.xml @@ -189,4 +189,31 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/sip/ProjectProductInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/sip/ProjectProductInfoMapper.xml index 7ab77c44..a8bc6ddd 100644 --- a/ruoyi-sip/src/main/resources/mapper/sip/ProjectProductInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/sip/ProjectProductInfoMapper.xml @@ -200,4 +200,31 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/system/AgentInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/system/AgentInfoMapper.xml index 70cf0810..68776d00 100644 --- a/ruoyi-sip/src/main/resources/mapper/system/AgentInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/system/AgentInfoMapper.xml @@ -123,4 +123,26 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/system/PartnerInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/system/PartnerInfoMapper.xml index 325f9e80..e873c0bc 100644 --- a/ruoyi-sip/src/main/resources/mapper/system/PartnerInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/system/PartnerInfoMapper.xml @@ -167,4 +167,30 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + diff --git a/ruoyi-sip/src/main/resources/mapper/system/ProductInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/system/ProductInfoMapper.xml index d54c42d5..a6f43218 100644 --- a/ruoyi-sip/src/main/resources/mapper/system/ProductInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/system/ProductInfoMapper.xml @@ -21,7 +21,7 @@ - + @@ -296,4 +296,30 @@ limit 1 + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mapper/system/VendorInfoMapper.xml b/ruoyi-sip/src/main/resources/mapper/system/VendorInfoMapper.xml index b1e17186..88f5d7a5 100644 --- a/ruoyi-sip/src/main/resources/mapper/system/VendorInfoMapper.xml +++ b/ruoyi-sip/src/main/resources/mapper/system/VendorInfoMapper.xml @@ -166,4 +166,30 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + \ No newline at end of file diff --git a/ruoyi-sip/src/main/resources/mcp-tool-router.yml b/ruoyi-sip/src/main/resources/mcp-tool-router.yml new file mode 100644 index 00000000..d7e92e02 --- /dev/null +++ b/ruoyi-sip/src/main/resources/mcp-tool-router.yml @@ -0,0 +1,140 @@ +# ===== MCP 工具路由(RAG)配置 ===== +# 中文按字符 2-gram 切分,配合本文件的别名与同义词做加权召回。 +# 新增工具时:在 tool-aliases 与 synonyms 各补一行即可,无需改 Java 代码。 +router: + enabled: true + top-k: 5 # tools/list 带 query 时最多返回的工具数 + min-score: 0.15 # 低于该分数不硬选工具(tools/call 未指定 name 时返回 INVALID_PARAMS 并给出候选) + + stopwords: [的, 了, 和, 与, 及, 或者, 是, 在, 有, 我, 你, 他, 请, 帮, 帮我, 查, 查询, 看, + 一下, 看一下, 多少, 几个, 哪些, 什么, 怎么, 如何, 以及, 这个, 那个, 一下, 麻烦, + the, a, an, of, for, to, and, is, are] + + # 工具别名:直接拼进该工具的检索文本(权重 3.0,最可信) + tool-aliases: + project_order_info: [订单, 合同, 合同编号, 项目订单, 订单台账, 下单, 订单状态, 归档] + product_info: [产品, 物料, 型号, 产品编码, 目录价, 指导折扣] + inventory_sn_trace: [序列号, SN, 条码, 机身码, 单件, 追溯, 在库, 已出库, 入库价, 出库价] + inventory_flow: [货流, 流转, 单据链, 这条货走到哪, 入出库关联, 物流轨迹, 备货状态, 货从哪来, 从哪个入库单, 入库来源, 来源单据] + finance_order_position: [一单到底, 全链路, 收付票, 这单钱到哪一步, 核销情况, 计收, 毛利, 收了多少款, 开了多少票, 计收了没, 单据全景] + inventory_stock_aggregate: [库存, 存货, 在库, 结存, 库存量, 库存汇总, 库存排行, 占用] + purchase_arrival_aggregate: [采购汇总, 到货率, 到货及时率, 未入库, 在途采购, 采购金额] + finance_balance_aggregate: [欠款, 未收, 未付, 余额, 账龄, 超期, 应收未收, 应付未付, 未开票, 未收票] + warehouse_list: [入库单, 出库单, 发货单, 物流, 签收, 撤单, 撤回记录, 备货] + purchase_list: [采购单, 采购订单, 采购明细, 供应商, 制造商, 采购变更, 采购历史] + finance_list: [应收单, 应付单, 收款单, 付款单, 发票, 收票, 核销单, 计收单, 单据明细, 附件] + master_data_list: [主数据, 编码转名称, 名称对照, 客户, 进货商, 代理商, 代表处, 办事处, 系统用户, 仓库主数据] + project_list: [项目, 立项, 项目进度, POC, 试点, 会审, 报价, 报价单, 项目清单, 把握度, 合同明细] + cross_domain_aggregate: [交叉分析, 透视, 组合分析, 客户产品, 按客户按产品, 按代表处, 多维, 销售额, 销量, 营业额, 交叉表, 多维统计] + approval_list: [待办, 已办, 审批, 审批意见, 驳回, 待我审批, 审批到哪了] + + # 同义词:口语 -> 工具(可带 #entity 作为参数建议),权重 2.5 + synonyms: + # ===== 仓储 ===== + 库存: [inventory_stock_aggregate, inventory_sn_trace] + 存货: [inventory_stock_aggregate] + 在库: [inventory_stock_aggregate, inventory_sn_trace] + 结存: [inventory_stock_aggregate] + 占用量: [inventory_stock_aggregate] + 条码: [inventory_sn_trace] + 序列号: [inventory_sn_trace] + SN: [inventory_sn_trace] + 扫码: [inventory_sn_trace] + 入库单: [warehouse_list#INNER, inventory_flow] + 出库单: [warehouse_list#OUTER, inventory_flow] + 发货单: [warehouse_list#DELIVERY, inventory_flow] + 签收: [warehouse_list#ORDER_DELIVERY] + 没签收: [warehouse_list#ORDER_DELIVERY] + 未签收: [warehouse_list#ORDER_DELIVERY] + 已签收: [warehouse_list#ORDER_DELIVERY] + 收货: [warehouse_list#ORDER_DELIVERY] + 物流: [warehouse_list#ORDER_DELIVERY, warehouse_list#DELIVERY] + 快递单号: [warehouse_list#ORDER_DELIVERY] + 撤回: [warehouse_list#RECALL, warehouse_list#DELIVERY] + 撤单: [warehouse_list#RECALL] + 备货: [warehouse_list#STOCK, inventory_flow] + 货流: [inventory_flow] + 流转: [inventory_flow, warehouse_list] + # ===== 采购 ===== + 采购单: [purchase_list#ORDER, purchase_arrival_aggregate] + 采购订单: [purchase_list#ORDER] + 采购明细: [purchase_list#ITEM] + 采购变更: [purchase_list#HISTORY] + 采购历史: [purchase_list#HISTORY] + 到货率: [purchase_arrival_aggregate] + 到货及时: [purchase_arrival_aggregate] + 未入库: [purchase_arrival_aggregate, purchase_list#ORDER] + 在途采购: [purchase_arrival_aggregate] + 供应商: [master_data_list#VENDOR, purchase_list#ORDER] + 厂商: [master_data_list#VENDOR] + 供货商: [master_data_list#VENDOR] + 制造商: [master_data_list#VENDOR, purchase_list#ORDER] + 账期: [master_data_list#VENDOR] + # ===== 财务 ===== + 应收: [finance_list#RECEIVABLE, finance_balance_aggregate] + 应付: [finance_list#PAYABLE, finance_balance_aggregate] + 收款: [finance_list#RECEIPT] + 回款: [finance_list#RECEIPT, finance_balance_aggregate] + 付款: [finance_list#PAYMENT] + 开票: [finance_list#INVOICE] + 发票: [finance_list#INVOICE] + 收票: [finance_list#TICKET] + 核销: [finance_list#RECEIPT, finance_order_position] + 计收: [finance_list#CHARGE, finance_order_position] + 毛利: [finance_order_position, finance_list#CHARGE] + 欠款: [finance_balance_aggregate] + 欠钱: [finance_balance_aggregate] + 还欠: [finance_balance_aggregate] + 未结: [finance_balance_aggregate] + 尾款: [finance_balance_aggregate] + 挂账: [finance_balance_aggregate] + 未收: [finance_balance_aggregate] + 未付: [finance_balance_aggregate] + 账龄: [finance_balance_aggregate] + 超期: [finance_balance_aggregate] + 未开票: [finance_balance_aggregate] + 未收票: [finance_balance_aggregate] + 凭据: [finance_list#ATTACHMENT] + 附件: [finance_list#ATTACHMENT] + # ===== 项目 / 报价 / 合同 ===== + 项目: [project_list#PROJECT] + 立项: [project_list#PROJECT] + 项目进度: [project_list#PROGRESS] + 把握度: [project_list#PROJECT] + 试点: [project_list#POC] + POC: [project_list#POC] + 会审: [project_list#PROJECT, project_list#POC] + 报价: [project_list#QUOTATION] + 报价单: [project_list#QUOTATION] + 合同清单: [project_list#CONTRACT] + 合同产品: [project_list#CONTRACT_PRODUCT] + # ===== 主数据 ===== + 客户: [master_data_list#CUSTOMER, master_data_list#PARTNER] + 进货商: [master_data_list#PARTNER] + 代理商: [master_data_list#PARTNER] + 代表处: [master_data_list#AGENT] + 办事处: [master_data_list#AGENT] + 产品: [master_data_list#PRODUCT, product_info] + 型号: [master_data_list#PRODUCT, product_info] + 编码转名称: [master_data_list] + 名称对照: [master_data_list] + 系统用户: [master_data_list#USER] + 仓库主数据: [master_data_list#WAREHOUSE] + 公司主体: [master_data_list#COMPANY] + # ===== 审批 ===== + 待办: [approval_list#TODO] + 待我审批: [approval_list#TODO] + 已办: [approval_list#DONE] + 审批意见: [approval_list#DONE] + 驳回: [approval_list#DONE] + 审批到哪了: [approval_list#TODO] + # ===== 跨域 / 组合分析 ===== + 透视: [cross_domain_aggregate] + 组合分析: [cross_domain_aggregate] + 按客户按产品: [cross_domain_aggregate] + 交叉分析: [cross_domain_aggregate] + 多维: [cross_domain_aggregate] + # ===== 订单(现有工具) ===== + 订单: [project_order_info] + 合同: [project_order_info] + 合同编号: [project_order_info]