1581 lines
81 KiB
Java
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;
|
|
}
|
|
|
|
}
|