diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 862242b..67d239b 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -103,6 +103,7 @@ xiaoqu-agent-service/ | `battery.inspection.list.query` | battery | read | 电池检查记录分页查询(m2 battery_inspection,对齐老 /batteryInspection/pageList)。入参 snCode(模糊)/agentId/propertyId/districtId/checkUserId/checkUserName(模糊)/checkType(0小保养 1大保养)/checkRecord14/productionDateStart/End/checkTimeStart/End;硬过滤 soft_delete=0,固定 id DESC;行权限 resolveRowScope 含直属下级落 apply_agent_id。详细设计见 doc/battery-inspection-repair-mcp-design.md | | `repair.recording.list.query` | repair | read | 故障汇总分页查询(m0 equipment_repair_recording,对齐老 /equipmentRepair/recordingByPage)。入参 id/snCode/problem(模糊)/agentId(收窄语义,仍叠加行权限)/propertyId/districtId/state/userId/sourceType/deviceType/createTimeStart/End;硬过滤 soft_delete=0+effective_state=1,固定 id DESC;富化按页批量(修 N+1)。详细设计见 doc/battery-inspection-repair-mcp-design.md | | `repair.handle.list.query` | repair | read | 现场维修记录分页查询(m0 equipment_repair_handle,对齐老 /equipmentRepair/repairRecordHandleList)。入参 id/equipmentRepairId/snCode/agentId/propertyId/districtId/state/userId/assembly/assemblyFault/chipCode(模糊)/accessoryId;硬过滤 soft_delete=0+effective_state=1,固定 id DESC;组件/零部件名按页查 fault_info 字典,chipCode 落 chip_code(修老接口拼不存在列的 bug)。详细设计见 doc/battery-inspection-repair-mcp-design.md | +| `warehouse.order.handler.update` | admin | write | 仓库单据当前待办处理人改派(平台首个写工具,研发/运维专用,REQ-20261107-0002)。入参 orderType(out/in/allocation/transfer)/orderNo/newHandlerMobile/memo;UPDATE 流程物理表 schedule_state=1 待办行的 user_id/user_name(并行多行全改派),不动主表不发消息;无行级权限,只绑"研发专用" scope,服务身份 Token 拒绝。详细设计见 doc/warehouse-order-handler-update-mcp-design.md | 分页统一规范: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 80fbf99..5a64417 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -1201,6 +1201,30 @@ --- +## 52. warehouse.order.handler.update + +仓库单据当前待办处理人改派(**写工具,平台首个 kind=write**,REQ-20261107-0002),研发/运维专用。 + +- 分类:admin | 类型:写(readOnlyHint=false / destructiveHint=true) +- 入参:`orderType`(out 出库 / in 入库 / allocation 调拨 / transfer 划转,必填)、 + `orderNo`(单号精确匹配,必填)、`newHandlerMobile`(新处理人手机号,m0 `user.mobile` + 精确匹配且 `soft_delete=0`,必填)、`memo`(改派原因,可选)。 +- 机制:UPDATE 流程物理表 `warehouse_out_audit_process` / `warehouse_in_audit_process` / + `warehouse_allocati_process`(注意拼写少一个 on)中 `schedule_state=1 AND soft_delete=0` + 的待办行 `user_id`/`user_name`,并补 `modify_id`(调用方)/`modify_time`; + 并行多条待办行全部改派。不动主表、不重推审批流、不发消息通知。 +- 校验:调拨要求 `op_type=0`、划转要求 `op_type=2`,不一致 INVALID_ARGUMENT; + 无待办行(已完成/已撤销/作废)NOT_FOUND_OR_FORBIDDEN 并附 orderStateText; + 手机号无有效用户 INVALID_ARGUMENT;服务身份 Token PERMISSION_DENIED(改派人须可追溯)。 +- 权限:**无行级权限**,任何单据都可改,访问控制完全靠 scope 绑定——只绑"研发专用" scope, + 不绑 1/2/3/5。审计由 GuardedToolCallback 自动落 svc `audit_log`,工具内另打 before/after 日志。 +- 返回:`orderType/orderNo/orderId/orderState/orderStateText/changedRows` + (processId + 新旧 userId/userName)`/changedCount/requestId`。 + +详细设计见 [warehouse-order-handler-update-mcp-design](warehouse-order-handler-update-mcp-design.md)。 + +--- + ## 附录:在线查看工具清单 ```bash diff --git a/xiaoqu-agent-service/doc/warehouse-order-handler-update-mcp-design.md b/xiaoqu-agent-service/doc/warehouse-order-handler-update-mcp-design.md new file mode 100644 index 0000000..89f52ca --- /dev/null +++ b/xiaoqu-agent-service/doc/warehouse-order-handler-update-mcp-design.md @@ -0,0 +1,152 @@ +# 仓库单据处理人改派 MCP 工具设计(warehouse.order.handler.update) + +- 需求:REQ-20261107-0002(ai-proj id 6442) +- 状态:已评审通过(2026-11-07 王恒拍板:入参=单号+手机号,手机号查 m0 user 表带 soft_delete=0 条件,其余按建议) +- 日期:2026-11-07 + +## 1. 背景 + +运维/研发场景需要把"卡"在某个处理人名下的仓库单据待办改派给他人(人员离职、调岗、账号停用等)。 +**老系统(xiaoqu-franchisee)没有"修改处理人"接口**(已全仓检索确认),目前只能靠人工改库。 +本需求提供 MCP 写工具完成改派,访问控制完全靠专用作用域(scope)门禁,不做行级权限。 + +这是 agent-service 的**首个 kind=write 工具**(ToolKindRegistry 已预留 write 注解: +readOnlyHint=false / idempotentHint=false / destructiveHint=true)。 + +## 2. 调研结论(行号级依据) + +### 2.1 四类单据的表结构 + +| 单据 | 主表(m2) | 单号列 | 流程物理表 | 流程视图 | +|---|---|---|---|---| +| 出库单 | `warehouse_out` | `out_order_no` | `warehouse_out_audit_process` | `warehouse_out_process` | +| 入库单 | `warehouse_in` | `in_order_no` | `warehouse_in_audit_process` | `warehouse_in_process` | +| 调拨单 | `warehouse_allocation`(op_type=0) | `allocation_order_no` | `warehouse_allocati_process` | `warehouse_allocation_process` | +| 划转单 | `warehouse_allocation`(op_type=2) | `allocation_order_no` | 同上 | 同上 | + +注意: +- 调拨/划转是**同一张表**,靠 `op_type` 区分(0调拨/1采购/2划转, + 见 beans/b2/WarehouseAllocation.java:86)。 +- 流程物理表名是 `warehouse_allocati_process`(**少一个 on**,历史拼写,勿写错), + 视图 `warehouse_allocation_process` 才是全名。 +- 视图 `*_process` 只读展示用;写必须落物理表 `*_audit_process` / `warehouse_allocati_process`。 + +### 2.2 "当前处理人"的存储语义 + +- 流程表中 `schedule_state=1`(待办)且 `soft_delete=0` 的行,其 `user_id`/`user_name` + 即为当前处理人(beans/b2/WarehouseOutProcess.java:54、WarehouseAllocationProcess.java:80; + agent-service 的 WarehouseTodoTools 也是按 `user_id = 当前登录人 AND schedule_state=1` 认定待办归属)。 +- 老系统审批/确认动作均校验操作人 == 待办行 user_id + (如 WarehouseAllocationController.java:403-409 先按 schedule_state=1 取当前用户待办行再办理)。 +- 因此**改派 = UPDATE 待办行的 user_id / user_name**(并按仓库规范补 modify_id/modify_time)。 +- 一张单同一时刻可能有多条并行待办行(多人会审),也可能为 0 条(已完成/已撤销/作废/无需待办)。 + +### 2.3 agent-service 现状 + +- 本地已有实体/Mapper:`WarehouseOut` / `WarehouseIn` / `WarehouseAllocation` / + `WarehouseOutAuditProcess` / `WarehouseInAuditProcess`(含 Mapper)。 +- **缺**:`warehouse_allocati_process` 的本地实体与 Mapper(需新增, + 参照 beans/b2/WarehouseAllocatiProcess.java 全列复制——PR #30/#31 教训: + publicbeanandmap 不是 Maven 依赖,必须本地拷贝且列要全)。 +- 新处理人校验用 m0 `user` 表(本地实体 `me.iiv.agent.entity.m0.User` 已存在)。 +- 审计写 svc 库 `audit_log`(实体 `me.iiv.agent.entity.svc.AuditLog` 已存在, + 字段含 userId/tokenId/toolName/paramsDigest/requestId 等)。 + +## 3. 工具设计 + +### 3.1 工具定义(1 个工具) + +``` +warehouse.order.handler.update kind=write category=admin +``` + +参数: + +| 参数 | 必填 | 说明 | +|---|---|---| +| orderType | 是 | 枚举:`out` 出库单 / `in` 入库单 / `allocation` 调拨单 / `transfer` 划转单 | +| orderNo | 是 | 单据编号(精确匹配,去空格) | +| newHandlerMobile | 是 | 新处理人手机号(m0 user.mobile 精确匹配,拍板定案:只认手机号) | +| memo | 否 | 改派原因,写入审计日志 | + +行为: +1. 按 orderType 路由到对应主表,按单号精确查询(soft_delete=0),不存在 → `NOT_FOUND`。 +2. 一致性校验:`allocation` 要求 op_type=0,`transfer` 要求 op_type=2,不符 → `INVALID_ARGUMENT` + (防止把调拨单号当划转单改)。 +3. 查该单流程物理表中 `schedule_state=1 AND soft_delete=0` 的待办行;0 条 → + `NOT_FOUND`「该单据当前无待办处理人(已完成/已撤销/作废)」,并返回 orderStateText。 +4. 新处理人校验:m0 user 存在且 soft_delete=0,否则 `INVALID_ARGUMENT`。 +5. UPDATE 所有待办行:`user_id`、`user_name`(取 user 表真实姓名)、 + `modify_id`=调用方 userId、`modify_time`=now。 +6. 写 svc `audit_log`:调用方 userId/tokenId、工具名、参数摘要 + (orderType/orderNo/新旧处理人/memo)、request_id。 +7. 返回 before/after。 + +返回结构: + +```json +{ + "orderType": "out", + "orderNo": "OUT20261001001", + "orderId": 12345, + "orderState": 2, + "orderStateText": "待出库", + "changedRows": [ + {"processId": 9001, "oldUserId": 101, "oldUserName": "张三", + "newUserId": 205, "newUserName": "李四"} + ], + "changedCount": 1, + "requestId": "..." +} +``` + +报错沿用 ToolCallException 透出风格(INVALID_ARGUMENT / NOT_FOUND_OR_FORBIDDEN / DATA_INTEGRITY_ERROR)。 + +审计说明:GuardedToolCallback 已对每次调用自动写 svc audit_log(操作人 userId/tokenId、 +参数摘要、结果状态),工具内再以 request_id 打一条 before/after 服务端日志,两者合一可追溯。 + +### 3.2 权限与作用域(核心拍板点已按用户口径定) + +- **不做任何行级权限**,不调用 PermissionService 行过滤;任何单据都可改。 +- 访问控制 = scope 绑定:工具上线后**只绑用户新建的"研发专用" scope** + (不绑 1/2/3/5,seed-dev.sql 也不预绑任何 scope)。 +- 服务 Token(principalType=service)只能签 SYNC 作用域,天然调不到本工具; + 调用方必须是持有该专用 scope 的用户委托 Token。 +- 审计兜底:audit_log 记录每次调用的操作人 token 身份,事后可追溯。 + +## 4. 与老系统差异 / 风险说明 + +1. 老系统无此功能,MCP 为新增能力,无兼容包袱。 +2. 改派只动流程表待办行,**不动主表、不重推审批流、不发消息通知** + (老系统审批动作会触发消息,改派属于运维动作,是否通知新处理人见拍板点 5)。 +3. 改派后新处理人在老系统 App/PC 端即可正常看到待办并办理(语义等价于原处理人办理)。 +4. 视图 `warehouse_allocation_process.next_user_name` 等冗余字段由视图实时计算,无需同步改。 + +## 5. 拍板结论(2026-11-07 王恒已拍板) + +1. **1 工具 + orderType 枚举** ✅(不拆 4 个)。 +2. 新处理人入参:**只认 mobile 精确匹配**(拍板定案,去掉 userId 入参); + 手机号查 m0 user 必须带 `soft_delete=0` 条件。 +3. 并行多条待办行:**默认全部改派并逐行返回**。 +4. **不加 dryRun**(返回已含 before/after,audit_log 可追溯)。 +5. **不发系统消息**(运维动作,避免惊动业务消息流)。 + +## 6. 改动文件清单 + +| 文件 | 动作 | +|---|---| +| `entity/m2/WarehouseAllocatiProcess.java` | 新增(全列本地拷贝) | +| `mapper/m2/WarehouseAllocatiProcessMapper.java` | 新增 | +| `mcp/tool/WarehouseOrderAdminTools.java` | 新增(1 个工具方法) | +| `mcp/tool/WarehouseOrderAdminToolsTest.java` | 新增(参数校验/单号不存在/op_type 不一致/无待办行/并行多行/新处理人不存在/审计写入) | +| `doc/agent-mcp-tools-reference.md` | 追加工具章节 | +| `README.md` | 工具表登记 | + +装配层零改动(组件扫描自动注册)。**seed-dev.sql 不预绑 scope**。 + +## 7. 上线步骤 + +1. 全量 `./mvnw clean package` 绿 → push main → remote-deploy。 +2. 王恒已建好"研发专用" scope(2026-11-07 确认)→ 查 scope id 后在 prod svc 库 + `INSERT IGNORE INTO scope_tool(scope_id, tool_id)` 只绑该 scope。 +4. 生产冒烟:用专用 scope 签用户 Token,对一张测试单改派 → 验证老系统待办归属变化 → 改回。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseAllocatiProcess.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseAllocatiProcess.java new file mode 100644 index 0000000..bf61423 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseAllocatiProcess.java @@ -0,0 +1,45 @@ +package me.iiv.agent.entity.m2; + +import com.baomidou.mybatisplus.annotation.IdType; +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.util.Date; + +/** 调拨/采购/划转流程物理表 warehouse_allocati_process(m2):老系统拼写少一个 on,勿与视图 warehouse_allocation_process 混淆。 */ +@Getter +@Setter +@ToString +@TableName("warehouse_allocati_process") +public class WarehouseAllocatiProcess implements Serializable { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + private Long createId; + private Date createTime; + private Long modifyId; + private Date modifyTime; + private Integer softDelete; + private Long warehouseAllocationId; + private Long warehouseOutId; + private String warehouseOutName; + /** 办理类型(0:调拨;1:采购) */ + private Integer opType; + private Long userId; + private String userName; + private Date operationTime; + /** 待办状态(0:无需待办;1:待办;2:已办) */ + private Integer scheduleState; + /** 审核状态(0:审核驳回/驳回;1:审核通过/出库确认) */ + private Integer auditState; + private Integer auditLevel; + private String memo; + /** 操作主题(0:审核通过;1:审核驳回;2:撤销入库;3:转出确认;4:出库确认驳回;5:驳回修改提交;6:入库确认;7:出库确认;8:发起人撤销) */ + private Integer operationTheme; + /** 操作类型(0:审批流操作;1:确认入库操作;2:确认出库操作;3:本人修改;4:驳回或修改操作) */ + private Integer operationType; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseAllocation.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseAllocation.java index 707dd86..8cb8ee1 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseAllocation.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseAllocation.java @@ -23,4 +23,5 @@ public class WarehouseAllocation implements Serializable { @TableField("allocation_order_no") private String allocationOrderNo; private Integer opType; + private Integer orderState; } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInAuditProcess.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInAuditProcess.java index 537eddb..589ddd4 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInAuditProcess.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseInAuditProcess.java @@ -10,7 +10,7 @@ import lombok.ToString; import java.io.Serializable; import java.util.Date; -/** 入库单审批/处理流程(m2,只读)。 */ +/** 入库单审批/处理流程(m2):WarehouseOrderAdminTools 改派处理人时会 UPDATE 待办行。 */ @Getter @Setter @ToString @@ -18,6 +18,8 @@ import java.util.Date; public class WarehouseInAuditProcess implements Serializable { @TableId(value = "id", type = IdType.AUTO) private Long id; + private Long modifyId; + private Date modifyTime; private Integer softDelete; private Long warehouseInId; private Long warehouseId; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutAuditProcess.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutAuditProcess.java index e5d61d8..b4e6ad3 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutAuditProcess.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOutAuditProcess.java @@ -10,7 +10,7 @@ import lombok.ToString; import java.io.Serializable; import java.util.Date; -/** 出库单审批/处理记录(m2,只读)。 */ +/** 出库单审批/处理记录(m2):WarehouseOrderAdminTools 改派处理人时会 UPDATE 待办行。 */ @Getter @Setter @ToString @@ -18,6 +18,8 @@ import java.util.Date; public class WarehouseOutAuditProcess implements Serializable { @TableId(value = "id", type = IdType.AUTO) private Long id; + private Long modifyId; + private Date modifyTime; private Integer softDelete; private Long warehouseOutId; private Long warehouseId; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseAllocatiProcessMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseAllocatiProcessMapper.java new file mode 100644 index 0000000..7ad1421 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseAllocatiProcessMapper.java @@ -0,0 +1,7 @@ +package me.iiv.agent.mapper.m2; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import me.iiv.agent.entity.m2.WarehouseAllocatiProcess; + +public interface WarehouseAllocatiProcessMapper extends BaseMapper { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseOrderAdminTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseOrderAdminTools.java new file mode 100644 index 0000000..c7c6d80 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseOrderAdminTools.java @@ -0,0 +1,300 @@ +package me.iiv.agent.mcp.tool; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.m2.WarehouseAllocatiProcess; +import me.iiv.agent.entity.m2.WarehouseAllocation; +import me.iiv.agent.entity.m2.WarehouseIn; +import me.iiv.agent.entity.m2.WarehouseInAuditProcess; +import me.iiv.agent.entity.m2.WarehouseOut; +import me.iiv.agent.entity.m2.WarehouseOutAuditProcess; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mapper.m2.WarehouseAllocatiProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseAllocationMapper; +import me.iiv.agent.mapper.m2.WarehouseInAuditProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseInMapper; +import me.iiv.agent.mapper.m2.WarehouseOutAuditProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseOutMapper; +import me.iiv.agent.mcp.McpCallContextHolder; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; +import me.iiv.agent.mcp.ToolObject; +import me.iiv.agent.mcp.XqTool; +import me.iiv.agent.security.AuthContextHolder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +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.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 仓库单据处理人改派工具(REQ-20261107-0002,设计文档 doc/warehouse-order-handler-update-mcp-design.md)。 + * 运维/研发专用:按单号把出库/入库/调拨/划转单的当前待办处理人改派给他人, + * 不做行级权限,访问控制完全靠专用 scope 门禁。平台首个 kind=write 工具。 + * 改派语义 = UPDATE 流程物理表(warehouse_out_audit_process / warehouse_in_audit_process / + * warehouse_allocati_process)中 schedule_state=1 待办行的 user_id/user_name。 + */ +@Component +public class WarehouseOrderAdminTools implements ToolObject { + + private static final Logger log = LoggerFactory.getLogger(WarehouseOrderAdminTools.class); + + 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, "作废"); + + final WarehouseOutMapper outMapper; + final WarehouseInMapper inMapper; + final WarehouseAllocationMapper allocationMapper; + final WarehouseOutAuditProcessMapper outAuditProcessMapper; + final WarehouseInAuditProcessMapper inAuditProcessMapper; + final WarehouseAllocatiProcessMapper allocatiProcessMapper; + final UserMapper userMapper; + + public WarehouseOrderAdminTools(WarehouseOutMapper outMapper, + WarehouseInMapper inMapper, + WarehouseAllocationMapper allocationMapper, + WarehouseOutAuditProcessMapper outAuditProcessMapper, + WarehouseInAuditProcessMapper inAuditProcessMapper, + WarehouseAllocatiProcessMapper allocatiProcessMapper, + UserMapper userMapper) { + this.outMapper = outMapper; + this.inMapper = inMapper; + this.allocationMapper = allocationMapper; + this.outAuditProcessMapper = outAuditProcessMapper; + this.inAuditProcessMapper = inAuditProcessMapper; + this.allocatiProcessMapper = allocatiProcessMapper; + this.userMapper = userMapper; + } + + @XqTool(category = "admin", kind = "write") + @Tool(name = "warehouse.order.handler.update", description = "【写操作·研发/运维专用】按单号改派仓库单据的当前待办处理人," + + "覆盖出库单(orderType=out)、入库单(in)、调拨单(allocation)、划转单(transfer)。" + + "机制:把该单流程物理表中 schedule_state=1 的待办行 user_id/user_name 改成新处理人," + + "并行多条待办行全部改派;不动主表、不重推审批流、不发消息通知。" + + "新处理人按手机号在 m0 user 表精确匹配(soft_delete=0)。" + + "无行级权限校验,任何单据都可改;访问控制靠 scope 绑定,每次调用写审计日志。" + + "单据无待办处理人(已完成/已撤销/作废)时返回错误并附 orderStateText。" + + "返回 before/after 明细(changedRows)与 changedCount。") + public Map warehouseOrderHandlerUpdate( + @ToolParam(description = "单据类型:out=出库单 in=入库单 allocation=调拨单 transfer=划转单") String orderType, + @ToolParam(description = "单据编号(精确匹配,如 OUT20261001001)") String orderNo, + @ToolParam(description = "新处理人手机号(m0 user.mobile 精确匹配)") String newHandlerMobile, + @ToolParam(required = false, description = "改派原因,写入审计日志") String memo) { + String type = normalizeOrderType(orderType); + String docNo = requireText(orderNo, "orderNo"); + String mobile = requireText(newHandlerMobile, "newHandlerMobile"); + + User caller = AuthContextHolder.get().getUser(); + if (caller == null) { + throw new ToolCallException(McpErrorCode.PERMISSION_DENIED, + "该工具要求用户身份 Token 调用(改派人需可追溯),服务身份 Token 不可用"); + } + + OrderRef order = locateOrder(type, docNo); + User newHandler = locateUserByMobile(mobile); + + List pendingRows = pendingRows(type, order.docId()); + if (pendingRows.isEmpty()) { + throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, + "该单据当前无待办处理人(orderState=" + order.orderStateText() + + ",已完成/已撤销/作废的单据不能改派)"); + } + + Date now = new Date(); + int updated = reassign(type, order.docId(), newHandler, caller.getId(), now); + + List> changedRows = new ArrayList<>(); + for (ProcessRow row : pendingRows) { + Map change = new LinkedHashMap<>(); + change.put("processId", row.processId()); + change.put("oldUserId", row.userId()); + change.put("oldUserName", row.userName()); + change.put("newUserId", newHandler.getId()); + change.put("newUserName", newHandler.getName()); + changedRows.add(change); + } + + String requestId = McpCallContextHolder.currentOrCreateRequestId(); + log.info("warehouse.order.handler.update 改派完成 requestId={} orderType={} orderNo={} " + + "newHandler={}({}) changedCount={} memo={} caller={}", + requestId, type, docNo, newHandler.getId(), newHandler.getName(), + updated, memo, caller.getId()); + + Map result = new LinkedHashMap<>(); + result.put("orderType", type); + result.put("orderNo", docNo); + result.put("orderId", order.docId()); + result.put("orderState", order.orderState()); + result.put("orderStateText", order.orderStateText()); + result.put("changedRows", changedRows); + result.put("changedCount", updated); + result.put("requestId", requestId); + return result; + } + + private static String normalizeOrderType(String orderType) { + String type = orderType == null ? "" : orderType.trim().toLowerCase(); + return switch (type) { + case "out", "in", "allocation", "transfer" -> type; + default -> throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, + "orderType 必须是 out / in / allocation / transfer"); + }; + } + + private static String requireText(String value, String param) { + if (!StringUtils.hasText(value)) { + throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, param + " 不能为空"); + } + return value.trim(); + } + + /** 按单号定位单据(soft_delete=0 精确匹配),并做调拨/划转 op_type 一致性校验。 */ + private OrderRef locateOrder(String type, String orderNo) { + return switch (type) { + case "out" -> { + WarehouseOut order = single(outMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseOut::getSoftDelete, 0) + .eq(WarehouseOut::getOutOrderNo, orderNo)), "出库单", orderNo); + yield new OrderRef(order.getId(), order.getOrderState(), + text(OUT_ORDER_STATE_TEXT, order.getOrderState())); + } + case "in" -> { + WarehouseIn order = single(inMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseIn::getSoftDelete, 0) + .eq(WarehouseIn::getInOrderNo, orderNo)), "入库单", orderNo); + yield new OrderRef(order.getId(), order.getOrderState(), + text(IN_ORDER_STATE_TEXT, order.getOrderState())); + } + default -> { + WarehouseAllocation order = single(allocationMapper.selectList( + new LambdaQueryWrapper() + .eq(WarehouseAllocation::getSoftDelete, 0) + .eq(WarehouseAllocation::getAllocationOrderNo, orderNo)), + "调拨/划转单", orderNo); + int expectedOpType = "allocation".equals(type) ? 0 : 2; + Integer opType = order.getOpType(); + if (opType == null || opType != expectedOpType) { + String actual = opType == null ? "未知" : switch (opType) { + case 0 -> "调拨单(orderType=allocation)"; + case 1 -> "采购单(本工具不支持,仅支持调拨/划转)"; + default -> "划转单(orderType=transfer)"; + }; + throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, + "单号 " + orderNo + " 实际是" + actual + ",与 orderType=" + type + " 不一致"); + } + yield new OrderRef(order.getId(), order.getOrderState(), + text(ALLOCATION_ORDER_STATE_TEXT, order.getOrderState())); + } + }; + } + + private static T single(List rows, String docTypeText, String orderNo) { + if (rows.isEmpty()) { + throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, + docTypeText + "单号 " + orderNo + " 不存在"); + } + if (rows.size() > 1) { + throw new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR, + docTypeText + "单号 " + orderNo + " 命中多条记录,请携带 request_id 联系运维排查"); + } + return rows.get(0); + } + + /** 新处理人:m0 user 表按手机号精确匹配且 soft_delete=0(王恒拍板口径)。 */ + private User locateUserByMobile(String mobile) { + List users = userMapper.selectList(new LambdaQueryWrapper() + .eq(User::getSoftDelete, 0) + .eq(User::getMobile, mobile)); + if (users.isEmpty()) { + throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, + "手机号 " + mobile + " 未匹配到有效用户(user 表 soft_delete=0)"); + } + if (users.size() > 1) { + throw new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR, + "手机号 " + mobile + " 命中多个有效用户,请携带 request_id 联系运维排查"); + } + return users.get(0); + } + + /** 当前待办行:流程物理表 schedule_state=1 且 soft_delete=0。 */ + private List pendingRows(String type, Long docId) { + return switch (type) { + case "out" -> outAuditProcessMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseOutAuditProcess::getSoftDelete, 0) + .eq(WarehouseOutAuditProcess::getScheduleState, 1) + .eq(WarehouseOutAuditProcess::getWarehouseOutId, docId) + .orderByAsc(WarehouseOutAuditProcess::getId)).stream() + .map(row -> new ProcessRow(row.getId(), row.getUserId(), row.getUserName())) + .toList(); + case "in" -> inAuditProcessMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseInAuditProcess::getSoftDelete, 0) + .eq(WarehouseInAuditProcess::getScheduleState, 1) + .eq(WarehouseInAuditProcess::getWarehouseInId, docId) + .orderByAsc(WarehouseInAuditProcess::getId)).stream() + .map(row -> new ProcessRow(row.getId(), row.getUserId(), row.getUserName())) + .toList(); + default -> allocatiProcessMapper.selectList(new LambdaQueryWrapper() + .eq(WarehouseAllocatiProcess::getSoftDelete, 0) + .eq(WarehouseAllocatiProcess::getScheduleState, 1) + .eq(WarehouseAllocatiProcess::getWarehouseAllocationId, docId) + .orderByAsc(WarehouseAllocatiProcess::getId)).stream() + .map(row -> new ProcessRow(row.getId(), row.getUserId(), row.getUserName())) + .toList(); + }; + } + + /** 并行多条待办行全部改派,并补 modify_id/modify_time。返回影响行数。 */ + private int reassign(String type, Long docId, User newHandler, Long callerUserId, Date now) { + return switch (type) { + case "out" -> outAuditProcessMapper.update(null, + new LambdaUpdateWrapper() + .eq(WarehouseOutAuditProcess::getSoftDelete, 0) + .eq(WarehouseOutAuditProcess::getScheduleState, 1) + .eq(WarehouseOutAuditProcess::getWarehouseOutId, docId) + .set(WarehouseOutAuditProcess::getUserId, newHandler.getId()) + .set(WarehouseOutAuditProcess::getUserName, newHandler.getName()) + .set(WarehouseOutAuditProcess::getModifyId, callerUserId) + .set(WarehouseOutAuditProcess::getModifyTime, now)); + case "in" -> inAuditProcessMapper.update(null, + new LambdaUpdateWrapper() + .eq(WarehouseInAuditProcess::getSoftDelete, 0) + .eq(WarehouseInAuditProcess::getScheduleState, 1) + .eq(WarehouseInAuditProcess::getWarehouseInId, docId) + .set(WarehouseInAuditProcess::getUserId, newHandler.getId()) + .set(WarehouseInAuditProcess::getUserName, newHandler.getName()) + .set(WarehouseInAuditProcess::getModifyId, callerUserId) + .set(WarehouseInAuditProcess::getModifyTime, now)); + default -> allocatiProcessMapper.update(null, + new LambdaUpdateWrapper() + .eq(WarehouseAllocatiProcess::getSoftDelete, 0) + .eq(WarehouseAllocatiProcess::getScheduleState, 1) + .eq(WarehouseAllocatiProcess::getWarehouseAllocationId, docId) + .set(WarehouseAllocatiProcess::getUserId, newHandler.getId()) + .set(WarehouseAllocatiProcess::getUserName, newHandler.getName()) + .set(WarehouseAllocatiProcess::getModifyId, callerUserId) + .set(WarehouseAllocatiProcess::getModifyTime, now)); + }; + } + + private static String text(Map texts, Integer code) { + return code == null ? null : texts.getOrDefault(code, "未知"); + } + + private record OrderRef(Long docId, Integer orderState, String orderStateText) { + } + + private record ProcessRow(Long processId, Long userId, String userName) { + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseOrderAdminToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseOrderAdminToolsTest.java new file mode 100644 index 0000000..db6dbc7 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseOrderAdminToolsTest.java @@ -0,0 +1,235 @@ +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.WarehouseAllocatiProcess; +import me.iiv.agent.entity.m2.WarehouseAllocation; +import me.iiv.agent.entity.m2.WarehouseIn; +import me.iiv.agent.entity.m2.WarehouseInAuditProcess; +import me.iiv.agent.entity.m2.WarehouseOut; +import me.iiv.agent.entity.m2.WarehouseOutAuditProcess; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mapper.m2.WarehouseAllocatiProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseAllocationMapper; +import me.iiv.agent.mapper.m2.WarehouseInAuditProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseInMapper; +import me.iiv.agent.mapper.m2.WarehouseOutAuditProcessMapper; +import me.iiv.agent.mapper.m2.WarehouseOutMapper; +import me.iiv.agent.mcp.McpErrorCode; +import me.iiv.agent.mcp.ToolCallException; +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.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WarehouseOrderAdminToolsTest { + + @Mock WarehouseOutMapper outMapper; + @Mock WarehouseInMapper inMapper; + @Mock WarehouseAllocationMapper allocationMapper; + @Mock WarehouseOutAuditProcessMapper outAuditProcessMapper; + @Mock WarehouseInAuditProcessMapper inAuditProcessMapper; + @Mock WarehouseAllocatiProcessMapper allocatiProcessMapper; + @Mock UserMapper userMapper; + + WarehouseOrderAdminTools tools; + + @BeforeEach + void setUp() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, WarehouseOut.class); + TableInfoHelper.initTableInfo(assistant, WarehouseIn.class); + TableInfoHelper.initTableInfo(assistant, WarehouseAllocation.class); + TableInfoHelper.initTableInfo(assistant, WarehouseOutAuditProcess.class); + TableInfoHelper.initTableInfo(assistant, WarehouseInAuditProcess.class); + TableInfoHelper.initTableInfo(assistant, WarehouseAllocatiProcess.class); + TableInfoHelper.initTableInfo(assistant, User.class); + tools = new WarehouseOrderAdminTools(outMapper, inMapper, allocationMapper, + outAuditProcessMapper, inAuditProcessMapper, allocatiProcessMapper, userMapper); + User caller = new User(); + caller.setId(812L); + AuthContextHolder.set(new AuthContext(null, caller)); + } + + @AfterEach + void tearDown() { + AuthContextHolder.clear(); + } + + @Test + void rejectsInvalidOrderType() { + ToolCallException e = assertThrows(ToolCallException.class, + () -> tools.warehouseOrderHandlerUpdate("purchase", "X-1", "13800000000", null)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, e.getCode()); + } + + @Test + void rejectsBlankOrderNoAndMobile() { + ToolCallException e1 = assertThrows(ToolCallException.class, + () -> tools.warehouseOrderHandlerUpdate("out", " ", "13800000000", null)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, e1.getCode()); + ToolCallException e2 = assertThrows(ToolCallException.class, + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-1", null, null)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, e2.getCode()); + } + + @Test + void deniesServiceTokenWithoutUser() { + AuthContextHolder.set(new AuthContext(null, null)); + ToolCallException e = assertThrows(ToolCallException.class, + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-1", "13800000000", null)); + assertEquals(McpErrorCode.PERMISSION_DENIED, e.getCode()); + } + + @Test + void orderNotFound() { + when(outMapper.selectList(any())).thenReturn(List.of()); + ToolCallException e = assertThrows(ToolCallException.class, + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-NONE", "13800000000", null)); + assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, e.getCode()); + } + + @Test + void duplicateOrderNoIsDataIntegrityError() { + when(outMapper.selectList(any())).thenReturn(List.of(outOrder(1L), outOrder(2L))); + ToolCallException e = assertThrows(ToolCallException.class, + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-DUP", "13800000000", null)); + assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, e.getCode()); + } + + @Test + void transferRejectsAllocationOrder() { + WarehouseAllocation order = new WarehouseAllocation(); + order.setId(40L); + order.setOpType(0); + when(allocationMapper.selectList(any())).thenReturn(List.of(order)); + ToolCallException e = assertThrows(ToolCallException.class, + () -> tools.warehouseOrderHandlerUpdate("transfer", "DB-001", "13800000000", null)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, e.getCode()); + assertTrue(e.getRawMessage().contains("orderType=transfer")); + } + + @Test + void noPendingRowsRejected() { + when(inMapper.selectList(any())).thenReturn(List.of(inOrder(30L, 3))); + stubNewHandler(); + when(inAuditProcessMapper.selectList(any())).thenReturn(List.of()); + ToolCallException e = assertThrows(ToolCallException.class, + () -> tools.warehouseOrderHandlerUpdate("in", "IN-1", "13800000000", null)); + assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, e.getCode()); + assertTrue(e.getRawMessage().contains("已完成")); + verify(inAuditProcessMapper, never()).update(isNull(), any()); + } + + @Test + void unknownMobileRejected() { + when(outMapper.selectList(any())).thenReturn(List.of(outOrder(20L))); + when(userMapper.selectList(any())).thenReturn(List.of()); + ToolCallException e = assertThrows(ToolCallException.class, + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-1", "13800000000", null)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, e.getCode()); + } + + @Test + void reassignsOutOrderPendingRows() { + when(outMapper.selectList(any())).thenReturn(List.of(outOrder(20L))); + stubNewHandler(); + WarehouseOutAuditProcess pending = new WarehouseOutAuditProcess(); + pending.setId(9001L); + pending.setUserId(101L); + pending.setUserName("张三"); + when(outAuditProcessMapper.selectList(any())).thenReturn(List.of(pending)); + when(outAuditProcessMapper.update(isNull(), any())).thenReturn(1); + + Map result = + tools.warehouseOrderHandlerUpdate("out", "OUT-1", "13800000000", "离职改派"); + + assertEquals("out", result.get("orderType")); + assertEquals(20L, result.get("orderId")); + assertEquals(2, result.get("orderState")); + assertEquals("待出库", result.get("orderStateText")); + assertEquals(1, result.get("changedCount")); + @SuppressWarnings("unchecked") + List> changedRows = (List>) result.get("changedRows"); + assertEquals(1, changedRows.size()); + assertEquals(9001L, changedRows.get(0).get("processId")); + assertEquals(101L, changedRows.get(0).get("oldUserId")); + assertEquals("张三", changedRows.get(0).get("oldUserName")); + assertEquals(205L, changedRows.get(0).get("newUserId")); + assertEquals("李四", changedRows.get(0).get("newUserName")); + verify(outAuditProcessMapper).update(isNull(), any()); + } + + @Test + void reassignsAllParallelPendingRowsOfTransferOrder() { + WarehouseAllocation order = new WarehouseAllocation(); + order.setId(40L); + order.setOpType(2); + order.setOrderState(3); + when(allocationMapper.selectList(any())).thenReturn(List.of(order)); + stubNewHandler(); + WarehouseAllocatiProcess row1 = new WarehouseAllocatiProcess(); + row1.setId(7001L); + row1.setUserId(101L); + row1.setUserName("张三"); + WarehouseAllocatiProcess row2 = new WarehouseAllocatiProcess(); + row2.setId(7002L); + row2.setUserId(102L); + row2.setUserName("王五"); + when(allocatiProcessMapper.selectList(any())).thenReturn(List.of(row1, row2)); + when(allocatiProcessMapper.update(isNull(), any())).thenReturn(2); + + Map result = + tools.warehouseOrderHandlerUpdate("transfer", "HZ-001", "13800000000", null); + + assertEquals("transfer", result.get("orderType")); + assertEquals("待入库", result.get("orderStateText")); + assertEquals(2, result.get("changedCount")); + @SuppressWarnings("unchecked") + List> changedRows = (List>) result.get("changedRows"); + assertEquals(2, changedRows.size()); + assertEquals(102L, changedRows.get(1).get("oldUserId")); + assertEquals(205L, changedRows.get(1).get("newUserId")); + verify(allocatiProcessMapper).update(isNull(), any()); + } + + private void stubNewHandler() { + User newHandler = new User(); + newHandler.setId(205L); + newHandler.setName("李四"); + when(userMapper.selectList(any())).thenReturn(List.of(newHandler)); + } + + private static WarehouseOut outOrder(Long id) { + WarehouseOut order = new WarehouseOut(); + order.setId(id); + order.setOrderState(2); + return order; + } + + private static WarehouseIn inOrder(Long id, Integer orderState) { + WarehouseIn order = new WarehouseIn(); + order.setId(id); + order.setOrderState(orderState); + return order; + } +}