From a88a04fd24553b285a8519f932a2988f738db694 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 23:52:36 +0800 Subject: [PATCH] feat(warehouse-overseas): reserve inbound pallet batches --- deploy/sql/海外区域统一增量-MySQL8.sql | 28 ++ scripts/db/incremental.sql | 28 ++ warehouse-overseas-service/AGENTS.md | 3 + ...nternalWarehousePalletBatchController.java | 45 +++ .../dto/WarehousePalletBatchRequests.java | 41 ++ .../dto/WarehousePalletBatchResponse.java | 24 ++ .../entity/WarehousePalletBatchEntity.java | 29 ++ .../WarehousePalletBatchItemEntity.java | 28 ++ .../WarehousePalletBatchItemMapper.java | 15 + .../mapper/WarehousePalletBatchMapper.java | 21 + .../mapper/WarehousePalletBindingMapper.java | 7 + .../mapper/WarehouseResourceLockMapper.java | 7 +- .../WarehouseResourceLockRepository.java | 4 +- .../WarehouseResourceRepository.java | 5 + .../service/WarehousePalletBatchService.java | 359 +++++++++++++++++ .../service/WarehouseResourceService.java | 3 +- .../src/main/resources/db/warehouse.sql | 43 ++ .../warehouse-pallet-batch-internal-v1.yaml | 93 +++++ .../SchemaChineseCommentContractTest.java | 6 +- .../WarehousePalletBatchContractTest.java | 44 ++ .../WarehousePalletBatchServiceTest.java | 381 ++++++++++++++++++ .../service/WarehouseResourceServiceTest.java | 19 + 22 files changed, 1227 insertions(+), 6 deletions(-) create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletBatchController.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchResponse.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchItemEntity.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchItemMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchMapper.java create mode 100644 warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java create mode 100644 warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehousePalletBatchContractTest.java create mode 100644 warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 3f6e67927..11ae9469b 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1991,3 +1991,31 @@ CALL assert_tail_order_commands_ready(); DROP PROCEDURE IF EXISTS assert_tail_order_commands_ready; SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message; + +-- 柜级入库计划跨客户批量托盘预留、确认与失败补偿。 +USE baiyue_lastlag_warehouse; +CREATE TABLE IF NOT EXISTS wh_pallet_batch ( + id BIGINT NOT NULL COMMENT '批量托盘预留ID', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + batch_key VARCHAR(160) NOT NULL COMMENT '柜级入库计划批次稳定业务键', request_hash CHAR(64) NOT NULL COMMENT '规范化批量请求SHA-256摘要', + status VARCHAR(32) NOT NULL COMMENT '批次状态:RESERVED、CONFIRMED、COMPENSATED', attempt_no INT NOT NULL DEFAULT 1 COMMENT '同批次重新预留尝试次数', + confirm_command_key VARCHAR(160) NULL COMMENT '确认命令幂等键', confirm_command_hash CHAR(64) NULL COMMENT '确认命令规范化SHA-256摘要', + compensate_command_key VARCHAR(160) NULL COMMENT '补偿命令幂等键', compensate_command_hash CHAR(64) NULL COMMENT '补偿命令规范化SHA-256摘要', + compensate_reason VARCHAR(500) NULL COMMENT '补偿原因', confirmed_at DATETIME(3) NULL COMMENT '确认绑定时间', + compensated_at DATETIME(3) NULL COMMENT '补偿释放时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_batch_key (warehouse_id,batch_key), + KEY idx_wh_pallet_batch_status (warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘预留与确认记录'; +CREATE TABLE IF NOT EXISTS wh_pallet_batch_item ( + id BIGINT NOT NULL COMMENT '批量托盘明细ID', batch_id BIGINT NOT NULL COMMENT '所属批量托盘预留ID', + customer_id BIGINT NOT NULL COMMENT '实际占用客户ID', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + business_key VARCHAR(100) NOT NULL COMMENT '柜号入库计划序号组成的稳定业务键', inbound_plan_no VARCHAR(128) NOT NULL COMMENT '入库计划号快照', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', pallet_id BIGINT NOT NULL COMMENT '公共实物托盘ID', + pallet_code VARCHAR(64) NOT NULL COMMENT '资源服务生成的规范托盘码快照', location_id BIGINT NOT NULL COMMENT '公共托盘当前预留库位ID', + resource_lock_id BIGINT NOT NULL COMMENT '按客户隔离的资源锁ID', status VARCHAR(32) NOT NULL COMMENT '明细状态:RESERVED、CONFIRMED、RELEASED', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key), + UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id), + KEY idx_wh_pallet_batch_item_batch (batch_id,status) +) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联'; diff --git a/scripts/db/incremental.sql b/scripts/db/incremental.sql index b2843d52c..715e1cfda 100644 --- a/scripts/db/incremental.sql +++ b/scripts/db/incremental.sql @@ -3834,6 +3834,34 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab 'SELECT 1', 'ALTER TABLE ll_cargo_discard_application ADD COLUMN review_idempotency_key VARCHAR(255) NULL COMMENT ''审核幂等键'' AFTER request_hash')); PREPARE phase4_discard_review_stmt FROM @ddl; EXECUTE phase4_discard_review_stmt; DEALLOCATE PREPARE phase4_discard_review_stmt; +-- 柜级入库计划跨客户批量托盘预留、确认与失败补偿。 +USE baiyue_lastlag_warehouse; +CREATE TABLE IF NOT EXISTS wh_pallet_batch ( + id BIGINT NOT NULL COMMENT '批量托盘预留ID', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + batch_key VARCHAR(160) NOT NULL COMMENT '柜级入库计划批次稳定业务键', request_hash CHAR(64) NOT NULL COMMENT '规范化批量请求SHA-256摘要', + status VARCHAR(32) NOT NULL COMMENT '批次状态:RESERVED、CONFIRMED、COMPENSATED', attempt_no INT NOT NULL DEFAULT 1 COMMENT '同批次重新预留尝试次数', + confirm_command_key VARCHAR(160) NULL COMMENT '确认命令幂等键', confirm_command_hash CHAR(64) NULL COMMENT '确认命令规范化SHA-256摘要', + compensate_command_key VARCHAR(160) NULL COMMENT '补偿命令幂等键', compensate_command_hash CHAR(64) NULL COMMENT '补偿命令规范化SHA-256摘要', + compensate_reason VARCHAR(500) NULL COMMENT '补偿原因', confirmed_at DATETIME(3) NULL COMMENT '确认绑定时间', + compensated_at DATETIME(3) NULL COMMENT '补偿释放时间', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_batch_key (warehouse_id,batch_key), + KEY idx_wh_pallet_batch_status (warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘预留与确认记录'; +CREATE TABLE IF NOT EXISTS wh_pallet_batch_item ( + id BIGINT NOT NULL COMMENT '批量托盘明细ID', batch_id BIGINT NOT NULL COMMENT '所属批量托盘预留ID', + customer_id BIGINT NOT NULL COMMENT '实际占用客户ID', warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + business_key VARCHAR(100) NOT NULL COMMENT '柜号入库计划序号组成的稳定业务键', inbound_plan_no VARCHAR(128) NOT NULL COMMENT '入库计划号快照', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', pallet_id BIGINT NOT NULL COMMENT '公共实物托盘ID', + pallet_code VARCHAR(64) NOT NULL COMMENT '资源服务生成的规范托盘码快照', location_id BIGINT NOT NULL COMMENT '公共托盘当前预留库位ID', + resource_lock_id BIGINT NOT NULL COMMENT '按客户隔离的资源锁ID', status VARCHAR(32) NOT NULL COMMENT '明细状态:RESERVED、CONFIRMED、RELEASED', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), UNIQUE KEY uk_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key), + UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id), + KEY idx_wh_pallet_batch_item_batch (batch_id,status) +) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联'; + USE baiyue_lastleg; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_outbound_order' AND column_name='lx_codes'), 'SELECT 1', 'ALTER TABLE ll_outbound_order ADD COLUMN lx_codes VARCHAR(1000) NULL COMMENT ''关联一件代发领星入库单代码汇总'' AFTER outbound_type')); PREPARE ll_outbound_lx_codes_stmt FROM @ddl; EXECUTE ll_outbound_lx_codes_stmt; DEALLOCATE PREPARE ll_outbound_lx_codes_stmt; diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index 0662bfcfc..47622f276 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -15,6 +15,7 @@ | 内部资源锁定、释放、报废 | `controller/InternalWarehouseResourceLockController.java` | | PDA 托盘状态流转与补偿 | `controller/InternalPdaPalletTransitionController.java`、`service/WarehousePalletTransitionService.java`、`mapper/WarehousePalletTransitionMapper.java`;内部前缀 `/internal/v1/pda-pallet-transitions` | | 托盘解析与业务绑定 | `controller/InternalWarehousePalletController.java`、`dto/WarehousePalletResolveResponse.java`、`entity/WarehousePalletBindingEntity.java` | +| 柜级入库计划批量托盘 | `controller/InternalWarehousePalletBatchController.java`、`service/WarehousePalletBatchService.java`、`warehouse-pallet-batch-internal-v1.yaml`;先预留,Lastleg 本地成功后确认,失败时仅补偿未确认资源 | | 仓储资源服务 | `service/WarehouseResourceService.java`、`WarehouseResourceLockService.java` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库基线与中文注释契约 | `src/main/resources/db/warehouse.sql`、`src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java` | @@ -39,6 +40,8 @@ | 资源锁 | `wh_resource_lock` | 仓储资源锁定记录 | | 托盘绑定 | `wh_pallet_business_binding` | 托盘业务绑定关系 | | 托盘操作 | `wh_pallet_operation` | PDA 托盘物理状态流转、幂等与补偿记录 | +| 批量预留 | `wh_pallet_batch` | 柜级入库计划批量托盘预留与确认记录 | +| 批量明细 | `wh_pallet_batch_item` | 跨客户批量托盘明细与资源锁关联 | - 新增或修改表时,表 `COMMENT` 和每个普通字段的 `COMMENT` 必须填写准确的中文业务含义。 - `SchemaChineseCommentContractTest` 自动扫描全部 `wh_` 表;新增字段必须同步更新基线 SQL、统一增量 SQL 和存量备注迁移。 diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletBatchController.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletBatchController.java new file mode 100644 index 000000000..6e1d23408 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/controller/InternalWarehousePalletBatchController.java @@ -0,0 +1,45 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.warehouse.dto.WarehousePalletBatchRequests; +import com.baiyue.warehouse.dto.WarehousePalletBatchResponse; +import com.baiyue.warehouse.security.InternalServiceTokenGuard; +import com.baiyue.warehouse.service.WarehousePalletBatchService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/internal/v1/warehouse-pallet-batches") +public class InternalWarehousePalletBatchController { + @Resource private WarehousePalletBatchService service; + @Resource private InternalServiceTokenGuard tokenGuard; + + @PostMapping("/ensure-reserved") + public ApiResponse ensureReserved( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WarehousePalletBatchRequests.EnsureReserved request) { + tokenGuard.verify(token); + return ApiResponse.success(service.ensureReserved(request)); + } + + @PostMapping("/confirm") + public ApiResponse confirm( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WarehousePalletBatchRequests.Confirm request) { + tokenGuard.verify(token); + return ApiResponse.success(service.confirm(request)); + } + + @PostMapping("/compensate") + public ApiResponse compensate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody WarehousePalletBatchRequests.Compensate request) { + tokenGuard.verify(token); + return ApiResponse.success(service.compensate(request)); + } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java new file mode 100644 index 000000000..a15574a96 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchRequests.java @@ -0,0 +1,41 @@ +package com.baiyue.warehouse.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +public final class WarehousePalletBatchRequests { + private WarehousePalletBatchRequests() { } + + public record EnsureReserved( + @Positive long warehouseId, + @NotBlank @Size(max = 160) String batchKey, + @NotEmpty @Size(max = 200) List<@Valid Item> items + ) { } + + public record Item( + @Positive long customerId, + @NotBlank @Size(max = 100) String businessKey, + @NotBlank @Size(max = 128) String inboundPlanNo, + @Positive int sequenceNo, + @NotBlank @Size(max = 32) String palletType, + @Size(max = 128) String fbaWarehouseCode + ) { } + + public record Confirm( + @Positive long warehouseId, + @NotBlank @Size(max = 160) String batchKey, + @NotBlank @Size(max = 160) String commandKey + ) { } + + public record Compensate( + @Positive long warehouseId, + @NotBlank @Size(max = 160) String batchKey, + @NotBlank @Size(max = 160) String commandKey, + @NotBlank @Size(max = 500) String reason + ) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchResponse.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchResponse.java new file mode 100644 index 000000000..d1c0fa7e5 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/dto/WarehousePalletBatchResponse.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.dto; + +import java.util.List; + +public record WarehousePalletBatchResponse( + long batchId, + String batchKey, + String status, + int attemptNo, + boolean replayed, + List items +) { + public record Item( + long customerId, + String businessKey, + String inboundPlanNo, + int sequenceNo, + long palletId, + String palletCode, + long locationId, + long resourceLockId, + String status + ) { } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchEntity.java new file mode 100644 index 000000000..fb5e0235f --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchEntity.java @@ -0,0 +1,29 @@ +package com.baiyue.warehouse.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; + +@Data +@TableName("wh_pallet_batch") +public class WarehousePalletBatchEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long warehouseId; + private String batchKey; + private String requestHash; + private String status; + private Integer attemptNo; + private String confirmCommandKey; + private String confirmCommandHash; + private String compensateCommandKey; + private String compensateCommandHash; + private String compensateReason; + private LocalDateTime confirmedAt; + private LocalDateTime compensatedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchItemEntity.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchItemEntity.java new file mode 100644 index 000000000..79ad9b008 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/entity/WarehousePalletBatchItemEntity.java @@ -0,0 +1,28 @@ +package com.baiyue.warehouse.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; + +@Data +@TableName("wh_pallet_batch_item") +public class WarehousePalletBatchItemEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long batchId; + private Long customerId; + private Long warehouseId; + private String businessKey; + private String inboundPlanNo; + private Integer sequenceNo; + private Long palletId; + private String palletCode; + private Long locationId; + private Long resourceLockId; + private String status; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchItemMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchItemMapper.java new file mode 100644 index 000000000..fb6e5a67e --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchItemMapper.java @@ -0,0 +1,15 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehousePalletBatchItemEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +@Mapper +public interface WarehousePalletBatchItemMapper extends BaseMapper { + @Select("SELECT * FROM wh_pallet_batch_item WHERE batch_id=#{batchId} ORDER BY sequence_no,id FOR UPDATE") + List lockByBatch(@Param("batchId") long batchId); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchMapper.java new file mode 100644 index 000000000..2bb0ca9cd --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBatchMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.warehouse.entity.WarehousePalletBatchEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface WarehousePalletBatchMapper extends BaseMapper { + @Insert("INSERT IGNORE INTO wh_pallet_batch " + + "(id,warehouse_id,batch_key,request_hash,status,attempt_no,created_at,updated_at) VALUES " + + "(#{id},#{warehouseId},#{batchKey},#{requestHash},#{status},#{attemptNo},#{createdAt},#{updatedAt})") + int insertIgnore(WarehousePalletBatchEntity entity); + + @Select("SELECT * FROM wh_pallet_batch WHERE warehouse_id=#{warehouseId} AND batch_key=#{batchKey} " + + "LIMIT 1 FOR UPDATE") + WarehousePalletBatchEntity lockByKey(@Param("warehouseId") long warehouseId, + @Param("batchKey") String batchKey); +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java index eb5c39c1c..a9dee2e8a 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/mapper/WarehousePalletBindingMapper.java @@ -13,4 +13,11 @@ public interface WarehousePalletBindingMapper extends BaseMapper() - .eq(WarehouseResourceLockEntity::getCustomerId, customerId) - .eq(WarehouseResourceLockEntity::getId, lockId)); + return locks.lockById(customerId, lockId); } public WarehouseResourceLockEntity findActiveByPallet(long customerId, long palletId) { diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java index bcc991bcd..2cac2c33d 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/repository/WarehouseResourceRepository.java @@ -28,6 +28,11 @@ public class WarehouseResourceRepository { return bindings.find(customerId, warehouseId, businessType, businessId); } + public WarehousePalletBindingEntity lockPalletBinding(long customerId, long warehouseId, + String businessType, String businessId) { + return bindings.lockByBusiness(customerId, warehouseId, businessType, businessId); + } + public void insertPalletBinding(WarehousePalletBindingEntity entity) { bindings.insert(entity); } diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java new file mode 100644 index 000000000..d52a09257 --- /dev/null +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java @@ -0,0 +1,359 @@ +package com.baiyue.warehouse.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.dto.WarehousePalletBatchRequests; +import com.baiyue.warehouse.dto.WarehousePalletBatchResponse; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.entity.WarehousePalletBatchEntity; +import com.baiyue.warehouse.entity.WarehousePalletBatchItemEntity; +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.mapper.WarehousePalletBatchItemMapper; +import com.baiyue.warehouse.mapper.WarehousePalletBatchMapper; +import com.baiyue.warehouse.repository.WarehouseResourceRepository; +import com.baiyue.warehouse.repository.WarehouseResourceLockRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +@Service +public class WarehousePalletBatchService { + private static final String BUSINESS_TYPE = "INBOUND_PLAN_AUTO"; + + @Resource private WarehousePalletBatchMapper batches; + @Resource private WarehousePalletBatchItemMapper items; + @Resource private WarehouseResourceService resources; + @Resource private WarehouseResourceLockService locks; + @Resource private WarehouseResourceLockRepository lockRecords; + @Resource private WarehouseResourceRepository repository; + @Resource private ObjectMapper objectMapper; + + @Transactional + public WarehousePalletBatchResponse ensureReserved(WarehousePalletBatchRequests.EnsureReserved request) { + WarehousePalletBatchRequests.EnsureReserved normalized = normalize(request); + String requestHash = hash(normalized); + WarehousePalletBatchEntity existing = batches.lockByKey(normalized.warehouseId(), normalized.batchKey()); + if (existing != null) { + requireSameRequest(existing, normalized.warehouseId(), requestHash); + if ("COMPENSATED".equals(existing.getStatus())) return reactivate(existing); + return response(existing, items.lockByBatch(existing.getId()), true); + } + + WarehousePalletBatchEntity batch = new WarehousePalletBatchEntity(); + batch.setId(IdWorker.getId()); + batch.setWarehouseId(normalized.warehouseId()); + batch.setBatchKey(normalized.batchKey()); + batch.setRequestHash(requestHash); + batch.setStatus("RESERVED"); + batch.setAttemptNo(1); + batch.setCreatedAt(LocalDateTime.now()); + batch.setUpdatedAt(batch.getCreatedAt()); + if (batches.insertIgnore(batch) == 0) { + WarehousePalletBatchEntity concurrent = batches.lockByKey(normalized.warehouseId(), normalized.batchKey()); + if (concurrent == null) { + throw conflict("Pallet batch was created concurrently but cannot be loaded"); + } + requireSameRequest(concurrent, normalized.warehouseId(), requestHash); + if ("COMPENSATED".equals(concurrent.getStatus())) return reactivate(concurrent); + return response(concurrent, items.lockByBatch(concurrent.getId()), true); + } + + for (WarehousePalletBatchRequests.Item requested : normalized.items()) { + if (repository.lockPalletBinding(requested.customerId(), normalized.warehouseId(), BUSINESS_TYPE, + requested.businessKey()) != null) { + throw conflict("Inbound plan pallet business key is already bound"); + } + } + + List created = new ArrayList<>(); + for (WarehousePalletBatchRequests.Item requested : normalized.items()) { + Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, + normalized.warehouseId(), new PalletRequests.SaveRequest(null, requested.palletType(), null, + BigDecimal.ZERO, "IDLE", null, "Auto-created for inbound plan " + requested.inboundPlanNo(), + requested.fbaWarehouseCode())); + WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, + normalized.warehouseId(), palletId); + WarehouseResourceLockResponse lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest( + requested.customerId(), normalized.warehouseId(), pallet.getId(), pallet.getLocationId(), + BUSINESS_TYPE, requested.businessKey(), reservationKey(batch, requested.sequenceNo()))); + + WarehousePalletBatchItemEntity item = new WarehousePalletBatchItemEntity(); + item.setId(IdWorker.getId()); + item.setBatchId(batch.getId()); + item.setCustomerId(requested.customerId()); + item.setWarehouseId(normalized.warehouseId()); + item.setBusinessKey(requested.businessKey()); + item.setInboundPlanNo(requested.inboundPlanNo()); + item.setSequenceNo(requested.sequenceNo()); + item.setPalletId(pallet.getId()); + item.setPalletCode(pallet.getPalletCode()); + item.setLocationId(pallet.getLocationId()); + item.setResourceLockId(lock.lockId()); + item.setStatus("RESERVED"); + item.setCreatedAt(LocalDateTime.now()); + item.setUpdatedAt(item.getCreatedAt()); + items.insert(item); + created.add(item); + } + return response(batch, created, false); + } + + @Transactional + public WarehousePalletBatchResponse confirm(WarehousePalletBatchRequests.Confirm request) { + WarehousePalletBatchRequests.Confirm normalized = normalize(request); + String commandHash = hash(normalized); + WarehousePalletBatchEntity batch = requiredBatch(normalized.warehouseId(), normalized.batchKey()); + List batchItems = items.lockByBatch(batch.getId()); + if ("CONFIRMED".equals(batch.getStatus())) { + requireSameCommand(batch.getConfirmCommandHash(), commandHash); + return response(batch, batchItems, true); + } + if (!"RESERVED".equals(batch.getStatus())) { + throw conflict("Only a RESERVED pallet batch can be confirmed"); + } + for (WarehousePalletBatchItemEntity item : batchItems) { + var lock = lockRecords.find(item.getCustomerId(), item.getResourceLockId()); + if (lock == null || !"ACTIVE".equals(lock.getStatus()) + || !Objects.equals(lock.getWarehouseId(), batch.getWarehouseId()) + || !Objects.equals(lock.getPalletId(), item.getPalletId()) + || !Objects.equals(lock.getLocationId(), item.getLocationId()) + || !BUSINESS_TYPE.equals(lock.getBusinessType()) + || !item.getBusinessKey().equals(lock.getBusinessId())) { + throw conflict("Inbound plan pallet reservation is no longer active"); + } + WarehousePalletBindingEntity binding = repository.lockPalletBinding(item.getCustomerId(), + batch.getWarehouseId(), BUSINESS_TYPE, item.getBusinessKey()); + if (binding == null) { + binding = new WarehousePalletBindingEntity(); + binding.setId(IdWorker.getId()); + binding.setCustomerId(item.getCustomerId()); + binding.setWarehouseId(batch.getWarehouseId()); + binding.setBusinessType(BUSINESS_TYPE); + binding.setBusinessId(item.getBusinessKey()); + binding.setPalletId(item.getPalletId()); + binding.setCreatedAt(LocalDateTime.now()); + repository.insertPalletBinding(binding); + } else if (!Objects.equals(binding.getPalletId(), item.getPalletId())) { + throw conflict("Inbound plan pallet business key is already bound to another pallet"); + } + if (lockRecords.close(lock, "CONFIRMED") != 1) { + throw conflict("Inbound plan pallet reservation changed concurrently"); + } + item.setStatus("CONFIRMED"); + item.setUpdatedAt(LocalDateTime.now()); + items.updateById(item); + } + batch.setStatus("CONFIRMED"); + batch.setConfirmCommandKey(normalized.commandKey()); + batch.setConfirmCommandHash(commandHash); + batch.setConfirmedAt(LocalDateTime.now()); + batch.setUpdatedAt(batch.getConfirmedAt()); + batches.updateById(batch); + return response(batch, batchItems, false); + } + + @Transactional + public WarehousePalletBatchResponse compensate(WarehousePalletBatchRequests.Compensate request) { + WarehousePalletBatchRequests.Compensate normalized = normalize(request); + String commandHash = hash(normalized); + WarehousePalletBatchEntity batch = requiredBatch(normalized.warehouseId(), normalized.batchKey()); + List batchItems = items.lockByBatch(batch.getId()); + if ("COMPENSATED".equals(batch.getStatus())) { + requireSameCommand(batch.getCompensateCommandHash(), commandHash); + return response(batch, batchItems, true); + } + if (!"RESERVED".equals(batch.getStatus())) { + throw conflict("Confirmed or subsequently occupied pallets cannot be compensated"); + } + for (WarehousePalletBatchItemEntity item : batchItems) { + WarehousePalletBindingEntity binding = repository.lockPalletBinding(item.getCustomerId(), + batch.getWarehouseId(), BUSINESS_TYPE, item.getBusinessKey()); + if (binding != null && Objects.equals(binding.getPalletId(), item.getPalletId())) { + throw conflict("A bound inbound plan pallet cannot be compensated"); + } + locks.release(item.getCustomerId(), item.getResourceLockId()); + item.setStatus("RELEASED"); + item.setUpdatedAt(LocalDateTime.now()); + items.updateById(item); + } + batch.setStatus("COMPENSATED"); + batch.setCompensateCommandKey(normalized.commandKey()); + batch.setCompensateCommandHash(commandHash); + batch.setCompensateReason(normalized.reason()); + batch.setCompensatedAt(LocalDateTime.now()); + batch.setUpdatedAt(batch.getCompensatedAt()); + batches.updateById(batch); + return response(batch, batchItems, false); + } + + private WarehousePalletBatchResponse reactivate(WarehousePalletBatchEntity batch) { + List batchItems = items.lockByBatch(batch.getId()); + int attempt = number(batch.getAttemptNo()) + 1; + batch.setAttemptNo(attempt); + for (WarehousePalletBatchItemEntity item : batchItems) { + if (repository.lockPalletBinding(item.getCustomerId(), batch.getWarehouseId(), BUSINESS_TYPE, + item.getBusinessKey()) != null) { + throw conflict("Inbound plan pallet business key became bound after compensation"); + } + } + for (WarehousePalletBatchItemEntity item : batchItems) { + WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, + batch.getWarehouseId(), item.getPalletId()); + WarehouseResourceLockResponse lock = locks.reserve(new WarehouseResourceLockRequests.ReserveRequest( + item.getCustomerId(), batch.getWarehouseId(), pallet.getId(), item.getLocationId(), BUSINESS_TYPE, + item.getBusinessKey(), reservationKey(batch.getId(), attempt, item.getSequenceNo()))); + item.setResourceLockId(lock.lockId()); + item.setStatus("RESERVED"); + item.setUpdatedAt(LocalDateTime.now()); + items.updateById(item); + } + batch.setStatus("RESERVED"); + batch.setCompensateCommandKey(null); + batch.setCompensateCommandHash(null); + batch.setCompensateReason(null); + batch.setCompensatedAt(null); + batch.setUpdatedAt(LocalDateTime.now()); + batches.updateById(batch); + return response(batch, batchItems, false); + } + + private WarehousePalletBatchEntity requiredBatch(long warehouseId, String batchKey) { + WarehousePalletBatchEntity batch = batches.lockByKey(warehouseId, required(batchKey, "Batch key is required")); + if (batch == null) throw new BizException(ResultCode.NOT_FOUND, "Warehouse pallet batch not found"); + return batch; + } + + private static WarehousePalletBatchRequests.EnsureReserved normalize( + WarehousePalletBatchRequests.EnsureReserved request) { + if (request == null || request.warehouseId() <= 0) { + throw badRequest("Warehouse is required"); + } + String batchKey = required(request.batchKey(), "Batch key is required"); + requireMax(batchKey, 160, "Batch key is too long"); + if (request.items() == null || request.items().isEmpty() || request.items().size() > 200) { + throw badRequest("One to 200 pallet items are required"); + } + Set businessKeys = new HashSet<>(); + Set sequences = new HashSet<>(); + List normalized = request.items().stream().map(item -> { + if (item == null || item.customerId() <= 0 || item.sequenceNo() <= 0) { + throw badRequest("Pallet customer and sequence must be positive"); + } + String businessKey = required(item.businessKey(), "Pallet business key is required"); + String inboundPlanNo = required(item.inboundPlanNo(), "Inbound plan number is required"); + String palletType = required(item.palletType(), "Pallet type is required"); + requireMax(businessKey, 100, "Pallet business key is too long"); + requireMax(inboundPlanNo, 128, "Inbound plan number is too long"); + requireMax(palletType, 32, "Pallet type is too long"); + if (item.fbaWarehouseCode() != null) { + requireMax(item.fbaWarehouseCode().trim(), 128, "FBA warehouse code is too long"); + } + if (!businessKeys.add(businessKey)) throw badRequest("Pallet business key must be unique"); + if (!sequences.add(item.sequenceNo())) throw badRequest("Pallet sequence must be unique"); + return new WarehousePalletBatchRequests.Item(item.customerId(), businessKey, inboundPlanNo, + item.sequenceNo(), palletType, + trim(item.fbaWarehouseCode())); + }).sorted(Comparator.comparingInt(WarehousePalletBatchRequests.Item::sequenceNo)).toList(); + return new WarehousePalletBatchRequests.EnsureReserved(request.warehouseId(), batchKey, normalized); + } + + private static WarehousePalletBatchRequests.Confirm normalize(WarehousePalletBatchRequests.Confirm request) { + if (request == null || request.warehouseId() <= 0) throw badRequest("Warehouse is required"); + String batchKey = required(request.batchKey(), "Batch key is required"); + String commandKey = required(request.commandKey(), "Confirm command key is required"); + requireMax(batchKey, 160, "Batch key is too long"); + requireMax(commandKey, 160, "Confirm command key is too long"); + return new WarehousePalletBatchRequests.Confirm(request.warehouseId(), batchKey, commandKey); + } + + private static WarehousePalletBatchRequests.Compensate normalize( + WarehousePalletBatchRequests.Compensate request) { + if (request == null || request.warehouseId() <= 0) throw badRequest("Warehouse is required"); + String batchKey = required(request.batchKey(), "Batch key is required"); + String commandKey = required(request.commandKey(), "Compensation command key is required"); + String reason = required(request.reason(), "Compensation reason is required"); + requireMax(batchKey, 160, "Batch key is too long"); + requireMax(commandKey, 160, "Compensation command key is too long"); + requireMax(reason, 500, "Compensation reason is too long"); + return new WarehousePalletBatchRequests.Compensate(request.warehouseId(), batchKey, commandKey, reason); + } + + private static void requireSameRequest(WarehousePalletBatchEntity batch, long warehouseId, String requestHash) { + if (batch.getWarehouseId() != warehouseId || !requestHash.equals(batch.getRequestHash())) { + throw conflict("Batch key was already used with a different pallet request"); + } + } + + private static void requireSameCommand(String storedHash, String requestHash) { + if (!Objects.equals(storedHash, requestHash)) { + throw conflict("Terminal pallet batch command cannot be replayed with a different payload"); + } + } + + private WarehousePalletBatchResponse response(WarehousePalletBatchEntity batch, + List batchItems, + boolean replayed) { + return new WarehousePalletBatchResponse(batch.getId(), batch.getBatchKey(), batch.getStatus(), + number(batch.getAttemptNo()), replayed, batchItems.stream().map(item -> + new WarehousePalletBatchResponse.Item(item.getCustomerId(), item.getBusinessKey(), item.getInboundPlanNo(), + item.getSequenceNo(), item.getPalletId(), item.getPalletCode(), item.getLocationId(), + item.getResourceLockId(), item.getStatus())).toList()); + } + + private String hash(Object value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(write(value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException(exception); + } + } + + private String write(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("Cannot serialize warehouse pallet batch request", exception); + } + } + + private static String reservationKey(WarehousePalletBatchEntity batch, int sequenceNo) { + return reservationKey(batch.getId(), number(batch.getAttemptNo()), sequenceNo); + } + + private static String reservationKey(long batchId, int attemptNo, int sequenceNo) { + return "INBOUND_PLAN_AUTO:" + batchId + ":" + attemptNo + ":" + sequenceNo; + } + + private static int number(Integer value) { return value == null ? 0 : value; } + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + private static String required(String value, String message) { + String normalized = trim(value); + if (normalized == null) throw badRequest(message); + return normalized; + } + private static void requireMax(String value, int max, String message) { + if (value.length() > max) throw badRequest(message); + } + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT, message); } + private static BizException badRequest(String message) { return new BizException(ResultCode.BAD_REQUEST, message); } +} diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java index 9b5aee983..202881ed8 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehouseResourceService.java @@ -358,7 +358,8 @@ public class WarehouseResourceService { } private static String generatedPalletCode(String warehouseCode, long palletId) { - return "PLT-" + warehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + String boundedWarehouseCode = warehouseCode.length() <= 30 ? warehouseCode : warehouseCode.substring(0, 30); + return "PLT-" + boundedWarehouseCode + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + Long.toUnsignedString(palletId); } diff --git a/warehouse-overseas-service/src/main/resources/db/warehouse.sql b/warehouse-overseas-service/src/main/resources/db/warehouse.sql index fd952af77..b6acb11c6 100644 --- a/warehouse-overseas-service/src/main/resources/db/warehouse.sql +++ b/warehouse-overseas-service/src/main/resources/db/warehouse.sql @@ -123,6 +123,49 @@ CREATE TABLE IF NOT EXISTS wh_pallet_operation ( KEY idx_wh_pallet_operation_pallet (warehouse_id,pallet_id,created_at) ) ENGINE=InnoDB COMMENT='实物托盘状态幂等变更与补偿记录'; +CREATE TABLE IF NOT EXISTS wh_pallet_batch ( + id BIGINT NOT NULL COMMENT '批量托盘预留ID', + warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + batch_key VARCHAR(160) NOT NULL COMMENT '柜级入库计划批次稳定业务键', + request_hash CHAR(64) NOT NULL COMMENT '规范化批量请求SHA-256摘要', + status VARCHAR(32) NOT NULL COMMENT '批次状态:RESERVED、CONFIRMED、COMPENSATED', + attempt_no INT NOT NULL DEFAULT 1 COMMENT '同批次重新预留尝试次数', + confirm_command_key VARCHAR(160) NULL COMMENT '确认命令幂等键', + confirm_command_hash CHAR(64) NULL COMMENT '确认命令规范化SHA-256摘要', + compensate_command_key VARCHAR(160) NULL COMMENT '补偿命令幂等键', + compensate_command_hash CHAR(64) NULL COMMENT '补偿命令规范化SHA-256摘要', + compensate_reason VARCHAR(500) NULL COMMENT '补偿原因', + confirmed_at DATETIME(3) NULL COMMENT '确认绑定时间', + compensated_at DATETIME(3) NULL COMMENT '补偿释放时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_wh_pallet_batch_key (warehouse_id,batch_key), + KEY idx_wh_pallet_batch_status (warehouse_id,status,updated_at) +) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘预留与确认记录'; + +CREATE TABLE IF NOT EXISTS wh_pallet_batch_item ( + id BIGINT NOT NULL COMMENT '批量托盘明细ID', + batch_id BIGINT NOT NULL COMMENT '所属批量托盘预留ID', + customer_id BIGINT NOT NULL COMMENT '实际占用客户ID', + warehouse_id BIGINT NOT NULL COMMENT '实际海外仓ID', + business_key VARCHAR(100) NOT NULL COMMENT '柜号入库计划序号组成的稳定业务键', + inbound_plan_no VARCHAR(128) NOT NULL COMMENT '入库计划号快照', + sequence_no INT NOT NULL COMMENT '计划内托盘序号', + pallet_id BIGINT NOT NULL COMMENT '公共实物托盘ID', + pallet_code VARCHAR(64) NOT NULL COMMENT '资源服务生成的规范托盘码快照', + location_id BIGINT NOT NULL COMMENT '公共托盘当前预留库位ID', + resource_lock_id BIGINT NOT NULL COMMENT '按客户隔离的资源锁ID', + status VARCHAR(32) NOT NULL COMMENT '明细状态:RESERVED、CONFIRMED、RELEASED', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_wh_pallet_batch_item_business (customer_id,warehouse_id,business_key), + UNIQUE KEY uk_wh_pallet_batch_item_sequence (batch_id,sequence_no), + UNIQUE KEY uk_wh_pallet_batch_item_pallet (pallet_id), + KEY idx_wh_pallet_batch_item_batch (batch_id,status) +) ENGINE=InnoDB COMMENT='柜级入库计划批量托盘明细与资源锁关联'; + ALTER TABLE wh_pallet COMMENT='仓库托盘表:按租户和仓库维护托盘、容量、单价及状态'; ALTER TABLE wh_pallet MODIFY COLUMN id BIGINT UNSIGNED NOT NULL COMMENT '托盘ID(雪花ID)', diff --git a/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml b/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml new file mode 100644 index 000000000..c7a6792f2 --- /dev/null +++ b/warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml @@ -0,0 +1,93 @@ +openapi: 3.0.3 +info: + title: Warehouse Overseas Internal Pallet Batch API + version: 1.0.0 +paths: + /internal/v1/warehouse-pallet-batches/ensure-reserved: + post: + summary: 按柜级入库计划稳定业务键批量确保并预留公共托盘 + description: 仅内部服务令牌;同柜批次可跨客户。物理托盘固定customer_id=0,每个item资源锁使用其真实customerId。同warehouseId+batchKey同请求重放不重复造码,异请求返回A0409;COMPENSATED批次同请求复用原托盘并开启新attempt。 + security: [{ internalServiceToken: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/EnsureReservedRequest' } } } } + responses: + '200': { description: 批量预留结果;业务冲突仍为HTTP 200且响应体code=A0409, content: { application/json: { schema: { $ref: '#/components/schemas/BatchApiResponse' } } } } + '401': { description: 内部服务令牌无效 } + /internal/v1/warehouse-pallet-batches/confirm: + post: + summary: 确认Lastleg已持久化托盘快照并建立业务绑定 + description: 仅RESERVED可确认;重复确认返回同一结果,不生成新托盘或资源锁。 + security: [{ internalServiceToken: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ConfirmRequest' } } } } + responses: + '200': { description: 已确认批次;业务冲突仍为HTTP 200且响应体code=A0409, content: { application/json: { schema: { $ref: '#/components/schemas/BatchApiResponse' } } } } + /internal/v1/warehouse-pallet-batches/compensate: + post: + summary: 补偿释放Lastleg本地失败对应的本批未确认资源 + description: 仅RESERVED且尚未建立业务绑定的本批资源可补偿;CONFIRMED或已被后续占用时返回A0409。 + security: [{ internalServiceToken: [] }] + requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/CompensateRequest' } } } } + responses: + '200': { description: 已补偿批次;业务冲突仍为HTTP 200且响应体code=A0409, content: { application/json: { schema: { $ref: '#/components/schemas/BatchApiResponse' } } } } +components: + securitySchemes: + internalServiceToken: { type: apiKey, in: header, name: X-Internal-Service-Token } + schemas: + BatchItemRequest: + type: object + required: [customerId, businessKey, inboundPlanNo, sequenceNo, palletType] + properties: + customerId: { type: string, pattern: '^[1-9][0-9]*$' } + businessKey: { type: string, minLength: 1, maxLength: 100 } + inboundPlanNo: { type: string, minLength: 1, maxLength: 128 } + sequenceNo: { type: integer, minimum: 1 } + palletType: { type: string, minLength: 1, maxLength: 32 } + fbaWarehouseCode: { type: string, nullable: true, maxLength: 128 } + EnsureReservedRequest: + type: object + required: [warehouseId, batchKey, items] + properties: + warehouseId: { type: string, pattern: '^[1-9][0-9]*$' } + batchKey: { type: string, minLength: 1, maxLength: 160 } + items: { type: array, minItems: 1, maxItems: 200, items: { $ref: '#/components/schemas/BatchItemRequest' } } + ConfirmRequest: + type: object + required: [warehouseId, batchKey, commandKey] + properties: + warehouseId: { type: string, pattern: '^[1-9][0-9]*$' } + batchKey: { type: string, minLength: 1, maxLength: 160 } + commandKey: { type: string, minLength: 1, maxLength: 160 } + CompensateRequest: + allOf: + - { $ref: '#/components/schemas/ConfirmRequest' } + - type: object + required: [reason] + properties: { reason: { type: string, minLength: 1, maxLength: 500 } } + BatchItemResponse: + type: object + required: [customerId, businessKey, inboundPlanNo, sequenceNo, palletId, palletCode, locationId, resourceLockId, status] + properties: + customerId: { type: string, pattern: '^[1-9][0-9]*$' } + businessKey: { type: string } + inboundPlanNo: { type: string } + sequenceNo: { type: integer, minimum: 1 } + palletId: { type: string, pattern: '^[1-9][0-9]*$' } + palletCode: { type: string, maxLength: 64 } + locationId: { type: string, pattern: '^[1-9][0-9]*$' } + resourceLockId: { type: string, pattern: '^[1-9][0-9]*$' } + status: { type: string, enum: [RESERVED, CONFIRMED, RELEASED] } + BatchResponse: + type: object + required: [batchId, batchKey, status, attemptNo, replayed, items] + properties: + batchId: { type: string, pattern: '^[1-9][0-9]*$' } + batchKey: { type: string } + status: { type: string, enum: [RESERVED, CONFIRMED, COMPENSATED] } + attemptNo: { type: integer, minimum: 1 } + replayed: { type: boolean } + items: { type: array, items: { $ref: '#/components/schemas/BatchItemResponse' } } + BatchApiResponse: + type: object + properties: + code: { type: string, example: '200' } + message: { type: string } + data: { $ref: '#/components/schemas/BatchResponse' } diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java index 040873e9e..d9f0faca9 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/SchemaChineseCommentContractTest.java @@ -29,7 +29,7 @@ class SchemaChineseCommentContractTest { Path.of("..", "scripts", "db", "incremental.sql"), StandardCharsets.UTF_8); List tables = parseAndAssert(baseline, "仓储基线 SQL"); - assertEquals(5, tables.size(), "海外仓资源表数量变化时必须同步表索引和备注迁移"); + assertEquals(7, tables.size(), "海外仓资源表数量变化时必须同步表索引和备注迁移"); assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_warehouse (")); assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_warehouse_address (")); assertFalse(baseline.contains("CREATE TABLE IF NOT EXISTS wh_service_capability (")); @@ -39,6 +39,10 @@ class SchemaChineseCommentContractTest { assertTrue(marker >= 0, "统一增量 SQL 缺少 025 中文备注迁移"); String migration = incremental.substring(marker); for (TableDefinition table : tables) { + if (Pattern.compile("(?is)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+" + table.name() + "\\b") + .matcher(migration).find()) { + continue; + } assertTrue(migration.contains("table_name='" + table.name() + "'"), "025 备注迁移缺少表: " + table.name()); for (String column : table.columns()) { diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehousePalletBatchContractTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehousePalletBatchContractTest.java new file mode 100644 index 000000000..3c4ef6ab7 --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/schema/WarehousePalletBatchContractTest.java @@ -0,0 +1,44 @@ +package com.baiyue.warehouse.schema; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehousePalletBatchContractTest { + + @Test + void baselineAndUnifiedIncrementalContainBatchTablesAndCommandHashes() throws Exception { + String baseline = read(Path.of("src", "main", "resources", "db", "warehouse.sql")); + String incremental = read(Path.of("..", "scripts", "db", "incremental.sql")); + String unified = read(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql")); + for (String sql : new String[] {baseline, incremental, unified}) { + assertThat(sql).contains("CREATE TABLE IF NOT EXISTS wh_pallet_batch (") + .contains("CREATE TABLE IF NOT EXISTS wh_pallet_batch_item (") + .contains("confirm_command_hash CHAR(64)") + .contains("compensate_command_hash CHAR(64)") + .contains("business_key VARCHAR(100)") + .contains("pallet_code VARCHAR(64)"); + } + } + + @Test + void internalOpenApiFreezesEnvelopeAndLengthContract() throws Exception { + String yaml = read(Path.of("src", "main", "resources", "openapi", + "warehouse-pallet-batch-internal-v1.yaml")); + assertThat(yaml).contains("/internal/v1/warehouse-pallet-batches/ensure-reserved:") + .contains("/internal/v1/warehouse-pallet-batches/confirm:") + .contains("/internal/v1/warehouse-pallet-batches/compensate:") + .contains("业务冲突仍为HTTP 200且响应体code=A0409") + .contains("businessKey: { type: string, minLength: 1, maxLength: 100 }") + .contains("palletCode: { type: string, maxLength: 64 }") + .doesNotContain("'409':"); + } + + private static String read(Path path) throws Exception { + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java new file mode 100644 index 000000000..292d6abee --- /dev/null +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java @@ -0,0 +1,381 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.dto.PalletRequests; +import com.baiyue.warehouse.dto.WarehousePalletBatchRequests; +import com.baiyue.warehouse.dto.WarehouseResourceLockRequests; +import com.baiyue.warehouse.dto.WarehouseResourceLockResponse; +import com.baiyue.warehouse.entity.WarehousePalletBatchEntity; +import com.baiyue.warehouse.entity.WarehousePalletBatchItemEntity; +import com.baiyue.warehouse.entity.WarehousePalletBindingEntity; +import com.baiyue.warehouse.entity.WarehousePalletEntity; +import com.baiyue.warehouse.entity.WarehouseResourceLockEntity; +import com.baiyue.warehouse.mapper.WarehousePalletBatchItemMapper; +import com.baiyue.warehouse.mapper.WarehousePalletBatchMapper; +import com.baiyue.warehouse.repository.WarehouseResourceLockRepository; +import com.baiyue.warehouse.repository.WarehouseResourceRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +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 WarehousePalletBatchServiceTest { + private static final long WAREHOUSE = 201L; + private static final long CUSTOMER_A = 101L; + private static final long CUSTOMER_B = 102L; + + @Mock WarehousePalletBatchMapper batches; + @Mock WarehousePalletBatchItemMapper items; + @Mock WarehouseResourceService resources; + @Mock WarehouseResourceLockService locks; + @Mock WarehouseResourceLockRepository lockRecords; + @Mock WarehouseResourceRepository repository; + private WarehousePalletBatchService service; + + @BeforeEach + void setUp() { + service = new WarehousePalletBatchService(); + ReflectionTestUtils.setField(service, "batches", batches); + ReflectionTestUtils.setField(service, "items", items); + ReflectionTestUtils.setField(service, "resources", resources); + ReflectionTestUtils.setField(service, "locks", locks); + ReflectionTestUtils.setField(service, "lockRecords", lockRecords); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + } + + @Test + void reservesPublicPalletsButIsolatesEachCrossCustomerLock() { + when(batches.insertIgnore(any())).thenReturn(1); + stubNewResources(); + + var response = service.ensureReserved(request()); + + assertThat(response.status()).isEqualTo("RESERVED"); + assertThat(response.items()).extracting(item -> item.customerId()).containsExactly(CUSTOMER_A, CUSTOMER_B); + ArgumentCaptor scope = ArgumentCaptor.forClass(Long.class); + verify(resources, times(2)).createPallet(scope.capture(), anyLong(), any(PalletRequests.SaveRequest.class)); + assertThat(scope.getAllValues()).containsOnly(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE); + ArgumentCaptor reservations = + ArgumentCaptor.forClass(WarehouseResourceLockRequests.ReserveRequest.class); + verify(locks, times(2)).reserve(reservations.capture()); + assertThat(reservations.getAllValues()).extracting(value -> value.customerId()) + .containsExactly(CUSTOMER_A, CUSTOMER_B); + assertThat(reservations.getAllValues()).extracting(value -> value.businessId()) + .containsExactly("container:plan-a:1", "container:plan-b:2"); + } + + @Test + void concurrentSameRequestReplaysWinningBatchWithoutCreatingResources() { + AtomicReference inserted = new AtomicReference<>(); + when(batches.lockByKey(WAREHOUSE, "container-1")).thenAnswer(call -> inserted.get()); + when(batches.insertIgnore(any())).thenAnswer(call -> { + WarehousePalletBatchEntity losingInsert = call.getArgument(0); + WarehousePalletBatchEntity winner = batch(losingInsert.getId(), "RESERVED", 1); + winner.setRequestHash(losingInsert.getRequestHash()); + inserted.set(winner); + return 0; + }); + when(items.lockByBatch(anyLong())).thenReturn(List.of(item(1L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"))); + + var response = service.ensureReserved(request()); + + assertThat(response.replayed()).isTrue(); + assertThat(response.batchId()).isEqualTo(inserted.get().getId()); + verify(resources, never()).createPallet(anyLong(), anyLong(), any()); + verify(locks, never()).reserve(any()); + } + + @Test + void sameBatchKeyWithDifferentPayloadIsRejectedAsA0409() { + WarehousePalletBatchEntity existing = batch(11L, "RESERVED", 1); + existing.setRequestHash("different"); + when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(existing); + + assertThatThrownBy(() -> service.ensureReserved(request())) + .isInstanceOf(BizException.class) + .hasMessageContaining("different pallet request"); + verify(resources, never()).createPallet(anyLong(), anyLong(), any()); + } + + @Test + void existingBusinessBindingIsRejectedBeforeAnyPalletIsCreated() { + when(batches.insertIgnore(any())).thenReturn(1); + when(repository.lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "container:plan-a:1")) + .thenReturn(new WarehousePalletBindingEntity()); + + assertThatThrownBy(() -> service.ensureReserved(request())) + .isInstanceOf(BizException.class) + .hasMessageContaining("already bound"); + verify(resources, never()).createPallet(anyLong(), anyLong(), any()); + } + + @Test + void confirmValidatesAndBindsEveryItemInItsOwnCustomerScope() { + WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1); + List batchItems = List.of( + item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"), + item(22L, CUSTOMER_B, 302L, 402L, 502L, 2, "RESERVED")); + when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch); + when(items.lockByBatch(11L)).thenReturn(batchItems); + when(lockRecords.find(CUSTOMER_A, 501L)).thenReturn(lock(CUSTOMER_A, batchItems.get(0))); + when(lockRecords.find(CUSTOMER_B, 502L)).thenReturn(lock(CUSTOMER_B, batchItems.get(1))); + when(lockRecords.close(any(), org.mockito.ArgumentMatchers.eq("CONFIRMED"))).thenReturn(1); + + var response = service.confirm(new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-1")); + + assertThat(response.status()).isEqualTo("CONFIRMED"); + assertThat(response.items()).extracting(value -> value.status()).containsOnly("CONFIRMED"); + verify(repository).lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-1"); + verify(repository).lockPalletBinding(CUSTOMER_B, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-2"); + ArgumentCaptor bindings = ArgumentCaptor.forClass(WarehousePalletBindingEntity.class); + verify(repository, times(2)).insertPalletBinding(bindings.capture()); + assertThat(bindings.getAllValues()).extracting(WarehousePalletBindingEntity::getCustomerId) + .containsExactly(CUSTOMER_A, CUSTOMER_B); + } + + @Test + void repeatedConfirmIsIdempotentAndDoesNotBindAgain() { + WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1); + WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"); + when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch); + when(items.lockByBatch(11L)).thenReturn(List.of(item)); + when(lockRecords.find(CUSTOMER_A, 501L)).thenReturn(lock(CUSTOMER_A, item)); + when(lockRecords.close(any(), org.mockito.ArgumentMatchers.eq("CONFIRMED"))).thenReturn(1); + var command = new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-1"); + + service.confirm(command); + var response = service.confirm(command); + + assertThat(response.replayed()).isTrue(); + verify(lockRecords, times(1)).find(CUSTOMER_A, 501L); + verify(repository, times(1)).insertPalletBinding(any()); + } + + @Test + void terminalConfirmRejectsDifferentCommandPayload() { + WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1); + WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"); + when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch); + when(items.lockByBatch(11L)).thenReturn(List.of(item)); + when(lockRecords.find(CUSTOMER_A, 501L)).thenReturn(lock(CUSTOMER_A, item)); + when(lockRecords.close(any(), org.mockito.ArgumentMatchers.eq("CONFIRMED"))).thenReturn(1); + service.confirm(new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-1")); + + assertThatThrownBy(() -> service.confirm( + new WarehousePalletBatchRequests.Confirm(WAREHOUSE, "container-1", "confirm-2"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("different payload"); + } + + @Test + void compensationReleasesOnlyThisBatchUsingEachCustomerScope() { + WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1); + List batchItems = List.of( + item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"), + item(22L, CUSTOMER_B, 302L, 402L, 502L, 2, "RESERVED")); + when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch); + when(items.lockByBatch(11L)).thenReturn(batchItems); + + var response = service.compensate( + new WarehousePalletBatchRequests.Compensate(WAREHOUSE, "container-1", "compensate-1", "lastleg failed")); + + assertThat(response.status()).isEqualTo("COMPENSATED"); + verify(locks).release(CUSTOMER_A, 501L); + verify(locks).release(CUSTOMER_B, 502L); + assertThat(batchItems).extracting(WarehousePalletBatchItemEntity::getStatus).containsOnly("RELEASED"); + } + + @Test + void compensationRejectsConfirmedOrAlreadyBoundResources() { + WarehousePalletBatchEntity confirmed = batch(11L, "CONFIRMED", 1); + when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(confirmed); + when(items.lockByBatch(11L)).thenReturn(List.of()); + var command = new WarehousePalletBatchRequests.Compensate( + WAREHOUSE, "container-1", "compensate-1", "lastleg failed"); + + assertThatThrownBy(() -> service.compensate(command)).isInstanceOf(BizException.class); + verify(locks, never()).release(anyLong(), anyLong()); + } + + @Test + void compensationRejectsBindingToThisBatchPallet() { + WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1); + WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"); + WarehousePalletBindingEntity binding = new WarehousePalletBindingEntity(); + binding.setPalletId(301L); + when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch); + when(items.lockByBatch(11L)).thenReturn(List.of(item)); + when(repository.lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-1")) + .thenReturn(binding); + + assertThatThrownBy(() -> service.compensate(new WarehousePalletBatchRequests.Compensate( + WAREHOUSE, "container-1", "compensate-1", "lastleg failed"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("cannot be compensated"); + verify(locks, never()).release(anyLong(), anyLong()); + } + + @Test + void compensationAllowsUnrelatedOldBindingToDifferentPallet() { + WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1); + WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"); + WarehousePalletBindingEntity oldBinding = new WarehousePalletBindingEntity(); + oldBinding.setPalletId(999L); + when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch); + when(items.lockByBatch(11L)).thenReturn(List.of(item)); + when(repository.lockPalletBinding(CUSTOMER_A, WAREHOUSE, "INBOUND_PLAN_AUTO", "business-1")) + .thenReturn(oldBinding); + + service.compensate(new WarehousePalletBatchRequests.Compensate( + WAREHOUSE, "container-1", "compensate-1", "lastleg failed")); + + verify(locks).release(CUSTOMER_A, 501L); + } + + @Test + void terminalCompensationOnlyReplaysIdenticalCommandAndReason() { + WarehousePalletBatchEntity batch = batch(11L, "RESERVED", 1); + WarehousePalletBatchItemEntity item = item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RESERVED"); + when(batches.lockByKey(WAREHOUSE, "container-1")).thenReturn(batch); + when(items.lockByBatch(11L)).thenReturn(List.of(item)); + var command = new WarehousePalletBatchRequests.Compensate( + WAREHOUSE, "container-1", "compensate-1", "lastleg failed"); + + service.compensate(command); + assertThat(service.compensate(command).replayed()).isTrue(); + assertThatThrownBy(() -> service.compensate(new WarehousePalletBatchRequests.Compensate( + WAREHOUSE, "container-1", "compensate-1", "different reason"))) + .isInstanceOf(BizException.class) + .hasMessageContaining("different payload"); + verify(locks, times(1)).release(CUSTOMER_A, 501L); + } + + @Test + void sameRequestAfterCompensationReusesPalletAndCreatesNextAttemptLocks() { + WarehousePalletBatchEntity batch = batch(11L, "COMPENSATED", 1); + List batchItems = List.of( + item(21L, CUSTOMER_A, 301L, 401L, 501L, 1, "RELEASED"), + item(22L, CUSTOMER_B, 302L, 402L, 502L, 2, "RELEASED")); + AtomicInteger calls = new AtomicInteger(); + when(batches.lockByKey(WAREHOUSE, "container-1")).thenAnswer(call -> { + if (batch.getRequestHash() == null) { + return null; + } + return batch; + }); + when(batches.insertIgnore(any())).thenAnswer(call -> { + WarehousePalletBatchEntity candidate = call.getArgument(0); + batch.setRequestHash(candidate.getRequestHash()); + return 0; + }); + when(items.lockByBatch(11L)).thenReturn(batchItems); + when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "PAL-301")); + when(resources.pallet(0L, WAREHOUSE, 302L)).thenReturn(pallet(302L, 402L, "PAL-302")); + when(locks.reserve(any())).thenAnswer(call -> lockResponse( + ((WarehouseResourceLockRequests.ReserveRequest) call.getArgument(0)).customerId(), + 601L + calls.getAndIncrement())); + + var response = service.ensureReserved(request()); + + assertThat(response.attemptNo()).isEqualTo(2); + assertThat(response.items()).extracting(value -> value.palletId()).containsExactly(301L, 302L); + verify(resources, never()).createPallet(anyLong(), anyLong(), any()); + ArgumentCaptor reservations = + ArgumentCaptor.forClass(WarehouseResourceLockRequests.ReserveRequest.class); + verify(locks, times(2)).reserve(reservations.capture()); + assertThat(reservations.getAllValues()).extracting(value -> value.reservationKey()) + .allMatch(key -> key.contains(":2:")); + } + + private void stubNewResources() { + AtomicInteger created = new AtomicInteger(); + when(resources.createPallet(anyLong(), anyLong(), any())).thenAnswer(call -> 301L + created.getAndIncrement()); + when(resources.pallet(0L, WAREHOUSE, 301L)).thenReturn(pallet(301L, 401L, "PAL-301")); + when(resources.pallet(0L, WAREHOUSE, 302L)).thenReturn(pallet(302L, 402L, "PAL-302")); + when(locks.reserve(any())).thenAnswer(call -> { + WarehouseResourceLockRequests.ReserveRequest value = call.getArgument(0); + return lockResponse(value.customerId(), 500L + value.palletId() - 300L); + }); + } + + private static WarehousePalletBatchRequests.EnsureReserved request() { + return new WarehousePalletBatchRequests.EnsureReserved(WAREHOUSE, "container-1", List.of( + new WarehousePalletBatchRequests.Item(CUSTOMER_B, "container:plan-b:2", "PLAN-B", 2, "STANDARD", "ONT8"), + new WarehousePalletBatchRequests.Item(CUSTOMER_A, "container:plan-a:1", "PLAN-A", 1, "STANDARD", "LAX9"))); + } + + private static WarehousePalletBatchEntity batch(long id, String status, int attempt) { + WarehousePalletBatchEntity entity = new WarehousePalletBatchEntity(); + entity.setId(id); + entity.setWarehouseId(WAREHOUSE); + entity.setBatchKey("container-1"); + entity.setStatus(status); + entity.setAttemptNo(attempt); + return entity; + } + + private static WarehousePalletBatchItemEntity item(long id, long customerId, long palletId, + long locationId, long lockId, int sequence, String status) { + WarehousePalletBatchItemEntity entity = new WarehousePalletBatchItemEntity(); + entity.setId(id); + entity.setBatchId(11L); + entity.setCustomerId(customerId); + entity.setWarehouseId(WAREHOUSE); + entity.setBusinessKey("business-" + sequence); + entity.setInboundPlanNo("PLAN-" + sequence); + entity.setSequenceNo(sequence); + entity.setPalletId(palletId); + entity.setPalletCode("PAL-" + palletId); + entity.setLocationId(locationId); + entity.setResourceLockId(lockId); + entity.setStatus(status); + return entity; + } + + private static WarehousePalletEntity pallet(long id, long locationId, String code) { + WarehousePalletEntity entity = new WarehousePalletEntity(); + entity.setId(id); + entity.setWarehouseId(WAREHOUSE); + entity.setLocationId(locationId); + entity.setPalletCode(code); + entity.setStatus("OCCUPIED"); + return entity; + } + + private static WarehouseResourceLockEntity lock(long customerId, WarehousePalletBatchItemEntity item) { + WarehouseResourceLockEntity entity = new WarehouseResourceLockEntity(); + entity.setId(item.getResourceLockId()); + entity.setCustomerId(customerId); + entity.setWarehouseId(WAREHOUSE); + entity.setPalletId(item.getPalletId()); + entity.setLocationId(item.getLocationId()); + entity.setBusinessType("INBOUND_PLAN_AUTO"); + entity.setBusinessId(item.getBusinessKey()); + entity.setStatus("ACTIVE"); + return entity; + } + + private static WarehouseResourceLockResponse lockResponse(long customerId, long lockId) { + return new WarehouseResourceLockResponse(lockId, customerId, WAREHOUSE, 0L, 0L, + "INBOUND_PLAN_AUTO", "business", "reservation", "ACTIVE", null, null); + } +} diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java index 1d6615537..807382a3f 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehouseResourceServiceTest.java @@ -251,6 +251,25 @@ class WarehouseResourceServiceTest { assertThat(captor.getValue().getId()).isNotNull(); } + @Test + void generatedPalletCodeRemainsWithinLastlegSnapshotLimitForLongWarehouseCode() { + String longCode = "US-LONG-WAREHOUSE-CODE-THAT-EXCEEDS-THIRTY-CHARACTERS"; + when(warehouseDirectory.required(WAREHOUSE)) + .thenReturn(new SharedWarehouseReference(WAREHOUSE, longCode, "Long warehouse", "ACTIVE")); + WarehouseLocationEntity location = new WarehouseLocationEntity(); + location.setId(30009L); + location.setLocationType("STORAGE"); + location.setStatus("IDLE"); + when(repository.findLocation(TENANT, WAREHOUSE, 30009L)).thenReturn(location); + + service.createPallet(TENANT, WAREHOUSE, + new PalletRequests.SaveRequest(null, "STANDARD", null, BigDecimal.ONE, "IDLE", 30009L, null)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WarehousePalletEntity.class); + verify(repository).insertPallet(captor.capture()); + assertThat(captor.getValue().getPalletCode()).hasSizeLessThanOrEqualTo(64); + } + @Test void defaultVirtualLocationIsInsertedIdempotentlyForAnExistingWarehouse() { WarehouseLocationEntity resolved = new WarehouseLocationEntity();