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:
@@ -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 |
|
| `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.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 |
|
| `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` 字段提示。
|
分页统一规范: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
|
```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,对一张测试单改派 → 验证老系统待办归属变化 → 改回。
|
||||||
+45
@@ -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")
|
@TableField("allocation_order_no")
|
||||||
private String allocationOrderNo;
|
private String allocationOrderNo;
|
||||||
private Integer opType;
|
private Integer opType;
|
||||||
|
private Integer orderState;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -10,7 +10,7 @@ import lombok.ToString;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
/** 入库单审批/处理流程(m2,只读)。 */
|
/** 入库单审批/处理流程(m2):WarehouseOrderAdminTools 改派处理人时会 UPDATE 待办行。 */
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@ToString
|
@ToString
|
||||||
@@ -18,6 +18,8 @@ import java.util.Date;
|
|||||||
public class WarehouseInAuditProcess implements Serializable {
|
public class WarehouseInAuditProcess implements Serializable {
|
||||||
@TableId(value = "id", type = IdType.AUTO)
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
private Long modifyId;
|
||||||
|
private Date modifyTime;
|
||||||
private Integer softDelete;
|
private Integer softDelete;
|
||||||
private Long warehouseInId;
|
private Long warehouseInId;
|
||||||
private Long warehouseId;
|
private Long warehouseId;
|
||||||
|
|||||||
+3
-1
@@ -10,7 +10,7 @@ import lombok.ToString;
|
|||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
|
||||||
/** 出库单审批/处理记录(m2,只读)。 */
|
/** 出库单审批/处理记录(m2):WarehouseOrderAdminTools 改派处理人时会 UPDATE 待办行。 */
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@ToString
|
@ToString
|
||||||
@@ -18,6 +18,8 @@ import java.util.Date;
|
|||||||
public class WarehouseOutAuditProcess implements Serializable {
|
public class WarehouseOutAuditProcess implements Serializable {
|
||||||
@TableId(value = "id", type = IdType.AUTO)
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
private Long modifyId;
|
||||||
|
private Date modifyTime;
|
||||||
private Integer softDelete;
|
private Integer softDelete;
|
||||||
private Long warehouseOutId;
|
private Long warehouseOutId;
|
||||||
private Long warehouseId;
|
private Long warehouseId;
|
||||||
|
|||||||
+7
@@ -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> {
|
||||||
|
}
|
||||||
+300
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+235
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user