Files
baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java
T
2026-09-10 15:14:30 +08:00

1581 lines
81 KiB
Java

package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.LoadingPickDtos.PartialPickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.PickRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ReviewRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.SkipRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.BatchItemRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.ClaimRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.AssignRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.CompleteLoadingRequest;
import com.baiyue.firstleg.dto.LoadingPickDtos.Task;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.LoadingPickAssignmentEntity;
import com.baiyue.firstleg.entity.LoadingPickHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPickItemEntity;
import com.baiyue.firstleg.entity.LoadingPickOperationEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.entity.LoadingPlanHistoryEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.service.LoadingDeliveryService;
import com.baiyue.firstleg.loading.service.CabinetLoadingEvidenceService;
import com.baiyue.firstleg.loading.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.loading.coload.port.ColoadGroupAssignmentPort;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.GroupSnapshot;
import com.baiyue.firstleg.loading.coload.model.ColoadGroupModels.Member;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
import com.baiyue.firstleg.mapper.LoadingPickItemMapper;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.InboundPalletAllocationRepository;
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;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InOrder;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.time.LocalDateTime;
import java.util.function.Consumer;
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;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.mock;
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)
class LoadingPickServiceTest {
@MockBean
private LoadingPickRepository repository;
@MockBean
private LoadingCompletionCommandRepository completionCommands;
@MockBean
private TransactionTemplate completionTransaction;
@MockBean
private SmartLoadingRepository smartLoadingRepository;
@MockBean
private OceanBookingRepository oceanBookingRepository;
@MockBean
private LoadingModelBridgeService loadingModelBridge;
@MockBean
private LoadingListService loadingListService;
@MockBean
private LoadingDeliveryService loadingDeliveryService;
@MockBean
private CabinetLoadingEvidenceService cabinetEvidenceService;
@MockBean
private ColoadGroupAssignmentPort coloadGroups;
@MockBean
private InboundStatusHistoryMapper inboundHistories;
@MockBean
private PdaPickSettlementService settlementService;
@MockBean
private InboundPalletAllocationRepository palletAllocations;
@MockBean
private InboundHoldGuard inboundHoldGuard;
@Resource
private LoadingPickService service;
@Test
void heldPlanBlocksDirectPdaItemSynchronizationBeforeTaskMutation() {
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单已扣货"))
.when(inboundHoldGuard).requirePlanNotHeld(10L);
assertThatThrownBy(() -> service.sync(10L, new LoadingPlanItemEntity()))
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
verify(repository, never()).taskByPlan(anyLong());
}
@Test
void heldTaskStopsDurableCompletionWorkerBeforeAnyFulfillmentReadOrWrite() {
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, "入库单已扣货"))
.when(inboundHoldGuard).requireTaskNotHeld(20L);
assertThatThrownBy(() -> service.processCompletion(
completionCommand(999L, "held-worker"), "worker-held"))
.isInstanceOf(BizException.class).hasMessageContaining("扣货");
verify(repository, never()).task(anyLong());
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
}
@BeforeEach
void activeOperationalContainer() {
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
Consumer<TransactionStatus> action = invocation.getArgument(0);
action.accept(mock(TransactionStatus.class));
return null;
}).when(completionTransaction).executeWithoutResult(any());
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setId(99L);
booking.setContainerNo("MSCU6639870");
booking.setWarehouseId(101L);
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);
when(repository.inboundOwners(anyLong())).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(repository.inbound(anyLong(), anyLong())).thenAnswer(invocation ->
new LoadingInboundMapper.Snapshot(invocation.getArgument(1), invocation.getArgument(0), "LOADING", 1));
}
@Test
void opensTaskAfterReleasedBookingWithoutWaitingForRealContainerNumber() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L);
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED");
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(smartLoadingRepository.plan(10L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, null, "EDITING", 1, 1, null)));
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
when(repository.taskByPlan(10L)).thenReturn(null);
when(smartLoadingRepository.items(10L)).thenReturn(List.of());
doAnswer(invocation -> {
invocation.getArgument(0, LoadingPickTaskEntity.class).setId(20L);
return null;
}).when(repository).insert(any(LoadingPickTaskEntity.class));
service.beginIfReady(40L, 7L, "admin");
verify(repository).insert(any(LoadingPickTaskEntity.class));
}
@Test
void doesNotOpenTaskBeforeOceanRelease() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("CONFIRMED");
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
service.beginIfReady(40L, 7L, "admin");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
}
@Test
void doesNotOpenTaskBeforeEveryLinkedPreloadIsConfirmed() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "PENDING", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
service.beginIfReady(40L, 7L, "admin");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(oceanBookingRepository, never()).allocationByContainer(40L);
}
@Test
void planConfirmationGateRequiresReleasedBookingButNotRealContainerNumber() {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, "MSCU-001", "EDITING", 3, 2, null);
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity();
ocean.setId(90L); ocean.setStatus("RELEASED");
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
}
@Test
void planConfirmationGateAllowsMissingRealContainerNumber() {
Link link = new Link(1L, 1L, 50L, 2L, "PENDING", 1,
10L, 40L, null, "EDITING", 3, 2, null);
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity();
ocean.setId(90L); ocean.setStatus("RELEASED");
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L)).thenReturn(List.of(link));
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
}
@Test
void newKeyCannotConfirmAnAlreadyConfirmedPlanAgain() {
Link confirmed = new Link(1L, 1L, 50L, 2L, "CONFIRMED", 2,
10L, 40L, "MSCU-001", "EDITING", 4, 2, null);
when(loadingModelBridge.requireAllocatedLinksForConfirmation(10L))
.thenReturn(List.of(confirmed));
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
.isInstanceOf(BizException.class).hasMessageContaining("已确认");
verify(smartLoadingRepository, never()).booking(anyLong());
}
@Test
void readinessCreatesAtMostOneTask() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity(); ocean.setId(90L); ocean.setStatus("RELEASED");
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(smartLoadingRepository.planByBooking(40L)).thenReturn(plan);
when(loadingModelBridge.byLoadingPlan(10L)).thenReturn(List.of(
new Link(1L, 1L, 50L, 2L, "CONFIRMED", 1, 10L, 40L, "MSCU-001", "EDITING", 1, 1, null)));
when(oceanBookingRepository.allocationByContainer(40L)).thenReturn(allocation);
when(oceanBookingRepository.find(90L)).thenReturn(ocean);
when(repository.taskByPlan(10L)).thenReturn(new LoadingPickTaskEntity());
service.beginIfReady(40L, 7L, "admin");
verify(repository, never()).insert(any(LoadingPickTaskEntity.class));
verify(smartLoadingRepository, never()).startPicking(
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyString());
}
@Test
void stableCabinetAndPlanQueriesReturnSameTaskWithCurrentContainerNumber() {
LoadingPickTaskEntity task = task();
task.setContainerBookingId(40L); task.setCabinetNo("CAB-001");
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-NEW");
booking.setWarehouseId(101L);
booking.setSourceType("SIMULATION"); booking.setRecordStatus("ACTIVE");
when(repository.taskByCabinet("CAB-001", 101L)).thenReturn(task);
when(repository.taskByPlan(10L, 101L)).thenReturn(task);
when(smartLoadingRepository.booking(40L)).thenReturn(booking);
when(repository.activeBookingByTask(20L)).thenReturn(booking);
when(repository.items(20L, 101L)).thenReturn(List.of());
when(repository.cartonLabels(20L)).thenReturn(List.of());
var byCabinet = service.task("CAB-001", null, 101L);
var byPlan = service.task(null, 10L, 101L);
assertThat(byCabinet.id()).isEqualTo(20L);
assertThat(byPlan.id()).isEqualTo(20L);
assertThat(byCabinet.currentContainerNo()).isEqualTo("MSCU-NEW");
assertThat(byCabinet.sourceType()).isEqualTo("SIMULATION");
assertThat(byCabinet.recordStatus()).isEqualTo("ACTIVE");
assertThat(byCabinet.mergedIntoContainerBookingId()).isNull();
}
@Test
void pendingTasksExposeServerFilteredContainerLifecycle() {
var row = new com.baiyue.firstleg.mapper.LoadingPickTaskMapper.PendingTaskRow();
row.setTaskId(20L);
row.setLoadingPlanId(10L);
row.setContainerBookingId(99L);
row.setCabinetNo("CAB-001");
row.setContainerNo("MSCU-001");
row.setSourceType("SIMULATION");
row.setRecordStatus("ACTIVE");
row.setMergedIntoContainerBookingId(null);
row.setWarehouseId(101L);
row.setTaskStatus("LOADING");
row.setWorkflowStatus("AWAITING_REVIEW");
row.setVersion(0);
row.setAssignmentVersion(0);
row.setItemCount(1);
row.setPendingItemCount(0);
when(repository.pendingTasks(9L, null)).thenReturn(List.of(row));
var result = service.pendingTasks(9L, null).get(0);
assertThat(result.sourceType()).isEqualTo("SIMULATION");
assertThat(result.recordStatus()).isEqualTo("ACTIVE");
assertThat(result.mergedIntoContainerBookingId()).isNull();
assertThat(result.workflowStatus()).isEqualTo("AWAITING_REVIEW");
assertThat(result.pendingItemCount()).isZero();
}
@Test
void taskDetailRejectsMergedContainerSource() {
LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(task);
when(repository.activeBookingByTask(20L)).thenReturn(null);
when(repository.items(20L, 101L)).thenReturn(List.of());
when(repository.cartonLabels(20L)).thenReturn(List.of());
assertThatThrownBy(() -> service.task(20L, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("已非有效存续记录");
}
@Test
void rejectsMissingOrConflictingCompatibilitySelectors() {
assertThatThrownBy(() -> service.task(null, null, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个");
assertThatThrownBy(() -> service.task("CAB", 10L, 101L))
.isInstanceOf(BizException.class).hasMessageContaining("必须且只能提供一个");
}
@Test
void keepsPlanLoadingWhenItemsRemain() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
preparePick(task, item, "pick-1");
when(repository.allTaskItemsResolved(20L)).thenReturn(false);
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-1"), 9L, "operator");
verify(repository).touchTask(20L, 0);
verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(settlementService, never()).settle(20L, 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
}
@Test
void keepsResourcesLockedAfterLastPickedItemUntilManualCompletion() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
preparePick(task, item, "pick-2");
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-2"), 9L, "operator");
verify(repository).touchTask(20L, 0);
verify(settlementService, never()).settle(20L, 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(repository).insert(any(LoadingPickHistoryEntity.class));
}
@Test
void keepsPickAsStartLoadingFallbackForOlderClients() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
preparePick(task, item, "pick-start-loading");
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1);
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-start-loading"),
9L, "operator");
verify(smartLoadingRepository).startPicking(10L, 9L, "operator");
verify(smartLoadingRepository).insert(any(com.baiyue.firstleg.entity.LoadingPlanHistoryEntity.class));
}
@Test
void startsInboundLoadingEvenWhenPlanWasAlreadyStarted() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
preparePick(task, item, "pick-start-inbound");
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(0);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(repository.inbound(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4));
when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(1);
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-start-inbound"),
9L, "operator");
verify(repository).beginInboundLoading(101L, 50L, 9L, 4);
verify(inboundHistories).insert(argThat((InboundStatusHistoryEntity history) ->
Long.valueOf(101L).equals(history.getTenantId())
&& Long.valueOf(50L).equals(history.getInboundId())
&& "WAIT_SPLIT".equals(history.getFromStatus())
&& "LOADING".equals(history.getToStatus())
&& "PDA_START_LOADING".equals(history.getAction())));
verify(smartLoadingRepository, never()).insert(any(
com.baiyue.firstleg.entity.LoadingPlanHistoryEntity.class));
}
@Test
void acceptsConcurrentInboundTransitionWithoutDuplicateHistory() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
preparePick(task, item, "pick-concurrent-inbound");
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(repository.inbound(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "WAIT_SPLIT", 4),
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 5));
when(repository.beginInboundLoading(101L, 50L, 9L, 4)).thenReturn(0);
service.pick(20L, 30L, new PickRequest(101L, 2, 0, "pick-concurrent-inbound"),
9L, "operator");
verify(repository).beginInboundLoading(101L, 50L, 9L, 4);
verify(inboundHistories, never()).insert(any(InboundStatusHistoryEntity.class));
}
@Test
void taskDetailReturnsServerInboundAndLoadingVersions() {
LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
when(repository.cartonLabels(20L)).thenReturn(List.of());
when(repository.assignment(20L)).thenReturn(null);
when(repository.review(20L)).thenReturn(null);
when(repository.allTaskItemsResolved(20L)).thenReturn(false);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(palletAllocations.list(101L, 50L)).thenReturn(List.of(allocation(50L, 2)));
when(repository.inbound(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
var result = service.task(20L, 101L);
assertThat(result.inboundId()).isEqualTo(50L);
assertThat(result.inboundVersion()).isEqualTo(7);
assertThat(result.loadingVersion()).isEqualTo(7);
assertThat(result.loadingPlanId()).isEqualTo(10L);
assertThat(result.containerBookingId()).isEqualTo(99L);
assertThat(result.sourceType()).isEqualTo("SIMULATION");
assertThat(result.recordStatus()).isEqualTo("ACTIVE");
assertThat(result.mergedIntoContainerBookingId()).isNull();
assertThat(result.items().get(0).inventoryResources()).singleElement().satisfies(resource -> {
assertThat(resource.blockId()).isEqualTo(71L);
assertThat(resource.slotNo()).isEqualTo(1);
assertThat(resource.reservationId()).isEqualTo(72L);
assertThat(resource.locationId()).isEqualTo(74L);
assertThat(resource.availablePackageCount()).isEqualTo(2);
});
verify(palletAllocations).list(101L, 50L);
verify(completionCommands, never()).latest(20L);
}
@Test
void loadingTaskDetailDoesNotDependOnCompletionCommandStorage() {
LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
when(repository.cartonLabels(20L)).thenReturn(List.of());
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(palletAllocations.list(101L, 50L)).thenReturn(List.of());
doThrow(new IllegalStateException("completion command schema unavailable"))
.when(completionCommands).latest(20L);
var result = service.task(20L, 101L);
assertThat(result.taskStatus()).isEqualTo("LOADING");
assertThat(result.completionCommand()).isNull();
verify(completionCommands, never()).latest(20L);
}
@Test
void taskDetailDoesNotRecheckWarehouseOwnership() {
LoadingPickTaskEntity task = task();
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item()));
when(repository.items(20L, 999L)).thenReturn(List.of(item()));
when(repository.cartonLabels(20L)).thenReturn(List.of());
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
when(palletAllocations.list(101L, 50L)).thenReturn(List.of(allocation(50L, 2, 202L)));
var result = service.task(20L, 999L);
assertThat(result.id()).isEqualTo(20L);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).inventoryResources()).singleElement()
.extracting("availablePackageCount").isEqualTo(2);
}
@Test
void claimStartsLoadingAndSameOperatorReentryDoesNotDuplicateStartHistory() {
LoadingPickTaskEntity task = task();
LoadingPickAssignmentEntity assignment = assignment();
AtomicReference<LoadingPickOperationEntity> operation = new AtomicReference<>();
when(repository.operation("claim-1")).thenAnswer(invocation -> operation.get());
when(repository.operation("claim-2")).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(null, assignment);
when(repository.createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED")).thenReturn(1);
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1, 0);
doAnswer(invocation -> {
LoadingPickOperationEntity value = invocation.getArgument(0);
if ("claim-1".equals(value.getOperationId())) operation.set(value);
return null;
}).when(repository).insert(any(LoadingPickOperationEntity.class));
service.claim(20L, new ClaimRequest(0, "claim-1"), 9L, "operator");
service.claim(20L, new ClaimRequest(0, "claim-1"), 9L, "operator");
service.claim(20L, new ClaimRequest(0, "claim-2"), 9L, "operator");
verify(smartLoadingRepository, times(2)).startPicking(10L, 9L, "operator");
verify(smartLoadingRepository, times(1)).insert(argThat(
(com.baiyue.firstleg.entity.LoadingPlanHistoryEntity history) ->
"START_LOADING".equals(history.getActionType())
&& "EDITING".equals(history.getFromStatus())
&& "LOADING".equals(history.getToStatus())
&& "PDA-START-20".equals(history.getOperationId())));
verify(repository, times(1)).createAssignment(20L, 9L, "operator", 9L, "operator", "CLAIMED");
}
@Test
void claimRechecksSameOperationAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickOperationEntity completed = taskOperation("claim-race", "CLAIM");
when(repository.operation("claim-race")).thenReturn(null);
when(repository.operationForUpdate("claim-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of());
Task replay = service.claim(20L, new ClaimRequest(0, "claim-race"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(),
anyString(), anyString());
verify(smartLoadingRepository, never()).startPicking(anyLong(), anyLong(), anyString());
}
@Test
void assignRechecksSameOperationAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickOperationEntity completed = taskOperation("assign-race", "ASSIGN");
when(repository.operation("assign-race")).thenReturn(null);
when(repository.operationForUpdate("assign-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of());
Task replay = service.assign(20L, new AssignRequest(11L, "picker", 0, "assign-race"),
9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).createAssignment(anyLong(), anyLong(), anyString(), anyLong(),
anyString(), anyString());
verify(repository, never()).reassign(anyLong(), anyLong(), anyString(), anyLong(),
anyString(), anyString(), anyInt());
}
@Test
void pickRechecksSameHistoryAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickHistoryEntity completed = itemHistory("pick-race", "PICK",
"20|30|101|2");
when(repository.history("pick-race")).thenReturn(null);
when(repository.historyForUpdate("pick-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
Task replay = service.pick(20L, 30L,
new PickRequest(101L, 2, 0, "pick-race"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).pick(anyLong(), anyInt(), anyInt(), anyLong());
verify(repository, never()).insert(any(LoadingPickHistoryEntity.class));
verify(repository, never()).touchTask(anyLong(), anyInt());
}
@Test
void partialRechecksSameHistoryAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickHistoryEntity completed = itemHistory("partial-race", "PARTIAL",
"PARTIAL|20|30|101|1");
when(repository.history("partial-race")).thenReturn(null);
when(repository.historyForUpdate("partial-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
Task replay = service.partial(20L, 30L,
new PartialPickRequest(101L, 1, 0, "partial-race"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).partial(anyLong(), anyInt(), anyInt(), anyLong());
verify(repository, never()).insert(any(LoadingPickHistoryEntity.class));
verify(repository, never()).touchTask(anyLong(), anyInt());
}
@Test
void skipRechecksSameHistoryAfterWaitingForTaskLock() {
LoadingPickTaskEntity task = task();
LoadingPickHistoryEntity completed = itemHistory("skip-race", "SKIP",
"SKIP|20|30|101|damaged");
when(repository.history("skip-race")).thenReturn(null);
when(repository.historyForUpdate("skip-race")).thenReturn(completed);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L, 101L)).thenReturn(List.of(item()));
Task replay = service.skip(20L, 30L,
new SkipRequest(101L, 0, "skip-race", "damaged"), 9L, "operator");
assertThat(replay.id()).isEqualTo(20L);
verify(repository, never()).skip(anyLong(), anyInt(), anyLong());
verify(repository, never()).insert(any(LoadingPickHistoryEntity.class));
verify(repository, never()).touchTask(anyLong(), anyInt());
}
@Test
void keepsPartialPickLockedUntilManualCompletion() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
preparePartial(task, item, "partial-1");
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
service.partial(20L, 30L, new PartialPickRequest(101L, 1, 0, "partial-1"),
9L, "operator");
verify(repository).touchTask(20L, 0);
verify(settlementService, never()).settle(20L, 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
}
@Test
void rejectsSkipWhenInboundAlreadyHasPickedItem() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity skipped = item();
LoadingPickItemEntity picked = item();
picked.setId(31L);
picked.setItemStatus("PICKED");
picked.setPickedPackageCount(2);
when(repository.history("skip-1")).thenReturn(null);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(skipped);
when(repository.hasPickedOrPartialItem(20L, 50L)).thenReturn(true);
assertThatThrownBy(() -> service.skip(20L, 30L,
new SkipRequest(101L, 0, "skip-1", "fixture unavailable"), 9L, "operator"))
.isInstanceOf(BizException.class);
verify(repository, never()).skip(30L, 0, 101L);
verify(settlementService, never()).settle(20L, 9L, "operator");
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void manualCompletionSettlesResolvedItemsCompletesPlanAndReplaysSameOperation() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
AtomicReference<LoadingPickOperationEntity> operation = new AtomicReference<>();
item.setPlannedPackageCount(5);
item.setPickedPackageCount(3);
item.setItemStatus("PARTIAL");
when(repository.operation("complete-1")).thenAnswer(invocation -> operation.get());
doAnswer(invocation -> {
operation.set(invocation.getArgument(0));
return null;
}).when(repository).insert(any(LoadingPickOperationEntity.class));
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
var incrementalOwner = new LoadingPickItemMapper.InboundOwner(101L, 60L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner, incrementalOwner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
when(repository.inboundForUpdate(101L, 60L)).thenReturn(
new LoadingInboundMapper.Snapshot(60L, 101L, "LOADING", 3));
service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator");
service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator");
verify(repository).insert(any(LoadingPickOperationEntity.class));
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).syncInboundProjections(20L, 9L, "operator");
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L);
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 9L, "operator",
"PDA-complete-1-60", "checked");
verify(loadingListService).publishCompletedEvent(10L);
InOrder finalization = inOrder(loadingListService, loadingDeliveryService);
finalization.verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
finalization.verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-1-50", "checked");
finalization.verify(loadingListService).publishCompletedEvent(10L);
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "checked")).thenReturn(1);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"入库单并发修改"))
.when(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-complete-finalize-fails-50", "checked");
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "complete-finalize-fails", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("并发修改");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingListService, never()).publishCompletedEvent(anyLong());
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void translatesUnexpectedSettlementFailureIntoActionableStageMessage() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new IllegalStateException("unexpected database failure"))
.when(settlementService).settle(20L, 9L, "operator");
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "complete-stage-error", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("拆单与库位结算")
.hasMessageContaining("请求编号");
verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString());
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void warehouseSettlementConflictLeavesCompletionClaimAndDownstreamFinalizationUnwritten() {
LoadingPickTaskEntity task = task();
task.setVersion(1);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"释放库位资源时发生并发变更"))
.when(settlementService).settle(20L, 9L, "operator");
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(1, "complete-real-a0409", "PDA确认装柜完成"), 9L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("释放库位资源时发生并发变更");
verify(repository, never()).approve(anyLong(), anyLong(), anyString(), anyString());
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
verify(repository, never()).syncInboundProjections(anyLong(), anyLong(), anyString());
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).publishCompletedEvent(anyLong());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), anyString());
}
@Test
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
void currentCompleteRequestFinishesAfterUniqueResourceSettlementSucceeds() {
LoadingPickTaskEntity task = task();
task.setVersion(1);
LoadingPickItemEntity item = item();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.approve(20L, 9L, "operator", "PDA确认装柜完成")).thenReturn(1);
var owner = new LoadingPickItemMapper.InboundOwner(101L, 50L);
when(repository.inboundOwners(20L)).thenReturn(List.of(owner));
when(repository.inboundForUpdate(101L, 50L)).thenReturn(
new LoadingInboundMapper.Snapshot(50L, 101L, "LOADING", 7));
Task result = service.complete(20L, new CompleteLoadingRequest(1,
"complete-1000001-2092817774453780481-2092787765815078913--45b8eb0887ff317c",
"PDA确认装柜完成"), 9L, "operator");
assertThat(result.id()).isEqualTo(20L);
verify(settlementService).settle(20L, 9L, "operator");
verify(repository).insert(org.mockito.ArgumentMatchers.argThat(
(LoadingPickOperationEntity operation) -> "COMPLETE_LOADING".equals(operation.getActionType())));
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
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 completionRejectsMissingRealContainerNumberBeforeCreatingCommand() {
LoadingPickTaskEntity loading = task();
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setId(99L);
booking.setWarehouseId(101L);
booking.setRecordStatus("ACTIVE");
when(repository.taskForUpdate(20L)).thenReturn(loading);
when(repository.activeBookingByTask(20L)).thenReturn(booking);
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "complete-missing-container", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("确认装柜完成前");
verify(repository, never()).allTaskItemsResolved(20L);
verify(completionCommands, never()).insert(any());
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
}
@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() {
assertNoTruckCoupling();
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 shortPickedColoadMemberCannotCreateCompletionCommandOrRemainderChild() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity shortPicked = item();
shortPicked.setInboundId(50L);
shortPicked.setPlannedPackageCount(5);
shortPicked.setPickedPackageCount(4);
shortPicked.setItemStatus("PARTIAL");
LoadingPickItemEntity complete = item();
complete.setId(31L);
complete.setInboundId(60L);
complete.setPlannedPackageCount(5);
complete.setPickedPackageCount(5);
complete.setItemStatus("PICKED");
GroupSnapshot group = coloadGroup("LOCKED", 10L,
new Member(101L, 301L, 50L, "IN-50", 5), new Member(101L, 301L, 60L, "IN-60", 5));
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L),
new LoadingPickItemMapper.InboundOwner(101L, 60L)));
when(repository.items(20L)).thenReturn(List.of(shortPicked, complete));
when(coloadGroups.findByInbound(101L, 50L)).thenReturn(group);
when(coloadGroups.findByInbound(101L, 60L)).thenReturn(group);
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "complete-short-coload", "短拣异常"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("全组箱数守恒");
verify(completionCommands, never()).insert(any());
verify(repository, never()).acceptCompletion(anyLong(), anyInt());
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
}
@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");
}
@ParameterizedTest(name = "truck state {0} never participates in PDA completion")
@ValueSource(strings = {"NO_APPOINTMENT", "DRAFT", "IN_PROGRESS", "COMPLETED"})
void completionWorkerIsIndependentOfEveryTruckAppointmentState(String truckState) {
assertNoTruckCoupling();
LoadingPickTaskEntity completing = task();
completing.setTaskStatus("COMPLETING");
when(repository.task(20L)).thenReturn(completing);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(101L, 50L)));
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);
long commandId = 920L + Math.abs(truckState.hashCode() % 20);
when(completionCommands.complete(commandId, "worker-truck-independent")).thenReturn(true);
service.processCompletion(completionCommand(commandId,
"async-truck-independent-" + truckState.toLowerCase()), "worker-truck-independent");
verify(cabinetEvidenceService).requireComplete(99L);
verify(settlementService).settleOwner(20L, 50L, 9L, "operator");
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
"PDA-async-truck-independent-" + truckState.toLowerCase() + "-50", "checked");
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
verify(loadingListService).publishCompletedEvent(10L);
verify(repository).completeAccepted(20L, 9L, "operator");
verify(completionCommands).complete(commandId, "worker-truck-independent");
}
@Test
void workerUsesCabinetEvidenceOnceWithReversedTwoCustomerOwnerOrder() {
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);
GroupSnapshot group = coloadGroup("LOCKED", 10L,
new Member(101L, 301L, 50L, "IN-50", 1), new Member(101L, 302L, 60L, "IN-60", 1),
new Member(202L, 303L, 70L, "IN-70", 1), new Member(202L, 304L, 80L, "IN-80", 1));
when(coloadGroups.findByInbound(anyLong(), anyLong())).thenReturn(group);
service.processCompletion(completionCommand(903L, "async-multi"), "worker-multi");
verify(cabinetEvidenceService, times(1)).requireComplete(99L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L);
verify(loadingDeliveryService).requirePdaCompletionReady(202L, 70L);
verify(loadingDeliveryService).requirePdaCompletionReady(202L, 80L);
verify(loadingDeliveryService).completeAfterPdaSettlement(
202L, 70L, 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(coloadGroups).consumeWholeGroup(eq(700L), eq(10L),
org.mockito.ArgumentMatchers.startsWith("PDA-CONSUME-"), eq(9L), eq("operator"));
verify(completionCommands).complete(903L, "worker-multi");
}
@Test
void secondCustomerOwnerReadyFailureLeavesEveryOwnerUnsettledAndUncompleted() {
assertNoTruckCoupling();
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);
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(),
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
void unpublishableCrossRegionSnapshotFailsBeforeAnyOwnerSettlement() {
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)));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"一件代发现场托盘箱唛数与实装箱数不一致"))
.when(loadingListService).requireCompletedEventReady(10L);
assertThatThrownBy(() -> service.processCompletion(
completionCommand(905L, "async-unpublishable"), "worker-unpublishable"))
.isInstanceOf(LoadingPickService.CompletionProcessingException.class)
.satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException) error).stage())
.isEqualTo("COMPLETED_EVENT_READY"));
verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), anyString());
verify(completionCommands, never()).checkpoint(anyLong(), anyLong(), anyLong(), anyInt(), anyString());
verify(repository, never()).insert(any(LoadingPickOperationEntity.class));
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingListService, never()).publishCompletedEvent(anyLong());
}
@Test
void workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements() {
assertNoTruckCoupling();
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, 60L)));
when(completionCommands.checkpoint(901L, 101L, 50L)).thenReturn("INBOUND_COMPLETED");
when(completionCommands.checkpoint(901L, 202L, 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(),
anyString(), anyString(), anyString());
verify(loadingDeliveryService).completeAfterPdaSettlement(202L, 60L, 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();
LoadingPickItemEntity picked = item();
LoadingPickItemEntity skipped = item();
skipped.setId(31L);
skipped.setLoadingPlanItemId(41L);
skipped.setInboundId(51L);
skipped.setInboundDetailId(61L);
List<LoadingPickItemEntity> items = List.of(picked, skipped);
AtomicReference<LoadingPickOperationEntity> operation = new AtomicReference<>();
when(repository.history(any(String.class))).thenReturn(null);
when(repository.operation("batch-1")).thenAnswer(invocation -> operation.get());
doAnswer(invocation -> {
Object value = invocation.getArgument(0);
if (value instanceof LoadingPickOperationEntity entity && "BATCH".equals(entity.getActionType())) {
operation.set(entity);
}
return null;
}).when(repository).insert(any(LoadingPickOperationEntity.class));
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(items);
when(repository.items(20L, 101L)).thenReturn(items);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.skip(31L, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.allPlanItemsPicked(10L)).thenReturn(true);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(1L, 50L),
new LoadingPickItemMapper.InboundOwner(1L, 51L)));
when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation ->
List.of(allocation(invocation.getArgument(1), 2)));
var snapshot = service.inventorySnapshot(20L, 101L);
String snapshotToken = snapshot.snapshotToken();
assertThat(snapshot.inbounds()).hasSize(2);
assertThat(snapshot.inbounds().get(0).resources().get(0))
.extracting("blockId", "slotNo", "reservationId", "locationId", "availablePackageCount")
.containsExactly(71L, 1, 72L, 74L, 2);
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of(
new BatchItemRequest(30L, "PICK", 2, null, 0, null),
new BatchItemRequest(31L, "SKIP", null, null, 0, "damaged carton")));
service.batch(20L, "batch-1", request, 9L, "operator");
service.batch(20L, "batch-1", request, 9L, "operator");
verify(repository, times(1)).pick(30L, 2, 0, 101L);
verify(repository, times(1)).skip(31L, 0, 101L);
verify(repository, times(1)).touchTask(20L, 0);
verify(settlementService, never()).settle(20L, 9L, "operator");
verify(loadingListService, never()).finishFromPda(10L, 20L, 9L, "operator");
verify(repository, times(1)).syncInboundProjections(20L, 9L, "operator");
}
@Test
void batchRecoversPlanResetToEditingWithoutDuplicatingExistingStartHistory() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
item.setPlannedPackageCount(100);
LoadingPlanHistoryEntity existingStart = new LoadingPlanHistoryEntity();
existingStart.setLoadingPlanId(10L);
existingStart.setOperationId("PDA-START-20");
existingStart.setActionType("START_LOADING");
existingStart.setFromStatus("EDITING");
existingStart.setToStatus("LOADING");
when(repository.history(any(String.class))).thenReturn(null);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.pick(30L, 100, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(smartLoadingRepository.startPicking(10L, 9L, "operator")).thenReturn(1);
when(smartLoadingRepository.operation("PDA-START-20")).thenReturn(existingStart);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(1L, 50L)));
when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation ->
List.of(allocation(invocation.getArgument(1), 100)));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, snapshotToken, List.of(
new BatchItemRequest(30L, "PICK", 100, null, 0, null)));
service.batch(20L, "batch-reset-plan", request, 9L, "operator");
verify(repository).pick(30L, 100, 0, 101L);
verify(smartLoadingRepository).startPicking(10L, 9L, "operator");
verify(smartLoadingRepository, never()).insert(any(LoadingPlanHistoryEntity.class));
verify(repository).touchTask(20L, 0);
}
@Test
void rejectsWholeBatchBeforeWritingWhenAnyLineIsInvalid() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity first = item();
LoadingPickItemEntity invalid = item();
invalid.setId(31L);
invalid.setLoadingPlanItemId(41L);
invalid.setInboundId(51L);
invalid.setInboundDetailId(61L);
invalid.setPlannedPackageCount(4);
List<LoadingPickItemEntity> items = List.of(first, invalid);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(items);
when(repository.items(20L, 101L)).thenReturn(items);
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, 0, null),
new BatchItemRequest(31L, "PARTIAL", null, 4, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-invalid", request, 9L, "operator"))
.isInstanceOf(BizException.class);
verify(repository, never()).pick(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class));
verify(repository, never()).partial(any(Long.class), any(Integer.class), any(Integer.class), any(Long.class));
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
verify(settlementService, never()).settle(20L, 9L, "operator");
}
@Test
void rejectsChangedInventorySnapshotBeforeWritingAnyBatchLine() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity picked = item();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(List.of(picked));
when(repository.items(20L, 101L)).thenReturn(List.of(picked));
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(1L, 50L)));
when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation ->
List.of(allocation(invocation.getArgument(1), 2)));
BatchRequest request = new BatchRequest(101L, 10L, 99L, 0, "0".repeat(64), List.of(
new BatchItemRequest(30L, "PICK", 2, null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-inventory-stale", request, 9L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("库存已变化");
verify(repository, never()).pick(any(Long.class), any(Integer.class),
any(Integer.class), any(Long.class));
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
verify(settlementService, never()).settle(20L, 9L, "operator");
}
@Test
void batchUsesConfirmedInboundInventoryWhenContainerLoadsAcrossWarehouses() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
item.setPlannedPackageCount(250);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(1L, 50L)));
when(palletAllocations.list(1L, 50L)).thenReturn(List.of(allocation(50L, 250, 202L)));
when(repository.pick(30L, 250, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1);
var snapshot = service.inventorySnapshot(20L, 101L);
assertThat(snapshot.warehouseId()).isEqualTo(101L);
assertThat(snapshot.inbounds()).singleElement().satisfies(inbound -> {
assertThat(inbound.availablePackageCount()).isEqualTo(250);
assertThat(inbound.resources()).singleElement()
.extracting("availablePackageCount")
.isEqualTo(250);
});
service.batch(20L, "batch-cross-warehouse", new BatchRequest(
101L, 10L, 99L, 0, snapshot.snapshotToken(), List.of(
new BatchItemRequest(30L, "PICK", 250, null, 0, null))), 9L, "operator");
verify(repository).pick(30L, 250, 0, 101L);
verify(repository).touchTask(20L, 0);
}
@Test
void rejectsBatchTaskVersionConflictBeforeWriting() {
LoadingPickTaskEntity task = task();
task.setVersion(3);
when(repository.taskForUpdate(20L)).thenReturn(task);
BatchRequest request = new BatchRequest(101L, 10L, 99L, 2, "stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-stale", request, 9L, "operator"))
.isInstanceOf(BizException.class);
verify(repository, never()).items(20L);
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
}
@Test
void staleTaskVersionDoesNotDriftItemAndFollowingValidBatchSucceeds() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity item = item();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.task(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.items(20L)).thenReturn(List.of(item));
when(repository.items(20L, 101L)).thenReturn(List.of(item));
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.inboundOwners(20L)).thenReturn(List.of(
new LoadingPickItemMapper.InboundOwner(1L, 50L)));
when(palletAllocations.list(anyLong(), anyLong())).thenAnswer(invocation ->
List.of(allocation(invocation.getArgument(1), 2)));
String snapshotToken = service.inventorySnapshot(20L, 101L).snapshotToken();
BatchItemRequest line = new BatchItemRequest(30L, "PICK", 2, null, 0, null);
assertThatThrownBy(() -> service.batch(20L, "batch-stale", new BatchRequest(
101L, 10L, 99L, 1, snapshotToken, List.of(line)), 9L, "operator"))
.isInstanceOf(BizException.class)
.hasMessageContaining("任务版本已变化");
assertThat(item.getVersion()).isZero();
assertThat(item.getPickedPackageCount()).isZero();
service.batch(20L, "batch-valid", new BatchRequest(
101L, 10L, 99L, 0, snapshotToken, List.of(line)), 9L, "operator");
verify(repository, times(1)).pick(30L, 2, 0, 101L);
verify(repository, times(1)).touchTask(20L, 0);
}
@Test
void repeatedTaskBeginDoesNotChangePendingItemBeforeRejectedStaleBatch() {
LoadingPickTaskEntity task = task();
LoadingPickItemEntity pending = item();
var planItem = new LoadingPlanItemEntity();
planItem.setId(40L);
planItem.setInboundId(50L);
planItem.setInboundDetailId(60L);
planItem.setPackageCount(2);
when(repository.taskByPlan(10L)).thenReturn(task);
when(repository.itemByPlanItem(40L)).thenReturn(pending);
when(repository.taskForUpdate(20L)).thenReturn(task);
service.begin(10L, "CSLU1234567", 9L, "operator", List.of(planItem));
BatchRequest staleRequest = new BatchRequest(101L, 10L, 99L, 1,
"stale-token", List.of(
new BatchItemRequest(30L, "PICK", 2, null, 0, null)));
assertThatThrownBy(() -> service.batch(20L, "batch-stale-after-resync", staleRequest,
9L, "operator")).isInstanceOf(BizException.class);
assertThat(pending.getVersion()).isZero();
assertThat(pending.getPickedPackageCount()).isZero();
assertThat(pending.getItemStatus()).isEqualTo("PENDING");
verify(repository, never()).update(any(LoadingPickItemEntity.class));
verify(repository, never()).pick(any(Long.class), any(Integer.class),
any(Integer.class), any(Long.class));
verify(repository, never()).touchTask(any(Long.class), any(Integer.class));
}
private static void assertNoTruckCoupling() {
assertThat(LoadingPickService.class.getDeclaredFields())
.noneMatch(field -> field.getType().equals(TruckAppointmentService.class));
assertThat(LoadingPickService.class.getDeclaredMethods())
.noneMatch(method -> method.getName().equals("completeTruckAfterCommit")
|| method.getName().equals("completeLoadingFromPda"));
}
private void preparePick(LoadingPickTaskEntity task, LoadingPickItemEntity item, String operationId) {
when(repository.history(operationId)).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.pick(30L, 2, 0, 101L)).thenReturn(1);
when(repository.touchTask(20L, 0)).thenReturn(1);
when(repository.items(20L, 101L)).thenReturn(List.of(item));
}
private void preparePartial(LoadingPickTaskEntity task, LoadingPickItemEntity item, String operationId) {
when(repository.history(operationId)).thenReturn(null);
when(repository.task(20L)).thenReturn(task);
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.assignment(20L)).thenReturn(assignment());
when(repository.item(20L, 30L, 101L)).thenReturn(item);
when(repository.partial(30L, 1, 0, 101L)).thenReturn(1);
when(repository.touchTask(20L, 0)).thenReturn(1);
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);
task.setLoadingPlanId(10L);
task.setContainerBookingId(99L);
task.setContainerNo("CSLU1234567");
task.setTaskStatus("LOADING");
task.setVersion(0);
return task;
}
private static LoadingPickItemEntity item() {
LoadingPickItemEntity item = new LoadingPickItemEntity();
item.setId(30L);
item.setPickTaskId(20L);
item.setLoadingPlanItemId(40L);
item.setInboundId(50L);
item.setInboundDetailId(60L);
item.setPlannedPackageCount(2);
item.setPickedPackageCount(0);
item.setItemStatus("PENDING");
item.setVersion(0);
return item;
}
private static InboundPalletAllocationEntity allocation(long inboundId, int packageCount) {
return allocation(inboundId, packageCount, 101L);
}
private static InboundPalletAllocationEntity allocation(long inboundId, int packageCount,
long warehouseId) {
InboundPalletAllocationEntity allocation = new InboundPalletAllocationEntity();
allocation.setId(72L);
allocation.setTenantId(1L);
allocation.setInboundId(inboundId);
allocation.setBlockId(71L);
allocation.setSlotNo(1);
allocation.setAllocationNo(1);
allocation.setWarehouseId(warehouseId);
allocation.setPalletId(73L);
allocation.setLocationId(74L);
allocation.setLocationCode("A-01");
allocation.setSharedOverflow(false);
allocation.setReservationId(72L);
allocation.setSlotStatus("BOUND");
allocation.setAllocationStatus("CONFIRMED");
allocation.setAllocatedPackageCount(packageCount);
allocation.setPickedPackageCount(0);
return allocation;
}
private static LoadingPickAssignmentEntity assignment() {
LoadingPickAssignmentEntity assignment = new LoadingPickAssignmentEntity();
assignment.setPickTaskId(20L);
assignment.setAssigneeId(9L);
assignment.setAssigneeName("operator");
assignment.setVersion(0);
return assignment;
}
private static LoadingPickOperationEntity taskOperation(String operationId, String action) {
LoadingPickOperationEntity operation = new LoadingPickOperationEntity();
operation.setPickTaskId(20L);
operation.setOperationId(operationId);
operation.setRequestHash(hashForTest(action + "|20|0"));
if ("ASSIGN".equals(action)) {
operation.setRequestHash(hashForTest("ASSIGN|20|11|picker|0"));
}
operation.setActionType(action);
operation.setOperatorId(9L);
operation.setOperatorName("operator");
return operation;
}
private static LoadingPickHistoryEntity itemHistory(String operationId, String action, String canonical) {
LoadingPickHistoryEntity history = new LoadingPickHistoryEntity();
history.setPickTaskId(20L);
history.setPickItemId(30L);
history.setOperationId(operationId);
history.setRequestHash(hashForTest(canonical));
history.setActionType(action);
history.setOperatorId(9L);
history.setOperatorName("operator");
return history;
}
private static String hashForTest(String value) {
try {
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private static GroupSnapshot coloadGroup(String status, Long lockedPlanId, Member... members) {
GroupSnapshot group = mock(GroupSnapshot.class);
when(group.groupId()).thenReturn(700L);
when(group.status()).thenReturn(status);
when(group.lockedPlanId()).thenReturn(lockedPlanId);
when(group.members()).thenReturn(List.of(members));
when(group.fullPackageCount()).thenReturn(List.of(members).stream()
.mapToInt(Member::fullPackageCount).sum());
return group;
}
}