diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 4ff061e..3c90e48 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -103,7 +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 | +| `warehouse.order.handler.update` | admin | write | 仓库单据当前待办处理人改派(平台首个写工具,研发/运维专用,REQ-20261107-0002)。入参 orderType(out/in/allocation/transfer)/orderNo/newHandlerMobile 或 newHandlerName(二选一,均 m0 user 精确匹配+soft_delete=0,姓名需唯一命中,同传需一致,REQ-20261107-0004)/memo;UPDATE 流程物理表 schedule_state=1 待办行的 user_id/user_name(并行多行全改派),调拨/划转同步改主表 warehouse_allocation.next_user_name 待办人,出库/入库不动主表,不发消息;无行级权限,只绑"研发专用" scope,服务身份 Token 拒绝。详细设计见 doc/warehouse-order-handler-update-mcp-design.md | | `warehouse.out.void` | admin | write | 出库单作废(研发/运维专用,REQ-20261107-0003,老系统 /myWarehouseOut/remove 的 MCP 化)。入参 outOrderNo/mallOrderNo(至少一个)/memo/confirmMulti;只作废已完成(order_state=3)单,全链路 7 步:主表/明细置作废、库存恢复、商城回滚待出库、SN 硬库存回库+LifeCycle 撤销推送、关联入库单级联作废+扣库存、金蝶作废推送、无安装单时送货工单级联软删;m2 一个事务、m1 提交后执行、Redis 最后且失败不回滚;mallOrderNo 多单需 confirmMulti=true 否则只回候选清单;无行级权限,只绑"研发专用" scope,用户身份 Token 调用。详细设计见 doc/warehouse-out-void-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 21c0d66..b1113ad 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -1208,14 +1208,21 @@ - 分类:admin | 类型:写(readOnlyHint=false / destructiveHint=true) - 入参:`orderType`(out 出库 / in 入库 / allocation 调拨 / transfer 划转,必填)、 `orderNo`(单号精确匹配,必填)、`newHandlerMobile`(新处理人手机号,m0 `user.mobile` - 精确匹配且 `soft_delete=0`,必填)、`memo`(改派原因,可选)。 + 精确匹配且 `soft_delete=0`)、`newHandlerName`(新处理人姓名,m0 `user.name` 精确匹配且 + `soft_delete=0`,需唯一命中;2026-11-07 REQ-20261107-0004 追加)——两者至少传一个, + 同传时必须解析到同一用户;`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`; - 并行多条待办行全部改派。不动主表、不重推审批流、不发消息通知。 + 并行多条待办行全部改派。调拨/划转同步 UPDATE 主表 `warehouse_allocation` 的 + `next_user_name` 待办人(2026-11-07 王恒补充:该冗余列是老系统列表页"待办人"展示字段, + 不改则页面仍显示旧人)并补 `modify_id`/`modify_time`;出库/入库主表无此字段不动。 + 不重推审批流、不发消息通知。 - 校验:调拨要求 `op_type=0`、划转要求 `op_type=2`,不一致 INVALID_ARGUMENT; 无待办行(已完成/已撤销/作废)NOT_FOUND_OR_FORBIDDEN 并附 orderStateText; - 手机号无有效用户 INVALID_ARGUMENT;服务身份 Token PERMISSION_DENIED(改派人须可追溯)。 + 手机号/姓名无有效用户 INVALID_ARGUMENT;姓名命中多个有效用户(同名)INVALID_ARGUMENT + 并附候选 id(手机号) 引导改用 newHandlerMobile;手机号重复 DATA_INTEGRITY_ERROR; + mobile 与 name 同传但解析到不同用户 INVALID_ARGUMENT;服务身份 Token PERMISSION_DENIED(改派人须可追溯)。 - 权限:**无行级权限**,任何单据都可改,访问控制完全靠 scope 绑定——只绑"研发专用" scope, 不绑 1/2/3/5。审计由 GuardedToolCallback 自动落 svc `audit_log`,工具内另打 before/after 日志。 - 返回:`orderType/orderNo/orderId/orderState/orderStateText/changedRows` 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 index 89f52ca..2d4e698 100644 --- a/xiaoqu-agent-service/doc/warehouse-order-handler-update-mcp-design.md +++ b/xiaoqu-agent-service/doc/warehouse-order-handler-update-mcp-design.md @@ -1,7 +1,8 @@ # 仓库单据处理人改派 MCP 工具设计(warehouse.order.handler.update) - 需求:REQ-20261107-0002(ai-proj id 6442) -- 状态:已评审通过(2026-11-07 王恒拍板:入参=单号+手机号,手机号查 m0 user 表带 soft_delete=0 条件,其余按建议) +- 状态:已评审通过(2026-11-07 王恒拍板:入参=单号+手机号,手机号查 m0 user 表带 soft_delete=0 条件,其余按建议); + 2026-11-07 追加(REQ-20261107-0004,王恒拍板):增加 newHandlerName 入参,姓名唯一命中也可改派,已随本版上线 - 日期:2026-11-07 ## 1. 背景 @@ -66,16 +67,21 @@ warehouse.order.handler.update kind=write category=admin |---|---|---| | orderType | 是 | 枚举:`out` 出库单 / `in` 入库单 / `allocation` 调拨单 / `transfer` 划转单 | | orderNo | 是 | 单据编号(精确匹配,去空格) | -| newHandlerMobile | 是 | 新处理人手机号(m0 user.mobile 精确匹配,拍板定案:只认手机号) | +| newHandlerMobile | 二选一* | 新处理人手机号(m0 user.mobile 精确匹配,soft_delete=0) | +| newHandlerName | 二选一* | 新处理人姓名(m0 user.name 精确匹配,soft_delete=0,需唯一命中;2026-11-07 王恒追加,REQ-20261107-0004) | | memo | 否 | 改派原因,写入审计日志 | +\* newHandlerMobile / newHandlerName 至少传一个;都传时必须解析到同一用户,否则 `INVALID_ARGUMENT`。 +姓名命中多个有效用户(同名)属正常情况,报 `INVALID_ARGUMENT` 并附候选 id(手机号),引导改用 newHandlerMobile。 + 行为: 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`。 +4. 新处理人校验:按手机号或姓名在 m0 user 精确匹配(soft_delete=0),查不到 → `INVALID_ARGUMENT`; + 手机号重复 → `DATA_INTEGRITY_ERROR`;姓名重复 → `INVALID_ARGUMENT` 引导改用手机号。 5. UPDATE 所有待办行:`user_id`、`user_name`(取 user 表真实姓名)、 `modify_id`=调用方 userId、`modify_time`=now。 6. 写 svc `audit_log`:调用方 userId/tokenId、工具名、参数摘要 @@ -117,19 +123,25 @@ warehouse.order.handler.update kind=write category=admin ## 4. 与老系统差异 / 风险说明 1. 老系统无此功能,MCP 为新增能力,无兼容包袱。 -2. 改派只动流程表待办行,**不动主表、不重推审批流、不发消息通知** +2. 改派只动流程表待办行 + 调拨/划转主表 `warehouse_allocation.next_user_name` 待办人, + **出库/入库不动主表、不重推审批流、不发消息通知** (老系统审批动作会触发消息,改派属于运维动作,是否通知新处理人见拍板点 5)。 3. 改派后新处理人在老系统 App/PC 端即可正常看到待办并办理(语义等价于原处理人办理)。 -4. 视图 `warehouse_allocation_process.next_user_name` 等冗余字段由视图实时计算,无需同步改。 +4. 视图 `warehouse_allocation_process` 等冗余字段由视图实时计算,无需同步改; + 主表 `warehouse_allocation.next_user_name` 是独立冗余列(2026-11-07 王恒指出),改派时必须同步更新, + 否则老系统列表页"待办人"仍显示旧人。出库/入库主表无此字段,无需处理。 ## 5. 拍板结论(2026-11-07 王恒已拍板) 1. **1 工具 + orderType 枚举** ✅(不拆 4 个)。 -2. 新处理人入参:**只认 mobile 精确匹配**(拍板定案,去掉 userId 入参); - 手机号查 m0 user 必须带 `soft_delete=0` 条件。 +2. 新处理人入参:mobile 精确匹配为主路径;2026-11-07 追加 name 精确匹配辅路径 + (唯一命中才允许,命中多条报错引导用手机号);两者同传需一致。查询均带 `soft_delete=0`。 3. 并行多条待办行:**默认全部改派并逐行返回**。 4. **不加 dryRun**(返回已含 before/after,audit_log 可追溯)。 5. **不发系统消息**(运维动作,避免惊动业务消息流)。 +6. **调拨/划转同步改主表 `warehouse_allocation.next_user_name`**(2026-11-07 王恒补充): + 该列是老系统列表页展示的"待办人"冗余列,只改流程表会导致页面仍显示旧处理人; + 与流程表待办行同一调用内更新,并补 modify_id/modify_time。 ## 6. 改动文件清单 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 8cb8ee1..e59ed2c 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 @@ -9,8 +9,9 @@ import lombok.Setter; import lombok.ToString; import java.io.Serializable; +import java.util.Date; -/** 调拨/采购/划转单 warehouse_allocation(m2,只读):op_type 0调拨 1采购 2划转。 */ +/** 调拨/采购/划转单 warehouse_allocation(m2):op_type 0调拨 1采购 2划转。 */ @Getter @Setter @ToString @@ -24,4 +25,11 @@ public class WarehouseAllocation implements Serializable { private String allocationOrderNo; private Integer opType; private Integer orderState; + /** 待办人(改派时与流程表待办行同步更新)。 */ + @TableField("next_user_name") + private String nextUserName; + @TableField("modify_id") + private Long modifyId; + @TableField("modify_time") + private Date modifyTime; } 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 index c7c6d80..bacd8c3 100644 --- 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 @@ -40,7 +40,8 @@ import java.util.Map; * 运维/研发专用:按单号把出库/入库/调拨/划转单的当前待办处理人改派给他人, * 不做行级权限,访问控制完全靠专用 scope 门禁。平台首个 kind=write 工具。 * 改派语义 = UPDATE 流程物理表(warehouse_out_audit_process / warehouse_in_audit_process / - * warehouse_allocati_process)中 schedule_state=1 待办行的 user_id/user_name。 + * warehouse_allocati_process)中 schedule_state=1 待办行的 user_id/user_name; + * 调拨/划转另外同步 UPDATE 主表 warehouse_allocation 的 next_user_name 待办人。 */ @Component public class WarehouseOrderAdminTools implements ToolObject { @@ -82,19 +83,24 @@ public class WarehouseOrderAdminTools implements ToolObject { @Tool(name = "warehouse.order.handler.update", description = "【写操作·研发/运维专用】按单号改派仓库单据的当前待办处理人," + "覆盖出库单(orderType=out)、入库单(in)、调拨单(allocation)、划转单(transfer)。" + "机制:把该单流程物理表中 schedule_state=1 的待办行 user_id/user_name 改成新处理人," - + "并行多条待办行全部改派;不动主表、不重推审批流、不发消息通知。" - + "新处理人按手机号在 m0 user 表精确匹配(soft_delete=0)。" + + "并行多条待办行全部改派;出库/入库不动主表,调拨/划转会同步把主表 warehouse_allocation 的 " + + "next_user_name 待办人改成新处理人;不重推审批流、不发消息通知。" + + "新处理人二选一:newHandlerMobile 按手机号、newHandlerName 按姓名," + + "均在 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 = "新处理人手机号(m0 user.mobile 精确匹配,与 newHandlerName 至少传一个)") + String newHandlerMobile, + @ToolParam(required = false, description = "新处理人姓名(m0 user.name 精确匹配,需唯一命中;与 newHandlerMobile 至少传一个)") + String newHandlerName, @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) { @@ -102,8 +108,15 @@ public class WarehouseOrderAdminTools implements ToolObject { "该工具要求用户身份 Token 调用(改派人需可追溯),服务身份 Token 不可用"); } + String trimmedMobile = StringUtils.hasText(newHandlerMobile) ? newHandlerMobile.trim() : null; + String trimmedName = StringUtils.hasText(newHandlerName) ? newHandlerName.trim() : null; + if (trimmedMobile == null && trimmedName == null) { + throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, + "newHandlerMobile 与 newHandlerName 至少传一个"); + } + OrderRef order = locateOrder(type, docNo); - User newHandler = locateUserByMobile(mobile); + User newHandler = locateNewHandler(trimmedMobile, trimmedName); List pendingRows = pendingRows(type, order.docId()); if (pendingRows.isEmpty()) { @@ -212,7 +225,28 @@ public class WarehouseOrderAdminTools implements ToolObject { return rows.get(0); } - /** 新处理人:m0 user 表按手机号精确匹配且 soft_delete=0(王恒拍板口径)。 */ + /** + * 新处理人定位:手机号/姓名二选一(至少传一个),均按 m0 user 表精确匹配且 soft_delete=0。 + * 两者都传时必须解析到同一用户;姓名命中多个有效用户属于正常情况,报错引导改用手机号。 + */ + private User locateNewHandler(String mobile, String name) { + String trimmedMobile = StringUtils.hasText(mobile) ? mobile.trim() : null; + String trimmedName = StringUtils.hasText(name) ? name.trim() : null; + if (trimmedMobile == null && trimmedName == null) { + throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, + "newHandlerMobile 与 newHandlerName 至少传一个"); + } + User byMobile = trimmedMobile == null ? null : locateUserByMobile(trimmedMobile); + User byName = trimmedName == null ? null : locateUserByName(trimmedName); + if (byMobile != null && byName != null && !byMobile.getId().equals(byName.getId())) { + throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, + "newHandlerMobile 与 newHandlerName 匹配到的不是同一用户(手机号=" + byMobile.getId() + + "(" + byMobile.getName() + "),姓名=" + byName.getId() + "(" + byName.getName() + "))"); + } + return byMobile != null ? byMobile : byName; + } + + /** 手机号路径:要求唯一命中,重复手机号视为数据异常。 */ private User locateUserByMobile(String mobile) { List users = userMapper.selectList(new LambdaQueryWrapper() .eq(User::getSoftDelete, 0) @@ -228,6 +262,26 @@ public class WarehouseOrderAdminTools implements ToolObject { return users.get(0); } + /** 姓名路径:同名多用户属正常情况,命中多条时报错并附候选,引导改用 newHandlerMobile。 */ + private User locateUserByName(String name) { + List users = userMapper.selectList(new LambdaQueryWrapper() + .eq(User::getSoftDelete, 0) + .eq(User::getName, name)); + if (users.isEmpty()) { + throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, + "姓名 " + name + " 未匹配到有效用户(user 表 soft_delete=0)"); + } + if (users.size() > 1) { + String candidates = users.stream() + .map(u -> u.getId() + "(" + (u.getMobile() == null ? "无手机号" : u.getMobile()) + ")") + .reduce((a, b) -> a + "、" + b).orElse(""); + throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT, + "姓名 " + name + " 命中 " + users.size() + " 个有效用户(" + candidates + + "),请改用 newHandlerMobile 手机号指定"); + } + return users.get(0); + } + /** 当前待办行:流程物理表 schedule_state=1 且 soft_delete=0。 */ private List pendingRows(String type, Long docId) { return switch (type) { @@ -276,15 +330,24 @@ public class WarehouseOrderAdminTools implements ToolObject { .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)); + default -> { + int rows = 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)); + allocationMapper.update(null, new LambdaUpdateWrapper() + .eq(WarehouseAllocation::getSoftDelete, 0) + .eq(WarehouseAllocation::getId, docId) + .set(WarehouseAllocation::getNextUserName, newHandler.getName()) + .set(WarehouseAllocation::getModifyId, callerUserId) + .set(WarehouseAllocation::getModifyTime, now)); + yield rows; + } }; } 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 index db6dbc7..a2455c9 100644 --- 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 @@ -1,6 +1,7 @@ package me.iiv.agent.mcp.tool; import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import me.iiv.agent.entity.m0.User; import me.iiv.agent.entity.m2.WarehouseAllocatiProcess; @@ -25,6 +26,7 @@ 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.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -78,25 +80,26 @@ class WarehouseOrderAdminToolsTest { @Test void rejectsInvalidOrderType() { ToolCallException e = assertThrows(ToolCallException.class, - () -> tools.warehouseOrderHandlerUpdate("purchase", "X-1", "13800000000", null)); + () -> tools.warehouseOrderHandlerUpdate("purchase", "X-1", "13800000000", null, null)); assertEquals(McpErrorCode.INVALID_ARGUMENT, e.getCode()); } @Test void rejectsBlankOrderNoAndMobile() { ToolCallException e1 = assertThrows(ToolCallException.class, - () -> tools.warehouseOrderHandlerUpdate("out", " ", "13800000000", null)); + () -> tools.warehouseOrderHandlerUpdate("out", " ", "13800000000", null, null)); assertEquals(McpErrorCode.INVALID_ARGUMENT, e1.getCode()); ToolCallException e2 = assertThrows(ToolCallException.class, - () -> tools.warehouseOrderHandlerUpdate("out", "OUT-1", null, null)); + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-1", null, null, null)); assertEquals(McpErrorCode.INVALID_ARGUMENT, e2.getCode()); + assertTrue(e2.getRawMessage().contains("至少传一个")); } @Test void deniesServiceTokenWithoutUser() { AuthContextHolder.set(new AuthContext(null, null)); ToolCallException e = assertThrows(ToolCallException.class, - () -> tools.warehouseOrderHandlerUpdate("out", "OUT-1", "13800000000", null)); + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-1", "13800000000", null, null)); assertEquals(McpErrorCode.PERMISSION_DENIED, e.getCode()); } @@ -104,7 +107,7 @@ class WarehouseOrderAdminToolsTest { void orderNotFound() { when(outMapper.selectList(any())).thenReturn(List.of()); ToolCallException e = assertThrows(ToolCallException.class, - () -> tools.warehouseOrderHandlerUpdate("out", "OUT-NONE", "13800000000", null)); + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-NONE", "13800000000", null, null)); assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, e.getCode()); } @@ -112,7 +115,7 @@ class WarehouseOrderAdminToolsTest { 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)); + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-DUP", "13800000000", null, null)); assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, e.getCode()); } @@ -123,7 +126,7 @@ class WarehouseOrderAdminToolsTest { order.setOpType(0); when(allocationMapper.selectList(any())).thenReturn(List.of(order)); ToolCallException e = assertThrows(ToolCallException.class, - () -> tools.warehouseOrderHandlerUpdate("transfer", "DB-001", "13800000000", null)); + () -> tools.warehouseOrderHandlerUpdate("transfer", "DB-001", "13800000000", null, null)); assertEquals(McpErrorCode.INVALID_ARGUMENT, e.getCode()); assertTrue(e.getRawMessage().contains("orderType=transfer")); } @@ -134,7 +137,7 @@ class WarehouseOrderAdminToolsTest { stubNewHandler(); when(inAuditProcessMapper.selectList(any())).thenReturn(List.of()); ToolCallException e = assertThrows(ToolCallException.class, - () -> tools.warehouseOrderHandlerUpdate("in", "IN-1", "13800000000", null)); + () -> tools.warehouseOrderHandlerUpdate("in", "IN-1", "13800000000", null, null)); assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, e.getCode()); assertTrue(e.getRawMessage().contains("已完成")); verify(inAuditProcessMapper, never()).update(isNull(), any()); @@ -145,10 +148,87 @@ class WarehouseOrderAdminToolsTest { 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)); + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-1", "13800000000", null, null)); assertEquals(McpErrorCode.INVALID_ARGUMENT, e.getCode()); } + @Test + void unknownNameRejected() { + 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", null, "不存在", null)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, e.getCode()); + assertTrue(e.getRawMessage().contains("姓名")); + } + + @Test + void ambiguousNameRejectedAndSuggestsMobile() { + when(outMapper.selectList(any())).thenReturn(List.of(outOrder(20L))); + User u1 = namedUser(201L, "王伟", "13100000001"); + User u2 = namedUser(202L, "王伟", "13100000002"); + when(userMapper.selectList(any())).thenReturn(List.of(u1, u2)); + ToolCallException e = assertThrows(ToolCallException.class, + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-1", null, "王伟", null)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, e.getCode()); + assertTrue(e.getRawMessage().contains("命中 2 个有效用户")); + assertTrue(e.getRawMessage().contains("newHandlerMobile")); + verify(outAuditProcessMapper, never()).update(isNull(), any()); + } + + @Test + void mobileAndNameMustResolveToSameUser() { + when(outMapper.selectList(any())).thenReturn(List.of(outOrder(20L))); + User byMobile = namedUser(205L, "李四", "13800000000"); + User byName = namedUser(206L, "王五", "13900000000"); + when(userMapper.selectList(any())).thenReturn(List.of(byMobile), List.of(byName)); + ToolCallException e = assertThrows(ToolCallException.class, + () -> tools.warehouseOrderHandlerUpdate("out", "OUT-1", "13800000000", "王五", null)); + assertEquals(McpErrorCode.INVALID_ARGUMENT, e.getCode()); + assertTrue(e.getRawMessage().contains("不是同一用户")); + } + + @Test + void mobileAndNameConsistentPasses() { + when(outMapper.selectList(any())).thenReturn(List.of(outOrder(20L))); + User handler = namedUser(205L, "李四", "13800000000"); + when(userMapper.selectList(any())).thenReturn(List.of(handler), List.of(handler)); + 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", "李四", null); + + assertEquals(1, result.get("changedCount")); + verify(outAuditProcessMapper).update(isNull(), any()); + } + + @Test + void reassignsByUniqueName() { + 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", null, "李四", "姓名改派"); + + assertEquals(1, result.get("changedCount")); + @SuppressWarnings("unchecked") + List> changedRows = (List>) result.get("changedRows"); + assertEquals(205L, changedRows.get(0).get("newUserId")); + assertEquals("李四", changedRows.get(0).get("newUserName")); + verify(outAuditProcessMapper).update(isNull(), any()); + } + @Test void reassignsOutOrderPendingRows() { when(outMapper.selectList(any())).thenReturn(List.of(outOrder(20L))); @@ -161,7 +241,7 @@ class WarehouseOrderAdminToolsTest { when(outAuditProcessMapper.update(isNull(), any())).thenReturn(1); Map result = - tools.warehouseOrderHandlerUpdate("out", "OUT-1", "13800000000", "离职改派"); + tools.warehouseOrderHandlerUpdate("out", "OUT-1", "13800000000", null, "离职改派"); assertEquals("out", result.get("orderType")); assertEquals(20L, result.get("orderId")); @@ -199,7 +279,7 @@ class WarehouseOrderAdminToolsTest { when(allocatiProcessMapper.update(isNull(), any())).thenReturn(2); Map result = - tools.warehouseOrderHandlerUpdate("transfer", "HZ-001", "13800000000", null); + tools.warehouseOrderHandlerUpdate("transfer", "HZ-001", "13800000000", null, null); assertEquals("transfer", result.get("orderType")); assertEquals("待入库", result.get("orderStateText")); @@ -210,6 +290,11 @@ class WarehouseOrderAdminToolsTest { assertEquals(102L, changedRows.get(1).get("oldUserId")); assertEquals(205L, changedRows.get(1).get("newUserId")); verify(allocatiProcessMapper).update(isNull(), any()); + ArgumentCaptor> mainTableUpdate = + ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + verify(allocationMapper).update(isNull(), mainTableUpdate.capture()); + assertTrue(mainTableUpdate.getValue().getSqlSet().contains("next_user_name")); + assertTrue(mainTableUpdate.getValue().getSqlSet().contains("modify_id")); } private void stubNewHandler() { @@ -219,6 +304,14 @@ class WarehouseOrderAdminToolsTest { when(userMapper.selectList(any())).thenReturn(List.of(newHandler)); } + private static User namedUser(Long id, String name, String mobile) { + User user = new User(); + user.setId(id); + user.setName(name); + user.setMobile(mobile); + return user; + } + private static WarehouseOut outOrder(Long id) { WarehouseOut order = new WarehouseOut(); order.setId(id);