diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 563a61af4..e9591329d 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1153,7 +1153,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', - sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', carton_marks VARCHAR(4000) NULL COMMENT '本次出库指定箱唛,逗号分隔', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pallet(customer_id,outbound_order_id,warehouse_pallet_id), KEY idx_ll_outbound_pallet_list(customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination(customer_id,destination_warehouse_code) @@ -1440,7 +1440,7 @@ BEGIN 'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick', 'll_delivery_order','ll_delivery_fee','ll_delivery_operation', 'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger', - 'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 24 THEN + 'll_exception_order','ll_customer_visit')) <> 22 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期排车、出库、派送、一件代发、异常或回访表未完整部署'; END IF; IF EXISTS (SELECT dispatch_no FROM ll_dispatch_order GROUP BY dispatch_no HAVING COUNT(*)>1) THEN @@ -1556,7 +1556,7 @@ BEGIN 'll_outbound_order','ll_outbound_pallet_item','ll_outbound_pda_task','ll_outbound_pda_pick', 'll_delivery_order','ll_delivery_fee','ll_delivery_operation', 'll_dropship_order_profile','ll_dropship_carton','ll_dropship_sku_inventory','ll_dropship_carton_ledger', - 'll_exception_order','ll_exception_return_item','ll_exception_order_command','ll_customer_visit')) <> 24 THEN + 'll_exception_order','ll_customer_visit')) <> 22 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期完整业务表集合未部署'; END IF; IF (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='baiyue_lastleg' @@ -1818,31 +1818,6 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation ( CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) ) 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_relabel_item' AND column_name='destination_fba_warehouse_code'), - 'SELECT 1', - 'ALTER TABLE ll_relabel_item ADD COLUMN destination_fba_warehouse_code VARCHAR(128) NULL COMMENT ''FBA目的仓编码(与海外操作仓分离)''')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF( - EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='operation_type'), - 'SELECT 1', - 'ALTER TABLE ll_relabel_item ADD COLUMN operation_type VARCHAR(32) NOT NULL DEFAULT ''CARTON_LABEL'' COMMENT ''换标模式:箱唛、SKU或组合换标''')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF( - EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='sku_relabel_items_json'), - 'SELECT 1', - 'ALTER TABLE ll_relabel_item ADD COLUMN sku_relabel_items_json JSON NULL COMMENT ''同箱SKU换标子明细快照(数量按件)''')); -PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_balance_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_balance_id BIGINT NULL COMMENT ''原库存余额ID''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inventory_position_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inventory_position_id BIGINT NULL COMMENT ''原库存库位ID''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_id BIGINT NULL COMMENT ''原入仓单ID''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_order_no'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_order_no VARCHAR(128) NULL COMMENT ''原入仓单号快照''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_inbound_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_inbound_code VARCHAR(128) NULL COMMENT ''原进仓编码快照''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_id'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_id VARCHAR(128) NULL COMMENT ''原海外中转仓ID''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_code'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_code VARCHAR(128) NULL COMMENT ''原海外中转仓编码快照''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='source_warehouse_name'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN source_warehouse_name VARCHAR(255) NULL COMMENT ''原海外中转仓名称快照''')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND index_name='idx_ll_relabel_item_source_position'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id)')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; -- 海外接柜仓由平台在尾程建单时预分配;历史测试数据不回填,缺失事实由业务接口失败关闭。 USE baiyue_lastleg; @@ -2020,12 +1995,14 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command ( idempotency_key VARCHAR(128) NOT NULL COMMENT '首次调用幂等键', request_hash CHAR(64) NOT NULL COMMENT '确认请求SHA-256摘要', snapshot_version BIGINT NOT NULL COMMENT '确认的柜级快照版本', snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', - status VARCHAR(32) NOT NULL COMMENT '转换状态:PREPARED、APPLYING、LOCAL_COMMITTED或COMPLETED', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', + status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', result_json JSON NULL COMMENT '本地计划提交结果', last_error VARCHAR(1000) 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 '更新时间', + 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_ll_container_conversion_command(source_container_booking_id), UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), KEY idx_ll_container_conversion_status(status,updated_at) ) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status')); +PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; ALTER TABLE ll_inbound_plan_pallet MODIFY COLUMN pallet_no VARCHAR(128) NOT NULL COMMENT 'Warehouse权威实物托盘码'; SELECT 'PASS' AS migration_status, '海外尾程人工计划、暂扣与人工节点结构已就绪' AS message; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java index 327338d62..8130c3593 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java @@ -19,6 +19,7 @@ public class ContainerPlanConversionCommand { private Long warehouseId; private String batchKey; private String status; + private String leaseToken; private String preparationJson; private String resultJson; private String lastError; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java index 8ddca8d0b..a4bc87cc3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java @@ -17,17 +17,33 @@ public interface ContainerPlanConversionCommandMapper extends BaseMapper { - throw palletLookupFailure(result.getStatusCode()); - }).body(WarehousePalletResponse.class); - if (response != null && "A0404".equals(response.code())) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found"); + @Override + public WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId, + long palletId, long locationId, long resourceLockId) { + if (customerId == null || customerId.isBlank() || warehouseId <= 0 || palletId <= 0 + || locationId <= 0 || resourceLockId <= 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Reserved pallet binding is incomplete"); } - if (response == null || response.data() == null) { - throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, - "Warehouse pallet service returned no usable response"); + try { + Long.parseLong(customerId); + } catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Reserved pallet customer binding is invalid"); } - return response.data(); - } - - private WarehousePalletReference pallet(WarehousePalletData data) { - return new WarehousePalletReference(data.id(), data.palletCode(), data.warehouseId(), data.locationId(), - data.status(), data.fbaWarehouseCode(), data.unitPrice(), data.version()); + WarehousePalletData pallet = lookupPallet(palletCode).data(); + if (pallet.id() != palletId || pallet.warehouseId() != warehouseId || pallet.locationId() != locationId + || !"OCCUPIED".equals(pallet.status())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Scanned pallet does not match the reserved inbound plan resource"); + } + return reference(pallet); } @Override @@ -169,6 +170,28 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed"); } + private WarehousePalletResponse lookupPallet(String palletCode) { + WarehousePalletResponse response = client.get() + .uri("/internal/v1/warehouse-pallets/resolve/{palletCode}", palletCode) + .header("X-Internal-Service-Token", internalToken) + .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { + throw palletLookupFailure(result.getStatusCode()); + }).body(WarehousePalletResponse.class); + if (response != null && "A0404".equals(response.code())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found"); + } + if (response == null || response.data() == null) { + throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, + "Warehouse pallet service returned no usable response"); + } + return response; + } + + private WarehousePalletReference reference(WarehousePalletData pallet) { + return new WarehousePalletReference(pallet.id(), pallet.palletCode(), pallet.warehouseId(), + pallet.locationId(), pallet.status(), pallet.fbaWarehouseCode(), pallet.unitPrice(), pallet.version()); + } + private ResponseStatusException warehouseReservationFailure(HttpStatusCode status) { HttpStatus mapped = status.value() == 409 ? HttpStatus.CONFLICT : status.value() == 404 ? HttpStatus.NOT_FOUND diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java index a6e1a21ab..9f7f369ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java @@ -14,6 +14,10 @@ public interface WarehouseResourceGateway { default WarehousePalletReference inspectPallet(String palletCode) { return resolvePallet(palletCode); } + default WarehousePalletReference resolveReservedPallet(String palletCode, String customerId, long warehouseId, + long palletId, long locationId, long resourceLockId) { + throw new UnsupportedOperationException("Reserved warehouse pallet resolution is not configured"); + } default WarehouseLocationReference resolveLocation(long warehouseId, String locationCode) { throw new UnsupportedOperationException("Warehouse location resolution is not configured"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 78ca21628..578da55e5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -113,9 +113,8 @@ public class InboundPdaWorkflowService { InboundPlanPallet pallet = requirePallet(plan.getId(), planPalletId); validateDestinationWarehouse(pallet, request.warehouseId()); if (pallet.getWarehouseResourceLockId() != null) { - if (Objects.equals(pallet.getWarehousePalletId(), request.palletId()) - && Objects.equals(pallet.getWarehouseLocationId(), request.locationId())) return; - throw conflict("Planned pallet is already assigned to another warehouse resource"); + confirmReservedAssignment(plan, pallet, request.palletId(), request.locationId(), null); + return; } String destinationKey = destinationKey(pallet); String businessId = plan.getContainerNo() + ":" + destinationKey; @@ -130,12 +129,7 @@ public class InboundPdaWorkflowService { pallet.setAssignmentStatus("ASSIGNED"); pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); - List pallets = repository.pallets(plan.getId()); - if (!pallets.isEmpty() && pallets.stream().allMatch(value -> !isNotAssigned(value))) { - plan.setStatus("ASSIGNED"); - plan.setUpdatedAt(LocalDateTime.now()); - repository.update(plan); - } + promotePlanWhenAllPalletsAssigned(plan); } @Transactional @@ -167,13 +161,13 @@ public class InboundPdaWorkflowService { @Transactional public Long assignResolvedPallet(String customerId, InboundPlan plan, InboundPlanPallet pallet, WarehouseResourceGateway.WarehousePalletReference resource) { - if (pallet.getWarehouseResourceLockId() != null) { - if (Objects.equals(pallet.getWarehousePalletId(), resource.palletId())) return null; - throw conflict("Destination pallet is already bound to another physical pallet"); - } if (resource.palletNo() == null || resource.palletNo().isBlank()) { throw conflict("Resolved physical pallet has no pallet code"); } + if (pallet.getWarehouseResourceLockId() != null) { + confirmReservedAssignment(plan, pallet, resource.palletId(), resource.locationId(), resource.palletNo()); + return null; + } String destinationKey = destinationKey(pallet); String businessId = plan.getContainerNo() + ":" + destinationKey; String reservationKey = "INBOUND_CONTAINER_DESTINATION_PALLET:" + businessId + ":" + resource.palletId(); @@ -184,6 +178,7 @@ public class InboundPdaWorkflowService { pallet.setWarehouseLocationId(resource.locationId()); pallet.setWarehouseResourceLockId(lockId); pallet.setAssignmentStatus("ASSIGNED"); pallet.setLockedAt(LocalDateTime.now()); repository.update(pallet); + promotePlanWhenAllPalletsAssigned(plan); return lockId; } catch (RuntimeException failure) { compensateReservation(customerId, lockId, failure); @@ -878,6 +873,35 @@ public class InboundPdaWorkflowService { || !("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())); } + private void confirmReservedAssignment(InboundPlan plan, InboundPlanPallet pallet, + long palletId, long locationId, String palletNo) { + boolean sameReservedResource = pallet.getWarehouseResourceLockId() != null + && pallet.getWarehouseResourceLockId() > 0 + && Objects.equals(pallet.getWarehousePalletId(), palletId) + && Objects.equals(pallet.getWarehouseLocationId(), locationId); + if (!sameReservedResource) { + throw conflict("Planned pallet is already assigned to another physical pallet or location"); + } + if ("ASSIGNED".equals(pallet.getAssignmentStatus()) || "LOCKED".equals(pallet.getAssignmentStatus())) return; + if (!"RESERVED".equals(pallet.getAssignmentStatus())) { + throw conflict("Reserved physical pallet has an invalid assignment status"); + } + if (palletNo != null && !palletNo.isBlank()) pallet.setPalletNo(palletNo.trim()); + pallet.setAssignmentStatus("ASSIGNED"); + if (pallet.getLockedAt() == null) pallet.setLockedAt(LocalDateTime.now()); + repository.update(pallet); + promotePlanWhenAllPalletsAssigned(plan); + } + + private void promotePlanWhenAllPalletsAssigned(InboundPlan plan) { + List plannedPallets = repository.pallets(plan.getId()); + if (plannedPallets.isEmpty() || plannedPallets.stream().anyMatch(this::isNotAssigned)) return; + if (!java.util.Set.of("PENDING_ASSIGNMENT", "RESERVED").contains(plan.getStatus())) return; + plan.setStatus("ASSIGNED"); + plan.setUpdatedAt(LocalDateTime.now()); + repository.update(plan); + } + private InboundPlan requirePlan(String customerId, String planNo) { InboundPlan plan = repository.lockPlan(customerId, planNo); if (plan == null) throw notFound("Inbound plan not found"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 54bae8773..3dc060640 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -274,14 +274,8 @@ public class ReceivingContainerService { ReceivingContainerSession session = requireSigned(operatorKey); String customerId = session.getCustomerId(); InboundRef ref = requireInbound(customerId, session.getContainerNo(), request.inboundCode()); - WarehouseResourceGateway.WarehousePalletReference pallet; - try { - pallet = warehouse.resolvePallet(request.palletCode()); - } catch (IllegalArgumentException exception) { - throw notFound("Pallet was not found"); - } - if (pallet == null) throw notFound("Pallet was not found"); - validatePallet(ref, pallet); + PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), false); + WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource(); return new ReceivingContainerResponses.PalletResolved(pallet.palletNo(), pallet.warehouseId(), pallet.status(), pallet.fbaWarehouseCode(), ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), true); } @@ -301,9 +295,9 @@ public class ReceivingContainerService { List skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount()); skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), allocation.packages())); - WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); - validatePallet(ref, resource); - InboundPlanPallet pallet = choosePallet(ref, resource); + PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true); + WarehouseResourceGateway.WarehousePalletReference resource = selection.resource(); + InboundPlanPallet pallet = selection.pallet(); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); try { int accepted = 0; @@ -344,9 +338,9 @@ public class ReceivingContainerService { if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { throw conflict("FBA destination must use bulk count"); } - WarehouseResourceGateway.WarehousePalletReference resource = warehouse.inspectPallet(request.palletCode()); - validatePallet(ref, resource); - InboundPlanPallet pallet = choosePallet(ref, resource); + PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true); + WarehouseResourceGateway.WarehousePalletReference resource = selection.resource(); + InboundPlanPallet pallet = selection.pallet(); workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark()); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); try { @@ -639,6 +633,47 @@ public class ReceivingContainerService { return candidates.get(0); } + private PalletSelection resolveReceivingPallet(InboundRef ref, String palletCode, boolean requirePlanPallet) { + List reserved = pallets.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPallet::getPlanId, ref.plan().getId())).stream() + .filter(value -> sameDestination(ref.line(), value)) + .filter(value -> palletCode.trim().equals(value.getPalletNo())) + .filter(this::hasConfirmedReservation) + .toList(); + if (reserved.size() > 1) { + throw conflict("Pallet code matches multiple reserved resources in this inbound plan"); + } + WarehouseResourceGateway.WarehousePalletReference resource; + InboundPlanPallet pallet; + if (reserved.size() == 1) { + pallet = reserved.get(0); + long warehouseId = ref.plan().getOverseasWarehouseId() == null + ? Long.parseLong(ref.order().getOverseasWarehouseId()) : ref.plan().getOverseasWarehouseId(); + resource = warehouse.resolveReservedPallet(palletCode.trim(), ref.order().getCustomerId(), warehouseId, + pallet.getWarehousePalletId(), pallet.getWarehouseLocationId(), pallet.getWarehouseResourceLockId()); + } else { + try { + resource = warehouse.resolvePallet(palletCode.trim()); + } catch (IllegalArgumentException exception) { + throw notFound("Pallet was not found"); + } + if (resource == null) throw notFound("Pallet was not found"); + pallet = requirePlanPallet ? choosePallet(ref, resource) : null; + } + validatePallet(ref, resource); + return new PalletSelection(pallet, resource); + } + + private boolean hasConfirmedReservation(InboundPlanPallet pallet) { + return pallet.getWarehousePalletId() != null && pallet.getWarehousePalletId() > 0 + && pallet.getWarehouseLocationId() != null && pallet.getWarehouseLocationId() > 0 + && pallet.getWarehouseResourceLockId() != null && pallet.getWarehouseResourceLockId() > 0 + && ("RESERVED".equals(pallet.getAssignmentStatus()) + || "ASSIGNED".equals(pallet.getAssignmentStatus()) + || "LOCKED".equals(pallet.getAssignmentStatus())); + } + private void validatePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference pallet) { if ("SCRAPPED".equals(pallet.status())) throw conflict("Pallet is scrapped"); long actualWarehouse; @@ -666,6 +701,9 @@ public class ReceivingContainerService { return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode()); } + private record PalletSelection(InboundPlanPallet pallet, + WarehouseResourceGateway.WarehousePalletReference resource) { } + private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { List inbounds = refs(session.getCustomerId(), session.getContainerNo()).stream() .map(this::inbound).toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java index 5ff55ce38..9ed4a0f3d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -46,82 +46,108 @@ public class ContainerInboundPlanConversionCoordinator { String bookingId = required(request.sourceContainerBookingId(), 128, "来源柜预订 ID 不能为空"); String key = required(idempotencyKey, 128, "Idempotency-Key 必须为1至128个字符"); String requestHash = hash(List.of(bookingId, request.snapshotVersion(), request.snapshotHash())); - ContainerPlanConversionCommand resolved = commands.find(bookingId, key); - if (resolved == null) resolved = commands.findByBooking(bookingId); - if (resolved == null) resolved = createCommand(bookingId, key, request, requestHash); + ContainerPlanConversionCommand direct = commands.find(bookingId, key); + ContainerPlanConversionCommand resolved = direct == null ? commands.findByBooking(bookingId) : direct; + if (resolved == null) { + resolved = createCommand(bookingId, key, request, requestHash); + } else if ("COMPLETED".equals(resolved.getStatus())) { + if (direct != null) requireSameRequest(resolved, requestHash); + return result(resolved); + } else if ("FAILED".equals(resolved.getStatus())) { + resolved = replaceFailedCommand(resolved, bookingId, key, request, requestHash); + } else { + requireSameRequest(resolved, requestHash); + } + if ("COMPLETED".equals(resolved.getStatus())) { + requireSameRequest(resolved, requestHash); + return result(resolved); + } final ContainerPlanConversionCommand command = resolved; - requireSameRequest(command, requestHash); Preparation preparation = read(command.getPreparationJson(), Preparation.class); - if ("COMPLETED".equals(command.getStatus())) return result(command); + boolean applyingLeaseOwned = false; if ("APPLYING".equals(command.getStatus())) { LocalDateTime now = LocalDateTime.now(); + String leaseToken = IdWorker.getIdStr(); if (command.getUpdatedAt() == null || command.getUpdatedAt().plus(APPLYING_LEASE).isAfter(now) - || commands.reclaimStale(command.getId(), command.getUpdatedAt(), now) != 1) { + || commands.reclaimStale(command.getId(), command.getUpdatedAt(), leaseToken, now) != 1) { throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交"); } + command.setLeaseToken(leaseToken); command.setUpdatedAt(now); + applyingLeaseOwned = true; } - if (!"LOCAL_COMMITTED".equals(command.getStatus())) { - if (commands.claim(command.getId(), "PREPARED", "APPLYING", LocalDateTime.now()) != 1) { + if ("PREPARED".equals(command.getStatus())) { + String leaseToken = IdWorker.getIdStr(); + if (commands.claim(command.getId(), "PREPARED", "APPLYING", leaseToken, LocalDateTime.now()) != 1) { ContainerPlanConversionCommand current = commands.findByBooking(bookingId); if (current != null && "COMPLETED".equals(current.getStatus())) return result(current); throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在生成入库计划,请勿重复提交"); } command.setStatus("APPLYING"); + command.setLeaseToken(leaseToken); + applyingLeaseOwned = true; + } + if (!applyingLeaseOwned && !"LOCAL_COMMITTED".equals(command.getStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级转换命令状态无效"); } if (preparation.items().isEmpty()) { try { List existing = inTransaction(() -> { List converted = plans.convertContainerReserved(bookingId, request.snapshotVersion(), request.snapshotHash(), List.of()); - state(command.getId(), "APPLYING", "COMPLETED", write(converted), null); + state(command, "APPLYING", "COMPLETED", write(converted), null); return converted; }); command.setStatus("COMPLETED"); return existing; } catch (RuntimeException failure) { - resetForRetry(command, failure); + ContainerPlanConversionCommand current = commands.findByBooking(bookingId); + if (current != null && "COMPLETED".equals(current.getStatus())) return result(current); + failWithoutResources(command, failure); throw failure; } } - WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure( - preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item -> - new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()), item.businessKey(), - item.planNo(), item.globalSequenceNo(), "STANDARD", item.fbaWarehouseCode())).toList()); - WarehouseResourceGateway.PalletBatchResponse reservation; - try { - reservation = warehouse.ensurePalletBatch(ensure); - requireBatch(reservation, command.getBatchKey(), preparation.items().size(), "RESERVED", "CONFIRMED"); - } catch (RuntimeException failure) { - resetForRetry(command, failure); - throw failure; - } - List localResult; if ("LOCAL_COMMITTED".equals(command.getStatus())) { localResult = result(command); } else { + WarehouseResourceGateway.PalletBatchEnsure ensure = new WarehouseResourceGateway.PalletBatchEnsure( + preparation.warehouseId(), command.getBatchKey(), preparation.items().stream().map(item -> + new WarehouseResourceGateway.PalletBatchItemRequest(Long.parseLong(item.customerId()), + item.businessKey(), item.planNo(), item.globalSequenceNo(), "STANDARD", + item.fbaWarehouseCode())).toList()); + WarehouseResourceGateway.PalletBatchResponse reservation; + try { + reservation = warehouse.ensurePalletBatch(ensure); + requireBatch(reservation, command.getBatchKey(), preparation.items().size(), "RESERVED", "CONFIRMED"); + } catch (RuntimeException failure) { + compensateAndFail(command, preparation, failure); + throw failure; + } try { List preparedPlans = bind(preparation, reservation); localResult = inTransaction(() -> { List created = plans.convertContainerReserved(bookingId, request.snapshotVersion(), request.snapshotHash(), preparedPlans); - state(command.getId(), "APPLYING", "LOCAL_COMMITTED", write(created), null); + state(command, "APPLYING", "LOCAL_COMMITTED", write(created), null); return created; }); command.setStatus("LOCAL_COMMITTED"); } catch (RuntimeException failure) { - try { - warehouse.compensatePalletBatch(new WarehouseResourceGateway.PalletBatchCompensation( - preparation.warehouseId(), command.getBatchKey(), "COMPENSATE:" + command.getId(), - "Lastleg local transaction did not commit")); - } catch (RuntimeException compensationFailure) { - failure.addSuppressed(compensationFailure); + ContainerPlanConversionCommand current = commands.findByBooking(bookingId); + if (current != null && "COMPLETED".equals(current.getStatus())) { + return result(current); + } + if (current != null && "LOCAL_COMMITTED".equals(current.getStatus())) { + command.setStatus(current.getStatus()); + command.setResultJson(current.getResultJson()); + localResult = result(current); + } else { + compensateAndFail(command, preparation, failure); + throw failure; } - resetForRetry(command, failure); - throw failure; } } @@ -136,6 +162,35 @@ public class ContainerInboundPlanConversionCoordinator { private ContainerPlanConversionCommand createCommand(String bookingId, String key, ConvertContainerInboundPlanRequest request, String requestHash) { + long commandId = IdWorker.getId(); + ContainerPlanConversionCommand command = prepareCommand(commandId, commandId, bookingId, key, request, requestHash); + try { inTransaction(() -> { commands.insert(command); return null; }); } + catch (DuplicateKeyException concurrent) { + ContainerPlanConversionCommand existing = commands.findByBooking(bookingId); + if (existing == null) throw concurrent; + return existing; + } + return command; + } + + private ContainerPlanConversionCommand replaceFailedCommand(ContainerPlanConversionCommand previous, + String bookingId, String key, + ConvertContainerInboundPlanRequest request, + String requestHash) { + ContainerPlanConversionCommand replacement = prepareCommand(previous.getId(), IdWorker.getId(), + bookingId, key, request, requestHash); + int updated = commands.replaceFailed(previous.getId(), key, requestHash, request.snapshotVersion(), + request.snapshotHash(), replacement.getWarehouseId(), replacement.getBatchKey(), + replacement.getPreparationJson(), replacement.getUpdatedAt()); + if (updated != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "该柜正在刷新入库计划,请勿重复提交"); + } + return replacement; + } + + private ContainerPlanConversionCommand prepareCommand(long commandId, long generationId, String bookingId, + String key, ConvertContainerInboundPlanRequest request, + String requestHash) { ContainerInboundPlanPreviewResponse preview = plans.previewContainer(bookingId); if (preview.snapshotVersion() != request.snapshotVersion() || !Objects.equals(preview.snapshotHash(), request.snapshotHash()) || !preview.ready()) { @@ -146,7 +201,6 @@ public class ContainerInboundPlanConversionCoordinator { catch (RuntimeException exception) { throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception); } - long commandId = IdWorker.getId(); List items = new ArrayList<>(); int globalSequence = 0; for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream() @@ -155,7 +209,7 @@ public class ContainerInboundPlanConversionCoordinator { String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId); for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) { items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo, - localSequence, ++globalSequence, "CIP:" + commandId + ":" + globalSequence, + localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence, "FBA".equals(order.destinationType()) ? order.destinationWarehouseCode() : null)); } } @@ -164,15 +218,9 @@ public class ContainerInboundPlanConversionCoordinator { command.setId(commandId); command.setSourceContainerBookingId(bookingId); command.setIdempotencyKey(key); command.setRequestHash(requestHash); command.setSnapshotVersion(request.snapshotVersion()); command.setSnapshotHash(request.snapshotHash()); command.setWarehouseId(warehouseId); - command.setBatchKey("CIP:" + hash(bookingId) + ":" + commandId); command.setStatus("PREPARED"); + command.setBatchKey("CIP:" + hash(bookingId) + ":" + generationId); command.setStatus("PREPARED"); command.setPreparationJson(write(preparation)); command.setCreatedAt(LocalDateTime.now()); command.setUpdatedAt(command.getCreatedAt()); - try { inTransaction(() -> { commands.insert(command); return null; }); } - catch (DuplicateKeyException concurrent) { - ContainerPlanConversionCommand existing = commands.findByBooking(bookingId); - if (existing == null) throw concurrent; - return existing; - } return command; } @@ -213,11 +261,26 @@ public class ContainerInboundPlanConversionCoordinator { private void requireBatch(WarehouseResourceGateway.PalletBatchResponse response, String batchKey, int size, String... acceptedStatuses) { - if (response == null || !Objects.equals(batchKey, response.batchKey()) || response.items() == null + if (response == null || response.batchId() <= 0 || response.attemptNo() <= 0 + || !Objects.equals(batchKey, response.batchKey()) || response.items() == null || response.items().size() != size || java.util.Arrays.stream(acceptedStatuses) .noneMatch(status -> status.equals(response.status()))) { throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次响应不完整或状态不一致"); } + String expectedItemStatus = switch (response.status()) { + case "RESERVED" -> "RESERVED"; + case "CONFIRMED" -> "CONFIRMED"; + case "COMPENSATED" -> "RELEASED"; + default -> null; + }; + boolean invalidItem = expectedItemStatus == null || response.items().stream().anyMatch(item -> + item.customerId() <= 0 || item.sequenceNo() <= 0 || item.palletId() <= 0 || item.locationId() <= 0 + || item.resourceLockId() <= 0 || trimToNull(item.businessKey()) == null + || trimToNull(item.inboundPlanNo()) == null || trimToNull(item.palletCode()) == null + || item.palletCode().length() > 128 || !expectedItemStatus.equals(item.status())); + if (invalidItem) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "海外仓托盘批次明细不完整或状态不一致"); + } } private void requireSameRequest(ContainerPlanConversionCommand command, String requestHash) { @@ -235,19 +298,41 @@ public class ContainerInboundPlanConversionCoordinator { } private void complete(ContainerPlanConversionCommand command, List result) { - inTransaction(() -> { state(command.getId(), "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; }); + inTransaction(() -> { state(command, "LOCAL_COMMITTED", "COMPLETED", write(result), null); return null; }); command.setStatus("COMPLETED"); } - private void resetForRetry(ContainerPlanConversionCommand command, RuntimeException failure) { + private void failWithoutResources(ContainerPlanConversionCommand command, RuntimeException failure) { if ("APPLYING".equals(command.getStatus())) { - state(command.getId(), "APPLYING", "PREPARED", null, safeMessage(failure)); - command.setStatus("PREPARED"); + state(command, "APPLYING", "FAILED", null, safeMessage(failure)); + command.setStatus("FAILED"); } } - private void state(long id, String expectedStatus, String status, String resultJson, String lastError) { - if (commands.updateState(id, expectedStatus, status, resultJson, lastError, LocalDateTime.now()) != 1) { + private void compensateAndFail(ContainerPlanConversionCommand command, Preparation preparation, + RuntimeException failure) { + try { + WarehouseResourceGateway.PalletBatchResponse compensated = warehouse.compensatePalletBatch( + new WarehouseResourceGateway.PalletBatchCompensation(preparation.warehouseId(), + command.getBatchKey(), "COMPENSATE:" + command.getBatchKey(), + "Lastleg local transaction did not commit")); + requireBatch(compensated, command.getBatchKey(), preparation.items().size(), "COMPENSATED"); + failWithoutResources(command, failure); + } catch (ResponseStatusException compensationFailure) { + if (compensationFailure.getStatusCode().value() == 404) { + failWithoutResources(command, failure); + return; + } + failure.addSuppressed(compensationFailure); + } catch (RuntimeException compensationFailure) { + failure.addSuppressed(compensationFailure); + } + } + + private void state(ContainerPlanConversionCommand command, String expectedStatus, String status, + String resultJson, String lastError) { + if (commands.updateState(command.getId(), expectedStatus, command.getLeaseToken(), status, + resultJson, lastError, LocalDateTime.now()) != 1) { throw new IllegalStateException("无法更新柜级转换命令状态"); } } @@ -281,6 +366,9 @@ public class ContainerInboundPlanConversionCoordinator { String message = value.getMessage(); return message == null ? value.getClass().getSimpleName() : message.substring(0, Math.min(1000, message.length())); } + private static String trimToNull(String value) { + return value == null || value.isBlank() ? null : value.trim(); + } public record Preparation(long warehouseId, List items) { } public record PreparedItem(String customerId, long orderId, long planId, String planNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index b2ab343e1..bae48b5c6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -198,7 +198,8 @@ public class InboundPlanService { .map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); long snapshotVersion = rows.stream().mapToLong(InboundPlanOrderMapper.ContainerPreviewOrderRow::version).max().orElse(0); - String snapshotHash = sha256(List.of(bookingId, containerNo, warehouseId, snapshotVersion, orders)); + String snapshotHash = sha256(java.util.Arrays.asList( + bookingId, containerNo, warehouseId, snapshotVersion, orders)); return new ContainerInboundPlanPreviewResponse(bookingId, containerNo, warehouseId, warehouseCode, warehouseName, snapshotVersion, snapshotHash, orders.size(), orders.size() - blocked, blocked, pendingOrders, pendingPallets, existingPallets, diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 1a3b49e7d..2712d3cbc 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -300,8 +300,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='total_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN total_weight_kg DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总重量(千克)'', ADD COLUMN total_volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT ''总体积(立方米)'', ADD COLUMN planned_delivery_at DATETIME(3) NULL COMMENT ''预计派送时间'', ADD COLUMN dispatched_at DATETIME(3) NULL COMMENT ''实际派送时间'', ADD COLUMN expected_arrival_at DATETIME(3) NULL COMMENT ''预计到达时间'', ADD COLUMN signed_at DATETIME(3) NULL COMMENT ''签收时间'', ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''派送备注'' AFTER pallet_count')); PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_delivery_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_delivery_order ADD COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''派送单乐观锁版本'' AFTER created_by')); -PREPARE delivery_detail_stmt FROM @ddl; EXECUTE delivery_detail_stmt; DEALLOCATE PREPARE delivery_detail_stmt; CREATE TABLE IF NOT EXISTS ll_delivery_fee ( id BIGINT NOT NULL COMMENT '派送费用主键', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', delivery_order_id BIGINT NOT NULL COMMENT '派送单ID', fee_direction VARCHAR(16) NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', fee_type VARCHAR(64) NOT NULL COMMENT '费用类型', currency VARCHAR(8) NOT NULL COMMENT '币种', @@ -589,17 +587,20 @@ CREATE TABLE IF NOT EXISTS ll_container_plan_conversion_command ( snapshot_hash CHAR(64) NOT NULL COMMENT '确认的柜级快照SHA-256', warehouse_id BIGINT NOT NULL COMMENT '海外接柜仓ID', batch_key VARCHAR(160) NOT NULL COMMENT '海外仓托盘批次幂等键', - status VARCHAR(32) NOT NULL COMMENT '转换状态:PREPARED、APPLYING、LOCAL_COMMITTED或COMPLETED', + status VARCHAR(32) NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', + lease_token VARCHAR(64) NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', preparation_json JSON NOT NULL COMMENT '稳定计划号与托盘业务键快照', result_json JSON NULL COMMENT '本地计划提交结果', last_error VARCHAR(1000) 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 '更新时间', + 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_ll_container_conversion_command(source_container_booking_id), UNIQUE KEY uk_ll_container_conversion_batch(warehouse_id,batch_key), KEY idx_ll_container_conversion_status(status,updated_at) ) ENGINE=InnoDB COMMENT='柜级入库计划转换Saga与幂等命令'; +SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_container_plan_conversion_command' AND column_name='lease_token'), 'SELECT 1', 'ALTER TABLE ll_container_plan_conversion_command ADD COLUMN lease_token VARCHAR(64) NULL COMMENT ''当前执行租约令牌,用于陈旧执行者隔离'' AFTER status')); +PREPARE container_conversion_lease_stmt FROM @ddl; EXECUTE container_conversion_lease_stmt; DEALLOCATE PREPARE container_conversion_lease_stmt; CREATE TABLE IF NOT EXISTS ll_inbound_plan_line ( id BIGINT NOT NULL COMMENT '入库计划明细ID(雪花ID)', @@ -1396,7 +1397,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_pallet_item ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', outbound_order_id BIGINT NOT NULL COMMENT '出库单ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', pallet_no VARCHAR(128) NULL COMMENT '托盘号', destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', - package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', carton_marks VARCHAR(4000) NULL COMMENT '本次出库指定箱唛,逗号分隔', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', picked_package_count INT NOT NULL DEFAULT 0 COMMENT '已拣箱数', status VARCHAR(32) NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', sku_code VARCHAR(128) NULL COMMENT '一件代发SKU', order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id), KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) @@ -1590,23 +1591,12 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item ( relabel_file_url VARCHAR(1000) NULL COMMENT '换标文件地址', carton_mark VARCHAR(255) NULL COMMENT '客户自有箱唛', fba_carton_mark VARCHAR(255) NULL COMMENT '关联FBA箱唛', - destination_warehouse_id VARCHAR(128) NOT NULL COMMENT '该箱目的海外仓ID', - destination_fba_warehouse_code VARCHAR(128) NOT NULL COMMENT 'FBA目的仓编码(与海外操作仓分离)', - operation_type VARCHAR(32) NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标', - sku_relabel_items_json JSON NULL COMMENT '同箱SKU换标子明细快照(数量按件)', - source_inventory_balance_id BIGINT NOT NULL COMMENT '原库存余额ID', - source_inventory_position_id BIGINT NOT NULL COMMENT '原库存库位ID', - source_inbound_order_id BIGINT NOT NULL COMMENT '原入仓单ID', - source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照', - source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照', - source_warehouse_id VARCHAR(128) NOT NULL COMMENT '原海外中转仓ID', - source_warehouse_code VARCHAR(128) NULL COMMENT '原海外中转仓编码快照', - source_warehouse_name VARCHAR(255) NULL COMMENT '原海外中转仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '该箱目的海外仓ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), KEY idx_ll_relabel_item_application (customer_id,application_id), KEY idx_ll_relabel_item_sku (customer_id,sku_code), - KEY idx_ll_relabel_item_source_position (customer_id,source_inventory_position_id) + KEY idx_ll_relabel_item_destination (customer_id,destination_warehouse_id,application_id) ) ENGINE=InnoDB COMMENT='换标货物冻结明细'; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_relabel_item' AND column_name='remark'), 'SELECT 1', 'ALTER TABLE ll_relabel_item ADD COLUMN remark VARCHAR(1000) NULL COMMENT ''货物明细备注'' AFTER label_requirement')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; @@ -1990,17 +1980,6 @@ CREATE TABLE IF NOT EXISTS ll_exception_order_command ( KEY idx_ll_exception_command_order(customer_id,exception_order_id,created_at) ) ENGINE=InnoDB COMMENT='异常处理命令幂等审计'; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_exception_order' AND column_name='handling_result'), 'SELECT 1', 'ALTER TABLE ll_exception_order ADD COLUMN handling_result VARCHAR(2000) NULL COMMENT ''异常处理结果'' AFTER handling_action')); -PREPARE exception_lifecycle_stmt FROM @ddl; EXECUTE exception_lifecycle_stmt; DEALLOCATE PREPARE exception_lifecycle_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_exception_order' AND column_name='evidence_file_ids_json'), 'SELECT 1', 'ALTER TABLE ll_exception_order ADD COLUMN evidence_file_ids_json JSON NULL COMMENT ''异常处理证据文件ID列表'' AFTER handling_result')); -PREPARE exception_lifecycle_stmt FROM @ddl; EXECUTE exception_lifecycle_stmt; DEALLOCATE PREPARE exception_lifecycle_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_exception_order' AND column_name='inventory_adjustment_id'), 'SELECT 1', 'ALTER TABLE ll_exception_order ADD COLUMN inventory_adjustment_id BIGINT NULL COMMENT ''退回库存恢复操作ID'' AFTER evidence_file_ids_json')); -PREPARE exception_lifecycle_stmt FROM @ddl; EXECUTE exception_lifecycle_stmt; DEALLOCATE PREPARE exception_lifecycle_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_exception_order' AND column_name='completed_at'), 'SELECT 1', 'ALTER TABLE ll_exception_order ADD COLUMN completed_at DATETIME(3) NULL COMMENT ''异常处理完成时间'' AFTER exception_at')); -PREPARE exception_lifecycle_stmt FROM @ddl; EXECUTE exception_lifecycle_stmt; DEALLOCATE PREPARE exception_lifecycle_stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_exception_order' AND column_name='version'), 'SELECT 1', 'ALTER TABLE ll_exception_order ADD COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT ''异常订单乐观锁版本'' AFTER completed_at')); -PREPARE exception_lifecycle_stmt FROM @ddl; EXECUTE exception_lifecycle_stmt; DEALLOCATE PREPARE exception_lifecycle_stmt; - CREATE TABLE IF NOT EXISTS ll_customer_visit ( id BIGINT NOT NULL COMMENT '客户回访记录ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', @@ -2061,25 +2040,6 @@ CREATE TABLE IF NOT EXISTS ll_security_projection ( KEY idx_security_projection_permission (aggregate_id,permission_version) ) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; --- 兼容已有的旧版投影表:CREATE TABLE IF NOT EXISTS 不会补充后续新增字段。 -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_security_projection' AND column_name='roles_json'), 'SELECT 1', - 'ALTER TABLE ll_security_projection ADD COLUMN roles_json JSON NULL COMMENT ''IAM投影的当前角色快照'' AFTER permission_version')); -PREPARE stmt FROM @ddl; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() - AND table_name='ll_security_projection' AND column_name='authorities_json'), 'SELECT 1', - 'ALTER TABLE ll_security_projection ADD COLUMN authorities_json JSON NULL COMMENT ''IAM投影的当前海外权限快照'' AFTER roles_json')); -PREPARE stmt FROM @ddl; -EXECUTE stmt; -DEALLOCATE PREPARE stmt; -UPDATE ll_security_projection SET roles_json=JSON_ARRAY() WHERE roles_json IS NULL; -UPDATE ll_security_projection SET authorities_json=JSON_ARRAY() WHERE authorities_json IS NULL; -ALTER TABLE ll_security_projection - MODIFY COLUMN roles_json JSON NOT NULL COMMENT 'IAM投影的当前角色快照', - MODIFY COLUMN authorities_json JSON NOT NULL COMMENT 'IAM投影的当前海外权限快照'; - -- 开发基线固定管理员;后续真实 IAM 事件从 aggregate_version=1 单调覆盖该 version=0 投影。 INSERT IGNORE INTO ll_security_projection ( aggregate_type,aggregate_id,tenant_id,security_status,permission_version,roles_json,authorities_json,aggregate_version, @@ -2151,91 +2111,3 @@ CREATE TABLE IF NOT EXISTS ll_destuff_pallet_cost_allocation ( PRIMARY KEY(id), UNIQUE KEY uk_ll_destuff_customer(cost_id,customer_id), UNIQUE KEY uk_ll_destuff_fee_source(fee_source_id), CONSTRAINT chk_ll_destuff_allocation CHECK(actual_volume_cbm>0 AND allocated_amount>=0) ) ENGINE=InnoDB COMMENT='拆柜打托成本按客户实收体积分摊'; - --- 客户退货预报、PDA 签收与仓储费起算批次 -CREATE TABLE IF NOT EXISTS ll_return_order ( - id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', return_no VARCHAR(128) NOT NULL COMMENT '退货订单号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', customer_reference_no VARCHAR(128) NULL COMMENT '客户参考号', status VARCHAR(32) NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', expected_sku_types INT NOT NULL COMMENT '预报SKU种类数', expected_quantity BIGINT NOT NULL COMMENT '预报总数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '累计实收总数量', remark VARCHAR(1000) NULL COMMENT '客户备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求SHA-256', created_by VARCHAR(128) NULL COMMENT '创建操作人ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_order_customer_no(customer_id,return_no), UNIQUE KEY uk_ll_return_order_customer_key(customer_id,idempotency_key), KEY idx_ll_return_order_warehouse_status(overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_order_quantity CHECK(expected_sku_types>0 AND expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) -) ENGINE=InnoDB COMMENT='客户退货订单及SKU实收汇总'; -CREATE TABLE IF NOT EXISTS ll_return_order_item ( - id BIGINT NOT NULL COMMENT '退货预报明细ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', expected_quantity BIGINT NOT NULL COMMENT 'SKU预报数量', received_quantity BIGINT NOT NULL DEFAULT 0 COMMENT 'SKU累计实收数量', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code), KEY idx_ll_return_item_sku(sku_code,received_quantity), CONSTRAINT chk_ll_return_item_quantity CHECK(expected_quantity>0 AND received_quantity>=0 AND received_quantity<=expected_quantity) -) ENGINE=InnoDB COMMENT='客户退货订单的SKU预报与实收明细'; -CREATE TABLE IF NOT EXISTS ll_return_receipt ( - id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', receipt_no VARCHAR(128) NOT NULL COMMENT '签收单号', receipt_batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', tracking_no VARCHAR(128) NULL COMMENT '本次签收包裹的可选快递单号', bound_by VARCHAR(16) NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', status VARCHAR(32) NOT NULL COMMENT '签收状态,当前SIGNED', received_quantity BIGINT NOT NULL COMMENT '本次实收数量', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', operator_name VARCHAR(128) NULL COMMENT 'PDA操作人名称', idempotency_key VARCHAR(255) NOT NULL COMMENT 'PDA确认幂等键', request_hash CHAR(64) NOT NULL COMMENT 'PDA确认请求SHA-256', signed_at DATETIME(3) NOT NULL COMMENT '签收时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_receipt_no(receipt_no), UNIQUE KEY uk_ll_return_receipt_batch(receipt_batch_no), UNIQUE KEY uk_ll_return_receipt_operator_key(operator_id,idempotency_key), KEY idx_ll_return_receipt_order(return_order_id,signed_at), CONSTRAINT chk_ll_return_receipt_quantity CHECK(received_quantity>0), CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN('RETURN_NO','SKU')) -) ENGINE=InnoDB COMMENT='PDA退货实收签收事实'; - --- 已创建开发库升级为“客户只预报 SKU,快递单号仅属于签收批次”的最终模型。 -SET @ll_return_item_drop_tracking_order_sku = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX uk_ll_return_item_order_tracking_sku', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_tracking_sku'); -PREPARE ll_return_item_drop_tracking_order_sku_stmt FROM @ll_return_item_drop_tracking_order_sku; -EXECUTE ll_return_item_drop_tracking_order_sku_stmt; -DEALLOCATE PREPARE ll_return_item_drop_tracking_order_sku_stmt; -SET @ll_return_item_drop_tracking = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_tracking', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_tracking'); -PREPARE ll_return_item_drop_tracking_stmt FROM @ll_return_item_drop_tracking; -EXECUTE ll_return_item_drop_tracking_stmt; -DEALLOCATE PREPARE ll_return_item_drop_tracking_stmt; -SET @ll_return_item_drop_order = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP INDEX idx_ll_return_item_order', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_order'); -PREPARE ll_return_item_drop_order_stmt FROM @ll_return_item_drop_order; -EXECUTE ll_return_item_drop_order_stmt; -DEALLOCATE PREPARE ll_return_item_drop_order_stmt; -SET @ll_return_item_drop_tracking_no = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_order_item DROP COLUMN tracking_no', 'SELECT 1') FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND column_name='tracking_no'); -PREPARE ll_return_item_drop_tracking_no_stmt FROM @ll_return_item_drop_tracking_no; -EXECUTE ll_return_item_drop_tracking_no_stmt; -DEALLOCATE PREPARE ll_return_item_drop_tracking_no_stmt; -SET @ll_return_item_add_order_sku = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD UNIQUE KEY uk_ll_return_item_order_sku(return_order_id,sku_code)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='uk_ll_return_item_order_sku'); -PREPARE ll_return_item_add_order_sku_stmt FROM @ll_return_item_add_order_sku; -EXECUTE ll_return_item_add_order_sku_stmt; -DEALLOCATE PREPARE ll_return_item_add_order_sku_stmt; -SET @ll_return_item_add_sku_idx = (SELECT IF(COUNT(*) = 0, 'ALTER TABLE ll_return_order_item ADD KEY idx_ll_return_item_sku(sku_code,received_quantity)', 'SELECT 1') FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_return_order_item' AND index_name='idx_ll_return_item_sku'); -PREPARE ll_return_item_add_sku_idx_stmt FROM @ll_return_item_add_sku_idx; -EXECUTE ll_return_item_add_sku_idx_stmt; -DEALLOCATE PREPARE ll_return_item_add_sku_idx_stmt; -UPDATE ll_return_receipt SET bound_by='RETURN_NO' WHERE bound_by='TRACKING'; -SET @ll_return_receipt_drop_bound_check = (SELECT IF(COUNT(*) > 0, 'ALTER TABLE ll_return_receipt DROP CHECK chk_ll_return_receipt_bound', 'SELECT 1') FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_receipt' AND constraint_name='chk_ll_return_receipt_bound'); -PREPARE ll_return_receipt_drop_bound_check_stmt FROM @ll_return_receipt_drop_bound_check; -EXECUTE ll_return_receipt_drop_bound_check_stmt; -DEALLOCATE PREPARE ll_return_receipt_drop_bound_check_stmt; -SET @ll_return_receipt_alter = 'ALTER TABLE ll_return_receipt MODIFY COLUMN tracking_no VARCHAR(128) NULL COMMENT ''本次签收包裹的可选快递单号'', MODIFY COLUMN bound_by VARCHAR(16) NOT NULL COMMENT ''开始签收方式,RETURN_NO退货单号或SKU候选'', ADD CONSTRAINT chk_ll_return_receipt_bound CHECK(bound_by IN(''RETURN_NO'',''SKU''))'; -PREPARE ll_return_receipt_alter_stmt FROM @ll_return_receipt_alter; -EXECUTE ll_return_receipt_alter_stmt; -DEALLOCATE PREPARE ll_return_receipt_alter_stmt; -CREATE TABLE IF NOT EXISTS ll_return_receipt_item ( - id BIGINT NOT NULL COMMENT '签收SKU明细ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', return_order_item_id BIGINT NOT NULL COMMENT '退货预报明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', received_quantity BIGINT NOT NULL COMMENT '本次SKU实收数量', - PRIMARY KEY(id), KEY idx_ll_return_receipt_item_receipt(receipt_id), KEY idx_ll_return_receipt_item_order_item(return_order_item_id), CONSTRAINT chk_ll_return_receipt_item_quantity CHECK(received_quantity>0) -) ENGINE=InnoDB COMMENT='PDA退货签收SKU实际数量明细'; -CREATE TABLE IF NOT EXISTS ll_return_receipt_batch ( - id BIGINT NOT NULL COMMENT '退货仓储计费批次ID', return_order_id BIGINT NOT NULL COMMENT '退货订单ID', receipt_id BIGINT NOT NULL COMMENT 'PDA退货签收单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', batch_no VARCHAR(128) NOT NULL COMMENT '签收批次号', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '操作海外仓ID', storage_billing_status VARCHAR(32) NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', storage_started_at DATETIME(3) NOT NULL COMMENT '仓储计费起算时间', created_at DATETIME(3) NOT NULL COMMENT '创建时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_batch_no(batch_no), UNIQUE KEY uk_ll_return_batch_receipt(receipt_id), KEY idx_ll_return_batch_billing(customer_id,overseas_warehouse_id,storage_billing_status,storage_started_at) -) ENGINE=InnoDB COMMENT='每次退货签收生成的仓储费起算批次'; - --- 退货库存:签收即生成箱号,库存按客户、海外仓和SKU汇总;箱内明细保留给合箱与箱唛。 -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_receipt' AND column_name='carton_mark'), 'SELECT 1', 'ALTER TABLE ll_return_receipt ADD COLUMN carton_mark VARCHAR(128) NULL COMMENT ''签收生成或绑定的退货箱号'' AFTER receipt_batch_no')); -PREPARE return_receipt_carton_mark_stmt FROM @ddl; EXECUTE return_receipt_carton_mark_stmt; DEALLOCATE PREPARE return_receipt_carton_mark_stmt; -CREATE TABLE IF NOT EXISTS ll_return_inventory_carton ( - id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', source_receipt_id BIGINT NULL COMMENT '来源PDA签收单ID', source_batch_no VARCHAR(128) NULL COMMENT '来源签收批次号', carton_mark VARCHAR(128) NOT NULL COMMENT '退货箱号和箱唛编码', status VARCHAR(32) NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', sku_types INT NOT NULL COMMENT '箱内SKU种类数', total_quantity BIGINT NOT NULL COMMENT '箱内总件数', length DECIMAL(12,3) NULL COMMENT '箱长CM', width DECIMAL(12,3) NULL COMMENT '箱宽CM', height DECIMAL(12,3) NULL COMMENT '箱高CM', weight DECIMAL(12,3) NULL COMMENT '箱重KG', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', merged_at DATETIME(3) NULL COMMENT '合箱完成时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_mark(carton_mark), UNIQUE KEY uk_ll_return_inventory_carton_receipt(source_receipt_id), KEY idx_ll_return_inventory_carton_customer(customer_id,overseas_warehouse_id,status,created_at), CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)), CONSTRAINT chk_ll_return_inventory_carton_status CHECK(status IN('AVAILABLE','MERGED')) -) ENGINE=InnoDB COMMENT='退货实物库存箱及箱唛'; --- 已合箱的来源箱保留审计记录,但其箱内库存必须归零;动态 SQL 兼容 Spring 的数据库初始化器。 -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND constraint_name='chk_ll_return_inventory_carton_quantity'), 'ALTER TABLE ll_return_inventory_carton DROP CHECK chk_ll_return_inventory_carton_quantity', 'SELECT 1')); -PREPARE return_inventory_carton_check_drop_stmt FROM @ddl; EXECUTE return_inventory_carton_check_drop_stmt; DEALLOCATE PREPARE return_inventory_carton_check_drop_stmt; -ALTER TABLE ll_return_inventory_carton ADD CONSTRAINT chk_ll_return_inventory_carton_quantity CHECK((status='AVAILABLE' AND sku_types>0 AND total_quantity>0) OR (status='MERGED' AND sku_types=0 AND total_quantity=0)); -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_carton' AND column_name='source_return_no'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_carton ADD COLUMN source_return_no VARCHAR(2000) NULL COMMENT ''来源退货单号'' AFTER source_batch_no')); -PREPARE return_inventory_carton_return_no_stmt FROM @ddl; EXECUTE return_inventory_carton_return_no_stmt; DEALLOCATE PREPARE return_inventory_carton_return_no_stmt; -CREATE TABLE IF NOT EXISTS ll_return_inventory_carton_item ( - id BIGINT NOT NULL COMMENT '退货库存箱SKU明细ID', carton_id BIGINT NOT NULL COMMENT '退货库存箱ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', quantity BIGINT NOT NULL COMMENT '该SKU箱内数量', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_carton_item(carton_id,sku_code), KEY idx_ll_return_inventory_carton_item_customer(customer_id,sku_code), CONSTRAINT chk_ll_return_inventory_carton_item_quantity CHECK(quantity>0) -) ENGINE=InnoDB COMMENT='退货库存箱内SKU和数量'; -CREATE TABLE IF NOT EXISTS ll_return_inventory_balance ( - id BIGINT NOT NULL COMMENT '退货库存余额ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID和隔离键', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '海外仓ID', sku_code VARCHAR(128) NOT NULL COMMENT 'SKU编码', product_name_zh VARCHAR(255) NULL COMMENT '中文品名', total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减', available_quantity BIGINT NOT NULL COMMENT '未合箱可用上架数量', carton_quantity BIGINT NOT NULL COMMENT '包含该SKU的可用箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code), CONSTRAINT chk_ll_return_inventory_balance_quantity CHECK(total_quantity>=available_quantity AND available_quantity>=0 AND carton_quantity>=0) -) ENGINE=InnoDB COMMENT='退货库存按客户海外仓SKU汇总余额'; -SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_return_inventory_balance' AND column_name='total_quantity'), 'SELECT 1', 'ALTER TABLE ll_return_inventory_balance ADD COLUMN total_quantity BIGINT NULL COMMENT ''实物总库存,仅实际出库扣减'' AFTER product_name_zh')); -PREPARE return_inventory_balance_total_stmt FROM @ddl; EXECUTE return_inventory_balance_total_stmt; DEALLOCATE PREPARE return_inventory_balance_total_stmt; --- 旧数据的可用量原先包含已合箱库存;升级时一次性拆分为总库存和未合箱可用库存。 -UPDATE ll_return_inventory_balance b LEFT JOIN (SELECT c.customer_id,c.overseas_warehouse_id,i.sku_code,SUM(i.quantity) merged_quantity 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' GROUP BY c.customer_id,c.overseas_warehouse_id,i.sku_code) m ON m.customer_id=b.customer_id AND m.overseas_warehouse_id=b.overseas_warehouse_id AND m.sku_code=b.sku_code SET b.total_quantity=b.available_quantity,b.available_quantity=GREATEST(0,b.available_quantity-COALESCE(m.merged_quantity,0)) WHERE b.total_quantity IS NULL; -ALTER TABLE ll_return_inventory_balance MODIFY COLUMN total_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '实物总库存,仅实际出库扣减'; -CREATE TABLE IF NOT EXISTS ll_return_inventory_operation ( - id BIGINT NOT NULL COMMENT '退货库存PDA操作ID', operator_id VARCHAR(128) NOT NULL COMMENT 'PDA操作人ID', idempotency_key VARCHAR(255) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求内容SHA-256', operation_type VARCHAR(32) NOT NULL COMMENT '操作类型,当前MERGE', result_carton_id BIGINT NULL COMMENT '合箱结果退货库存箱ID', created_at DATETIME(3) NOT NULL COMMENT '创建时间', updated_at DATETIME(3) NOT NULL COMMENT '最后更新时间', - PRIMARY KEY(id), UNIQUE KEY uk_ll_return_inventory_operation_operator_key(operator_id,idempotency_key), KEY idx_ll_return_inventory_operation_result(result_carton_id) -) ENGINE=InnoDB COMMENT='退货库存PDA合箱幂等操作审计'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java index 142433c11..4407a7b31 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -66,6 +66,22 @@ class HttpWarehouseResourceGatewayTest { server.verify(); } + @Test + void resolveReservedAcceptsOnlyExactOccupiedPlanBinding() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED")) + .andRespond(withSuccess(""" + {"code":"200","data":{"palletId":31,"palletCode":"PAL-RESERVED", + "warehouseId":21,"locationId":41,"status":"OCCUPIED","version":3, + "reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}} + """, MediaType.APPLICATION_JSON)); + + var result = gateway.resolveReservedPallet("PAL-RESERVED", "11", 21L, 31L, 41L, 51L); + + assertThat(result.palletId()).isEqualTo(31L); + assertThat(result.status()).isEqualTo("OCCUPIED"); + server.verify(); + } + @Test void inspectAllowsOccupiedPalletForReadOnlyInventoryQueries() { server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-OCCUPIED")) @@ -84,6 +100,22 @@ class HttpWarehouseResourceGatewayTest { server.verify(); } + @Test + void resolveReservedRejectsDifferentPhysicalBinding() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-RESERVED")) + .andRespond(withSuccess(""" + {"code":"200","data":{"palletId":31,"palletCode":"PAL-RESERVED", + "warehouseId":21,"locationId":99,"status":"OCCUPIED","version":3, + "reservable":false,"reservationBlockReason":"PALLET_NOT_IDLE"}} + """, MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> gateway.resolveReservedPallet("PAL-RESERVED", "11", 21L, 31L, 41L, 51L)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("409") + .hasMessageContaining("does not match"); + server.verify(); + } + @Test void resolveFailsClosedWhenWarehouseOmitsReservationFacts() { server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-UNKNOWN")) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index f8093b7c4..f91971b8a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -188,6 +188,98 @@ class InboundPdaWorkflowServiceTest { verify(repository).update(targetPallet); } + @Test + void reservedPalletScanConfirmsOnSiteWithoutReservingAgainAndAdvancesPlan() { + plan.setStatus("RESERVED"); + targetPallet.setPalletNo("IP-ACY2-20260909-P001"); + targetPallet.setWarehousePalletId(188L); + targetPallet.setWarehouseLocationId(288L); + targetPallet.setWarehouseResourceLockId(999L); + targetPallet.setAssignmentStatus("RESERVED"); + targetPallet.setLockedAt(null); + WarehouseResourceGateway.WarehousePalletReference resource = + new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 288L, + "OCCUPIED", "ACY2", 1); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + service.assignResolvedPallet("100", plan, targetPallet, resource); + service.assignResolvedPallet("100", plan, targetPallet, resource); + + assertEquals("A002", targetPallet.getPalletNo()); + assertEquals("ASSIGNED", targetPallet.getAssignmentStatus()); + org.junit.jupiter.api.Assertions.assertNotNull(targetPallet.getLockedAt()); + assertEquals("ASSIGNED", plan.getStatus()); + verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any()); + verify(repository, times(1)).update(targetPallet); + verify(repository, times(1)).update(plan); + } + + @Test + void publicAssignmentAlsoConfirmsReservedPalletWithoutReservingAgain() { + plan.setStatus("PENDING_ASSIGNMENT"); + targetPallet.setDestinationWarehouseId("88"); + targetPallet.setWarehousePalletId(188L); + targetPallet.setWarehouseLocationId(288L); + targetPallet.setWarehouseResourceLockId(999L); + targetPallet.setAssignmentStatus("RESERVED"); + targetPallet.setLockedAt(null); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(88L, 188L, 288L, "reserved-public")); + service.assign("100", "LIP1", targetPallet.getId(), + new PdaRequests.Assign(88L, 188L, 288L, "reserved-public-retry")); + + assertEquals("ASSIGNED", targetPallet.getAssignmentStatus()); + assertEquals("ASSIGNED", plan.getStatus()); + verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any()); + verify(repository, times(1)).update(targetPallet); + verify(repository, times(1)).update(plan); + } + + @Test + void reservedConfirmationDoesNotRegressAdvancedPlanStatus() { + plan.setStatus("SIGNED"); + targetPallet.setWarehousePalletId(188L); + targetPallet.setWarehouseLocationId(288L); + targetPallet.setWarehouseResourceLockId(999L); + targetPallet.setAssignmentStatus("RESERVED"); + WarehouseResourceGateway.WarehousePalletReference resource = + new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 288L, + "OCCUPIED", "ACY2", 1); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + + service.assignResolvedPallet("100", plan, targetPallet, resource); + + assertEquals("ASSIGNED", targetPallet.getAssignmentStatus()); + assertEquals("SIGNED", plan.getStatus()); + verify(repository, never()).update(plan); + } + + @Test + void reservedPalletScanRejectsAnotherPhysicalPalletOrLocation() { + targetPallet.setWarehousePalletId(188L); + targetPallet.setWarehouseLocationId(288L); + targetPallet.setWarehouseResourceLockId(999L); + targetPallet.setAssignmentStatus("RESERVED"); + WarehouseResourceGateway.WarehousePalletReference anotherPallet = + new WarehouseResourceGateway.WarehousePalletReference(189L, "A003", 88L, 288L, + "IDLE", "ACY2", 1); + WarehouseResourceGateway.WarehousePalletReference anotherLocation = + new WarehouseResourceGateway.WarehousePalletReference(188L, "A002", 88L, 289L, + "IDLE", "ACY2", 1); + + assertThrows(ResponseStatusException.class, + () -> service.assignResolvedPallet("100", plan, targetPallet, anotherPallet)); + assertThrows(ResponseStatusException.class, + () -> service.assignResolvedPallet("100", plan, targetPallet, anotherLocation)); + + assertEquals("RESERVED", targetPallet.getAssignmentStatus()); + verify(warehouse, never()).reserve(any(), anyLong(), anyLong(), anyLong(), any(), any(), any()); + verify(repository, never()).update(targetPallet); + } + @Test void resolvedAssignmentRejectsPhysicalPalletWithoutBusinessCodeBeforeReservation() { targetPallet.setWarehouseResourceLockId(null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 0f944f4b4..9d9511c5a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -465,6 +465,37 @@ class ReceivingContainerServiceBehaviorTest { verify(workflow).compensateReservation("100", 55L, failure); } + @Test + void bulkReceivingResolvesExactPreReservedPalletWithoutIdleLookup() { + stubCurrent("FBA", "CONT-A"); + stubSku(); + InboundPlanPallet reserved = planPallet(); + reserved.setPalletNo("PAL-RESERVED"); + reserved.setWarehousePalletId(30L); + reserved.setWarehouseLocationId(40L); + reserved.setWarehouseResourceLockId(50L); + reserved.setAssignmentStatus("RESERVED"); + when(pallets.selectList(any())).thenReturn(List.of(reserved)); + WarehouseResourceGateway.WarehousePalletReference occupied = + new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-RESERVED", 100L, 40L, + "OCCUPIED", "FBA-1", 0); + when(warehouse.resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L)) + .thenReturn(occupied); + when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any())) + .thenReturn(1); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + + service.receiveBulk("7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-RESERVED", 1, "reserved-key")); + + verify(warehouse).resolveReservedPallet("PAL-RESERVED", "100", 100L, 30L, 40L, 50L); + verify(warehouse, never()).resolvePallet("PAL-RESERVED"); + verify(workflow).assignResolvedPallet(org.mockito.ArgumentMatchers.eq("100"), + any(InboundPlan.class), org.mockito.ArgumentMatchers.same(reserved), + org.mockito.ArgumentMatchers.same(occupied)); + } + @Test void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index 196833bd4..0e8b2736d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -1,9 +1,11 @@ package com.baiyue.lastleg.inboundplan.service; import com.baiyue.lastleg.inboundplan.dto.ConvertContainerInboundPlanRequest; +import com.baiyue.lastleg.inboundplan.dto.ContainerInboundPlanPreviewResponse; import com.baiyue.lastleg.inboundplan.entity.ContainerPlanConversionCommand; import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.fasterxml.jackson.databind.json.JsonMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -11,8 +13,12 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.SimpleTransactionStatus; import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.math.BigDecimal; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -20,6 +26,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.eq; @ExtendWith(MockitoExtension.class) class ContainerInboundPlanConversionCoordinatorTest { @@ -29,6 +37,8 @@ class ContainerInboundPlanConversionCoordinatorTest { @Mock InboundPlanService plans; @Mock WarehouseResourceGateway warehouse; @Mock ContainerPlanConversionCommandMapper commands; + @Mock LastlegOrderNoGenerator numbers; + @Mock PlatformTransactionManager transactionManager; private ContainerInboundPlanConversionCoordinator coordinator; private JsonMapper objectMapper; @@ -40,7 +50,11 @@ class ContainerInboundPlanConversionCoordinatorTest { ReflectionTestUtils.setField(coordinator, "plans", plans); ReflectionTestUtils.setField(coordinator, "warehouse", warehouse); ReflectionTestUtils.setField(coordinator, "commands", commands); + ReflectionTestUtils.setField(coordinator, "numbers", numbers); ReflectionTestUtils.setField(coordinator, "objectMapper", objectMapper); + ReflectionTestUtils.setField(coordinator, "transactionManager", transactionManager); + org.mockito.Mockito.lenient().when(transactionManager.getTransaction(any())) + .thenReturn(new SimpleTransactionStatus()); } @Test @@ -57,6 +71,19 @@ class ContainerInboundPlanConversionCoordinatorTest { verify(commands, never()).insert(org.mockito.ArgumentMatchers.any(ContainerPlanConversionCommand.class)); } + @Test + void sameIdempotencyKeyStillRejectsAlteredCompletedRequest() throws Exception { + ContainerPlanConversionCommand completed = command("FIRST-KEY", "COMPLETED", request()); + completed.setResultJson("[]"); + when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(completed); + + ConvertContainerInboundPlanRequest changed = + new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64)); + + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> coordinator.confirm(changed, "FIRST-KEY")).getStatusCode().value()); + } + @Test void concurrentCabinetClaimRejectsFollowerBeforeWarehouseReservation() throws Exception { ConvertContainerInboundPlanRequest request = request(); @@ -78,7 +105,7 @@ class ContainerInboundPlanConversionCoordinatorTest { when(commands.find(BOOKING_ID, "SECOND-KEY")).thenReturn(null); when(commands.findByBooking(BOOKING_ID)).thenReturn(prepared); when(commands.claim(org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq("PREPARED"), - org.mockito.ArgumentMatchers.eq("APPLYING"), org.mockito.ArgumentMatchers.any())).thenReturn(0); + org.mockito.ArgumentMatchers.eq("APPLYING"), any(String.class), org.mockito.ArgumentMatchers.any())).thenReturn(0); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> coordinator.confirm(request, "SECOND-KEY")); @@ -102,6 +129,102 @@ class ContainerInboundPlanConversionCoordinatorTest { assertEquals(409, error.getStatusCode().value()); } + @Test + void staleApplyingLeaseIsReclaimedWithoutRepeatingPreparedClaim() throws Exception { + ConvertContainerInboundPlanRequest request = request(); + ContainerPlanConversionCommand stale = command("FIRST-KEY", "APPLYING", request); + stale.setUpdatedAt(LocalDateTime.now().minusMinutes(3)); + when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(stale); + when(commands.reclaimStale(eq(1L), eq(stale.getUpdatedAt()), any(String.class), any())).thenReturn(1); + when(plans.convertContainerReserved(BOOKING_ID, 7L, SNAPSHOT_HASH, List.of())).thenReturn(List.of()); + when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"), + org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1); + + assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY")); + + verify(commands, never()).claim(any(Long.class), any(String.class), any(String.class), + any(String.class), any()); + } + + @Test + void compensatedFailureCanBeReplacedByFreshSnapshotAndComplete() throws Exception { + ContainerPlanConversionCommand failed = command("OLD-KEY", "FAILED", request()); + when(commands.find(BOOKING_ID, "NEW-KEY")).thenReturn(null); + when(commands.findByBooking(BOOKING_ID)).thenReturn(failed); + when(plans.previewContainer(BOOKING_ID)).thenReturn(preview(8L, "b".repeat(64))); + when(commands.replaceFailed(eq(1L), eq("NEW-KEY"), any(), eq(8L), eq("b".repeat(64)), + eq(31L), any(), any(), any())).thenReturn(1); + when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1); + when(plans.convertContainerReserved(BOOKING_ID, 8L, "b".repeat(64), List.of())).thenReturn(List.of()); + when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("COMPLETED"), eq("[]"), + org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1); + + assertEquals(List.of(), coordinator.confirm( + new ConvertContainerInboundPlanRequest(BOOKING_ID, 8L, "b".repeat(64)), "NEW-KEY")); + } + + @Test + void invalidEnsureResponseIsCompensatedBeforeCommandBecomesFailed() throws Exception { + ConvertContainerInboundPlanRequest request = request(); + ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request); + setOnePreparedItem(prepared); + when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared); + when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1); + when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse( + 0L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED")))); + when(warehouse.compensatePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse( + 41L, "BATCH-1", "COMPENSATED", 1, false, List.of(reserved("RELEASED")))); + when(commands.updateState(eq(1L), eq("APPLYING"), any(String.class), eq("FAILED"), + org.mockito.ArgumentMatchers.isNull(), any(), any())).thenReturn(1); + + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> coordinator.confirm(request, "FIRST-KEY")).getStatusCode().value()); + + verify(warehouse).compensatePalletBatch(any()); + } + + @Test + void uncertainLocalCommitIsRereadBeforeDecidingWhetherToCompensate() throws Exception { + ConvertContainerInboundPlanRequest request = request(); + ContainerPlanConversionCommand prepared = command("FIRST-KEY", "PREPARED", request); + setOnePreparedItem(prepared); + ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request); + committed.setResultJson("[]"); + when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(prepared); + when(commands.claim(eq(1L), eq("PREPARED"), eq("APPLYING"), any(String.class), any())).thenReturn(1); + when(warehouse.ensurePalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse( + 41L, "BATCH-1", "RESERVED", 1, false, List.of(reserved("RESERVED")))); + when(plans.convertContainerReserved(eq(BOOKING_ID), eq(7L), eq(SNAPSHOT_HASH), any())) + .thenThrow(new IllegalStateException("commit outcome unknown")); + when(commands.findByBooking(BOOKING_ID)).thenReturn(committed); + when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse( + 41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED")))); + when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"), + org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1); + + assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY")); + + verify(warehouse, never()).compensatePalletBatch(any()); + } + + @Test + void localCommittedReplayOnlyRetriesRemoteConfirmation() throws Exception { + ConvertContainerInboundPlanRequest request = request(); + ContainerPlanConversionCommand committed = command("FIRST-KEY", "LOCAL_COMMITTED", request); + committed.setResultJson("[]"); + setOnePreparedItem(committed); + when(commands.find(BOOKING_ID, "FIRST-KEY")).thenReturn(committed); + when(warehouse.confirmPalletBatch(any())).thenReturn(new WarehouseResourceGateway.PalletBatchResponse( + 41L, "BATCH-1", "CONFIRMED", 1, false, List.of(reserved("CONFIRMED")))); + when(commands.updateState(eq(1L), eq("LOCAL_COMMITTED"), any(String.class), eq("COMPLETED"), eq("[]"), + org.mockito.ArgumentMatchers.isNull(), any())).thenReturn(1); + + assertEquals(List.of(), coordinator.confirm(request, "FIRST-KEY")); + + verify(warehouse, never()).ensurePalletBatch(any()); + verify(warehouse).confirmPalletBatch(any()); + } + private ConvertContainerInboundPlanRequest request() { return new ConvertContainerInboundPlanRequest(BOOKING_ID, 7L, SNAPSHOT_HASH); } @@ -119,8 +242,26 @@ class ContainerInboundPlanConversionCoordinatorTest { command.setWarehouseId(31L); command.setBatchKey("BATCH-1"); command.setStatus(status); + if ("APPLYING".equals(status) || "LOCAL_COMMITTED".equals(status)) command.setLeaseToken("LEASE-1"); command.setPreparationJson(objectMapper.writeValueAsString( new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of()))); return command; } + + private void setOnePreparedItem(ContainerPlanConversionCommand command) throws Exception { + var item = new ContainerInboundPlanConversionCoordinator.PreparedItem( + "100", 11L, 21L, "IP-21", 1, 1, "CIP:1:1", "ONT8"); + command.setPreparationJson(objectMapper.writeValueAsString( + new ContainerInboundPlanConversionCoordinator.Preparation(31L, List.of(item)))); + } + + private WarehouseResourceGateway.PalletBatchItem reserved(String status) { + return new WarehouseResourceGateway.PalletBatchItem( + 100L, "CIP:1:1", "IP-21", 1, 51L, "PLT-51", 61L, 71L, status); + } + + private ContainerInboundPlanPreviewResponse preview(long version, String hash) { + return new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01", + "Los Angeles", version, hash, 1, 1, 0, 0, 0, 1, 1, 1, true, List.of()); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java index fb7c97688..b5186dec5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java @@ -67,6 +67,21 @@ class ContainerInboundPlanPreviewTest { assertEquals(List.of("DESTINATION_NOT_READY"), result.groups().get(4).orders().get(0).blockers()); } + @Test + void hashesBlockedPreviewEvenWhenContainerAndWarehouseFactsAreNull() { + when(orderMapper.containerPreviewOrders("BOOKING-NULL")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-1", "FO-1", "测试客户", + "FIRST_LEG", "OVERSEAS", "IN-1", "PENDING_ASSIGNMENT", null, null, 0, 0, + BigDecimal.ZERO, null, null, null, null, null, null, null, 0, false, 1, + null, null, null))); + + var result = service.previewContainer("BOOKING-NULL"); + + assertNotNull(result.snapshotHash()); + assertEquals(64, result.snapshotHash().length()); + assertFalse(result.ready()); + } + private InboundPlanOrderMapper.ContainerPreviewOrderRow row( long id, String orderNo, String orderType, String destinationType, String destinationCode, String destinationName, int recommendedPallets, String planNo, Integer plannedPallets, int version) {