From 01675b8220857ba87a92f9f31b43f6caaf11bc50 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 24 Aug 2026 17:12:30 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/DropshipPalletSnapshot.java | 8 ++ .../service/DropshipOrderService.java | 34 ++++-- .../pda/controller/InboundPdaController.java | 10 ++ .../inboundplan/pda/dto/PdaRequests.java | 3 + .../inboundplan/pda/dto/PdaResponses.java | 17 ++- .../pda/repository/PdaWorkflowRepository.java | 5 + .../service/InboundPdaWorkflowService.java | 99 +++++++++++++++-- .../service/InboundStockReceiptService.java | 24 ++-- .../service/InboundPlanService.java | 103 ++++++++++++++++-- .../service/LoadingCompletedConsumer.java | 5 + .../service/DropshipOrderServiceTest.java | 2 +- .../InboundPdaWorkflowServiceTest.java | 52 +++++++++ .../InboundStockReceiptServiceTest.java | 23 ++++ .../service/InboundPlanServiceTest.java | 21 ++++ .../service/LoadingCompletedConsumerTest.java | 4 +- 15 files changed, 367 insertions(+), 43 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java new file mode 100644 index 000000000..105ae33f1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dropship.model; + +import java.util.List; + +public record DropshipPalletSnapshot(String sourcePalletNo, long packageCount, + List skus) { + public record Sku(String skuCode, String productName, long packageCount, long productQuantity) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java index 6d37fc43f..c8e1e029f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java @@ -7,6 +7,7 @@ import com.baiyue.lastleg.dropship.mapper.*; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -109,6 +110,21 @@ public class DropshipOrderService { return profile; } + @Transactional(readOnly = true) + public List palletSnapshots(String customerId, Long orderId) { + List orderProfiles = profiles.listOrder(customerId, orderId); + Map> skusByProfile = skuInventory.listOrder(customerId, orderId).stream() + .collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId)); + return orderProfiles.stream().map(profile -> { + List skus = skusByProfile.getOrDefault(profile.getId(), List.of()).stream() + .map(sku -> new DropshipPalletSnapshot.Sku(sku.getSkuCode(), sku.getProductName(), + value(sku.getPackageCount()), value(sku.getProductQuantity()))) + .toList(); + return new DropshipPalletSnapshot(profile.getSourcePalletNo(), + skus.stream().mapToLong(DropshipPalletSnapshot.Sku::packageCount).sum(), skus); + }).toList(); + } + @Transactional public void activateAfterStockIn(String customerId, Long orderId, Long taskId, List receipts) { @@ -118,20 +134,18 @@ public class DropshipOrderService { if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) { throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying"); } - Map> receiptsByPallet = receipts.stream() - .filter(receipt -> receipt.getWarehousePalletId() != null) - .collect(Collectors.groupingBy(InboundStockReceipt::getWarehousePalletId)); + Map> receiptsByPallet = receipts.stream() + .filter(receipt -> receipt.getPalletNo() != null) + .collect(Collectors.groupingBy(InboundStockReceipt::getPalletNo)); if (receiptsByPallet.size() != profilesForOrder.size()) { throw conflict("PDA pallet count does not match dropship source pallet count"); } - List orderedProfiles = profilesForOrder.stream() - .sorted(java.util.Comparator.comparing(DropshipOrderProfile::getSourcePalletNo)).toList(); - List> orderedReceipts = receiptsByPallet.values().stream() - .sorted(java.util.Comparator.comparing(value -> value.get(0).getWarehousePalletId())).toList(); LocalDateTime now = LocalDateTime.now(); - for (int index = 0; index < orderedProfiles.size(); index++) { - DropshipOrderProfile profile = orderedProfiles.get(index); - List actual = orderedReceipts.get(index); + for (DropshipOrderProfile profile : profilesForOrder) { + List actual = receiptsByPallet.get(profile.getSourcePalletNo()); + if (actual == null || actual.isEmpty()) { + throw conflict("PDA receipt is missing for dropship source pallet " + profile.getSourcePalletNo()); + } List profileCartons = cartons.listProfile(customerId, profile.getId()); long expected = profileCartons.size(); long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java index 81dd62966..da48dafaf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/InboundPdaController.java @@ -65,6 +65,16 @@ public class InboundPdaController { return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request)); } + @PostMapping("/allocations/{allocationId}/pick-whole-pallet") + public ApiResponse pickWholePallet( + @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, + @PathVariable long allocationId, @RequestParam(required = false) String customerId, + @Valid @RequestBody PdaRequests.WholePalletOperation request) { + requireOperate(user); + return ApiResponse.success(service.pickWholePallet( + scope(user, customerId), planNo, allocationId, request)); + } + @PostMapping("/cartons/scan") public ApiResponse scanCarton(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java index 03d8a246c..d05ea8f4a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java @@ -17,6 +17,9 @@ public final class PdaRequests { public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey, String operatorId, String operatorName) {} + public record WholePalletOperation(@NotBlank String idempotencyKey, + String operatorId, String operatorName) {} + public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount, @NotBlank String idempotencyKey, String operatorId, String operatorName) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java index fd8a92754..20fb0f557 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java @@ -11,7 +11,18 @@ public final class PdaResponses { LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount, - int pickedPackageCount) { + int pickedPackageCount, String orderType, boolean wholePalletInbound, + List dropshipPallets) { + public Task(long taskId, String taskNo, String planNo, String status, + LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, + List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, + LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount, + int pickedPackageCount) { + this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt, + unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt, + expectedPackageCount, pickedPackageCount, null, false, List.of()); + } + public Task(long taskId, String taskNo, String planNo, String status, LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, List unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, @@ -21,6 +32,10 @@ public final class PdaResponses { } } + public record DropshipPallet(String sourcePalletNo, long planPalletId, long allocationId, + long packageCount, List skus) {} + public record DropshipSku(String skuCode, String productName, long packageCount, long productQuantity) {} + public record UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {} public record Operation(String result, long allocationId, int requiredPackageCount, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index 6d8dd4cbe..726cf9054 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -17,6 +17,8 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPalletMergeMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaOperationMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaTaskMapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import org.springframework.stereotype.Repository; import jakarta.annotation.Resource; @@ -32,6 +34,7 @@ public class PdaWorkflowRepository { @Resource private InboundPdaOperationMapper operations; @Resource private InboundPalletMergeMapper merges; @Resource private InboundPlanCartonMapper cartons; + @Resource private LastlegOrderMapper orders; public InboundPlan lockPlan(String customerId, String planNo) { return plans.selectOne(new LambdaQueryWrapper() @@ -92,6 +95,8 @@ public class PdaWorkflowRepository { .eq(InboundPlanPalletAllocation::getPlanId, planId)); } + public LastlegOrder order(Long orderId) { return orderId == null ? null : orders.selectById(orderId); } + public void insert(InboundPdaTask value) { tasks.insert(value); } public void insert(InboundPdaOperation value) { operations.insert(value); } public void insert(InboundPalletMerge value) { merges.insert(value); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 96a4dd50f..228017b3a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -14,6 +14,9 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.entity.LastlegOrder; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.ObjectMapper; @@ -50,6 +53,7 @@ public class InboundPdaWorkflowService { @Resource private InboundStockReceiptService stockReceipts; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Resource private UnloadPhotoFileGateway unloadPhotoFiles; + @Resource private DropshipOrderService dropshipOrders; @Resource private ObjectMapper sourceObjectMapper; private ObjectMapper objectMapper; @@ -209,6 +213,39 @@ public class InboundPdaWorkflowService { return result; } + @Transactional + public PdaResponses.Operation pickWholePallet(String customerId, String planNo, long allocationId, + PdaRequests.WholePalletOperation request) { + InboundPlan plan = requirePlan(customerId, planNo); + requireDropship(plan); + InboundPdaOperation replay = replay(customerId, "PICK_WHOLE_PALLET", request.idempotencyKey(), request); + if (replay != null) return read(replay, PdaResponses.Operation.class); + InboundPdaTask task = requireActiveTask(customerId, plan); + InboundPlanPalletAllocation allocation = requireAllocation(plan.getId(), allocationId); + InboundPlanLine line = requireLine(plan.getId(), allocation.getLineId()); + int planned = value(allocation.getPlannedPackageCount()); + if (planned <= 0) throw conflict("One-click dropship pallet has no planned packages"); + if (value(allocation.getPickedPackageCount()) != 0) { + throw conflict("One-click dropship pallet has already been partially operated"); + } + allocation.setCountedPackageCount(planned); + allocation.setPickedPackageCount(planned); + allocation.setActualVolumeCbm(volume(allocation.getAllocatedVolumeCbm())); + allocation.setStatus("PICKED"); + bump(allocation); + repository.update(allocation); + InboundPlanPallet pallet = requirePallet(plan.getId(), allocation.getPalletId()); + pallet.setActualPackageCount(planned); + pallet.setActualVolumeCbm(volume(allocation.getAllocatedVolumeCbm())); + repository.update(pallet); + startPicking(task, plan, request.operatorId(), request.operatorName()); + PdaResponses.Operation result = operation("WHOLE_PALLET_PICKED", allocation, line, + List.of(), 0, planned, 0); + saveOperation(customerId, task.getId(), allocationId, "PICK_WHOLE_PALLET", + request.idempotencyKey(), request, result); + return result; + } + /** 海外首次扫描箱唛:仅接受本柜国内冻结且已归属本计划的箱。 */ @Transactional public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) { @@ -423,7 +460,8 @@ public class InboundPdaWorkflowService { } } List cartons = repository.cartons(plan.getId()); - if (!cartons.isEmpty() && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) { + if (!isDropship(plan) && !cartons.isEmpty() + && cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) { throw conflict("All expected carton marks must be scanned onto a pallet before stock-in"); } LocalDateTime now = LocalDateTime.now(); @@ -639,6 +677,9 @@ public class InboundPdaWorkflowService { } private void requireLegacyCounting(InboundPlan plan) { + if (isDropship(plan)) { + throw conflict("One-click dropship inbound must be picked by complete source pallet"); + } if (repository.hasCartonLabelPool(plan.getId())) { throw conflict("This inbound plan has a carton manifest and must be operated by carton scanning"); } @@ -708,20 +749,56 @@ public class InboundPdaWorkflowService { volume(allocation.getActualVolumeCbm()), remaining, requested, merge, candidates); } - private PdaResponses.Task task(InboundPdaTask task, String planNo) { - return task(task, planNo, 0, 0); - } - private PdaResponses.Task taskResponse(InboundPdaTask task, InboundPlan plan, String planNo) { int expected = repository.lines(plan.getId()).stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); - int picked = repository.allocations(plan.getId()).stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); - return task(task, planNo, expected, picked); - } - - private PdaResponses.Task task(InboundPdaTask task, String planNo, int expected, int picked) { + List allocations = repository.allocations(plan.getId()); + int picked = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum(); + LastlegOrder order = repository.order(plan.getSourceOrderId()); + String orderType = order == null ? null : order.getOrderType(); + boolean wholePalletInbound = "ONE_CLICK_DROPSHIP".equals(orderType); + Map palletsByNo = wholePalletInbound + ? repository.pallets(plan.getId()).stream().collect(java.util.stream.Collectors.toMap( + InboundPlanPallet::getPalletNo, value -> value)) : Map.of(); + Map allocationsByPallet = wholePalletInbound + ? allocations.stream().collect(java.util.stream.Collectors.toMap( + InboundPlanPalletAllocation::getPalletId, value -> value)) : Map.of(); + List dropshipPallets = wholePalletInbound + ? dropshipOrders.palletSnapshots(task.getCustomerId(), order.getId()).stream() + .map(pallet -> dropshipPallet(pallet, palletsByNo, allocationsByPallet)).toList() : List.of(); return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), - task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked); + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt(), expected, picked, + orderType, wholePalletInbound, dropshipPallets); + } + + private PdaResponses.Task task(InboundPdaTask task, String planNo) { + return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(), + task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), + task.getUnloadPhotoUploadedAt(), task.getEstimatedEmptyReturnAt()); + } + + private PdaResponses.DropshipPallet dropshipPallet(DropshipPalletSnapshot pallet, + Map palletsByNo, + Map allocationsByPallet) { + InboundPlanPallet planPallet = palletsByNo.get(pallet.sourcePalletNo()); + if (planPallet == null) throw conflict("Dropship source pallet is missing from the inbound plan"); + InboundPlanPalletAllocation allocation = allocationsByPallet.get(planPallet.getId()); + if (allocation == null) throw conflict("Dropship source pallet has no inbound allocation"); + return new PdaResponses.DropshipPallet(pallet.sourcePalletNo(), planPallet.getId(), allocation.getId(), + pallet.packageCount(), pallet.skus().stream() + .map(sku -> new PdaResponses.DropshipSku(sku.skuCode(), sku.productName(), + sku.packageCount(), sku.productQuantity())).toList()); + } + + private void requireDropship(InboundPlan plan) { + if (!isDropship(plan)) { + throw conflict("Whole-pallet picking is only available for one-click dropship inbound plans"); + } + } + + private boolean isDropship(InboundPlan plan) { + LastlegOrder order = repository.order(plan.getSourceOrderId()); + return order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType()); } private List unloadPhotoFileIds(InboundPdaTask task) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java index c428261aa..9f826b8f2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java @@ -18,6 +18,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -93,11 +94,24 @@ public class InboundStockReceiptService { } List skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) ? List.of() : skuRemainders(order.getId(), value(line.getRequiredPackageCount())); + Map dropshipPallets = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) + ? dropshipOrders.palletSnapshots(customerId, order.getId()).stream() + .collect(Collectors.toMap(DropshipPalletSnapshot::sourcePalletNo, value -> value)) + : Map.of(); for (InboundPlanPalletAllocation allocation : lineAllocations) { InboundPlanPallet pallet = requirePallet(palletById, allocation); if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { - created.add(persist(customerId, plan, task, line, allocation, pallet, order, - SkuSnapshot.unspecified(value(allocation.getPickedPackageCount())), receivedAt)); + if (value(allocation.getPickedPackageCount()) != value(allocation.getPlannedPackageCount())) { + throw conflict("One-click dropship inbound must receive a complete source pallet"); + } + DropshipPalletSnapshot sourcePallet = dropshipPallets.get(pallet.getPalletNo()); + if (sourcePallet == null || sourcePallet.packageCount() != value(allocation.getPickedPackageCount())) { + throw conflict("One-click dropship source pallet SKU package total does not match PDA receipt"); + } + for (DropshipPalletSnapshot.Sku sku : sourcePallet.skus()) { + created.add(persist(customerId, plan, task, line, allocation, pallet, order, + new SkuSnapshot(sku.skuCode(), sku.productName(), null, sku.packageCount()), receivedAt)); + } } else { long allocationPackages = value(allocation.getPickedPackageCount()); for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) { @@ -279,9 +293,5 @@ public class InboundStockReceiptService { } } - private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) { - private static SkuSnapshot unspecified(long packages) { - return new SkuSnapshot("UNSPECIFIED", null, null, packages); - } - } + private record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index ba081a5c7..27b745e6b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -30,6 +30,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.HashSet; import java.util.stream.Collectors; @Service @@ -62,6 +63,32 @@ public class InboundPlanService { @Transactional public InboundPlanResponse convert(String customerId, Long selectedOrderId, List sourcePalletNos) { + return convert(customerId, selectedOrderId, sourcePalletNos, null); + } + + @Transactional + public InboundPlanResponse convertDropship(String customerId, Long selectedOrderId, + List sourcePallets) { + if (sourcePallets == null || sourcePallets.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship inbound requires source pallets"); + } + HashSet palletNos = new HashSet<>(); + for (WholePalletInput pallet : sourcePallets) { + if (pallet == null || trimToNull(pallet.palletNo()) == null || pallet.packageCount() <= 0) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship source pallet number and package count are required"); + } + if (!palletNos.add(pallet.palletNo().trim())) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Duplicate one-click dropship source pallet number: " + pallet.palletNo()); + } + } + return convert(customerId, selectedOrderId, List.of(), List.copyOf(sourcePallets)); + } + + private InboundPlanResponse convert(String customerId, Long selectedOrderId, List sourcePalletNos, + List wholePallets) { List orders = orderMapper.lockOrder(customerId, selectedOrderId); if (orders == null || orders.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); @@ -91,21 +118,27 @@ public class InboundPlanService { Map orderById = orders.stream() .collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value)); Map lineByOrder = createLines(plan, orders); - PalletAllocator.Result allocation = PalletAllocator.allocate(orders.stream() + PalletAllocator.Result allocation = wholePallets == null ? PalletAllocator.allocate(orders.stream() .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order), - order.packageCount(), order.recommendedPalletCount())) - .toList()); - plan.setPalletCount(allocation.pallets().size()); + order.packageCount(), order.recommendedPalletCount())).toList()) : null; + if (wholePallets != null) validateWholePallets(orders, wholePallets); + plan.setPalletCount(wholePallets == null ? allocation.pallets().size() : wholePallets.size()); planMapper.insert(plan); lineByOrder.values().forEach(lineMapper::insert); - Map persistedPallets = createPallets(plan, allocation, orderById, - sourcePalletNos == null ? List.of() : sourcePalletNos); - createAllocations(plan, allocation, lineByOrder, persistedPallets); - - Map palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy( - PalletAllocator.AllocationDraft::orderId, - Collectors.collectingAndThen(Collectors.mapping(item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size))); + Map palletCounts; + if (wholePallets == null) { + Map persistedPallets = createPallets(plan, allocation, + orderById, sourcePalletNos == null ? List.of() : sourcePalletNos); + createAllocations(plan, allocation, lineByOrder, persistedPallets); + palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy( + PalletAllocator.AllocationDraft::orderId, Collectors.collectingAndThen(Collectors.mapping( + item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size))); + } else { + SourceOrderSnapshot order = orders.get(0); + createWholePallets(plan, order, lineByOrder.get(order.orderId()), wholePallets); + palletCounts = Map.of(order.orderId(), wholePallets.size()); + } for (SourceOrderSnapshot order : orders) { int count = palletCounts.getOrDefault(order.orderId(), 0); InboundPlanLine line = lineByOrder.get(order.orderId()); @@ -120,6 +153,54 @@ public class InboundPlanService { return detail(customerId, plan); } + private void validateWholePallets(List orders, List pallets) { + if (orders.size() != 1) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship inbound plan must belong to one inbound order"); + } + int packages = pallets.stream().mapToInt(WholePalletInput::packageCount).sum(); + if (packages != orders.get(0).packageCount()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "One-click dropship pallet package total must equal inbound order package count"); + } + } + + private void createWholePallets(InboundPlan plan, SourceOrderSnapshot order, InboundPlanLine line, + List sourcePallets) { + BigDecimal remainingVolume = volume(order); + int remainingPackages = order.packageCount(); + for (int index = 0; index < sourcePallets.size(); index++) { + WholePalletInput source = sourcePallets.get(index); + BigDecimal palletVolume = index == sourcePallets.size() - 1 ? remainingVolume + : volume(order).multiply(BigDecimal.valueOf(source.packageCount())) + .divide(BigDecimal.valueOf(order.packageCount()), 4, java.math.RoundingMode.HALF_UP); + if (palletVolume.compareTo(remainingVolume) > 0) palletVolume = remainingVolume; + InboundPlanPallet pallet = new InboundPlanPallet(); + pallet.setId(IdWorker.getId()); pallet.setCustomerId(plan.getCustomerId()); pallet.setPlanId(plan.getId()); + pallet.setPalletNo(source.palletNo().trim()); pallet.setDestinationWarehouseId(order.destinationWarehouseId()); + pallet.setDestinationWarehouseCode(order.destinationWarehouseCode()); + pallet.setDestinationWarehouseName(order.destinationWarehouseName()); + pallet.setCapacityCbm(PalletAllocator.PALLET_CAPACITY_CBM); pallet.setAllocatedVolumeCbm(palletVolume); + pallet.setSequenceNo(index + 1); pallet.setAssignmentStatus("UNASSIGNED"); + pallet.setActualVolumeCbm(BigDecimal.ZERO); pallet.setActualPackageCount(0); palletMapper.insert(pallet); + + InboundPlanPalletAllocation allocation = new InboundPlanPalletAllocation(); + allocation.setId(IdWorker.getId()); allocation.setCustomerId(plan.getCustomerId()); allocation.setPlanId(plan.getId()); + allocation.setLineId(line.getId()); allocation.setPalletId(pallet.getId()); + allocation.setAllocatedVolumeCbm(palletVolume); allocation.setPlannedPackageCount(source.packageCount()); + allocation.setCountedPackageCount(0); allocation.setPickedPackageCount(0); + allocation.setActualVolumeCbm(BigDecimal.ZERO); allocation.setStatus("PENDING_COUNT"); allocation.setVersion(0); + allocationMapper.insert(allocation); + remainingVolume = remainingVolume.subtract(palletVolume); + remainingPackages -= source.packageCount(); + } + if (remainingPackages != 0 || remainingVolume.signum() != 0) { + throw new IllegalStateException("Failed to conserve one-click dropship pallet totals"); + } + } + + public record WholePalletInput(String palletNo, int packageCount) {} + @Transactional(readOnly = true) public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 0dec5a0b3..febdbe99d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -207,6 +207,11 @@ public class LoadingCompletedConsumer { catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); } dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId, event.eventId() + ":DROPSHIP", dropshipPallets)); + InboundPlanResponse plan = inboundPlans.convertDropship(customerId, order.getId(), dropshipPallets.stream() + .map(pallet -> new InboundPlanService.WholePalletInput(pallet.palletNo(), pallet.skuPackages().stream() + .mapToInt(sku -> Math.toIntExact(sku.packageCount())).sum())) + .toList()); + registerInTransit(event, payload, source, order, plan.planNo()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java index 85564aa1f..9c37e00a0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java @@ -97,7 +97,7 @@ class DropshipOrderServiceTest { when(cartons.listProfile("C-1", 2L)).thenReturn(List.of(secondCarton)); service.activateAfterStockIn("C-1", 10L, 50L, List.of( - receipt(101L, "WH-P-1", 201L, 1), receipt(102L, "WH-P-2", 202L, 1))); + receipt(101L, "SOURCE-P-1", 201L, 1), receipt(102L, "SOURCE-P-2", 202L, 1))); assertThat(first.getStatus()).isEqualTo("ACTIVE"); assertThat(first.getWarehousePalletId()).isEqualTo(101L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index d009229fe..210cf2dba 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -12,6 +12,9 @@ import com.baiyue.lastleg.inboundplan.pda.entity.InboundPdaTask; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; +import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; +import com.baiyue.lastleg.entity.LastlegOrder; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -48,6 +51,7 @@ class InboundPdaWorkflowServiceTest { @Mock InboundStockReceiptService stockReceipts; @Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Mock UnloadPhotoFileGateway unloadPhotoFiles; + @Mock DropshipOrderService dropshipOrders; private InboundPdaWorkflowService service; private InboundPlan plan; @@ -69,6 +73,7 @@ class InboundPdaWorkflowServiceTest { ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox); ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles); + ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); service.initializeObjectMapper(); plan = plan(); @@ -492,6 +497,53 @@ class InboundPdaWorkflowServiceTest { verify(unloadCompletedOutbox, never()).publish(any(), any(), any()); } + @Test + void dropshipTaskIsMarkedAndWholePalletPickReceivesThePlannedPalletAtOnce() { + plan.setSourceOrderId(101L); + LastlegOrder order = new LastlegOrder(); + order.setId(101L); order.setOrderType("ONE_CLICK_DROPSHIP"); + when(repository.order(101L)).thenReturn(order); + when(dropshipOrders.palletSnapshots("100", 101L)).thenReturn(List.of( + new DropshipPalletSnapshot("PLT-21", 3, + List.of(new DropshipPalletSnapshot.Sku("SKU-A", "Product A", 3, 30))))); + when(repository.lines(plan.getId())).thenReturn(List.of(targetLine)); + when(repository.allocations(plan.getId())).thenReturn(List.of(target)); + when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet)); + when(repository.task("100", plan.getId())).thenReturn(task); + activeTask(); + when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); + when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); + when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); + + var taskResult = service.task("100", "LIP1"); + var pickResult = service.pickWholePallet("100", "LIP1", target.getId(), + new PdaRequests.WholePalletOperation("whole-1", "7", "worker")); + + assertEquals("ONE_CLICK_DROPSHIP", taskResult.orderType()); + assertTrue(taskResult.wholePalletInbound()); + assertEquals("SKU-A", taskResult.dropshipPallets().get(0).skus().get(0).skuCode()); + assertEquals(targetPallet.getId(), taskResult.dropshipPallets().get(0).planPalletId()); + assertEquals(target.getId(), taskResult.dropshipPallets().get(0).allocationId()); + assertEquals("WHOLE_PALLET_PICKED", pickResult.result()); + assertEquals(3, target.getPickedPackageCount()); + assertEquals(3, targetPallet.getActualPackageCount()); + } + + @Test + void dropshipPlanRejectsOrdinaryPackagePicking() { + plan.setSourceOrderId(101L); + LastlegOrder order = new LastlegOrder(); + order.setId(101L); order.setOrderType("ONE_CLICK_DROPSHIP"); + when(repository.order(101L)).thenReturn(order); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.pick("100", "LIP1", target.getId(), + new PdaRequests.PackageOperation(1, "wrong-mode", "7", "worker"))); + + assertEquals(409, exception.getStatusCode().value()); + verify(repository, never()).lockAllocation(plan.getId(), target.getId()); + } + @Test void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() { InboundPdaOperation existing = new InboundPdaOperation(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java index 75ba697ee..e46a8ed57 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java @@ -17,6 +17,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.dropship.service.DropshipOrderService; +import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -166,6 +167,7 @@ class InboundStockReceiptServiceTest { void activatesDropshipAfterGenericStockInReceiptIsCreated() { Fixture fixture = fixture(5, 5, 5, 10); fixture.order.setOrderType("ONE_CLICK_DROPSHIP"); + when(dropshipOrders.palletSnapshots("100", 101L)).thenReturn(List.of(dropshipPallet("PLT-21", 5))); when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false)); @@ -183,6 +185,7 @@ class InboundStockReceiptServiceTest { void partialStockInSynchronizesOrderWithoutActivatingDropship() { Fixture fixture = fixture(5, 2, 2, 10); fixture.order.setOrderType("ONE_CLICK_DROPSHIP"); + when(dropshipOrders.palletSnapshots("100", 101L)).thenReturn(List.of(dropshipPallet("PLT-21", 2))); when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 2, 0, 0, 0, false, false)); @@ -197,6 +200,26 @@ class InboundStockReceiptServiceTest { verify(dropshipOrders, never()).activateAfterStockIn(any(), any(), any(), any()); } + @Test + void rejectsPartiallyPickedDropshipSourcePallet() { + Fixture fixture = fixture(5, 5, 2, 10); + fixture.order.setOrderType("ONE_CLICK_DROPSHIP"); + when(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order)); + when(dropshipOrders.palletSnapshots("100", 101L)).thenReturn(List.of(dropshipPallet("PLT-21", 5))); + + ResponseStatusException exception = assertThrows(ResponseStatusException.class, + () -> service.stockIn("100", fixture.plan, fixture.task, List.of(fixture.line), + List.of(fixture.allocation), List.of(fixture.pallet), LocalDateTime.now(), true)); + + assertEquals(409, exception.getStatusCode().value()); + verify(inventory, never()).stockIn(any(), any(), any()); + } + + private DropshipPalletSnapshot dropshipPallet(String palletNo, long packages) { + return new DropshipPalletSnapshot(palletNo, packages, + List.of(new DropshipPalletSnapshot.Sku("SKU-1", "Product", packages, packages * 10))); + } + private Fixture fixture(int required, int planned, int picked, int quantity) { InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 7dd827a96..9422763b1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -146,6 +146,27 @@ class InboundPlanServiceTest { verify(planMapper, never()).insert(any(InboundPlan.class)); } + @Test + void convertsDropshipSourcePalletsWithoutRedistributingTheirPackages() { + when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(order(2L, "RK-2", "1.0000", "WH-A"))); + when(orderMapper.markPlanned(any(), any(), any(), org.mockito.ArgumentMatchers.anyInt(), any())) + .thenReturn(1); + + service.convertDropship("100", 2L, List.of( + new InboundPlanService.WholePalletInput("SOURCE-P-1", 3), + new InboundPlanService.WholePalletInput("SOURCE-P-2", 7))); + + ArgumentCaptor pallets = ArgumentCaptor.forClass(InboundPlanPallet.class); + verify(palletMapper, times(2)).insert(pallets.capture()); + assertEquals(List.of("SOURCE-P-1", "SOURCE-P-2"), + pallets.getAllValues().stream().map(InboundPlanPallet::getPalletNo).toList()); + ArgumentCaptor allocations = + ArgumentCaptor.forClass(InboundPlanPalletAllocation.class); + verify(allocationMapper, times(2)).insert(allocations.capture()); + assertEquals(List.of(3, 7), allocations.getAllValues().stream() + .map(InboundPlanPalletAllocation::getPlannedPackageCount).toList()); + } + private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) { return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 34dae31ba..0c075c064 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -3,12 +3,12 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InboundPlanService inboundPlans; @Mock InventoryCommandService inventory; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of())); } + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inboundPlans",inboundPlans); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"objectMapper",json); ReflectionTestUtils.setField(service,"orderNoGenerator",new LastlegOrderNoGenerator()); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); var plan=new InboundPlanResponse(1L,"LIP-1","100",1L,"MSCU1","PENDING_ASSIGNMENT",1,1,BigDecimal.ONE,List.of(),List.of(),List.of()); lenient().when(inboundPlans.convert(anyString(),anyLong())).thenReturn(plan); lenient().when(inboundPlans.convertDropship(anyString(),anyLong(),anyList())).thenReturn(plan); } @Test void createsOneOrderAndOneInboundPlanPerInbound(){ EventEnvelope event=event(); assertEquals(2,service.consume(event)); verify(orders,times(2)).insert(any(LastlegOrder.class)); verify(inboundPlans,times(2)).convert(eq("100"),anyLong()); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),any(InventoryCommandRequests.InTransit.class)); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU1".equals(o.getContainerNo()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertEquals(new LastlegOrderNoGenerator().forContainerNo("MSCU1"),c.getAllValues().get(0).getOrderNo()); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void rejectsEventFromWrongRegion(){ EventEnvelope source=event(); EventEnvelope invalid=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),RegionCode.OVERSEAS,RegionCode.CN,source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),source.payloadHash()); assertThrows(IllegalArgumentException.class,()->service.consume(invalid)); } - @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); } + @Test void consumesFormalDropshipPalletSnapshots(){ var sku=new FirstLegLoadingPlanCompletedV1.SkuPackageSnapshot("SKU-A","Product A",2,20,List.of("BOX-1","BOX-2")); var pallet=new FirstLegLoadingPlanCompletedV1.PalletSnapshot("PLT-1",List.of(sku)); var item=new FirstLegLoadingPlanCompletedV1.Item("d-1","SKU-A","Product A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,null,"MARK",null,List.of()); var order=new FirstLegLoadingPlanCompletedV1.Order("in-d","root-d","ord-d","FL-D","root-od","RK-D","wh-1","ONT8","ONT8",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,List.of(item),"100","ONE_CLICK_DROPSHIP",List.of(pallet)); var payload=new FirstLegLoadingPlanCompletedV1("plan-d","LP-D","book-d","CONT-D","CAB-D","92","Warehouse",List.of(order)); JsonNode node=json.valueToTree(payload); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); var event=new EventEnvelope("123e4567-e89b-12d3-a456-426614174010",EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED,1,Instant.now(),RegionCode.CN,RegionCode.OVERSEAS,"100","LOADING_PLAN","plan-d",2,1L,"123e4567-e89b-12d3-a456-426614174011","FIRSTLEG_LOADING_COMPLETED:plan-d:100:1",null,node,hash); assertEquals(1,service.consume(event)); ArgumentCaptor captor=ArgumentCaptor.forClass(DropshipRequests.Receive.class); verify(dropshipOrders).receive(captor.capture()); assertEquals("100",captor.getValue().customerId()); assertEquals(1,captor.getValue().pallets().size()); assertEquals("PLT-1",captor.getValue().pallets().get(0).palletNo()); assertEquals(List.of("BOX-1","BOX-2"),captor.getValue().pallets().get(0).skuPackages().get(0).cartonMarks()); ArgumentCaptor> plans=ArgumentCaptor.forClass(List.class); verify(inboundPlans).convertDropship(eq("100"),anyLong(),plans.capture()); assertEquals(List.of(new InboundPlanService.WholePalletInput("PLT-1",2)),plans.getValue()); verify(inventory).registerInTransit(eq("100"),eq("SYSTEM"),any()); } @Test void oneDropshipInboundWithMultipleSkusCreatesOneOrderAndMultipleSkuDetails(){ var itemA=new FirstLegLoadingPlanCompletedV1.Item("d-a","SKU-A","产品A","Product A",2,20,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-A","MARK-A",null,List.of("BOX-A1","BOX-A2")); var itemB=new FirstLegLoadingPlanCompletedV1.Item("d-b","SKU-B","产品B","Product B",3,15,1,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,BigDecimal.ONE,"wh-1","ONT8","ONT8",null,"SHIP-B","MARK-B",null,List.of("BOX-B1","BOX-B2","BOX-B3"));