diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index b947345..66fb68f 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -84,6 +84,7 @@ xiaoqu-agent-service/ | `integration.context.get` | integration | read | 身份与能力自检(REQ-20260911-0012)。无入参;返回 tenant_id(作用域编码映射)/principal_type/subject_id/scopes(可调用工具集合,与强制校验同源实时计算)/data_scope_version(token_scope 绑定最大修改时间 epoch 毫秒)/capability_levels(QUERY/SYNC,按 *.changes.list 数据驱动)/server_time | | `warehouse.in.list.query` | warehouse | read | 入库单分页查询;入库/申请日期独立过滤,主单代理、涉及仓库、流程参与人三类权限取并集 | | `warehouse.in.detail.query` | warehouse | read | 入库单详情;支持调拨单,物料汇总、可选 SN、流程、附件和关联出库索引 | +| `warehouse.todo.list.query` | warehouse | read | 当前用户仓库待办统一查询(REQ-20260915-0008),合并老系统出入库/调拨三个待办接口;流程视图 user_id 过滤,不按代理商过滤;调拨派生出入库单带 derivedFrom 标记 | 分页统一规范:page 从 1 开始,rows 默认 20、上限 100,超出截断为 100 并在返回 `notice` 字段提示。 diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 5da27c0..01930b9 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -547,6 +547,42 @@ --- +## 19. warehouse.todo.list.query + +统一查询当前登录人的仓库待办,参考老系统 `/myWarehouseOut/needToBeDealtWith`、 +`/myWarehouseIn/needToBeDealtWith`、`/warehouseAllocation/warehouseAllocationProcessListByPage` 三个待办接口。 + +- 分类:warehouse | 类型:读 +- 数据来源:m2 流程视图 `warehouse_out_process` / `warehouse_in_process` / `warehouse_allocation_process` +- 权限:待办是"人"的语义,只按流程表 `user_id = 当前登录人` 过滤,不叠加代理商行级权限。 +- 待办判定:`user_id = 当前登录人 AND soft_delete = 0 AND operation_type IN (0,1,2)` + (3=纯日志排除)。`todoOnly` 默认 true 再限定 `schedule_state=1` 真待办。 +- 同一笔调拨最多以调拨审批/确认、出库执行、入库执行 3 个视角出现,不去重; + 派生自调拨的真实出入库单带 `derivedFrom="allocation"`,可按 `allocationOrderNo` 归并。 +- 分页实现:先全量查符合条件流程行按单据 id 内存去重 → 内存分页 → 按页 id 批量查视图行组装,防 N+1。 + +**入参**: + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| docType | string | 否 | out=出库单 in=入库单 allocation=调拨/采购/划转单 all=全部,默认 all | +| todoOnly | boolean | 否 | true=只返回真待办(默认);false=当前用户参与过的全部单据 | +| orderState | number | 否 | 出库/入库单 0待审核 1被驳回 2待出库或待入库 3已完成 4已撤销 5作废;调拨类 0待审核 1被驳回 2待出库 3待入库 4已完成 5已撤销 6作废 | +| orderNo | string | 否 | 单据主单号模糊匹配 | +| page / rows | number | 否 | 页码默认 1;每页默认 20、上限 100,超出截断并在 `notice` 提示 | + +**出参 records 字段**:`docType/docTypeText`(出库单/入库单/调拨单/采购单/划转单, +按调拨视图 `op_type` 区分)、`docId/docNo`、`orderState/orderStateText`、 +`scheduleState/scheduleStateText`、`operationType/operationTypeText`(当前待办环节)、 +仓库名(allocation 为转出/转入仓库)、`agentId/agentName`、申请人/申请时间、 +`allocationOrderNo` 等关联单号、派生单 `derivedFrom="allocation"`; +调拨类另含 `opType/opTypeText/allocationCount/nextUserName/inUserName/outUserName`。 +按申请时间倒序排列。 + +详细设计与老系统差异见 `warehouse-todo-mcp-tool-design.md`。 + +--- + ## 附录:在线查看工具清单 ```bash diff --git a/xiaoqu-agent-service/doc/warehouse-todo-mcp-tool-design.md b/xiaoqu-agent-service/doc/warehouse-todo-mcp-tool-design.md new file mode 100644 index 0000000..b6c0ce6 --- /dev/null +++ b/xiaoqu-agent-service/doc/warehouse-todo-mcp-tool-design.md @@ -0,0 +1,103 @@ +# 仓库待办 MCP 工具设计(REQ-20260915-0008) + +## 1. 范围与评审结论 + +老系统有 4 个"待办"接口,本需求统一为一个工具: + +| 老系统接口 | 数据来源 | +|---|---| +| `POST /myWarehouseOut/needToBeDealtWith` | m2 视图 `warehouse_out_process` | +| `POST /myWarehouseIn/needToBeDealtWith` | m2 视图 `warehouse_in_process` | +| `POST /warehouseAllocation/warehouseAllocationProcessListByPage`(调拨/划转/采购共用) | m2 视图 `warehouse_allocation_process` | + +新增工具:`warehouse.todo.list.query`(warehouse/read),统一查询当前登录人的仓库待办。 + +## 2. 待办语义(以老系统为准) + +流程视图一行 = 单据 × 处理人 × 待办环节。待办判定: + +``` +user_id = 当前登录人 AND soft_delete = 0 AND operation_type IN (0, 1, 2) +``` + +- `operation_type=3` 为"驳回或修改记录",纯日志不需要待办,排除(出入库视图); + 调拨视图 `operation_type=4`(撤销)同为纯日志,也被 IN (0,1,2) 排除。 +- 不按代理商过滤:待办是"人"的语义,不是代理商数据权限语义。 + 老系统出入库待办同样只按 `user_id` 过滤(`input.setUserId(user.getId())`), + 只有调拨待办显式 `eq("user_id", user.getId())` 且代理行级过滤被注释掉, + 与本实现一致。 + +`operation_type` 枚举(老系统视图字段注释,xiaoqu-publicbeanandmap beans/b2): + +| 值 | 出库/入库视图 | 调拨视图 | +|---|---|---| +| 0 | 审批流操作 | 审批流操作 | +| 1 | 确认出库 / 确认入库 | 确认入库操作 | +| 2 | 本人修改(需申请人待办) | 确认出库操作 | +| 3 | 驳回或修改操作(纯日志,排除) | 本人修改 | +| 4 | — | 撤销(纯日志,排除) | + +`schedule_state`:0=无需待办,1=待办,2=已办。 + +`order_state`:出库/入库单 0=待审核 1=被驳回 2=待出库/待入库 3=已完成 4=已撤销 5=作废; +调拨类 0=待审核 1=被驳回 2=待出库 3=待入库 4=已完成 5=已撤销 6=作废。调拨视图 `op_type`: +0=调拨(调拨单),1=采购(采购单),2=划转(划转单)。 + +## 3. 单据类型与调拨三视角 + +- 出库单(24 开头单号)、入库单(23 开头)是独立表。 +- 调拨/采购/划转是同一张 `warehouse_allocation` 表,`op_type` 区分。 +- 调拨/划转审批通过后由 `WarehouseServiceImpl.procAfterAllocation` 生成真实入库单 + + 出库单并回填 `allocation_order_no`,同一笔调拨最多以 3 个视角出现在待办里 + (调拨审批/确认、出库执行、入库执行)。这是正常业务,**不去重**; + 派生出入库单带 `derivedFrom="allocation"` 标记,客户端可按 `allocationOrderNo` 归并。 + +## 4. 工具契约 + +入参全部可选: + +| 参数 | 说明 | +|---|---| +| docType | out / in / allocation / all,默认 all;allocation 涵盖调拨/采购/划转 | +| todoOnly | 默认 true 只返回 `schedule_state=1` 真待办;false 返回当前用户参与过的全部单据 | +| orderState | 按单据状态过滤(枚举值见上,写入 description) | +| orderNo | 单据主单号模糊匹配(出库/入库/调拨单号) | +| page / rows | 默认 1 / 20,rows >100 截断为 100 并在 notice 提示 | + +出参:`total/page/rows/records`。每条 record 统一结构:`docType/docTypeText` +(出库单/入库单/调拨单/采购单/划转单)、`docId/docNo`、`orderState/orderStateText`、 +`scheduleState/scheduleStateText`、`operationType/operationTypeText`(当前待办环节)、 +仓库名(allocation 为 `warehouseOutName/warehouseInName`)、`agentId/agentName`、 +申请人/申请时间、`allocationOrderNo` 等关联单号(out/in 另含 `mallOrderNo`, +in 另含关联 `outOrderNo`)、派生单 `derivedFrom="allocation"`; +allocation 记录另含 `opType/opTypeText/allocationCount/allocationDate/nextUserName/inUserName/outUserName`。 + +排序:申请时间倒序(null 在后),同时间按单据 id 倒序。 + +## 5. 实现要点 + +- **两阶段分页**:阶段一按过滤条件全量查流程行(只 select 单据 id + 申请时间), + 内存按单据 id 去重;内存分页;阶段二按页内 id 批量查视图整行组装。 + 避开老系统 `GROUP BY` 下 `selectCount` 不可靠的坑,天然防 N+1。 + (注意:阶段一只取 id 列时若同一单据多行需内存去重,本实现用 `putIfAbsent`。) +- **多环节取行**:一个单据对同一用户可能有多行(已办 + 待办),`todoOnly=false` 时 + 优先取 `schedule_state=1` 的行作为"当前待办环节";`todoOnly=true` 时全部行均为待办,取首行。 +- 权限:只用 `AuthContextHolder` 当前用户 id 做 `user_id` 过滤, + 不叠加 `PermissionService` 行级过滤。 + +## 6. 与老系统的已知差异 + +- 老系统出入库待办接口按 `GROUP BY (单据id, schedule_state, operation_type)` 分页, + 同一单据可能按环节拆成多行返回;本实现按单据聚合为一条记录, + `operationType/scheduleState` 取当前待办环节。更符合"待办列表"语义。 +- 老系统调拨待办不过滤 `operation_type`(会带入撤销日志行);本实现按统一语义 + IN (0,1,2) 排除,同时把调拨视图被驳回后"本人修改"(type=3)一并排除 + (与出入库视图 type=3 语义对齐的取舍)。如需申请人修改待办可再评估。 +- 老系统出入库待办前端可传 `schedule_state` 筛选"已办";本实现用 `todoOnly=false` 表达。 + +## 7. 验收 + +- 三类型合并排序分页、rows 截断 notice、派生单 derivedFrom 标记; +- todoOnly=true/false 两态、orderState/orderNo/docType 过滤、非法 docType 拒绝; +- Spring AI schema 发布(@Tool + @XqTool 自动注册,ToolSyncService 同步 tool 表); +- 固定 `TZ=Asia/Shanghai` 执行全量后端测试,不连接生产数据库、不执行部署。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseAllocationProcess.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseAllocationProcess.java new file mode 100644 index 0000000..80153c7 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseAllocationProcess.java @@ -0,0 +1,71 @@ +package me.iiv.agent.entity.m2; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Date; + +/** 调拨/采购/划转流程视图 warehouse_allocation_process(m2,只读):一行 = 单据 × 处理人 × 待办环节。 */ +@Getter +@Setter +@ToString +@TableName("warehouse_allocation_process") +public class WarehouseAllocationProcess implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Integer softDelete; + private Long warehouseAllocationId; + private String allocationOrderNo; + private Integer allocationCount; + private Date applyDate; + private String applyUserName; + private Long applyUserId; + /** 视图列名即 currend_audit_level(老系统拼写保留) */ + @TableField("currend_audit_level") + private Integer currendAuditLevel; + private Integer goodsCount; + private LocalDate allocationDate; + private String userName; + private Long userId; + /** 操作主题(0:审核通过;1:审核驳回;2:入库确认驳回;3:转出确认;4:出库确认驳回;5:驳回后修改提交;6:入库确认;7:出库确认;8:撤销) */ + private Integer operationTheme; + /** 待办状态(0:无需待办;1:待办;2:已办) */ + private Integer scheduleState; + /** 审核状态(0:审核驳回/驳回;1:审核通过/出库确认) */ + private Integer auditState; + /** 流程行审核级别 */ + @TableField("process_audit_level") + private Integer processAuditLevel; + /** 操作类型(0:审批流操作;1:确认入库操作;2:确认出库操作;3:本人修改;4:撤销,纯日志) */ + private Integer operationType; + private String warehouseOutName; + private String warehouseInName; + private Long warehouseInId; + private Long warehouseOutId; + /** 单据审核级别 */ + private Integer auditLevel; + /** 调拨单状态(0:待审核;1:被驳回;2:待出库;3:待入库;4:已完成;5:已撤销;6:作废) */ + private Integer orderState; + private Long agentId; + private String agentName; + private Date createTime; + /** 办理类型(0:调拨;1:采购;2:划转) */ + private Integer opType; + private String memo; + private String inUserName; + private Date inTime; + private String nextUserName; + private String outUserName; + private Date outTime; + private String pickupOrderNo; + private String installOrderNo; + private String deliveryOrderNo; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInProcess.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInProcess.java new file mode 100644 index 0000000..d4714ab --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInProcess.java @@ -0,0 +1,65 @@ +package me.iiv.agent.entity.m2; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Date; + +/** 入库单流程视图 warehouse_in_process(m2,只读):一行 = 单据 × 处理人 × 待办环节。 */ +@Getter +@Setter +@ToString +@TableName("warehouse_in_process") +public class WarehouseInProcess implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Integer softDelete; + private Long warehouseInId; + private Long warehouseId; + private String warehouseName; + private Long userId; + private String userName; + private Date operationTime; + /** 待办状态(0:无需待办;1:待办;2:已办) */ + private Integer scheduleState; + /** 审核状态(0:审核驳回;1:审核通过),仅与流程审批有关 */ + private Integer auditState; + /** 操作主题(0:审核通过;1:审核驳回;2:撤销入库;3:入库确认;4:驳回修改提交;5:撤销;6:入库驳回) */ + private Integer operationTheme; + /** 操作类型(0:审批流操作;1:确认入库操作;2:本人修改;3:驳回或修改操作,纯日志) */ + private Integer operationType; + private String inOrderNo; + private String allocationOrderNo; + private String outOrderNo; + private String mallOrderNo; + private Long agentId; + private String agentName; + private Long warehouseTypeId; + private String warehouseTypeName; + private LocalDate inDate; + private Integer goodsCount; + private Integer inCount; + /** 入库单状态(0:待审核;1:被驳回;2:待入库;3:已完成;4:已撤销;5:作废) */ + private Integer orderState; + private Long applyUserId; + private String applyUserName; + private Date applyDate; + private Integer auditLevel; + /** 视图列名即 currend_audit_level(老系统拼写保留) */ + @TableField("currend_audit_level") + private Integer currendAuditLevel; + private String memo; + private Date createTime; + private String pickupOrderNo; + private String installOrderNo; + private String deliveryOrderNo; + private String withdrawOrderNo; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutProcess.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutProcess.java new file mode 100644 index 0000000..c8b554b --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutProcess.java @@ -0,0 +1,63 @@ +package me.iiv.agent.entity.m2; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.Date; + +/** 出库单流程视图 warehouse_out_process(m2,只读):一行 = 单据 × 处理人 × 待办环节。 */ +@Getter +@Setter +@ToString +@TableName("warehouse_out_process") +public class WarehouseOutProcess implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Integer softDelete; + private Long warehouseOutId; + private Long warehouseId; + private String warehouseName; + private Long userId; + private String userName; + private Date operationTime; + /** 待办状态(0:无需待办;1:待办;2:已办) */ + private Integer scheduleState; + /** 审核状态(0:审核驳回;1:审核通过),仅与流程审批有关 */ + private Integer auditState; + /** 操作主题(0:审核通过;1:审核驳回;2:撤销入库;3:出库确认;4:驳回修改提交;5:撤销) */ + private Integer operationTheme; + /** 操作类型(0:审批流操作;1:确认出库操作;2:本人修改;3:驳回或修改操作,纯日志) */ + private Integer operationType; + private String outOrderNo; + private String allocationOrderNo; + private String mallOrderNo; + private Long agentId; + private String agentName; + private Long warehouseTypeId; + private String warehouseTypeName; + private LocalDate outDate; + private Integer goodsCount; + private Integer outCount; + /** 出库单状态(0:待审核;1:被驳回;2:待出库;3:已完成;4:已撤销;5:作废) */ + private Integer orderState; + private Long applyUserId; + private String applyUserName; + private Date applyDate; + private Integer auditLevel; + /** 视图列名即 currend_audit_level(老系统拼写保留) */ + @TableField("currend_audit_level") + private Integer currendAuditLevel; + private String memo; + private Date createTime; + private String pickupOrderNo; + private String installOrderNo; + private String deliveryOrderNo; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseAllocationProcessMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseAllocationProcessMapper.java new file mode 100644 index 0000000..eed6827 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseAllocationProcessMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.WarehouseAllocationProcess; + +public interface WarehouseAllocationProcessMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInProcessMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInProcessMapper.java new file mode 100644 index 0000000..cde6385 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseInProcessMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.WarehouseInProcess; + +public interface WarehouseInProcessMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutProcessMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutProcessMapper.java new file mode 100644 index 0000000..78a25f5 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseOutProcessMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.WarehouseOutProcess; + +public interface WarehouseOutProcessMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseTodoTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseTodoTools.java new file mode 100644 index 0000000..41bf66d --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseTodoTools.java @@ -0,0 +1,381 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.WarehouseAllocationProcess; +import me.iiv.agent.entity.m2.WarehouseInProcess; +import me.iiv.agent.entity.m2.WarehouseOutProcess; +import me.iiv.agent.mapper.m2.WarehouseAllocationProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseInProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseOutProcessMapper; +import me.iiv.agent.mcp.ToolObject; +import me.iiv.agent.mcp.XqTool; +import me.iiv.agent.security.AuthContextHolder; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.annotation.ToolParam; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 仓库待办查询工具,参考老系统 /myWarehouseOut/needToBeDealtWith、/myWarehouseIn/needToBeDealtWith、 + * /warehouseAllocation/warehouseAllocationProcessListByPage,统一查询三张流程视图。 + */ +@Component +public class WarehouseTodoTools implements ToolObject { + + private static final int DEFAULT_ROWS = 20; + private static final int MAX_ROWS = 100; + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private static final DateTimeFormatter TIME_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(BUSINESS_ZONE); + + /** 待办环节只保留可办理的操作类型,3(驳回或修改记录)为纯日志,排除。 */ + private static final List TODO_OPERATION_TYPES = List.of(0, 1, 2); + + private static final Map SCHEDULE_STATE_TEXT = Map.of( + 0, "无需待办", 1, "待办", 2, "已办"); + private static final Map OUT_ORDER_STATE_TEXT = Map.of( + 0, "待审核", 1, "被驳回", 2, "待出库", 3, "已完成", 4, "已撤销", 5, "作废"); + private static final Map IN_ORDER_STATE_TEXT = Map.of( + 0, "待审核", 1, "被驳回", 2, "待入库", 3, "已完成", 4, "已撤销", 5, "作废"); + private static final Map ALLOCATION_ORDER_STATE_TEXT = Map.of( + 0, "待审核", 1, "被驳回", 2, "待出库", 3, "待入库", 4, "已完成", 5, "已撤销", 6, "作废"); + private static final Map OUT_OPERATION_TYPE_TEXT = Map.of( + 0, "审批流操作", 1, "确认出库", 2, "本人修改"); + private static final Map IN_OPERATION_TYPE_TEXT = Map.of( + 0, "审批流操作", 1, "确认入库", 2, "本人修改"); + private static final Map ALLOCATION_OPERATION_TYPE_TEXT = Map.of( + 0, "审批流操作", 1, "确认入库", 2, "确认出库", 3, "本人修改", 4, "撤销"); + private static final Map OP_TYPE_TEXT = Map.of( + 0, "调拨", 1, "采购", 2, "划转"); + + final WarehouseOutProcessMapper outProcessMapper; + final WarehouseInProcessMapper inProcessMapper; + final WarehouseAllocationProcessMapper allocationProcessMapper; + + public WarehouseTodoTools(WarehouseOutProcessMapper outProcessMapper, + WarehouseInProcessMapper inProcessMapper, + WarehouseAllocationProcessMapper allocationProcessMapper) { + this.outProcessMapper = outProcessMapper; + this.inProcessMapper = inProcessMapper; + this.allocationProcessMapper = allocationProcessMapper; + } + + @XqTool(category = "warehouse", kind = "read") + @Tool(name = "warehouse.todo.list.query", description = "分页查询当前用户的仓库待办,统一老系统 /myWarehouseOut/needToBeDealtWith、" + + "/myWarehouseIn/needToBeDealtWith、/warehouseAllocation/warehouseAllocationProcessListByPage 三个待办接口。" + + "数据来自 m2 流程视图 warehouse_out_process / warehouse_in_process / warehouse_allocation_process:" + + "待办语义 = 视图里存在当前用户的任务行(user_id = 当前登录人 AND soft_delete = 0 AND operation_type IN (0,1,2)," + + "3=纯日志排除),不按代理商过滤。同一笔调拨最多以调拨审批/确认、出库执行、入库执行 3 个视角出现,不去重;" + + "派生自调拨的真实出入库单带 derivedFrom=\"allocation\" 标记。" + + "docType:out=出库单 in=入库单 allocation=调拨/采购/划转单(按 op_type 区分) all=全部,默认 all。" + + "todoOnly 默认 true(只返回 schedule_state=1 真待办),false 返回当前用户参与过的全部单据。" + + "orderState 按单据状态过滤:出库/入库单 0待审核 1被驳回 2待出库/待入库 3已完成 4已撤销 5作废;" + + "调拨类 0待审核 1被驳回 2待出库 3待入库 4已完成 5已撤销 6作废。" + + "分页 page 默认1,rows 默认20、上限100,超出截断为 100 并在 notice 提示。") + public Map warehouseTodoListQuery( + @ToolParam(required = false, description = "单据类型:out/in/allocation/all,默认 all。allocation 涵盖调拨/采购/划转") String docType, + @ToolParam(required = false, description = "true=只返回 schedule_state=1 真待办(默认);false=当前用户参与过的全部单据") Boolean todoOnly, + @ToolParam(required = false, description = "按单据状态过滤:出库/入库单 0待审核 1被驳回 2待出库或待入库 3已完成 4已撤销 5作废;调拨类 0待审核 1被驳回 2待出库 3待入库 4已完成 5已撤销 6作废") Integer orderState, + @ToolParam(required = false, description = "单号模糊匹配(出库/入库/调拨单号)") String orderNo, + @ToolParam(required = false, description = "页码,从1开始,默认1") Integer page, + @ToolParam(required = false, description = "每页条数,默认20,上限100") Integer rows) { + String type = StringUtils.hasText(docType) ? docType.trim().toLowerCase() : "all"; + if (!type.equals("out") && !type.equals("in") && !type.equals("allocation") && !type.equals("all")) { + throw new IllegalArgumentException("docType 必须是 out / in / allocation / all"); + } + boolean onlyTodo = todoOnly == null || todoOnly; + int pageNo = page == null || page < 1 ? 1 : page; + boolean truncated = rows != null && rows > MAX_ROWS; + int pageSize = rows == null || rows < 1 ? DEFAULT_ROWS : Math.min(rows, MAX_ROWS); + + User user = AuthContextHolder.get().getUser(); + Long userId = user.getId(); + + List stubs = new ArrayList<>(); + if (type.equals("out") || type.equals("all")) { + stubs.addAll(outStubs(userId, onlyTodo, orderState, orderNo)); + } + if (type.equals("in") || type.equals("all")) { + stubs.addAll(inStubs(userId, onlyTodo, orderState, orderNo)); + } + if (type.equals("allocation") || type.equals("all")) { + stubs.addAll(allocationStubs(userId, onlyTodo, orderState, orderNo)); + } + stubs.sort(Comparator.comparing(TodoStub::applyDate, + Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(TodoStub::docId, Comparator.reverseOrder())); + + int total = stubs.size(); + int from = Math.min((pageNo - 1) * pageSize, total); + int to = Math.min(from + pageSize, total); + List pageStubs = stubs.subList(from, to); + + List> records = assemble(pageStubs, userId, onlyTodo); + Map result = new LinkedHashMap<>(); + result.put("total", total); + result.put("page", pageNo); + result.put("rows", pageSize); + if (truncated) { + result.put("notice", "rows 超过上限 100,已截断为 100"); + } + result.put("records", records); + return result; + } + + /** 第一阶段:全量查符合条件(含过滤)的流程行,内存按单据 id 去重。 */ + private List outStubs(Long userId, boolean onlyTodo, Integer orderState, String orderNo) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .select(WarehouseOutProcess::getWarehouseOutId, WarehouseOutProcess::getApplyDate) + .eq(WarehouseOutProcess::getSoftDelete, 0) + .eq(WarehouseOutProcess::getUserId, userId) + .in(WarehouseOutProcess::getOperationType, TODO_OPERATION_TYPES) + .eq(onlyTodo, WarehouseOutProcess::getScheduleState, 1) + .eq(orderState != null, WarehouseOutProcess::getOrderState, orderState) + .like(StringUtils.hasText(orderNo), WarehouseOutProcess::getOutOrderNo, + StringUtils.hasText(orderNo) ? orderNo.trim() : orderNo); + Map byDocId = new LinkedHashMap<>(); + for (WarehouseOutProcess row : outProcessMapper.selectList(wrapper)) { + byDocId.putIfAbsent(row.getWarehouseOutId(), row.getApplyDate()); + } + return byDocId.entrySet().stream() + .map(entry -> new TodoStub("out", entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + } + + private List inStubs(Long userId, boolean onlyTodo, Integer orderState, String orderNo) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .select(WarehouseInProcess::getWarehouseInId, WarehouseInProcess::getApplyDate) + .eq(WarehouseInProcess::getSoftDelete, 0) + .eq(WarehouseInProcess::getUserId, userId) + .in(WarehouseInProcess::getOperationType, TODO_OPERATION_TYPES) + .eq(onlyTodo, WarehouseInProcess::getScheduleState, 1) + .eq(orderState != null, WarehouseInProcess::getOrderState, orderState) + .like(StringUtils.hasText(orderNo), WarehouseInProcess::getInOrderNo, + StringUtils.hasText(orderNo) ? orderNo.trim() : orderNo); + Map byDocId = new LinkedHashMap<>(); + for (WarehouseInProcess row : inProcessMapper.selectList(wrapper)) { + byDocId.putIfAbsent(row.getWarehouseInId(), row.getApplyDate()); + } + return byDocId.entrySet().stream() + .map(entry -> new TodoStub("in", entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + } + + private List allocationStubs(Long userId, boolean onlyTodo, Integer orderState, String orderNo) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .select(WarehouseAllocationProcess::getWarehouseAllocationId, WarehouseAllocationProcess::getApplyDate) + .eq(WarehouseAllocationProcess::getSoftDelete, 0) + .eq(WarehouseAllocationProcess::getUserId, userId) + .in(WarehouseAllocationProcess::getOperationType, TODO_OPERATION_TYPES) + .eq(onlyTodo, WarehouseAllocationProcess::getScheduleState, 1) + .eq(orderState != null, WarehouseAllocationProcess::getOrderState, orderState) + .like(StringUtils.hasText(orderNo), WarehouseAllocationProcess::getAllocationOrderNo, + StringUtils.hasText(orderNo) ? orderNo.trim() : orderNo); + Map byDocId = new LinkedHashMap<>(); + for (WarehouseAllocationProcess row : allocationProcessMapper.selectList(wrapper)) { + byDocId.putIfAbsent(row.getWarehouseAllocationId(), row.getApplyDate()); + } + return byDocId.entrySet().stream() + .map(entry -> new TodoStub("allocation", entry.getKey(), entry.getValue())) + .collect(Collectors.toList()); + } + + /** 第二阶段:按页内单据 id 批量查视图行组装记录,避免 N+1;输出顺序与分页排序一致。 */ + private List> assemble(List stubs, Long userId, boolean onlyTodo) { + Map> byType = stubs.stream() + .collect(Collectors.groupingBy(TodoStub::docType, LinkedHashMap::new, Collectors.toList())); + + Map> outRowsByDoc = Map.of(); + List outStubs = byType.getOrDefault("out", List.of()); + if (!outStubs.isEmpty()) { + List ids = outStubs.stream().map(TodoStub::docId).collect(Collectors.toList()); + outRowsByDoc = outProcessMapper.selectList( + new LambdaQueryWrapper() + .eq(WarehouseOutProcess::getSoftDelete, 0) + .eq(WarehouseOutProcess::getUserId, userId) + .in(WarehouseOutProcess::getOperationType, TODO_OPERATION_TYPES) + .in(WarehouseOutProcess::getWarehouseOutId, ids) + .orderByAsc(WarehouseOutProcess::getId)).stream() + .collect(Collectors.groupingBy(WarehouseOutProcess::getWarehouseOutId, + LinkedHashMap::new, Collectors.toList())); + } + + Map> inRowsByDoc = Map.of(); + List inStubs = byType.getOrDefault("in", List.of()); + if (!inStubs.isEmpty()) { + List ids = inStubs.stream().map(TodoStub::docId).collect(Collectors.toList()); + inRowsByDoc = inProcessMapper.selectList( + new LambdaQueryWrapper() + .eq(WarehouseInProcess::getSoftDelete, 0) + .eq(WarehouseInProcess::getUserId, userId) + .in(WarehouseInProcess::getOperationType, TODO_OPERATION_TYPES) + .in(WarehouseInProcess::getWarehouseInId, ids) + .orderByAsc(WarehouseInProcess::getId)).stream() + .collect(Collectors.groupingBy(WarehouseInProcess::getWarehouseInId, + LinkedHashMap::new, Collectors.toList())); + } + + Map> allocationRowsByDoc = Map.of(); + List allocationStubs = byType.getOrDefault("allocation", List.of()); + if (!allocationStubs.isEmpty()) { + List ids = allocationStubs.stream().map(TodoStub::docId).collect(Collectors.toList()); + allocationRowsByDoc = allocationProcessMapper.selectList( + new LambdaQueryWrapper() + .eq(WarehouseAllocationProcess::getSoftDelete, 0) + .eq(WarehouseAllocationProcess::getUserId, userId) + .in(WarehouseAllocationProcess::getOperationType, TODO_OPERATION_TYPES) + .in(WarehouseAllocationProcess::getWarehouseAllocationId, ids) + .orderByAsc(WarehouseAllocationProcess::getId)).stream() + .collect(Collectors.groupingBy(WarehouseAllocationProcess::getWarehouseAllocationId, + LinkedHashMap::new, Collectors.toList())); + } + + List> records = new ArrayList<>(); + for (TodoStub stub : stubs) { + switch (stub.docType()) { + case "out" -> addIfPresent(records, outRowsByDoc.get(stub.docId()), + rows -> outRecord(pickPending(rows, onlyTodo))); + case "in" -> addIfPresent(records, inRowsByDoc.get(stub.docId()), + rows -> inRecord(pickPending(rows, onlyTodo))); + default -> addIfPresent(records, allocationRowsByDoc.get(stub.docId()), + rows -> allocationRecord(pickPending(rows, onlyTodo))); + } + } + return records; + } + + private static void addIfPresent(List> records, List rows, + java.util.function.Function, Map> mapper) { + if (rows != null && !rows.isEmpty()) { + records.add(mapper.apply(rows)); + } + } + + /** 取代表当前待办环节的行:优先 schedule_state=1 的真待办行,否则取首行(已办/无需待办)。 */ + private static T pickPending(List rows, boolean onlyTodo) { + if (onlyTodo) { + return rows.get(0); + } + return rows.stream() + .filter(row -> currentScheduleState(row) == 1) + .findFirst() + .orElse(rows.get(0)); + } + + private static int currentScheduleState(Object row) { + if (row instanceof WarehouseOutProcess out) { + return out.getScheduleState() == null ? -1 : out.getScheduleState(); + } + if (row instanceof WarehouseInProcess in) { + return in.getScheduleState() == null ? -1 : in.getScheduleState(); + } + WarehouseAllocationProcess allocation = (WarehouseAllocationProcess) row; + return allocation.getScheduleState() == null ? -1 : allocation.getScheduleState(); + } + + private Map outRecord(WarehouseOutProcess row) { + Map record = baseRecord("out", "出库单", row.getWarehouseOutId(), + row.getOutOrderNo(), row.getOrderState(), text(OUT_ORDER_STATE_TEXT, row.getOrderState()), + row.getScheduleState(), row.getOperationType(), + text(OUT_OPERATION_TYPE_TEXT, row.getOperationType()), + row.getWarehouseName(), row.getAgentId(), row.getAgentName(), + row.getApplyUserId(), row.getApplyUserName(), row.getApplyDate()); + record.put("allocationOrderNo", row.getAllocationOrderNo()); + record.put("mallOrderNo", row.getMallOrderNo()); + if (StringUtils.hasText(row.getAllocationOrderNo())) { + record.put("derivedFrom", "allocation"); + } + return record; + } + + private Map inRecord(WarehouseInProcess row) { + Map record = baseRecord("in", "入库单", row.getWarehouseInId(), + row.getInOrderNo(), row.getOrderState(), text(IN_ORDER_STATE_TEXT, row.getOrderState()), + row.getScheduleState(), row.getOperationType(), + text(IN_OPERATION_TYPE_TEXT, row.getOperationType()), + row.getWarehouseName(), row.getAgentId(), row.getAgentName(), + row.getApplyUserId(), row.getApplyUserName(), row.getApplyDate()); + record.put("allocationOrderNo", row.getAllocationOrderNo()); + record.put("outOrderNo", row.getOutOrderNo()); + record.put("mallOrderNo", row.getMallOrderNo()); + if (StringUtils.hasText(row.getAllocationOrderNo())) { + record.put("derivedFrom", "allocation"); + } + return record; + } + + private Map allocationRecord(WarehouseAllocationProcess row) { + Map record = baseRecord("allocation", + text(OP_TYPE_TEXT, row.getOpType()) + "单", row.getWarehouseAllocationId(), + row.getAllocationOrderNo(), row.getOrderState(), + text(ALLOCATION_ORDER_STATE_TEXT, row.getOrderState()), + row.getScheduleState(), row.getOperationType(), + text(ALLOCATION_OPERATION_TYPE_TEXT, row.getOperationType()), + null, row.getAgentId(), row.getAgentName(), + row.getApplyUserId(), row.getApplyUserName(), row.getApplyDate()); + record.put("opType", row.getOpType()); + record.put("opTypeText", text(OP_TYPE_TEXT, row.getOpType())); + record.put("warehouseOutName", row.getWarehouseOutName()); + record.put("warehouseInName", row.getWarehouseInName()); + record.put("allocationCount", row.getAllocationCount()); + record.put("allocationDate", row.getAllocationDate() == null ? null : row.getAllocationDate().toString()); + record.put("nextUserName", row.getNextUserName()); + record.put("inUserName", row.getInUserName()); + record.put("inTime", formatTime(row.getInTime())); + record.put("outUserName", row.getOutUserName()); + record.put("outTime", formatTime(row.getOutTime())); + return record; + } + + private Map baseRecord(String docType, String docTypeText, Long docId, String docNo, + Integer orderState, String orderStateText, + Integer scheduleState, Integer operationType, String operationTypeText, + String warehouseName, Long agentId, String agentName, + Long applyUserId, String applyUserName, Date applyDate) { + Map record = new LinkedHashMap<>(); + record.put("docType", docType); + record.put("docTypeText", docTypeText); + record.put("docId", docId); + record.put("docNo", docNo); + record.put("orderState", orderState); + record.put("orderStateText", orderStateText); + record.put("scheduleState", scheduleState); + record.put("scheduleStateText", text(SCHEDULE_STATE_TEXT, scheduleState)); + record.put("operationType", operationType); + record.put("operationTypeText", operationTypeText); + record.put("warehouseName", warehouseName); + record.put("agentId", agentId); + record.put("agentName", agentName); + record.put("applyUserId", applyUserId); + record.put("applyUserName", applyUserName); + record.put("applyDate", formatTime(applyDate)); + return record; + } + + private static String text(Map texts, Integer code) { + return code == null ? null : texts.getOrDefault(code, "未知"); + } + + private static String formatTime(Date date) { + return date == null ? null : TIME_FORMATTER.format(date.toInstant()); + } + + private record TodoStub(String docType, Long docId, Date applyDate) { + private TodoStub { + Objects.requireNonNull(docType); + Objects.requireNonNull(docId); + } + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseTodoToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseTodoToolsTest.java new file mode 100644 index 0000000..3efe0de --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseTodoToolsTest.java @@ -0,0 +1,231 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.WarehouseAllocationProcess; +import me.iiv.agent.entity.m2.WarehouseInProcess; +import me.iiv.agent.entity.m2.WarehouseOutProcess; +import me.iiv.agent.mapper.m2.WarehouseAllocationProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseInProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseOutProcessMapper; +import me.iiv.agent.security.AuthContext; +import me.iiv.agent.security.AuthContextHolder; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseTodoToolsTest { + + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + + @Mock WarehouseOutProcessMapper outProcessMapper; + @Mock WarehouseInProcessMapper inProcessMapper; + @Mock WarehouseAllocationProcessMapper allocationProcessMapper; + + WarehouseTodoTools tools; + + @BeforeEach + void setUp() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, WarehouseOutProcess.class); + TableInfoHelper.initTableInfo(assistant, WarehouseInProcess.class); + TableInfoHelper.initTableInfo(assistant, WarehouseAllocationProcess.class); + tools = new WarehouseTodoTools(outProcessMapper, inProcessMapper, allocationProcessMapper); + User user = new User(); + user.setId(7L); + AuthContextHolder.set(new AuthContext(null, user)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + void listMergesAllDocTypesSortsByApplyDateAndMarksDerivedDocs() { + // 第一阶段(去重 id + applyDate)与第二阶段(整行组装)共用 mock,按调用顺序返回 + WarehouseOutProcess outStub1 = new WarehouseOutProcess(); + outStub1.setWarehouseOutId(20L); + outStub1.setApplyDate(date(2026, 9, 10)); + WarehouseOutProcess outStub2 = new WarehouseOutProcess(); + outStub2.setWarehouseOutId(21L); + outStub2.setApplyDate(date(2026, 9, 14)); + WarehouseInProcess inStub = new WarehouseInProcess(); + inStub.setWarehouseInId(30L); + inStub.setApplyDate(date(2026, 9, 15)); + WarehouseAllocationProcess allocationStub = new WarehouseAllocationProcess(); + allocationStub.setWarehouseAllocationId(40L); + allocationStub.setApplyDate(date(2026, 9, 12)); + when(outProcessMapper.selectList(any())).thenReturn(List.of(outStub1, outStub2), List.of(outRow(20L, null), outRow(21L, "DB-001"))); + when(inProcessMapper.selectList(any())).thenReturn(List.of(inStub), List.of(inRow(30L))); + when(allocationProcessMapper.selectList(any())).thenReturn(List.of(allocationStub), List.of(allocationRow(40L))); + + Map result = tools.warehouseTodoListQuery("all", null, null, null, 1, 500); + + assertEquals(4, result.get("total")); + assertEquals(1, result.get("page")); + assertEquals(100, result.get("rows")); + assertEquals("rows 超过上限 100,已截断为 100", result.get("notice")); + @SuppressWarnings("unchecked") + List> records = (List>) result.get("records"); + assertEquals(4, records.size()); + // 申请时间倒序:in(9/15) → out(9/14) → allocation(9/12) → out(9/10) + assertEquals("in", records.get(0).get("docType")); + assertEquals(30L, records.get(0).get("docId")); + assertEquals("待入库", records.get(0).get("orderStateText")); + assertEquals("待办", records.get(0).get("scheduleStateText")); + assertEquals("确认入库", records.get(0).get("operationTypeText")); + assertNull(records.get(0).get("derivedFrom")); + + assertEquals("out", records.get(1).get("docType")); + assertEquals(21L, records.get(1).get("docId")); + assertEquals("DB-001", records.get(1).get("allocationOrderNo")); + assertEquals("allocation", records.get(1).get("derivedFrom")); + + assertEquals("allocation", records.get(2).get("docType")); + assertEquals("采购单", records.get(2).get("docTypeText")); + assertEquals(1, records.get(2).get("opType")); + assertEquals("待审核", records.get(2).get("orderStateText")); + + assertEquals("out", records.get(3).get("docType")); + assertEquals(20L, records.get(3).get("docId")); + assertNull(records.get(3).get("derivedFrom")); + verify(outProcessMapper, times(2)).selectList(any()); + verify(inProcessMapper, times(2)).selectList(any()); + verify(allocationProcessMapper, times(2)).selectList(any()); + } + + @Test + void todoOnlyFalsePrefersPendingRowAmongParticipatedDocs() { + WarehouseOutProcess stub = new WarehouseOutProcess(); + stub.setWarehouseOutId(20L); + stub.setApplyDate(date(2026, 9, 10)); + WarehouseOutProcess doneRow = outRow(20L, null); + doneRow.setScheduleState(2); + doneRow.setOperationType(0); + WarehouseOutProcess pendingRow = outRow(20L, null); + pendingRow.setScheduleState(1); + pendingRow.setOperationType(1); + when(outProcessMapper.selectList(any())).thenReturn(List.of(stub), List.of(doneRow, pendingRow)); + + Map result = tools.warehouseTodoListQuery("out", false, null, null, 1, 20); + + assertEquals(1, result.get("total")); + @SuppressWarnings("unchecked") + List> records = (List>) result.get("records"); + assertEquals(1, records.size()); + assertEquals(1, records.get(0).get("scheduleState")); + assertEquals("待办", records.get(0).get("scheduleStateText")); + assertEquals(1, records.get(0).get("operationType")); + assertEquals("确认出库", records.get(0).get("operationTypeText")); + } + + @Test + void listRejectsInvalidDocType() { + assertThrows(IllegalArgumentException.class, + () -> tools.warehouseTodoListQuery("transfer", null, null, null, 1, 20)); + verify(outProcessMapper, times(0)).selectList(any()); + verify(inProcessMapper, times(0)).selectList(any()); + verify(allocationProcessMapper, times(0)).selectList(any()); + } + + @Test + void springAiPublishesTodoListToolSchema() { + var callbacks = org.springframework.ai.tool.method.MethodToolCallbackProvider.builder() + .toolObjects(tools).build().getToolCallbacks(); + Map schemas = Arrays.stream(callbacks).collect(java.util.stream.Collectors.toMap( + callback -> callback.getToolDefinition().name(), + callback -> callback.getToolDefinition().inputSchema())); + + assertEquals(1, schemas.size()); + assertTrue(schemas.get("warehouse.todo.list.query").contains("docType")); + assertTrue(schemas.get("warehouse.todo.list.query").contains("todoOnly")); + assertTrue(schemas.get("warehouse.todo.list.query").contains("orderNo")); + } + + private static WarehouseOutProcess outRow(Long docId, String allocationOrderNo) { + WarehouseOutProcess row = new WarehouseOutProcess(); + row.setId(docId * 10 + 1); + row.setSoftDelete(0); + row.setWarehouseOutId(docId); + row.setOutOrderNo("CK-" + docId); + row.setAllocationOrderNo(allocationOrderNo); + row.setUserId(7L); + row.setScheduleState(1); + row.setOperationType(1); + row.setOrderState(2); + row.setWarehouseName("一号仓"); + row.setAgentId(5L); + row.setAgentName("代理商甲"); + row.setApplyUserId(7L); + row.setApplyUserName("申请人"); + row.setApplyDate(date(2026, 9, 10)); + return row; + } + + private static WarehouseInProcess inRow(Long docId) { + WarehouseInProcess row = new WarehouseInProcess(); + row.setId(docId * 10 + 1); + row.setSoftDelete(0); + row.setWarehouseInId(docId); + row.setInOrderNo("RK-" + docId); + row.setUserId(7L); + row.setScheduleState(1); + row.setOperationType(1); + row.setOrderState(2); + row.setWarehouseName("二号仓"); + row.setAgentId(5L); + row.setAgentName("代理商甲"); + row.setApplyUserId(7L); + row.setApplyUserName("申请人"); + row.setApplyDate(date(2026, 9, 15)); + return row; + } + + private static WarehouseAllocationProcess allocationRow(Long docId) { + WarehouseAllocationProcess row = new WarehouseAllocationProcess(); + row.setId(docId * 10 + 1); + row.setSoftDelete(0); + row.setWarehouseAllocationId(docId); + row.setAllocationOrderNo("DB-" + docId); + row.setUserId(7L); + row.setScheduleState(1); + row.setOperationType(0); + row.setOrderState(0); + row.setOpType(1); + row.setAgentId(5L); + row.setAgentName("代理商甲"); + row.setApplyUserId(7L); + row.setApplyUserName("申请人"); + row.setApplyDate(date(2026, 9, 12)); + row.setWarehouseOutName("一号仓"); + row.setWarehouseInName("二号仓"); + return row; + } + + private static Date date(int year, int month, int day) { + return Date.from(LocalDate.of(year, month, day).atStartOfDay(BUSINESS_ZONE).toInstant()); + } +}