This commit is contained in:
2026-08-24 17:58:47 +08:00
parent 01675b8220
commit 5c331f173d
3 changed files with 51 additions and 33 deletions
@@ -12,7 +12,7 @@ public final class PdaResponses {
List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt, List<Long> unloadPhotoFileIds, LocalDateTime unloadPhotoUploadedAt,
LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount, LocalDateTime estimatedEmptyReturnAt, int expectedPackageCount,
int pickedPackageCount, String orderType, boolean wholePalletInbound, int pickedPackageCount, String orderType, boolean wholePalletInbound,
List<DropshipPallet> dropshipPallets) { List<DropshipPallet> dropshipPallets, String inboundMode) {
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,
@@ -20,7 +20,7 @@ public final class PdaResponses {
int pickedPackageCount) { int pickedPackageCount) {
this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt, this(taskId, taskNo, planNo, status, pushedAt, startedAt, completedAt,
unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt, unloadPhotoFileIds, unloadPhotoUploadedAt, estimatedEmptyReturnAt,
expectedPackageCount, pickedPackageCount, null, false, List.of()); expectedPackageCount, pickedPackageCount, null, false, List.of(), null);
} }
public Task(long taskId, String taskNo, String planNo, String status, public Task(long taskId, String taskNo, String planNo, String status,
@@ -146,7 +146,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Operation count(String customerId, String planNo, long allocationId, public PdaResponses.Operation count(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) { PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo); InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan); requireCountMode(plan);
InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request); InboundPdaOperation replay = replay(customerId, "COUNT", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class); if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan); InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -174,7 +174,7 @@ public class InboundPdaWorkflowService {
public PdaResponses.Operation pick(String customerId, String planNo, long allocationId, public PdaResponses.Operation pick(String customerId, String planNo, long allocationId,
PdaRequests.PackageOperation request) { PdaRequests.PackageOperation request) {
InboundPlan plan = requirePlan(customerId, planNo); InboundPlan plan = requirePlan(customerId, planNo);
requireLegacyCounting(plan); requireCountMode(plan);
InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); InboundPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Operation.class); if (replay != null) return read(replay, PdaResponses.Operation.class);
InboundPdaTask task = requireActiveTask(customerId, plan); InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -250,6 +250,7 @@ public class InboundPdaWorkflowService {
@Transactional @Transactional
public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) { public PdaResponses.Carton scanCarton(String customerId, String planNo, PdaRequests.ScanCarton request) {
InboundPlan plan = requirePlan(customerId, planNo); InboundPlan plan = requirePlan(customerId, planNo);
requireCartonScanMode(plan);
InboundPdaOperation replay = replay(customerId, "SCAN_CARTON", request.idempotencyKey(), request); InboundPdaOperation replay = replay(customerId, "SCAN_CARTON", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Carton.class); if (replay != null) return read(replay, PdaResponses.Carton.class);
InboundPdaTask task = requireActiveTask(customerId, plan); InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -271,22 +272,12 @@ public class InboundPdaWorkflowService {
: requireLine(plan.getId(), carton.getLineId()); : requireLine(plan.getId(), carton.getLineId());
if (line == null) throw notFound("Carton mark does not belong to cargo in this inbound plan"); if (line == null) throw notFound("Carton mark does not belong to cargo in this inbound plan");
int requestedCount = request.packageCount() == null ? 1 : request.packageCount(); int requestedCount = request.packageCount() == null ? 1 : request.packageCount();
if (requestedCount < 1) throw conflict("Package count must be positive"); if (requestedCount != 1) throw conflict("Overseas warehouse carton scanning accepts exactly one carton per scan");
int alreadyScanned = (int) repository.scannedCartons(plan.getId(), line.getId()); int alreadyScanned = (int) repository.scannedCartons(plan.getId(), line.getId());
if (alreadyScanned + requestedCount > value(line.getRequiredPackageCount())) { if (alreadyScanned + requestedCount > value(line.getRequiredPackageCount())) {
throw conflict("This inbound plan line has reached its required carton count"); throw conflict("This inbound plan line has reached its required carton count");
} }
List<InboundPlanCarton> cartonsToScan = new ArrayList<>(); List<InboundPlanCarton> cartonsToScan = List.of(carton);
cartonsToScan.add(carton);
if (requestedCount > 1) {
repository.cartons(plan.getId()).stream()
.filter(candidate -> !Objects.equals(candidate.getId(), carton.getId()))
.filter(candidate -> Objects.equals(candidate.getLineId(), line.getId()))
.filter(candidate -> "UNASSIGNED".equals(candidate.getStatus()) || "EXPECTED".equals(candidate.getStatus()))
.filter(candidate -> sameWarehouse(candidate, pallet))
.limit(requestedCount - 1L).forEach(cartonsToScan::add);
if (cartonsToScan.size() != requestedCount) throw conflict("Not enough unscanned cartons for the requested quantity");
}
InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId()); InboundPlanPalletAllocation allocation = repository.lockAllocation(plan.getId(), line.getId(), pallet.getId());
if (allocation == null) { if (allocation == null) {
allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId()); allocation = new InboundPlanPalletAllocation(); allocation.setId(IdWorker.getId()); allocation.setPlanId(plan.getId());
@@ -323,6 +314,7 @@ public class InboundPdaWorkflowService {
@Transactional @Transactional
public PdaResponses.Carton removeCarton(String customerId, String planNo, PdaRequests.RemoveCarton request) { public PdaResponses.Carton removeCarton(String customerId, String planNo, PdaRequests.RemoveCarton request) {
InboundPlan plan = requirePlan(customerId, planNo); InboundPlan plan = requirePlan(customerId, planNo);
requireCartonScanMode(plan);
InboundPdaOperation replay = replay(customerId, "REMOVE_CARTON", request.idempotencyKey(), request); InboundPdaOperation replay = replay(customerId, "REMOVE_CARTON", request.idempotencyKey(), request);
if (replay != null) return read(replay, PdaResponses.Carton.class); if (replay != null) return read(replay, PdaResponses.Carton.class);
InboundPdaTask task = requireActiveTask(customerId, plan); InboundPdaTask task = requireActiveTask(customerId, plan);
@@ -460,7 +452,7 @@ public class InboundPdaWorkflowService {
} }
} }
List<InboundPlanCarton> cartons = repository.cartons(plan.getId()); List<InboundPlanCarton> cartons = repository.cartons(plan.getId());
if (!isDropship(plan) && !cartons.isEmpty() if (mode(plan) == InboundMode.OWN_OVERSEAS && !cartons.isEmpty()
&& cartons.stream().anyMatch(carton -> !"ON_PALLET".equals(carton.getStatus()))) { && 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");
} }
@@ -676,6 +668,17 @@ public class InboundPdaWorkflowService {
repository.update(plan); repository.update(plan);
} }
private void requireCountMode(InboundPlan plan) {
InboundMode mode = mode(plan);
if (mode == InboundMode.DROPSHIP) {
throw conflict("One-click dropship inbound must be picked by complete source pallet");
}
if (mode == InboundMode.OWN_OVERSEAS) {
throw conflict("Overseas warehouse inbound must be operated by one-carton scanning");
}
}
/** Backward-compatible guard for pallet merge, which remains a legacy quantity workflow. */
private void requireLegacyCounting(InboundPlan plan) { private void requireLegacyCounting(InboundPlan plan) {
if (isDropship(plan)) { if (isDropship(plan)) {
throw conflict("One-click dropship inbound must be picked by complete source pallet"); throw conflict("One-click dropship inbound must be picked by complete source pallet");
@@ -685,6 +688,14 @@ public class InboundPdaWorkflowService {
} }
} }
private void requireCartonScanMode(InboundPlan plan) {
if (mode(plan) != InboundMode.OWN_OVERSEAS) {
throw conflict(mode(plan) == InboundMode.FBA
? "FBA inbound must be operated by package quantity, not carton scanning"
: "One-click dropship inbound must be operated by complete source pallet");
}
}
private boolean isNotAssigned(InboundPlanPallet pallet) { private boolean isNotAssigned(InboundPlanPallet pallet) {
return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null return pallet.getWarehousePalletId() == null || pallet.getWarehouseLocationId() == null
|| pallet.getWarehouseResourceLockId() == null || pallet.getWarehouseResourceLockId() == null
@@ -768,7 +779,7 @@ public class InboundPdaWorkflowService {
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); orderType, wholePalletInbound, dropshipPallets, mode(plan).name());
} }
private PdaResponses.Task task(InboundPdaTask task, String planNo) { private PdaResponses.Task task(InboundPdaTask task, String planNo) {
@@ -801,6 +812,15 @@ public class InboundPdaWorkflowService {
return order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType()); return order != null && "ONE_CLICK_DROPSHIP".equals(order.getOrderType());
} }
private InboundMode mode(InboundPlan plan) {
if (isDropship(plan)) return InboundMode.DROPSHIP;
List<InboundPlanLine> lines = repository.lines(plan.getId());
return lines.stream().anyMatch(line -> line.getFbaWarehouseCode() != null
&& !line.getFbaWarehouseCode().isBlank()) ? InboundMode.FBA : InboundMode.OWN_OVERSEAS;
}
private enum InboundMode { DROPSHIP, OWN_OVERSEAS, FBA }
private List<Long> unloadPhotoFileIds(InboundPdaTask task) { private List<Long> unloadPhotoFileIds(InboundPdaTask task) {
if (task.getUnloadPhotoFileIds() == null || task.getUnloadPhotoFileIds().isBlank()) return List.of(); if (task.getUnloadPhotoFileIds() == null || task.getUnloadPhotoFileIds().isBlank()) return List.of();
try { try {
@@ -167,27 +167,17 @@ class InboundPdaWorkflowServiceTest {
} }
@Test @Test
void cartonScanCanAutoSelectDestinationPalletAndBindAQuantity() { void cartonScanRejectsBatchQuantityAndRequiresOneRealCartonPerScan() {
InboundPlanCarton first = carton("BOX-BATCH-1", "UNASSIGNED", targetLine.getId(), "WH-A"); InboundPlanCarton first = carton("BOX-BATCH-1", "UNASSIGNED", targetLine.getId(), "WH-A");
InboundPlanCarton second = carton("BOX-BATCH-2", "UNASSIGNED", targetLine.getId(), "WH-A"); InboundPlanCarton second = carton("BOX-BATCH-2", "UNASSIGNED", targetLine.getId(), "WH-A");
second.setId(42L); second.setId(42L);
activeTask(); activeTask();
when(repository.lockCarton("100", "BOX-BATCH-1")).thenReturn(first); when(repository.lockCarton("100", "BOX-BATCH-1")).thenReturn(first);
when(repository.pallets(plan.getId())).thenReturn(List.of(targetPallet));
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.cartons(plan.getId())).thenReturn(List.of(first, second));
when(repository.lockAllocation(plan.getId(), targetLine.getId(), targetPallet.getId())).thenReturn(target);
var result = service.scanCarton("100", "LIP1", assertThrows(ResponseStatusException.class, () -> service.scanCarton("100", "LIP1",
new PdaRequests.ScanCarton("BOX-BATCH-1", null, 2, "scan-batch", "7", "worker")); new PdaRequests.ScanCarton("BOX-BATCH-1", null, 2, "scan-batch", "7", "worker")));
assertEquals("UNASSIGNED", first.getStatus());
assertEquals(targetPallet.getId(), result.planPalletId()); assertEquals("UNASSIGNED", second.getStatus());
assertEquals("PLT-21", result.palletNo());
assertEquals(2, result.scannedPackageCount());
assertEquals("ON_PALLET", first.getStatus());
assertEquals("ON_PALLET", second.getStatus());
assertEquals(2, target.getPickedPackageCount());
} }
@Test @Test
@@ -226,7 +216,9 @@ class InboundPdaWorkflowServiceTest {
@Test @Test
void countMustPrecedePickAndPickedBoxesDriveActualVolume() { void countMustPrecedePickAndPickedBoxesDriveActualVolume() {
targetLine.setFbaWarehouseCode("ONT8");
activeTask(); activeTask();
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
target.setCountedPackageCount(0); target.setCountedPackageCount(0);
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
@@ -249,7 +241,9 @@ class InboundPdaWorkflowServiceTest {
@Test @Test
void overPickReturnsConfirmationWithoutChangingAllocationOrPallet() { void overPickReturnsConfirmationWithoutChangingAllocationOrPallet() {
targetLine.setFbaWarehouseCode("ONT8");
activeTask(); activeTask();
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
candidateQueries(); candidateQueries();
@@ -546,6 +540,8 @@ class InboundPdaWorkflowServiceTest {
@Test @Test
void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() { void rejectsReuseOfIdempotencyKeyWithDifferentActionOrPayload() {
targetLine.setFbaWarehouseCode("ONT8");
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
InboundPdaOperation existing = new InboundPdaOperation(); InboundPdaOperation existing = new InboundPdaOperation();
existing.setAction("COUNT"); existing.setAction("COUNT");
existing.setRequestHash("different"); existing.setRequestHash("different");
@@ -557,7 +553,9 @@ class InboundPdaWorkflowServiceTest {
@Test @Test
void completedTaskStillReplaysAnAlreadyPersistedPick() { void completedTaskStillReplaysAnAlreadyPersistedPick() {
targetLine.setFbaWarehouseCode("ONT8");
activeTask(); activeTask();
when(repository.lines(plan.getId())).thenReturn(List.of(targetLine));
when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target); when(repository.lockAllocation(plan.getId(), target.getId())).thenReturn(target);
when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine); when(repository.line(plan.getId(), targetLine.getId())).thenReturn(targetLine);
when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet); when(repository.lockPallet(plan.getId(), targetPallet.getId())).thenReturn(targetPallet);