This commit is contained in:
2026-08-24 17:12:30 +08:00
parent ce9f08c756
commit 01675b8220
15 changed files with 367 additions and 43 deletions
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.dropship.model;
import java.util.List;
public record DropshipPalletSnapshot(String sourcePalletNo, long packageCount,
List<Sku> skus) {
public record Sku(String skuCode, String productName, long packageCount, long productQuantity) {}
}
@@ -7,6 +7,7 @@ import com.baiyue.lastleg.dropship.mapper.*;
import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrder;
import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt; import com.baiyue.lastleg.inboundplan.pda.entity.InboundStockReceipt;
import com.baiyue.lastleg.mapper.LastlegOrderMapper; import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -109,6 +110,21 @@ public class DropshipOrderService {
return profile; return profile;
} }
@Transactional(readOnly = true)
public List<DropshipPalletSnapshot> palletSnapshots(String customerId, Long orderId) {
List<DropshipOrderProfile> orderProfiles = profiles.listOrder(customerId, orderId);
Map<Long, List<DropshipSkuInventory>> skusByProfile = skuInventory.listOrder(customerId, orderId).stream()
.collect(Collectors.groupingBy(DropshipSkuInventory::getProfileId));
return orderProfiles.stream().map(profile -> {
List<DropshipPalletSnapshot.Sku> 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 @Transactional
public void activateAfterStockIn(String customerId, Long orderId, Long taskId, public void activateAfterStockIn(String customerId, Long orderId, Long taskId,
List<InboundStockReceipt> receipts) { List<InboundStockReceipt> receipts) {
@@ -118,20 +134,18 @@ public class DropshipOrderService {
if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) { if (profilesForOrder.stream().anyMatch(profile -> "ACTIVE".equals(profile.getStatus()))) {
throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying"); throw conflict("Dropship pallet activation is incomplete; repair profiles before retrying");
} }
Map<Long, List<InboundStockReceipt>> receiptsByPallet = receipts.stream() Map<String, List<InboundStockReceipt>> receiptsByPallet = receipts.stream()
.filter(receipt -> receipt.getWarehousePalletId() != null) .filter(receipt -> receipt.getPalletNo() != null)
.collect(Collectors.groupingBy(InboundStockReceipt::getWarehousePalletId)); .collect(Collectors.groupingBy(InboundStockReceipt::getPalletNo));
if (receiptsByPallet.size() != profilesForOrder.size()) { if (receiptsByPallet.size() != profilesForOrder.size()) {
throw conflict("PDA pallet count does not match dropship source pallet count"); throw conflict("PDA pallet count does not match dropship source pallet count");
} }
List<DropshipOrderProfile> orderedProfiles = profilesForOrder.stream()
.sorted(java.util.Comparator.comparing(DropshipOrderProfile::getSourcePalletNo)).toList();
List<List<InboundStockReceipt>> orderedReceipts = receiptsByPallet.values().stream()
.sorted(java.util.Comparator.comparing(value -> value.get(0).getWarehousePalletId())).toList();
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
for (int index = 0; index < orderedProfiles.size(); index++) { for (DropshipOrderProfile profile : profilesForOrder) {
DropshipOrderProfile profile = orderedProfiles.get(index); List<InboundStockReceipt> actual = receiptsByPallet.get(profile.getSourcePalletNo());
List<InboundStockReceipt> actual = orderedReceipts.get(index); if (actual == null || actual.isEmpty()) {
throw conflict("PDA receipt is missing for dropship source pallet " + profile.getSourcePalletNo());
}
List<DropshipCarton> profileCartons = cartons.listProfile(customerId, profile.getId()); List<DropshipCarton> profileCartons = cartons.listProfile(customerId, profile.getId());
long expected = profileCartons.size(); long expected = profileCartons.size();
long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum(); long received = actual.stream().mapToLong(value -> value.getReceivedPackages() == null ? 0 : value.getReceivedPackages()).sum();
@@ -65,6 +65,16 @@ public class InboundPdaController {
return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request)); return ApiResponse.success(service.pick(scope(user, customerId), planNo, allocationId, request));
} }
@PostMapping("/allocations/{allocationId}/pick-whole-pallet")
public ApiResponse<PdaResponses.Operation> 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") @PostMapping("/cartons/scan")
public ApiResponse<PdaResponses.Carton> scanCarton(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<PdaResponses.Carton> scanCarton(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String planNo, @PathVariable String planNo,
@@ -17,6 +17,9 @@ public final class PdaRequests {
public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey, public record PackageOperation(@Positive int packageCount, @NotBlank String idempotencyKey,
String operatorId, String operatorName) {} String operatorId, String operatorName) {}
public record WholePalletOperation(@NotBlank String idempotencyKey,
String operatorId, String operatorName) {}
public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount, public record ConfirmMerge(@Positive long sourceAllocationId, @Positive int packageCount,
@NotBlank String idempotencyKey, String operatorId, String operatorName) {} @NotBlank String idempotencyKey, String operatorId, String operatorName) {}
@@ -11,7 +11,18 @@ public final class PdaResponses {
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount, LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount,
int pickedPackageCount) { int pickedPackageCount, String orderType, boolean wholePalletInbound,
List<DropshipPallet> dropshipPallets) {
public Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> 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, public Task(long taskId, String taskNo, String planNo, String status,
LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt, LocalDateTime pushedAt, LocalDateTime startedAt, LocalDateTime completedAt,
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
@@ -21,6 +32,10 @@ public final class PdaResponses {
} }
} }
public record DropshipPallet(String sourcePalletNo, long planPalletId, long allocationId,
long packageCount, List<DropshipSku> 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 UnloadPhotoFile(long fileId, String fileName, String contentType, long sizeBytes) {}
public record Operation(String result, long allocationId, int requiredPackageCount, public record Operation(String result, long allocationId, int requiredPackageCount,
@@ -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.InboundPalletMergeMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaOperationMapper; import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaOperationMapper;
import com.baiyue.lastleg.inboundplan.pda.mapper.InboundPdaTaskMapper; 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 org.springframework.stereotype.Repository;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -32,6 +34,7 @@ public class PdaWorkflowRepository {
@Resource private InboundPdaOperationMapper operations; @Resource private InboundPdaOperationMapper operations;
@Resource private InboundPalletMergeMapper merges; @Resource private InboundPalletMergeMapper merges;
@Resource private InboundPlanCartonMapper cartons; @Resource private InboundPlanCartonMapper cartons;
@Resource private LastlegOrderMapper orders;
public InboundPlan lockPlan(String customerId, String planNo) { public InboundPlan lockPlan(String customerId, String planNo) {
return plans.selectOne(new LambdaQueryWrapper<InboundPlan>() return plans.selectOne(new LambdaQueryWrapper<InboundPlan>()
@@ -92,6 +95,8 @@ public class PdaWorkflowRepository {
.eq(InboundPlanPalletAllocation::getPlanId, planId)); .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(InboundPdaTask value) { tasks.insert(value); }
public void insert(InboundPdaOperation value) { operations.insert(value); } public void insert(InboundPdaOperation value) { operations.insert(value); }
public void insert(InboundPalletMerge value) { merges.insert(value); } public void insert(InboundPalletMerge value) { merges.insert(value); }
@@ -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.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; 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.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature; import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@@ -50,6 +53,7 @@ public class InboundPdaWorkflowService {
@Resource private InboundStockReceiptService stockReceipts; @Resource private InboundStockReceiptService stockReceipts;
@Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Resource private UnloadPhotoFileGateway unloadPhotoFiles; @Resource private UnloadPhotoFileGateway unloadPhotoFiles;
@Resource private DropshipOrderService dropshipOrders;
@Resource private ObjectMapper sourceObjectMapper; @Resource private ObjectMapper sourceObjectMapper;
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@@ -209,6 +213,39 @@ public class InboundPdaWorkflowService {
return result; 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 @Transactional
public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) { public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) {
@@ -423,7 +460,8 @@ public class InboundPdaWorkflowService {
} }
} }
List<InboundPlanCarton> cartons = repository.cartons(plan.getId()); List<InboundPlanCarton> 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"); throw conflict("All expected carton marks must be scanned onto a pallet before stock-in");
} }
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
@@ -639,6 +677,9 @@ public class InboundPdaWorkflowService {
} }
private void requireLegacyCounting(InboundPlan plan) { 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())) { if (repository.hasCartonLabelPool(plan.getId())) {
throw conflict("This inbound plan has a carton manifest and must be operated by carton scanning"); 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); 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) { private PdaResponses.Task taskResponse(InboundPdaTask task, InboundPlan plan, String planNo) {
int expected = repository.lines(plan.getId()).stream().mapToInt(line -> value(line.getRequiredPackageCount())).sum(); 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(); List<InboundPlanPalletAllocation> allocations = repository.allocations(plan.getId());
return task(task, planNo, expected, picked); int picked = allocations.stream().mapToInt(allocation -> value(allocation.getPickedPackageCount())).sum();
} LastlegOrder order = repository.order(plan.getSourceOrderId());
String orderType = order == null ? null : order.getOrderType();
private PdaResponses.Task task(InboundPdaTask task, String planNo, int expected, int picked) { boolean wholePalletInbound = "ONE_CLICK_DROPSHIP".equals(orderType);
Map<String, InboundPlanPallet> palletsByNo = wholePalletInbound
? repository.pallets(plan.getId()).stream().collect(java.util.stream.Collectors.toMap(
InboundPlanPallet::getPalletNo, value -> value)) : Map.of();
Map<Long, InboundPlanPalletAllocation> allocationsByPallet = wholePalletInbound
? allocations.stream().collect(java.util.stream.Collectors.toMap(
InboundPlanPalletAllocation::getPalletId, value -> value)) : Map.of();
List<PdaResponses.DropshipPallet> 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(), return new PdaResponses.Task(task.getId(), task.getTaskNo(), planNo, task.getStatus(),
task.getPushedAt(), task.getStartedAt(), task.getCompletedAt(), unloadPhotoFileIds(task), 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<String, InboundPlanPallet> palletsByNo,
Map<Long, InboundPlanPalletAllocation> 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<Long> unloadPhotoFileIds(InboundPdaTask task) { private List<Long> unloadPhotoFileIds(InboundPdaTask task) {
@@ -18,6 +18,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.dropship.service.DropshipOrderService;
import com.baiyue.lastleg.dropship.model.DropshipPalletSnapshot;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -93,11 +94,24 @@ public class InboundStockReceiptService {
} }
List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType()) List<SkuRemainder> skuRemainders = "ONE_CLICK_DROPSHIP".equals(order.getOrderType())
? List.of() : skuRemainders(order.getId(), value(line.getRequiredPackageCount())); ? List.of() : skuRemainders(order.getId(), value(line.getRequiredPackageCount()));
Map<String, DropshipPalletSnapshot> 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) { for (InboundPlanPalletAllocation allocation : lineAllocations) {
InboundPlanPallet pallet = requirePallet(palletById, allocation); InboundPlanPallet pallet = requirePallet(palletById, allocation);
if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) { if ("ONE_CLICK_DROPSHIP".equals(order.getOrderType())) {
created.add(persist(customerId, plan, task, line, allocation, pallet, order, if (value(allocation.getPickedPackageCount()) != value(allocation.getPlannedPackageCount())) {
SkuSnapshot.unspecified(value(allocation.getPickedPackageCount())), receivedAt)); 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 { } else {
long allocationPackages = value(allocation.getPickedPackageCount()); long allocationPackages = value(allocation.getPickedPackageCount());
for (SkuSnapshot sku : consume(skuRemainders, allocationPackages)) { 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 record SkuSnapshot(String skuCode, String productNameZh, String productNameEn, long packages) {}
private static SkuSnapshot unspecified(long packages) {
return new SkuSnapshot("UNSPECIFIED", null, null, packages);
}
}
} }
@@ -30,6 +30,7 @@ import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.HashSet;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Service @Service
@@ -62,6 +63,32 @@ public class InboundPlanService {
@Transactional @Transactional
public InboundPlanResponse convert(String customerId, Long selectedOrderId, List<String> sourcePalletNos) { public InboundPlanResponse convert(String customerId, Long selectedOrderId, List<String> sourcePalletNos) {
return convert(customerId, selectedOrderId, sourcePalletNos, null);
}
@Transactional
public InboundPlanResponse convertDropship(String customerId, Long selectedOrderId,
List<WholePalletInput> sourcePallets) {
if (sourcePallets == null || sourcePallets.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST,
"One-click dropship inbound requires source pallets");
}
HashSet<String> 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<String> sourcePalletNos,
List<WholePalletInput> wholePallets) {
List<SourceOrderSnapshot> orders = orderMapper.lockOrder(customerId, selectedOrderId); List<SourceOrderSnapshot> orders = orderMapper.lockOrder(customerId, selectedOrderId);
if (orders == null || orders.isEmpty()) { if (orders == null || orders.isEmpty()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound order not found");
@@ -91,21 +118,27 @@ public class InboundPlanService {
Map<Long, SourceOrderSnapshot> orderById = orders.stream() Map<Long, SourceOrderSnapshot> orderById = orders.stream()
.collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value)); .collect(Collectors.toMap(SourceOrderSnapshot::orderId, value -> value));
Map<Long, InboundPlanLine> lineByOrder = createLines(plan, orders); Map<Long, InboundPlanLine> 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), .map(order -> new PalletAllocator.LineInput(order.orderId(), warehouseKey(order), volume(order),
order.packageCount(), order.recommendedPalletCount())) order.packageCount(), order.recommendedPalletCount())).toList()) : null;
.toList()); if (wholePallets != null) validateWholePallets(orders, wholePallets);
plan.setPalletCount(allocation.pallets().size()); plan.setPalletCount(wholePallets == null ? allocation.pallets().size() : wholePallets.size());
planMapper.insert(plan); planMapper.insert(plan);
lineByOrder.values().forEach(lineMapper::insert); lineByOrder.values().forEach(lineMapper::insert);
Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation, orderById, Map<Long, Integer> palletCounts;
sourcePalletNos == null ? List.of() : sourcePalletNos); if (wholePallets == null) {
createAllocations(plan, allocation, lineByOrder, persistedPallets); Map<PalletAllocator.PalletDraft, InboundPlanPallet> persistedPallets = createPallets(plan, allocation,
orderById, sourcePalletNos == null ? List.of() : sourcePalletNos);
Map<Long, Integer> palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy( createAllocations(plan, allocation, lineByOrder, persistedPallets);
PalletAllocator.AllocationDraft::orderId, palletCounts = allocation.allocations().stream().collect(Collectors.groupingBy(
Collectors.collectingAndThen(Collectors.mapping(item -> item.pallet().sequenceNo(), Collectors.toSet()), java.util.Set::size))); 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) { for (SourceOrderSnapshot order : orders) {
int count = palletCounts.getOrDefault(order.orderId(), 0); int count = palletCounts.getOrDefault(order.orderId(), 0);
InboundPlanLine line = lineByOrder.get(order.orderId()); InboundPlanLine line = lineByOrder.get(order.orderId());
@@ -120,6 +153,54 @@ public class InboundPlanService {
return detail(customerId, plan); return detail(customerId, plan);
} }
private void validateWholePallets(List<SourceOrderSnapshot> orders, List<WholePalletInput> 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<WholePalletInput> 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) @Transactional(readOnly = true)
public InboundPlanResponse detailByPlanNo(String customerId, String planNo) { public InboundPlanResponse detailByPlanNo(String customerId, String planNo) {
@@ -207,6 +207,11 @@ public class LoadingCompletedConsumer {
catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); } catch (Exception exception) { throw new IllegalArgumentException("One-click dropship warehouseId is invalid", exception); }
dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId, dropshipOrders.receive(new DropshipRequests.Receive(customerId, order.getId(), warehouseId,
event.eventId() + ":DROPSHIP", dropshipPallets)); 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());
} }
} }
@@ -97,7 +97,7 @@ class DropshipOrderServiceTest {
when(cartons.listProfile("C-1", 2L)).thenReturn(List.of(secondCarton)); when(cartons.listProfile("C-1", 2L)).thenReturn(List.of(secondCarton));
service.activateAfterStockIn("C-1", 10L, 50L, List.of( 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.getStatus()).isEqualTo("ACTIVE");
assertThat(first.getWarehousePalletId()).isEqualTo(101L); assertThat(first.getWarehousePalletId()).isEqualTo(101L);
@@ -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.WarehouseResourceGateway;
import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway; import com.baiyue.lastleg.inboundplan.pda.gateway.UnloadPhotoFileGateway;
import com.baiyue.lastleg.inboundplan.pda.repository.PdaWorkflowRepository; 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 com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -48,6 +51,7 @@ class InboundPdaWorkflowServiceTest {
@Mock InboundStockReceiptService stockReceipts; @Mock InboundStockReceiptService stockReceipts;
@Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Mock InboundUnloadCompletedOutboxService unloadCompletedOutbox;
@Mock UnloadPhotoFileGateway unloadPhotoFiles; @Mock UnloadPhotoFileGateway unloadPhotoFiles;
@Mock DropshipOrderService dropshipOrders;
private InboundPdaWorkflowService service; private InboundPdaWorkflowService service;
private InboundPlan plan; private InboundPlan plan;
@@ -69,6 +73,7 @@ class InboundPdaWorkflowServiceTest {
ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts);
ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", unloadCompletedOutbox);
ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles); ReflectionTestUtils.setField(service, "unloadPhotoFiles", unloadPhotoFiles);
ReflectionTestUtils.setField(service, "dropshipOrders", dropshipOrders);
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper().findAndRegisterModules());
service.initializeObjectMapper(); service.initializeObjectMapper();
plan = plan(); plan = plan();
@@ -492,6 +497,53 @@ class InboundPdaWorkflowServiceTest {
verify(unloadCompletedOutbox, never()).publish(any(), any(), any()); 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 @Test
void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() { void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() {
InboundPdaOperation existing = new InboundPdaOperation(); InboundPdaOperation existing = new InboundPdaOperation();
@@ -17,6 +17,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderDetailMapper;
import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper;
import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory;
import com.baiyue.lastleg.dropship.service.DropshipOrderService; 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -166,6 +167,7 @@ class InboundStockReceiptServiceTest {
void activatesDropshipAfterGenericStockInReceiptIsCreated() { void activatesDropshipAfterGenericStockInReceiptIsCreated() {
Fixture fixture = fixture(5, 5, 5, 10); Fixture fixture = fixture(5, 5, 5, 10);
fixture.order.setOrderType("ONE_CLICK_DROPSHIP"); 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(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) 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)); .thenReturn(new InventoryCommandResponse(8L, "STOCK_IN", 9L, 10L, 5, 0, 0, 0, false, false));
@@ -183,6 +185,7 @@ class InboundStockReceiptServiceTest {
void partialStockInSynchronizesOrderWithoutActivatingDropship() { void partialStockInSynchronizesOrderWithoutActivatingDropship() {
Fixture fixture = fixture(5, 2, 2, 10); Fixture fixture = fixture(5, 2, 2, 10);
fixture.order.setOrderType("ONE_CLICK_DROPSHIP"); 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(orders.selectBatchIds(any())).thenReturn(List.of(fixture.order));
when(inventory.stockIn(eq("100"), eq("operator"), any(InventoryCommandRequests.StockIn.class))) 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)); .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()); 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) { private Fixture fixture(int required, int planned, int picked, int quantity) {
InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1"); InboundPlan plan = new InboundPlan(); plan.setId(1L); plan.setPlanNo("IP-1");
InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator"); InboundPdaTask task = new InboundPdaTask(); task.setId(2L); task.setOperatorId("operator");
@@ -146,6 +146,27 @@ class InboundPlanServiceTest {
verify(planMapper, never()).insert(any(InboundPlan.class)); 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<InboundPlanPallet> 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<InboundPlanPalletAllocation> 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) { private SourceOrderSnapshot order(Long id, String inboundCode, String volume, String warehouse) {
return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null, return new SourceOrderSnapshot(id, "100", "CONT-1", inboundCode, "PENDING_ASSIGNMENT", null,
new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(), new BigDecimal(volume), new BigDecimal(volume).multiply(BigDecimal.TEN).intValue(),
@@ -3,12 +3,12 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com
@ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class)
class LoadingCompletedConsumerTest { 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; @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<LastlegOrder> 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 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<LastlegOrder> 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 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 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 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<DropshipRequests.Receive> 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<DropshipRequests.Receive> 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<List<InboundPlanService.WholePalletInput>> 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(){ @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 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")); 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"));