diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index b626a54b2..3a3c83bef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -61,7 +61,7 @@ public class InboundPlanController { @PostMapping("/convert-container") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_CREATE + "')") - @Operation(summary = "按整柜批量生成入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;每个入仓码独立生成计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) + @Operation(summary = "按整柜批量生成剩余入库计划", description = "平台按 sourceContainerBookingId 锁定整柜订单;保留已有有效计划,只为剩余入仓码分别生成独立计划,整批原子且可幂等重放。Required authority: " + OverseasPermissions.INBOUND_ORDER_CREATE) public ApiResponse> convertContainer( @AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ConvertContainerInboundPlanRequest request) { 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 bce2f9900..14ccb5533 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 @@ -285,26 +285,24 @@ public class InboundPlanService { List existingPlans = refs.stream() .map(ref -> planMapper.findBySourceOrder(ref.customerId(), ref.orderId())) .toList(); - long existingCount = existingPlans.stream().filter(Objects::nonNull).count(); - if (existingCount == refs.size()) { - List replay = new ArrayList<>(refs.size()); - for (int index = 0; index < refs.size(); index++) { - requirePlanContainer(existingPlans.get(index), containerNo); - replay.add(detail(refs.get(index).customerId(), existingPlans.get(index))); + List warehouseIds = new ArrayList<>(refs.size()); + List pendingIndexes = new ArrayList<>(); + List> snapshots = new ArrayList<>(); + List> wholePallets = new ArrayList<>(); + for (int index = 0; index < refs.size(); index++) { + InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(index); + InboundPlan existing = existingPlans.get(index); + if (existing != null) { + requireValidExistingPlan(existing, containerNo); + InboundPlanOrderMapper.ReceivingWarehouseSnapshot warehouse = + orderMapper.findReceivingWarehouse(ref.customerId(), ref.orderId()); + warehouseIds.add(warehouse == null ? null : warehouse.overseasWarehouseId()); + continue; + } + if (!("PENDING_ASSIGNMENT".equals(ref.currentStatus()) + || "DESTINATION_ASSIGNED".equals(ref.currentStatus()))) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单"); } - return List.copyOf(replay); - } - if (existingCount != 0) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜仅部分尾程订单已有入库计划"); - } - if (refs.stream().anyMatch(ref -> !("PENDING_ASSIGNMENT".equals(ref.currentStatus()) - || "DESTINATION_ASSIGNED".equals(ref.currentStatus())))) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在不能转换的尾程订单"); - } - - List> snapshots = new ArrayList<>(refs.size()); - List> wholePallets = new ArrayList<>(refs.size()); - for (InboundPlanOrderMapper.ContainerOrderRef ref : refs) { List orders = orderMapper.lockOrder(ref.customerId(), ref.orderId()); if (orders == null || orders.isEmpty()) { throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜订单在锁定后不存在"); @@ -314,7 +312,9 @@ public class InboundPlanService { if (!Objects.equals(containerNo, currentContainerNo)) { throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜内尾程订单柜号不一致"); } + pendingIndexes.add(index); snapshots.add(orders); + warehouseIds.addAll(orders.stream().map(SourceOrderSnapshot::overseasWarehouseId).toList()); List sourcePallets = "ONE_CLICK_DROPSHIP".equals(ref.orderType()) ? dropshipOrders.palletSnapshots(ref.customerId(), ref.orderId()).stream() .map(this::wholePallet).toList() @@ -328,16 +328,21 @@ public class InboundPlanService { } wholePallets.add(sourcePallets); } - requireSingleWarehouseId(snapshots.stream().flatMap(List::stream) - .map(SourceOrderSnapshot::overseasWarehouseId).toList()); + requireSingleWarehouseId(warehouseIds); - 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, null)); + Map created = new HashMap<>(); + for (int pendingIndex = 0; pendingIndex < pendingIndexes.size(); pendingIndex++) { + int refIndex = pendingIndexes.get(pendingIndex); + InboundPlanOrderMapper.ContainerOrderRef ref = refs.get(refIndex); + created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), List.of(), + wholePallets.get(pendingIndex), null, null)); } - return List.copyOf(created); + List result = new ArrayList<>(refs.size()); + for (int index = 0; index < refs.size(); index++) { + InboundPlan existing = existingPlans.get(index); + result.add(existing == null ? created.get(index) : detail(refs.get(index).customerId(), existing)); + } + return List.copyOf(result); } private WholePalletInput wholePallet(DropshipPalletSnapshot pallet) { @@ -397,6 +402,13 @@ public class InboundPlanService { } } + private void requireValidExistingPlan(InboundPlan plan, String containerNo) { + requirePlanContainer(plan, containerNo); + if (trimToNull(plan.getPlanNo()) == null || trimToNull(plan.getStatus()) == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜存在无效入库计划,不能继续按柜生成"); + } + } + private void validateWholePallets(List orders, List pallets) { if (orders.size() != 1) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, 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 887c14925..bc6f11000 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -420,8 +420,8 @@ paths: '409': { description: 订单版本、状态、暂扣状态、重复计划或幂等键冲突 } /api/platform/v1/last-leg/inbound-plans/convert-container: post: - summary: 按整柜批量生成入库计划 - description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单,并要求全柜尾程订单及既有计划的真实 containerNo 快照一致。每个尾程订单/入仓码独立生成一个入库计划,整批原子且幂等;任何柜号漂移均冲突且不部分写入。 + summary: 按整柜批量生成剩余入库计划 + description: 权限 platform:overseas-warehouse:inbound-order:create;使用稳定 sourceContainerBookingId 锁定整柜订单,并要求全柜尾程订单及既有有效计划的真实 containerNo 与接柜仓快照一致。已有有效计划保持不变,只为剩余尾程订单/入仓码分别生成独立计划;响应按柜内订单稳定顺序返回全部已有及新增计划。整批原子且幂等,剩余为0时直接返回已有结果;任何快照漂移均冲突且不部分写入。 security: [{ bearerAuth: [] }] requestBody: required: true 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 f12f3c52f..f7ed9407a 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 @@ -524,16 +524,50 @@ class InboundPlanServiceTest { } @Test - void partialExistingPlansAreRejectedBeforeAnyWrite() { + void partialExistingPlansArePreservedWhileOnlyRemainingOrdersAreCreated() { when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED", "MSCU6639870", "STANDARD"), new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD"))); when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(order("100", 2L, "RK-2", "WH-B"))); + when(orderMapper.markPlanned(eq("100"), eq(2L), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); - assertThrows(ResponseStatusException.class, () -> service.convertContainer("BOOKING-1")); + var result = service.convertContainer("BOOKING-1"); - verify(orderMapper, never()).lockOrder(any(), any()); + assertEquals(2, result.size()); + assertEquals("LIP-1", result.get(0).planNo()); + assertEquals(2L, result.get(1).sourceOrderId()); + verify(orderMapper, never()).lockOrder("100", 1L); + verify(orderMapper).lockOrder("100", 2L); + verify(planMapper).insert(any(InboundPlan.class)); + verify(orderMapper, never()).markPlanned(eq("100"), eq(1L), any(), + org.mockito.ArgumentMatchers.anyInt(), any()); + verify(orderMapper).markPlanned(eq("100"), eq(2L), any(), + org.mockito.ArgumentMatchers.anyInt(), any()); + } + + @Test + void partialExistingPlansWithMixedWarehouseFactsCauseZeroNewWrites() { + when(orderMapper.lockContainerOrders("BOOKING-1")).thenReturn(List.of( + new InboundPlanOrderMapper.ContainerOrderRef("100", 1L, "INBOUND_PLANNED", "MSCU6639870", "STANDARD"), + new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "PENDING_ASSIGNMENT", "MSCU6639870", "STANDARD"))); + when(planMapper.findBySourceOrder("100", 1L)).thenReturn(existingPlan("100", 1L, "LIP-1")); + when(orderMapper.findReceivingWarehouse("100", 1L)).thenReturn( + new InboundPlanOrderMapper.ReceivingWarehouseSnapshot( + "193000000000200003", "US-LAX-01", "洛杉矶仓")); + SourceOrderSnapshot differentWarehouse = new SourceOrderSnapshot(2L, "100", "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("100", 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