From 4655090f76320269b04abf292fa91e67ba0a38db Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 17:47:11 +0800 Subject: [PATCH] fix(lastleg): account return carton outbound stock atomically --- deploy/sql/README.md | 2 +- deploy/sql/海外区域统一增量-MySQL8.sql | 30 ++++- .../service/DeliveryTransferService.java | 8 +- .../ReturnInventoryOutboundMovement.java | 30 +++++ .../mapper/ReturnInventoryBalanceMapper.java | 12 ++ .../mapper/ReturnInventoryMovementMapper.java | 8 +- .../service/ReturnInventoryService.java | 53 ++++++++ .../src/main/resources/db/lastleg.sql | 28 ++++ .../ReturnOutboundPdaPersistenceTest.java | 123 +++++++++++++++++- .../initialization-baseline-contract.test.mjs | 13 +- 10 files changed, 294 insertions(+), 13 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 0729767aa..b5600909e 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -7,7 +7,7 @@ | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 223 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 135 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 136 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 - 两份文件必须在同一测试数据库实例中完成初始化;业务服务仍只访问本服务 schema,经内部接口访问 IAM/Warehouse。已移除事件桥两个 schema 的创建,不对已有数据库执行 DROP。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 8f54c1129..18f0c47a5 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2258,6 +2258,34 @@ CREATE TABLE ll_relabel_status_history ( KEY idx_ll_relabel_status_history_application (customer_id,application_id,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='换标状态变更历史'; +-- 退货出库按SKU固化扣减前后快照,与PDA拣货同事务提交;不修改历史箱内容。 +CREATE TABLE ll_return_inventory_outbound_movement ( + id BIGINT NOT NULL COMMENT '退货出库SKU流水ID', + outbound_pick_id BIGINT NOT NULL COMMENT '同事务PDA出库拣货记录ID', + customer_id VARCHAR(128) NOT NULL COMMENT '库存所属客户ID和隔离键', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '实际出库海外仓ID', + carton_id BIGINT NOT NULL COMMENT '真实退货库存箱ID', + carton_mark VARCHAR(128) NOT NULL COMMENT '冻结的真实退货箱业务码', + outbound_no VARCHAR(64) NOT NULL COMMENT '本次退货出库业务单号', + sku_code VARCHAR(128) NOT NULL COMMENT '本次出库SKU编码快照', + product_name_zh VARCHAR(255) NULL COMMENT '中文品名快照', + quantity BIGINT NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正', + total_before BIGINT NOT NULL COMMENT '客户仓SKU出库前实物总数量', + total_after BIGINT NOT NULL COMMENT '客户仓SKU出库后实物总数量', + available_before BIGINT NOT NULL COMMENT '客户仓SKU出库前可用数量', + available_after BIGINT NOT NULL COMMENT '客户仓SKU出库后可用数量', + cartons_before BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库前可用箱数', + cartons_after BIGINT NOT NULL COMMENT '客户仓包含该SKU的出库后可用箱数', + occurred_at DATETIME(3) NOT NULL COMMENT '出库记账时间', + PRIMARY KEY(id), + UNIQUE KEY uk_ll_return_outbound_pick_sku(outbound_pick_id,sku_code), + UNIQUE KEY uk_ll_return_outbound_carton_sku(customer_id,carton_id,sku_code), + KEY idx_ll_return_outbound_movement_query(customer_id,overseas_warehouse_id,sku_code,occurred_at), + CONSTRAINT chk_ll_return_outbound_movement_delta CHECK(quantity>0 AND total_after>=0 AND available_after>=0 + AND total_before-total_after=quantity AND available_before-available_after=quantity + AND cartons_after>=0 AND cartons_before-cartons_after=1 AND total_after>=available_after) +) ENGINE=InnoDB COMMENT='不可变退货出库SKU库存流水'; + CREATE TABLE ll_return_inventory_balance ( id bigint NOT NULL COMMENT '退货库存余额ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', @@ -2992,4 +3020,4 @@ CREATE TABLE event_delivery_attempt ( KEY idx_delivery_attempt_outcome (outcome,started_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本服务业务事件逐次投递审计'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 135 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 136 AS initialized_tables; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 96001b314..0253b723d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -109,6 +109,7 @@ public class DeliveryTransferService { @Resource private DropshipCartonSkuMapper dropshipCartonSkus; @Resource private DropshipSkuInventoryMapper dropshipSkuInventory; @Resource private ReturnInventoryCartonMapper returnCartons; + @Resource private com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock; @Resource private InboundPlanPalletAllocationMapper planAllocations; @Resource private InboundPlanPalletMapper inboundPlanPallets; @Resource private WarehouseResourceGateway warehouseResources; @@ -672,9 +673,10 @@ public class DeliveryTransferService { ReturnInventoryCarton carton = returnCartons.lockMark(item.getCartonMarks()); requireReturnSource(outbound, item, carton); if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱唛不可出库:" + item.getCartonMarks()); - carton.setStatus("OUTBOUNDED"); carton.setUpdatedAt(LocalDateTime.now()); - if (returnCartons.updateById(carton) != 1) throw conflict("退货箱唛状态已变化,请重新核对"); - OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); + long outboundPickId = IdWorker.getId(); + returnStock.shipCarton(customerId, warehouseId, item.getInboundCode(), item.getCartonMarks(), + outbound.getOutboundNo(), outboundPickId); + OutboundPdaPick pick = new OutboundPdaPick(); pick.setId(outboundPickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setOutboundPalletItemId(item.getId()); pick.setInventoryPositionId(null); pick.setPackages(request.packages()); pick.setIdempotencyKey(request.idempotencyKey().trim()); pick.setPickedAt(LocalDateTime.now()); pdaPicks.insert(pick); item.setPickedPackageCount(request.packages().intValue()); item.setStatus("PICKED"); outboundPallets.updateById(item); updateOutboundPickingStatus(customerId, outbound); task.setStatus("IN_PROGRESS"); task.setUpdatedAt(LocalDateTime.now()); pdaTasks.updateById(task); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java new file mode 100644 index 000000000..747901a7b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.returnreceipt.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import java.time.LocalDateTime; + +/** Immutable SKU stock deltas written in the outbound pick transaction. */ +@Data +@TableName("ll_return_inventory_outbound_movement") +public class ReturnInventoryOutboundMovement { + @TableId(type = IdType.INPUT) private Long id; + private Long outboundPickId; + private String customerId; + private String overseasWarehouseId; + private Long cartonId; + private String cartonMark; + private String outboundNo; + private String skuCode; + private String productNameZh; + private Long quantity; + private Long totalBefore; + private Long totalAfter; + private Long availableBefore; + private Long availableAfter; + private Long cartonsBefore; + private Long cartonsAfter; + private LocalDateTime occurredAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java index 9aaeeb487..caf327ce9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java @@ -12,6 +12,18 @@ import java.util.List; @Mapper public interface ReturnInventoryBalanceMapper extends BaseMapper { + @Select("SELECT * FROM ll_return_inventory_balance WHERE customer_id=#{customerId} " + + "AND overseas_warehouse_id=#{warehouseId} AND sku_code=#{skuCode} FOR UPDATE") + ReturnInventoryBalance lockSku(@Param("customerId") String customerId, @Param("warehouseId") String warehouseId, + @Param("skuCode") String skuCode); + + @Update("UPDATE ll_return_inventory_balance SET total_quantity=total_quantity-#{quantity}, " + + "available_quantity=available_quantity-#{quantity},carton_quantity=carton_quantity-1,updated_at=NOW(3) " + + "WHERE id=#{id} AND customer_id=#{customerId} AND overseas_warehouse_id=#{warehouseId} " + + "AND total_quantity>=#{quantity} AND available_quantity>=#{quantity} AND carton_quantity>=1") + int deductOutbound(@Param("id") long id, @Param("customerId") String customerId, + @Param("warehouseId") String warehouseId, @Param("quantity") long quantity); + @Insert("INSERT INTO ll_return_inventory_balance (id,customer_id,overseas_warehouse_id,sku_code,product_name_zh,total_quantity,available_quantity,carton_quantity,created_at,updated_at) " + "VALUES (#{id},#{customerId},#{warehouseId},#{skuCode},#{productNameZh},#{totalQuantity},#{availableQuantity},#{cartons},NOW(3),NOW(3)) " + "ON DUPLICATE KEY UPDATE total_quantity=total_quantity+VALUES(total_quantity),available_quantity=available_quantity+VALUES(available_quantity),carton_quantity=carton_quantity+VALUES(carton_quantity), " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java index d4aa8999b..16d780a55 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java @@ -7,15 +7,17 @@ import org.apache.ibatis.annotations.Select; import java.util.List; -/** Read-only return inventory movement ledger reconstructed from signed receipts and merged cartons. */ +/** Receipt/merge projections plus immutable outbound SKU movements. */ @Mapper -public interface ReturnInventoryMovementMapper { +public interface ReturnInventoryMovementMapper extends com.baomidou.mybatisplus.core.mapper.BaseMapper { String MOVEMENTS = "SELECT o.customer_id customerId,o.overseas_warehouse_id overseasWarehouseId,ri.sku_code skuCode,oi.product_name_zh productNameZh," + "'RECEIPT' movementType,r.receipt_no referenceNo,ri.received_quantity totalDelta,ri.received_quantity availableDelta,r.signed_at occurredAt " + "FROM ll_return_receipt r JOIN ll_return_order o ON o.id=r.return_order_id JOIN ll_return_receipt_item ri ON ri.receipt_id=r.id " + "JOIN ll_return_order_item oi ON oi.id=ri.return_order_item_id " + "UNION ALL SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,i.product_name_zh,'MERGE',c.carton_mark,0,-i.quantity,c.created_at " + - "FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'"; + "FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'" + + " UNION ALL SELECT customer_id,overseas_warehouse_id,sku_code,product_name_zh,'OUTBOUND',outbound_no," + + "-quantity,-quantity,occurred_at FROM ll_return_inventory_outbound_movement"; @Select("