feat(mcp): 新增出库单作废写工具 warehouse.out.void(REQ-20261107-0003)
老系统 /myWarehouseOut/remove 的 MCP 化,只作废已完成(order_state=3)单: 主表/明细置作废+库存原子恢复、商城订单回滚待出库、SN 硬库存回库+LifeCycle 撤销推送、关联入库单级联作废+扣库存、金蝶作废推送、无安装单时送货工单级联软删。 m2TransactionManager 包 m2 写,m1 提交后执行,Redis 失败不回滚。 mallOrderNo 多单需 confirmMulti=true 确认(王恒拍板)。无行级权限,只绑研发专用 scope。
This commit is contained in:
@@ -104,6 +104,7 @@ xiaoqu-agent-service/
|
||||
| `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.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` 字段提示。
|
||||
|
||||
|
||||
@@ -1225,6 +1225,37 @@
|
||||
|
||||
---
|
||||
|
||||
## 53. warehouse.out.void
|
||||
|
||||
出库单作废(**写工具**,REQ-20261107-0003),研发/运维专用。
|
||||
老系统 `/myWarehouseOut/remove`(WarehouseOutController.java:3040-3206)的 MCP 化。
|
||||
|
||||
- 分类:admin | 类型:写(readOnlyHint=false / destructiveHint=true)
|
||||
- 入参:`outOrderNo`(出库单号精确匹配)、`mallOrderNo`(电商订单号,可能命中多单)、
|
||||
至少给一个;`memo`(作废原因,可选);`confirmMulti`(多单确认开关,默认 false)。
|
||||
- 只作废 `order_state=3`(已完成)的单;关联入库单(`warehouse_in.out_order_no`)存在且未完成
|
||||
时该单整单拒绝。
|
||||
- 多单确认:mallOrderNo 命中 ≥2 张已完成单且未传 `confirmMulti=true` 时**全部不作废**,
|
||||
返回 `confirmMultiRequired=true` + `candidates` 候选清单(王恒拍板)。
|
||||
- 全链路 7 步(与老系统逐步对齐):主表置作废 → 明细置 5 + 汇总行恢复库存(原子
|
||||
`current_inventory+delta`)→ SN 行硬库存回库(`warehouse_hard_stock` upsert 在库 +
|
||||
`warehouse_hard_log` op_type=1 + 电池费用化规则:库存商品仓 warehouse_attribute=0 且
|
||||
materiel_index_id=6 且 charge_flag=0 → battery_inventory+1)→ 关联入库单级联作废 +
|
||||
扣库存 → 金蝶作废推送(Redis `SyncJinDieData`,有 mallOrderNo → SAL_SaleOrder_Cancel,
|
||||
否则 STK_MisDelivery_Cancel)→ 无安装单时送货工单级联软删。
|
||||
- 事务(拍板):m2 写包 `m2TransactionManager` 一个事务(每单一事务);
|
||||
m1(mall_order_materials 回写 + mall_goods_order 回滚为待出库 order_state=1、
|
||||
jindie_internal_id=0)在 m2 提交后执行;Redis push(LifeCycle messageType=2 撤销 +
|
||||
SyncJinDieData)最后,失败不回滚主流程、记入 stepResults。
|
||||
- 权限:无行级权限,只绑"研发专用" scope;必须用户身份 Token(作废人落
|
||||
remove_user_name + audit_log),服务身份 PERMISSION_DENIED。作废不可逆。
|
||||
- 返回:逐单 `results[]`(outOrderNo/mallOrderNo/orderId/success/skipped/stepResults[]
|
||||
每步成败)+ `totalCount/successCount/skippedCount/skippedOrders/requestId`。
|
||||
|
||||
详细设计见 [warehouse-out-void-mcp-design](warehouse-out-void-mcp-design.md)。
|
||||
|
||||
---
|
||||
|
||||
## 附录:在线查看工具清单
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# 出库单作废 MCP 工具设计(warehouse.out.void)
|
||||
|
||||
- 需求:REQ-20261107-0003(ai-proj id=6443,项目 224)
|
||||
- 状态:已评审(2026-11-07 王恒拍板,结论见 §5),开发中
|
||||
- 日期:2026-11-07
|
||||
|
||||
## 1. 背景
|
||||
|
||||
研发/运维场景需要把"已完成"的出库单作废(测试数据清理、错单回滚、电商单异常处理等)。
|
||||
老系统(xiaoqu-franchisee)已有完整作废接口 `/myWarehouseOut/remove`
|
||||
(WarehouseOutController.java:3040-3206),本需求把它 MCP 化:
|
||||
**输入出库单号或电商订单号(mall_order_no),执行全链路作废**。
|
||||
|
||||
访问控制同 `warehouse.order.handler.update`(REQ-20261107-0002):零行级权限,
|
||||
门禁完全靠"研发专用" scope 绑定,用户身份 Token 调用(作废人要可追溯)。
|
||||
|
||||
## 2. 调研结论(行号级依据)
|
||||
|
||||
### 2.1 老系统作废全流程(WarehouseOutController.java:3040-3206)
|
||||
|
||||
出库单状态枚举:`0待审核 1被驳回 2待出库 3已完成 4已撤销 5作废`(:3056 注释)。
|
||||
|
||||
**前置校验**:
|
||||
- `order_state` 必须 = 3(已完成),否则拒绝「已完成状态才可执行」(:3057)。
|
||||
- 关联入库单(`warehouse_in.out_order_no` = 本出库单号)若存在且 `order_state != 3` →
|
||||
整个作废被拒绝「关联的入库单未完成,不能作废」(:3060-3067)。
|
||||
|
||||
**执行步骤(老系统无 @Transactional,顺序执行)**:
|
||||
|
||||
| # | 步骤 | 涉及表/资源 | 库 |
|
||||
|---|---|---|---|
|
||||
| 1 | 主表置作废:`order_state=5` + `remove_date`/`remove_user_name` + `modify_*` | warehouse_out | m2 |
|
||||
| 2 | 明细全部 `order_state=5`;`parent_id=0` 汇总行恢复库存 `updateStock(+out_count)`(作废路径不回写明细快照,:3087 注释);`mall_order_detail_id != 0` 时回写 mall_order_materials:`not_out_count+`、`been_out_count-`、`warehouse_state`(beenOut=0→0未出库,否则→2部分出库) | warehouse_out_details → warehouse_stock_manage / mall_order_materials | m2 → m1 |
|
||||
| 3 | `mall_order_no` 非空:按 mall_order_materials 明细汇总 warehouse_state(有 2→部分出库;无 2 且无 1→0未出库;否则→2),`mall_goods_order.order_state=1`、`jindie_internal_id=0` | mall_goods_order | m1 |
|
||||
| 4 | SN 行(`parent_id != 0` 且 sn_code 非空):`newUpdateStock` 硬库存恢复——warehouse_hard_stock upsert(stock_state=0 在库、current_inventory=1)+ warehouse_hard_log(op_type=1);含电池费用化库存规则(库存商品仓 warehouse_attribute=0 且 materiel_index_id=6 且 charge_flag=0 → battery_inventory+1,WarehouseStockManageServiceImpl.java:255-265);随后 `revokeLifeCycle`:Redis list `LifeCycle` push `{snCode, messageType:2, memo}` | warehouse_hard_stock / warehouse_hard_log / warehouse_stock_manage + Redis | m2 + Redis |
|
||||
| 5 | 关联入库单级联作废:`order_state=5` + remove_*;明细 `order_state=5`、`good_in_state=5`;每行 `updateStock(-in_count)` | warehouse_in / warehouse_in_details → warehouse_stock_manage | m2 |
|
||||
| 6 | `jindie_internal_id != 0`:Redis list `SyncJinDieData` push(有 mall_order_no → `SAL_SaleOrder_Cancel`,否则 → `STK_MisDelivery_Cancel`,businessDataId=out_order_no) | Redis | Redis |
|
||||
| 7 | `install_order_no` 为空且 `delivery_order_no` 非空:`cancelDeliveryOrder` 软删 delivery_order + install_distribute_details + delivery_order_process(PublicService.java:2672-2695) | 三张表 soft_delete=1 | m2 |
|
||||
|
||||
### 2.2 agent-service 现状盘点
|
||||
|
||||
**已有(直接复用)**:
|
||||
- 实体/Mapper:`WarehouseOut`、`WarehouseOutDetails`(含 parentId/snCode/mallOrderDetailId 全字段)、
|
||||
`WarehouseIn`、`WarehouseInDetails`(含 goodInState/removeDate/removeUserName)、
|
||||
`WarehouseStockManage`、`Warehouse`、`DeliveryOrder`、`DeliveryOrderProcess`、
|
||||
`InstallDistributeDetail`(m2);`MallGoodsOrder`(含 orderState/warehouseState/jindieInternalId)、
|
||||
`MallOrderMaterial`(m1,mall_order_materials,含 notOutCount/beenOutCount/warehouseState)。
|
||||
- Redis:`StringRedisTemplate` 与 franchisee 同实例同库(已由 StatisticsDays 读取链路生产实证),
|
||||
`LifeCycle` / `SyncJinDieData` push 可直接用同一 template。
|
||||
- 写工具范式:`WarehouseOrderAdminTools`(kind=write / category=admin / ToolCallException 透出 /
|
||||
用户身份强制 / requestId 日志),本工具完全沿用。
|
||||
|
||||
**缺口(需新增)**:
|
||||
- `WarehouseHardStock` / `WarehouseHardLog` 实体 + Mapper(m2,全列本地拷贝,
|
||||
参照 beans/b2/WarehouseHardStock.java、WarehouseHardLog.java)。
|
||||
- `Warehouse` 本地实体缺 `agentId` / `warehouseAttribute` 列(硬库存恢复与电池规则需要),需扩列。
|
||||
- `WarehouseStockManageMapper` 缺原子库存更新 SQL(用户变量方案 `updateAndAssignCurrentInventory`),
|
||||
需从 publicbeanandmap 移植(m2 库内 UPDATE `warehouse_stock_manage`)。
|
||||
- m2 数据源**没有事务管理器**(只有 svc 有 svcTransactionManager,config/M2DataSourceConfig.java)——
|
||||
见拍板结论 2。
|
||||
|
||||
### 2.3 电商订单号的语义
|
||||
|
||||
`warehouse_out.mall_order_no` 即电商(商城)订单号。一个电商订单可多次部分出库 →
|
||||
**一个 mallOrderNo 可对应多张出库单**。按电商订单号作废时必须处理多单,见拍板结论 1。
|
||||
|
||||
## 3. 工具设计
|
||||
|
||||
### 3.1 工具定义(1 个工具)
|
||||
|
||||
```
|
||||
warehouse.out.void kind=write category=admin
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---|---|
|
||||
| outOrderNo | 条件必填 | 出库单号(精确匹配,去空格)。与 mallOrderNo 至少给一个 |
|
||||
| mallOrderNo | 条件必填 | 电商订单号(mall_order_no 精确匹配)。两个都给时以 outOrderNo 为准并校验一致性 |
|
||||
| memo | 否 | 作废原因,写入审计日志与返回 |
|
||||
| confirmMulti | 否 | mallOrderNo 命中多张已完成出库单时的确认开关:默认 false=不作废只返回候选清单;true=逐张作废 |
|
||||
|
||||
行为:
|
||||
1. 定位单据:`soft_delete=0` 精确匹配;不存在 → `NOT_FOUND_OR_FORBIDDEN`。
|
||||
2. 多单确认:mallOrderNo 命中 ≥2 张 `order_state=3` 出库单且 `confirmMulti != true` →
|
||||
全部不作废,返回候选清单 + `confirmMultiRequired: true`(见拍板结论 1)。
|
||||
3. 前置校验(每张目标单):`order_state=3`;关联入库单存在则其 `order_state` 也必须=3(否则该单跳过并报原因)。
|
||||
4. 按 §2.1 步骤 1-7 全链路执行(步骤 4 的 Redis LifeCycle、步骤 6 的金蝶 push 放最后,失败不回滚主流程、记入 stepResults)。
|
||||
5. 要求用户身份 Token(caller user 写入 remove_user_name/modify_id/审计),服务 Token 拒绝。
|
||||
6. 返回逐单 stepResults(before/after + 每步成败)。
|
||||
|
||||
返回结构(多单时 results 为数组):
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"outOrderNo": "OUT20261001001",
|
||||
"mallOrderNo": "MO20261001001",
|
||||
"orderId": 12345,
|
||||
"success": true,
|
||||
"stepResults": [
|
||||
{"step": "outOrderVoid", "success": true, "detail": "orderState 3→5"},
|
||||
{"step": "detailsAndStockRestore", "success": true, "detail": "明细3行,恢复库存2项"},
|
||||
{"step": "mallOrderRollback", "success": true, "detail": "mall_goods_order 回到待出库"},
|
||||
{"step": "hardStockRestore", "success": true, "detail": "SN 5 个回库,LifeCycle 已推送"},
|
||||
{"step": "linkedInOrderVoid", "success": true, "detail": "入库单 IN... 级联作废,扣减库存3项"},
|
||||
{"step": "jindieSyncPush", "success": true, "detail": "STK_MisDelivery_Cancel"},
|
||||
{"step": "deliveryOrderCancel", "success": false, "detail": "无关联送货工单,跳过"}
|
||||
],
|
||||
"skipped": false
|
||||
}
|
||||
],
|
||||
"totalCount": 1,
|
||||
"successCount": 1,
|
||||
"requestId": "..."
|
||||
}
|
||||
```
|
||||
|
||||
报错沿用 ToolCallException 透出风格(INVALID_ARGUMENT / NOT_FOUND_OR_FORBIDDEN / DATA_INTEGRITY_ERROR)。
|
||||
|
||||
### 3.2 权限与作用域
|
||||
|
||||
- **不做任何行级权限**,任何出库单都可作废。
|
||||
- 上线后**只绑"研发专用" scope**(不绑 1/2/3/5,seed-dev.sql 不预绑)。
|
||||
- 必须用户身份 Token 调用(作废人落 remove_user_name + audit_log 可追溯);服务 Token `PERMISSION_DENIED`。
|
||||
- 审计兜底:GuardedToolCallback 自动写 svc audit_log + 工具内 requestId 逐单日志。
|
||||
|
||||
## 4. 与老系统差异 / 风险说明
|
||||
|
||||
1. **事务**:老系统无事务(步骤中断会留半截状态)。MCP 版见拍板结论 2。
|
||||
2. **多单处理**:老系统按单号一次一单;MCP 版支持电商订单号命中多单逐单作废(逐单独立成败)。
|
||||
3. **作废后不可逆**:老系统同样无"反作废"。返回 stepResults + audit_log 是仅有的追溯手段。
|
||||
4. **Redis 依赖**:LifeCycle / SyncJinDieData push 失败不回滚主流程(老系统是 @Async 异步,本来也不保证);stepResults 透出失败供人工补推。
|
||||
5. **库存恢复不回写明细快照**:沿用老系统作废路径决策(:3087 注释明确不回写)。
|
||||
6. 送货工单只在 `install_order_no` 为空时才级联软删(有安装单的出库单不动送货单),与老系统一致。
|
||||
|
||||
## 5. 拍板结论(2026-11-07 王恒评审)
|
||||
|
||||
1. **电商订单号命中多单:多单不自动作废,必须显式确认**。
|
||||
落地:mallOrderNo 命中 ≥2 张 `order_state=3` 出库单时,默认**全部不作废**,
|
||||
返回候选清单(每张 outOrderNo/orderState/出库时间),调用方需传 `confirmMulti=true`
|
||||
才会逐张作废;或改用精确 outOrderNo 逐张作废。非已完成单无论是否确认都跳过并报原因。
|
||||
2. **事务:按建议新增 `m2TransactionManager`**。步骤 1/2(库存)/4(硬库存)/5/7(全是 m2)包一个事务,
|
||||
m1(步骤 2 的 mall_order_materials、步骤 3)在 m2 提交后执行,Redis push 最后;
|
||||
失败按 stepResults 透出已完成步骤。
|
||||
3. **关联入库单:按建议沿用老系统**——关联入库单未完成(order_state != 3)时该出库单整单拒绝
|
||||
(success=false + 原因),关联入库单已完成则级联作废。
|
||||
4. **dryRun:不加**(stepResults 已透出全部动作)。
|
||||
5. **消息通知:不发**(运维动作;老系统作废也不发)。
|
||||
|
||||
## 6. 改动文件清单
|
||||
|
||||
| 文件 | 动作 |
|
||||
|---|---|
|
||||
| `entity/m2/WarehouseHardStock.java` + `mapper/m2/WarehouseHardStockMapper.java` | 新增(全列本地拷贝) |
|
||||
| `entity/m2/WarehouseHardLog.java` + `mapper/m2/WarehouseHardLogMapper.java` | 新增 |
|
||||
| `entity/m2/Warehouse.java` | 扩列 agentId / warehouseAttribute |
|
||||
| `entity/m2/WarehouseOut.java` | 扩列 jindieInternalId(金蝶推送前置) |
|
||||
| `mapper/m2/WarehouseStockManageMapper.java` | 新增原子库存更新 updateCurrentInventory / updateBatteryInventory(@Update 注解,移植用户变量方案语义,无 XML 基建故走注解) |
|
||||
| `config/M2DataSourceConfig.java` | 新增 m2TransactionManager(拍板结论 2) |
|
||||
| `mcp/tool/WarehouseOutVoidTools.java` | 新增(1 个工具方法) |
|
||||
| `mcp/tool/WarehouseOutVoidToolsTest.java` | 新增(参数校验/单号不存在/非已完成/关联入库单未完成/库存恢复/硬库存 SN/电商多单/送货工单级联/Redis push 失败容忍) |
|
||||
| `doc/agent-mcp-tools-reference.md`、`README.md` | 登记 |
|
||||
|
||||
装配层零改动(组件扫描自动注册)。**seed-dev.sql 不预绑 scope**。
|
||||
|
||||
## 7. 上线步骤
|
||||
|
||||
1. 全量 `./mvnw clean package` 绿 → push main → remote-deploy。
|
||||
2. prod svc 库只绑"研发专用" scope(INSERT IGNORE scope_tool),不绑 1/2/3/5。
|
||||
3. 生产冒烟:专用 scope 签用户 Token → 造一张已完成测试出库单 → 作废 →
|
||||
核验主表/明细/库存/硬库存/老系统列表页状态 → (可选)重新出库恢复。
|
||||
@@ -10,6 +10,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.jdbc.DataSourceBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
@@ -38,4 +40,10 @@ public class M2DataSourceConfig {
|
||||
bean.setPlugins(syncQueryTimeoutInterceptor);
|
||||
return bean.getObject();
|
||||
}
|
||||
|
||||
/** m2 写工具(出库作废等)专用事务管理器(拍板结论 2,REQ-20261107-0003)。 */
|
||||
@Bean(name = "m2TransactionManager")
|
||||
public PlatformTransactionManager m2TransactionManager(@Qualifier("m2DataSource") DataSource dataSource) {
|
||||
return new DataSourceTransactionManager(dataSource);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,4 +23,8 @@ public class Warehouse implements Serializable {
|
||||
private Integer softDelete;
|
||||
private String warehouseNo;
|
||||
private String warehouseName;
|
||||
/** 仓库管理公司ID(硬库存恢复需要) */
|
||||
private Long agentId;
|
||||
/** 仓库属性(0:库存商品仓;),电池费用化库存规则需要 */
|
||||
private Integer warehouseAttribute;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
|
||||
/** 设备日志(m2.warehouse_hard_log),全列拷贝自 b2 参考实体。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("warehouse_hard_log")
|
||||
public class WarehouseHardLog implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@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 agentId;
|
||||
private String agentName;
|
||||
private String outInOrderNo;
|
||||
private Long materielDefinitionId;
|
||||
private String materialNo;
|
||||
private String materielFullName;
|
||||
private String materielSort;
|
||||
private Long materielIndexId;
|
||||
private String specifications;
|
||||
private String snCode;
|
||||
private Long warehouseId;
|
||||
private String warehouseName;
|
||||
/** 操作类型(0:出库;1:入库;2:撤销) */
|
||||
private Integer opType;
|
||||
private Date opTime;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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;
|
||||
|
||||
/** 设备库存(m2.warehouse_hard_stock),全列拷贝自 b2 参考实体。 */
|
||||
@Getter
|
||||
@Setter
|
||||
@ToString
|
||||
@TableName("warehouse_hard_stock")
|
||||
public class WarehouseHardStock implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@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 agentId;
|
||||
private String agentName;
|
||||
private Long warehouseId;
|
||||
private String warehouseName;
|
||||
private String outInOrderNo;
|
||||
private Long materielDefinitionId;
|
||||
private String materialNo;
|
||||
private String materielFullName;
|
||||
private String materielSort;
|
||||
private Long materielIndexId;
|
||||
private String specifications;
|
||||
private String snCode;
|
||||
private Date outInDate;
|
||||
/** 库存状态(0:在库;1:已出库;) */
|
||||
private Integer stockState;
|
||||
private Integer currentInventory;
|
||||
private Integer useCustomerType;
|
||||
private Long useCustomerId;
|
||||
private String useCustomerName;
|
||||
private Integer assembleState;
|
||||
private Long warehouseOwnerId;
|
||||
private String warehouseOwnerName;
|
||||
private Integer replaceState;
|
||||
/** 费用化标识(0:费用化;1:非费用化;) */
|
||||
private Integer chargeFlag;
|
||||
}
|
||||
@@ -58,4 +58,6 @@ public class WarehouseOut implements Serializable {
|
||||
private String deliveryOrderNo;
|
||||
private String installOrderNo;
|
||||
private String pickupOrderNo;
|
||||
/** 金蝶系统内码(作废推送 SyncJinDieData 的前置条件) */
|
||||
private Long jindieInternalId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package me.iiv.agent.mapper.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m2.WarehouseHardLog;
|
||||
|
||||
/** 设备日志 Mapper。 */
|
||||
public interface WarehouseHardLogMapper extends BaseMapper<WarehouseHardLog> {
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package me.iiv.agent.mapper.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m2.WarehouseHardStock;
|
||||
|
||||
/** 设备库存 Mapper。 */
|
||||
public interface WarehouseHardStockMapper extends BaseMapper<WarehouseHardStock> {
|
||||
}
|
||||
+19
@@ -2,7 +2,26 @@ package me.iiv.agent.mapper.m2;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import me.iiv.agent.entity.m2.WarehouseStockManage;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
/** 库存管理 Mapper。 */
|
||||
public interface WarehouseStockManageMapper extends BaseMapper<WarehouseStockManage> {
|
||||
|
||||
/**
|
||||
* 原子更新 current_inventory(移植自 publicbeanandmap 用户变量方案的语义):
|
||||
* 故意不带扣负校验,与老系统 setSql 行为一致——作废/撤销路径允许扣成负数。
|
||||
*
|
||||
* @return 影响行数;0 表示仓库行不存在
|
||||
*/
|
||||
@Update("UPDATE warehouse_stock_manage"
|
||||
+ " SET current_inventory = current_inventory + #{delta}, modify_time = NOW()"
|
||||
+ " WHERE materiel_definition_id = #{mdId} AND warehouse_id = #{whId} AND soft_delete = 0")
|
||||
int updateCurrentInventory(@Param("mdId") Long mdId, @Param("whId") Long whId, @Param("delta") int delta);
|
||||
|
||||
/** 电池费用化库存原子更新(库存商品仓 + 电池物料 + 费用化时才调用)。 */
|
||||
@Update("UPDATE warehouse_stock_manage"
|
||||
+ " SET battery_inventory = battery_inventory + #{delta}, modify_time = NOW()"
|
||||
+ " WHERE materiel_definition_id = #{mdId} AND warehouse_id = #{whId} AND soft_delete = 0")
|
||||
int updateBatteryInventory(@Param("mdId") Long mdId, @Param("whId") Long whId, @Param("delta") int delta);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,679 @@
|
||||
package me.iiv.agent.mcp.tool;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import me.iiv.agent.entity.m0.User;
|
||||
import me.iiv.agent.entity.m1.MallGoodsOrder;
|
||||
import me.iiv.agent.entity.m1.MallOrderMaterial;
|
||||
import me.iiv.agent.entity.m2.DeliveryOrder;
|
||||
import me.iiv.agent.entity.m2.DeliveryOrderProcess;
|
||||
import me.iiv.agent.entity.m2.InstallDistributeDetail;
|
||||
import me.iiv.agent.entity.m2.Warehouse;
|
||||
import me.iiv.agent.entity.m2.WarehouseHardLog;
|
||||
import me.iiv.agent.entity.m2.WarehouseHardStock;
|
||||
import me.iiv.agent.entity.m2.WarehouseIn;
|
||||
import me.iiv.agent.entity.m2.WarehouseInDetails;
|
||||
import me.iiv.agent.entity.m2.WarehouseOut;
|
||||
import me.iiv.agent.entity.m2.WarehouseOutDetails;
|
||||
import me.iiv.agent.mapper.m1.MallGoodsOrderMapper;
|
||||
import me.iiv.agent.mapper.m1.MallOrderMaterialMapper;
|
||||
import me.iiv.agent.mapper.m2.DeliveryOrderMapper;
|
||||
import me.iiv.agent.mapper.m2.DeliveryOrderProcessMapper;
|
||||
import me.iiv.agent.mapper.m2.InstallDistributeDetailMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseHardLogMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseHardStockMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseInDetailsMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseInMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseOutDetailsMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseOutMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseStockManageMapper;
|
||||
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.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
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-0003,设计文档 doc/warehouse-out-void-mcp-design.md)。
|
||||
* 老系统 /myWarehouseOut/remove(WarehouseOutController.java:3040-3206)的 MCP 化:
|
||||
* 只作废"已完成"(order_state=3) 的出库单,全链路 7 步与老系统逐步对齐。
|
||||
* 零行级权限,门禁靠"研发专用" scope;必须用户身份 Token(作废人落 remove_user_name + 审计)。
|
||||
*
|
||||
* 拍板结论:① mallOrderNo 命中多张已完成单时必须 confirmMulti=true 才作废,否则只回候选清单;
|
||||
* ② m2 写(主表/明细/库存/硬库存/入库单级联/送货工单)包 m2TransactionManager 一个事务,
|
||||
* m1(商城回写)在 m2 提交后执行,Redis push 最后且失败不回滚主流程;
|
||||
* ③ 关联入库单未完成则该单整单拒绝;④ 无 dryRun;⑤ 不发消息通知。
|
||||
*/
|
||||
@Component
|
||||
public class WarehouseOutVoidTools implements ToolObject {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WarehouseOutVoidTools.class);
|
||||
|
||||
private static final int ORDER_STATE_DONE = 3;
|
||||
private static final int ORDER_STATE_VOID = 5;
|
||||
/** 库存商品仓 + 电池物料索引 + 费用化 → 费用化库存联动(老系统 2025-11-18 规则)。 */
|
||||
private static final int WAREHOUSE_ATTRIBUTE_STOCK = 0;
|
||||
private static final long MATERIEL_INDEX_BATTERY = 6L;
|
||||
private static final int CHARGE_FLAG_EXPENSED = 0;
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
|
||||
final WarehouseOutMapper outMapper;
|
||||
final WarehouseOutDetailsMapper outDetailsMapper;
|
||||
final WarehouseInMapper inMapper;
|
||||
final WarehouseInDetailsMapper inDetailsMapper;
|
||||
final WarehouseStockManageMapper stockManageMapper;
|
||||
final WarehouseHardStockMapper hardStockMapper;
|
||||
final WarehouseHardLogMapper hardLogMapper;
|
||||
final WarehouseMapper warehouseMapper;
|
||||
final DeliveryOrderMapper deliveryOrderMapper;
|
||||
final DeliveryOrderProcessMapper deliveryOrderProcessMapper;
|
||||
final InstallDistributeDetailMapper installDistributeDetailMapper;
|
||||
final MallGoodsOrderMapper mallGoodsOrderMapper;
|
||||
final MallOrderMaterialMapper mallOrderMaterialMapper;
|
||||
final StringRedisTemplate redisTemplate;
|
||||
final TransactionTemplate m2Tx;
|
||||
|
||||
public WarehouseOutVoidTools(WarehouseOutMapper outMapper,
|
||||
WarehouseOutDetailsMapper outDetailsMapper,
|
||||
WarehouseInMapper inMapper,
|
||||
WarehouseInDetailsMapper inDetailsMapper,
|
||||
WarehouseStockManageMapper stockManageMapper,
|
||||
WarehouseHardStockMapper hardStockMapper,
|
||||
WarehouseHardLogMapper hardLogMapper,
|
||||
WarehouseMapper warehouseMapper,
|
||||
DeliveryOrderMapper deliveryOrderMapper,
|
||||
DeliveryOrderProcessMapper deliveryOrderProcessMapper,
|
||||
InstallDistributeDetailMapper installDistributeDetailMapper,
|
||||
MallGoodsOrderMapper mallGoodsOrderMapper,
|
||||
MallOrderMaterialMapper mallOrderMaterialMapper,
|
||||
StringRedisTemplate redisTemplate,
|
||||
@Qualifier("m2TransactionManager") PlatformTransactionManager m2TransactionManager) {
|
||||
this.outMapper = outMapper;
|
||||
this.outDetailsMapper = outDetailsMapper;
|
||||
this.inMapper = inMapper;
|
||||
this.inDetailsMapper = inDetailsMapper;
|
||||
this.stockManageMapper = stockManageMapper;
|
||||
this.hardStockMapper = hardStockMapper;
|
||||
this.hardLogMapper = hardLogMapper;
|
||||
this.warehouseMapper = warehouseMapper;
|
||||
this.deliveryOrderMapper = deliveryOrderMapper;
|
||||
this.deliveryOrderProcessMapper = deliveryOrderProcessMapper;
|
||||
this.installDistributeDetailMapper = installDistributeDetailMapper;
|
||||
this.mallGoodsOrderMapper = mallGoodsOrderMapper;
|
||||
this.mallOrderMaterialMapper = mallOrderMaterialMapper;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.m2Tx = new TransactionTemplate(m2TransactionManager);
|
||||
}
|
||||
|
||||
@XqTool(category = "admin", kind = "write")
|
||||
@Tool(name = "warehouse.out.void", description = "【写操作·研发/运维专用】作废一张或多张『已完成』的出库单,"
|
||||
+ "按出库单号(outOrderNo)或电商订单号(mallOrderNo)定位(至少给一个)。"
|
||||
+ "全链路与老系统出库作废一致:主表/明细置作废、恢复库存、商城订单回滚为待出库、"
|
||||
+ "SN 硬库存回库并推送生命周期撤销、关联入库单级联作废并扣减库存、金蝶作废推送、"
|
||||
+ "无安装单时送货工单级联软删。只作废 order_state=3(已完成)的单;"
|
||||
+ "关联入库单未完成时该单整单拒绝。作废不可逆。"
|
||||
+ "mallOrderNo 命中多张已完成出库单时默认不作废,返回候选清单,"
|
||||
+ "需 confirmMulti=true 确认后逐张作废,或改用精确 outOrderNo。"
|
||||
+ "无行级权限校验;必须用户身份 Token(作废人可追溯),每次调用写审计日志。"
|
||||
+ "返回逐单 stepResults(每步成败透出,Redis 推送失败不回滚主流程)。")
|
||||
public Map<String, Object> warehouseOutVoid(
|
||||
@ToolParam(required = false, description = "出库单号(精确匹配)。与 mallOrderNo 至少给一个;都给时以此为准并校验一致性") String outOrderNo,
|
||||
@ToolParam(required = false, description = "电商订单号(mall_order_no 精确匹配),可能命中多张出库单") String mallOrderNo,
|
||||
@ToolParam(required = false, description = "作废原因,写入审计日志与返回") String memo,
|
||||
@ToolParam(required = false, description = "mallOrderNo 命中多张已完成出库单时的确认开关:默认 false 只返回候选清单不作废,true 逐张作废") Boolean confirmMulti) {
|
||||
|
||||
String outNo = StringUtils.hasText(outOrderNo) ? outOrderNo.trim() : null;
|
||||
String mallNo = StringUtils.hasText(mallOrderNo) ? mallOrderNo.trim() : null;
|
||||
if (outNo == null && mallNo == null) {
|
||||
throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT,
|
||||
"outOrderNo 与 mallOrderNo 至少给一个");
|
||||
}
|
||||
|
||||
User caller = AuthContextHolder.get().getUser();
|
||||
if (caller == null) {
|
||||
throw new ToolCallException(McpErrorCode.PERMISSION_DENIED,
|
||||
"该工具要求用户身份 Token 调用(作废人需可追溯),服务身份 Token 不可用");
|
||||
}
|
||||
|
||||
List<WarehouseOut> orders = locateOrders(outNo, mallNo);
|
||||
|
||||
List<WarehouseOut> voidable = new ArrayList<>();
|
||||
List<Map<String, Object>> skippedOrders = new ArrayList<>();
|
||||
for (WarehouseOut order : orders) {
|
||||
if (order.getOrderState() != null && order.getOrderState() == ORDER_STATE_DONE) {
|
||||
voidable.add(order);
|
||||
} else {
|
||||
skippedOrders.add(orderBrief(order, "orderState=" + orderStateText(order.getOrderState())
|
||||
+ ",只作废已完成(order_state=3)的单"));
|
||||
}
|
||||
}
|
||||
if (voidable.isEmpty()) {
|
||||
throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN,
|
||||
"没有可作废的出库单(只作废已完成 order_state=3 的单)");
|
||||
}
|
||||
|
||||
String requestId = McpCallContextHolder.currentOrCreateRequestId();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
|
||||
// 拍板结论 1:多单必须显式确认
|
||||
if (outNo == null && voidable.size() > 1 && !Boolean.TRUE.equals(confirmMulti)) {
|
||||
List<Map<String, Object>> candidates = new ArrayList<>();
|
||||
for (WarehouseOut order : voidable) {
|
||||
candidates.add(orderBrief(order, null));
|
||||
}
|
||||
result.put("confirmMultiRequired", true);
|
||||
result.put("candidates", candidates);
|
||||
result.put("skippedOrders", skippedOrders);
|
||||
result.put("totalCount", 0);
|
||||
result.put("successCount", 0);
|
||||
result.put("requestId", requestId);
|
||||
log.info("warehouse.out.void 多单待确认 requestId={} mallOrderNo={} candidates={} caller={}",
|
||||
requestId, mallNo, voidable.size(), caller.getId());
|
||||
return result;
|
||||
}
|
||||
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
int successCount = 0;
|
||||
int skippedCount = 0;
|
||||
for (WarehouseOut order : voidable) {
|
||||
Map<String, Object> one = voidOneOrder(order, caller, memo, requestId);
|
||||
results.add(one);
|
||||
if (Boolean.TRUE.equals(one.get("success"))) {
|
||||
successCount++;
|
||||
}
|
||||
if (Boolean.TRUE.equals(one.get("skipped"))) {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.put("results", results);
|
||||
result.put("skippedOrders", skippedOrders);
|
||||
result.put("totalCount", results.size());
|
||||
result.put("successCount", successCount);
|
||||
result.put("skippedCount", skippedCount);
|
||||
result.put("memo", memo);
|
||||
result.put("requestId", requestId);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 按单号定位:outOrderNo 精确单张;否则 mallOrderNo 全量(soft_delete=0)。 */
|
||||
private List<WarehouseOut> locateOrders(String outNo, String mallNo) {
|
||||
if (outNo != null) {
|
||||
List<WarehouseOut> rows = outMapper.selectList(new LambdaQueryWrapper<WarehouseOut>()
|
||||
.eq(WarehouseOut::getSoftDelete, 0)
|
||||
.eq(WarehouseOut::getOutOrderNo, outNo));
|
||||
if (rows.isEmpty()) {
|
||||
throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN,
|
||||
"出库单号 " + outNo + " 不存在");
|
||||
}
|
||||
if (rows.size() > 1) {
|
||||
throw new ToolCallException(McpErrorCode.DATA_INTEGRITY_ERROR,
|
||||
"出库单号 " + outNo + " 命中多条记录,请携带 request_id 联系运维排查");
|
||||
}
|
||||
WarehouseOut order = rows.get(0);
|
||||
if (mallNo != null && !mallNo.equals(order.getMallOrderNo())) {
|
||||
throw new ToolCallException(McpErrorCode.INVALID_ARGUMENT,
|
||||
"出库单 " + outNo + " 的电商订单号是 " + order.getMallOrderNo()
|
||||
+ ",与入参 mallOrderNo=" + mallNo + " 不一致");
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
List<WarehouseOut> rows = outMapper.selectList(new LambdaQueryWrapper<WarehouseOut>()
|
||||
.eq(WarehouseOut::getSoftDelete, 0)
|
||||
.eq(WarehouseOut::getMallOrderNo, mallNo)
|
||||
.orderByAsc(WarehouseOut::getId));
|
||||
if (rows.isEmpty()) {
|
||||
throw new ToolCallException(McpErrorCode.NOT_FOUND_OR_FORBIDDEN,
|
||||
"电商订单号 " + mallNo + " 没有对应出库单");
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 单张出库单全链路作废。m2 写一个事务;m1 商城回写在提交后;Redis push 最后且失败只记 stepResults。 */
|
||||
private Map<String, Object> voidOneOrder(WarehouseOut order, User caller, String memo, String requestId) {
|
||||
Map<String, Object> one = new LinkedHashMap<>();
|
||||
one.put("outOrderNo", order.getOutOrderNo());
|
||||
one.put("mallOrderNo", order.getMallOrderNo());
|
||||
one.put("orderId", order.getId());
|
||||
List<Map<String, Object>> stepResults = new ArrayList<>();
|
||||
one.put("stepResults", stepResults);
|
||||
|
||||
// 前置校验:关联入库单未完成则整单拒绝(拍板结论 3)
|
||||
WarehouseIn linkedIn = inMapper.selectOne(new LambdaQueryWrapper<WarehouseIn>()
|
||||
.eq(WarehouseIn::getSoftDelete, 0)
|
||||
.eq(WarehouseIn::getOutOrderNo, order.getOutOrderNo())
|
||||
.last("LIMIT 1"));
|
||||
if (linkedIn != null && (linkedIn.getOrderState() == null || linkedIn.getOrderState() != ORDER_STATE_DONE)) {
|
||||
one.put("success", false);
|
||||
one.put("skipped", true);
|
||||
one.put("skipReason", "关联的入库单 " + linkedIn.getInOrderNo() + " 未完成"
|
||||
+ "(orderState=" + linkedIn.getOrderState() + "),不能作废");
|
||||
return one;
|
||||
}
|
||||
|
||||
List<MallMaterialRestore> mallRestores = new ArrayList<>();
|
||||
List<String> snCodes = new ArrayList<>();
|
||||
try {
|
||||
m2Tx.executeWithoutResult(status -> {
|
||||
stepResults.add(step("outOrderVoid", true, voidOutOrder(order, caller)));
|
||||
stepResults.add(step("detailsAndStockRestore", true,
|
||||
voidDetailsAndRestoreStock(order, mallRestores)));
|
||||
stepResults.add(step("hardStockRestore", true,
|
||||
restoreHardStock(order, caller, snCodes)));
|
||||
stepResults.add(step("linkedInOrderVoid", true,
|
||||
voidLinkedInOrder(linkedIn, caller)));
|
||||
stepResults.add(step("deliveryOrderCancel", true, cancelDeliveryOrder(order)));
|
||||
});
|
||||
} catch (RuntimeException e) {
|
||||
log.error("warehouse.out.void m2 事务回滚 requestId={} outOrderNo={} error={}",
|
||||
requestId, order.getOutOrderNo(), e.getMessage(), e);
|
||||
one.put("success", false);
|
||||
one.put("skipped", false);
|
||||
one.put("error", "m2 事务回滚:" + e.getMessage());
|
||||
return one;
|
||||
}
|
||||
|
||||
// m2 已提交。m1 商城回写失败不回滚主流程(拍板结论 2)
|
||||
try {
|
||||
stepResults.add(step("mallOrderRollback", true, rollbackMallOrder(order, mallRestores)));
|
||||
} catch (RuntimeException e) {
|
||||
log.error("warehouse.out.void 商城回滚失败 requestId={} outOrderNo={} error={}",
|
||||
requestId, order.getOutOrderNo(), e.getMessage(), e);
|
||||
stepResults.add(step("mallOrderRollback", false, "商城回写失败:" + e.getMessage()));
|
||||
}
|
||||
|
||||
// Redis push 最后,失败只记 stepResults(老系统是 @Async 异步,本就不保证)
|
||||
try {
|
||||
stepResults.add(step("lifeCyclePush", true, pushLifeCycle(snCodes)));
|
||||
} catch (RuntimeException e) {
|
||||
log.error("warehouse.out.void LifeCycle 推送失败 requestId={} outOrderNo={} error={}",
|
||||
requestId, order.getOutOrderNo(), e.getMessage(), e);
|
||||
stepResults.add(step("lifeCyclePush", false, "LifeCycle 推送失败:" + e.getMessage()));
|
||||
}
|
||||
try {
|
||||
stepResults.add(step("jindieSyncPush", true, pushJindieSync(order)));
|
||||
} catch (RuntimeException e) {
|
||||
log.error("warehouse.out.void 金蝶推送失败 requestId={} outOrderNo={} error={}",
|
||||
requestId, order.getOutOrderNo(), e.getMessage(), e);
|
||||
stepResults.add(step("jindieSyncPush", false, "金蝶推送失败:" + e.getMessage()));
|
||||
}
|
||||
|
||||
one.put("success", true);
|
||||
one.put("skipped", false);
|
||||
log.info("warehouse.out.void 作废完成 requestId={} outOrderNo={} orderId={} memo={} caller={}",
|
||||
requestId, order.getOutOrderNo(), order.getId(), memo, caller.getId());
|
||||
return one;
|
||||
}
|
||||
|
||||
/** 步骤 1:主表置作废。 */
|
||||
private String voidOutOrder(WarehouseOut order, User caller) {
|
||||
int updated = outMapper.update(null, new LambdaUpdateWrapper<WarehouseOut>()
|
||||
.eq(WarehouseOut::getId, order.getId())
|
||||
.set(WarehouseOut::getOrderState, ORDER_STATE_VOID)
|
||||
.set(WarehouseOut::getRemoveDate, new Date())
|
||||
.set(WarehouseOut::getRemoveUserName, caller.getName())
|
||||
.set(WarehouseOut::getModifyId, caller.getId())
|
||||
.set(WarehouseOut::getModifyTime, new Date()));
|
||||
if (updated != 1) {
|
||||
throw new IllegalStateException("主表置作废影响行数=" + updated + ",预期 1");
|
||||
}
|
||||
return "orderState 3→5";
|
||||
}
|
||||
|
||||
/** 步骤 2:明细置作废 + 汇总行(parent_id=0)恢复库存 + 收集商城回写(m1 延迟到提交后)。 */
|
||||
private String voidDetailsAndRestoreStock(WarehouseOut order, List<MallMaterialRestore> mallRestores) {
|
||||
List<WarehouseOutDetails> details = outDetailsMapper.selectList(
|
||||
new LambdaQueryWrapper<WarehouseOutDetails>()
|
||||
.eq(WarehouseOutDetails::getSoftDelete, 0)
|
||||
.eq(WarehouseOutDetails::getWarehouseOutId, order.getId()));
|
||||
int stockRestoreCount = 0;
|
||||
Date now = new Date();
|
||||
for (WarehouseOutDetails detail : details) {
|
||||
outDetailsMapper.update(null, new LambdaUpdateWrapper<WarehouseOutDetails>()
|
||||
.eq(WarehouseOutDetails::getId, detail.getId())
|
||||
.set(WarehouseOutDetails::getOrderState, ORDER_STATE_VOID)
|
||||
.set(WarehouseOutDetails::getModifyTime, now));
|
||||
long parentId = detail.getParentId() == null ? 0L : detail.getParentId();
|
||||
if (parentId != 0L) {
|
||||
continue;
|
||||
}
|
||||
// 作废路径不回写明细库存快照(老系统 :3087 决策)
|
||||
int outCount = detail.getOutCount() == null ? 0 : detail.getOutCount();
|
||||
if (outCount > 0) {
|
||||
stockManageMapper.updateCurrentInventory(
|
||||
detail.getMaterielDefinitionId(), detail.getWarehouseId(), outCount);
|
||||
stockRestoreCount++;
|
||||
}
|
||||
long mallOrderDetailId = detail.getMallOrderDetailId() == null ? 0L : detail.getMallOrderDetailId();
|
||||
if (mallOrderDetailId != 0L && outCount > 0) {
|
||||
mallRestores.add(new MallMaterialRestore(mallOrderDetailId, outCount));
|
||||
}
|
||||
}
|
||||
return "明细" + details.size() + "行置作废,恢复库存" + stockRestoreCount + "项";
|
||||
}
|
||||
|
||||
/** 步骤 4:SN 行硬库存回库(upsert + 日志 + 电池费用化规则),LifeCycle 推迟到提交后 push。 */
|
||||
private String restoreHardStock(WarehouseOut order, User caller, List<String> snCodes) {
|
||||
List<WarehouseOutDetails> snRows = outDetailsMapper.selectList(
|
||||
new LambdaQueryWrapper<WarehouseOutDetails>()
|
||||
.eq(WarehouseOutDetails::getSoftDelete, 0)
|
||||
.eq(WarehouseOutDetails::getWarehouseOutId, order.getId())
|
||||
.ne(WarehouseOutDetails::getParentId, 0));
|
||||
int restored = 0;
|
||||
Date now = new Date();
|
||||
for (WarehouseOutDetails detail : snRows) {
|
||||
if (!StringUtils.hasText(detail.getSnCode())) {
|
||||
continue;
|
||||
}
|
||||
Warehouse warehouse = detail.getWarehouseId() == null ? null
|
||||
: warehouseMapper.selectById(detail.getWarehouseId());
|
||||
Long agentId = warehouse == null ? null : warehouse.getAgentId();
|
||||
String agentName = lookupAgentName(agentId);
|
||||
|
||||
WarehouseHardStock stock = hardStockMapper.selectOne(
|
||||
new LambdaQueryWrapper<WarehouseHardStock>()
|
||||
.eq(WarehouseHardStock::getSoftDelete, 0)
|
||||
.eq(WarehouseHardStock::getMaterielDefinitionId, detail.getMaterielDefinitionId())
|
||||
.eq(WarehouseHardStock::getSnCode, detail.getSnCode())
|
||||
.last("LIMIT 1"));
|
||||
boolean isNew = stock == null;
|
||||
if (isNew) {
|
||||
stock = new WarehouseHardStock();
|
||||
stock.setCreateId(caller.getId());
|
||||
stock.setCreateTime(now);
|
||||
stock.setChargeFlag(CHARGE_FLAG_EXPENSED);
|
||||
}
|
||||
stock.setAgentId(agentId);
|
||||
stock.setAgentName(agentName);
|
||||
stock.setWarehouseId(detail.getWarehouseId());
|
||||
stock.setWarehouseName(detail.getWarehouseName());
|
||||
stock.setOutInOrderNo(order.getOutOrderNo());
|
||||
stock.setMaterielDefinitionId(detail.getMaterielDefinitionId());
|
||||
stock.setMaterialNo(detail.getMaterialNo());
|
||||
stock.setMaterielFullName(detail.getMaterielFullName());
|
||||
stock.setMaterielSort(detail.getMaterielSort());
|
||||
stock.setMaterielIndexId(detail.getMaterielIndexId());
|
||||
stock.setSpecifications(detail.getSpecifications());
|
||||
stock.setSnCode(detail.getSnCode());
|
||||
stock.setModifyId(caller.getId());
|
||||
stock.setModifyTime(now);
|
||||
stock.setCurrentInventory(1);
|
||||
stock.setOutInDate(now);
|
||||
stock.setStockState(0); // operation=1 → 在库
|
||||
if (isNew) {
|
||||
hardStockMapper.insert(stock);
|
||||
} else {
|
||||
hardStockMapper.updateById(stock);
|
||||
}
|
||||
|
||||
WarehouseHardLog hardLog = new WarehouseHardLog();
|
||||
hardLog.setCreateId(stock.getCreateId());
|
||||
hardLog.setCreateTime(stock.getCreateTime());
|
||||
hardLog.setModifyId(stock.getModifyId());
|
||||
hardLog.setModifyTime(stock.getModifyTime());
|
||||
hardLog.setSoftDelete(stock.getSoftDelete());
|
||||
hardLog.setAgentId(stock.getAgentId());
|
||||
hardLog.setAgentName(stock.getAgentName());
|
||||
hardLog.setOutInOrderNo(stock.getOutInOrderNo());
|
||||
hardLog.setMaterielDefinitionId(stock.getMaterielDefinitionId());
|
||||
hardLog.setMaterialNo(stock.getMaterialNo());
|
||||
hardLog.setMaterielFullName(stock.getMaterielFullName());
|
||||
hardLog.setMaterielSort(stock.getMaterielSort());
|
||||
hardLog.setMaterielIndexId(stock.getMaterielIndexId());
|
||||
hardLog.setSpecifications(stock.getSpecifications());
|
||||
hardLog.setSnCode(stock.getSnCode());
|
||||
hardLog.setWarehouseId(stock.getWarehouseId());
|
||||
hardLog.setWarehouseName(stock.getWarehouseName());
|
||||
hardLog.setOpType(1); // 入库(回库)
|
||||
hardLog.setOpTime(now);
|
||||
hardLogMapper.insert(hardLog);
|
||||
|
||||
// 电池费用化库存规则(老系统 2025-11-18)
|
||||
if (warehouse != null
|
||||
&& warehouse.getWarehouseAttribute() != null
|
||||
&& warehouse.getWarehouseAttribute() == WAREHOUSE_ATTRIBUTE_STOCK
|
||||
&& detail.getMaterielIndexId() != null
|
||||
&& detail.getMaterielIndexId() == MATERIEL_INDEX_BATTERY
|
||||
&& stock.getChargeFlag() != null
|
||||
&& stock.getChargeFlag() == CHARGE_FLAG_EXPENSED) {
|
||||
stockManageMapper.updateBatteryInventory(
|
||||
detail.getMaterielDefinitionId(), detail.getWarehouseId(), 1);
|
||||
}
|
||||
snCodes.add(detail.getSnCode());
|
||||
restored++;
|
||||
}
|
||||
if (restored == 0) {
|
||||
return "无 SN 行,跳过";
|
||||
}
|
||||
return "SN " + restored + " 个回库,LifeCycle 待推送";
|
||||
}
|
||||
|
||||
/** 步骤 5:关联入库单级联作废 + 扣库存。 */
|
||||
private String voidLinkedInOrder(WarehouseIn linkedIn, User caller) {
|
||||
if (linkedIn == null) {
|
||||
return "无关联入库单,跳过";
|
||||
}
|
||||
Date now = new Date();
|
||||
inMapper.update(null, new LambdaUpdateWrapper<WarehouseIn>()
|
||||
.eq(WarehouseIn::getId, linkedIn.getId())
|
||||
.set(WarehouseIn::getOrderState, ORDER_STATE_VOID)
|
||||
.set(WarehouseIn::getRemoveDate, now)
|
||||
.set(WarehouseIn::getRemoveUserName, caller.getName())
|
||||
.set(WarehouseIn::getModifyId, caller.getId())
|
||||
.set(WarehouseIn::getModifyTime, now));
|
||||
List<WarehouseInDetails> inDetails = inDetailsMapper.selectList(
|
||||
new LambdaQueryWrapper<WarehouseInDetails>()
|
||||
.eq(WarehouseInDetails::getSoftDelete, 0)
|
||||
.eq(WarehouseInDetails::getWarehouseInId, linkedIn.getId()));
|
||||
int stockDeductCount = 0;
|
||||
for (WarehouseInDetails inDetail : inDetails) {
|
||||
inDetailsMapper.update(null, new LambdaUpdateWrapper<WarehouseInDetails>()
|
||||
.eq(WarehouseInDetails::getId, inDetail.getId())
|
||||
.set(WarehouseInDetails::getOrderState, ORDER_STATE_VOID)
|
||||
.set(WarehouseInDetails::getGoodInState, ORDER_STATE_VOID)
|
||||
.set(WarehouseInDetails::getModifyTime, now));
|
||||
int inCount = inDetail.getInCount() == null ? 0 : inDetail.getInCount();
|
||||
if (inCount > 0) {
|
||||
stockManageMapper.updateCurrentInventory(
|
||||
inDetail.getMaterielDefinitionId(), inDetail.getWarehouseId(), -inCount);
|
||||
stockDeductCount++;
|
||||
}
|
||||
}
|
||||
return "入库单 " + linkedIn.getInOrderNo() + " 级联作废,扣减库存" + stockDeductCount + "项";
|
||||
}
|
||||
|
||||
/** 步骤 7:无安装单时送货工单级联软删(老系统 cancelDeliveryOrder)。 */
|
||||
private String cancelDeliveryOrder(WarehouseOut order) {
|
||||
if (StringUtils.hasText(order.getInstallOrderNo())
|
||||
|| !StringUtils.hasText(order.getDeliveryOrderNo())) {
|
||||
return "无关联送货工单或有安装单,跳过";
|
||||
}
|
||||
String deliveryOrderNo = order.getDeliveryOrderNo();
|
||||
Date now = new Date();
|
||||
int orderRows = deliveryOrderMapper.update(null, new LambdaUpdateWrapper<DeliveryOrder>()
|
||||
.eq(DeliveryOrder::getSoftDelete, 0)
|
||||
.eq(DeliveryOrder::getDeliveryOrderNo, deliveryOrderNo)
|
||||
.set(DeliveryOrder::getSoftDelete, 1)
|
||||
.set(DeliveryOrder::getModifyTime, now));
|
||||
int detailRows = installDistributeDetailMapper.update(null,
|
||||
new LambdaUpdateWrapper<InstallDistributeDetail>()
|
||||
.eq(InstallDistributeDetail::getSoftDelete, 0)
|
||||
.eq(InstallDistributeDetail::getDeliveryOrderNo, deliveryOrderNo)
|
||||
.set(InstallDistributeDetail::getSoftDelete, 1));
|
||||
int processRows = deliveryOrderProcessMapper.update(null,
|
||||
new LambdaUpdateWrapper<DeliveryOrderProcess>()
|
||||
.eq(DeliveryOrderProcess::getSoftDelete, 0)
|
||||
.eq(DeliveryOrderProcess::getDeliveryOrderNo, deliveryOrderNo)
|
||||
.set(DeliveryOrderProcess::getSoftDelete, 1)
|
||||
.set(DeliveryOrderProcess::getModifyTime, now));
|
||||
return "送货工单 " + deliveryOrderNo + " 软删:主表" + orderRows
|
||||
+ "行/配发明细" + detailRows + "行/流程" + processRows + "行";
|
||||
}
|
||||
|
||||
/** 步骤 2m/3(m1,提交后执行):商城物料回写 + 商城订单回滚为待出库。 */
|
||||
private String rollbackMallOrder(WarehouseOut order, List<MallMaterialRestore> mallRestores) {
|
||||
if (!StringUtils.hasText(order.getMallOrderNo())) {
|
||||
return "非商城订单,跳过";
|
||||
}
|
||||
int restoredMaterials = 0;
|
||||
for (MallMaterialRestore restore : mallRestores) {
|
||||
MallOrderMaterial material = mallOrderMaterialMapper.selectById(restore.mallOrderDetailId());
|
||||
if (material == null) {
|
||||
continue;
|
||||
}
|
||||
int notOutCount = (material.getNotOutCount() == null ? 0 : material.getNotOutCount())
|
||||
+ restore.outCount();
|
||||
int beenOutCount = (material.getBeenOutCount() == null ? 0 : material.getBeenOutCount())
|
||||
- restore.outCount();
|
||||
int warehouseState = beenOutCount == 0 ? 0 : 2;
|
||||
mallOrderMaterialMapper.update(null, new LambdaUpdateWrapper<MallOrderMaterial>()
|
||||
.eq(MallOrderMaterial::getId, material.getId())
|
||||
.set(MallOrderMaterial::getNotOutCount, notOutCount)
|
||||
.set(MallOrderMaterial::getBeenOutCount, beenOutCount)
|
||||
.set(MallOrderMaterial::getWarehouseState, warehouseState)
|
||||
.setSql("modify_time = NOW()"));
|
||||
restoredMaterials++;
|
||||
}
|
||||
// 订单维度 warehouse_state 汇总:有 2→部分出库;无 2 且无 1→未出库;否则→部分出库
|
||||
String mallOrderNo = order.getMallOrderNo();
|
||||
Long partialCount = mallOrderMaterialMapper.selectCount(new LambdaQueryWrapper<MallOrderMaterial>()
|
||||
.eq(MallOrderMaterial::getSoftDelete, 0)
|
||||
.eq(MallOrderMaterial::getOrderNo, mallOrderNo)
|
||||
.eq(MallOrderMaterial::getWarehouseState, 2));
|
||||
int orderWarehouseState;
|
||||
if (partialCount != null && partialCount > 0) {
|
||||
orderWarehouseState = 2;
|
||||
} else {
|
||||
Long doneCount = mallOrderMaterialMapper.selectCount(new LambdaQueryWrapper<MallOrderMaterial>()
|
||||
.eq(MallOrderMaterial::getSoftDelete, 0)
|
||||
.eq(MallOrderMaterial::getOrderNo, mallOrderNo)
|
||||
.eq(MallOrderMaterial::getWarehouseState, 1));
|
||||
orderWarehouseState = (doneCount == null || doneCount == 0) ? 0 : 2;
|
||||
}
|
||||
MallGoodsOrder goodsOrder = mallGoodsOrderMapper.selectOne(new LambdaQueryWrapper<MallGoodsOrder>()
|
||||
.eq(MallGoodsOrder::getSoftDelete, 0)
|
||||
.eq(MallGoodsOrder::getOrderNo, mallOrderNo)
|
||||
.last("LIMIT 1"));
|
||||
if (goodsOrder != null) {
|
||||
mallGoodsOrderMapper.update(null, new LambdaUpdateWrapper<MallGoodsOrder>()
|
||||
.eq(MallGoodsOrder::getId, goodsOrder.getId())
|
||||
.set(MallGoodsOrder::getWarehouseState, orderWarehouseState)
|
||||
.set(MallGoodsOrder::getOrderState, 1) // 待出库
|
||||
.set(MallGoodsOrder::getJindieInternalId, 0L)
|
||||
.set(MallGoodsOrder::getModifyTime, new Date()));
|
||||
}
|
||||
return "商城订单回滚为待出库(warehouseState=" + orderWarehouseState
|
||||
+ "),回写物料" + restoredMaterials + "行";
|
||||
}
|
||||
|
||||
/** 步骤 4r:LifeCycle 撤销推送(与老系统 revokeLifeCycle 报文一致)。 */
|
||||
private String pushLifeCycle(List<String> snCodes) {
|
||||
if (snCodes.isEmpty()) {
|
||||
return "无 SN,跳过";
|
||||
}
|
||||
for (String snCode : snCodes) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("snCode", snCode);
|
||||
payload.put("memo", "xiaoqu-franchisee->/revokeLifeCycle出库单作废");
|
||||
payload.put("messageType", 2);
|
||||
redisTemplate.opsForList().leftPush("LifeCycle", toJson(payload));
|
||||
}
|
||||
return "LifeCycle 撤销推送 " + snCodes.size() + " 条";
|
||||
}
|
||||
|
||||
/** 步骤 6:金蝶作废推送(jindie_internal_id != 0 时)。 */
|
||||
private String pushJindieSync(WarehouseOut order) {
|
||||
if (order.getJindieInternalId() == null || order.getJindieInternalId() == 0L) {
|
||||
return "无金蝶内码,跳过";
|
||||
}
|
||||
String business = StringUtils.hasText(order.getMallOrderNo())
|
||||
? "SAL_SaleOrder_Cancel" : "STK_MisDelivery_Cancel";
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("business", business);
|
||||
payload.put("businessDataId", order.getOutOrderNo());
|
||||
redisTemplate.opsForList().leftPush("SyncJinDieData", toJson(payload));
|
||||
return business;
|
||||
}
|
||||
|
||||
private static String toJson(Map<String, Object> payload) {
|
||||
try {
|
||||
return JSON.writeValueAsString(payload);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("JSON 序列化失败:" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 仓库所属代理名称:与 franchisee 同实例 Redis 字典 name:Agent:{id},读失败不阻断主流程。 */
|
||||
private String lookupAgentName(Long agentId) {
|
||||
if (agentId == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return redisTemplate.opsForValue().get("name:Agent:" + agentId);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("warehouse.out.void 读取 name:Agent:{} 失败:{}", agentId, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> orderBrief(WarehouseOut order, String skipReason) {
|
||||
Map<String, Object> brief = new LinkedHashMap<>();
|
||||
brief.put("outOrderNo", order.getOutOrderNo());
|
||||
brief.put("mallOrderNo", order.getMallOrderNo());
|
||||
brief.put("orderId", order.getId());
|
||||
brief.put("orderState", order.getOrderState());
|
||||
brief.put("orderStateText", orderStateText(order.getOrderState()));
|
||||
brief.put("outDate", order.getOutDate());
|
||||
if (skipReason != null) {
|
||||
brief.put("skipReason", skipReason);
|
||||
}
|
||||
return brief;
|
||||
}
|
||||
|
||||
private static Map<String, Object> step(String name, boolean success, String detail) {
|
||||
Map<String, Object> step = new LinkedHashMap<>();
|
||||
step.put("step", name);
|
||||
step.put("success", success);
|
||||
step.put("detail", detail);
|
||||
return step;
|
||||
}
|
||||
|
||||
private static String orderStateText(Integer code) {
|
||||
if (code == null) {
|
||||
return null;
|
||||
}
|
||||
return switch (code) {
|
||||
case 0 -> "待审核";
|
||||
case 1 -> "被驳回";
|
||||
case 2 -> "待出库";
|
||||
case 3 -> "已完成";
|
||||
case 4 -> "已撤销";
|
||||
case 5 -> "作废";
|
||||
default -> "未知";
|
||||
};
|
||||
}
|
||||
|
||||
/** 商城物料回写指令(m2 事务内收集,提交后落到 m1)。 */
|
||||
private record MallMaterialRestore(Long mallOrderDetailId, int outCount) {
|
||||
}
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
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.m1.MallGoodsOrder;
|
||||
import me.iiv.agent.entity.m1.MallOrderMaterial;
|
||||
import me.iiv.agent.entity.m2.DeliveryOrder;
|
||||
import me.iiv.agent.entity.m2.DeliveryOrderProcess;
|
||||
import me.iiv.agent.entity.m2.InstallDistributeDetail;
|
||||
import me.iiv.agent.entity.m2.Warehouse;
|
||||
import me.iiv.agent.entity.m2.WarehouseHardLog;
|
||||
import me.iiv.agent.entity.m2.WarehouseHardStock;
|
||||
import me.iiv.agent.entity.m2.WarehouseIn;
|
||||
import me.iiv.agent.entity.m2.WarehouseInDetails;
|
||||
import me.iiv.agent.entity.m2.WarehouseOut;
|
||||
import me.iiv.agent.entity.m2.WarehouseOutDetails;
|
||||
import me.iiv.agent.mapper.m1.MallGoodsOrderMapper;
|
||||
import me.iiv.agent.mapper.m1.MallOrderMaterialMapper;
|
||||
import me.iiv.agent.mapper.m2.DeliveryOrderMapper;
|
||||
import me.iiv.agent.mapper.m2.DeliveryOrderProcessMapper;
|
||||
import me.iiv.agent.mapper.m2.InstallDistributeDetailMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseHardLogMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseHardStockMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseInDetailsMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseInMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseOutDetailsMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseOutMapper;
|
||||
import me.iiv.agent.mapper.m2.WarehouseStockManageMapper;
|
||||
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 org.springframework.data.redis.core.ListOperations;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.data.redis.core.ValueOperations;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
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.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isNull;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WarehouseOutVoidToolsTest {
|
||||
|
||||
@Mock WarehouseOutMapper outMapper;
|
||||
@Mock WarehouseOutDetailsMapper outDetailsMapper;
|
||||
@Mock WarehouseInMapper inMapper;
|
||||
@Mock WarehouseInDetailsMapper inDetailsMapper;
|
||||
@Mock WarehouseStockManageMapper stockManageMapper;
|
||||
@Mock WarehouseHardStockMapper hardStockMapper;
|
||||
@Mock WarehouseHardLogMapper hardLogMapper;
|
||||
@Mock WarehouseMapper warehouseMapper;
|
||||
@Mock DeliveryOrderMapper deliveryOrderMapper;
|
||||
@Mock DeliveryOrderProcessMapper deliveryOrderProcessMapper;
|
||||
@Mock InstallDistributeDetailMapper installDistributeDetailMapper;
|
||||
@Mock MallGoodsOrderMapper mallGoodsOrderMapper;
|
||||
@Mock MallOrderMaterialMapper mallOrderMaterialMapper;
|
||||
@Mock StringRedisTemplate redisTemplate;
|
||||
@Mock PlatformTransactionManager txManager;
|
||||
@Mock TransactionStatus txStatus;
|
||||
@Mock ListOperations<String, String> listOps;
|
||||
@Mock ValueOperations<String, String> valueOps;
|
||||
|
||||
WarehouseOutVoidTools tools;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
|
||||
TableInfoHelper.initTableInfo(assistant, WarehouseOut.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WarehouseOutDetails.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WarehouseIn.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WarehouseInDetails.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WarehouseHardStock.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WarehouseHardLog.class);
|
||||
TableInfoHelper.initTableInfo(assistant, Warehouse.class);
|
||||
TableInfoHelper.initTableInfo(assistant, DeliveryOrder.class);
|
||||
TableInfoHelper.initTableInfo(assistant, DeliveryOrderProcess.class);
|
||||
TableInfoHelper.initTableInfo(assistant, InstallDistributeDetail.class);
|
||||
TableInfoHelper.initTableInfo(assistant, MallGoodsOrder.class);
|
||||
TableInfoHelper.initTableInfo(assistant, MallOrderMaterial.class);
|
||||
TableInfoHelper.initTableInfo(assistant, User.class);
|
||||
tools = new WarehouseOutVoidTools(outMapper, outDetailsMapper, inMapper, inDetailsMapper,
|
||||
stockManageMapper, hardStockMapper, hardLogMapper, warehouseMapper,
|
||||
deliveryOrderMapper, deliveryOrderProcessMapper, installDistributeDetailMapper,
|
||||
mallGoodsOrderMapper, mallOrderMaterialMapper, redisTemplate, txManager);
|
||||
User caller = new User();
|
||||
caller.setId(812L);
|
||||
caller.setName("王恒");
|
||||
AuthContextHolder.set(new AuthContext(null, caller));
|
||||
lenient().when(txManager.getTransaction(any())).thenReturn(txStatus);
|
||||
lenient().when(redisTemplate.opsForList()).thenReturn(listOps);
|
||||
lenient().when(redisTemplate.opsForValue()).thenReturn(valueOps);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
AuthContextHolder.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWhenBothOrderNosBlank() {
|
||||
ToolCallException e = assertThrows(ToolCallException.class,
|
||||
() -> tools.warehouseOutVoid(null, " ", null, null));
|
||||
assertEquals(McpErrorCode.INVALID_ARGUMENT, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deniesServiceTokenWithoutUser() {
|
||||
AuthContextHolder.set(new AuthContext(null, null));
|
||||
ToolCallException e = assertThrows(ToolCallException.class,
|
||||
() -> tools.warehouseOutVoid("OUT-1", null, null, null));
|
||||
assertEquals(McpErrorCode.PERMISSION_DENIED, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void outOrderNotFound() {
|
||||
when(outMapper.selectList(any())).thenReturn(List.of());
|
||||
ToolCallException e = assertThrows(ToolCallException.class,
|
||||
() -> tools.warehouseOutVoid("OUT-NONE", null, null, null));
|
||||
assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateOutOrderNoIsDataIntegrityError() {
|
||||
when(outMapper.selectList(any())).thenReturn(List.of(doneOrder(1L), doneOrder(2L)));
|
||||
ToolCallException e = assertThrows(ToolCallException.class,
|
||||
() -> tools.warehouseOutVoid("OUT-DUP", null, null, null));
|
||||
assertEquals(McpErrorCode.DATA_INTEGRITY_ERROR, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMallOrderNoMismatch() {
|
||||
WarehouseOut order = doneOrder(1L);
|
||||
order.setMallOrderNo("MO-REAL");
|
||||
when(outMapper.selectList(any())).thenReturn(List.of(order));
|
||||
ToolCallException e = assertThrows(ToolCallException.class,
|
||||
() -> tools.warehouseOutVoid("OUT-1", "MO-OTHER", null, null));
|
||||
assertEquals(McpErrorCode.INVALID_ARGUMENT, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWhenNoCompletedOrder() {
|
||||
WarehouseOut order = doneOrder(1L);
|
||||
order.setOrderState(2);
|
||||
when(outMapper.selectList(any())).thenReturn(List.of(order));
|
||||
ToolCallException e = assertThrows(ToolCallException.class,
|
||||
() -> tools.warehouseOutVoid(null, "MO-1", null, null));
|
||||
assertEquals(McpErrorCode.NOT_FOUND_OR_FORBIDDEN, e.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void multiOrdersRequireConfirm() {
|
||||
when(outMapper.selectList(any())).thenReturn(List.of(doneOrder(1L), doneOrder(2L)));
|
||||
Map<String, Object> result = tools.warehouseOutVoid(null, "MO-MULTI", null, null);
|
||||
assertTrue((Boolean) result.get("confirmMultiRequired"));
|
||||
assertEquals(2, ((List<?>) result.get("candidates")).size());
|
||||
assertEquals(0, result.get("totalCount"));
|
||||
// 未确认前不发生任何写
|
||||
verify(outMapper, never()).update(isNull(), any());
|
||||
verify(txManager, never()).getTransaction(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void linkedInOrderNotDoneSkipsWholeOrder() {
|
||||
WarehouseOut order = doneOrder(1L);
|
||||
when(outMapper.selectList(any())).thenReturn(List.of(order));
|
||||
WarehouseIn linkedIn = new WarehouseIn();
|
||||
linkedIn.setId(9L);
|
||||
linkedIn.setInOrderNo("IN-9");
|
||||
linkedIn.setOrderState(2);
|
||||
when(inMapper.selectOne(any())).thenReturn(linkedIn);
|
||||
Map<String, Object> result = tools.warehouseOutVoid("OUT-1", null, null, null);
|
||||
Map<?, ?> one = ((List<Map<?, ?>>) result.get("results")).get(0);
|
||||
assertEquals(false, one.get("success"));
|
||||
assertEquals(true, one.get("skipped"));
|
||||
assertTrue(String.valueOf(one.get("skipReason")).contains("IN-9"));
|
||||
verify(txManager, never()).getTransaction(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void happyPathSingleOrder() {
|
||||
WarehouseOut order = doneOrder(1L);
|
||||
order.setMallOrderNo("MO-1");
|
||||
order.setJindieInternalId(100L);
|
||||
order.setInstallOrderNo(""); // 触发送货工单级联
|
||||
order.setDeliveryOrderNo("DO-1");
|
||||
when(outMapper.selectList(any())).thenReturn(List.of(order));
|
||||
when(inMapper.selectOne(any())).thenReturn(null);
|
||||
when(outMapper.update(isNull(), any())).thenReturn(1);
|
||||
|
||||
WarehouseOutDetails summary = summaryDetail(11L);
|
||||
WarehouseOutDetails snRow = snDetail(12L);
|
||||
when(outDetailsMapper.selectList(any())).thenReturn(List.of(summary, snRow));
|
||||
Warehouse warehouse = new Warehouse();
|
||||
warehouse.setId(5L);
|
||||
warehouse.setAgentId(17L);
|
||||
warehouse.setWarehouseAttribute(0);
|
||||
when(warehouseMapper.selectById(anyLong())).thenReturn(warehouse);
|
||||
when(hardStockMapper.selectOne(any())).thenReturn(null);
|
||||
when(valueOps.get(anyString())).thenReturn("望京代理");
|
||||
|
||||
MallOrderMaterial material = new MallOrderMaterial();
|
||||
material.setId(88L);
|
||||
material.setNotOutCount(0);
|
||||
material.setBeenOutCount(2);
|
||||
when(mallOrderMaterialMapper.selectById(88L)).thenReturn(material);
|
||||
when(mallOrderMaterialMapper.selectCount(any())).thenReturn(1L);
|
||||
MallGoodsOrder goodsOrder = new MallGoodsOrder();
|
||||
goodsOrder.setId(77L);
|
||||
when(mallGoodsOrderMapper.selectOne(any())).thenReturn(goodsOrder);
|
||||
|
||||
Map<String, Object> result = tools.warehouseOutVoid("OUT-1", null, "测试作废", null);
|
||||
Map<?, ?> one = ((List<Map<?, ?>>) result.get("results")).get(0);
|
||||
assertEquals(true, one.get("success"));
|
||||
assertEquals(1, result.get("successCount"));
|
||||
List<?> steps = (List<?>) one.get("stepResults");
|
||||
assertEquals(8, steps.size());
|
||||
|
||||
// 主表/明细置作废
|
||||
verify(outMapper, times(1)).update(isNull(), any());
|
||||
verify(outDetailsMapper, times(2)).update(isNull(), any());
|
||||
// 汇总行恢复库存 +2;SN 行不动普通库存
|
||||
verify(stockManageMapper, times(1)).updateCurrentInventory(eq(1000L), eq(5L), eq(2));
|
||||
// 硬库存 upsert(新建)+ 日志 + 电池费用化(materielIndexId=6 + attribute=0 + chargeFlag=0)
|
||||
verify(hardStockMapper, times(1)).insert(any(WarehouseHardStock.class));
|
||||
verify(hardLogMapper, times(1)).insert(any(WarehouseHardLog.class));
|
||||
verify(stockManageMapper, times(1)).updateBatteryInventory(eq(1000L), eq(5L), eq(1));
|
||||
// 商城回写 + 订单回滚
|
||||
verify(mallOrderMaterialMapper, times(1)).update(isNull(), any());
|
||||
verify(mallGoodsOrderMapper, times(1)).update(isNull(), any());
|
||||
// Redis:LifeCycle 撤销 + 金蝶 SAL_SaleOrder_Cancel
|
||||
verify(listOps, times(1)).leftPush(eq("LifeCycle"), anyString());
|
||||
verify(listOps, times(1)).leftPush(eq("SyncJinDieData"), anyString());
|
||||
// 送货工单级联软删(installOrderNo 空 + deliveryOrderNo 非空)
|
||||
verify(deliveryOrderMapper, times(1)).update(isNull(), any());
|
||||
verify(installDistributeDetailMapper, times(1)).update(isNull(), any());
|
||||
verify(deliveryOrderProcessMapper, times(1)).update(isNull(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void m2FailureRollsBackAndSkipsRedis() {
|
||||
WarehouseOut order = doneOrder(1L);
|
||||
when(outMapper.selectList(any())).thenReturn(List.of(order));
|
||||
when(inMapper.selectOne(any())).thenReturn(null);
|
||||
when(outMapper.update(isNull(), any())).thenThrow(new RuntimeException("db down"));
|
||||
|
||||
Map<String, Object> result = tools.warehouseOutVoid("OUT-1", null, null, null);
|
||||
Map<?, ?> one = ((List<Map<?, ?>>) result.get("results")).get(0);
|
||||
assertEquals(false, one.get("success"));
|
||||
assertTrue(String.valueOf(one.get("error")).contains("db down"));
|
||||
verify(txManager, times(1)).rollback(txStatus);
|
||||
verify(listOps, never()).leftPush(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void redisPushFailureDoesNotRollbackMainFlow() {
|
||||
WarehouseOut order = doneOrder(1L);
|
||||
order.setJindieInternalId(100L);
|
||||
when(outMapper.selectList(any())).thenReturn(List.of(order));
|
||||
when(inMapper.selectOne(any())).thenReturn(null);
|
||||
when(outMapper.update(isNull(), any())).thenReturn(1);
|
||||
when(outDetailsMapper.selectList(any())).thenReturn(List.of(summaryDetail(11L)));
|
||||
when(listOps.leftPush(anyString(), anyString())).thenThrow(new RuntimeException("redis down"));
|
||||
|
||||
Map<String, Object> result = tools.warehouseOutVoid("OUT-1", null, null, null);
|
||||
Map<?, ?> one = ((List<Map<?, ?>>) result.get("results")).get(0);
|
||||
assertEquals(true, one.get("success"));
|
||||
List<Map<?, ?>> steps = (List<Map<?, ?>>) one.get("stepResults");
|
||||
Map<?, ?> jindie = steps.stream()
|
||||
.filter(s -> "jindieSyncPush".equals(s.get("step"))).findFirst().orElseThrow();
|
||||
assertEquals(false, jindie.get("success"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmMultiVoidsEachCompletedOrder() {
|
||||
WarehouseOut o1 = doneOrder(1L);
|
||||
WarehouseOut o2 = doneOrder(2L);
|
||||
when(outMapper.selectList(any())).thenReturn(List.of(o1, o2));
|
||||
when(inMapper.selectOne(any())).thenReturn(null);
|
||||
when(outMapper.update(isNull(), any())).thenReturn(1);
|
||||
when(outDetailsMapper.selectList(any())).thenReturn(List.of(summaryDetail(11L)));
|
||||
|
||||
Map<String, Object> result = tools.warehouseOutVoid(null, "MO-MULTI", null, true);
|
||||
assertEquals(2, result.get("totalCount"));
|
||||
assertEquals(2, result.get("successCount"));
|
||||
verify(outMapper, times(2)).update(isNull(), any());
|
||||
}
|
||||
|
||||
private static WarehouseOut doneOrder(long id) {
|
||||
WarehouseOut order = new WarehouseOut();
|
||||
order.setId(id);
|
||||
order.setOutOrderNo("OUT-" + id);
|
||||
order.setOrderState(3);
|
||||
order.setSoftDelete(0);
|
||||
return order;
|
||||
}
|
||||
|
||||
/** 汇总行(parent_id=0):恢复库存 + 商城回写。 */
|
||||
private static WarehouseOutDetails summaryDetail(long id) {
|
||||
WarehouseOutDetails detail = new WarehouseOutDetails();
|
||||
detail.setId(id);
|
||||
detail.setParentId(0L);
|
||||
detail.setOutCount(2);
|
||||
detail.setMaterielDefinitionId(1000L);
|
||||
detail.setWarehouseId(5L);
|
||||
detail.setMallOrderDetailId(88L);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** SN 子行(parent_id!=0 + sn_code):硬库存回库 + 电池规则。 */
|
||||
private static WarehouseOutDetails snDetail(long id) {
|
||||
WarehouseOutDetails detail = new WarehouseOutDetails();
|
||||
detail.setId(id);
|
||||
detail.setParentId(11L);
|
||||
detail.setSnCode("SN-001");
|
||||
detail.setMaterielDefinitionId(1000L);
|
||||
detail.setWarehouseId(5L);
|
||||
detail.setMaterielIndexId(6L);
|
||||
detail.setWarehouseName("总仓");
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user