This commit is contained in:
2026-08-17 15:41:42 +08:00
parent 4abcb2b80f
commit 5f79616623
60 changed files with 1457 additions and 270 deletions
@@ -34,5 +34,24 @@ class PlatformSmartLoadingControllerContractTest {
.contains("领取后才进入 LOADING")
.doesNotContain("真实柜号必须已录入");
}
@Test void openApiFreezesIncrementalMutationAndCapabilityContract() throws Exception {
String yaml=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/openapi/firstleg-v1.yaml"));
assertThat(yaml)
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:")
.contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:")
.contains("SmartLoadingAssignRequest:")
.contains("SmartLoadingAdjustRequest:")
.contains("SmartLoadingDeleteRequest:")
.contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}")
.contains("minPackageCount: {type: integer, minimum: 1}")
.contains("canAdjust: {type: boolean}")
.contains("canRemove: {type: boolean}")
.contains("原 inventorySnapshotToken 随即失效")
.contains("PDA task 行锁 -> plan 乐观锁")
.contains("回到 IN_PROGRESS")
.contains("COMPLETED 永久拒绝增量");
}
private void authority(String method,String permission,Class<?>...types)throws Exception{Method m=PlatformSmartLoadingController.class.getDeclaredMethod(method,types);assertThat(m.getAnnotation(PreAuthorize.class).value()).isEqualTo("hasAuthority('"+permission+"')");}
}
@@ -18,7 +18,9 @@ class LoadingCompletionGateSqlContractTest {
.contains("fl_ocean_booking_container_allocation")
.contains("b.status='RELEASED'")
.contains("fl_truck_appointment")
.contains("t.container_booking_id=p.container_booking_id")
.contains("t.status='COMPLETED'")
.doesNotContain("t.loading_plan_id=p.id")
.contains("l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}")
.doesNotContain("truckReady, FROM")
.doesNotContain("fl_customs_case", "fl_customs_receipt", "customsReady")
@@ -102,6 +102,44 @@ class LoadingDeliveryServiceTest {
verify(histories).insert(any(InboundStatusHistoryEntity.class));
}
@Test
void pdaCompletionPreflightChecksStartedPlanTransportAndEvidenceWithoutWriting() {
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
when(repository.listEvidence(10, 20)).thenReturn(List.of(
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
evidence("SEAL", 5L, "LOCK-001")));
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
long fileId = invocation.getArgument(3);
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
});
service.requirePdaCompletionReady(10, 20);
verify(loadingModelBridge).requireStarted(10, 20);
verify(completionGate).requireBookingAndTransport(10, 20);
verify(loadingModelBridge, never()).requireCompleted(anyLong(), anyLong());
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
verify(repository, never()).claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString());
}
@Test
void pdaCompletionPreflightReportsTruckBlockerBeforeEvidenceOrWrites() {
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
doThrow(new com.baiyue.common.core.exception.BizException(
com.baiyue.common.core.api.ResultCode.CONFLICT,
"LOADED门禁未通过:拖车尚未完成(必须为 COMPLETED)"))
.when(completionGate).requireBookingAndTransport(10, 20);
assertThatThrownBy(() -> service.requirePdaCompletionReady(10, 20))
.hasMessageContaining("拖车尚未完成");
verify(repository, never()).listEvidence(anyLong(), anyLong());
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
void completesLoadedFulfillmentSliceBeforeAggregatingRoot() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
@@ -20,6 +20,7 @@ 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.bridge.dto.LoadingModelBridgeDtos.Link;
import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService;
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
@@ -43,7 +44,10 @@ 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.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.verify;
import static org.mockito.Mockito.when;
@@ -64,6 +68,8 @@ class LoadingPickServiceTest {
@MockBean
private LoadingListService loadingListService;
@MockBean
private LoadingDeliveryService loadingDeliveryService;
@MockBean
private InboundStatusHistoryMapper inboundHistories;
@MockBean
private PdaPickSettlementService settlementService;
@@ -491,6 +497,10 @@ class LoadingPickServiceTest {
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));
service.complete(20L, new CompleteLoadingRequest(0, "complete-1", "checked"), 9L, "operator");
@@ -498,6 +508,35 @@ class LoadingPickServiceTest {
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).completeLoading(eq(101L), eq(50L), eq(9L), eq("operator"),
eq("PDA-complete-1-50"), argThat(value -> value.expectedVersion() == 7
&& "checked".equals(value.remark())));
}
@Test
void truckBlockerLeavesPdaTaskPlanAndSettlementUntouched() {
LoadingPickTaskEntity task = task();
when(repository.taskForUpdate(20L)).thenReturn(task);
when(repository.allTaskItemsResolved(20L)).thenReturn(true);
when(repository.review(20L)).thenReturn(null);
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,
"LOADED门禁未通过:拖车尚未完成(必须为 COMPLETED)"))
.when(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
assertThatThrownBy(() -> service.complete(20L,
new CompleteLoadingRequest(0, "complete-blocked", "checked"), 9L, "operator"))
.isInstanceOf(BizException.class).hasMessageContaining("拖车尚未完成");
verify(settlementService, never()).settle(anyLong(), anyLong(), anyString());
verify(repository, never()).approve(anyLong(), anyLong(), anyString(), any());
verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString());
verify(loadingDeliveryService, never()).completeLoading(anyLong(), anyLong(), anyLong(),
anyString(), anyString(), any());
}
@Test
@@ -34,6 +34,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.catchThrowableOfType;
@@ -68,6 +69,8 @@ class SmartLoadingServiceTest {
@BeforeEach
void setUp() {
when(loadingPickService.mutationSnapshot(anyLong())).thenReturn(
new LoadingPickService.MutationSnapshot(null, null, null, Map.of()));
when(cargoClient.detail(anyString(), anyLong(), anyLong(), anyLong())).thenReturn(
ApiResponse.success(new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 5,
new BigDecimal("10"), new BigDecimal("2"))));
@@ -95,6 +98,53 @@ class SmartLoadingServiceTest {
assertThat(service.draftByBooking(99L).status()).isEqualTo("LOADING");
}
@Test
void exposesPickedMinimumAndItemCapabilitiesForRunningTask() {
LoadingPlanEntity plan = plan("LOADING", 3);
LoadingPlanItemEntity line = item(8, 9, 20, 3);
when(repository.booking(99L)).thenReturn(booking());
when(repository.plan(1L)).thenReturn(plan);
when(repository.items(1L)).thenReturn(List.of(line));
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of(8L, 2)));
Plan result = service.detail(1L);
assertThat(result.canAssign()).isTrue();
assertThat(result.canEdit()).isTrue();
assertThat(result.finalCompletionStatus()).isEqualTo("IN_PROGRESS");
assertThat(result.pdaTaskId()).isEqualTo(77L);
assertThat(result.pdaTaskVersion()).isEqualTo(6);
assertThat(result.items()).singleElement().satisfies(item -> {
assertThat(item.getPickedPackageCount()).isEqualTo(2);
assertThat(item.getMinPackageCount()).isEqualTo(2);
assertThat(item.getCanAdjust()).isTrue();
assertThat(item.getCanRemove()).isFalse();
});
}
@Test
void completedTaskDisablesEveryIncrementalCapability() {
LoadingPlanEntity plan = plan("COMPLETED", 4);
LoadingPlanItemEntity line = item(8, 9, 20, 3);
when(repository.booking(99L)).thenReturn(booking());
when(repository.plan(1L)).thenReturn(plan);
when(repository.items(1L)).thenReturn(List.of(line));
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "COMPLETED", 7, Map.of(8L, 3)));
Plan result = service.detail(1L);
assertThat(result.canAssign()).isFalse();
assertThat(result.canEdit()).isFalse();
assertThat(result.blockedReason()).contains("最终完成");
assertThat(result.finalCompletionStatus()).isEqualTo("COMPLETED");
assertThat(result.items()).singleElement().satisfies(item -> {
assertThat(item.getCanAdjust()).isFalse();
assertThat(item.getCanRemove()).isFalse();
});
}
@Test
void doesNotRestoreCompletedPlanInTheLoadingWorkbench() {
when(repository.booking(99L)).thenReturn(booking());
@@ -227,13 +277,12 @@ class SmartLoadingServiceTest {
}
@Test
void recyclesLegacyLoadingPlanBeforeAddingFirstItem() {
LoadingPlanEntity stale = plan("LOADING", 5);
LoadingPlanEntity after = plan("EDITING", 7);
when(repository.plan(1)).thenReturn(stale, after);
void addsItemToLoadingPlanWithoutChangingStableTaskOrPlanStatus() {
LoadingPlanEntity loading = plan("LOADING", 5);
LoadingPlanEntity after = plan("LOADING", 6);
when(repository.plan(1)).thenReturn(loading, after);
when(repository.booking(99)).thenReturn(booking());
when(repository.recycleUnpicked(1, 5, 7, "u")).thenReturn(1);
when(repository.touch(1, 6, 7, "u")).thenReturn(1);
when(repository.touch(1, 5, 7, "u")).thenReturn(1);
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(100, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
@@ -241,11 +290,15 @@ class SmartLoadingServiceTest {
new OrderCargoClient.CargoBinding(20L, 30L, 1, "货物", 1,
BigDecimal.ONE, BigDecimal.ONE, null, null, null, null, null, null)));
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-recycle"), 7, "u");
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u");
assertThat(result.status()).isEqualTo("EDITING");
verify(repository).recycleUnpicked(1, 5, 7, "u");
verify(repository).touch(1, 6, 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
var order = inOrder(loadingPickService, repository, loadingModelBridge);
order.verify(loadingPickService).lockMutable(1L);
order.verify(repository).touch(1, 5, 7, "u");
order.verify(loadingModelBridge).bindIncrementalAllocation(1L, 9L, 10L,
7L, "u", "assign-loading");
order.verify(loadingPickService).sync(eq(1L), any(LoadingPlanItemEntity.class));
}
@Test
@@ -78,6 +78,7 @@ class TruckAppointmentServiceTest {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED");
when(oceanBookings.find(99L)).thenReturn(booking);
assertThatThrownBy(() -> service.create(7L, "admin", request)).isInstanceOf(BizException.class).hasMessageContaining("放舱");
@@ -88,15 +89,24 @@ class TruckAppointmentServiceTest {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.customerId()).thenReturn(7L);
when(request.loadingPlanId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer");
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity(); allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(77L); plan.setContainerBookingId(88L);
when(loadingPlans.planByBooking(88L)).thenReturn(plan);
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getCustomerId()).isEqualTo(7L);
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getContainerBookingId()).isEqualTo(88L);
assertThat(entity.getValue().getLoadingPlanId()).isEqualTo(77L);
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@@ -113,11 +123,11 @@ class TruckAppointmentServiceTest {
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(77L); plan.setContainerBookingId(66L);
when(loadingPlans.plan(77L)).thenReturn(plan);
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(78L); plan.setContainerBookingId(88L);
when(loadingPlans.planByBooking(88L)).thenReturn(plan);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("柜体");
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
verify(repository, never()).insert(any());
}
@@ -134,10 +144,10 @@ class TruckAppointmentServiceTest {
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
when(loadingPlans.plan(77L)).thenReturn(null);
when(loadingPlans.planByBooking(88L)).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("装柜计划不存在");
.isInstanceOf(BizException.class).hasMessageContaining("尚未建立装柜计划");
verify(repository, never()).insert(any());
}