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 6eff4b2a3..7ad10ccf2 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 @@ -25,7 +25,8 @@ public interface InboundPlanOrderMapper { java.math.BigDecimal volumeCbm, String overseasWarehouseId, String overseasWarehouseCode, String overseasWarehouseName, String fbaWarehouseCode, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, int destinationCount, - boolean detained, int version, String planNo, String planStatus, Integer plannedPalletCount + boolean detained, int version, String planNo, String planStatus, + Integer plannedPalletCount, Integer materializedPalletCount ) { } @Select("SELECT o.customer_id AS customerId,o.id AS orderId,o.order_no AS orderNo," + @@ -40,7 +41,10 @@ public interface InboundPlanOrderMapper { "COALESCE(NULLIF(o.transfer_warehouse_name,''),(SELECT MIN(NULLIF(d.destination_warehouse_name,'')) FROM ll_order_detail d WHERE d.order_id=o.id)) AS destinationWarehouseName," + "CASE WHEN COALESCE(NULLIF(o.transfer_warehouse_id,''),NULLIF(o.transfer_warehouse_code,'')) IS NOT NULL THEN 1 ELSE " + "(SELECT COUNT(DISTINCT COALESCE(NULLIF(d.destination_warehouse_id,''),NULLIF(d.destination_warehouse_code,''),'#EMPTY#')) FROM ll_order_detail d WHERE d.order_id=o.id) END AS destinationCount," + - "o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus,p.pallet_count AS plannedPalletCount " + + "o.detained AS detained,o.version AS version,p.plan_no AS planNo,p.status AS planStatus," + + "p.pallet_count AS plannedPalletCount," + + "(SELECT COUNT(*) FROM ll_inbound_plan_pallet pp WHERE pp.customer_id=p.customer_id " + + "AND pp.plan_id=p.id AND pp.warehouse_pallet_id IS NOT NULL) AS materializedPalletCount " + "FROM ll_order o LEFT JOIN ll_inbound_plan p ON p.customer_id=o.customer_id AND p.source_order_id=o.id " + "WHERE o.source_container_booking_id=#{sourceContainerBookingId} ORDER BY o.customer_id,o.id") List containerPreviewOrders( 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 5eff2d693..ca824ad84 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 @@ -30,6 +30,10 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; @Service @@ -116,7 +120,7 @@ public class ContainerInboundPlanConversionCoordinator { 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.businessKey(), item.planNo(), item.globalSequenceNo(), "WOOD", item.fbaWarehouseCode(), item.palletCode())).toList()); WarehouseResourceGateway.PalletBatchResponse reservation; try { @@ -201,21 +205,76 @@ public class ContainerInboundPlanConversionCoordinator { catch (RuntimeException exception) { throw new ResponseStatusException(HttpStatus.CONFLICT, "柜级接柜仓 ID 无效", exception); } - List items = new ArrayList<>(); - int globalSequence = 0; - for (ContainerInboundPlanPreviewResponse.Order order : preview.groups().stream() - .flatMap(group -> group.orders().stream()).filter(item -> "READY".equals(item.readiness())).toList()) { - long planId = IdWorker.getId(); - String planNo = numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId); + List preparedOrders = new ArrayList<>(); + Map prefixByFba = new LinkedHashMap<>(); + Map nextIndexByFba = new LinkedHashMap<>(); + Set usedPalletCodes = new HashSet<>(); + List convertibleOrders = preview.groups().stream() + .flatMap(group -> group.orders().stream()) + .filter(item -> "READY".equals(item.readiness()) || "RESOURCE_PENDING".equals(item.readiness())) + .toList(); + for (ContainerInboundPlanPreviewResponse.Order order : convertibleOrders) { List sourcePalletNos = plans.sourcePalletNos( order.customerId(), Long.parseLong(order.orderId()), order.orderType()); + int targetPalletCount = "RESOURCE_PENDING".equals(order.readiness()) + ? Objects.requireNonNullElse(order.plannedPalletCount(), 0) + : Objects.requireNonNullElse(order.suggestedPalletCount(), 0); + if (sourcePalletNos.size() > targetPalletCount) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "计划托数不能少于已生成的头程托盘码数量"); + } + String fbaCode = trimToNull(order.fbaWarehouseCode()); + if (fbaCode != null) { + for (String palletCode : sourcePalletNos) { + PalletCodeParts parts = palletCodeParts(preview.containerNo(), palletCode); + String previousPrefix = prefixByFba.putIfAbsent(fbaCode, parts.prefix()); + if (previousPrefix != null && !previousPrefix.equals(parts.prefix())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "同一 FBA 仓的头程托盘码内部编码不一致"); + } + nextIndexByFba.merge(fbaCode, parts.index(), Math::max); + if (!usedPalletCodes.add(palletCode)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码存在重复"); + } + } + if (sourcePalletNos.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "散货订单缺少头程托盘码快照"); + } + } + InboundPlanService.PlanIdentity existing = "RESOURCE_PENDING".equals(order.readiness()) + ? plans.existingPlanIdentity(order.customerId(), Long.parseLong(order.orderId())) : null; + long planId = existing == null ? IdWorker.getId() : existing.planId(); + String planNo = existing == null + ? numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId) : existing.planNo(); + if (existing != null && existing.palletCount() != targetPalletCount) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级预览已不一致"); + } + preparedOrders.add(new PreparedOrder(order, planId, planNo, targetPalletCount, + List.copyOf(sourcePalletNos))); + } + + List items = new ArrayList<>(); + int globalSequence = 0; + for (PreparedOrder preparedOrder : preparedOrders) { + ContainerInboundPlanPreviewResponse.Order order = preparedOrder.order(); + List sourcePalletNos = new ArrayList<>(preparedOrder.sourcePalletNos()); boolean warehouseGeneratedPallets = trimToNull(order.fbaWarehouseCode()) == null && !"ONE_CLICK_DROPSHIP".equals(order.orderType()); - if (!warehouseGeneratedPallets && sourcePalletNos.size() != order.suggestedPalletCount()) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "头程托盘码数量与柜级预览不一致"); + if (!warehouseGeneratedPallets && !"ONE_CLICK_DROPSHIP".equals(order.orderType())) { + String fbaCode = order.fbaWarehouseCode().trim(); + String prefix = prefixByFba.get(fbaCode); + while (sourcePalletNos.size() < preparedOrder.targetPalletCount()) { + int next = nextIndexByFba.compute(fbaCode, (ignored, current) -> current == null ? 1 : current + 1); + String palletCode = prefix + "-" + String.format("%03d", next); + if (!usedPalletCodes.add(palletCode)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "补生成托盘码发生重复"); + } + sourcePalletNos.add(palletCode); + } + } else if (!warehouseGeneratedPallets && sourcePalletNos.size() != preparedOrder.targetPalletCount()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "一件代发托盘码数量与柜级预览不一致"); } - for (int localSequence = 1; localSequence <= order.suggestedPalletCount(); localSequence++) { - items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), planId, planNo, + for (int localSequence = 1; localSequence <= preparedOrder.targetPalletCount(); localSequence++) { + items.add(new PreparedItem(order.customerId(), Long.parseLong(order.orderId()), preparedOrder.planId(), + preparedOrder.planNo(), localSequence, ++globalSequence, "CIP:" + generationId + ":" + globalSequence, trimToNull(order.fbaWarehouseCode()), warehouseGeneratedPallets ? null : sourcePalletNos.get(localSequence - 1))); @@ -379,7 +438,26 @@ public class ContainerInboundPlanConversionCoordinator { } public record Preparation(long warehouseId, List items) { } + private record PreparedOrder(ContainerInboundPlanPreviewResponse.Order order, long planId, String planNo, + int targetPalletCount, List sourcePalletNos) { } public record PreparedItem(String customerId, long orderId, long planId, String planNo, int localSequenceNo, int globalSequenceNo, String businessKey, String fbaWarehouseCode, String palletCode) { } + + private static PalletCodeParts palletCodeParts(String containerNo, String palletCode) { + String stableContainerNo = trimToNull(containerNo); + String stablePalletCode = trimToNull(palletCode); + if (stableContainerNo == null || stablePalletCode == null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "集装箱号或头程托盘码缺失"); + } + Matcher matcher = Pattern.compile("^(" + Pattern.quote(stableContainerNo) + "-.+)-(\\d{3})$") + .matcher(stablePalletCode); + if (!matcher.matches()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "头程托盘码不符合‘集装箱号-FBA内部编码-下标’规则"); + } + return new PalletCodeParts(matcher.group(1), Integer.parseInt(matcher.group(2))); + } + + private record PalletCodeParts(String prefix, int index) { } } 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 761e892bb..09b5dad93 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 @@ -121,6 +121,14 @@ public class InboundPlanService { WarehouseResourceGateway.WarehouseReference receivingWarehouse = requiredReceivingWarehouse(containerWarehouseId); validateOrders(snapshots); + int minimumPalletCount = suggestedPalletCount(order); + int sourcePalletCount = Objects.requireNonNullElse( + sourcePalletMapper.palletNos(customerId, request.orderId()), List.of()).size(); + minimumPalletCount = Math.max(minimumPalletCount, sourcePalletCount); + if (request.palletCount() < minimumPalletCount) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "人工计划托数不能少于柜级预览建议托数 " + minimumPalletCount); + } InboundPlanResponse response = createPlan(customerId, request.orderId(), snapshots, List.of(), null, request, receivingWarehouse); LastlegOrderCommandRecord command = new LastlegOrderCommandRecord(); @@ -186,9 +194,9 @@ public class InboundPlanService { ContainerInboundPlanPreviewResponse.Order first = items.get(0); return new ContainerInboundPlanPreviewResponse.Group(first.destinationType(), first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(), - items.size(), (int) items.stream().filter(item -> "READY".equals(item.readiness())).count(), - items.stream().filter(item -> "READY".equals(item.readiness())) - .map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount) + items.size(), (int) items.stream().filter(InboundPlanService::requiresContainerConversion).count(), + items.stream().filter(InboundPlanService::requiresContainerConversion) + .map(InboundPlanService::pendingPalletCount) .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), items.stream().filter(item -> "PLANNED".equals(item.readiness())) .map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) @@ -199,9 +207,9 @@ public class InboundPlanService { .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(), List.copyOf(items)); }).toList(); int blocked = (int) orders.stream().filter(item -> "BLOCKED".equals(item.readiness())).count(); - int pendingOrders = (int) orders.stream().filter(item -> "READY".equals(item.readiness())).count(); - int pendingPallets = orders.stream().filter(item -> "READY".equals(item.readiness())) - .map(ContainerInboundPlanPreviewResponse.Order::suggestedPalletCount) + int pendingOrders = (int) orders.stream().filter(InboundPlanService::requiresContainerConversion).count(); + int pendingPallets = orders.stream().filter(InboundPlanService::requiresContainerConversion) + .map(InboundPlanService::pendingPalletCount) .filter(Objects::nonNull).mapToInt(Integer::intValue).sum(); int existingPallets = orders.stream().filter(item -> "PLANNED".equals(item.readiness())) .map(ContainerInboundPlanPreviewResponse.Order::plannedPalletCount) @@ -246,7 +254,19 @@ public class InboundPlanService { String fbaWarehouseCode = isFbaDestination(row) ? trimToNull(row.fbaWarehouseCode()) : null; String destinationCode = fbaWarehouseCode != null ? row.fbaWarehouseCode().trim() : row.destinationWarehouseCode(); - String readiness = planned ? "PLANNED" : blockers.isEmpty() ? "READY" : "BLOCKED"; + boolean materialized = planned && row.plannedPalletCount() != null + && row.materializedPalletCount() != null + && row.materializedPalletCount() >= row.plannedPalletCount(); + if (planned && !materialized && row.materializedPalletCount() != null + && row.materializedPalletCount() > 0) { + blockers.add("PLAN_PALLET_BINDING_INCOMPLETE"); + } + if (planned && !materialized && !"PENDING_ASSIGNMENT".equals(row.planStatus())) { + blockers.add("PLAN_ALREADY_STARTED"); + } + String readiness = planned ? materialized ? "PLANNED" + : blockers.isEmpty() ? "RESOURCE_PENDING" : "BLOCKED" + : blockers.isEmpty() ? "READY" : "BLOCKED"; return new ContainerInboundPlanPreviewResponse.Order(row.customerId(), row.customerName(), String.valueOf(row.orderId()), trimToNull(row.orderNo()) == null ? row.sourceOrderNo() : row.orderNo(), row.inboundCode(), row.orderType(), destinationType, row.destinationWarehouseId(), @@ -276,6 +296,26 @@ public class InboundPlanService { } } + private int suggestedPalletCount(SourceOrderSnapshot order) { + try { + return PalletAllocator.allocate(List.of(new PalletAllocator.LineInput(order.orderId(), + warehouseKey(order), volume(order), order.packageCount(), order.recommendedPalletCount()))) + .pallets().size(); + } catch (IllegalArgumentException exception) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "订单货量不能计算预览建议托数", exception); + } + } + + private static boolean requiresContainerConversion(ContainerInboundPlanPreviewResponse.Order order) { + return "READY".equals(order.readiness()) || "RESOURCE_PENDING".equals(order.readiness()); + } + + private static Integer pendingPalletCount(ContainerInboundPlanPreviewResponse.Order order) { + return "RESOURCE_PENDING".equals(order.readiness()) + ? Objects.requireNonNullElse(order.plannedPalletCount(), 0) + : Objects.requireNonNullElse(order.suggestedPalletCount(), 0); + } + private static String destinationType(InboundPlanOrderMapper.ContainerPreviewOrderRow row) { if ("ONE_CLICK_DROPSHIP".equals(row.orderType())) return "ONE_CLICK_DROPSHIP"; if (isFbaDestination(row)) return "FBA"; @@ -552,9 +592,22 @@ public class InboundPlanService { Map reserved = prepared == null ? Map.of() : prepared.pallets().stream().collect(Collectors.toMap(PreparedPallet::sequenceNo, PreparedPallet::reservation)); - created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), standardPalletNos.get(pendingIndex), + List effectivePalletNos = standardPalletNos.get(pendingIndex); + if (prepared != null && !effectivePalletNos.isEmpty()) { + effectivePalletNos = prepared.pallets().stream() + .sorted(Comparator.comparingInt(PreparedPallet::sequenceNo)) + .map(value -> value.reservation().palletCode()).toList(); + } + created.put(refIndex, createPlan(ref.customerId(), ref.orderId(), snapshots.get(pendingIndex), effectivePalletNos, wholePallets.get(pendingIndex), null, null, prepared, reserved)); } + for (int index = 0; index < refs.size(); index++) { + InboundPlan existing = existingPlans.get(index); + PreparedPlan prepared = preparedPlans.get(refs.get(index).orderId()); + if (existing != null && prepared != null) { + materializeExistingPlan(existing, refs.get(index), prepared); + } + } List result = new ArrayList<>(refs.size()); for (int index = 0; index < refs.size(); index++) { InboundPlan existing = existingPlans.get(index); @@ -568,6 +621,47 @@ public class InboundPlanService { public record PreparedPallet(int sequenceNo, String businessKey, WarehouseResourceGateway.PalletBatchItem reservation) { } + public PlanIdentity existingPlanIdentity(String customerId, long orderId) { + InboundPlan plan = planMapper.findBySourceOrder(customerId, orderId); + if (plan == null) return null; + return new PlanIdentity(plan.getId(), plan.getPlanNo(), Objects.requireNonNullElse(plan.getPalletCount(), 0)); + } + + public record PlanIdentity(long planId, String planNo, int palletCount) { } + + private void materializeExistingPlan(InboundPlan plan, InboundPlanOrderMapper.ContainerOrderRef ref, + PreparedPlan prepared) { + if (!Objects.equals(plan.getId(), prepared.planId()) || !Objects.equals(plan.getPlanNo(), prepared.planNo()) + || !Objects.equals(plan.getCustomerId(), prepared.customerId()) + || !Objects.equals(ref.orderId(), prepared.orderId())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划与柜级托盘预留快照不一致"); + } + if (!"PENDING_ASSIGNMENT".equals(plan.getStatus())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "仅未开始作业的已有计划可由整柜生成补齐托盘"); + } + List pallets = palletMapper.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPallet::getCustomerId, plan.getCustomerId()) + .eq(InboundPlanPallet::getPlanId, plan.getId()) + .orderByAsc(InboundPlanPallet::getSequenceNo).last("FOR UPDATE")); + if (pallets.size() != prepared.pallets().size() || pallets.size() != plan.getPalletCount()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级托盘预留数量不一致"); + } + Map preparedBySequence = prepared.pallets().stream().collect(Collectors.toMap( + PreparedPallet::sequenceNo, value -> value)); + for (InboundPlanPallet pallet : pallets) { + if (pallet.getWarehousePalletId() != null) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划已存在真实托盘,不能重复补齐"); + } + PreparedPallet value = preparedBySequence.get(pallet.getSequenceNo()); + if (value == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托盘序号不完整"); + pallet.setSourcePalletNo(value.reservation().palletCode()); + applyReservedPallet(pallet, value.reservation()); + palletMapper.updateById(pallet); + } + plan.setUpdatedAt(LocalDateTime.now()); + planMapper.updateById(plan); + } + private WholePalletInput wholePallet(DropshipPalletSnapshot pallet) { return new WholePalletInput(pallet.sourcePalletNo(), Math.toIntExact(pallet.packageCount())); } 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 2dea90075..b2621bbd9 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 @@ -290,6 +290,72 @@ class ContainerInboundPlanConversionCoordinatorTest { .map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList()); } + @Test + void standardFbaPreviewMaySupplementButNeverReduceHeadLegPalletCodes() { + ConvertContainerInboundPlanRequest request = request(); + var order = new ContainerInboundPlanPreviewResponse.Order( + "100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA", + null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3, + null, null, null, 0, "READY", List.of()); + var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01", + "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 0, 3, 0, true, + List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2", + 1, 1, 3, 0, 3, 0, List.of(order)))); + when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); + when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2"); + when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of( + "MSCU6639870-101-001", "MSCU6639870-101-002")); + + ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand", + 1L, 2L, BOOKING_ID, "FIRST-KEY", request, + ReflectionTestUtils.invokeMethod(coordinator, "hash", + List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash()))); + ContainerInboundPlanConversionCoordinator.Preparation preparation; + try { + preparation = objectMapper.readValue(command.getPreparationJson(), + ContainerInboundPlanConversionCoordinator.Preparation.class); + } catch (Exception exception) { + throw new AssertionError(exception); + } + + assertEquals(List.of("MSCU6639870-101-001", "MSCU6639870-101-002", "MSCU6639870-101-003"), + preparation.items().stream().map(ContainerInboundPlanConversionCoordinator.PreparedItem::palletCode).toList()); + } + + @Test + void existingManualPlanIsPreparedForWarehouseMaterialization() { + ConvertContainerInboundPlanRequest request = request(); + var order = new ContainerInboundPlanPreviewResponse.Order( + "100", "Customer 100", "11", "LM-11", "RK-FBA", "FIRST_LEG", "FBA", + null, "ACY2", "Amazon ACY2", "ACY2", 20, new BigDecimal("5.8000"), 3, + 3, "IP-MANUAL", "PENDING_ASSIGNMENT", 1, "RESOURCE_PENDING", List.of()); + var preview = new ContainerInboundPlanPreviewResponse(BOOKING_ID, "MSCU6639870", "31", "US-LAX-01", + "Los Angeles", 7L, SNAPSHOT_HASH, 1, 1, 0, 1, 3, 3, 3, 3, true, + List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2", + 1, 1, 3, 3, 3, 3, List.of(order)))); + when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); + when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of( + "MSCU6639870-101-001", "MSCU6639870-101-002")); + when(plans.existingPlanIdentity("100", 11L)) + .thenReturn(new InboundPlanService.PlanIdentity(21L, "IP-MANUAL", 3)); + + ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand", + 1L, 2L, BOOKING_ID, "FIRST-KEY", request, + ReflectionTestUtils.invokeMethod(coordinator, "hash", + List.of(BOOKING_ID, request.snapshotVersion(), request.snapshotHash()))); + ContainerInboundPlanConversionCoordinator.Preparation preparation; + try { + preparation = objectMapper.readValue(command.getPreparationJson(), + ContainerInboundPlanConversionCoordinator.Preparation.class); + } catch (Exception exception) { + throw new AssertionError(exception); + } + + assertEquals(21L, preparation.items().get(0).planId()); + assertEquals("IP-MANUAL", preparation.items().get(0).planNo()); + verify(numbers, never()).next(any(String.class), any(String.class), any(Long.class)); + } + private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId, String inboundCode, String destinationCode, String fbaWarehouseCode) { 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 77dfac1c4..f8f54c7ec 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 @@ -74,7 +74,7 @@ class ContainerInboundPlanPreviewTest { 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))); + null, null, null, null))); var result = service.previewContainer("BOOKING-NULL"); @@ -113,6 +113,6 @@ class ContainerInboundPlanPreviewTest { "FBA".equals(destinationType) || destinationCode == null ? null : "WH-" + id, "FBA".equals(destinationType) ? null : destinationCode, destinationName, "FBA".equals(destinationType) || destinationCode == null ? 0 : 1, false, version, planNo, - planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets); + planNo == null ? null : "PENDING_ASSIGNMENT", plannedPallets, plannedPallets); } } 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 b6b616fe4..72441b51d 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 @@ -164,6 +164,53 @@ class InboundPlanServiceTest { verify(orderMapper).lockContainerWarehouseFacts("100", 2L); } + @Test + void manualPlanMayExceedButCannotUndercutPreviewPalletCount() { + SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", + "PENDING_ASSIGNMENT", null, new BigDecimal("5.8000"), 58, 2, + "193000000000200003", "US-LAX-01", "洛杉矶仓", "ACY2", + null, null, "Amazon ACY2", 0, 0, false, 0); + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(source)); + ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual( + new ManualInboundPlanRequest("100", 2L, 0, LocalDate.of(2026, 9, 15), + 2, "WOOD", false, false, false, null), "manual-below-preview", "900")); + + assertEquals(400, error.getStatusCode().value()); + verify(planMapper, never()).insert(any(InboundPlan.class)); + verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class)); + } + + @Test + void containerConversionBindsRealWarehousePalletsBackToExistingManualPlan() { + InboundPlan plan = existingPlan("100", 2L, "IP-MANUAL"); + plan.setCreationMode("MANUAL"); + plan.setPalletCount(2); + InboundPlanPallet first = new InboundPlanPallet(); + first.setId(301L); first.setCustomerId("100"); first.setPlanId(plan.getId()); first.setSequenceNo(1); + InboundPlanPallet second = new InboundPlanPallet(); + second.setId(302L); second.setCustomerId("100"); second.setPlanId(plan.getId()); second.setSequenceNo(2); + when(palletMapper.selectList(any())).thenReturn(List.of(first, second)); + var firstReservation = new WarehouseResourceGateway.PalletBatchItem(100L, "CIP:1:1", "IP-MANUAL", + 1, 401L, "MSCU6639870-101-001", 501L, 601L, "RESERVED"); + var secondReservation = new WarehouseResourceGateway.PalletBatchItem(100L, "CIP:1:2", "IP-MANUAL", + 2, 402L, "MSCU6639870-101-002", 502L, 602L, "RESERVED"); + var prepared = new InboundPlanService.PreparedPlan(plan.getId(), plan.getPlanNo(), "100", 2L, List.of( + new InboundPlanService.PreparedPallet(1, "CIP:1:1", firstReservation), + new InboundPlanService.PreparedPallet(2, "CIP:1:2", secondReservation))); + + ReflectionTestUtils.invokeMethod(service, "materializeExistingPlan", plan, + new InboundPlanOrderMapper.ContainerOrderRef("100", 2L, "INBOUND_PLANNED", "MSCU6639870", "FIRST_LEG"), + prepared); + + assertEquals("MSCU6639870-101-001", first.getPalletNo()); + assertEquals("MSCU6639870-101-001", first.getSourcePalletNo()); + assertEquals(401L, first.getWarehousePalletId()); + assertEquals("RESERVED", first.getAssignmentStatus()); + assertEquals(402L, second.getWarehousePalletId()); + verify(palletMapper, times(2)).updateById(any(InboundPlanPallet.class)); + verify(planMapper).updateById(plan); + } + @Test void manuallyCreatesPlanWhenStableWarehouseIdExistsWithoutDisplaySnapshots() { SourceOrderSnapshot source = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2", diff --git a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java index 407b7ecf1..0f6c9b495 100644 --- a/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java +++ b/warehouse-overseas-service/src/main/java/com/baiyue/warehouse/service/WarehousePalletBatchService.java @@ -38,6 +38,9 @@ import java.util.Set; @Service public class WarehousePalletBatchService { private static final String BUSINESS_TYPE = "INBOUND_PLAN_AUTO"; + private static final BigDecimal AUTO_PALLET_CAPACITY_CBM = new BigDecimal("2.0000"); + private static final BigDecimal AUTO_PALLET_UNIT_PRICE_USD = new BigDecimal("5.00"); + private static final String AUTO_PALLET_REMARK = "自动生成"; @Resource private WarehousePalletBatchMapper batches; @Resource private WarehousePalletBatchItemMapper items; @@ -80,8 +83,8 @@ public class WarehousePalletBatchService { List created = new ArrayList<>(); for (WarehousePalletBatchRequests.Item requested : normalized.items()) { Long palletId = resources.createPallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, - normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), requested.palletType(), null, - BigDecimal.ZERO, "IDLE", null, "Auto-created for inbound plan " + requested.inboundPlanNo(), + normalized.warehouseId(), new PalletRequests.SaveRequest(requested.palletCode(), "WOOD", + AUTO_PALLET_CAPACITY_CBM, AUTO_PALLET_UNIT_PRICE_USD, "IDLE", null, AUTO_PALLET_REMARK, requested.fbaWarehouseCode())); WarehousePalletEntity pallet = resources.pallet(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE, normalized.warehouseId(), palletId); diff --git a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java index c1baf254d..ab3f2b447 100644 --- a/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java +++ b/warehouse-overseas-service/src/test/java/com/baiyue/warehouse/service/WarehousePalletBatchServiceTest.java @@ -105,6 +105,10 @@ class WarehousePalletBatchServiceTest { verify(resources).createPallet(org.mockito.ArgumentMatchers.eq(WarehouseResourceService.PUBLIC_RESOURCE_SCOPE), org.mockito.ArgumentMatchers.eq(WAREHOUSE), saves.capture()); assertThat(saves.getValue().palletCode()).isNull(); + assertThat(saves.getValue().palletType()).isEqualTo("WOOD"); + assertThat(saves.getValue().capacityCbm()).isEqualByComparingTo("2.0000"); + assertThat(saves.getValue().unitPrice()).isEqualByComparingTo("5.00"); + assertThat(saves.getValue().remark()).isEqualTo("自动生成"); assertThat(response.items()).extracting(value -> value.palletCode()).containsExactly("PAL-301"); }