This commit is contained in:
2026-08-27 14:53:27 +08:00
parent ed3c1dafcc
commit d7bff980ca
18 changed files with 991 additions and 46 deletions
@@ -27,6 +27,7 @@ import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.LoadingCompletionCommandRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
@@ -35,6 +36,7 @@ import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.InOrder;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -45,6 +47,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.inOrder;
@@ -54,6 +57,7 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.clearInvocations;
import static org.assertj.core.api.Assertions.assertThat;
@SpringJUnitConfig(LoadingPickService.class)
@@ -61,6 +65,8 @@ class LoadingPickServiceTest {
@MockBean
private LoadingPickRepository repository;
@MockBean
private LoadingCompletionCommandRepository completionCommands;
@MockBean
private SmartLoadingRepository smartLoadingRepository;
@MockBean
private OceanBookingRepository oceanBookingRepository;
@@ -89,6 +95,9 @@ class LoadingPickServiceTest {
booking.setSourceType("SIMULATION");
booking.setRecordStatus("ACTIVE");
when(repository.activeBookingByTask(20L)).thenReturn(booking);
when(completionCommands.insert(any())).thenReturn(1);
when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true);
when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1);
}
@Test
@@ -489,6 +498,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
@@ -539,6 +549,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void conflictingTruckAppointmentsDoNotBlockPdaCompletion() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
@@ -569,6 +580,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
@@ -593,6 +605,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void translatesUnexpectedSettlementFailureIntoActionableStageMessage() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
@@ -615,6 +628,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void warehouseSettlementConflictLeavesCompletionClaimAndDownstreamFinalizationUnwritten() {
LoadingPickTaskEntity task = task();
task.setVersion(1);
@@ -643,6 +657,7 @@ class LoadingPickServiceTest {
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void currentCompleteRequestFinishesAfterUniqueResourceSettlementSucceeds() {
LoadingPickTaskEntity task = task();
task.setVersion(1);
@@ -670,6 +685,141 @@ class LoadingPickServiceTest {
verify(loadingListService).publishCompletedEvent(10L);
}
@Test
void sameOperationIdReplayReturnsProcessingWithoutAnotherTaskForUpdate() {
LoadingPickTaskEntity loading = task();
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
completing.setVersion(1);
AtomicReference<com.baiyue.firstleg.entity.LoadingCompletionCommandEntity> accepted = new AtomicReference<>();
when(completionCommands.operation("async-complete-1")).thenAnswer(invocation -> accepted.get());
doAnswer(invocation -> {
accepted.set(invocation.getArgument(0));
return 1;
}).when(completionCommands).insert(any());
when(repository.taskForUpdate(20L)).thenReturn(loading);
when(repository.task(20L)).thenReturn(completing);
when(repository.items(20L)).thenReturn(List.of(item()));
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
Task first = service.complete(20L,
new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator");
verify(repository).taskForUpdate(20L);
clearInvocations(repository);
Task replay = org.junit.jupiter.api.Assertions.assertTimeout(java.time.Duration.ofMillis(200), () ->
service.complete(20L,
new CompleteLoadingRequest(0, "async-complete-1", "checked"), 9L, "operator"));
assertThat(first.taskStatus()).isEqualTo("COMPLETING");
assertThat(first.workflowStatus()).isEqualTo("PROCESSING");
assertThat(replay.taskStatus()).isEqualTo("COMPLETING");
verify(repository, never()).taskForUpdate(20L);
verify(completionCommands, times(1)).insert(any());
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
void differentOperationIdIsRejectedImmediatelyWithConflict() {
// different operationId is a canonical A0409 CONFLICT while the durable command is active.
LoadingPickTaskEntity loading = task();
when(repository.taskForUpdate(20L)).thenReturn(loading);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
var active = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
active.setOperationId("already-processing");
when(completionCommands.active(20L)).thenReturn(active);
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "different-operation", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("其他装柜完成命令");
verify(completionCommands, never()).insert(any());
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
void failedSameOperationIdCanResumeWithoutCreatingAnotherCommand() {
LoadingPickTaskEntity failed = task();
failed.setTaskStatus("COMPLETION_FAILED");
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
var command = completionCommand(902L, "resume-same-operation");
command.setRequestHash("48ea6be16079a761f4e77abcf58efaafebc2ab629250ce06118315232e9f3070");
command.setCommandStatus("FAILED");
command.setExpectedTaskVersion(0);
when(completionCommands.operation("resume-same-operation")).thenReturn(command);
when(completionCommands.resume(902L)).thenReturn(true);
when(repository.taskForUpdate(20L)).thenReturn(failed);
when(repository.resumeCompletion(20L)).thenReturn(1);
when(repository.task(20L)).thenReturn(completing);
when(repository.items(20L)).thenReturn(List.of(item()));
when(repository.inboundOwners(20L)).thenReturn(List.of(new LoadingPickItemMapper.InboundOwner(101L, 50L)));
Task result = service.complete(20L,
new CompleteLoadingRequest(0, "resume-same-operation", "checked"), 9L, "operator");
assertThat(result.workflowStatus()).isEqualTo("PROCESSING");
verify(completionCommands).resume(902L);
verify(repository).resumeCompletion(20L);
verify(completionCommands, never()).insert(any());
}
@Test
void workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce() {
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
when(repository.task(20L)).thenReturn(completing);
List<LoadingPickItemMapper.InboundOwner> owners = java.util.stream.IntStream.range(0, 15)
.mapToObj(index -> new LoadingPickItemMapper.InboundOwner(101L, 1014L - index)).toList();
when(repository.inboundOwners(20L)).thenReturn(owners);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.review(20L)).thenReturn(null);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
when(repository.completeAccepted(20L, 9L, "operator")).thenReturn(1);
when(completionCommands.complete(900L, "worker-1")).thenReturn(true);
var command = completionCommand(900L, "async-15");
service.processCompletion(command, "worker-1");
ArgumentCaptor<Long> inboundIds = ArgumentCaptor.forClass(Long.class);
verify(settlementService, times(15)).settleOwner(eq(20L), inboundIds.capture(), eq(9L), eq("operator"));
assertThat(inboundIds.getAllValues()).containsExactly(
1000L, 1001L, 1002L, 1003L, 1004L, 1005L, 1006L, 1007L,
1008L, 1009L, 1010L, 1011L, 1012L, 1013L, 1014L);
verify(loadingListService).publishCompletedEvent(10L);
verify(repository).completeAccepted(20L, 9L, "operator");
verify(completionCommands).complete(900L, "worker-1");
}
@Test
void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() {
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(101L, 60L)));
when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED");
when(completionCommands.checkpoint(901L, 101L, 60L)).thenReturn("SETTLED");
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(901L, "worker-2")).thenReturn(true);
service.processCompletion(completionCommand(901L, "async-resume"), "worker-2");
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(eq(101L), eq(50L), anyLong(),
anyLong(), anyString(), anyString(), anyString());
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 50L, 9L, "operator",
"PDA-async-resume-60", "checked");
verify(loadingListService).publishCompletedEvent(10L);
verify(repository).insert(org.mockito.ArgumentMatchers.<LoadingPickOperationEntity>argThat(
operation -> "COMPLETE_LOADING".equals(operation.getActionType())));
}
@Test
void atomicallySubmitsCompleteBatchAndReplaysSameResult() {
LoadingPickTaskEntity task = task();
@@ -871,6 +1021,21 @@ class LoadingPickServiceTest {
when(repository.items(20L, 101L)).thenReturn(List.of(item));
}
private static com.baiyue.firstleg.entity.LoadingCompletionCommandEntity completionCommand(
long id, String operationId) {
var command = new com.baiyue.firstleg.entity.LoadingCompletionCommandEntity();
command.setId(id);
command.setPickTaskId(20L);
command.setOperationId(operationId);
command.setRequestHash("hash");
command.setCommandStatus("PROCESSING");
command.setOperatorId(9L);
command.setOperatorName("operator");
command.setRemark("checked");
command.setAttemptCount(1);
return command;
}
private static LoadingPickTaskEntity task() {
LoadingPickTaskEntity task = new LoadingPickTaskEntity();
task.setId(20L);