From 795e852944160faaa6f089bbc92031fa83233a3f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 26 Aug 2026 17:09:02 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReceivingContainerController.java | 8 - .../pda/dto/ReceivingContainerRequests.java | 4 +- .../pda/dto/ReceivingContainerResponses.java | 9 +- .../service/ReceivingContainerService.java | 204 ++++++++++-------- .../openapi/overseas-business-v1.yaml | 31 +-- .../pda/ReceivingContainerContractTest.java | 10 +- ...ReceivingContainerServiceBehaviorTest.java | 63 +++++- scripts/admin-test-env-acceptance.mjs | 12 +- .../manual-pda-ready-fixture.mjs | 2 +- .../manual-state-loading-pda.mjs | 2 +- ...nt-qualification-fixture-contract.test.mjs | 3 + .../ocean-booking-fixture-contract.test.mjs | 1 + .../ocean-booking-fixture.mjs | 1 + .../phase3-container-loading.mjs | 2 +- .../admin-test-env-modules/phase3-loading.mjs | 2 +- .../批量待分柜数据生成指南.md | 2 +- 16 files changed, 201 insertions(+), 155 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index d614409c4..f5aa7e7d8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -112,14 +112,6 @@ public class ReceivingContainerController { return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request)); } - @PostMapping("/current/skus/resolve") - public ApiResponse resolveSku( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody ReceivingContainerRequests.SkuResolve request) { - PdaWarehouseIdentity identity = currentIdentity(user); - return ApiResponse.success(service.resolveSku(identity.operatorKey(), request)); - } - @PostMapping("/current/pallets/resolve") public ApiResponse resolvePallet( @AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java index 1b02cf639..6d06a96c9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java @@ -18,10 +18,8 @@ public final class ReceivingContainerRequests { public record SignedPhoto(@NotNull ReceivingPhotoRole role, @NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { } public record CartonResolve(@NotBlank String cartonMark) { } - public record SkuResolve(@NotBlank @Size(max = 128) String skuCode) { } public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { } - public record BulkReceive(@NotBlank String inboundCode, @NotBlank @Size(max = 128) String skuCode, - @NotBlank String palletCode, + public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode, @Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { } public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, @NotBlank @Size(max = 255) String idempotencyKey) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index 23a3be0bd..6afe67cba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -28,20 +28,13 @@ public final class ReceivingContainerResponses { String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } - public record CartonResolved(String cartonMark, String skuCode, String inboundCode, + public record CartonResolved(String cartonMark, String inboundCode, String destinationType, String overseasWarehouseId, String overseasWarehouseName, String destinationWarehouseId, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, int plannedPalletCount, int totalPackages, int receivedPackages, int pendingPackages) { } - public record SkuResolved(String skuCode, String productName, String inboundCode, - String destinationType, String overseasWarehouseId, - String overseasWarehouseName, String destinationWarehouseId, - String destinationWarehouseCode, String destinationWarehouseName, - String fbaWarehouseCode, int plannedPalletCount, int totalPackages, - int receivedPackages, int pendingPackages) { } - public record PalletResolved(String palletCode, long warehouseId, String status, String fbaWarehouseCode, String inboundCode, String destinationType, boolean compatible) { } 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 5aa3b65e0..d9c2e539f 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 @@ -226,42 +226,16 @@ public class ReceivingContainerService { ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); String customerId = session.getCustomerId(); - List currentRefs = refs(customerId, session.getContainerNo()); - if (currentRefs.stream().noneMatch(value -> - "OVERSEAS".equals(destinationType(value.line(), value.order())))) { - throw conflict("FBA destination must use bulk count"); - } - InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() - .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); - if (carton == null) throw notFound("Carton mark was not found"); - InboundRef ref = currentRefs.stream() - .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() - .orElseThrow(() -> conflict("Carton mark belongs to another container")); - if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { - throw conflict("FBA inbound must use bulk count instead of carton scan"); - } + CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + InboundRef ref = carton.ref(); ReceivingContainerResponses.Inbound inbound = inbound(ref); - return new ReceivingContainerResponses.CartonResolved(carton.getCartonMark(), carton.getSkuCode(), + return new ReceivingContainerResponses.CartonResolved(carton.carton().getCartonMark(), inbound.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); } - public ReceivingContainerResponses.SkuResolved resolveSku(String operatorKey, - ReceivingContainerRequests.SkuResolve request) { - ReceivingContainerSession session = requireSigned(operatorKey); - String customerId = session.getCustomerId(); - List matches = refs(customerId, session.getContainerNo()).stream() - .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) - .map(ref -> skuRef(ref, request.skuCode())) - .filter(Objects::nonNull) - .toList(); - if (matches.isEmpty()) throw notFound("SKU was not found in the current FBA container"); - if (matches.size() != 1) throw conflict("SKU belongs to multiple inbound codes in the current container"); - return skuResponse(matches.get(0)); - } - public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, ReceivingContainerRequests.PalletResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); @@ -284,22 +258,27 @@ public class ReceivingContainerService { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); } - SkuRef sku = requireSku(ref, request.skuCode()); - validateSkuPending(customerId, sku, request.packageCount()); - workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); + List fbaPlanRefs = requireFbaPlanRefs(customerId, session.getContainerNo(), ref); + 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); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); try { - int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, - request.packageCount(), operatorKey, operatorName); - recordSkuReceipt(session, sku, pallet, accepted); + int accepted = 0; + for (FbaSkuAllocation allocation : skuAllocations) { + accepted += workflow.receiveBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), pallet, + allocation.packages(), operatorKey, operatorName); + recordSkuReceipt(session, allocation, pallet); + } markReceiving(session); - ReceivingContainerResponses.Inbound inbound = inbound(ref); + int received = fbaPlanRefs.stream().mapToInt(this::receivedLinePackages).sum(); + int total = fbaPlanRefs.stream().mapToInt(refValue -> value(refValue.line().getRequiredPackageCount())).sum(); ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED", - session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted, - inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); + session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted, + received, Math.max(0, total - received), session.getStatus()); save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); return result; } catch (RuntimeException failure) { @@ -320,12 +299,8 @@ public class ReceivingContainerService { "OVERSEAS".equals(destinationType(value.line(), value.order())))) { throw conflict("FBA destination must use bulk count"); } - InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() - .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); - if (carton == null) throw notFound("Carton mark was not found"); - InboundRef ref = currentRefs.stream() - .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).findFirst() - .orElseThrow(() -> conflict("Carton mark belongs to another container")); + CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + InboundRef ref = carton.ref(); if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { throw conflict("FBA destination must use bulk count"); } @@ -426,7 +401,8 @@ public class ReceivingContainerService { List result = new ArrayList<>(); for (InboundPlan plan : plansForContainer(customerId, containerNo)) { for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() - .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()))) { + .eq(InboundPlanLine::getCustomerId, customerId).eq(InboundPlanLine::getPlanId, plan.getId()) + .orderByAsc(InboundPlanLine::getId))) { LastlegOrder order = orders.selectById(line.getOrderId()); if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing"); result.add(new InboundRef(plan, line, order)); @@ -439,8 +415,37 @@ public class ReceivingContainerService { List matches = refs(customerId, containerNo).stream() .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); - if (matches.size() != 1) throw conflict("Inbound code is duplicated inside the current container"); - return matches.get(0); + Set planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet()); + if (planIds.size() != 1) throw conflict("Inbound code is duplicated inside the current container"); + return matches.stream().min(Comparator.comparing(value -> value.line().getId())).orElseThrow(); + } + + private CartonRef requireCartonRef(String customerId, String containerNo, String cartonMark) { + InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper() + .eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark)); + if (carton == null) throw notFound("Carton mark was not found"); + List matches = refs(customerId, containerNo).stream() + .filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId())) + .filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId())) + .filter(value -> Objects.equals(value.line().getOrderId(), carton.getOrderId())).toList(); + if (matches.size() != 1) throw conflict("Carton mark belongs to another container"); + return new CartonRef(carton, matches.get(0)); + } + + private List requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) { + List values = refs(customerId, containerNo).stream() + .filter(value -> Objects.equals(value.plan().getId(), selected.plan().getId())) + .sorted(Comparator.comparing(value -> value.line().getId())).toList(); + if (values.isEmpty() || values.stream().anyMatch(value -> + !"FBA".equals(destinationType(value.line(), value.order())))) { + throw conflict("FBA bulk receiving requires every line in the selected inbound plan to be FBA"); + } + Set destinations = values.stream().map(value -> trim(value.line().getFbaWarehouseCode())) + .collect(Collectors.toSet()); + if (destinations.size() != 1 || destinations.contains(null)) { + throw conflict("FBA bulk receiving requires one destination warehouse per inbound plan"); + } + return values; } private InboundPlanPallet choosePallet(InboundRef ref, WarehouseResourceGateway.WarehousePalletReference resource) { @@ -553,44 +558,54 @@ public class ReceivingContainerService { throw conflict("Unload photo file ID must be a positive decimal string"); } - private SkuRef requireSku(InboundRef ref, String skuCode) { - SkuRef result = skuRef(ref, skuCode); - if (result == null) throw notFound("SKU is not part of the selected inbound code"); + private List allocateFbaSkuReceipts(String customerId, List refs, int packageCount) { + int remaining = packageCount; + List result = new ArrayList<>(); + for (InboundRef ref : refs) { + int linePending = value(ref.line().getRequiredPackageCount()) - receivedLinePackages(ref); + List expectations = skuExpectations(ref); + int skuPending = expectations.stream().mapToInt(expectation -> expectation.expectedPackages() + - receivedSkuPackages(customerId, ref.line().getId(), expectation.skuCode())).sum(); + if (linePending < 0 || skuPending != linePending) { + throw conflict("FBA SKU receipt progress must exactly match the inbound line progress"); + } + for (SkuExpectation expectation : expectations) { + if (remaining == 0) break; + int skuPendingPackages = expectation.expectedPackages() + - receivedSkuPackages(customerId, ref.line().getId(), expectation.skuCode()); + int accepted = Math.min(remaining, skuPendingPackages); + if (accepted > 0) { + result.add(new FbaSkuAllocation(ref, expectation.skuCode(), expectation.productName(), accepted)); + remaining -= accepted; + } + } + if (remaining == 0) break; + } + if (remaining != 0) throw conflict("Received package count exceeds the FBA inbound pending total"); return result; } - private SkuRef skuRef(InboundRef ref, String skuCode) { - String requested = trim(skuCode); - if (requested == null) return null; + private List skuExpectations(InboundRef ref) { List details = orderDetails.selectList(new LambdaQueryWrapper() .eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId()) .eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId)); - List matched = details.stream() - .filter(detail -> requested.equalsIgnoreCase(trim(detail.getSkuCode()))).toList(); - int expected = matched.stream().mapToInt(detail -> value(detail.getPackageCount())).sum(); - if (expected <= 0) return null; - String canonicalSku = matched.stream().map(LastlegOrderDetail::getSkuCode).map(ReceivingContainerService::trim) - .filter(Objects::nonNull).findFirst().orElse(requested); - String productName = matched.stream().map(detail -> first(detail.getProductNameZh(), detail.getProductNameEn())) - .filter(Objects::nonNull).findFirst().orElse(null); - return new SkuRef(ref, canonicalSku, productName, expected); - } - - private ReceivingContainerResponses.SkuResolved skuResponse(SkuRef sku) { - ReceivingContainerResponses.Inbound inbound = inbound(sku.ref()); - int received = receivedSkuPackages(sku.ref().order().getCustomerId(), sku.ref().line().getId(), sku.skuCode()); - return new ReceivingContainerResponses.SkuResolved(sku.skuCode(), sku.productName(), inbound.inboundCode(), - inbound.destinationType(), inbound.overseasWarehouseId(), inbound.overseasWarehouseName(), - inbound.destinationWarehouseId(), inbound.destinationWarehouseCode(), - inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.plannedPalletCount(), - sku.expectedPackages(), received, Math.max(0, sku.expectedPackages() - received)); - } - - private void validateSkuPending(String customerId, SkuRef sku, int packageCount) { - int received = receivedSkuPackages(customerId, sku.ref().line().getId(), sku.skuCode()); - if (packageCount <= 0 || packageCount > sku.expectedPackages() - received) { - throw conflict("Received package count exceeds the SKU pending total"); + Map grouped = new LinkedHashMap<>(); + for (LastlegOrderDetail detail : details) { + String skuCode = trim(detail.getSkuCode()); + int expected = value(detail.getPackageCount()); + if (skuCode == null || expected <= 0) continue; + String key = skuCode.toUpperCase(java.util.Locale.ROOT); + SkuExpectation existing = grouped.get(key); + grouped.put(key, existing == null ? new SkuExpectation(skuCode, + first(detail.getProductNameZh(), detail.getProductNameEn()), expected) + : new SkuExpectation(existing.skuCode(), existing.productName(), existing.expectedPackages() + expected)); } + List result = List.copyOf(grouped.values()); + int expected = result.stream().mapToInt(SkuExpectation::expectedPackages).sum(); + if (result.isEmpty() || expected != value(ref.line().getRequiredPackageCount())) { + throw conflict("Frozen FBA SKU package snapshots must exactly match the inbound line total"); + } + return result; } private int receivedSkuPackages(String customerId, Long lineId, String skuCode) { @@ -598,7 +613,15 @@ public class ReceivingContainerService { .mapToInt(value -> value(value.getReceivedPackages())).sum(); } - private void recordSkuReceipt(ReceivingContainerSession session, SkuRef sku, InboundPlanPallet pallet, int packages) { + private int receivedLinePackages(InboundRef ref) { + return allocations.selectList(new LambdaQueryWrapper() + .eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId()) + .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) + .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream() + .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + } + + private void recordSkuReceipt(ReceivingContainerSession session, FbaSkuAllocation sku, InboundPlanPallet pallet) { List existing = skuReceipts.lockLineSku(session.getCustomerId(), sku.ref().line().getId(), sku.skuCode()); ReceivingSkuReceipt receipt = existing.stream() @@ -610,11 +633,11 @@ public class ReceivingContainerService { receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId()); receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId()); receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode()); - receipt.setProductName(sku.productName()); receipt.setReceivedPackages(packages); + receipt.setProductName(sku.productName()); receipt.setReceivedPackages(sku.packages()); receipt.setCreatedAt(now); receipt.setUpdatedAt(now); receipt.setVersion(0); skuReceipts.insert(receipt); } else { - receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + packages); + receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + sku.packages()); receipt.setUpdatedAt(now); if (skuReceipts.updateById(receipt) != 1) throw conflict("SKU receiving progress changed concurrently"); } @@ -623,21 +646,14 @@ public class ReceivingContainerService { private void validateFbaSkuBalance(String customerId, String containerNo) { for (InboundRef ref : refs(customerId, containerNo)) { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; - List details = orderDetails.selectList(new LambdaQueryWrapper() - .eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId())); - Set skuCodes = details.stream().map(LastlegOrderDetail::getSkuCode) - .map(ReceivingContainerService::trim).filter(Objects::nonNull) - .map(value -> value.toUpperCase(java.util.Locale.ROOT)) - .collect(Collectors.toCollection(java.util.LinkedHashSet::new)); - int expected = 0; + List skuCodes = skuExpectations(ref); int received = 0; - for (String skuCode : skuCodes) { - SkuRef sku = requireSku(ref, skuCode); + for (SkuExpectation sku : skuCodes) { int skuReceived = receivedSkuPackages(customerId, ref.line().getId(), sku.skuCode()); if (skuReceived != sku.expectedPackages()) throw conflict("Every FBA SKU must be fully received before completing the container"); - expected += sku.expectedPackages(); received += skuReceived; + received += skuReceived; } - if (skuCodes.isEmpty() || expected != value(ref.line().getRequiredPackageCount()) || received != expected) { + if (received != value(ref.line().getRequiredPackageCount())) { throw conflict("FBA SKU package snapshots must exactly match the inbound code total"); } } @@ -790,7 +806,9 @@ public class ReceivingContainerService { private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { } - private record SkuRef(InboundRef ref, String skuCode, String productName, int expectedPackages) { } + private record CartonRef(InboundPlanCarton carton, InboundRef ref) { } + private record SkuExpectation(String skuCode, String productName, int expectedPackages) { } + private record FbaSkuAllocation(InboundRef ref, String skuCode, String productName, int packages) { } private record Totals(int inboundCount, List destinationCodes, int plannedPalletCount, int total, int received) { } private record PrepareCommand(String containerNo, String idempotencyKey) { } 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 c059d4b88..d39ab278b 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -72,17 +72,10 @@ paths: /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve: post: summary: 解析当前柜箱唛 - description: 权限 lastleg:pda:operate;仅 OVERSEAS 目的类型可逐箱收货,返回该箱所属入仓码完整信息。 + description: 权限 lastleg:pda:operate;FBA 和 OVERSEAS 均可解析当前锁定柜内箱码,返回唯一入仓码、目的类型、目标仓和实收进度;不返回 SKU。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } } - responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '409': { description: 跨柜、重复箱或目的类型不符 } } - /api/pda/v1/last-leg/receiving-containers/current/skus/resolve: - post: - summary: 解析当前柜 FBA SKU - description: 权限 lastleg:pda:operate;仅解析当前已签柜内唯一归属一个入仓码的 FBA SKU,返回冻结箱数与实收进度。 - security: [{ bearerAuth: [] }] - requestBody: { required: true, content: { application/json: { schema: { type: object, required: [skuCode], properties: { skuCode: { type: string, minLength: 1, maxLength: 128 } } } } } } - responses: { '200': { description: SKU与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSkuResolvedApiResponse' } } } }, '404': { description: A0404:SKU不在当前FBA柜内 }, '409': { description: A0409:SKU跨多个入仓码导致归属不唯一 } } + responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '409': { description: 箱码跨柜或归属不唯一 } } /api/pda/v1/last-leg/receiving-containers/current/pallets/resolve: post: summary: 解析当前柜实物托盘码 @@ -93,7 +86,7 @@ paths: /api/pda/v1/last-leg/receiving-containers/current/receipts/bulk: post: summary: FBA 入仓码按箱数收货 - description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;不接收 customerId、planNo、allocationId、warehouseId、locationId。 + description: 权限 lastleg:pda:operate;只允许 destinationType=FBA,OVERSEAS 在服务端硬拒绝;服务端按当前入仓码所属计划的 line.id ASC 和冻结 SKU 明细顺序拆分箱数并记录真实 SKU 收货;不接收 SKU、customerId、planNo、allocationId、warehouseId、locationId。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } } responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 海外仓批量、超收或托盘不匹配 } } @@ -658,10 +651,9 @@ components: downloadPath: { type: string, description: 用于会话恢复时直接下载签收照片 } ReceivingBulk: type: object - required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey] + required: [inboundCode, palletCode, packageCount, idempotencyKey] properties: inboundCode: { type: string, minLength: 1 } - skuCode: { type: string, minLength: 1, maxLength: 128 } palletCode: { type: string, minLength: 1 } packageCount: { type: integer, minimum: 1 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } @@ -751,15 +743,6 @@ components: required: [cartonMark] properties: cartonMark: { type: string } - skuCode: { type: string, nullable: true } - ReceivingSkuResolved: - allOf: - - { $ref: '#/components/schemas/ReceivingInbound' } - - type: object - required: [skuCode] - properties: - skuCode: { type: string } - productName: { type: string, nullable: true } ReceivingPalletResolved: type: object required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible] @@ -814,12 +797,6 @@ components: - type: object required: [data] properties: { data: { $ref: '#/components/schemas/ReceivingCartonResolved' } } - ReceivingSkuResolvedApiResponse: - allOf: - - { $ref: '#/components/schemas/ReceivingApiResponseBase' } - - type: object - required: [data] - properties: { data: { $ref: '#/components/schemas/ReceivingSkuResolved' } } ReceivingPalletResolvedApiResponse: allOf: - { $ref: '#/components/schemas/ReceivingApiResponseBase' } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index 2ac0fd471..65b4ae086 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -126,7 +126,7 @@ class ReceivingContainerContractTest { assertTrue(yaml.contains(prefix + "/candidates:")); assertTrue(yaml.contains(prefix + "/warehouse-profile:")); assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); - assertTrue(yaml.contains(prefix + "/current/skus/resolve:")); + assertFalse(yaml.contains(prefix + "/current/skus/resolve:")); assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); assertTrue(yaml.contains(prefix + "/current/complete:")); assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:")); @@ -134,8 +134,8 @@ class ReceivingContainerContractTest { String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); List successResponses = receivingPaths.lines().map(String::trim) .filter(line -> line.startsWith("responses: { '200':")).toList(); - assertEquals(17, successResponses.size()); - assertEquals(13, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); + assertEquals(16, successResponses.size()); + assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); @@ -146,6 +146,10 @@ class ReceivingContainerContractTest { assertTrue(yaml.contains("overseasWarehouseId: { type: string")); assertTrue(yaml.contains("overseasWarehouseName: { type: string")); assertFalse(receivingPaths.contains("photoFileIds")); + assertFalse(receivingPaths.contains("skuCode")); + assertFalse(yaml.contains("ReceivingSkuResolved")); + assertFalse(Arrays.stream(ReceivingContainerRequests.class.getDeclaredClasses()) + .anyMatch(type -> "SkuResolve".equals(type.getSimpleName()))); } private String resource(String name) throws IOException { 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 b7bf58732..1362e3053 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 @@ -182,7 +182,7 @@ class ReceivingContainerServiceBehaviorTest { void destinationModesAreHardRejectedBeforeWarehouseCalls() { stubCurrent("OVERSEAS", "CONT-A"); assertThatThrownBy(() -> service.receiveBulk("7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "bulk-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "bulk-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -209,7 +209,7 @@ class ReceivingContainerServiceBehaviorTest { stubSku(); when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1")); assertThatThrownBy(() -> service.receiveBulk("7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "pallet-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "pallet-key"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); } @@ -223,7 +223,7 @@ class ReceivingContainerServiceBehaviorTest { doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) .when(workflow).validateBulkResolved(any(), any(), any(), anyInt()); assertThatThrownBy(() -> service.receiveBulk("7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 2, "over-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 2, "over-key"))) .isInstanceOf(ResponseStatusException.class); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); @@ -291,7 +291,7 @@ class ReceivingContainerServiceBehaviorTest { doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()); assertThatThrownBy(() -> service.receiveBulk("7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "compensate-key"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "compensate-key"))) .isSameAs(failure); verify(workflow).compensateReservation("100", 55L, failure); } @@ -391,7 +391,7 @@ class ReceivingContainerServiceBehaviorTest { } @Test - void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() { + void bulkRejectsAggregateSkuOverReceiptBeforeWarehouseLookup() { stubCurrent("FBA", "CONT-A"); stubSku(); com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received = @@ -400,11 +400,62 @@ class ReceivingContainerServiceBehaviorTest { when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received)); assertThatThrownBy(() -> service.receiveBulk("7", "receiver", - new ReceivingContainerRequests.BulkReceive("IN-1", "SKU-1", "PAL-1", 1, "sku-over"))) + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 1, "sku-over"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); } + @Test + void cartonResolutionSupportsFbaWithoutProjectingSku() { + stubCurrent("FBA", "CONT-A"); + when(cartons.selectOne(any())).thenReturn(carton(1L, "FBA-BOX-1")); + + var resolved = service.resolveCarton("7", new ReceivingContainerRequests.CartonResolve("FBA-BOX-1")); + + assertThat(resolved.cartonMark()).isEqualTo("FBA-BOX-1"); + assertThat(resolved.inboundCode()).isEqualTo("IN-1"); + assertThat(resolved.destinationType()).isEqualTo("FBA"); + } + + @Test + void fbaBulkDistributesAcrossLinesAndFrozenSkusInStableLineOrder() { + InboundPlan plan = plan("CONT-A"); + InboundPlanLine first = line("FBA"); + InboundPlanLine second = line("FBA"); + second.setId(20L); second.setOrderId(2L); second.setRequiredPackageCount(2); + LastlegOrder firstOrder = order("FBA"); + LastlegOrder secondOrder = order("FBA"); secondOrder.setId(2L); + when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "SIGNED")); + when(plans.selectList(any())).thenReturn(List.of(plan)); + when(lines.selectList(any())).thenReturn(List.of(second, first)); + when(orders.selectById(1L)).thenReturn(firstOrder); + when(orders.selectById(2L)).thenReturn(secondOrder); + when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-A", 1), detail("SKU-B", 1)), + List.of(detail("SKU-C", 2))); + when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(100L, "FBA-1")); + when(pallets.selectList(any())).thenReturn(List.of(planPallet())); + when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any())) + .thenAnswer(invocation -> invocation.getArgument(4)); + when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); + when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); + + var result = service.receiveBulk("7", "receiver", + new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 3, "split-key")); + + assertThat(result.acceptedPackages()).isEqualTo(3); + ArgumentCaptor capturedLines = ArgumentCaptor.forClass(InboundPlanLine.class); + ArgumentCaptor capturedCounts = ArgumentCaptor.forClass(Integer.class); + verify(workflow, org.mockito.Mockito.times(3)).receiveBulkResolved(any(), any(), capturedLines.capture(), any(), + capturedCounts.capture(), any(), any()); + assertThat(capturedLines.getAllValues()).containsExactly(first, first, second); + assertThat(capturedCounts.getAllValues()).containsExactly(1, 1, 1); + ArgumentCaptor receipts = + ArgumentCaptor.forClass(com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt.class); + verify(skuReceipts, org.mockito.Mockito.times(3)).insert(receipts.capture()); + assertThat(receipts.getAllValues()).extracting(value -> value.getSkuCode()) + .containsExactly("SKU-A", "SKU-B", "SKU-C"); + } + private void stubCurrent(String destinationType, String containerNo) { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index abb0069d8..1a69e9b83 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -768,10 +768,18 @@ export class AcceptanceRun { for (let sequence = 2; sequence <= total; sequence += 1) { this.marker = `${baseMarker}-WS${String(sequence).padStart(2, '0')}` this.applyWaitSplitVariant(sequence) + const orderStepStart = this.steps.length await this.executeModule('orders', () => orders.run(this)) - if (this.modules.at(-1)?.success !== true) break + const orderRun = this.modules.at(-1) + const orderBlockingFailure = Boolean(orderRun?.fatal) + || this.steps.slice(orderStepStart).some((step) => !step.passed && step.deploymentBlocker) + if (orderBlockingFailure) break + const measurementStepStart = this.steps.length await this.executeModule('inbound-measurement', () => measurement.run(this)) - if (this.modules.at(-1)?.success !== true || !this.captureWaitSplitFixture(sequence)) break + const measurementRun = this.modules.at(-1) + const measurementBlockingFailure = Boolean(measurementRun?.fatal) + || this.steps.slice(measurementStepStart).some((step) => !step.passed && step.deploymentBlocker) + if (measurementBlockingFailure || !this.captureWaitSplitFixture(sequence)) break } } finally { this.marker = baseMarker diff --git a/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs b/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs index 5d483e5f6..c4c13b89b 100644 --- a/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs +++ b/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs @@ -79,7 +79,7 @@ export async function run(context) { beforeContainerVersion: Number(container?.version), beforeItems, expectedAssignments: source.expectedAssignments || [], instructions: [ - '在订舱页面创建与本清单客户、起运仓、渠道和柜型一致的 DRAFT 订舱,并填写完整运输资料。', + '在订舱页面创建与本清单客户、起运仓、渠道和柜型一致的 DRAFT 订舱,并填写承运船司代码 carrierCode 及完整运输资料。', '确认放舱,使订舱进入 RELEASED;放舱本身不得创建货柜。', '在原 containerBookingId 上选择该订舱未占用的柜型需求,录入唯一真实 containerNo 完成关联。', '回到预装详情,确认 canConfirm=true 后执行整份计划确认;不要领取 PDA 任务。', diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 46089b6f2..031a93a37 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -137,7 +137,7 @@ async function createConfirmedContainer(context, source, label) { customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', channelCode: summary.channelCode, warehouseId: source.warehouseId, billOfLadingNo: `BL-${value}`.slice(0, 128), - shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, + shippingCompanyId: id(agent.id), shippingCompanyName: agent.name, carrierCode: 'OTHER', bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX', vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index ac4d215a1..19330450f 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -39,6 +39,9 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () => assert.match(acceptance, /wait-split-count必须是1-100的整数/) assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/) assert.match(acceptance, /createAdditionalWaitSplitFixtures/) + assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/) + assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/) + assert.doesNotMatch(acceptance, /this\.modules\.at\(-1\)\?\.success !== true/) assert.match(acceptance, /measurement\.processStatus === 'WAIT_SPLIT'/) assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/) assert.match(acceptance, /waitSplitVariant\(sequence\)/) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index 1eeb5857a..6252d8407 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -28,6 +28,7 @@ test('shared DRAFT uses requirements and never registers a real container number assert.equal(JSON.stringify(draft).includes('containerNo'), false) assert.equal(draft.serviceTerm, 'CY-CY') assert.equal(draft.freightTerm, 'NINGBO') + assert.equal(draft.carrierCode, 'OTHER') for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field]) }) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index 13b80411c..b3e3a01cf 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -61,6 +61,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { return { ...writableMaster, billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`, + carrierCode: labels.carrierCode || 'OTHER', shippingCompanyId: labels.shippingCompanyId ?? null, shippingCompanyName: labels.shippingCompanyId ? labels.shippingCompanyName ?? null : null, bookingAgentId: labels.bookingAgentId ?? null, diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 5a09b0a54..d6a4f3469 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -159,7 +159,7 @@ export async function run(context) { customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, channelCode, warehouseId, billOfLadingNo: `BL-${suffix}`.slice(0, 128), - shippingCompanyId: id(bookingAgent.id), shippingCompanyName: bookingAgent.name, + shippingCompanyId: id(bookingAgent.id), shippingCompanyName: bookingAgent.name, carrierCode: 'OTHER', bookingAgentId: id(bookingAgent.id), bookingAgentName: bookingAgent.name, vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 060f643d0..0340e092b 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -175,7 +175,7 @@ export async function run(context) { warehouseId, channelCode: initial?.summary?.channelCode, billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), - shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, + shippingCompanyId: id(shippingCarrier.id), shippingCompanyName: shippingCarrier.name, carrierCode: 'OTHER', bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', diff --git a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md index 26f2c6e41..92fdfa315 100644 --- a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md +++ b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md @@ -263,7 +263,7 @@ node scripts/admin-test-env-acceptance.mjs ` 严格按以下顺序操作: -1. 创建与准备清单客户、起运仓、渠道、柜型一致的海运订舱 DRAFT,并补齐提单号、船名、航次、ETD、ETA、参与方和柜型需求。 +1. 创建与准备清单客户、起运仓、渠道、柜型一致的海运订舱 DRAFT,并补齐承运船司代码 `carrierCode`、提单号、船名、航次、ETD、ETA、参与方和柜型需求。 2. 点击放舱,使订舱进入 `RELEASED`。此时不应自动生成真实柜次或 PDA 任务。 3. 在原内部柜次上选择该订舱未占用的柜型需求,录入唯一真实 `containerNo` 完成关联;不得新建替代柜次。 4. 回到预装详情,确认门禁变为 `canConfirm=true`,再执行整份预装计划确认。