From dfc18b17aa91a28b1861b0a0c1b22bdab7c3fff0 Mon Sep 17 00:00:00 2001 From: my_local_kimi_dev2 Date: Sat, 7 Nov 2026 17:10:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcp):=20=E6=96=B0=E5=A2=9E=E5=87=BA?= =?UTF-8?q?=E5=BA=93=E5=8D=95=E4=BD=9C=E5=BA=9F=E5=86=99=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=20warehouse.out.void=EF=BC=88REQ-20261107-0003=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 老系统 /myWarehouseOut/remove 的 MCP 化,只作废已完成(order_state=3)单: 主表/明细置作废+库存原子恢复、商城订单回滚待出库、SN 硬库存回库+LifeCycle 撤销推送、关联入库单级联作废+扣库存、金蝶作废推送、无安装单时送货工单级联软删。 m2TransactionManager 包 m2 写,m1 提交后执行,Redis 失败不回滚。 mallOrderNo 多单需 confirmMulti=true 确认(王恒拍板)。无行级权限,只绑研发专用 scope。 --- xiaoqu-agent-service/README.md | 1 + .../doc/agent-mcp-tools-reference.md | 31 + .../doc/warehouse-out-void-mcp-design.md | 174 +++++ .../iiv/agent/config/M2DataSourceConfig.java | 8 + .../me/iiv/agent/entity/m2/Warehouse.java | 4 + .../iiv/agent/entity/m2/WarehouseHardLog.java | 44 ++ .../agent/entity/m2/WarehouseHardStock.java | 54 ++ .../me/iiv/agent/entity/m2/WarehouseOut.java | 2 + .../mapper/m2/WarehouseHardLogMapper.java | 8 + .../mapper/m2/WarehouseHardStockMapper.java | 8 + .../mapper/m2/WarehouseStockManageMapper.java | 19 + .../agent/mcp/tool/WarehouseOutVoidTools.java | 679 ++++++++++++++++++ .../mcp/tool/WarehouseOutVoidToolsTest.java | 347 +++++++++ 13 files changed, 1379 insertions(+) create mode 100644 xiaoqu-agent-service/doc/warehouse-out-void-mcp-design.md create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseHardLog.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseHardStock.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseHardLogMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseHardStockMapper.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseOutVoidTools.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseOutVoidToolsTest.java diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 67d239b..4ff061e 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -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` 字段提示。 diff --git a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md index 5a64417..21c0d66 100644 --- a/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md +++ b/xiaoqu-agent-service/doc/agent-mcp-tools-reference.md @@ -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 diff --git a/xiaoqu-agent-service/doc/warehouse-out-void-mcp-design.md b/xiaoqu-agent-service/doc/warehouse-out-void-mcp-design.md new file mode 100644 index 0000000..f7fd7b8 --- /dev/null +++ b/xiaoqu-agent-service/doc/warehouse-out-void-mcp-design.md @@ -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 → 造一张已完成测试出库单 → 作废 → + 核验主表/明细/库存/硬库存/老系统列表页状态 → (可选)重新出库恢复。 diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/M2DataSourceConfig.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/M2DataSourceConfig.java index c6ebe1e..48f21fd 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/M2DataSourceConfig.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/M2DataSourceConfig.java @@ -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); + } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/Warehouse.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/Warehouse.java index a74432e..00f79bf 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/Warehouse.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/Warehouse.java @@ -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; } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseHardLog.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseHardLog.java new file mode 100644 index 0000000..ca39d1c --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseHardLog.java @@ -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; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseHardStock.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseHardStock.java new file mode 100644 index 0000000..edbee43 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseHardStock.java @@ -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; +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOut.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOut.java index 8655735..1852332 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOut.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/m2/WarehouseOut.java @@ -58,4 +58,6 @@ public class WarehouseOut implements Serializable { private String deliveryOrderNo; private String installOrderNo; private String pickupOrderNo; + /** 金蝶系统内码(作废推送 SyncJinDieData 的前置条件) */ + private Long jindieInternalId; } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseHardLogMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseHardLogMapper.java new file mode 100644 index 0000000..532077a --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseHardLogMapper.java @@ -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 { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseHardStockMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseHardStockMapper.java new file mode 100644 index 0000000..24b662b --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseHardStockMapper.java @@ -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 { +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseStockManageMapper.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseStockManageMapper.java index a72d549..2fb90c9 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseStockManageMapper.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mapper/m2/WarehouseStockManageMapper.java @@ -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 { + + /** + * 原子更新 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); } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseOutVoidTools.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseOutVoidTools.java new file mode 100644 index 0000000..277297a --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/mcp/tool/WarehouseOutVoidTools.java @@ -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 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 orders = locateOrders(outNo, mallNo); + + List voidable = new ArrayList<>(); + List> 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 result = new LinkedHashMap<>(); + + // 拍板结论 1:多单必须显式确认 + if (outNo == null && voidable.size() > 1 && !Boolean.TRUE.equals(confirmMulti)) { + List> 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> results = new ArrayList<>(); + int successCount = 0; + int skippedCount = 0; + for (WarehouseOut order : voidable) { + Map 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 locateOrders(String outNo, String mallNo) { + if (outNo != null) { + List rows = outMapper.selectList(new LambdaQueryWrapper() + .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 rows = outMapper.selectList(new LambdaQueryWrapper() + .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 voidOneOrder(WarehouseOut order, User caller, String memo, String requestId) { + Map one = new LinkedHashMap<>(); + one.put("outOrderNo", order.getOutOrderNo()); + one.put("mallOrderNo", order.getMallOrderNo()); + one.put("orderId", order.getId()); + List> stepResults = new ArrayList<>(); + one.put("stepResults", stepResults); + + // 前置校验:关联入库单未完成则整单拒绝(拍板结论 3) + WarehouseIn linkedIn = inMapper.selectOne(new LambdaQueryWrapper() + .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 mallRestores = new ArrayList<>(); + List 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() + .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 mallRestores) { + List details = outDetailsMapper.selectList( + new LambdaQueryWrapper() + .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() + .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 snCodes) { + List snRows = outDetailsMapper.selectList( + new LambdaQueryWrapper() + .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() + .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() + .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 inDetails = inDetailsMapper.selectList( + new LambdaQueryWrapper() + .eq(WarehouseInDetails::getSoftDelete, 0) + .eq(WarehouseInDetails::getWarehouseInId, linkedIn.getId())); + int stockDeductCount = 0; + for (WarehouseInDetails inDetail : inDetails) { + inDetailsMapper.update(null, new LambdaUpdateWrapper() + .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() + .eq(DeliveryOrder::getSoftDelete, 0) + .eq(DeliveryOrder::getDeliveryOrderNo, deliveryOrderNo) + .set(DeliveryOrder::getSoftDelete, 1) + .set(DeliveryOrder::getModifyTime, now)); + int detailRows = installDistributeDetailMapper.update(null, + new LambdaUpdateWrapper() + .eq(InstallDistributeDetail::getSoftDelete, 0) + .eq(InstallDistributeDetail::getDeliveryOrderNo, deliveryOrderNo) + .set(InstallDistributeDetail::getSoftDelete, 1)); + int processRows = deliveryOrderProcessMapper.update(null, + new LambdaUpdateWrapper() + .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 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() + .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() + .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() + .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() + .eq(MallGoodsOrder::getSoftDelete, 0) + .eq(MallGoodsOrder::getOrderNo, mallOrderNo) + .last("LIMIT 1")); + if (goodsOrder != null) { + mallGoodsOrderMapper.update(null, new LambdaUpdateWrapper() + .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 snCodes) { + if (snCodes.isEmpty()) { + return "无 SN,跳过"; + } + for (String snCode : snCodes) { + Map 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 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 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 orderBrief(WarehouseOut order, String skipReason) { + Map 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 step(String name, boolean success, String detail) { + Map 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) { + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseOutVoidToolsTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseOutVoidToolsTest.java new file mode 100644 index 0000000..adc4cc2 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/mcp/tool/WarehouseOutVoidToolsTest.java @@ -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 listOps; + @Mock ValueOperations 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 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 result = tools.warehouseOutVoid("OUT-1", null, null, null); + Map one = ((List>) 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 result = tools.warehouseOutVoid("OUT-1", null, "测试作废", null); + Map one = ((List>) 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 result = tools.warehouseOutVoid("OUT-1", null, null, null); + Map one = ((List>) 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 result = tools.warehouseOutVoid("OUT-1", null, null, null); + Map one = ((List>) result.get("results")).get(0); + assertEquals(true, one.get("success")); + List> steps = (List>) 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 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; + } +}