feat(mcp): 处理人改派工具支持姓名入参+调拨/划转同步主表待办人(REQ-20261107-0004/0006)

- 新增 newHandlerName 可选入参:与 newHandlerMobile 二选一,m0 user.name 精确
  匹配+soft_delete=0,唯一命中才生效,同名报错附候选 id(手机号),同传需解析到同一用户
- 调拨/划转改派同步 UPDATE 主表 warehouse_allocation.next_user_name 待办人
  (老系统列表页展示字段),并补 modify_id/modify_time;出库/入库主表无此字段不动
- 参数轻校验提到订单查询之前,参数错误优先返回 INVALID_ARGUMENT
This commit is contained in:
my_local_kimi_dev2
2026-11-07 21:37:11 +08:00
parent 4a65003375
commit ddb5e74745
6 changed files with 222 additions and 39 deletions
+1 -1
View File
@@ -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` 字段提示。
@@ -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`
@@ -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. 改动文件清单
@@ -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;
}
@@ -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<String, Object> 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<ProcessRow> 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<User> users = userMapper.selectList(new LambdaQueryWrapper<User>()
.eq(User::getSoftDelete, 0)
@@ -228,6 +262,26 @@ public class WarehouseOrderAdminTools implements ToolObject {
return users.get(0);
}
/** 姓名路径:同名多用户属正常情况,命中多条时报错并附候选,引导改用 newHandlerMobile。 */
private User locateUserByName(String name) {
List<User> users = userMapper.selectList(new LambdaQueryWrapper<User>()
.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<ProcessRow> 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<WarehouseAllocatiProcess>()
.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<WarehouseAllocatiProcess>()
.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<WarehouseAllocation>()
.eq(WarehouseAllocation::getSoftDelete, 0)
.eq(WarehouseAllocation::getId, docId)
.set(WarehouseAllocation::getNextUserName, newHandler.getName())
.set(WarehouseAllocation::getModifyId, callerUserId)
.set(WarehouseAllocation::getModifyTime, now));
yield rows;
}
};
}
@@ -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<String, Object> 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<String, Object> result =
tools.warehouseOrderHandlerUpdate("out", "OUT-1", null, "李四", "姓名改派");
assertEquals(1, result.get("changedCount"));
@SuppressWarnings("unchecked")
List<Map<String, Object>> changedRows = (List<Map<String, Object>>) 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<String, Object> 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<String, Object> 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<LambdaUpdateWrapper<WarehouseAllocation>> 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);