feat(firstleg): enforce booking-owned truck workflow

This commit is contained in:
2026-08-31 19:55:58 +08:00
parent f2a980e5c1
commit 0cc4eae209
27 changed files with 1318 additions and 86 deletions
@@ -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","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 schemaHasAggregateTablesStatesAndActualEventTimes() 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","BILL_OF_LADING","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");}
@Test void schemaHasAggregateTablesStatesAndActualEventTimes() 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");}
@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,8 +30,8 @@ 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 openApiFreezesIndependentCreateAndOptionalAssociations() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [customerId, carrierId, plannedLoadingAt, expectedVersion]","independent operational records","no RELEASED-state prerequisite","It may be supplied only with containerBookingId","ACTIVE IAM customer","ACTIVE Warehouse supplier ID validated as TRUCKING","Fulfillment associations remain null","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true");}
@Test void openApiFreezesAttachmentRequestAndOwnershipContract() 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]","BILL_OF_LADING","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:");}
@Test void openApiRequiresReleasedBookingAndAllocatedRealContainer() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, customerId, 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");}
@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 methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
@@ -24,6 +24,13 @@ class PlatformOceanBookingControllerContractTest {
Map.entry("bookingAgents", "platform:firstleg:ocean-booking:list"),
Map.entry("channels", "platform:firstleg:ocean-booking:list"),
Map.entry("detail", "platform:firstleg:ocean-booking:list"),
Map.entry("documents", "platform:firstleg:ocean-booking:list"),
Map.entry("downloadDocument", "platform:firstleg:ocean-booking:list"),
Map.entry("printShippingInstruction", "platform:firstleg:ocean-booking:list"),
Map.entry("printBillConfirmation", "platform:firstleg:ocean-booking:list"),
Map.entry("attachDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("uploadDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("invalidateDocument", "platform:firstleg:ocean-booking:edit"),
Map.entry("create", "platform:firstleg:ocean-booking:create"),
Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"),
Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"),
@@ -33,7 +40,8 @@ class PlatformOceanBookingControllerContractTest {
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
if (!name.equals("page") && !name.equals("detail") && !name.equals("channels") && !name.equals("bookingAgents")
&& !name.equals("linkCandidates")) {
&& !name.equals("linkCandidates") && !name.equals("documents") && !name.equals("downloadDocument")
&& !name.startsWith("print")) {
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
}
});
@@ -45,7 +53,8 @@ class PlatformOceanBookingControllerContractTest {
.filter(method -> !method.getName().equals("page") && !method.getName().equals("create"))
.flatMap(method -> Arrays.stream(method.getParameters()))
.filter(parameter -> parameter.isAnnotationPresent(org.springframework.web.bind.annotation.PathVariable.class))
.allMatch(parameter -> parameter.getName().equals("oceanBookingId"))).isTrue();
.allMatch(parameter -> parameter.getName().equals("oceanBookingId")
|| parameter.getName().equals("documentId"))).isTrue();
}
@Test
@@ -55,6 +64,8 @@ class PlatformOceanBookingControllerContractTest {
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
.contains("Create an editable DRAFT", "Replace a DRAFT", "Delete a DRAFT")
.contains("Validate and permanently release a DRAFT")
.contains("OCEAN_BOOKING_DOCUMENT", "BOOKING_CONFIRMATION", "BILL_OF_LADING")
.contains("/prints/shipping-instruction:", "/prints/bill-of-lading-confirmation:")
.contains("containerRequirements:", "ContainerRequirementRequest:")
.contains("never requires or creates a real container number")
.doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo");
@@ -2,9 +2,11 @@ package com.baiyue.firstleg.oceanbooking.repository;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingDocumentMapper;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Mapper;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
@@ -76,4 +78,13 @@ class OceanBookingMapperContractTest {
.contains("allocation.ocean_booking_id=#{oceanBookingId}")
.contains("allocation.requirement_id=#{requirementId}");
}
@Test void documentMapperIsRegisteredAndOnlyLogicallyInvalidatesActiveReference() throws Exception {
assertThat(OceanBookingDocumentMapper.class.getAnnotation(Mapper.class)).isNotNull();
String sql = String.join("\n", OceanBookingDocumentMapper.class
.getDeclaredMethod("invalidate", long.class, long.class, long.class, String.class)
.getAnnotation(Update.class).value());
assertThat(sql).contains("SET valid=0", "ocean_booking_id=#{oceanBookingId}",
"id=#{documentId}", "valid=1", "invalidated_at=CURRENT_TIMESTAMP(3)");
}
}
@@ -16,6 +16,7 @@ class OceanBookingSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_party")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_allocation")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_document")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_status_history")
.contains("CREATE TABLE IF NOT EXISTS fl_ocean_booking_operation")
.contains("customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
@@ -42,6 +43,9 @@ class OceanBookingSqlContractTest {
.contains("<> 'RELEASED' THEN")
.contains("订舱状态历史不可修改")
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)")
.contains("document_type IN ('BOOKING_CONFIRMATION','BILL_OF_LADING')")
.contains("订舱文件关联不可物理删除")
.contains("已放舱订舱文件永久只读")
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED");
}
@@ -0,0 +1,133 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OceanBookingFileClient;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingDocumentEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.MockitoAnnotations;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OceanBookingDocumentServiceTest {
@Mock OceanBookingRepository repository;
@Mock OceanBookingFileClient files;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks OceanBookingDocumentService service;
@BeforeEach void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "internalToken", "token");
lenient().when(repository.find(10L)).thenReturn(draft(0));
}
@Test void uploadIsIdempotentAndDoesNotUploadASecondFileOnReplay() throws Exception {
OceanBookingEntity booking = draft(2);
MockMultipartFile file = new MockMultipartFile("file", "booking.pdf", "application/pdf", "pdf".getBytes());
when(repository.claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
.thenReturn(true, false);
when(repository.findForUpdate(10L)).thenReturn(booking);
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", file))
.thenReturn(ApiResponse.success(metadata(91L)));
when(repository.touchDraft(10L, 2, 7L, "admin")).thenReturn(1);
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
.thenReturn(1);
var first = service.upload(10L, 2, "BOOKING_CONFIRMATION", "upload-1", file, 7L, "admin");
ArgumentCaptor<String> hash = ArgumentCaptor.forClass(String.class);
verify(repository).claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), hash.capture());
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
operation.setRequestHash(hash.getValue()); operation.setStatus("SUCCESS");
operation.setResultJson(objectMapper.writeValueAsString(first));
when(repository.operation(7L, "UPLOAD_DOCUMENT", "upload-1")).thenReturn(operation);
var replay = service.upload(10L, 2, "BOOKING_CONFIRMATION", "upload-1", file, 7L, "admin");
assertThat(replay.bookingVersion()).isEqualTo(3);
assertThat(replay.document().getFileId()).isEqualTo(91L);
verify(files, times(1)).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any());
verify(repository, times(1)).insert(any(OceanBookingDocumentEntity.class));
}
@Test void attachRejectsCrossScopeFileBeforePersisting() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(draft(1));
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
.thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 19L,
"OCEAN_BOOKING_DOCUMENT", "10", "x.pdf", "hash", "application/pdf", 3L)));
assertThatThrownBy(() -> service.attach(10L,
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest(1, 92L, "BILL_OF_LADING"),
"attach-1", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
verify(repository, never()).insert(any(OceanBookingDocumentEntity.class));
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
}
@Test void sameIdempotencyKeyWithDifferentPayloadIsRejectedWithoutFileLookup() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(false);
OceanBookingOperationEntity operation = new OceanBookingOperationEntity();
operation.setRequestHash("different"); operation.setStatus("SUCCESS"); operation.setResultJson("{}");
when(repository.operation(7L, "ATTACH_DOCUMENT", "same-key")).thenReturn(operation);
assertThatThrownBy(() -> service.attach(10L,
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest(1, 92L, "BILL_OF_LADING"),
"same-key", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("其他请求");
verifyNoInteractions(files);
verify(repository, never()).insert(any(OceanBookingDocumentEntity.class));
}
@Test void invalidationIsRejectedAfterReleaseWithoutDrift() {
OceanBookingEntity released = draft(4); released.setStatus("RELEASED");
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(released);
assertThatThrownBy(() -> service.invalidate(10L, 20L, 4, "delete-1", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("永久只读");
verify(repository, never()).invalidateDocument(anyLong(), anyLong(), anyLong(), anyString());
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
}
@Test void activeDocumentMustStillExistInFileService() {
OceanBookingEntity booking = draft(3);
OceanBookingDocumentEntity document = new OceanBookingDocumentEntity();
document.setId(20L); document.setOceanBookingId(10L); document.setFileId(91L);
document.setDocumentType("BILL_OF_LADING"); document.setValid(true);
when(repository.activeDocuments(10L)).thenReturn(List.of(document));
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
.thenReturn(ApiResponse.success(metadata(91L)));
service.requireActiveValidDocument(booking);
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
}
private static OceanBookingEntity draft(int version) {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version);
return booking;
}
private static OceanBookingFileClient.Metadata metadata(long fileId) {
return new OceanBookingFileClient.Metadata(fileId, 9L, "OCEAN_BOOKING_DOCUMENT", "10",
"booking.pdf", "hash", "application/pdf", 3L);
}
}
@@ -0,0 +1,89 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.PrintPdfClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class OceanBookingPrintServiceTest {
@Mock OceanBookingService bookings;
@Mock PrintPdfClient print;
@InjectMocks OceanBookingPrintService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); ReflectionTestUtils.setField(service, "internalToken", "token"); }
@Test void shippingInstructionUsesOnlyAuthoritativeReleasedAggregate() {
when(bookings.detail(10L)).thenReturn(detail("RELEASED"));
HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_PDF);
headers.set("X-Print-Page-Count", "1");
when(print.generateFreightA4Documents(eq("token"), eq("req-1"), any()))
.thenReturn(new ResponseEntity<>(new byte[]{1, 2, 3}, headers, 200));
var pdf = service.shippingInstruction(10L, "req-1");
assertThat(pdf.fileName()).isEqualTo("OB-1-出口货物托运单.pdf");
ArgumentCaptor<PrintPdfClient.FreightA4PdfRequest> request =
ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class);
verify(print).generateFreightA4Documents(eq("token"), eq("req-1"), request.capture());
assertThat(request.getValue().templateCode()).isEqualTo("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1");
var document = request.getValue().documents().get(0);
assertThat(document.fields()).containsEntry("jobNumber", "OB-1")
.containsEntry("shipper", "Shipper\nAddress")
.containsEntry("oceanVesselVoyage", "Vessel / V001")
.containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity",
"portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords");
assertThat(document.cargoRows()).singleElement().satisfies(row -> {
assertThat(row.quantity()).isEqualTo("2");
assertThat(row.descriptionOfGoods()).isEqualTo("40HQ DRY x 2");
assertThat(row.grossWeight()).isEqualTo("56000.000 KGS");
});
}
@Test void printRejectsDraftWithoutCallingPrintService() {
when(bookings.detail(10L)).thenReturn(detail("DRAFT"));
assertThatThrownBy(() -> service.billConfirmation(10L, "req-2"))
.isInstanceOf(BizException.class).hasMessageContaining("放舱后");
verifyNoInteractions(print);
}
private static Detail detail(String status) {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status);
booking.setBillOfLadingNo("BL-1"); booking.setVesselName("Vessel"); booking.setVoyageNo("V001");
booking.setDeparturePortName("Ningbo"); booking.setDestinationPortName("LA");
booking.setPlaceOfDelivery("LA DC"); booking.setServiceTerm("CY-CY");
booking.setFreightTerm("PREPAID"); booking.setBookingAgentName("Agent");
booking.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
OceanBookingPartyEntity shipper = new OceanBookingPartyEntity();
shipper.setPartyType("SHIPPER"); shipper.setPartyName("Shipper"); shipper.setAddress("Address");
OceanBookingPartyEntity consignee = new OceanBookingPartyEntity();
consignee.setPartyType("CONSIGNEE"); consignee.setPartyName("Consignee");
OceanBookingPartyEntity notify = new OceanBookingPartyEntity();
notify.setPartyType("NOTIFY_PARTY"); notify.setPartyName("Notify");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setContainerType("40HQ"); requirement.setContainerKind("DRY"); requirement.setQuantity(2);
requirement.setCapacityCbm(new BigDecimal("68")); requirement.setMaxWeightKg(new BigDecimal("28000"));
return new Detail(booking, List.of(shipper, consignee, notify), List.of(requirement), List.of(), List.of(), List.of());
}
}
@@ -39,6 +39,7 @@ class OceanBookingServiceTest {
@Mock private WarehouseMasterDataClient warehouses;
@Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners;
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
@Mock private OceanBookingDocumentService documents;
@InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper;
@@ -53,7 +54,9 @@ class OceanBookingServiceTest {
when(repository.parties(anyLong())).thenReturn(List.of());
when(repository.requirements(anyLong())).thenReturn(List.of());
when(repository.containers(anyLong())).thenReturn(List.of());
when(repository.documents(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
lenient().when(repository.findForUpdate(anyLong())).thenAnswer(invocation -> repository.find(invocation.getArgument(0)));
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
}
@@ -115,6 +118,23 @@ class OceanBookingServiceTest {
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void rejectsReleaseWithoutActiveBookingOwnedFileWithoutStateDrift() {
stubMasterData();
OceanBookingEntity draft = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(draft);
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"订舱缺少有效订舱确认或提单文件"))
.when(documents).requireActiveValidDocument(draft);
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-no-file",
new ReleaseRequest(3, "确认")))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void releasesCompleteAggregateAndReplaysFrozenResult() throws Exception {
stubMasterData();
@@ -331,6 +351,9 @@ class OceanBookingServiceTest {
value.setWarehouseId(701L); value.setBillOfLadingNo("BL-001"); value.setShippingCompanyId(71L);
value.setCarrierCode("MATSON");
value.setBookingAgentId(81L); value.setVesselName("VESSEL"); value.setVoyageNo("V001");
value.setDeparturePortCode("CNSZX"); value.setDeparturePortName("深圳");
value.setDestinationPortCode("USLAX"); value.setDestinationPortName("洛杉矶");
value.setPlaceOfDelivery("LAX");
value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
@@ -16,6 +16,7 @@ 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.IamCustomerSnapshotClient;
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
@@ -47,6 +48,7 @@ class TruckAppointmentServiceTest {
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock IamCustomerSnapshotService customers;
@Mock TruckAppointmentFileClient files;
@Mock OceanBookingDocumentService oceanBookingDocuments;
@Mock FirstlegDocumentNumberGenerator documentNumbers;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks TruckAppointmentService service;
@@ -60,6 +62,13 @@ class TruckAppointmentServiceTest {
when(suppliers.trucking(any(), any())).thenReturn(
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() {
@@ -90,7 +99,7 @@ class TruckAppointmentServiceTest {
verifyNoInteractions(repository);
}
@Test void createWithoutBookingContainerOrPlanWritesIndependentDraft() {
@Test void createRejectsMissingBookingAndContainer() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.customerId()).thenReturn(7L);
@@ -101,28 +110,20 @@ class TruckAppointmentServiceTest {
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
TruckAppointmentEntity created = service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getOceanBookingId()).isNull();
assertThat(entity.getValue().getContainerBookingId()).isNull();
assertThat(entity.getValue().getLoadingPlanId()).isNull();
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier");
assertThat(entity.getValue().getAppointmentNo()).isEqualTo("TA-260818-0001");
assertThat(created.getAttachmentCount()).isZero();
verifyNoInteractions(oceanBookings, containers, loadingPlans);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("oceanBookingId");
verify(repository, never()).insert(any());
}
@Test void createAllowsDraftWithoutBillOfLadingOrAttachment() {
@Test void createFreezesBookingBillAndRequiresBookingOwnedDocument() {
SaveRequest request = validRequest();
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getBillOfLadingNo()).isNull();
assertThat(entity.getValue().getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
verify(oceanBookingDocuments).requireActiveValidDocument(any(OceanBookingEntity.class));
verifyNoInteractions(files);
}
@@ -147,35 +148,28 @@ class TruckAppointmentServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("billOfLadingNo").hasMessageContaining("不一致");
}
@Test void createAcceptsConfirmedBookingWithoutContainerOrPlan() {
@Test void createRejectsBookingThatIsNotReleased() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.containerBookingId()).thenReturn(null);
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.setCustomerId(7L); booking.setWarehouseId(18L);
when(oceanBookings.find(99L)).thenReturn(booking);
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getCustomerId()).isEqualTo(7L);
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getOceanBookingId()).isEqualTo(99L);
assertThat(entity.getValue().getContainerBookingId()).isNull();
assertThat(entity.getValue().getLoadingPlanId()).isNull();
assertThat(entity.getValue().getWarehouseId()).isEqualTo(18L);
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已放舱");
verify(repository, never()).insert(any());
}
@Test void createRejectsBookingFromAnotherCustomer() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setCustomerId(17L);
booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(17L); booking.setBillOfLadingNo("BL-1");
when(oceanBookings.find(99L)).thenReturn(booking);
assertThatThrownBy(() -> service.create(7L, "admin", request))
@@ -188,9 +182,9 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setCustomerId(7L); booking.setWarehouseId(18L);
booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-1");
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setCustomerId(7L); container.setWarehouseId(18L);
container.setId(88L); container.setRecordStatus("ACTIVE"); container.setCustomerId(7L); 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());
@@ -200,13 +194,61 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void createRejectsLoadingPlanWithoutContainerAssociation() {
@Test void createRejectsContainerWithoutRealIsoNumberAndMissingBookingDocument() {
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());
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).insert(any());
}
@Test void editCanSwitchOnlyToAnotherMatchedReleasedBookingAndContainer() {
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 3);
existing.setCustomerId(7L); existing.setOceanBookingId(99L); existing.setContainerBookingId(88L);
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.updateEdit(any(), eq(3))).thenReturn(1);
service.edit(1L, 7L, "admin", request);
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().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("loadingPlanId")
.hasMessageContaining("containerBookingId");
.isInstanceOf(BizException.class).hasMessageContaining("containerBookingId");
verify(repository, never()).insert(any());
}
@@ -220,9 +262,9 @@ class TruckAppointmentServiceTest {
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L);
OceanBookingEntity booking = releasedBooking();
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
ContainerBookingEntity container = realContainer();
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
@@ -245,9 +287,9 @@ class TruckAppointmentServiceTest {
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L);
OceanBookingEntity booking = releasedBooking();
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
ContainerBookingEntity container = realContainer();
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
@@ -293,8 +335,11 @@ class TruckAppointmentServiceTest {
verify(repository, never()).history(any());
}
@Test void dispatchRequiresBillOfLadingNumberAndActiveOwnedAttachment() {
@Test void dispatchRequiresBookingBillAndActiveBookingOwnedDocument() {
TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
missingBill.setOceanBookingId(99L); missingBill.setContainerBookingId(88L);
OceanBookingEntity bookingWithoutBill = releasedBooking(); bookingWithoutBill.setBillOfLadingNo(null);
when(oceanBookings.find(99L)).thenReturn(bookingWithoutBill);
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingBill);
@@ -303,34 +348,30 @@ class TruckAppointmentServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
missingAttachment.setBillOfLadingNo("BL-1");
missingAttachment.setOceanBookingId(99L); missingAttachment.setContainerBookingId(88L); missingAttachment.setBillOfLadingNo("BL-BOOKING-1");
missingAttachment.setCustomerId(7L);
when(oceanBookings.find(99L)).thenReturn(releasedBooking());
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingAttachment);
when(repository.attachments(1L)).thenReturn(List.of());
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-file", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("有效提单附件");
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
}
@Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
before.setCustomerId(7L); before.setOceanBookingId(99L); before.setBillOfLadingNo(null);
before.setCustomerId(7L); before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2);
after.setCustomerId(7L); after.setOceanBookingId(99L); after.setBillOfLadingNo("BL-BOOKING-1");
after.setCustomerId(7L); 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");
TruckAppointmentAttachmentEntity attachment = new TruckAppointmentAttachmentEntity();
attachment.setFileId(88L); attachment.setAttachmentType("BILL_OF_LADING");
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(repository.attachments(1L)).thenReturn(List.of(attachment));
when(files.metadata("test-internal-token", 7L, "TRUCK_APPOINTMENT_ATTACHMENT", "1", 88L))
.thenReturn(ApiResponse.success(new TruckAppointmentFileClient.Metadata(88L, 7L,
"TRUCK_APPOINTMENT_ATTACHMENT", "1", "bill.pdf", "hash", "application/pdf", 10L)));
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);
@@ -457,10 +498,24 @@ class TruckAppointmentServiceTest {
when(request.customerId()).thenReturn(7L);
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.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
return request;
}
private static OceanBookingEntity releasedBooking() {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L);
booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-BOOKING-1");
return booking;
}
private static ContainerBookingEntity realContainer() {
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setRecordStatus("ACTIVE"); container.setCustomerId(7L);
container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
return container;
}
}