From fd27a0ed210df9d3a9b556fae65db4e3e7834ece Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 18 Sep 2026 16:48:30 +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 | 12 +- .../service/ReceivingContainerService.java | 185 ++++++++++-------- .../openapi/overseas-business-v1.yaml | 12 +- ...ReceivingContainerServiceBehaviorTest.java | 68 +++++-- 4 files changed, 177 insertions(+), 100 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 783c55b38..d80e315d5 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 @@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.server.ResponseStatusException; @@ -58,17 +59,20 @@ public class ReceivingContainerController { @GetMapping("/{containerNo}") @Operation(summary = "Get one receiving container without exposing plan or allocation IDs") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String containerNo) { + @PathVariable String containerNo, + @RequestParam String sourceContainerBookingId) { PdaWarehouseIdentity identity = identity(user); - return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), containerNo)); + return ApiResponse.success(service.detail(identity.operatorKey(), identity.overseasWarehouseId(), + containerNo, sourceContainerBookingId)); } @PostMapping("/{containerNo}/prepare") public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String containerNo, @Valid @RequestBody ReceivingContainerRequests.Action request) { + @PathVariable String containerNo, @RequestParam String sourceContainerBookingId, + @Valid @RequestBody ReceivingContainerRequests.Action request) { PdaWarehouseIdentity identity = identity(user); return ApiResponse.success(service.prepare(identity.operatorKey(), identity.operatorName(), identity.overseasWarehouseId(), - containerNo, request)); + containerNo, sourceContainerBookingId, request)); } @PostMapping(value = "/current/unload-photos/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) 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 2670248de..3f1e70f3b 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 @@ -62,6 +62,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; @Service @@ -142,14 +143,16 @@ public class ReceivingContainerService { .map(value -> candidate(value, current)).toList(); } - public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo) { - requireReadyContainer(containerNo, overseasWarehouseId, false); - List containerPlans = requirePlans(containerNo); + public ReceivingContainerResponses.Session detail(String operatorKey, long overseasWarehouseId, String containerNo, + String sourceContainerBookingId) { + requireReadyContainer(containerNo, sourceContainerBookingId, overseasWarehouseId, false); + List containerPlans = requirePlans(sourceContainerBookingId, containerNo); requireMaterializedPlans(containerPlans); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); requireSessionWarehouse(current, overseasWarehouseId); - if (current != null && !containerNo.equals(current.getContainerNo())) { + if (current != null && (!containerNo.equals(current.getContainerNo()) + || !sourceContainerBookingId.equals(current.getSourceContainerBookingId()))) { throw conflict("Current account is locked to another selected container"); } ReceivingContainerSession reserved = sessions.findActiveByContainer(activeContainerKey(containerNo)); @@ -158,15 +161,17 @@ public class ReceivingContainerService { } WarehouseResourceGateway.WarehouseReference receivingWarehouse = warehouse.requireActiveSharedWarehouse(overseasWarehouseId); - return snapshot(current != null && containerNo.equals(current.getContainerNo()) ? current : waitingView( - operatorKey, receivingWarehouse, containerNo), containerPlans); + return snapshot(current != null && containerNo.equals(current.getContainerNo()) + && sourceContainerBookingId.equals(current.getSourceContainerBookingId()) ? current : waitingView( + operatorKey, receivingWarehouse, sourceContainerBookingId, containerNo), containerPlans); } @Transactional public ReceivingContainerResponses.Session prepare(String operatorKey, String operatorName, long overseasWarehouseId, String containerNo, + String sourceContainerBookingId, ReceivingContainerRequests.Action request) { - PrepareCommand command = new PrepareCommand(containerNo, request.idempotencyKey()); - ContainerReadiness readiness = requireReadyContainer(containerNo, overseasWarehouseId, true); + PrepareCommand command = new PrepareCommand(sourceContainerBookingId, containerNo, request.idempotencyKey()); + ContainerReadiness readiness = requireReadyContainer(containerNo, sourceContainerBookingId, overseasWarehouseId, true); ReceivingContainerOperation replay = replay(operatorKey, "PREPARE", request.idempotencyKey(), command); if (replay != null) { ReceivingContainerResponses.Session result = read(replay, ReceivingContainerResponses.Session.class); @@ -175,13 +180,14 @@ public class ReceivingContainerService { } return result; } - List containerPlans = requirePlans(containerNo); + List containerPlans = requirePlans(sourceContainerBookingId, containerNo); requireMaterializedPlans(containerPlans); if (!warehouseEligible(containerPlans, overseasWarehouseId)) throw conflict("当前柜已分配至其他海外接柜仓,无法在本仓操作"); String activeOperatorKey = activeOperatorKey(operatorKey); ReceivingContainerSession active = sessions.lockActiveByOperator(activeOperatorKey); requireSessionWarehouse(active, overseasWarehouseId); - if (active != null && !containerNo.equals(active.getContainerNo())) { + if (active != null && (!containerNo.equals(active.getContainerNo()) + || !sourceContainerBookingId.equals(active.getSourceContainerBookingId()))) { throw conflict("Finish the current container before selecting another one"); } if (active == null && sessions.lockActiveByContainer(activeContainerKey(containerNo)) != null) { @@ -229,7 +235,7 @@ public class ReceivingContainerService { List photos = validateSignPhotos(request); List photoFileIds = photos.stream().map(photo -> positiveFileId(photo.fileId())).toList(); photoFileIds.forEach(fileId -> unloadPhotos.validate(PLATFORM_SCOPE, session.getId(), fileId)); - List containerPlans = requirePlans(session.getContainerNo()); + List containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo()); requireMaterializedPlans(containerPlans); LocalDateTime now = LocalDateTime.now(); session.setSignPhotos(write(photos)); session.setStatus("SIGNED"); session.setSignedAt(now); @@ -243,7 +249,7 @@ public class ReceivingContainerService { public ReceivingContainerResponses.Session current(String operatorKey) { ReceivingContainerSession session = requireCurrent(operatorKey, false); - return snapshot(session, requirePlans(session.getContainerNo())); + return snapshot(session, requirePlans(session.getSourceContainerBookingId(), session.getContainerNo())); } public UnloadPhotoFileGateway.DownloadedFile downloadCurrentPhoto(String operatorKey, String fileId) { @@ -276,10 +282,11 @@ public class ReceivingContainerService { public ReceivingContainerResponses.CartonResolved resolveCarton(String operatorKey, ReceivingContainerRequests.CartonResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); - List currentRefs = refs(session.getContainerNo()); + List currentRefs = refs(session.getSourceContainerBookingId(), session.getContainerNo()); String sourcePalletNo = dropshipSourcePallet(currentRefs, session.getContainerNo(), request.cartonMark()); if (sourcePalletNo != null) { - List palletRefs = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo); + List palletRefs = dropshipPalletRefs( + session.getSourceContainerBookingId(), session.getContainerNo(), sourcePalletNo); requireCompleteManifest(session.getContainerNo(), sourcePalletNo); List group = palletRefs.stream().map(DropshipPalletRef::ref).distinct().toList(); InboundRef first = group.get(0); @@ -306,14 +313,16 @@ public class ReceivingContainerService { } carton = new CartonRef(matches.get(0)); } else if (matches.size() == 1) { - carton = requireCartonRef(matches.get(0).order().getCustomerId(), session.getContainerNo(), request.cartonMark()); + carton = requireCartonRef(matches.get(0).order().getCustomerId(), + session.getSourceContainerBookingId(), session.getContainerNo(), request.cartonMark()); } else { carton = requireUnmatchedFbaCartonPrefix(inboundCode); } InboundRef ref = carton.ref(); if ("FBA".equals(destinationType(ref.line(), ref.order())) && !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) { - List group = requireFbaPlanRefs(session.getContainerNo(), ref); + List group = requireFbaPlanRefs( + session.getSourceContainerBookingId(), session.getContainerNo(), ref); List inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList(); int total = group.stream().mapToInt(value -> value(value.line().getRequiredPackageCount())).sum(); int received = group.stream().mapToInt(this::receivedLinePackages).sum(); @@ -339,10 +348,11 @@ public class ReceivingContainerService { public ReceivingContainerResponses.PalletResolved resolvePallet(String operatorKey, ReceivingContainerRequests.PalletResolve request) { ReceivingContainerSession session = requireSigned(operatorKey); - InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); + InboundRef ref = requireInbound(session.getSourceContainerBookingId(), + session.getContainerNo(), request.inboundCode()); List group = "FBA".equals(destinationType(ref.line(), ref.order())) && !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType()) - ? requireFbaPlanRefs(session.getContainerNo(), ref) : List.of(ref); + ? requireFbaPlanRefs(session.getSourceContainerBookingId(), session.getContainerNo(), ref) : List.of(ref); PalletSelection selection = group.size() == 1 && !"FBA".equals(destinationType(ref.line(), ref.order())) ? resolveReceivingPallet(ref, request.palletCode(), false) : resolveFbaGroupPallet(group, request.palletCode()); WarehouseResourceGateway.WarehousePalletReference pallet = selection.resource(); @@ -369,14 +379,16 @@ public class ReceivingContainerService { if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); requirePalletCapacity(request.packageCount()); ReceivingContainerSession session = requireSigned(operatorKey); - InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode()); + InboundRef ref = requireInbound(session.getSourceContainerBookingId(), + session.getContainerNo(), request.inboundCode()); if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) { throw conflict("One-click dropship inbound must be operated by complete source pallet"); } if (!"FBA".equals(destinationType(ref.line(), ref.order()))) { throw conflict("OVERSEAS destination must use carton-by-carton scanning; bulk count is forbidden"); } - List fbaPlanRefs = requireFbaPlanRefs(session.getContainerNo(), ref); + List fbaPlanRefs = requireFbaPlanRefs( + session.getSourceContainerBookingId(), session.getContainerNo(), ref); PalletSelection selection = resolveFbaGroupPallet(fbaPlanRefs, request.palletCode()); WarehouseResourceGateway.WarehousePalletReference resource = selection.resource(); InboundPlanPallet pallet = selection.pallet(); @@ -451,15 +463,16 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "CARTON_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); ReceivingContainerSession session = requireSigned(operatorKey); - List currentRefs = refs(session.getContainerNo()); + List currentRefs = refs(session.getSourceContainerBookingId(), session.getContainerNo()); if (currentRefs.stream().noneMatch(value -> "OVERSEAS".equals(destinationType(value.line(), value.order())))) { throw conflict("FBA destination must use bulk count"); } String inboundCode = cartonInboundCode(request.cartonMark()); - InboundRef inboundRef = requireInbound(session.getContainerNo(), inboundCode); + InboundRef inboundRef = requireInbound(session.getSourceContainerBookingId(), session.getContainerNo(), inboundCode); String customerId = inboundRef.order().getCustomerId(); - CartonRef carton = requireCartonRef(customerId, session.getContainerNo(), request.cartonMark()); + CartonRef carton = requireCartonRef(customerId, session.getSourceContainerBookingId(), + session.getContainerNo(), request.cartonMark()); InboundRef ref = carton.ref(); if (!"OVERSEAS".equals(destinationType(ref.line(), ref.order()))) { throw conflict("FBA destination must use bulk count"); @@ -496,10 +509,12 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "PALLET_RECEIVE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class); ReceivingContainerSession session = requireSigned(operatorKey); - String sourcePalletNo = dropshipSourcePallet(session.getContainerNo(), request.sourcePalletNo()); + String sourcePalletNo = dropshipSourcePallet( + session.getSourceContainerBookingId(), session.getContainerNo(), request.sourcePalletNo()); if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container"); requireCompleteManifest(session.getContainerNo(), sourcePalletNo); - List matches = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo); + List matches = dropshipPalletRefs( + session.getSourceContainerBookingId(), session.getContainerNo(), sourcePalletNo); int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum(); requirePalletCapacity(total); boolean alreadyReceived = matches.stream().allMatch(match -> @@ -538,17 +553,17 @@ public class ReceivingContainerService { ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); ReceivingContainerSession session = requireSigned(operatorKey); - List containerPlans = requirePlans(session.getContainerNo()); - Totals totals = totals(containerPlans); + List containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo()); + Totals totals = totals(session, containerPlans); boolean hasDifference = totals.total() != totals.received() || hasFbaOverReceipt(session, containerPlans); if (hasDifference && !request.allowDifference()) { throw conflict("Container has a receiving difference; confirm onsite completion with difference"); } if (!hasDifference) { - validateFbaSkuBalance(session.getContainerNo()); + validateFbaSkuBalance(session.getSourceContainerBookingId(), session.getContainerNo()); workflow.completeContainer(containerPlans); } else { - workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(containerPlans)); + workflow.completeContainerWithDifferences(containerPlans, reconciledPlans(session, containerPlans)); } LocalDateTime now = LocalDateTime.now(); session.setStatus(hasDifference ? "COMPLETED_WITH_DIFFERENCE" : "COMPLETED"); @@ -558,23 +573,24 @@ public class ReceivingContainerService { save(session, "COMPLETE", request.idempotencyKey(), request, result); return result; } - private List reconciledPlans(List containerPlans) { - Set complete = refs(containerPlans.get(0).getContainerNo()).stream() + private List reconciledPlans(ReceivingContainerSession session, List containerPlans) { + List currentRefs = refs(session.getSourceContainerBookingId(), session.getContainerNo()); + Set complete = currentRefs.stream() .collect(Collectors.groupingBy(value -> value.plan().getId(), LinkedHashMap::new, Collectors.toList())) .entrySet().stream().filter(entry -> entry.getValue().stream().allMatch(ref -> receivedLinePackages(ref) == value(ref.line().getRequiredPackageCount()))) .map(Map.Entry::getKey).collect(Collectors.toSet()); - Set incompleteFbaGroups = refs(containerPlans.get(0).getContainerNo()).stream() + Set incompleteFbaGroups = currentRefs.stream() .filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .filter(ref -> !complete.contains(ref.plan().getId())) .map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).collect(Collectors.toSet()); - refs(containerPlans.get(0).getContainerNo()).stream() + currentRefs.stream() .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .filter(ref -> fbaGroupHasOverReceipt(findSession(containerPlans.get(0).getContainerNo()), ref)) .map(ref -> normalizedFba(ref.line().getFbaWarehouseCode())).forEach(incompleteFbaGroups::add); return containerPlans.stream().filter(plan -> complete.contains(plan.getId())) - .filter(plan -> refs(plan.getContainerNo()).stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId())) + .filter(plan -> currentRefs.stream().filter(ref -> Objects.equals(ref.plan().getId(), plan.getId())) .noneMatch(ref -> "FBA".equals(destinationType(ref.line(), ref.order())) && incompleteFbaGroups.contains(normalizedFba(ref.line().getFbaWarehouseCode())))) .toList(); @@ -589,22 +605,23 @@ public class ReceivingContainerService { return session; } - private ContainerReadiness requireReadyContainer(String containerNo, long overseasWarehouseId, boolean lock) { + private ContainerReadiness requireReadyContainer(String containerNo, String sourceContainerBookingId, + long overseasWarehouseId, boolean lock) { + String bookingId = trim(sourceContainerBookingId); + if (bookingId == null) throw conflict("sourceContainerBookingId is required"); LambdaQueryWrapper query = new LambdaQueryWrapper() - .eq(LastlegOrder::getContainerNo, containerNo).orderByAsc(LastlegOrder::getId); + .eq(LastlegOrder::getContainerNo, containerNo) + .eq(LastlegOrder::getSourceContainerBookingId, bookingId) + .orderByAsc(LastlegOrder::getId); if (lock) query.last("FOR UPDATE"); List orderFacts = orders.selectList(query); - Set bookingIds = orderFacts.stream().map(LastlegOrder::getSourceContainerBookingId) - .map(ReceivingContainerService::trim).filter(Objects::nonNull).collect(Collectors.toSet()); - if (orderFacts.isEmpty() || bookingIds.size() != 1) { - throw conflict("Container must resolve exactly one sourceContainerBookingId"); - } + if (orderFacts.isEmpty()) throw notFound("当前柜次来源与集装箱号不匹配"); List planFacts = plans.selectList(new LambdaQueryWrapper() .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); List lineFacts = lines.selectList(new LambdaQueryWrapper() .in(InboundPlanLine::getOrderId, orderFacts.stream().map(LastlegOrder::getId).toList()) .orderByAsc(InboundPlanLine::getId)); - ContainerReadiness value = readiness(bookingIds.iterator().next(), orderFacts, planFacts, lineFacts, + ContainerReadiness value = readiness(bookingId, orderFacts, planFacts, lineFacts, overseasWarehouseId); if (value == null || !value.operable()) { throw conflict(value == null ? "当前柜已分配至其他海外接柜仓,无法在本仓操作" : value.blockReason()); @@ -614,7 +631,8 @@ public class ReceivingContainerService { private void requireStableSessionFacts(ReceivingContainerSession session, boolean lock) { if (trim(session.getContainerFactHash()) == null) return; - ContainerReadiness current = requireReadyContainer(session.getContainerNo(), session.getOverseasWarehouseId(), lock); + ContainerReadiness current = requireReadyContainer(session.getContainerNo(), + session.getSourceContainerBookingId(), session.getOverseasWarehouseId(), lock); if (!Objects.equals(session.getSourceContainerBookingId(), current.sourceContainerBookingId()) || !Objects.equals(session.getContainerFactHash(), current.factHash())) { throw conflict("Container orders or inbound plans changed after prepare; restart the receiving session"); @@ -657,10 +675,8 @@ public class ReceivingContainerService { int planCount = matchedPlans.size(); boolean allPlansReady = totalOrders > 0 && inboundCodes == totalOrders && plannedOrders == totalOrders && planCount == totalOrders; - List containerPlans = planFacts.stream() - .filter(plan -> containerNo.equals(plan.getContainerNo())).toList(); - boolean allPlansCompleted = !containerPlans.isEmpty() - && containerPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") + boolean allPlansCompleted = !matchedPlans.isEmpty() + && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") .contains(plan.getStatus())); boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; boolean operable = allPlansReady && shippingReady && warehouseSnapshotConsistent; @@ -716,9 +732,12 @@ public class ReceivingContainerService { return session; } - private List requirePlans(String containerNo) { - List values = plans.selectList(new LambdaQueryWrapper() - .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); + private List requirePlans(String sourceContainerBookingId, String containerNo) { + List values = refs(sourceContainerBookingId, containerNo).stream() + .map(InboundRef::plan) + .collect(Collectors.toMap(InboundPlan::getId, Function.identity(), (left, right) -> left, + LinkedHashMap::new)) + .values().stream().toList(); if (values.isEmpty()) throw notFound("Receiving container was not found"); if (values.stream().anyMatch(value -> trim(value.getCustomerId()) == null)) { throw conflict("Every inbound plan in the receiving container must have an owning customer"); @@ -730,32 +749,31 @@ public class ReceivingContainerService { return values; } - private List plansForContainer(String containerNo) { - List values = plans.selectList(new LambdaQueryWrapper() - .eq(InboundPlan::getContainerNo, containerNo) - .orderByAsc(InboundPlan::getId)); - if (values.isEmpty()) throw notFound("Receiving container was not found"); - return values; - } - - private List refs(String containerNo) { + private List refs(String sourceContainerBookingId, String containerNo) { List result = new ArrayList<>(); - for (InboundPlan plan : plansForContainer(containerNo)) { + List containerPlans = plans.selectList(new LambdaQueryWrapper() + .eq(InboundPlan::getContainerNo, containerNo).orderByAsc(InboundPlan::getId)); + for (InboundPlan plan : containerPlans) { String customerId = trim(plan.getCustomerId()); if (customerId == null) throw conflict("Inbound plan has no owning customer"); for (InboundPlanLine line : lines.selectList(new LambdaQueryWrapper() .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"); + if (order == null || !customerId.equals(order.getCustomerId()) + || !Objects.equals(containerNo, trim(order.getContainerNo()))) { + throw conflict("Inbound line order is missing or belongs to another cabinet occurrence"); + } + if (!Objects.equals(sourceContainerBookingId, trim(order.getSourceContainerBookingId()))) continue; result.add(new InboundRef(plan, line, order)); } } + if (result.isEmpty()) throw notFound("Receiving container was not found"); return result; } - private InboundRef requireInbound(String containerNo, String inboundCode) { - List matches = refs(containerNo).stream() + private InboundRef requireInbound(String sourceContainerBookingId, String containerNo, String inboundCode) { + List matches = refs(sourceContainerBookingId, containerNo).stream() .filter(value -> inboundCode.equals(value.line().getInboundCode())).toList(); if (matches.isEmpty()) throw notFound("Inbound code is not part of the current container"); Set planIds = matches.stream().map(value -> value.plan().getId()).collect(Collectors.toSet()); @@ -763,11 +781,12 @@ public class ReceivingContainerService { return matches.stream().min(Comparator.comparing(value -> value.line().getId())).orElseThrow(); } - private CartonRef requireCartonRef(String customerId, String containerNo, String cartonMark) { + private CartonRef requireCartonRef(String customerId, String sourceContainerBookingId, + 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(containerNo).stream() + List matches = refs(sourceContainerBookingId, containerNo).stream() .filter(value -> customerId.equals(value.order().getCustomerId())) .filter(value -> carton.getLineId() == null || Objects.equals(value.line().getId(), carton.getLineId())) .filter(value -> carton.getPlanId() == null || Objects.equals(value.plan().getId(), carton.getPlanId())) @@ -791,8 +810,9 @@ public class ReceivingContainerService { throw notFound("Carton mark was not found"); } - private List requireFbaPlanRefs(String containerNo, InboundRef selected) { - List values = refs(containerNo).stream() + private List requireFbaPlanRefs(String sourceContainerBookingId, + String containerNo, InboundRef selected) { + List values = refs(sourceContainerBookingId, containerNo).stream() .filter(value -> !"ONE_CLICK_DROPSHIP".equals(value.order().getOrderType())) .filter(value -> "FBA".equals(destinationType(value.line(), value.order()))) .filter(value -> Objects.equals(normalizedFba(value.line().getFbaWarehouseCode()), @@ -940,8 +960,8 @@ public class ReceivingContainerService { return Objects.equals(line.getDestinationWarehouseCode(), pallet.getDestinationWarehouseCode()); } - private String dropshipSourcePallet(String containerNo, String scanCode) { - return dropshipSourcePallet(refs(containerNo), containerNo, scanCode); + private String dropshipSourcePallet(String sourceContainerBookingId, String containerNo, String scanCode) { + return dropshipSourcePallet(refs(sourceContainerBookingId, containerNo), containerNo, scanCode); } private String dropshipSourcePallet(List currentRefs, String containerNo, String scanCode) { @@ -960,9 +980,10 @@ public class ReceivingContainerService { return resolved.isEmpty() ? null : resolved.get(0); } - private List dropshipPalletRefs(String containerNo, String sourcePalletNo) { + private List dropshipPalletRefs(String sourceContainerBookingId, + String containerNo, String sourcePalletNo) { List result = new ArrayList<>(); - for (InboundRef ref : refs(containerNo)) { + for (InboundRef ref : refs(sourceContainerBookingId, containerNo)) { if (!"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue; List sourcePallets = pallets.selectList(new LambdaQueryWrapper() .eq(InboundPlanPallet::getCustomerId, ref.order().getCustomerId()) @@ -1009,7 +1030,8 @@ public class ReceivingContainerService { InboundPlanPalletAllocation allocation) { } private ReceivingContainerResponses.Session snapshot(ReceivingContainerSession session, List containerPlans) { - List inbounds = refs(session.getContainerNo()).stream() + List inbounds = refs( + session.getSourceContainerBookingId(), session.getContainerNo()).stream() .map(this::inbound).toList(); int total = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::totalPackages).sum(); int received = inbounds.stream().mapToInt(ReceivingContainerResponses.Inbound::receivedPackages).sum(); @@ -1065,7 +1087,7 @@ public class ReceivingContainerService { private boolean hasFbaOverReceipt(ReceivingContainerSession session, List containerPlans) { if (session == null || containerPlans == null || containerPlans.isEmpty()) return false; - return refs(session.getContainerNo()).stream() + return refs(session.getSourceContainerBookingId(), session.getContainerNo()).stream() .filter(ref -> !"ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) .filter(ref -> "FBA".equals(destinationType(ref.line(), ref.order()))) .anyMatch(ref -> fbaGroupHasOverReceipt(session, ref)); @@ -1073,7 +1095,7 @@ public class ReceivingContainerService { private boolean fbaGroupHasOverReceipt(ReceivingContainerSession session, InboundRef ref) { if (session == null || session.getId() == null) return false; - int planned = requireFbaPlanRefs(session.getContainerNo(), ref).stream() + int planned = requireFbaPlanRefs(session.getSourceContainerBookingId(), session.getContainerNo(), ref).stream() .mapToInt(value -> value(value.line().getRequiredPackageCount())).sum(); return fbaPalletCounts.sumActual(session.getId(), fbaGroupKey(session.getContainerNo(), ref)) > planned; } @@ -1115,8 +1137,9 @@ public class ReceivingContainerService { return mergeAllowed(plan) ? PALLET_MERGE_HINT : null; } - private Totals totals(List containerPlans) { - List values = refs(containerPlans.get(0).getContainerNo()) + private Totals totals(ReceivingContainerSession session, List containerPlans) { + List values = refs( + session.getSourceContainerBookingId(), session.getContainerNo()) .stream().map(this::inbound).toList(); List destinationCodes = values.stream().map(this::destinationCode) .filter(Objects::nonNull).distinct().sorted().toList(); @@ -1246,8 +1269,8 @@ public class ReceivingContainerService { } } - private void validateFbaSkuBalance(String containerNo) { - for (InboundRef ref : refs(containerNo)) { + private void validateFbaSkuBalance(String sourceContainerBookingId, String containerNo) { + for (InboundRef ref : refs(sourceContainerBookingId, containerNo)) { if (!"FBA".equals(destinationType(ref.line(), ref.order()))) continue; if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) continue; String customerId = ref.order().getCustomerId(); @@ -1296,12 +1319,14 @@ public class ReceivingContainerService { private ReceivingContainerSession waitingView(String operatorKey, WarehouseResourceGateway.WarehouseReference warehouse, + String sourceContainerBookingId, String containerNo) { ReceivingContainerSession value = new ReceivingContainerSession(); value.setOverseasWarehouseId(warehouse.warehouseId()); value.setOverseasWarehouseCode(warehouse.warehouseCode()); value.setOverseasWarehouseName(warehouse.warehouseName()); - value.setOperatorKey(operatorKey); value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); + value.setOperatorKey(operatorKey); value.setSourceContainerBookingId(sourceContainerBookingId); + value.setContainerNo(containerNo); value.setStatus("WAIT_SIGN"); value.setVersion(0); return value; } @@ -1411,5 +1436,5 @@ public class ReceivingContainerService { int planCount, int plannedInboundCodeCount, int pendingPlanCount, boolean allPlansReady, boolean allPlansCompleted, boolean shippingReady, boolean operable, String blockReason, LocalDateTime plannedArrivalAt, String factHash, Totals totals) { } - private record PrepareCommand(String containerNo, String idempotencyKey) { } + private record PrepareCommand(String sourceContainerBookingId, 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 689745678..c0d00b457 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -42,16 +42,20 @@ paths: /api/pda/v1/last-leg/receiving-containers/{containerNo}: get: summary: 查询待签收柜号详情 - description: 权限 lastleg:pda:operate;按来源柜返回跨客户的全部入仓码、目的类型、目的仓、托数和实收进度;每个入仓码仍按自身客户隔离。 + description: 权限 lastleg:pda:operate;sourceContainerBookingId 与真实集装箱号共同锁定本次柜次,允许物理集装箱号在不同柜次重复使用;按来源柜返回跨客户的全部入仓码、目的类型、目的仓、托数和实收进度。 security: [{ bearerAuth: [] }] - parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + parameters: + - { name: containerNo, in: path, required: true, schema: { type: string } } + - { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } responses: { '200': { description: 柜号详情, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 当前账号已锁定其他柜号 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}/prepare: post: summary: 选择当前待签收柜号 - description: 权限 lastleg:pda:operate;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、柜号全局双唯一键防止切柜或多人同时处理。 + description: 权限 lastleg:pda:operate;sourceContainerBookingId 与真实集装箱号共同锁定本次柜次;仅 RECEIVING_AVAILABLE 柜可建立 WAIT_SIGN 会话,并以平台操作账号、当前作业柜全局双唯一键防止切柜或多人同时处理。 security: [{ bearerAuth: [] }] - parameters: [{ name: containerNo, in: path, required: true, schema: { type: string } }] + parameters: + - { name: containerNo, in: path, required: true, schema: { type: string } } + - { name: sourceContainerBookingId, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingAction' } } } } responses: { '200': { description: 当前柜会话, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingSessionApiResponse' } } } }, '409': { description: 账号或柜号已有未完成会话 } } /api/pda/v1/last-leg/receiving-containers/current/unload-photos/files: 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 7350daba5..7aa26fc05 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 @@ -117,7 +117,7 @@ class ReceivingContainerServiceBehaviorTest { when(shippingGates.receivingAvailable("BOOKING-A", "CONT-A")).thenReturn(0); assertThat(service.candidates("7", 100L)).isEmpty(); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A", new ReceivingContainerRequests.Action("not-ready"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); @@ -133,7 +133,7 @@ class ReceivingContainerServiceBehaviorTest { when(sessions.insert(any(ReceivingContainerSession.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); - var response = service.prepare("7", "receiver", 100L, "CONT-A", + var response = service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A", new ReceivingContainerRequests.Action("same-key")); assertThat(response.overseasWarehouseId()).isEqualTo(100L); assertThat(response.overseasWarehouseName()).isEqualTo("Shared LAX"); @@ -145,7 +145,7 @@ class ReceivingContainerServiceBehaviorTest { verify(operations).insert(saved.capture()); when(operations.find("7", "same-key")).thenReturn(saved.getValue()); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-A", new ReceivingContainerRequests.Action("same-key"))).isInstanceOf(ResponseStatusException.class) .hasMessageContaining("409"); } @@ -249,13 +249,44 @@ class ReceivingContainerServiceBehaviorTest { when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); var candidate = service.candidates("7", 100L).get(0); - var detail = service.detail("7", 100L, "TSTU0000204"); + var detail = service.detail("7", 100L, "TSTU0000204", "BOOKING-204"); assertThat(candidate.operable()).isTrue(); assertThat(detail.overseasWarehouseId()).isEqualTo(100L); assertThat(detail.overseasWarehouseCode()).isEqualTo("SHARED-LAX"); } + @Test + void detailUsesBookingOccurrenceWhenPhysicalContainerNumberIsReused() { + InboundPlan historicalPlan = plan("VAXU2748524"); + historicalPlan.setId(2L); historicalPlan.setPlanNo("PLAN-OLD"); historicalPlan.setStatus("COMPLETED"); + InboundPlan currentPlan = plan("VAXU2748524"); + currentPlan.setId(3L); currentPlan.setPlanNo("PLAN-CURRENT"); + InboundPlanLine historicalLine = line("FBA"); + historicalLine.setId(10L); historicalLine.setPlanId(2L); historicalLine.setOrderId(1L); + historicalLine.setInboundCode("IN-OLD"); + InboundPlanLine currentLine = line("FBA"); + currentLine.setId(11L); currentLine.setPlanId(3L); currentLine.setOrderId(2L); + currentLine.setInboundCode("IN-CURRENT"); + LastlegOrder historicalOrder = containerOrder( + 1L, "100", "VAXU2748524", "BOOKING-OLD", "IN-OLD"); + LastlegOrder currentOrder = containerOrder( + 2L, "100", "VAXU2748524", "BOOKING-CURRENT", "IN-CURRENT"); + when(plans.selectList(any())).thenReturn(List.of(historicalPlan, currentPlan)); + when(lines.selectList(any())).thenReturn( + List.of(currentLine), + List.of(historicalLine), List.of(currentLine), + List.of(historicalLine), List.of(currentLine)); + when(orders.selectList(any())).thenReturn(List.of(currentOrder)); + when(orders.selectById(1L)).thenReturn(historicalOrder); + when(orders.selectById(2L)).thenReturn(currentOrder); + when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); + + var detail = service.detail("7", 100L, "VAXU2748524", "BOOKING-CURRENT"); + + assertThat(detail.inbounds()).extracting("inboundCode").containsExactly("IN-CURRENT"); + } + @Test void prepareAtomicallyBindsUnassignedPlansToThePickerWarehouse() { InboundPlan unassigned = plan("TSTU0195472"); @@ -269,7 +300,7 @@ class ReceivingContainerServiceBehaviorTest { when(orders.selectById(1L)).thenReturn(order); when(warehouse.requireActiveSharedWarehouse(100L)).thenReturn(warehouseReference()); - var result = service.prepare("7", "receiver", 100L, "TSTU0195472", + var result = service.prepare("7", "receiver", 100L, "TSTU0195472", "BOOKING-472", new ReceivingContainerRequests.Action("bind-warehouse")); assertThat(result.overseasWarehouseId()).isEqualTo(100L); @@ -285,9 +316,10 @@ class ReceivingContainerServiceBehaviorTest { when(plans.selectList(any())).thenReturn(List.of(plan)); when(lines.selectList(any())).thenReturn(List.of(planLine)); when(orders.selectList(any())).thenReturn(List.of(order)); + when(orders.selectById(1L)).thenReturn(order); when(pallets.selectCount(any())).thenReturn(0L); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "TSTU0195472", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "TSTU0195472", "BOOKING-472", new ReceivingContainerRequests.Action("missing-real-pallets"))) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("尚未完整绑定真实托盘"); @@ -339,7 +371,7 @@ class ReceivingContainerServiceBehaviorTest { assertThat(candidate.pendingPlanCount()).isEqualTo(1); assertThat(candidate.operable()).isFalse(); assertThat(candidate.blockReason()).isEqualTo("1个入仓码已生成计划,1个订单未操作/缺入仓码"); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-A", "BOOKING-A", new ReceivingContainerRequests.Action("partial"))) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); @@ -351,12 +383,14 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); when(orders.selectList(any())).thenReturn(List.of( containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); + when(orders.selectById(1L)).thenReturn( + containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1")); ReceivingContainerSession other = session("CONT-B", "SIGNED"); other.setOperatorKey("8"); other.setActiveOperatorKey("OPERATOR:8"); other.setActiveContainerKey("CONTAINER:CONT-B"); when(sessions.findActiveByContainer("CONTAINER:CONT-B")).thenReturn(other); - assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) + assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B", "BOOKING-B")) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); verifyNoInteractions(warehouse); } @@ -367,8 +401,10 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); when(orders.selectList(any())).thenReturn(List.of( containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"))); + when(orders.selectById(1L)).thenReturn( + containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1")); when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B")) + assertThatThrownBy(() -> service.detail("7", 100L, "CONT-B", "BOOKING-B")) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("409"); } @@ -378,14 +414,15 @@ class ReceivingContainerServiceBehaviorTest { when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); LastlegOrder order = containerOrder(1L, "100", "CONT-B", "BOOKING-B", "IN-1"); when(orders.selectList(any())).thenReturn(List.of(order)); + when(orders.selectById(1L)).thenReturn(order); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-B", new ReceivingContainerRequests.Action("account-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); org.mockito.Mockito.reset(sessions, operations); when(sessions.lockActiveByContainer("CONTAINER:CONT-B")).thenReturn(session("CONT-B", "WAIT_SIGN")); - assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", + assertThatThrownBy(() -> service.prepare("7", "receiver", 100L, "CONT-B", "BOOKING-B", new ReceivingContainerRequests.Action("container-lock"))).isInstanceOf(ResponseStatusException.class); verify(sessions, never()).insert(any(ReceivingContainerSession.class)); } @@ -675,7 +712,8 @@ class ReceivingContainerServiceBehaviorTest { void signRequiresEachTypedPhotoExactlyOnceAndRestoresRoles() { when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session("CONT-A", "WAIT_SIGN")); when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); - when(lines.selectList(any())).thenReturn(List.of()); + when(lines.selectList(any())).thenReturn(List.of(line("FBA"))); + when(orders.selectById(1L)).thenReturn(order("FBA")); when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1); when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1); ReceivingContainerRequests.Sign request = new ReceivingContainerRequests.Sign(List.of( @@ -1046,9 +1084,12 @@ class ReceivingContainerServiceBehaviorTest { InboundPlan plan = plan(containerNo); InboundPlanLine line = line(destinationType); LastlegOrder order = order(destinationType); + order.setContainerNo(containerNo); order.setSourceContainerBookingId("BOOKING-A"); + order.setInboundCode("IN-1"); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED")); when(plans.selectList(any())).thenReturn(List.of(plan)); when(lines.selectList(any())).thenReturn(List.of(line)); + org.mockito.Mockito.lenient().when(orders.selectList(any())).thenReturn(List.of(order)); when(orders.selectById(1L)).thenReturn(order); } @@ -1076,6 +1117,8 @@ class ReceivingContainerServiceBehaviorTest { private static LastlegOrder order(String destinationType) { LastlegOrder value = new LastlegOrder(); value.setId(1L); value.setCustomerId("100"); value.setDestinationType(destinationType); + value.setContainerNo("CONT-A"); value.setSourceContainerBookingId("BOOKING-A"); + value.setInboundCode("IN-1"); value.setOverseasWarehouseId("100"); value.setOverseasWarehouseCode("SHARED-LAX"); value.setOverseasWarehouseName("Shared LAX"); value.setFbaWarehouseCode("FBA".equals(destinationType) ? "FBA-1" : null); @@ -1100,6 +1143,7 @@ class ReceivingContainerServiceBehaviorTest { private static ReceivingContainerSession session(String containerNo, String status) { ReceivingContainerSession value = new ReceivingContainerSession(); value.setId(8L); value.setContainerNo(containerNo); value.setOperatorKey("7"); + value.setSourceContainerBookingId("BOOKING-A"); value.setOverseasWarehouseId(100L); value.setOverseasWarehouseCode("SHARED-LAX"); value.setOverseasWarehouseName("Shared LAX"); value.setStatus(status); value.setVersion(0);