From cc6df5993f09c2d36f2086566bf3e7688709d8d8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 11:00:39 +0800 Subject: [PATCH] fix(lastleg): enforce container warehouse plan facts --- .../dto/ManualInboundPlanRequest.java | 4 - .../mapper/InboundPlanOrderMapper.java | 11 +++ .../service/InboundPlanService.java | 73 +++++++++++++----- .../openapi/overseas-business-v1.yaml | 7 +- .../TailPlanCommandOpenApiContractTest.java | 10 +++ .../controller/InboundPlanControllerTest.java | 3 +- .../InboundPlanOrderMapperContractTest.java | 13 ++++ .../service/InboundPlanServiceTest.java | 75 ++++++++++++++++++- 8 files changed, 161 insertions(+), 35 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java index fb8bd05e8..ce7be8228 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java @@ -11,11 +11,7 @@ public record ManualInboundPlanRequest( @NotBlank @Size(max = 128) String customerId, @NotNull Long orderId, @NotNull @Min(0) Integer expectedOrderVersion, - @NotNull Long plannedWarehouseId, - @NotBlank @Size(max = 128) String plannedWarehouseCode, - @NotBlank @Size(max = 255) String plannedWarehouseName, @NotNull LocalDate plannedArrivalDate, - @NotBlank @Size(max = 128) String inboundCode, @NotNull @Min(1) @Max(999) Integer palletCount, @NotBlank @Size(max = 32) String palletType, @NotNull Boolean assigned, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java index 77bfc983d..8aedf41ff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java @@ -16,6 +16,17 @@ public interface InboundPlanOrderMapper { String orderType) { } record ReceivingWarehouseSnapshot(String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName) { } + record ContainerWarehouseRef(String customerId, Long orderId, String sourceContainerBookingId, + String overseasWarehouseId) { } + + @Select("SELECT c.customer_id AS customerId,c.id AS orderId," + + "c.source_container_booking_id AS sourceContainerBookingId,c.overseas_warehouse_id AS overseasWarehouseId " + + "FROM ll_order target JOIN ll_order c ON c.source_container_booking_id=target.source_container_booking_id " + + "WHERE target.customer_id=#{customerId} AND target.id=#{orderId} " + + "AND target.source_container_booking_id IS NOT NULL AND target.source_container_booking_id!='' " + + "ORDER BY c.customer_id,c.id FOR UPDATE") + List lockContainerWarehouseFacts(@Param("customerId") String customerId, + @Param("orderId") Long orderId); @Select("SELECT customer_id AS customerId,id AS orderId,current_status AS currentStatus,container_no AS containerNo," + "order_type AS orderType " + 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 ff07150f4..bce2f9900 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 @@ -75,6 +75,13 @@ public class InboundPlanService { if (key == null || key.length() > 128) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Idempotency-Key 必须为1至128个字符"); } + List containerFacts = + orderMapper.lockContainerWarehouseFacts(customerId, request.orderId()); + if (containerFacts == null || containerFacts.isEmpty()) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在或未绑定来源柜"); + } + String containerWarehouseId = requireSingleWarehouseId(containerFacts.stream() + .map(InboundPlanOrderMapper.ContainerWarehouseRef::overseasWarehouseId).toList()); List snapshots = orderMapper.lockOrder(customerId, request.orderId()); if (snapshots == null || snapshots.size() != 1) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "尾程订单不存在"); @@ -96,20 +103,14 @@ public class InboundPlanService { throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单版本已变化,请刷新后重试"); } if (order.detained()) throw new ResponseStatusException(HttpStatus.CONFLICT, "暂扣尾程订单不能生成入库计划"); - if (!Objects.equals(trimToNull(order.inboundCode()), trimToNull(request.inboundCode()))) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "入仓码与尾程订单冻结事实不一致"); - } - WarehouseResourceGateway.WarehouseReference warehouse = - warehouseGateway.requireActiveSharedWarehouse(request.plannedWarehouseId()); - if (!Objects.equals(String.valueOf(warehouse.warehouseId()), trimToNull(order.overseasWarehouseId())) - || !Objects.equals(warehouse.warehouseCode(), request.plannedWarehouseCode().trim()) - || !Objects.equals(warehouse.warehouseName(), request.plannedWarehouseName().trim()) - || !Objects.equals(warehouse.warehouseCode(), trimToNull(order.overseasWarehouseCode())) - || !Objects.equals(warehouse.warehouseName(), trimToNull(order.overseasWarehouseName()))) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "计划入仓仓库不存在、已停用或与订单预分配事实不一致"); + if (!Objects.equals(containerWarehouseId, trimToNull(order.overseasWarehouseId()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜接柜仓冻结事实已变化,请刷新后重试"); } + WarehouseResourceGateway.WarehouseReference receivingWarehouse = + requiredReceivingWarehouse(containerWarehouseId); validateOrders(snapshots); - InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null, request); + InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null, + request, receivingWarehouse); LastlegOrderCommandRecord command = new LastlegOrderCommandRecord(); command.setId(IdWorker.getId()); command.setCustomerId(customerId); command.setOrderId(request.orderId()); command.setAction("MANUAL_PLAN"); command.setOperatorId(operatorId); command.setIdempotencyKey(key); @@ -180,12 +181,13 @@ public class InboundPlanService { return detail(customerId, existing); } validateOrders(orders); - return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null); + return createPlan(customerId, selectedOrderId, orders, sourcePalletNos, wholePallets, null, null); } private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, List orders, List sourcePalletNos, - List wholePallets, ManualInboundPlanRequest manual) { + List wholePallets, ManualInboundPlanRequest manual, + WarehouseResourceGateway.WarehouseReference receivingWarehouse) { String containerNo = requiredContainerNo(orders.get(0).containerNo()); LocalDateTime now = LocalDateTime.now(); InboundPlan plan = new InboundPlan(); @@ -262,7 +264,7 @@ public class InboundPlanService { orderMapper.insertHistory(customerId, order.orderId(), order.currentStatus(), "Inbound order converted to inbound plan " + plan.getPlanNo(), now); } - return detail(customerId, plan); + return detail(customerId, plan, receivingWarehouse); } /** 同一来源柜的每个尾程订单独立建计划;整个柜级批次共用一个事务。 */ @@ -326,12 +328,14 @@ public class InboundPlanService { } wholePallets.add(sourcePallets); } + requireSingleWarehouseId(snapshots.stream().flatMap(List::stream) + .map(SourceOrderSnapshot::overseasWarehouseId).toList()); List created = new ArrayList<>(refs.size()); for (int index = 0; index < refs.size(); index++) { InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); created.add(createPlan(ref.customerId(), ref.orderId(), snapshots.get(index), List.of(), - wholePallets.get(index), null)); + wholePallets.get(index), null, null)); } return List.copyOf(created); } @@ -623,16 +627,21 @@ public class InboundPlanService { } private InboundPlanResponse detail(String customerId, InboundPlan plan) { + return detail(customerId, plan, null); + } + + private InboundPlanResponse detail(String customerId, InboundPlan plan, + WarehouseResourceGateway.WarehouseReference resolvedWarehouse) { if (customerId != null && !customerId.equals(plan.getCustomerId())) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); } InboundPlanOrderMapper.ReceivingWarehouseSnapshot receivingWarehouse = orderMapper.findReceivingWarehouse( plan.getCustomerId(), plan.getSourceOrderId()); - if (receivingWarehouse == null || trimToNull(receivingWarehouse.overseasWarehouseId()) == null - || trimToNull(receivingWarehouse.overseasWarehouseCode()) == null - || trimToNull(receivingWarehouse.overseasWarehouseName()) == null) { + if (receivingWarehouse == null || trimToNull(receivingWarehouse.overseasWarehouseId()) == null) { throw new ResponseStatusException(HttpStatus.CONFLICT, "入库计划关联订单缺少接柜仓预分配事实"); } + WarehouseResourceGateway.WarehouseReference authoritativeWarehouse = resolvedWarehouse == null + ? requiredReceivingWarehouse(receivingWarehouse.overseasWarehouseId()) : resolvedWarehouse; List lines = lineMapper.selectList(new LambdaQueryWrapper() .eq(InboundPlanLine::getPlanId, plan.getId()).orderByAsc(InboundPlanLine::getId)); List pallets = palletMapper.selectList(new LambdaQueryWrapper() @@ -642,7 +651,7 @@ public class InboundPlanService { .eq(InboundPlanPalletAllocation::getPlanId, plan.getId()) .orderByAsc(InboundPlanPalletAllocation::getId)); return new InboundPlanResponse(plan.getId(), plan.getPlanNo(), plan.getCustomerId(), plan.getSourceOrderId(), - plan.getContainerNo(), receivingWarehouse.overseasWarehouseCode(), receivingWarehouse.overseasWarehouseName(), + plan.getContainerNo(), authoritativeWarehouse.warehouseCode(), authoritativeWarehouse.warehouseName(), plan.getStatus(), plan.getLineCount(), plan.getPalletCount(), plan.getTotalVolumeCbm(), lines.stream().mapToInt(InboundPlanLine::getRequiredPackageCount).sum(), allocations.stream().mapToInt(InboundPlanPalletAllocation::getPickedPackageCount).sum(), @@ -663,6 +672,30 @@ public class InboundPlanService { Boolean.TRUE.equals(plan.getUrgentFlag()), plan.getCreationMode(), plan.getPlanRemark()); } + private WarehouseResourceGateway.WarehouseReference requiredReceivingWarehouse(String warehouseId) { + String stableId = trimToNull(warehouseId); + if (stableId == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓未预分配"); + } + try { + return warehouseGateway.requireActiveSharedWarehouse(Long.parseLong(stableId)); + } catch (NumberFormatException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内接柜仓ID格式无效", exception); + } + } + + private String requireSingleWarehouseId(List warehouseIds) { + List normalized = warehouseIds.stream().map(InboundPlanService::trimToNull).toList(); + if (normalized.stream().anyMatch(Objects::isNull)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在未分配接柜仓的尾程订单"); + } + java.util.Set distinct = new java.util.HashSet<>(normalized); + if (distinct.size() != 1) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单接柜仓不一致"); + } + return distinct.iterator().next(); + } + private BigDecimal volume(SourceOrderSnapshot order) { return Objects.requireNonNullElse(order.volumeCbm(), BigDecimal.ZERO); } diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 9c24059fc..d6ef2bc94 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1856,16 +1856,13 @@ components: items: { $ref: '#/components/schemas/LastlegOrderManualNode' } ManualInboundPlanRequest: type: object - required: [customerId, orderId, expectedOrderVersion, plannedWarehouseId, plannedWarehouseCode, plannedWarehouseName, plannedArrivalDate, inboundCode, palletCount, palletType, assigned, mergeRequired, urgent] + description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。 + required: [customerId, orderId, expectedOrderVersion, plannedArrivalDate, palletCount, palletType, assigned, mergeRequired, urgent] properties: customerId: { type: string, pattern: '^[1-9][0-9]*$' } orderId: { type: integer, format: int64, minimum: 1 } expectedOrderVersion: { type: integer, minimum: 0 } - plannedWarehouseId: { type: integer, format: int64, minimum: 1 } - plannedWarehouseCode: { type: string, minLength: 1, maxLength: 64 } - plannedWarehouseName: { type: string, minLength: 1, maxLength: 255 } plannedArrivalDate: { type: string, format: date } - inboundCode: { type: string, minLength: 1, maxLength: 128 } palletCount: { type: integer, minimum: 1, maximum: 999 } palletType: { type: string, minLength: 1, maxLength: 64 } assigned: { type: boolean } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java index 435a92e0c..2256edd4c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java @@ -1,5 +1,6 @@ package com.baiyue.lastleg.inboundplan; +import com.baiyue.lastleg.inboundplan.dto.ManualInboundPlanRequest; import org.junit.jupiter.api.Test; import java.nio.charset.StandardCharsets; @@ -16,6 +17,15 @@ class TailPlanCommandOpenApiContractTest { String api = new String(stream.readAllBytes(), StandardCharsets.UTF_8); assertTrue(api.contains("/api/platform/v1/last-leg/inbound-plans/manual:")); assertFalse(api.contains("/api/platform/v1/last-leg/inbound-plans/convert:")); + assertFalse(api.contains("plannedWarehouseId")); + assertFalse(api.contains("plannedWarehouseCode")); + assertFalse(api.contains("plannedWarehouseName")); + int manualSchemaStart = api.indexOf(" ManualInboundPlanRequest:"); + int manualSchemaEnd = api.indexOf("\n PageMetadata:", manualSchemaStart); + assertTrue(manualSchemaStart >= 0 && manualSchemaEnd > manualSchemaStart); + assertFalse(api.substring(manualSchemaStart, manualSchemaEnd).contains("inboundCode")); + assertFalse(java.util.Arrays.stream(ManualInboundPlanRequest.class.getRecordComponents()) + .anyMatch(component -> "inboundCode".equals(component.getName()))); assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/hold:")); assertFalse(api.contains("/api/platform/v1/last-leg/orders/{id}/detained:")); assertTrue(api.contains("/api/platform/v1/last-leg/orders/{id}/manual-nodes:")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java index a540f63ed..d33ad32c2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java @@ -101,7 +101,6 @@ class InboundPlanControllerTest { private static ManualInboundPlanRequest manualRequest() { return new ManualInboundPlanRequest("193000000000200003", 9223372036854775807L, 0, - 193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 8), - "FBA-001", 2, "STANDARD", false, true, true, "加急入仓"); + LocalDate.of(2026, 9, 8), 2, "STANDARD", false, true, true, "加急入仓"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java index b7718d8e8..e9b5e1e48 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java @@ -34,4 +34,17 @@ class InboundPlanOrderMapperContractTest { assertTrue(sql.contains("customer_id=#{customerId} AND id=#{orderId}")); assertTrue(!sql.contains("destination_warehouse")); } + + @Test + void manualPlanLocksEveryOrderInTheSelectedStableContainer() throws Exception { + Select select = InboundPlanOrderMapper.class + .getMethod("lockContainerWarehouseFacts", String.class, Long.class) + .getAnnotation(Select.class); + String sql = String.join(" ", select.value()); + + assertTrue(sql.contains("c.source_container_booking_id=target.source_container_booking_id")); + assertTrue(sql.contains("target.customer_id=#{customerId} AND target.id=#{orderId}")); + assertTrue(sql.contains("c.overseas_warehouse_id AS overseasWarehouseId")); + assertTrue(sql.contains("ORDER BY c.customer_id,c.id FOR UPDATE")); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index ce969a403..f12f3c52f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -78,6 +78,12 @@ class InboundPlanServiceTest { org.mockito.Mockito.lenient().when(orderMapper.findReceivingWarehouse(any(), any())).thenReturn( new InboundPlanOrderMapper.ReceivingWarehouseSnapshot( "193000000000200003", "US-LAX-01", "洛杉矶仓")); + org.mockito.Mockito.lenient().when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference( + 193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE")); + org.mockito.Mockito.lenient().when(orderMapper.lockContainerWarehouseFacts(any(), any())).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerWarehouseRef( + "100", 2L, "BOOKING-1", "193000000000200003"))); } @Test @@ -133,8 +139,7 @@ class InboundPlanServiceTest { when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any())) .thenReturn(1); ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0, - 193000000000200003L, "US-LAX-01", "洛杉矶仓", LocalDate.of(2026, 9, 15), - "RK-2", 2, "STANDARD", true, true, true, "优先收货"); + LocalDate.of(2026, 9, 15), 2, "STANDARD", true, true, true, "优先收货"); var result = service.convertManual(request, "manual-2", "900"); @@ -149,6 +154,46 @@ class InboundPlanServiceTest { verify(orderMapper).markPlannedManual(eq("100"), eq(2L), any(), eq(2), eq(true), eq(0), any()); verify(commandMapper).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class)); verify(orderMapper, never()).lockContainerOrders(any()); + verify(orderMapper).lockContainerWarehouseFacts("100", 2L); + } + + @Test + void manuallyCreatesPlanWhenStableWarehouseIdExistsWithoutDisplaySnapshots() { + SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 10, 0, + "193000000000200003", null, null, null, + "WH-A", "WH-A", "仓A", 1, 0, false, 0); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source)); + when(warehouseGateway.requireActiveSharedWarehouse(193000000000200003L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference( + 193000000000200003L, "US-LAX-01", "洛杉矶仓", "ACTIVE")); + when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(3), eq(false), eq(0), any())) + .thenReturn(1); + + var result = service.convertManual(new ManualInboundPlanRequest("100", 2L, 0, + LocalDate.of(2026, 9, 15), 3, "STANDARD", true, false, false, null), + "manual-id-only", "900"); + + assertEquals("US-LAX-01", result.overseasWarehouseCode()); + assertEquals("洛杉矶仓", result.overseasWarehouseName()); + verify(warehouseGateway).requireActiveSharedWarehouse(193000000000200003L); + verify(planMapper).insert(any(InboundPlan.class)); + } + + @Test + void manualPlanRejectsMixedContainerWarehousesBeforeLockingOrderOrWriting() { + when(orderMapper.lockContainerWarehouseFacts("100", 2L)).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerWarehouseRef("100", 2L, "BOOKING-1", "193000000000200003"), + new InboundPlanOrderMapper.ContainerWarehouseRef("200", 3L, "BOOKING-1", "193000000000200004"))); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( + new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15), + 1, "STANDARD", false, false, false, null), "mixed-warehouse", "900")); + + assertEquals(409, error.getStatusCode().value()); + verify(orderMapper, never()).lockOrder(any(), any()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class)); } @Test @@ -160,8 +205,8 @@ class InboundPlanServiceTest { when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held)); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( - new ManualInboundPlanRequest("100", 2L, 5, 193000000000200003L, "US-LAX-01", "洛杉矶仓", - LocalDate.of(2026, 9, 15), "RK-2", 1, "STANDARD", false, false, false, null), + new ManualInboundPlanRequest("100", 2L, 5, LocalDate.of(2026, 9, 15), + 1, "STANDARD", false, false, false, null), "manual-held", "900")); assertEquals(409, error.getStatusCode().value()); @@ -217,6 +262,8 @@ class InboundPlanServiceTest { when(planMapper.selectOne(any())).thenReturn(plan); when(orderMapper.findReceivingWarehouse("100", 1L)).thenReturn( new InboundPlanOrderMapper.ReceivingWarehouseSnapshot("88", "US-NY-01", "纽约仓")); + when(warehouseGateway.requireActiveSharedWarehouse(88L)).thenReturn( + new WarehouseResourceGateway.WarehouseReference(88L, "US-NY-01", "纽约仓", "ACTIVE")); var result = service.detailByPlanNo("100", "LIP-1"); @@ -440,6 +487,26 @@ class InboundPlanServiceTest { verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any()); } + @Test + void containerConversionRejectsMixedWarehouseSnapshotsBeforeCreatingAnyPlan() { + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD"), + new InboundPlanOrderMapper.ContainerOrderRef("200", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD"))); + when(orderMapper.lockOrder("100", 1L)).thenReturn(List.of(order("100", 1L, "RK-1", "WH-A"))); + SourceOrderSnapshot differentWarehouse = new SourceOrderSnapshot(2L, "200", "MSCU6639870", "RK-2", + "PENDING_ASSIGNMENT", null, BigDecimal.ONE, 10, 0, + "193000000000200004", null, null, null, "WH-B", "WH-B", "仓B", 1, 0, false, 0); + when(orderMapper.lockOrder("200", 2L)).thenReturn(List.of(differentWarehouse)); + + ResponseStatusException error = assertThrows(ResponseStatusException.class, + () -> service.convertContainer("BOOKING-1")); + + assertEquals(409, error.getStatusCode().value()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(orderMapper, never()).markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any()); + verify(inventoryBalanceMapper, never()).bindInboundPlan(any(), any(), any(), any(), any()); + } + @Test void completeContainerReplayReturnsStableExistingPlansWithoutWriting() { var refs = List.of(