Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-01 15:59:15 +08:00
26 changed files with 726 additions and 172 deletions
@@ -69,7 +69,7 @@ class LoadingListControllerContractTest {
.contains("beforeSnapshot")
.contains("containerNo")
.contains("voyageNo")
.contains("唯一纠错入口")
.contains("首次补录真实集装箱号")
.contains("校验ISO 6346结构、设备类别和校验位")
.contains("PDA任务尚未领取且无任何物理作业事实")
.contains("/loading-plans/{planId}/confirm")
@@ -100,10 +100,10 @@ class LoadingListControllerContractTest {
}
@Test
void transportCorrectionRequiresReasonAndKeepsNullContainerAsUnchanged() {
void transportCorrectionReasonIsRuntimeConditionalAndNullContainerKeepsCurrentValue() {
assertThat(component(TransportUpdateRequest.class, "operationReason").getAccessor()
.getAnnotation(NotBlank.class))
.isNotNull();
.isNull();
assertThat(component(TransportUpdateRequest.class, "containerNo").getAccessor()
.getAnnotation(NotBlank.class))
.isNull();
@@ -18,7 +18,7 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class TruckAppointmentControllerContractTest {
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","candidates","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");}
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
@@ -31,12 +31,13 @@ class TruckAppointmentControllerContractTest {
}
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
@Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","before booking-document validation","database unique key");}
@Test void openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
@Test void openApiPublishesCreateOnlyAuthoritativeCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged flat booking-container candidates","bookingDocumentReady","A missing containerNo remains eligible","Completed/LOADED containers, occupied containers","Edit pages must display their current binding from appointment detail");}
@Test void openApiRequiresReleasedBookingAndAllocatedInternalContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","allocated ACTIVE internal container identity","real ISO 6346 container number is not required for create, edit, submit or dispatch","PICKUP may omit it and still advances normally","Omitting it never blocks the transition to PICKUP_IN_PROGRESS","must use the preload correction flow","Other actions reject containerNo","OCEAN_BOOKING_DOCUMENT","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("candidates","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
Map.entry("history","platform:firstleg:truck-appointment:list"), Map.entry("attachments","platform:firstleg:truck-appointment:list"),
Map.entry("attach","platform:firstleg:truck-appointment:edit"), Map.entry("upload","platform:firstleg:truck-appointment:edit"),
Map.entry("deleteAttachment","platform:firstleg:truck-appointment:edit"), Map.entry("create","platform:firstleg:truck-appointment:create"),
@@ -80,6 +80,8 @@ class LoadingListQueryMapperContractTest {
void mutationsShouldUseStatusAndOptimisticVersionConditions() throws Exception {
String touch = sql("touchEditable", Update.class);
String transport = sql("updateTransport", Update.class);
String touchInitialFill = sql("touchForInitialContainerFill", Update.class);
String initialFill = sql("fillInitialContainerNo", Update.class);
String confirmPreload = sql("confirmLinkedPreload", Update.class);
String confirmPlan = sql("touchForPreloadConfirmation", Update.class);
String advance = sql("advance", Update.class);
@@ -94,10 +96,20 @@ class LoadingListQueryMapperContractTest {
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.contains("p.plan_status='EDITING'")
.contains("preload_pushed_at IS NOT NULL")
.contains("#{containerNo} IS NOT NULL")
.doesNotContain("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task", "fl_loading_pick_assignment")
.doesNotContain("DELETE")
.doesNotContain("fl_loading_plan_item");
assertThat(touchInitialFill)
.contains("p.plan_status IN ('EDITING','LOADING')")
.contains("p.version=#{expectedVersion}")
.contains("b.container_no IS NULL")
.doesNotContain("fl_loading_pick_assignment", "item_status");
assertThat(initialFill)
.contains("p.plan_status IN ('EDITING','LOADING')")
.contains("b.container_no IS NULL")
.contains("#{containerNo} IS NOT NULL")
.doesNotContain("fl_loading_pick_assignment", "item_status");
assertThat(advance)
.contains("plan_status = #{fromStatus}")
.contains("plan_status = #{toStatus}")
@@ -72,4 +72,30 @@ class LoadingListRepositoryTest {
assertThat(confirmed.planId()).isEqualTo(41L);
assertThat(confirmed.containerBookingId()).isEqualTo(61L);
}
@Test
void missingContainerNumberDoesNotBlockPreloadConfirmationOrPdaReadiness() {
AggregateRow row = new AggregateRow();
row.setPlanId(41L);
row.setContainerBookingId(61L);
row.setPlanNo("LP-41");
row.setPlanStatus("EDITING");
row.setVersion(1);
row.setPreloadPushedAt(java.time.LocalDateTime.now());
row.setPreloadLinkCount(2L);
row.setConfirmedPreloadCount(0L);
row.setOceanBookingId(81L);
row.setOceanBookingStatus("RELEASED");
when(mapper.selectItems(41L)).thenReturn(List.of());
when(mapper.selectHistories(41L)).thenReturn(List.of());
var pending = repository.preloadDetail(row).summary().readiness();
assertThat(pending.containerNoReady()).isFalse();
assertThat(pending.canConfirm()).isTrue();
assertThat(pending.blockingReasons()).doesNotContain("CONTAINER_NO_MISSING");
row.setConfirmedPreloadCount(2L);
var confirmed = repository.preloadDetail(row).summary().readiness();
assertThat(confirmed.readyForPda()).isTrue();
}
}
@@ -16,10 +16,20 @@ class TruckAppointmentMapperContractTest {
assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "container_booking_id=#{q.containerBookingId}",
"fl_ocean_booking", "fl_container_booking", "record_status='ACTIVE'", "cabinet_no", "container_no", "fl_truck_appointment_attachment", "attachment_count");
assertThat(page).doesNotContain("customer_id", "customerId", "customer_name");
String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0];
assertThat(candidates).contains("o.status='RELEASED'", "fl_ocean_booking_container_allocation",
"fl_ocean_booking_container_requirement", "c.record_status='ACTIVE'", "d.valid=1",
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "p.plan_status&lt;&gt;'COMPLETED'",
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "containerBookingId",
"bookingDocumentReady");
assertThat(candidates).doesNotContain("c.container_no IS NOT NULL", "currentAppointmentId", "currentContainerBookingId");
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1");
String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1");
String pickupContainer = TruckAppointmentMapper.class.getMethod("bindContainerNoForPickup", long.class, String.class, long.class, String.class).getAnnotation(Update.class).value()[0];
assertThat(pickupContainer).contains("UPDATE fl_container_booking", "container_no=#{containerNo}",
"id=#{containerBookingId}", "record_status='ACTIVE'", "container_no IS NULL", "version=version+1");
String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0];
assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE")
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
@@ -208,20 +208,67 @@ class LoadingListServiceTest {
}
@Test
void loadingPlanAndMissingInitialOceanBookingNumberBothRejectCorrection() {
void loadingPlanRejectsTransportUpdateButPushedEditingPlanAllowsFirstContainerFill() {
AggregateRow loading = row("LOADING", 3);
loading.setPreloadPushedAt(java.time.LocalDateTime.now());
loading.setContainerNo("MSCU6639870");
AggregateRow missing = row("EDITING", 3);
missing.setPreloadPushedAt(java.time.LocalDateTime.now());
when(repository.aggregate(10L)).thenReturn(loading, missing);
AggregateRow afterFill = row("EDITING", 4);
afterFill.setPreloadPushedAt(missing.getPreloadPushedAt());
afterFill.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(loading, missing, afterFill);
when(repository.fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator"))
.thenReturn(1);
when(repository.preloadDetail(afterFill)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "loading-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "作业后纠错")))
.isInstanceOf(BizException.class).hasMessageContaining("PDA尚未开始");
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "missing-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "首次补录")))
.isInstanceOf(BizException.class).hasMessageContaining("首次写入");
service.updateTransport(10L, 100L, 9L, "operator", "missing-key",
new TransportUpdateRequest("CSQU3054383", null, 3, null));
verify(repository).fillInitialContainerNo(10L, 3, "CSQU3054383", null, 9L, "operator");
verify(loadingPickService).beginIfReady(30L, 9L, "operator");
}
@Test
void loadingPlanWithClaimedAndPickedPdaTaskStillAllowsInitialContainerFill() {
AggregateRow before = row("LOADING", 7);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
AggregateRow after = row("LOADING", 8);
after.setPreloadPushedAt(before.getPreloadPushedAt());
after.setContainerNo("CSQU3054383");
when(repository.aggregate(10L)).thenReturn(before, after);
when(repository.hasPdaWorkStarted(10L)).thenReturn(true);
when(repository.fillInitialContainerNo(10L, 7, "CSQU3054383", "023E", 9L, "operator"))
.thenReturn(1);
when(repository.preloadDetail(after)).thenReturn(
new com.baiyue.firstleg.dto.LoadingListDtos.PreloadDetail(null, List.of(), List.of()));
service.updateTransport(10L, 100L, 9L, "operator", "late-fill-key",
new TransportUpdateRequest("CSQU3054383", "023E", 7, null));
verify(repository).fillInitialContainerNo(10L, 7, "CSQU3054383", "023E", 9L, "operator");
verify(repository, never()).hasPdaWorkStarted(10L);
verify(repository).appendHistory(eq(10L), anyString(), eq("UPDATE_PRELOAD_TRANSPORT"),
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"),
org.mockito.ArgumentMatchers.isNull());
}
@Test
void correctingExistingContainerNumberStillRequiresReason() {
AggregateRow before = row("EDITING", 3);
before.setPreloadPushedAt(java.time.LocalDateTime.now());
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "reason-key",
new TransportUpdateRequest("CSQU3054383", null, 3, null)))
.isInstanceOf(BizException.class).hasMessageContaining("纠错原因");
verify(repository, never()).updateTransport(anyLong(), anyInt(), any(), any(), anyLong(), any());
}
@Test
@@ -113,7 +113,7 @@ class LoadingPickServiceTest {
}).when(completionTransaction).executeWithoutResult(any());
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setId(99L);
booking.setContainerNo("CSLU1234567");
booking.setContainerNo("MSCU6639870");
booking.setWarehouseId(101L);
booking.setSourceType("SIMULATION");
booking.setRecordStatus("ACTIVE");
@@ -124,9 +124,9 @@ class LoadingPickServiceTest {
}
@Test
void opensTaskOnlyAfterReleasedBookingAndRealContainerNumber() {
void opensTaskAfterReleasedBookingWithoutWaitingForRealContainerNumber() {
var booking = new com.baiyue.firstleg.entity.ContainerBookingEntity();
booking.setId(40L); booking.setContainerNo("MSCU-001");
booking.setId(40L);
var plan = new com.baiyue.firstleg.entity.LoadingPlanEntity();
plan.setId(10L); plan.setContainerBookingId(40L); plan.setPlanStatus("EDITING");
plan.setPreloadPushedAt(java.time.LocalDateTime.now());
@@ -136,7 +136,7 @@ class LoadingPickServiceTest {
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, "MSCU-001", "EDITING", 1, 1, null)));
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);
@@ -191,11 +191,11 @@ class LoadingPickServiceTest {
}
@Test
void planConfirmationGateRequiresRealContainerAndReleasedBooking() {
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); booking.setContainerNo("MSCU-001");
booking.setId(40L);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(90L);
var ocean = new OceanBookingEntity();
@@ -209,17 +209,21 @@ class LoadingPickServiceTest {
}
@Test
void planConfirmationGateRejectsMissingRealContainerNumber() {
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);
assertThatThrownBy(() -> service.requirePreloadConfirmationReady(10L))
.isInstanceOf(BizException.class).hasMessageContaining("真实集装箱号");
verify(oceanBookingRepository, never()).allocationByContainer(anyLong());
assertThat(service.requirePreloadConfirmationReady(10L)).containsExactly(link);
}
@Test
@@ -775,6 +779,25 @@ class LoadingPickServiceTest {
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.
@@ -1,9 +1,12 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.ActionRequest;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.AttachmentRequest;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.Candidate;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest;
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity;
@@ -25,6 +28,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
@@ -90,6 +94,18 @@ class TruckAppointmentServiceTest {
assertThat(detail.getContainerNo()).isEqualTo("MSCU1234567");
}
@Test void createCandidatesDelegateOneBoundedAuthoritativeQuery() {
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西",
70L, "45HC", 1, 88L, "CAB-1", null, 77L, "LP-1", "EDITING", true);
when(repository.candidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
PageResult<Candidate> result = service.candidates(new CandidateQuery(" OB-1 ", 1, 20));
assertThat(result.records()).containsExactly(candidate);
verify(repository, times(1)).candidates(any(CandidateQuery.class));
verifyNoInteractions(oceanBookings, containers, loadingPlans);
}
@Test void createRequiresZeroVersionAndInitialisesDraft() {
SaveRequest invalid = request(1);
assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class);
@@ -272,15 +288,21 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void createRejectsContainerWithoutRealIsoNumberAndMissingBookingDocument() {
@Test void createAcceptsAllocatedInternalContainerWithoutRealNumber() {
SaveRequest request = validRequest();
ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null);
when(containers.find(88L)).thenReturn(missingNo);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("集装箱号");
verify(repository, never()).insert(any());
when(containers.find(88L)).thenReturn(realContainer());
service.create(7L, "admin", request);
verify(repository).insert(any(TruckAppointmentEntity.class));
verify(oceanBookingDocuments).requireActiveValidDocument(any(OceanBookingEntity.class));
}
@Test void createStillRequiresBookingDocumentBeforeRealNumberIsAssigned() {
SaveRequest request = validRequest();
ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null);
when(containers.find(88L)).thenReturn(missingNo);
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
@@ -377,6 +399,19 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void createRejectsCompletedLoadingContainerEvenWhenAppointmentIsTerminal() {
SaveRequest request = validRequest();
LoadingPlanEntity completed = new LoadingPlanEntity(); completed.setId(77L);
completed.setContainerBookingId(88L); completed.setPlanStatus("COMPLETED");
when(loadingPlans.planByBooking(88L)).thenReturn(completed);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已装柜完成");
verify(repository, never()).insert(any());
verify(repository, never()).history(any());
}
@Test void actionAdvancesMainLifecycleAndWritesIdempotentOperation() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
@@ -385,7 +420,7 @@ class TruckAppointmentServiceTest {
when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("idem-1"), anyString())).thenReturn(1);
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", null, 7L, "admin");
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交", null), "idem-1", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
assertThat(result.getVersion()).isEqualTo(1);
@@ -399,12 +434,12 @@ class TruckAppointmentServiceTest {
TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity();
operation.setAppointmentId(1L); operation.setAction("SUBMIT"); operation.setIdempotencyKey("idem-1");
String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交"));
String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交", null));
operation.setRequestHash(hash); operation.setResponseSnapshot(objectMapper.writeValueAsString(completed));
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), eq(hash))).thenReturn(false);
when(repository.operation("idem-1")).thenReturn(operation);
var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", hash, 7L, "admin");
var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交", null), "idem-1", hash, 7L, "admin");
assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
@@ -412,7 +447,7 @@ class TruckAppointmentServiceTest {
}
@Test void concurrentSameKeyBeforeFirstCompletionReturnsRetryableConflictWithoutDrift() throws Exception {
var request = new ActionRequest(0, "并发提交");
var request = new ActionRequest(0, "并发提交", null);
String hash = requestHash("SUBMIT", 1L, request);
TruckAppointmentOperationEntity processing = new TruckAppointmentOperationEntity();
processing.setAppointmentId(1L); processing.setAction("SUBMIT");
@@ -437,7 +472,7 @@ class TruckAppointmentServiceTest {
.thenReturn(false);
when(repository.operation("reused-key")).thenReturn(existing);
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A"),
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A", null),
"reused-key", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
@@ -453,7 +488,7 @@ class TruckAppointmentServiceTest {
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingBill);
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
"dispatch-no-bill", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
@@ -465,26 +500,28 @@ class TruckAppointmentServiceTest {
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
"dispatch-no-file", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
}
@Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() {
@Test void dispatchFreezesReleasedBookingBillWithoutRequiringRealContainerNumber() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2);
after.setOceanBookingId(99L); after.setContainerBookingId(88L); after.setBillOfLadingNo("BL-BOOKING-1");
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1");
ContainerBookingEntity internalContainer = realContainer(); internalContainer.setContainerNo(null);
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(before, after);
when(oceanBookings.find(99L)).thenReturn(booking);
when(containers.find(88L)).thenReturn(internalContainer);
when(repository.dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(1);
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null),
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
"dispatch-ok", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED);
@@ -494,8 +531,168 @@ class TruckAppointmentServiceTest {
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void pickupWithoutContainerNumberStillAdvancesWithoutBinding() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
when(repository.find(1L)).thenReturn(before, after);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(1);
TruckAppointmentEntity result = service.action(1L, "PICKUP", new ActionRequest(2, "提柜", null),
"pickup-without-number", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void pickupRejectsSuppliedInvalidContainerNumberWithoutDrift() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-invalid"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
assertThatThrownBy(() -> service.action(1L, "PICKUP", new ActionRequest(2, "提柜", "MSCU6639871"),
"pickup-invalid", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void pickupNormalizesAndAtomicallyBindsContainerNumberBeforeTransition() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
when(repository.find(1L)).thenReturn(before, after);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-bind"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
when(containers.containerNoExists("MSCU6639870", 88L)).thenReturn(false);
when(repository.bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin")).thenReturn(1);
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-bind"), anyString())).thenReturn(1);
TruckAppointmentEntity result = service.action(1L, "PICKUP",
new ActionRequest(2, "司机已提柜", " ms cu 663987 0 "), "pickup-bind", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
InOrder writes = inOrder(repository);
writes.verify(repository).bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin");
writes.verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
writes.verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void pickupReusesExistingValidContainerNumberWithoutRewritingIt() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
when(repository.find(1L)).thenReturn(before, after);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-reuse"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(realContainer());
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-reuse"), anyString())).thenReturn(1);
service.action(1L, "PICKUP", new ActionRequest(2, "复用已有柜号", null),
"pickup-reuse", null, 7L, "admin");
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
}
@Test void pickupIdempotentReplayDoesNotLockOrRewriteContainerAndChangedNumberConflicts() throws Exception {
ActionRequest request = new ActionRequest(2, "提柜", "MSCU6639870");
TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
completed.setOceanBookingId(99L); completed.setContainerBookingId(88L);
TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity();
operation.setAppointmentId(1L); operation.setAction("PICKUP"); operation.setIdempotencyKey("pickup-replay");
operation.setRequestHash(requestHash("PICKUP", 1L, request));
operation.setResponseSnapshot(objectMapper.writeValueAsString(completed));
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-replay"), anyString()))
.thenReturn(false);
when(repository.operation("pickup-replay")).thenReturn(operation);
TruckAppointmentEntity replay = service.action(1L, "PICKUP", request,
"pickup-replay", operation.getRequestHash(), 7L, "admin");
assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "提柜", "TSTU1503306"), "pickup-replay", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
verify(containers, never()).findActiveForUpdate(anyLong());
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
}
@Test void pickupRejectsMismatchedOrDuplicateContainerNumberBeforeStatusDrift() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), anyString(), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(realContainer());
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "错误柜号", "TSTU1503306"), "pickup-mismatch", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不一致").hasMessageContaining("预装纠错");
ContainerBookingEntity unnumbered = realContainer(); unnumbered.setContainerNo(null);
when(containers.findActiveForUpdate(88L)).thenReturn(unnumbered);
when(containers.containerNoExists("TSTU1503306", 88L)).thenReturn(true);
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "重复柜号", "TSTU1503306"), "pickup-duplicate", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("其他柜次使用");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void concurrentPickupContainerNumberCollisionRollsBackBeforeStatusTransition() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-concurrent"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
when(containers.containerNoExists("MSCU6639870", 88L)).thenReturn(false);
doThrow(new DuplicateKeyException("uk_container_booking_container_no"))
.when(repository).bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin");
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "并发提柜", "MSCU6639870"), "pickup-concurrent", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("其他柜次使用");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
verify(repository, never()).completeOperation(anyLong(), anyString(), anyString(), anyString());
}
@Test void nonPickupActionsRejectContainerNumberWithoutWritingIt() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("submit-with-container"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.action(1L, "SUBMIT",
new ActionRequest(0, "错误携带柜号", "MSCU6639870"), "submit-with-container", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("仅PICKUP动作");
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void actionRejectsMissingKeyAndMismatchedClientHash() {
var request = new ActionRequest(0, "提交");
var request = new ActionRequest(0, "提交", null);
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, null, null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("Idempotency-Key");
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, "idem-1", "wrong", 7L, "admin"))
@@ -507,11 +704,11 @@ class TruckAppointmentServiceTest {
TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0);
when(repository.find(1L)).thenReturn(e);
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-action"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null), "idem-action", null, 7L, "admin"))
assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null, null), "idem-action", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("专用接口");
e.setStatus(TruckAppointmentStatus.COMPLETED);
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), "idem-reschedule", null, 7L, "admin"))
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约", null), "idem-reschedule", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不可改约");
verify(repository, never()).reschedule(anyLong(), any(), anyString(), anyInt());
}
@@ -524,7 +721,7 @@ class TruckAppointmentServiceTest {
when(repository.reschedule(eq(1L), any(), eq("改约"), eq(2))).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(1);
var at = LocalDateTime.of(2026, 8, 12, 10, 0);
var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), "idem-reschedule", null, 7L, "admin");
var result = service.reschedule(1L, at, new ActionRequest(2, "改约", null), "idem-reschedule", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.RESCHEDULED);
assertThat(result.getVersion()).isEqualTo(3);
ArgumentCaptor<TruckAppointmentHistoryEntity> history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);