fix(firstleg): recover multi-customer PDA completion

This commit is contained in:
2026-08-29 08:25:42 +08:00
parent da32a67d7f
commit e6ee9cb442
4 changed files with 185 additions and 42 deletions
@@ -110,17 +110,20 @@ public class LoadingDeliveryService {
@Transactional @Transactional
public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId, public LoadingActionResponse completeLoading(long tenantId, long inboundId, long operatorId,
String operatorName, String key, CompleteLoadingRequest request) { String operatorName, String key, CompleteLoadingRequest request) {
return completeLoading(tenantId, inboundId, inboundId, operatorId, operatorName, key, request); return completeLoading(tenantId, inboundId, tenantId, inboundId,
operatorId, operatorName, key, request);
} }
private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId, private LoadingActionResponse completeLoading(long tenantId, long inboundId,
long evidenceTenantId, long evidenceInboundId,
long operatorId, String operatorName, String key, long operatorId, String operatorName, String key,
CompleteLoadingRequest request) { CompleteLoadingRequest request) {
return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, return completeLoading(tenantId, inboundId, evidenceTenantId, evidenceInboundId,
key, request, false); operatorId, operatorName, key, request, false);
} }
private LoadingActionResponse completeLoading(long tenantId, long inboundId, long evidenceInboundId, private LoadingActionResponse completeLoading(long tenantId, long inboundId,
long evidenceTenantId, long evidenceInboundId,
long operatorId, String operatorName, String key, long operatorId, String operatorName, String key,
CompleteLoadingRequest request, CompleteLoadingRequest request,
boolean acceptedPdaCompletion) { boolean acceptedPdaCompletion) {
@@ -131,8 +134,8 @@ public class LoadingDeliveryService {
Snapshot inbound = requireInbound(tenantId, inboundId); Snapshot inbound = requireInbound(tenantId, inboundId);
CompletionTarget target = repository.completionTarget(tenantId, inboundId); CompletionTarget target = repository.completionTarget(tenantId, inboundId);
if (target != null) { if (target != null) {
return completeFulfillmentSlice(tenantId, evidenceInboundId, operatorId, operatorName, return completeFulfillmentSlice(tenantId, inboundId, evidenceTenantId, evidenceInboundId,
key, request, target, acceptedPdaCompletion); operatorId, operatorName, key, request, target, acceptedPdaCompletion);
} }
if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) { if ("LOADED".equals(inbound.processStatus()) || "DELIVERED".equals(inbound.processStatus())) {
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion); requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
@@ -143,7 +146,7 @@ public class LoadingDeliveryService {
requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion); requireLoadingPlanGate(tenantId, inboundId, acceptedPdaCompletion);
completionGate.requireReleasedBooking(tenantId, inboundId); completionGate.requireReleasedBooking(tenantId, inboundId);
requirePickingGate(tenantId, inboundId, acceptedPdaCompletion); requirePickingGate(tenantId, inboundId, acceptedPdaCompletion);
requireCompletionEvidence(tenantId, evidenceInboundId); requireCompletionEvidence(evidenceTenantId, evidenceInboundId);
if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent(); if (repository.completeLoading(tenantId, inboundId, operatorId, request.expectedVersion()) != 1) throw concurrent();
addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName); addHistory(tenantId, inboundId, "LOADING", "LOADED", COMPLETE_LOADING, request.remark(), operatorId, operatorName);
return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key, return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
@@ -159,12 +162,14 @@ public class LoadingDeliveryService {
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
long operatorId, String operatorName, long operatorId, String operatorName,
String key, String remark) { String key, String remark) {
return completeAfterPdaSettlement(tenantId, inboundId, inboundId, operatorId, operatorName, key, remark); return completeAfterPdaSettlement(tenantId, inboundId, tenantId, inboundId,
operatorId, operatorName, key, remark);
} }
@Transactional @Transactional
public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId, public LoadingActionResponse completeAfterPdaSettlement(long tenantId, long inboundId,
long evidenceInboundId, long operatorId, long evidenceTenantId, long evidenceInboundId,
long operatorId,
String operatorName, String key, String remark) { String operatorName, String key, String remark) {
CompletionTarget target = repository.completionTarget(tenantId, inboundId); CompletionTarget target = repository.completionTarget(tenantId, inboundId);
long completionInboundId = target == null ? inboundId : target.completionInboundId(); long completionInboundId = target == null ? inboundId : target.completionInboundId();
@@ -172,8 +177,8 @@ public class LoadingDeliveryService {
if (current == null || current.version() == null) { if (current == null || current.version() == null) {
throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试"); throw new BizException(ResultCode.CONFLICT, "PDA结算后的实装履约分片不存在,请刷新后重试");
} }
return completeLoading(tenantId, inboundId, evidenceInboundId, operatorId, operatorName, key, return completeLoading(tenantId, inboundId, evidenceTenantId, evidenceInboundId,
new CompleteLoadingRequest(current.version(), remark), true); operatorId, operatorName, key, new CompleteLoadingRequest(current.version(), remark), true);
} }
/** /**
@@ -181,27 +186,29 @@ public class LoadingDeliveryService {
* every external/business hard gate must pass before the PDA task is mutated. * every external/business hard gate must pass before the PDA task is mutated.
*/ */
public void requirePdaCompletionReady(long tenantId, long inboundId) { public void requirePdaCompletionReady(long tenantId, long inboundId) {
requirePdaCompletionReady(tenantId, inboundId, inboundId); requirePdaCompletionReady(tenantId, inboundId, tenantId, inboundId);
} }
public void requirePdaCompletionReady(long tenantId, long inboundId, long evidenceInboundId) { public void requirePdaCompletionReady(long tenantId, long inboundId,
long evidenceTenantId, long evidenceInboundId) {
Snapshot inbound = requireInbound(tenantId, inboundId); Snapshot inbound = requireInbound(tenantId, inboundId);
if (!"LOADING".equals(inbound.processStatus())) { if (!"LOADING".equals(inbound.processStatus())) {
throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING"); throw new BizException(ResultCode.CONFLICT, "PDA确认装柜完成前订单必须处于LOADING");
} }
loadingModelBridge.requireStarted(tenantId, inboundId); loadingModelBridge.requireStarted(tenantId, inboundId);
completionGate.requireReleasedBooking(tenantId, inboundId); completionGate.requireReleasedBooking(tenantId, inboundId);
requireCompletionEvidence(tenantId, evidenceInboundId); requireCompletionEvidence(evidenceTenantId, evidenceInboundId);
} }
private LoadingActionResponse completeFulfillmentSlice(long tenantId, long evidenceInboundId, private LoadingActionResponse completeFulfillmentSlice(long tenantId, long inboundId,
long evidenceTenantId, long evidenceInboundId,
long operatorId, String operatorName, String key, long operatorId, String operatorName, String key,
CompleteLoadingRequest request, CompleteLoadingRequest request,
CompletionTarget target, CompletionTarget target,
boolean acceptedPdaCompletion) { boolean acceptedPdaCompletion) {
if ("LOADED".equals(target.loadedChildStatus())) { if ("LOADED".equals(target.loadedChildStatus())) {
return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key, return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId))); new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId)));
} }
if (!"LOADING".equals(target.loadedChildStatus())) { if (!"LOADING".equals(target.loadedChildStatus())) {
throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中"); throw new BizException(ResultCode.CONFLICT, "实装履约分片不在装柜中");
@@ -209,14 +216,14 @@ public class LoadingDeliveryService {
requireLoadingPlanGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion); requireLoadingPlanGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
completionGate.requireReleasedBooking(tenantId, target.sourceInboundId()); completionGate.requireReleasedBooking(tenantId, target.sourceInboundId());
requirePickingGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion); requirePickingGate(tenantId, target.sourceInboundId(), acceptedPdaCompletion);
requireCompletionEvidence(tenantId, evidenceInboundId); requireCompletionEvidence(evidenceTenantId, evidenceInboundId);
if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId, if (repository.completeLoadedChild(tenantId, target.sourceInboundId(), operatorId,
request.expectedVersion()) != 1) throw concurrent(); request.expectedVersion()) != 1) throw concurrent();
repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId); repository.aggregateRootLoaded(tenantId, target.rootInboundId(), operatorId);
addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING, addHistory(tenantId, target.completionInboundId(), "LOADING", "LOADED", COMPLETE_LOADING,
request.remark(), operatorId, operatorName); request.remark(), operatorId, operatorName);
return complete(tenantId, evidenceInboundId, operatorId, COMPLETE_LOADING, key, return complete(tenantId, inboundId, operatorId, COMPLETE_LOADING, key,
new LoadingActionResponse("LOADED", evidenceResponse(tenantId, evidenceInboundId))); new LoadingActionResponse("LOADED", evidenceResponse(tenantId, inboundId)));
} }
public EvidenceListResponse evidence(long tenantId, long inboundId) { public EvidenceListResponse evidence(long tenantId, long inboundId) {
@@ -512,17 +512,32 @@ public class LoadingPickService {
.thenComparing(LoadingPickItemMapper.InboundOwner::inboundId)) .thenComparing(LoadingPickItemMapper.InboundOwner::inboundId))
.toList(); .toList();
if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单")); if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单"));
long evidenceInboundId = owners.get(0).inboundId(); LoadingPickItemMapper.InboundOwner evidenceOwner = owners.get(0);
// Phase 1 is read-only: every owner must pass its business gates against the same cabinet evidence owner
// before any warehouse resource or inbound status can be changed. This prevents a later customer gate from
// leaving earlier customers partially completed.
for (int index = 0; index < owners.size(); index++) { for (int index = 0; index < owners.size(); index++) {
requireCompletionLease(command.getId(), worker); requireCompletionLease(command.getId(), worker);
LoadingPickItemMapper.InboundOwner owner = owners.get(index); LoadingPickItemMapper.InboundOwner owner = owners.get(index);
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId()); String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
if (!checkpointAtLeast(checkpoint, "READY")) { if (!checkpointAtLeast(checkpoint, "SETTLED")) {
runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady( runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady(
owner.tenantId(), owner.inboundId(), evidenceInboundId)); owner.tenantId(), owner.inboundId(), evidenceOwner.tenantId(), evidenceOwner.inboundId()));
if (!checkpointAtLeast(checkpoint, "READY")) {
requireCompletionLease(command.getId(), worker); requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "READY"); completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(),
index + 1, "READY");
} }
}
}
// Phase 2 is recoverable by per-owner checkpoints and stable idempotency keys. It starts only after the
// complete cabinet passed phase 1, while prior deployments' completed checkpoints remain safely resumable.
for (int index = 0; index < owners.size(); index++) {
requireCompletionLease(command.getId(), worker);
LoadingPickItemMapper.InboundOwner owner = owners.get(index);
String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId());
if (!checkpointAtLeast(checkpoint, "SETTLED")) { if (!checkpointAtLeast(checkpoint, "SETTLED")) {
runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner( runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner(
taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName())); taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName()));
@@ -531,9 +546,9 @@ public class LoadingPickService {
} }
if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) { if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) {
runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement( runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement(
owner.tenantId(), owner.inboundId(), evidenceInboundId, command.getOperatorId(), owner.tenantId(), owner.inboundId(), evidenceOwner.tenantId(), evidenceOwner.inboundId(),
command.getOperatorName(), "PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getOperatorId(), command.getOperatorName(),
command.getRemark())); "PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark()));
requireCompletionLease(command.getId(), worker); requireCompletionLease(command.getId(), worker);
completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1,
"INBOUND_COMPLETED"); "INBOUND_COMPLETED");
@@ -253,10 +253,72 @@ class LoadingDeliveryServiceTest {
long.class, long.class, long.class, String.class, String.class, String.class) long.class, long.class, long.class, String.class, String.class, String.class)
.getAnnotation(Transactional.class)).isNotNull(); .getAnnotation(Transactional.class)).isNotNull();
assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement", assertThat(LoadingDeliveryService.class.getMethod("completeAfterPdaSettlement",
long.class, long.class, long.class, long.class, String.class, String.class, String.class) long.class, long.class, long.class, long.class, long.class,
String.class, String.class, String.class)
.getAnnotation(Transactional.class)).isNotNull(); .getAnnotation(Transactional.class)).isNotNull();
} }
@Test
void pdaPreflightUsesCabinetEvidenceOwnerWithoutCrossCustomerLookup() {
when(repository.findInbound(20, 200)).thenReturn(new Snapshot(200L, 20L, "LOADING", 3));
when(repository.listEvidence(10, 100)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(100L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
service.requirePdaCompletionReady(20, 200, 10, 100);
verify(loadingModelBridge).requireStarted(20, 200);
verify(completionGate).requireReleasedBooking(20, 200);
verify(repository).listEvidence(10, 100);
verify(repository, never()).listEvidence(20, 100);
verify(files, times(5)).validate(eq("token"), eq(10L), eq(100L), anyLong());
}
@Test
void pdaFinalizeKeepsOperationOnBusinessOwnerWhileUsingCabinetEvidenceOwner() {
when(repository.claimOperation(eq(20L), eq(200L), eq(7L), eq("COMPLETE_LOADING"),
eq("pda-multi-customer"), anyString())).thenReturn(true);
when(repository.findInboundForUpdate(20, 200)).thenReturn(new Snapshot(200L, 20L, "LOADING", 7));
when(repository.findInbound(20, 200)).thenReturn(new Snapshot(200L, 20L, "LOADING", 7));
LoadingPickItemMapper.InboundProgress progress = mock(LoadingPickItemMapper.InboundProgress.class);
when(progress.getExpectedCount()).thenReturn(1);
when(progress.getPickedCount()).thenReturn(1);
when(progress.getTaskCount()).thenReturn(1);
when(progress.getCompletedTaskCount()).thenReturn(0);
when(progress.getUnresolvedCount()).thenReturn(0);
when(loadingPicks.inboundProgress(20, 200)).thenReturn(progress);
when(repository.listEvidence(10, 100)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(100L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
when(repository.completeLoading(20, 200, 7, 7)).thenReturn(1);
LoadingActionResponse result = service.completeAfterPdaSettlement(
20, 200, 10, 100, 7, "admin", "pda-multi-customer", "完成");
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(repository).completeLoading(20, 200, 7, 7);
verify(repository).completeOperation(eq(20L), eq(200L), eq(7L),
eq("COMPLETE_LOADING"), eq("pda-multi-customer"), anyString());
verify(repository, never()).completeOperation(eq(20L), eq(100L), anyLong(),
anyString(), anyString(), anyString());
verify(repository).listEvidence(10, 100);
verify(repository, never()).listEvidence(20, 100);
}
@Test @Test
void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() { void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() {
readyForPdaFinalize(); readyForPdaFinalize();
@@ -567,17 +567,17 @@ class LoadingPickServiceTest {
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(settlementService).settle(20L, 9L, "operator"); verify(settlementService).settle(20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator"); verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L, 50L); verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L, 101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L, 50L); verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L, 101L, 50L);
verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator"); verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked"); "PDA-complete-1-50", "checked");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator", verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 101L, 50L, 9L, "operator",
"PDA-complete-1-60", "checked"); "PDA-complete-1-60", "checked");
verify(loadingListService).publishCompletedEvent(10L); verify(loadingListService).publishCompletedEvent(10L);
InOrder finalization = inOrder(loadingListService, loadingDeliveryService); InOrder finalization = inOrder(loadingListService, loadingDeliveryService);
finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked"); "PDA-complete-1-50", "checked");
finalization.verify(loadingListService).publishCompletedEvent(10L); finalization.verify(loadingListService).publishCompletedEvent(10L);
} }
@@ -608,7 +608,7 @@ class LoadingPickServiceTest {
verify(settlementService).settle(20L, 9L, "operator"); verify(settlementService).settle(20L, 9L, "operator");
verify(repository).approve(20L, 9L, "operator", "checked"); verify(repository).approve(20L, 9L, "operator", "checked");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator"); verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator",
"PDA-complete-not-blocked-50", "checked"); "PDA-complete-not-blocked-50", "checked");
verify(loadingListService).publishCompletedEvent(10L); verify(loadingListService).publishCompletedEvent(10L);
} }
@@ -627,7 +627,7 @@ class LoadingPickServiceTest {
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7)); new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"入库单并发修改")) "入库单并发修改"))
.when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 50L, 9L, "operator", .when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 101L, 50L, 9L, "operator",
"PDA-complete-finalize-fails-50", "checked"); "PDA-complete-finalize-fails-50", "checked");
assertThatThrownBy(() -> service.complete(20L, assertThatThrownBy(() -> service.complete(20L,
@@ -687,7 +687,7 @@ class LoadingPickServiceTest {
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).publishCompletedEvent(anyLong()); verify(loadingListService, never()).publishCompletedEvent(anyLong());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyLong(), anyString(), anyString(), anyString()); anyLong(), anyLong(), anyString(), anyString(), anyString());
} }
@Test @Test
@@ -827,6 +827,65 @@ class LoadingPickServiceTest {
verify(completionCommands).complete(900L, "worker-1"); verify(completionCommands).complete(900L, "worker-1");
} }
@Test
void workerUsesOneStableCabinetEvidenceOwnerAcrossTwoCustomers() {
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
when(repository.task(20L)).thenReturn(completing);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(202L, 80L),
new LoadingPickItemMapper.InboundOwner(101L, 60L),
new LoadingPickItemMapper.InboundOwner(202L, 70L),
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
when(completionCommands.complete(903L, "worker-multi")).thenReturn(true);
service.processCompletion(completionCommand(903L, "async-multi"), "worker-multi");
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L, 101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L, 101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(202L, 70L, 101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(202L, 80L, 101L, 50L);
verify(loadingDeliveryService).completeAfterPdaSettlement(
202L, 70L, 101L, 50L, 9L, "operator", "PDA-async-multi-70", "checked");
verify(settlementService, times(4)).settleOwner(eq(20L), anyLong(), eq(9L), eq("operator"));
verify(repository, times(1)).insert(argThat((LoadingPickOperationEntity operation) ->
"COMPLETE_LOADING".equals(operation.getActionType())
&& "async-multi".equals(operation.getOperationId())));
verify(loadingListService, times(1)).publishCompletedEvent(10L);
verify(completionCommands).complete(903L, "worker-multi");
}
@Test
void secondCustomerOwnerReadyFailureLeavesEveryOwnerUnsettledAndUncompleted() {
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
when(repository.task(20L)).thenReturn(completing);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L),
new LoadingPickItemMapper.InboundOwner(202L, 70L)));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "第二客户门禁未就绪"))
.when(loadingDeliveryService).requirePdaCompletionReady(202L, 70L, 101L, 50L);
assertThatThrownBy(() -> service.processCompletion(
completionCommand(904L, "async-owner-ready-fails"), "worker-failure"))
.isInstanceOf(LoadingPickService.CompletionProcessingException.class)
.satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException) error).stage())
.isEqualTo("OWNER_READY"));
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyLong(), anyLong(), anyString(), anyString(), anyString());
verify(completionCommands).checkpoint(904L, 101L, 50L, 1, "READY");
verify(completionCommands, never()).checkpoint(eq(904L), anyLong(), anyLong(), anyInt(),
eq("SETTLED"));
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).publishCompletedEvent(anyLong());
}
@Test @Test
void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() { void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() {
LoadingPickTaskEntity completing = task(); LoadingPickTaskEntity completing = task();
@@ -834,9 +893,9 @@ class LoadingPickServiceTest {
when(repository.task(20L)).thenReturn(completing); when(repository.task(20L)).thenReturn(completing);
when(repository.inboundOwners(20L)).thenReturn(List.of( when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L), new LoadingPickItemMapper.InboundOwner(101L, 50L),
new LoadingPickItemMapper.InboundOwner(101L, 60L))); new LoadingPickItemMapper.InboundOwner(202L, 60L)));
when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED"); when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED");
when(completionCommands.checkpoint(901L, 101L, 60L)).thenReturn("SETTLED"); when(completionCommands.checkpoint(901L, 202L, 60L)).thenReturn("SETTLED");
when(repository.allPlanItemsPicked(10L)).thenReturn(true); when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1); when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1); when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
@@ -846,8 +905,8 @@ class LoadingPickServiceTest {
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(101L), eq(50L), anyLong(), verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(101L), eq(50L), anyLong(),
anyLong(), anyString(), anyString(), anyString()); anyLong(), anyLong(), anyString(), anyString(), anyString());
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator", verify(loadingDeliveryService).completeAfterPdaSettlement(202L, 60L, 101L, 50L, 9L, "operator",
"PDA-async-resume-60", "checked"); "PDA-async-resume-60", "checked");
verify(loadingListService).publishCompletedEvent(10L); verify(loadingListService).publishCompletedEvent(10L);
verify(repository).insert(org.mockito.ArgumentMatchers.<LoadingPickOperationEntity>argThat( verify(repository).insert(org.mockito.ArgumentMatchers.<LoadingPickOperationEntity>argThat(