feat(mcp): 新增仓库单据处理人改派写工具 warehouse.order.handler.update(REQ-20261107-0002)

- 平台首个 kind=write 工具,研发/运维专用 scope 门禁,无行级权限
- 按单号改派出库/入库/调拨/划转单当前待办处理人:UPDATE 流程物理表
  warehouse_out_audit_process / warehouse_in_audit_process / warehouse_allocati_process
  的 schedule_state=1 待办行 user_id/user_name,补 modify_id/modify_time
- 调拨/划转按 warehouse_allocation.op_type(0/2) 一致性校验;并行待办行全部改派
- 新处理人按手机号查 m0 user(soft_delete=0 精确匹配);服务身份 Token 拒绝
- 新增 WarehouseAllocatiProcess 实体+Mapper,WarehouseAllocation 补 orderState 列
- 525 tests 全绿(+10)
This commit is contained in:
my_local_kimi_dev2
2026-11-07 16:28:22 +08:00
parent 1a7a761a66
commit 52ffe8eac2
10 changed files with 771 additions and 2 deletions
+1
View File
@@ -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` 字段提示。
@@ -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
@@ -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,对一张测试单改派 → 验证老系统待办归属变化 → 改回。
@@ -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;
}
@@ -23,4 +23,5 @@ public class WarehouseAllocation implements Serializable {
@TableField("allocation_order_no")
private String allocationOrderNo;
private Integer opType;
private Integer orderState;
}
@@ -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;
@@ -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;
@@ -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<WarehouseAllocatiProcess> {
}
@@ -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<Integer, String> OUT_ORDER_STATE_TEXT = Map.of(
0, "待审核", 1, "被驳回", 2, "待出库", 3, "已完成", 4, "已撤销", 5, "作废");
private static final Map<Integer, String> IN_ORDER_STATE_TEXT = Map.of(
0, "待审核", 1, "被驳回", 2, "待入库", 3, "已完成", 4, "已撤销", 5, "作废");
private static final Map<Integer, String> 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<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 = "改派原因,写入审计日志") 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<ProcessRow> 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<Map<String, Object>> changedRows = new ArrayList<>();
for (ProcessRow row : pendingRows) {
Map<String, Object> 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<String, Object> 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<WarehouseOut>()
.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<WarehouseIn>()
.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<WarehouseAllocation>()
.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> T single(List<T> 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<User> users = userMapper.selectList(new LambdaQueryWrapper<User>()
.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<ProcessRow> pendingRows(String type, Long docId) {
return switch (type) {
case "out" -> outAuditProcessMapper.selectList(new LambdaQueryWrapper<WarehouseOutAuditProcess>()
.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<WarehouseInAuditProcess>()
.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<WarehouseAllocatiProcess>()
.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<WarehouseOutAuditProcess>()
.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<WarehouseInAuditProcess>()
.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<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));
};
}
private static String text(Map<Integer, String> 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) {
}
}
@@ -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<String, Object> 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<Map<String, Object>> changedRows = (List<Map<String, Object>>) 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<String, Object> 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<Map<String, Object>> changedRows = (List<Map<String, Object>>) 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;
}
}