This commit is contained in:
2026-08-26 17:09:02 +08:00
parent 6c4543c18c
commit 795e852944
16 changed files with 201 additions and 155 deletions
@@ -112,14 +112,6 @@ public class ReceivingContainerController {
return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request)); return ApiResponse.success(service.resolveCarton(identity.operatorKey(), request));
} }
@PostMapping("/current/skus/resolve")
public ApiResponse<ReceivingContainerResponses.SkuResolved> 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") @PostMapping("/current/pallets/resolve")
public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet( public ApiResponse<ReceivingContainerResponses.PalletResolved> resolvePallet(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@@ -18,10 +18,8 @@ public final class ReceivingContainerRequests {
public record SignedPhoto(@NotNull ReceivingPhotoRole role, public record SignedPhoto(@NotNull ReceivingPhotoRole role,
@NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { } @NotBlank @Pattern(regexp = "^[1-9][0-9]{0,18}$") String fileId) { }
public record CartonResolve(@NotBlank String cartonMark) { } 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 PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
public record BulkReceive(@NotBlank String inboundCode, @NotBlank @Size(max = 128) String skuCode, public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
@NotBlank String palletCode,
@Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { } @Positive int packageCount, @NotBlank @Size(max = 255) String idempotencyKey) { }
public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode, public record CartonReceive(@NotBlank String cartonMark, @NotBlank String palletCode,
@NotBlank @Size(max = 255) String idempotencyKey) { } @NotBlank @Size(max = 255) String idempotencyKey) { }
@@ -28,20 +28,13 @@ public final class ReceivingContainerResponses {
String fbaWarehouseCode, int plannedPalletCount, int totalPackages, String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { } 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 destinationType, String overseasWarehouseId,
String overseasWarehouseName, String destinationWarehouseId, String overseasWarehouseName, String destinationWarehouseId,
String destinationWarehouseCode, String destinationWarehouseName, String destinationWarehouseCode, String destinationWarehouseName,
String fbaWarehouseCode, int plannedPalletCount, int totalPackages, String fbaWarehouseCode, int plannedPalletCount, int totalPackages,
int receivedPackages, int pendingPackages) { } 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, public record PalletResolved(String palletCode, long warehouseId, String status,
String fbaWarehouseCode, String inboundCode, String fbaWarehouseCode, String inboundCode,
String destinationType, boolean compatible) { } String destinationType, boolean compatible) { }
@@ -226,42 +226,16 @@ public class ReceivingContainerService {
ReceivingContainerRequests.CartonResolve request) { ReceivingContainerRequests.CartonResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
String customerId = session.getCustomerId(); String customerId = session.getCustomerId();
List<InboundRef> currentRefs = refs(customerId, session.getContainerNo()); CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark());
if (currentRefs.stream().noneMatch(value -> InboundRef ref = carton.ref();
"OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count");
}
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>()
.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");
}
ReceivingContainerResponses.Inbound inbound = inbound(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.inboundCode(), inbound.destinationType(), inbound.overseasWarehouseId(),
inbound.overseasWarehouseName(), inbound.destinationWarehouseId(), inbound.overseasWarehouseName(), inbound.destinationWarehouseId(),
inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(), inbound.destinationWarehouseCode(), inbound.destinationWarehouseName(), inbound.fbaWarehouseCode(),
inbound.plannedPalletCount(), inbound.totalPackages(), inbound.receivedPackages(), inbound.pendingPackages()); 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<SkuRef> 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, public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey,
ReceivingContainerRequests.PalletResolve request) { ReceivingContainerRequests.PalletResolve request) {
ReceivingContainerSession session = requireSigned(operatorKey); ReceivingContainerSession session = requireSigned(operatorKey);
@@ -284,22 +258,27 @@ public class ReceivingContainerService {
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden");
} }
SkuRef sku = requireSku(ref, request.skuCode()); List<InboundRef> fbaPlanRefs = requireFbaPlanRefs(customerId, session.getContainerNo(), ref);
validateSkuPending(customerId, sku, request.packageCount()); List<FbaSkuAllocation> skuAllocations = allocateFbaSkuReceipts(customerId, fbaPlanRefs, request.packageCount());
workflow.validateBulkResolved(customerId, ref.plan(), ref.line(), request.packageCount()); skuAllocations.forEach(allocation -> workflow.validateBulkResolved(customerId, allocation.ref().plan(),
allocation.ref().line(), allocation.packages()));
WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode()); WarehouseResourceGateway.WarehousePalletReference resource = warehouse.resolvePallet(request.palletCode());
validatePallet(ref, resource); validatePallet(ref, resource);
InboundPlanPallet pallet = choosePallet(ref, resource); InboundPlanPallet pallet = choosePallet(ref, resource);
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource); Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try { try {
int accepted = workflow.receiveBulkResolved(customerId, ref.plan(), ref.line(), pallet, int accepted = 0;
request.packageCount(), operatorKey, operatorName); for (FbaSkuAllocation allocation : skuAllocations) {
recordSkuReceipt(session, sku, pallet, accepted); accepted += workflow.receiveBulkResolved(customerId, allocation.ref().plan(), allocation.ref().line(), pallet,
allocation.packages(), operatorKey, operatorName);
recordSkuReceipt(session, allocation, pallet);
}
markReceiving(session); 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", ReceivingContainerResponses.Receipt result = new ReceivingContainerResponses.Receipt("RECEIVED",
session.getContainerNo(), inbound.inboundCode(), inbound.destinationType(), resource.palletNo(), accepted, session.getContainerNo(), ref.line().getInboundCode(), "FBA", resource.palletNo(), accepted,
inbound.receivedPackages(), inbound.pendingPackages(), session.getStatus()); received, Math.max(0, total - received), session.getStatus());
save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result); save(session, "BULK_RECEIVE", request.idempotencyKey(), request, result);
return result; return result;
} catch (RuntimeException failure) { } catch (RuntimeException failure) {
@@ -320,12 +299,8 @@ public class ReceivingContainerService {
"OVERSEAS".equals(destinationType(value.line(), value.order())))) { "OVERSEAS".equals(destinationType(value.line(), value.order())))) {
throw conflict("FBA destination must use bulk count"); throw conflict("FBA destination must use bulk count");
} }
InboundPlanCarton carton = cartons.selectOne(new LambdaQueryWrapper<InboundPlanCarton>() CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark());
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, request.cartonMark())); InboundRef ref = carton.ref();
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()))) { if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) {
throw conflict("FBA destination must use bulk count"); throw conflict("FBA destination must use bulk count");
} }
@@ -426,7 +401,8 @@ public class ReceivingContainerService {
List<InboundRef> result = new ArrayList<>(); List<InboundRef> result = new ArrayList<>();
for (InboundPlan plan : plansForContainer(customerId, containerNo)) { for (InboundPlan plan : plansForContainer(customerId, containerNo)) {
for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>() for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper<InboundPlanLine>()
.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()); LastlegOrder order = orders.selectById(line.getOrderId());
if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing"); if (order == null || !customerId.equals(order.getCustomerId())) throw conflict("Inbound line order is missing");
result.add(new InboundRef(plan, line, order)); result.add(new InboundRef(plan, line, order));
@@ -439,8 +415,37 @@ public class ReceivingContainerService {
List<InboundRef> matches = refs(customerId, containerNo).stream() List<InboundRef> matches = refs(customerId, containerNo).stream()
.filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList();
if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); 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"); Set<Long> planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet());
return matches.get(0); 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<InboundPlanCarton>()
.eq(InboundPlanCarton::getCustomerId, customerId).eq(InboundPlanCarton::getCartonMark, cartonMark));
if (carton == null) throw notFound("Carton mark was not found");
List<InboundRef> 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<InboundRef> requireFbaPlanRefs(String customerId, String containerNo, InboundRef selected) {
List<InboundRef> 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<String> 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) { 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"); throw conflict("Unload photo file ID must be a positive decimal string");
} }
private SkuRef requireSku(InboundRef ref, String skuCode) { private List<FbaSkuAllocation> allocateFbaSkuReceipts(String customerId, List<InboundRef> refs, int packageCount) {
SkuRef result = skuRef(ref, skuCode); int remaining = packageCount;
if (result == null) throw notFound("SKU is not part of the selected inbound code"); List<FbaSkuAllocation> result = new ArrayList<>();
for (InboundRef ref : refs) {
int linePending = value(ref.line().getRequiredPackageCount()) - receivedLinePackages(ref);
List<SkuExpectation> 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; return result;
} }
private SkuRef skuRef(InboundRef ref, String skuCode) { private List<SkuExpectation> skuExpectations(InboundRef ref) {
String requested = trim(skuCode);
if (requested == null) return null;
List<LastlegOrderDetail> details = orderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>() List<LastlegOrderDetail> details = orderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>()
.eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId()) .eq(LastlegOrderDetail::getCustomerId, ref.order().getCustomerId())
.eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId)); .eq(LastlegOrderDetail::getOrderId, ref.order().getId()).orderByAsc(LastlegOrderDetail::getId));
List<LastlegOrderDetail> matched = details.stream() Map<String, SkuExpectation> grouped = new LinkedHashMap<>();
.filter(detail -> requested.equalsIgnoreCase(trim(detail.getSkuCode()))).toList(); for (LastlegOrderDetail detail : details) {
int expected = matched.stream().mapToInt(detail -> value(detail.getPackageCount())).sum(); String skuCode = trim(detail.getSkuCode());
if (expected <= 0) return null; int expected = value(detail.getPackageCount());
String canonicalSku = matched.stream().map(LastlegOrderDetail::getSkuCode).map(ReceivingContainerService::trim) if (skuCode == null || expected <= 0) continue;
.filter(Objects::nonNull).findFirst().orElse(requested); String key = skuCode.toUpperCase(java.util.Locale.ROOT);
String productName = matched.stream().map(detail -> first(detail.getProductNameZh(), detail.getProductNameEn())) SkuExpectation existing = grouped.get(key);
.filter(Objects::nonNull).findFirst().orElse(null); grouped.put(key, existing == null ? new SkuExpectation(skuCode,
return new SkuRef(ref, canonicalSku, productName, expected); first(detail.getProductNameZh(), detail.getProductNameEn()), expected)
} : new SkuExpectation(existing.skuCode(), existing.productName(), existing.expectedPackages() + 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");
} }
List<SkuExpectation> 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) { private int receivedSkuPackages(String customerId, Long lineId, String skuCode) {
@@ -598,7 +613,15 @@ public class ReceivingContainerService {
.mapToInt(value -> value(value.getReceivedPackages())).sum(); .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<InboundPlanPalletAllocation>()
.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<ReceivingSkuReceipt> existing = skuReceipts.lockLineSku(session.getCustomerId(), List<ReceivingSkuReceipt> existing = skuReceipts.lockLineSku(session.getCustomerId(),
sku.ref().line().getId(), sku.skuCode()); sku.ref().line().getId(), sku.skuCode());
ReceivingSkuReceipt receipt = existing.stream() ReceivingSkuReceipt receipt = existing.stream()
@@ -610,11 +633,11 @@ public class ReceivingContainerService {
receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId()); receipt.setPlanId(sku.ref().plan().getId()); receipt.setLineId(sku.ref().line().getId());
receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId()); receipt.setPalletId(pallet.getId()); receipt.setOrderId(sku.ref().order().getId());
receipt.setInboundCode(sku.ref().line().getInboundCode()); receipt.setSkuCode(sku.skuCode()); 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); receipt.setCreatedAt(now); receipt.setUpdatedAt(now); receipt.setVersion(0);
skuReceipts.insert(receipt); skuReceipts.insert(receipt);
} else { } else {
receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + packages); receipt.setReceivedPackages(value(receipt.getReceivedPackages()) + sku.packages());
receipt.setUpdatedAt(now); receipt.setUpdatedAt(now);
if (skuReceipts.updateById(receipt) != 1) throw conflict("SKU receiving progress changed concurrently"); 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) { private void validateFbaSkuBalance(String customerId, String containerNo) {
for (InboundRef ref : refs(customerId, containerNo)) { for (InboundRef ref : refs(customerId, containerNo)) {
if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue;
List<LastlegOrderDetail> details = orderDetails.selectList(new LambdaQueryWrapper<LastlegOrderDetail>() List<SkuExpectation> skuCodes = skuExpectations(ref);
.eq(LastlegOrderDetail::getCustomerId, customerId).eq(LastlegOrderDetail::getOrderId, ref.order().getId()));
Set<String> 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;
int received = 0; int received = 0;
for (String skuCode : skuCodes) { for (SkuExpectation sku : skuCodes) {
SkuRef sku = requireSku(ref, skuCode);
int skuReceived = receivedSkuPackages(customerId, ref.line().getId(), sku.skuCode()); 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"); 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"); 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 static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); }
private record InboundRef(InboundPlan plan, InboundPlanLine line, LastlegOrder order) { } 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<String> destinationCodes, int plannedPalletCount, private record Totals(int inboundCount, List<String> destinationCodes, int plannedPalletCount,
int total, int received) { } int total, int received) { }
private record PrepareCommand(String containerNo, String idempotencyKey) { } private record PrepareCommand(String containerNo, String idempotencyKey) { }
@@ -72,17 +72,10 @@ paths:
/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve: /api/pda/v1/last-leg/receiving-containers/current/cartons/resolve:
post: post:
summary: 解析当前柜箱唛 summary: 解析当前柜箱唛
description: 权限 lastleg:pda:operate;仅 OVERSEAS 目的类型可逐箱收货,返回该箱所属入仓码完整信息。 description: 权限 lastleg:pda:operate;FBA 和 OVERSEAS 均可解析当前锁定柜内箱码,返回唯一入仓码、目的类型、目标仓和实收进度;不返回 SKU。
security: [{ bearerAuth: [] }] security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } } 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: 跨柜、重复箱或目的类型不符 } } 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跨多个入仓码导致归属不唯一 } }
/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve: /api/pda/v1/last-leg/receiving-containers/current/pallets/resolve:
post: post:
summary: 解析当前柜实物托盘码 summary: 解析当前柜实物托盘码
@@ -93,7 +86,7 @@ paths:
/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk: /api/pda/v1/last-leg/receiving-containers/current/receipts/bulk:
post: post:
summary: FBA 入仓码按箱数收货 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: [] }] security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 海外仓批量、超收或托盘不匹配 } } responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 海外仓批量、超收或托盘不匹配 } }
@@ -658,10 +651,9 @@ components:
downloadPath: { type: string, description: 用于会话恢复时直接下载签收照片 } downloadPath: { type: string, description: 用于会话恢复时直接下载签收照片 }
ReceivingBulk: ReceivingBulk:
type: object type: object
required: [inboundCode, skuCode, palletCode, packageCount, idempotencyKey] required: [inboundCode, palletCode, packageCount, idempotencyKey]
properties: properties:
inboundCode: { type: string, minLength: 1 } inboundCode: { type: string, minLength: 1 }
skuCode: { type: string, minLength: 1, maxLength: 128 }
palletCode: { type: string, minLength: 1 } palletCode: { type: string, minLength: 1 }
packageCount: { type: integer, minimum: 1 } packageCount: { type: integer, minimum: 1 }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
@@ -751,15 +743,6 @@ components:
required: [cartonMark] required: [cartonMark]
properties: properties:
cartonMark: { type: string } 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: ReceivingPalletResolved:
type: object type: object
required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible] required: [palletCode, warehouseId, status, inboundCode, destinationType, compatible]
@@ -814,12 +797,6 @@ components:
- type: object - type: object
required: [data] required: [data]
properties: { data: { $ref: '#/components/schemas/ReceivingCartonResolved' } } properties: { data: { $ref: '#/components/schemas/ReceivingCartonResolved' } }
ReceivingSkuResolvedApiResponse:
allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' }
- type: object
required: [data]
properties: { data: { $ref: '#/components/schemas/ReceivingSkuResolved' } }
ReceivingPalletResolvedApiResponse: ReceivingPalletResolvedApiResponse:
allOf: allOf:
- { $ref: '#/components/schemas/ReceivingApiResponseBase' } - { $ref: '#/components/schemas/ReceivingApiResponseBase' }
@@ -126,7 +126,7 @@ class ReceivingContainerContractTest {
assertTrue(yaml.contains(prefix + "/candidates:")); assertTrue(yaml.contains(prefix + "/candidates:"));
assertTrue(yaml.contains(prefix + "/warehouse-profile:")); assertTrue(yaml.contains(prefix + "/warehouse-profile:"));
assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); 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/receipts/carton:"));
assertTrue(yaml.contains(prefix + "/current/complete:")); assertTrue(yaml.contains(prefix + "/current/complete:"));
assertTrue(yaml.contains(prefix + "/current/unload-photos/files/{fileId}/download:")); 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:")); String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:"));
List<String> successResponses = receivingPaths.lines().map(String::trim) List<String> successResponses = receivingPaths.lines().map(String::trim)
.filter(line -> line.startsWith("responses: { '200':")).toList(); .filter(line -> line.startsWith("responses: { '200':")).toList();
assertEquals(17, successResponses.size()); assertEquals(16, successResponses.size());
assertEquals(13, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count());
assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count());
assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜"));
assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用"));
@@ -146,6 +146,10 @@ class ReceivingContainerContractTest {
assertTrue(yaml.contains("overseasWarehouseId: { type: string")); assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
assertTrue(yaml.contains("overseasWarehouseName: { type: string")); assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
assertFalse(receivingPaths.contains("photoFileIds")); 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 { private String resource(String name) throws IOException {
@@ -182,7 +182,7 @@ class ReceivingContainerServiceBehaviorTest {
void destinationModesAreHardRejectedBeforeWarehouseCalls() { void destinationModesAreHardRejectedBeforeWarehouseCalls() {
stubCurrent("OVERSEAS", "CONT-A"); stubCurrent("OVERSEAS", "CONT-A");
assertThatThrownBy(() -> service.receiveBulk("7", "receiver", 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"); .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse); verifyNoInteractions(warehouse);
@@ -209,7 +209,7 @@ class ReceivingContainerServiceBehaviorTest {
stubSku(); stubSku();
when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1")); when(warehouse.resolvePallet("PAL-1")).thenReturn(resource(101L, "FBA-1"));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver", 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"); .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
} }
@@ -223,7 +223,7 @@ class ReceivingContainerServiceBehaviorTest {
doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt")) doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "over receipt"))
.when(workflow).validateBulkResolved(any(), any(), any(), anyInt()); .when(workflow).validateBulkResolved(any(), any(), any(), anyInt());
assertThatThrownBy(() -> service.receiveBulk("7", "receiver", 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); .isInstanceOf(ResponseStatusException.class);
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any()); 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()); doThrow(failure).when(workflow).receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any());
assertThatThrownBy(() -> service.receiveBulk("7", "receiver", 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); .isSameAs(failure);
verify(workflow).compensateReservation("100", 55L, failure); verify(workflow).compensateReservation("100", 55L, failure);
} }
@@ -391,7 +391,7 @@ class ReceivingContainerServiceBehaviorTest {
} }
@Test @Test
void bulkRejectsSkuOverReceiptBeforeWarehouseLookup() { void bulkRejectsAggregateSkuOverReceiptBeforeWarehouseLookup() {
stubCurrent("FBA", "CONT-A"); stubCurrent("FBA", "CONT-A");
stubSku(); stubSku();
com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt received = 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)); when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of(received));
assertThatThrownBy(() -> service.receiveBulk("7", "receiver", 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"); .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
verifyNoInteractions(warehouse); 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<InboundPlanLine> capturedLines = ArgumentCaptor.forClass(InboundPlanLine.class);
ArgumentCaptor<Integer> 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<com.baiyue.lastleg.inboundplan.pda.entity.ReceivingSkuReceipt> 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) { private void stubCurrent(String destinationType, String containerNo) {
InboundPlan plan = plan(containerNo); InboundPlan plan = plan(containerNo);
InboundPlanLine line = line(destinationType); InboundPlanLine line = line(destinationType);
+10 -2
View File
@@ -768,10 +768,18 @@ export class AcceptanceRun {
for (let sequence = 2; sequence <= total; sequence += 1) { for (let sequence = 2; sequence <= total; sequence += 1) {
this.marker = `${baseMarker}-WS${String(sequence).padStart(2, '0')}` this.marker = `${baseMarker}-WS${String(sequence).padStart(2, '0')}`
this.applyWaitSplitVariant(sequence) this.applyWaitSplitVariant(sequence)
const orderStepStart = this.steps.length
await this.executeModule('orders', () => orders.run(this)) 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)) 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 { } finally {
this.marker = baseMarker this.marker = baseMarker
@@ -79,7 +79,7 @@ export async function run(context) {
beforeContainerVersion: Number(container?.version), beforeItems, beforeContainerVersion: Number(container?.version), beforeItems,
expectedAssignments: source.expectedAssignments || [], expectedAssignments: source.expectedAssignments || [],
instructions: [ instructions: [
'在订舱页面创建与本清单客户、起运仓、渠道和柜型一致的 DRAFT 订舱,并填写完整运输资料。', '在订舱页面创建与本清单客户、起运仓、渠道和柜型一致的 DRAFT 订舱,并填写承运船司代码 carrierCode 及完整运输资料。',
'确认放舱,使订舱进入 RELEASED;放舱本身不得创建货柜。', '确认放舱,使订舱进入 RELEASED;放舱本身不得创建货柜。',
'在原 containerBookingId 上选择该订舱未占用的柜型需求,录入唯一真实 containerNo 完成关联。', '在原 containerBookingId 上选择该订舱未占用的柜型需求,录入唯一真实 containerNo 完成关联。',
'回到预装详情,确认 canConfirm=true 后执行整份计划确认;不要领取 PDA 任务。', '回到预装详情,确认 canConfirm=true 后执行整份计划确认;不要领取 PDA 任务。',
@@ -137,7 +137,7 @@ async function createConfirmedContainer(context, source, label) {
customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户', customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户',
channelCode: summary.channelCode, warehouseId: source.warehouseId, channelCode: summary.channelCode, warehouseId: source.warehouseId,
billOfLadingNo: `BL-${value}`.slice(0, 128), 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', bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX',
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
@@ -39,6 +39,9 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () =>
assert.match(acceptance, /wait-split-count必须是1-100的整数/) assert.match(acceptance, /wait-split-count必须是1-100的整数/)
assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/) assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/)
assert.match(acceptance, /createAdditionalWaitSplitFixtures/) 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\.processStatus === 'WAIT_SPLIT'/)
assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/) assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/)
assert.match(acceptance, /waitSplitVariant\(sequence\)/) assert.match(acceptance, /waitSplitVariant\(sequence\)/)
@@ -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(JSON.stringify(draft).includes('containerNo'), false)
assert.equal(draft.serviceTerm, 'CY-CY') assert.equal(draft.serviceTerm, 'CY-CY')
assert.equal(draft.freightTerm, 'NINGBO') assert.equal(draft.freightTerm, 'NINGBO')
assert.equal(draft.carrierCode, 'OTHER')
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field]) for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field])
}) })
@@ -61,6 +61,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
return { return {
...writableMaster, ...writableMaster,
billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`, billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`,
carrierCode: labels.carrierCode || 'OTHER',
shippingCompanyId: labels.shippingCompanyId ?? null, shippingCompanyId: labels.shippingCompanyId ?? null,
shippingCompanyName: labels.shippingCompanyId ? labels.shippingCompanyName ?? null : null, shippingCompanyName: labels.shippingCompanyId ? labels.shippingCompanyName ?? null : null,
bookingAgentId: labels.bookingAgentId ?? null, bookingAgentId: labels.bookingAgentId ?? null,
@@ -159,7 +159,7 @@ export async function run(context) {
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
channelCode, warehouseId, channelCode, warehouseId,
billOfLadingNo: `BL-${suffix}`.slice(0, 128), 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, bookingAgentId: id(bookingAgent.id), bookingAgentName: bookingAgent.name,
vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳', departurePortCode: 'CNSZX', departurePortName: '深圳',
@@ -175,7 +175,7 @@ export async function run(context) {
warehouseId, warehouseId,
channelCode: initial?.summary?.channelCode, channelCode: initial?.summary?.channelCode,
billOfLadingNo: `P3BL-${suffix}`.slice(0, 128), 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, bookingAgentId: id(supplier.id), bookingAgentName: supplier.name,
vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
@@ -263,7 +263,7 @@ node scripts/admin-test-env-acceptance.mjs `
严格按以下顺序操作: 严格按以下顺序操作:
1. 创建与准备清单客户、起运仓、渠道、柜型一致的海运订舱 DRAFT,并补齐提单号、船名、航次、ETD、ETA、参与方和柜型需求。 1. 创建与准备清单客户、起运仓、渠道、柜型一致的海运订舱 DRAFT,并补齐承运船司代码 `carrierCode`、提单号、船名、航次、ETD、ETA、参与方和柜型需求。
2. 点击放舱,使订舱进入 `RELEASED`。此时不应自动生成真实柜次或 PDA 任务。 2. 点击放舱,使订舱进入 `RELEASED`。此时不应自动生成真实柜次或 PDA 任务。
3. 在原内部柜次上选择该订舱未占用的柜型需求,录入唯一真实 `containerNo` 完成关联;不得新建替代柜次。 3. 在原内部柜次上选择该订舱未占用的柜型需求,录入唯一真实 `containerNo` 完成关联;不得新建替代柜次。
4. 回到预装详情,确认门禁变为 `canConfirm=true`,再执行整份预装计划确认。 4. 回到预装详情,确认门禁变为 `canConfirm=true`,再执行整份预装计划确认。