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

This commit is contained in:
2026-09-01 15:14:24 +08:00
138 changed files with 4762 additions and 1056 deletions
@@ -62,8 +62,9 @@ class ContainerControllerContractTest {
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
.contains("RELEASED oceanBookingId")
.contains("未满配 requirementId")
.contains("唯一 containerNo")
.contains("校验ISO 6346结构、设备类别和校验位")
.contains("containerNo 可空")
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
.contains("校验 ISO 6346 结构、设备类别、校验位和全局唯一性")
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
.doesNotContain("ContainerOceanBookingUnlinkRequest")
.doesNotContain("ContainerTransportUpdateRequest")
@@ -81,7 +82,9 @@ class ContainerControllerContractTest {
assertThat(openapi)
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
.contains("Content-Disposition 与 Cache-Control no-store")
.contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
.contains("containerNo: {type: string, nullable: true")
.doesNotContain("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
@@ -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 schemaHasPlatformAggregateWithoutCustomerColumns() 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)").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer");}
@Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");}
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
@@ -30,8 +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 openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.customs.container;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ActionRequest;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ScopeRequest;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
import com.baiyue.firstleg.customs.client.CustomsFileClient;
@@ -108,6 +109,35 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).freezeScopes(anyLong());
}
@Test void declaringContainerFreezesUploadReplacementScopeAndDeletionWithoutDrift(){
var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
var declaring=container(ContainerCustomsService.DECLARING,3);
var source=document(1L);
var file=new MockMultipartFile("file","source.bin","application/octet-stream",new byte[]{1});
when(mapper.planInbounds(9L)).thenReturn(List.of(inbound));
when(mapper.container(9L)).thenReturn(declaring);
when(mapper.document(1L)).thenReturn(source);
when(mapper.lockContainerById(2L)).thenReturn(declaring);
assertThatThrownBy(()->service.uploadSource(7L,9L,5L,"upload-after-start",List.of(11L),file))
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
assertThatThrownBy(()->service.replaceSource(7L,1L,5L,"replace-after-start",new ScopeRequest(List.of(11L),1),file))
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
assertThatThrownBy(()->service.updateScope(7L,1L,5L,"scope-after-start",new ScopeRequest(List.of(11L),1)))
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
assertThatThrownBy(()->service.deleteSource(7L,1L,5L,"delete-after-start",1))
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
verifyNoInteractions(files);
verify(mapper,never()).insertDocument(anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong());
verify(mapper,never()).replaceDocument(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong());
verify(mapper,never()).touchScope(anyLong(),anyLong(),anyInt());
verify(mapper,never()).deleteDocument(anyLong(),anyLong(),anyInt(),anyLong());
verify(mapper,never()).insertDocumentVersion(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyString(),anyString(),anyLong());
assertThat(source.version()).isEqualTo(1);
assertThat(source.status()).isEqualTo("ACTIVE");
}
@Test void concurrentStartWithSameKeyReplaysOneAggregateAndDifferentPayloadConflicts() throws Exception {
var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
var ready=container(ContainerCustomsService.READY,2);
@@ -271,6 +301,27 @@ class ContainerCustomsServiceTest {
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
}
@Test void allBuyContainerCompletesOnceAndPublishesAutoDispatchTrigger(){
var declaring=container(ContainerCustomsService.DECLARING,3);
var completed=container(ContainerCustomsService.COMPLETED,4);
var buy=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","BUY_DECLARATION");
when(mapper.claimCommand(anyLong(),eq(5L),eq("COMPLETE_ALL_BUY_CUSTOMS_9"),eq("all-buy-key"),anyString())).thenReturn(1);
when(mapper.lockContainer(9L)).thenReturn(declaring);
when(mapper.planInbounds(9L)).thenReturn(List.of(buy));
when(mapper.complete(2L,3,5L,"admin")).thenReturn(1);
when(mapper.completeCommand(5L,"COMPLETE_ALL_BUY_CUSTOMS_9","all-buy-key",2L)).thenReturn(1);
when(mapper.container(9L)).thenReturn(completed);
when(mapper.documents(2L,null)).thenReturn(List.of());
var result=service.completeAllBuy(9L,5L,"admin","all-buy-key",new ActionRequest(3,true,"代理已完成"));
assertThat(result.status()).isEqualTo(ContainerCustomsService.COMPLETED);
verify(mapper,times(1)).complete(2L,3,5L,"admin");
verify(mapper,times(1)).insertHistory(anyLong(),eq(2L),eq("COMPLETE_CUSTOMS"),
eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.COMPLETED),eq("代理已完成"),eq(5L),eq("admin"));
verify(events,times(1)).publishEvent(new ContainerCustomsService.CustomsCompletedEvent(8L));
}
private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);}
private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);}
private void assertMerchantOrderNotFound(long tenantId,long orderId){
@@ -14,8 +14,10 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@SpringJUnitConfig(LoadingDeliveryService.class)
class LoadingDeliveryServiceTest {
@@ -45,4 +47,76 @@ class LoadingDeliveryServiceTest {
verify(cabinetEvidence).requireComplete(99L);
}
@Test
void pdaFinalizeUsesSourceVersionIncrementedByFullSettlement() throws Exception {
Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8);
when(repository.completionTarget(10L, 20L)).thenReturn(null);
when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed);
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
eq("pda-full"), anyString())).thenReturn(true);
when(repository.findInbound(10L, 20L)).thenReturn(refreshed);
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(1);
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result");
service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-full", "done");
verify(repository).completeLoading(10L, 20L, 9L, 8);
}
@Test
void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() throws Exception {
var target = new com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget(
20L, 21L, 22L, "LOADING", 6, "LOADING", 9);
Snapshot source = new Snapshot(20L, 10L, "LOADING", 6);
Snapshot loadedSlice = new Snapshot(21L, 10L, "LOADING", 9);
when(repository.completionTarget(10L, 20L)).thenReturn(target);
when(repository.findInboundForUpdate(10L, 21L)).thenReturn(loadedSlice);
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
eq("pda-partial"), anyString())).thenReturn(true);
when(repository.findInbound(10L, 20L)).thenReturn(source);
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
when(repository.completeLoadedChild(10L, 20L, 9L, 9)).thenReturn(1);
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result");
service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-partial", "done");
verify(repository).findInboundForUpdate(10L, 21L);
verify(repository).completeLoadedChild(10L, 20L, 9L, 9);
}
@Test
void pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange() {
Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8);
when(repository.completionTarget(10L, 20L)).thenReturn(null);
when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed);
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
eq("pda-conflict"), anyString())).thenReturn(true);
when(repository.findInbound(10L, 20L)).thenReturn(refreshed);
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(0);
try {
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("request");
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
throw new AssertionError(exception);
}
assertThatThrownBy(() -> service.completeAfterPdaSettlement(10L, 20L, 9L, "operator",
"pda-conflict", "done")).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
verify(repository).completeLoading(10L, 20L, 9L, 8);
verify(histories, never()).insert(org.mockito.ArgumentMatchers
.any(com.baiyue.firstleg.entity.InboundStatusHistoryEntity.class));
}
private static InboundProgress resolvedProgress() {
InboundProgress progress = new InboundProgress();
progress.setExpectedCount(2); progress.setPickedCount(2); progress.setTaskCount(1);
progress.setCompletedTaskCount(0); progress.setUnresolvedCount(0);
return progress;
}
}
@@ -67,6 +67,37 @@ class OceanBookingDocumentServiceTest {
verify(repository, times(1)).insert(any(OceanBookingDocumentEntity.class));
}
@Test void draftAcceptsBookingConfirmationAndBillOfLadingAsTwoVersionedFiles() {
MockMultipartFile confirmation = new MockMultipartFile("file", "confirmation.pdf",
"application/pdf", "confirmation".getBytes());
MockMultipartFile bill = new MockMultipartFile("file", "bill.pdf",
"application/pdf", "bill".getBytes());
when(repository.claimOperation(anyLong(), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
.thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(draft(0), draft(1));
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation))
.thenReturn(ApiResponse.success(metadata(91L)));
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", bill))
.thenReturn(ApiResponse.success(metadata(92L)));
when(repository.touchDraft(eq(10L), anyInt(), eq(7L), eq("admin"))).thenReturn(1);
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
.thenReturn(1);
var first = service.upload(10L, 0, "BOOKING_CONFIRMATION", "confirmation-key",
confirmation, 7L, "admin");
var second = service.upload(10L, 1, "BILL_OF_LADING", "bill-key", bill, 7L, "admin");
assertThat(first.bookingVersion()).isEqualTo(1);
assertThat(second.bookingVersion()).isEqualTo(2);
ArgumentCaptor<OceanBookingDocumentEntity> documents =
ArgumentCaptor.forClass(OceanBookingDocumentEntity.class);
verify(repository, times(2)).insert(documents.capture());
assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getDocumentType)
.containsExactly("BOOKING_CONFIRMATION", "BILL_OF_LADING");
assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getFileId)
.containsExactly(91L, 92L);
}
@Test void attachRejectsCrossScopeFileBeforePersisting() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(draft(1));
@@ -121,6 +152,27 @@ class OceanBookingDocumentServiceTest {
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
}
@Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() {
OceanBookingEntity booking = draft(3);
OceanBookingDocumentEntity confirmation = activeDocument(20L, 91L, "BOOKING_CONFIRMATION");
OceanBookingDocumentEntity bill = activeDocument(21L, 92L, "BILL_OF_LADING");
when(repository.activeDocuments(10L)).thenReturn(List.of(confirmation, bill));
assertThatThrownBy(() -> service.requireActiveValidDocument(booking))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L);
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
}
private static OceanBookingDocumentEntity activeDocument(long id, long fileId, String type) {
OceanBookingDocumentEntity document = new OceanBookingDocumentEntity();
document.setId(id); document.setOceanBookingId(10L); document.setFileId(fileId);
document.setDocumentType(type); document.setValid(true);
return document;
}
private static OceanBookingEntity draft(int version) {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version);
@@ -75,14 +75,17 @@ class ContainerBookingMapperContractTest {
}
@Test
@DisplayName("货柜草稿更新不得形成真实柜号或内部柜次号写入口")
void onlyOceanBookingLinkWritesInitialContainerNumber() throws Exception {
@DisplayName("真实柜号只允许订舱关联或提柜后原子补录")
void onlyBookingLinkOrPickupAssignmentWritesContainerNumber() throws Exception {
String draftSql = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("updateDraft", ContainerBookingEntity.class, int.class,
long.class, String.class).getAnnotation(Update.class).value());
String linkSql = String.join("\n", Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
.filter(method -> method.getName().equals("linkReleasedBooking"))
.findFirst().orElseThrow().getAnnotation(Update.class).value());
String pickupSql = String.join("\n", ContainerBookingMapper.class
.getDeclaredMethod("assignRealContainerNo", long.class, String.class, int.class,
long.class, String.class).getAnnotation(Update.class).value());
assertThat(draftSql)
.contains("capacity_cbm")
.contains("p.plan_status!='EDITING'")
@@ -92,6 +95,11 @@ class ContainerBookingMapperContractTest {
.doesNotContain("&gt;");
assertThat(linkSql).contains("container_no=#{containerNo}", "source_type='SIMULATION'",
"ocean_booking_id IS NULL");
assertThat(pickupSql)
.contains("container_no=#{containerNo}", "container_no IS NULL", "ocean_booking_id IS NOT NULL",
"ocean_booking_requirement_id IS NOT NULL", "version=#{version}", "fl_loading_pick_task",
"fl_loading_cabinet_evidence", "fl_container_document")
.doesNotContain("ocean_booking_id=#{");
assertThat(Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()).map(java.lang.reflect.Method::getName))
.doesNotContain("updateTransport", "unlinkReleasedBooking");
}
@@ -105,6 +113,7 @@ class ContainerBookingMapperContractTest {
}) {
assertThat(source)
.contains("国内唯一权威真实集装箱号")
.contains("提柜后可原子补录")
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
.contains("CONSTRAINT chk_container_booking_iso6346")
.contains("container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'");
@@ -78,7 +78,7 @@ class Phase3FinalUnifiedSqlContractTest {
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id));
for (int id : java.util.List.of(5102170,5102172)) assertThat(sql).contains(Integer.toString(id));
assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN (5102171,5102173,5102174,5102175)")
assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)")
.doesNotContain("(5102171,0,'PLATFORM','BUTTON'","(5102173,0,'PLATFORM','BUTTON'",
"(5102174,0,'PLATFORM','BUTTON'","(5102175,0,'PLATFORM','BUTTON'");
for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id));
@@ -111,6 +111,35 @@ class Phase3FinalUnifiedSqlContractTest {
"iam/src/main/resources/db/phase3-warehouse-roles.sql");
}
@Test
void customsBaselineIsUniqueSynchronisedAndRunsBeforeFinalSchemaAssertion() throws Exception {
String unified = normalise(Files.readString(resolveUnified()));
String marker = "-- 来源:firstleg/src/main/resources/db/phase3-container-customs-schema.sql";
String finalAssertion = "CREATE PROCEDURE assert_phase3_final_schema()";
List<String> tables = List.of(
"fl_customs_container",
"fl_customs_customer_document",
"fl_customs_customer_document_inbound",
"fl_customs_customer_document_version",
"fl_customs_frozen_document_inbound",
"fl_customs_completion_document",
"fl_customs_completion_document_version",
"fl_customs_container_history",
"fl_customs_command"
);
assertBlock(unified, marker, "/* Phase 3: link inbound preload plans",
"firstleg/src/main/resources/db/phase3-container-customs-schema.sql");
assertThat(count(unified, marker)).isEqualTo(1);
assertThat(unified.indexOf(marker)).isLessThan(unified.indexOf(finalAssertion));
for (String table : tables) {
String create = "CREATE TABLE IF NOT EXISTS " + table + " (";
assertThat(count(unified, create)).as(table).isEqualTo(1);
assertThat(unified.indexOf(create)).as(table + " before final assertion")
.isLessThan(unified.indexOf(finalAssertion));
}
}
@Test
void financeFeeSourceMigrationsAreContiguousAndSynchronised() throws Exception {
String unified = normalise(Files.readString(resolveUnified()));
@@ -155,7 +184,7 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2379);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2378);
assertThat(sql)
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
@@ -23,6 +23,9 @@ class TruckAppointmentMapperContractTest {
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")
.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");
@@ -34,5 +37,6 @@ class TruckAppointmentMapperContractTest {
"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("customer_id", "customer_name", "customerId", "customerName");
assertThat(edit).doesNotContain("receivable_amount", "receivableAmount");
}
}
@@ -212,6 +212,58 @@ class ContainerServiceTest {
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("模拟柜允许不带真实柜号原子关联已发布订舱需求")
void linksReleasedBookingWithoutRealContainerNumber() {
stubLinkContext();
ContainerBookingEntity container = repository.find(11L);
when(repository.find(11L)).thenReturn(container, container);
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, null, 2));
assertThat(result.getContainerNo()).isNull();
assertThat(result.getCabinetNo()).isEqualTo("柜-001");
verify(repository, never()).containerNoExists(anyString(), anyLong());
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("提柜后按柜次版本原子补录真实柜号")
void assignsRealContainerNumberAfterPickup() {
ContainerBookingEntity linked = booking("EDITING", 3);
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
ContainerBookingEntity assigned = booking("EDITING", 4);
assigned.setContainerNo("MSCU6639870"); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L);
when(repository.find(11L)).thenReturn(linked, assigned);
when(repository.assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin")).thenReturn(1);
ContainerBookingEntity result = service.assignRealContainerNo(
11L, " ms cu 663987 0 ", 3, 9L, "admin");
assertThat(result.getContainerNo()).isEqualTo("MSCU6639870");
verify(repository).assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin");
}
@Test
@DisplayName("提柜补录真实柜号仍拒绝全局重复号")
void rejectsDuplicateRealContainerNumberAfterPickup() {
ContainerBookingEntity linked = booking("EDITING", 3);
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
when(repository.find(11L)).thenReturn(linked);
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
assertThatThrownBy(() -> service.assignRealContainerNo(
11L, "MSCU6639870", 3, 9L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("首次关联拒绝错误ISO 6346校验位")
void rejectsInvalidContainerNumberCheckDigit() {
@@ -234,6 +234,38 @@ class InboundMeasurementServiceTest {
assertThat(result.confidence()).isEqualByComparingTo("0.50972");
}
@Test
void ocrFailureCreatesManualWeightThatCanBeAdjustedAndConfirmed() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
InboundMeasurementResponses.WeightResult created = service.createWeightResult(
1L, 10L, 9L,
new InboundMeasurementRequests.CreateWeightResultRequest(
120L, 501L, "FILE_WEIGHT_OCR", null, null, null, null));
assertThat(created.recognitionStatus()).isEqualTo("NEEDS_MANUAL");
ArgumentCaptor<InboundWeightResultEntity> saved =
ArgumentCaptor.forClass(InboundWeightResultEntity.class);
verify(repository).insertWeight(saved.capture());
InboundWeightResultEntity weight = saved.getValue();
weight.setId(20L);
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
when(repository.updateWeight(weight)).thenReturn(true);
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
when(repository.updateInboundWeightSummary(inbound)).thenReturn(true);
InboundMeasurementResponses.WeightResult confirmed = service.confirmWeightResult(
1L, 10L, 20L, 9L, "操作员",
new InboundMeasurementRequests.ConfirmWeightResultRequest(
0, new BigDecimal("30.000"), "KG", "OCR失败后人工确认"));
assertThat(confirmed.recognitionStatus()).isEqualTo("CONFIRMED");
assertThat(confirmed.grossWeightKg()).isEqualByComparingTo("30.000");
assertThat(confirmed.netWeightKg()).isEqualByComparingTo("11.500");
verify(repository, org.mockito.Mockito.times(2)).insertRevision(any(InboundWeightRevisionEntity.class));
}
@Test
void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -254,6 +286,30 @@ class InboundMeasurementServiceTest {
verify(warehousePallets).palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5");
}
@Test
void weightPalletOptionsFailsClosedOnWarehouseNon2xxResponse() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5"))
.thenThrow(mock(feign.FeignException.class));
assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, "TP-5"))
.isInstanceOf(BizException.class)
.hasMessageContaining("仓库服务暂不可用");
}
@Test
void weightPalletOptionsFailsClosedOnWarehouseNetworkError() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, null))
.thenThrow(mock(feign.RetryableException.class));
assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, null))
.isInstanceOf(BizException.class)
.hasMessageContaining("仓库服务暂不可用");
}
@Test
void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -775,9 +831,13 @@ class InboundMeasurementServiceTest {
}
@Test
void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() {
void approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals() {
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
inbound.setMeasureReviewStatus("APPROVED");
inbound.setActualGrossWeightKg(new BigDecimal("40.000"));
inbound.setActualNetWeightKg(new BigDecimal("30.000"));
inbound.setActualVolumeCbm(new BigDecimal("2.0000"));
inbound.setVolumeWeightKg(new BigDecimal("334.000"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(30L);
measurement.setStatus("APPROVED");
@@ -791,14 +851,17 @@ class InboundMeasurementServiceTest {
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
when(repository.updateInbound(inbound)).thenReturn(true);
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2));
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
new InboundMeasurementRequests.SubmitRequest(30L, 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("审核完成");
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.measureReviewStatus()).isEqualTo("APPROVED");
assertThat(measurement.getStatus()).isEqualTo("APPROVED");
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("40.000");
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("30.000");
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.0000");
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("334.000");
verify(repository, never()).updateMeasurement(any());
verify(repository).updateInbound(inbound);
verify(repository, never()).updateInbound(any());
}
@Test
@@ -44,10 +44,13 @@ import org.mockito.InOrder;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.time.LocalDateTime;
import java.util.function.Consumer;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
@@ -74,6 +77,8 @@ class LoadingPickServiceTest {
@MockBean
private LoadingCompletionCommandRepository completionCommands;
@MockBean
private TransactionTemplate completionTransaction;
@MockBean
private SmartLoadingRepository smartLoadingRepository;
@MockBean
private OceanBookingRepository oceanBookingRepository;
@@ -100,6 +105,12 @@ class LoadingPickServiceTest {
@BeforeEach
void activeOperationalContainer() {
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
Consumer<TransactionStatus> action = invocation.getArgument(0);
action.accept(mock(TransactionStatus.class));
return null;
}).when(completionTransaction).executeWithoutResult(any());
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setId(99L);
booking.setContainerNo("CSLU1234567");
@@ -92,6 +92,7 @@ class MeasurementReviewServiceTest {
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
item.setVolumeWeightKg(new BigDecimal("124.916"));
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
@@ -119,6 +120,40 @@ class MeasurementReviewServiceTest {
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
}
@Test
@DisplayName("多SKU测量行的箱数、重量、体积和体积重守恒后才能进入WAIT_SPLIT")
void shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch() {
Fixture fixture = fixture(false);
InboundMeasurementItemEntity first = measurementItem(2, "0.3000", "50.100");
InboundMeasurementItemEntity second = measurementItem(3, "0.4480", "74.816");
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(first, second));
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-multi-sku",
new MeasurementReviewRequest(20L, 3, null));
ArgumentCaptor<InboundStatusHistoryEntity> history =
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
@DisplayName("体积或体积重任一层汇总不一致时审核零写入")
void shouldRejectApprovalWhenVolumeSnapshotDoesNotConserve() {
Fixture fixture = fixture(false);
fixture.order.setActualVolumeCbm(new BigDecimal("0.7479"));
assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L,
"approve-volume-mismatch", new MeasurementReviewRequest(20L, 3, null)))
.isInstanceOf(BizException.class)
.hasMessageContaining("体积和体积重");
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
verify(fixture.repository, never()).transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString());
}
@Test
@DisplayName("驳回原因必填且状态推进为测量驳回")
void shouldRequireRejectReason() {
@@ -218,13 +253,15 @@ class MeasurementReviewServiceTest {
order.setTotalPackageCount(5);
order.setActualGrossWeightKg(new BigDecimal("1787.500"));
order.setActualNetWeightKg(new BigDecimal("1747.500"));
order.setActualVolumeCbm(new BigDecimal("0.7480"));
order.setVolumeWeightKg(new BigDecimal("124.916"));
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
measurement.setId(20L);
measurement.setStatus("SUBMITTED");
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(5);
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
item.setVolumeWeightKg(new BigDecimal("124.916"));
measurement.setTotalPackageCount(5);
measurement.setTotalVolumeCbm(new BigDecimal("0.7480"));
measurement.setVolumeWeightKg(new BigDecimal("124.916"));
InboundMeasurementItemEntity item = measurementItem(5, "0.7480", "124.916");
when(repository.findOwned(10001L, 10L)).thenReturn(order);
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
@@ -237,6 +274,14 @@ class MeasurementReviewServiceTest {
return new Fixture(service, repository, idempotency, order, measurement);
}
private InboundMeasurementItemEntity measurementItem(int packages, String volume, String volumeWeight) {
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
item.setPackageCount(packages);
item.setTotalVolumeCbm(new BigDecimal(volume));
item.setVolumeWeightKg(new BigDecimal(volumeWeight));
return item;
}
private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) {
InboundWeightResultEntity weight = new InboundWeightResultEntity();
weight.setRecognitionStatus("CONFIRMED");
@@ -125,24 +125,35 @@ class TruckAppointmentServiceTest {
@Test void createRejectsAnotherActiveAppointmentForTheSameRealContainerWithoutWriting() {
SaveRequest request = validRequest();
when(repository.activeForUpdate(88L)).thenReturn(List.of(entity(TruckAppointmentStatus.DRAFT, 0)));
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 0);
existing.setAppointmentNo("TA-260901-0003");
when(repository.firstActiveForUpdate(88L)).thenReturn(existing);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约");
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
verifyNoInteractions(oceanBookingDocuments);
verify(repository, never()).insert(any());
verify(repository, never()).history(any());
assertThat(existing.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
assertThat(existing.getVersion()).isZero();
}
@Test void concurrentCreateUniqueCollisionBecomesBusinessConflictWithoutHistory() {
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).insert(any());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约");
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
verify(repository, times(2)).firstActiveForUpdate(88L);
verify(repository, never()).history(any());
assertThat(concurrent.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
assertThat(concurrent.getVersion()).isZero();
}
@Test void createPreservesMaximumChineseRemarkAndBoundaryValues() {
@@ -164,6 +175,31 @@ class TruckAppointmentServiceTest {
assertThat(inserted.getValue().getPalletCount()).isZero();
assertThat(inserted.getValue().getGrossWeightKg()).isZero();
assertThat(inserted.getValue().getVolumeCbm()).isZero();
assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB");
}
@Test void createAcceptsUsd() {
SaveRequest request = validRequest();
when(request.currency()).thenReturn("USD");
when(request.payableAmount()).thenReturn(new java.math.BigDecimal("123.45"));
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(inserted.capture());
assertThat(inserted.getValue().getCurrency()).isEqualTo("USD");
assertThat(inserted.getValue().getPayableAmount()).isEqualByComparingTo("123.45");
}
@Test void createRejectsUnsupportedCurrencyWithoutWriting() {
SaveRequest request = validRequest();
when(request.currency()).thenReturn("CNY");
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("RMB或USD");
verify(repository, never()).insert(any());
verify(repository, never()).history(any());
}
@Test void platformOwnedAttachmentUsesZeroTenantWithoutCustomerSnapshot() {