fix: preserve lastleg comments in overseas migration
This commit is contained in:
+5
-5
@@ -19,7 +19,7 @@ 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","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 schemaHasBookingLevelUniqueAggregateWithoutCustomerScope() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","bill_of_lading_no VARCHAR(128)","active_ocean_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_ocean_booking(active_ocean_booking_id)","ALTER TABLE fl_truck_appointment DROP INDEX uk_truck_active_container","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "receivable_amount");}
|
||||
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
|
||||
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
|
||||
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
|
||||
@@ -30,10 +30,10 @@ 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 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 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","database booking-level unique key");}
|
||||
@Test void openApiPublishesBookingLevelCandidateContract() 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 booking candidates","bookingDocumentReady","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
|
||||
@Test void openApiRequiresReleasedBookingWithoutContainerWriteProtocol() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","PICKUP does not accept or write a container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}").doesNotContain("containerBookingId:","loadingPlanId:","containerNo:","customerId", "customerName");}
|
||||
@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:","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(
|
||||
|
||||
+5
-2
@@ -67,7 +67,7 @@ class OutboundDispatchReadMapperContractTest {
|
||||
void oceanAndTruckQueriesAreResolvedOnceFromTheContainerRoot() throws Exception {
|
||||
String oceanSql = OutboundDispatchReadMapper.class.getMethod("ocean", long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class, long.class)
|
||||
String truckSql = OutboundDispatchReadMapper.class.getMethod("truck", long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
|
||||
assertThat(oceanSql)
|
||||
@@ -75,7 +75,10 @@ class OutboundDispatchReadMapperContractTest {
|
||||
.contains("booking.customer_id customerId")
|
||||
.doesNotContain("#{tenantId}");
|
||||
assertThat(truckSql)
|
||||
.contains("container_booking_id=#{containerBookingId}", "loading_plan_id=#{loadingPlanId}")
|
||||
.contains("fl_truck_appointment appointment", "fl_ocean_booking_container_allocation allocation")
|
||||
.contains("allocation.ocean_booking_id=appointment.ocean_booking_id")
|
||||
.contains("allocation.container_booking_id=#{containerBookingId}")
|
||||
.doesNotContain("loading_plan_id", "#{loadingPlanId}")
|
||||
.doesNotContain("#{tenantId}");
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -69,7 +69,7 @@ class OutboundDispatchServiceTest {
|
||||
void confirmAllowsMissingTruckAppointmentWhenEveryAuthoritativeGatePasses() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.truck(22L, 44L)).thenReturn(null);
|
||||
when(repository.truck(22L)).thenReturn(null);
|
||||
stubConfirmMutation("no-truck");
|
||||
|
||||
Detail result = service.confirm(22L, 9L, "admin", "no-truck", new ActionRequest(0, null));
|
||||
@@ -83,7 +83,7 @@ class OutboundDispatchServiceTest {
|
||||
void confirmAllowsPendingTruckAppointmentWhenEveryAuthoritativeGatePasses() {
|
||||
OutboundDispatchService service = serviceWithoutCarrier();
|
||||
stubContainer();
|
||||
when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING"));
|
||||
when(repository.truck(22L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "PENDING"));
|
||||
stubConfirmMutation("pending-truck");
|
||||
|
||||
Detail result = service.confirm(22L, 9L, "admin", "pending-truck", new ActionRequest(0, null));
|
||||
@@ -247,7 +247,7 @@ class OutboundDispatchServiceTest {
|
||||
when(repository.bindings(22L)).thenReturn(values);
|
||||
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED"));
|
||||
when(repository.ocean(22L)).thenReturn(new OutboundDispatchReadMapper.OceanBinding(66L, "RELEASED", 7L));
|
||||
when(repository.truck(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
|
||||
when(repository.truck(22L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
|
||||
when(repository.evidence(22L, 44L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
|
||||
}
|
||||
|
||||
|
||||
+11
-20
@@ -13,39 +13,30 @@ class TruckAppointmentMapperContractTest {
|
||||
@Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception {
|
||||
assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment");
|
||||
String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0];
|
||||
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).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "ocean_booking_id=#{q.oceanBookingId}",
|
||||
"fl_ocean_booking", "fl_truck_appointment_attachment", "attachment_count")
|
||||
.doesNotContain("fl_container_booking", "cabinet_no", "container_no");
|
||||
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<>'COMPLETED'",
|
||||
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "containerBookingId",
|
||||
"bookingDocumentReady");
|
||||
assertThat(candidates).doesNotContain("c.container_no IS NOT NULL", "currentAppointmentId", "currentContainerBookingId");
|
||||
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'", "d.valid=1",
|
||||
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "t.ocean_booking_id=o.id",
|
||||
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "bookingDocumentReady");
|
||||
assertThat(candidates).doesNotContain("fl_ocean_booking_container_allocation", "fl_container_booking",
|
||||
"loadingPlanId", "containerBookingId", "cabinetNo", "containerNo");
|
||||
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'");
|
||||
String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0];
|
||||
assertThat(firstActive).contains("container_booking_id=#{containerBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
|
||||
assertThat(firstActive).contains("ocean_booking_id=#{oceanBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
|
||||
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
|
||||
String pdaComplete = TruckAppointmentMapper.class.getMethod("completeLoadingFromPda", long.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
assertThat(pdaComplete).contains("status='COMPLETED'", "actual_loading_at=COALESCE", "status=#{fromStatus}",
|
||||
"version=#{version}", "revision=revision+1", "version=version+1");
|
||||
String reschedule = TruckAppointmentMapper.class.getMethod("reschedule", long.class, java.time.LocalDateTime.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1");
|
||||
String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "container_booking_id=#{e.containerBookingId}",
|
||||
"loading_plan_id=#{e.loadingPlanId}", "carrier_id=#{e.carrierId}",
|
||||
assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "carrier_id=#{e.carrierId}",
|
||||
"planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}",
|
||||
"version=version+1", "revision=revision+1");
|
||||
assertThat(edit).doesNotContain("container_booking_id", "loading_plan_id");
|
||||
assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName");
|
||||
assertThat(edit).doesNotContain("receivable_amount", "receivableAmount");
|
||||
}
|
||||
|
||||
@@ -93,8 +93,6 @@ class LoadingPickServiceTest {
|
||||
@MockBean
|
||||
private ColoadGroupAssignmentPort coloadGroups;
|
||||
@MockBean
|
||||
private TruckAppointmentService truckAppointmentService;
|
||||
@MockBean
|
||||
private InboundStatusHistoryMapper inboundHistories;
|
||||
@MockBean
|
||||
private PdaPickSettlementService settlementService;
|
||||
@@ -594,7 +592,6 @@ class LoadingPickServiceTest {
|
||||
verify(repository).syncInboundProjections(20L, 9L, "operator");
|
||||
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 50L);
|
||||
verify(loadingDeliveryService).requirePdaCompletionReady(101L, 60L);
|
||||
verify(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
|
||||
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
|
||||
"PDA-complete-1-50", "checked");
|
||||
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 60L, 9L, "operator",
|
||||
@@ -607,37 +604,6 @@ class LoadingPickServiceTest {
|
||||
finalization.verify(loadingListService).publishCompletedEvent(10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void conflictingTruckAppointmentsDoNotBlockPdaCompletion() {
|
||||
LoadingPickTaskEntity task = task();
|
||||
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", "checked")).thenReturn(1);
|
||||
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,
|
||||
"同一柜次存在多条有效拖车预约,跳过自动完成"))
|
||||
.when(truckAppointmentService).completeLoadingFromPda(99L, 20L, 9L, "operator");
|
||||
|
||||
service.complete(20L,
|
||||
new CompleteLoadingRequest(0, "complete-not-blocked", "checked"), 9L, "operator");
|
||||
|
||||
verify(settlementService).settle(20L, 9L, "operator");
|
||||
verify(repository).approve(20L, 9L, "operator", "checked");
|
||||
verify(loadingListService).finishFromPda(10L, 20L, 9L, "operator");
|
||||
verify(loadingDeliveryService).completeAfterPdaSettlement(101L, 50L, 9L, "operator",
|
||||
"PDA-complete-not-blocked-50", "checked");
|
||||
verify(loadingListService).publishCompletedEvent(10L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@org.junit.jupiter.api.Disabled("Replaced by durable asynchronous completion command contract")
|
||||
void doesNotPublishCompletedEventWhenInboundFinalizationFails() {
|
||||
|
||||
+35
-368
@@ -12,14 +12,9 @@ import com.baiyue.firstleg.entity.TruckAppointmentEntity;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
|
||||
import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity;
|
||||
import com.baiyue.firstleg.entity.LoadingPlanEntity;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
|
||||
import com.baiyue.firstleg.repository.SmartLoadingRepository;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.service.OceanBookingDocumentService;
|
||||
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
@@ -28,7 +23,6 @@ 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;
|
||||
@@ -48,8 +42,6 @@ import static org.mockito.Mockito.*;
|
||||
class TruckAppointmentServiceTest {
|
||||
@Mock TruckAppointmentRepository repository;
|
||||
@Mock OceanBookingRepository oceanBookings;
|
||||
@Mock SmartLoadingRepository loadingPlans;
|
||||
@Mock ContainerRepository containers;
|
||||
@Mock WarehouseSupplierSnapshotService suppliers;
|
||||
@Mock TruckAppointmentFileClient files;
|
||||
@Mock OceanBookingDocumentService oceanBookingDocuments;
|
||||
@@ -64,46 +56,36 @@ class TruckAppointmentServiceTest {
|
||||
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE", java.util.List.of()));
|
||||
lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
|
||||
OceanBookingEntity booking = releasedBooking();
|
||||
ContainerBookingEntity container = realContainer();
|
||||
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setContainerBookingId(88L);
|
||||
lenient().when(oceanBookings.find(99L)).thenReturn(booking);
|
||||
lenient().when(containers.find(88L)).thenReturn(container);
|
||||
lenient().when(oceanBookings.allocations(99L)).thenReturn(List.of(allocation));
|
||||
}
|
||||
|
||||
@Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() {
|
||||
@Test void detailResolvesStableNamesFromLinkedBooking() {
|
||||
TruckAppointmentEntity appointment = new TruckAppointmentEntity();
|
||||
appointment.setId(51L); appointment.setOceanBookingId(61L); appointment.setContainerBookingId(71L);
|
||||
appointment.setId(51L); appointment.setOceanBookingId(61L);
|
||||
when(repository.find(51L)).thenReturn(appointment);
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(61L); booking.setBookingNo("OB61");
|
||||
booking.setWarehouseName("Shenzhen Transit"); booking.setShippingCompanyId(81L);
|
||||
booking.setShippingCompanyName("Carrier Line"); booking.setBookingAgentId(91L); booking.setBookingAgentName("Agent Co");
|
||||
when(oceanBookings.find(61L)).thenReturn(booking);
|
||||
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(71L);
|
||||
container.setCabinetNo("CAB-71"); container.setContainerNo("MSCU1234567");
|
||||
when(containers.find(71L)).thenReturn(container);
|
||||
|
||||
TruckAppointmentEntity detail = service.detail(51L);
|
||||
|
||||
assertThat(detail.getOceanBookingNo()).isEqualTo("OB61");
|
||||
assertThat(detail.getWarehouseName()).isEqualTo("Shenzhen Transit");
|
||||
assertThat(detail.getShippingCompanyName()).isEqualTo("Carrier Line");
|
||||
assertThat(detail.getBookingAgentName()).isEqualTo("Agent Co");
|
||||
assertThat(detail.getCabinetNo()).isEqualTo("CAB-71");
|
||||
assertThat(detail.getContainerNo()).isEqualTo("MSCU1234567");
|
||||
assertThat(detail.getContainerBookingId()).isNull();
|
||||
assertThat(detail.getLoadingPlanId()).isNull();
|
||||
}
|
||||
|
||||
@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);
|
||||
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西", 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);
|
||||
verifyNoInteractions(oceanBookings);
|
||||
}
|
||||
|
||||
@Test void createRequiresZeroVersionAndInitialisesDraft() {
|
||||
@@ -112,14 +94,12 @@ class TruckAppointmentServiceTest {
|
||||
verifyNoInteractions(repository);
|
||||
}
|
||||
|
||||
@Test void createRejectsMissingBookingAndContainer() {
|
||||
@Test void createRejectsMissingBooking() {
|
||||
SaveRequest request = mock(SaveRequest.class);
|
||||
when(request.expectedVersion()).thenReturn(0);
|
||||
when(request.carrierId()).thenReturn(8L);
|
||||
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
|
||||
when(request.oceanBookingId()).thenReturn(null);
|
||||
when(request.containerBookingId()).thenReturn(null);
|
||||
when(request.loadingPlanId()).thenReturn(null);
|
||||
when(request.warehouseId()).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
@@ -139,14 +119,14 @@ class TruckAppointmentServiceTest {
|
||||
verifyNoInteractions(files);
|
||||
}
|
||||
|
||||
@Test void createRejectsAnotherActiveAppointmentForTheSameRealContainerWithoutWriting() {
|
||||
@Test void createRejectsAnotherActiveAppointmentForTheSameBookingWithoutWriting() {
|
||||
SaveRequest request = validRequest();
|
||||
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||
existing.setAppointmentNo("TA-260901-0003");
|
||||
when(repository.firstActiveForUpdate(88L)).thenReturn(existing);
|
||||
when(repository.firstActiveForUpdate(99L)).thenReturn(existing);
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("该订舱已存在拖车预约 TA-260901-0003,请继续编辑");
|
||||
|
||||
verifyNoInteractions(oceanBookingDocuments);
|
||||
verify(repository, never()).insert(any());
|
||||
@@ -159,14 +139,14 @@ class TruckAppointmentServiceTest {
|
||||
SaveRequest request = validRequest();
|
||||
TruckAppointmentEntity concurrent = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||
concurrent.setAppointmentNo("TA-260901-0003");
|
||||
when(repository.firstActiveForUpdate(88L)).thenReturn(null, concurrent);
|
||||
doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_container'"))
|
||||
when(repository.firstActiveForUpdate(99L)).thenReturn(null, concurrent);
|
||||
doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_ocean_booking'"))
|
||||
.when(repository).insert(any());
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("该订舱已存在拖车预约 TA-260901-0003,请继续编辑");
|
||||
|
||||
verify(repository, times(2)).firstActiveForUpdate(88L);
|
||||
verify(repository, times(2)).firstActiveForUpdate(99L);
|
||||
verify(repository, never()).history(any());
|
||||
assertThat(concurrent.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
|
||||
assertThat(concurrent.getVersion()).isZero();
|
||||
@@ -261,8 +241,6 @@ class TruckAppointmentServiceTest {
|
||||
when(request.oceanBookingId()).thenReturn(99L);
|
||||
when(request.carrierId()).thenReturn(8L);
|
||||
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
|
||||
when(request.containerBookingId()).thenReturn(88L);
|
||||
when(request.loadingPlanId()).thenReturn(null);
|
||||
when(request.warehouseId()).thenReturn(null);
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(18L);
|
||||
when(oceanBookings.find(99L)).thenReturn(booking);
|
||||
@@ -271,38 +249,8 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void createWithBookingAndContainerRequiresTheirAllocation() {
|
||||
@Test void createStillRequiresBookingDocumentWithoutAnyContainerAssociation() {
|
||||
SaveRequest request = validRequest();
|
||||
when(request.oceanBookingId()).thenReturn(99L);
|
||||
when(request.containerBookingId()).thenReturn(88L);
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(99L); booking.setStatus("RELEASED"); booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-1");
|
||||
ContainerBookingEntity container = new ContainerBookingEntity();
|
||||
container.setId(88L); container.setRecordStatus("ACTIVE"); container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
|
||||
when(oceanBookings.find(99L)).thenReturn(booking);
|
||||
when(containers.find(88L)).thenReturn(container);
|
||||
when(oceanBookings.allocations(99L)).thenReturn(List.of());
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("未分配至该订舱");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void createAcceptsAllocatedInternalContainerWithoutRealNumber() {
|
||||
SaveRequest request = validRequest();
|
||||
ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null);
|
||||
when(containers.find(88L)).thenReturn(missingNo);
|
||||
|
||||
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());
|
||||
@@ -311,22 +259,16 @@ class TruckAppointmentServiceTest {
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void editCanSwitchOnlyToAnotherMatchedReleasedBookingAndContainer() {
|
||||
@Test void editCanSwitchToAnotherUnoccupiedReleasedBooking() {
|
||||
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 3);
|
||||
existing.setOceanBookingId(99L); existing.setContainerBookingId(88L);
|
||||
existing.setOceanBookingId(99L);
|
||||
when(repository.find(1L)).thenReturn(existing, existing);
|
||||
SaveRequest request = validRequest();
|
||||
when(request.expectedVersion()).thenReturn(3);
|
||||
when(request.oceanBookingId()).thenReturn(199L);
|
||||
when(request.containerBookingId()).thenReturn(188L);
|
||||
OceanBookingEntity booking = releasedBooking(); booking.setId(199L); booking.setBillOfLadingNo("BL-NEW");
|
||||
ContainerBookingEntity container = realContainer(); container.setId(188L); container.setOceanBookingId(199L);
|
||||
container.setContainerNo("MSCU6639870");
|
||||
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setContainerBookingId(188L);
|
||||
when(oceanBookings.find(199L)).thenReturn(booking);
|
||||
when(containers.find(188L)).thenReturn(container);
|
||||
when(oceanBookings.allocations(199L)).thenReturn(List.of(allocation));
|
||||
when(repository.firstActiveForUpdate(199L)).thenReturn(null);
|
||||
when(repository.updateEdit(any(), eq(3))).thenReturn(1);
|
||||
|
||||
service.edit(1L, 7L, "admin", request);
|
||||
@@ -334,82 +276,9 @@ class TruckAppointmentServiceTest {
|
||||
ArgumentCaptor<TruckAppointmentEntity> updated = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
|
||||
verify(repository).updateEdit(updated.capture(), eq(3));
|
||||
assertThat(updated.getValue().getOceanBookingId()).isEqualTo(199L);
|
||||
assertThat(updated.getValue().getContainerBookingId()).isEqualTo(188L);
|
||||
assertThat(updated.getValue().getContainerBookingId()).isNull();
|
||||
assertThat(updated.getValue().getLoadingPlanId()).isNull();
|
||||
assertThat(updated.getValue().getBillOfLadingNo()).isEqualTo("BL-NEW");
|
||||
|
||||
container.setOceanBookingId(99L);
|
||||
assertThatThrownBy(() -> service.edit(1L, 7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("未关联至所选订舱");
|
||||
}
|
||||
|
||||
@Test void createRejectsMissingContainerBeforeLoadingPlanValidation() {
|
||||
SaveRequest request = validRequest();
|
||||
when(request.loadingPlanId()).thenReturn(77L);
|
||||
when(request.containerBookingId()).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("containerBookingId");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void createRejectsLoadingPlanFromAnotherContainer() {
|
||||
SaveRequest request = mock(SaveRequest.class);
|
||||
when(request.expectedVersion()).thenReturn(0);
|
||||
when(request.oceanBookingId()).thenReturn(99L);
|
||||
when(request.containerBookingId()).thenReturn(88L);
|
||||
when(request.loadingPlanId()).thenReturn(77L);
|
||||
when(request.carrierId()).thenReturn(8L);
|
||||
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
|
||||
when(request.warehouseId()).thenReturn(null);
|
||||
OceanBookingEntity booking = releasedBooking();
|
||||
when(oceanBookings.find(99L)).thenReturn(booking);
|
||||
ContainerBookingEntity container = realContainer();
|
||||
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(78L); plan.setContainerBookingId(88L);
|
||||
when(loadingPlans.planByBooking(88L)).thenReturn(plan);
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
|
||||
verify(repository, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test void createRejectsUnknownLoadingPlan() {
|
||||
SaveRequest request = mock(SaveRequest.class);
|
||||
when(request.expectedVersion()).thenReturn(0);
|
||||
when(request.oceanBookingId()).thenReturn(99L);
|
||||
when(request.containerBookingId()).thenReturn(88L);
|
||||
when(request.loadingPlanId()).thenReturn(77L);
|
||||
when(request.carrierId()).thenReturn(8L);
|
||||
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
|
||||
when(request.warehouseId()).thenReturn(null);
|
||||
OceanBookingEntity booking = releasedBooking();
|
||||
when(oceanBookings.find(99L)).thenReturn(booking);
|
||||
ContainerBookingEntity container = realContainer();
|
||||
when(containers.find(88L)).thenReturn(container);
|
||||
var allocation = new OceanBookingContainerAllocationEntity();
|
||||
allocation.setContainerBookingId(88L);
|
||||
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
|
||||
when(loadingPlans.planByBooking(88L)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
|
||||
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() {
|
||||
@@ -420,7 +289,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, "提交", null), "idem-1", null, 7L, "admin");
|
||||
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", null, 7L, "admin");
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
|
||||
assertThat(result.getVersion()).isEqualTo(1);
|
||||
@@ -434,12 +303,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, "提交", null));
|
||||
String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交"));
|
||||
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, "提交", null), "idem-1", hash, 7L, "admin");
|
||||
var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", hash, 7L, "admin");
|
||||
|
||||
assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
|
||||
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
|
||||
@@ -447,7 +316,7 @@ class TruckAppointmentServiceTest {
|
||||
}
|
||||
|
||||
@Test void concurrentSameKeyBeforeFirstCompletionReturnsRetryableConflictWithoutDrift() throws Exception {
|
||||
var request = new ActionRequest(0, "并发提交", null);
|
||||
var request = new ActionRequest(0, "并发提交");
|
||||
String hash = requestHash("SUBMIT", 1L, request);
|
||||
TruckAppointmentOperationEntity processing = new TruckAppointmentOperationEntity();
|
||||
processing.setAppointmentId(1L); processing.setAction("SUBMIT");
|
||||
@@ -472,7 +341,7 @@ class TruckAppointmentServiceTest {
|
||||
.thenReturn(false);
|
||||
when(repository.operation("reused-key")).thenReturn(existing);
|
||||
|
||||
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A", null),
|
||||
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A"),
|
||||
"reused-key", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
|
||||
|
||||
@@ -488,7 +357,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, null),
|
||||
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
|
||||
"dispatch-no-bill", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
|
||||
|
||||
@@ -500,7 +369,7 @@ 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, null),
|
||||
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
|
||||
"dispatch-no-file", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
|
||||
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
|
||||
@@ -513,15 +382,13 @@ class TruckAppointmentServiceTest {
|
||||
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, null),
|
||||
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null),
|
||||
"dispatch-ok", null, 7L, "admin");
|
||||
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED);
|
||||
@@ -533,166 +400,24 @@ class TruckAppointmentServiceTest {
|
||||
|
||||
@Test void pickupWithoutContainerNumberStillAdvancesWithoutBinding() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
|
||||
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
|
||||
before.setOceanBookingId(99L);
|
||||
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
|
||||
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
|
||||
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
|
||||
after.setOceanBookingId(99L);
|
||||
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),
|
||||
TruckAppointmentEntity result = service.action(1L, "PICKUP", new ActionRequest(2, "提柜"),
|
||||
"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, "提交", null);
|
||||
var request = new ActionRequest(0, "提交");
|
||||
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"))
|
||||
@@ -704,11 +429,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, null), "idem-action", null, 7L, "admin"))
|
||||
assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, 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, "改约", null), "idem-reschedule", null, 7L, "admin"))
|
||||
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), "idem-reschedule", null, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("不可改约");
|
||||
verify(repository, never()).reschedule(anyLong(), any(), anyString(), anyInt());
|
||||
}
|
||||
@@ -721,7 +446,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, "改约", null), "idem-reschedule", null, 7L, "admin");
|
||||
var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), "idem-reschedule", null, 7L, "admin");
|
||||
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.RESCHEDULED);
|
||||
assertThat(result.getVersion()).isEqualTo(3);
|
||||
ArgumentCaptor<TruckAppointmentHistoryEntity> history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
|
||||
@@ -730,56 +455,6 @@ class TruckAppointmentServiceTest {
|
||||
assertThat(history.getValue().getToStatus()).isEqualTo("RESCHEDULED");
|
||||
}
|
||||
|
||||
@Test void pdaCompletionDoesNotRequireTruckAppointment() {
|
||||
when(repository.activeForUpdate(99L)).thenReturn(List.of());
|
||||
|
||||
service.completeLoadingFromPda(99L, 20L, 7L, "admin");
|
||||
|
||||
verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt());
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test void pdaCompletionLocksAndCompletesTheOnlyActiveTruckWithHistory() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.LOADING, 4);
|
||||
TruckAppointmentEntity after = entity(TruckAppointmentStatus.COMPLETED, 5);
|
||||
when(repository.activeForUpdate(99L)).thenReturn(List.of(before));
|
||||
when(repository.completeLoadingFromPda(1L, "LOADING", 4)).thenReturn(1);
|
||||
when(repository.find(1L)).thenReturn(after);
|
||||
|
||||
service.completeLoadingFromPda(99L, 20L, 7L, "admin");
|
||||
|
||||
verify(repository).completeLoadingFromPda(1L, "LOADING", 4);
|
||||
ArgumentCaptor<TruckAppointmentHistoryEntity> history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
|
||||
verify(repository).history(history.capture());
|
||||
assertThat(history.getValue().getFromStatus()).isEqualTo("LOADING");
|
||||
assertThat(history.getValue().getToStatus()).isEqualTo("COMPLETED");
|
||||
assertThat(history.getValue().getAction()).isEqualTo("PDA_COMPLETE_LOADING");
|
||||
assertThat(history.getValue().getOperatorId()).isEqualTo(7L);
|
||||
assertThat(history.getValue().getReason()).contains("20");
|
||||
}
|
||||
|
||||
@Test void pdaCompletionRejectsMultipleActiveTruckAppointmentsWithoutMutation() {
|
||||
when(repository.activeForUpdate(99L)).thenReturn(List.of(
|
||||
entity(TruckAppointmentStatus.LOADING, 1), entity(TruckAppointmentStatus.DEPARTED, 2)));
|
||||
|
||||
assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("多条有效拖车预约");
|
||||
|
||||
verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt());
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test void pdaCompletionRejectsConcurrentTruckModificationWithoutHistory() {
|
||||
TruckAppointmentEntity before = entity(TruckAppointmentStatus.RESCHEDULED, 4);
|
||||
when(repository.activeForUpdate(99L)).thenReturn(List.of(before));
|
||||
when(repository.completeLoadingFromPda(1L, "RESCHEDULED", 4)).thenReturn(0);
|
||||
|
||||
assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("并发修改");
|
||||
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
private String requestHash(String action, long id, Object request) throws Exception {
|
||||
String canonical = action + '|' + id + '|' + objectMapper.writeValueAsString(request);
|
||||
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
|
||||
@@ -804,8 +479,6 @@ class TruckAppointmentServiceTest {
|
||||
when(request.carrierId()).thenReturn(8L);
|
||||
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
|
||||
when(request.oceanBookingId()).thenReturn(99L);
|
||||
when(request.containerBookingId()).thenReturn(88L);
|
||||
when(request.loadingPlanId()).thenReturn(null);
|
||||
when(request.warehouseId()).thenReturn(null);
|
||||
return request;
|
||||
}
|
||||
@@ -817,10 +490,4 @@ class TruckAppointmentServiceTest {
|
||||
return booking;
|
||||
}
|
||||
|
||||
private static ContainerBookingEntity realContainer() {
|
||||
ContainerBookingEntity container = new ContainerBookingEntity();
|
||||
container.setId(88L); container.setRecordStatus("ACTIVE");
|
||||
container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
|
||||
return container;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user