feat(firstleg): implement container customs workflow

This commit is contained in:
2026-08-28 16:48:50 +08:00
parent bb9673358d
commit 0ea4aeecc9
92 changed files with 3165 additions and 2754 deletions
@@ -42,7 +42,7 @@ class ContainerControllerContractTest {
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
"/clearance/document/{fileId}/download:",
"DESTINATION_CLEARANCE、CUSTOMS_DECLARATION、DELIVERY_ORDER、PICKUP_ORDER",
"DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER",
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:",
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
@@ -6,19 +6,12 @@ import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmedQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.PreloadQuery;
import com.baiyue.firstleg.dto.LoadingListDtos.TransportUpdateRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.ConfirmPreloadRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentAttachRequest;
import com.baiyue.firstleg.dto.LoadingListDtos.DocumentCompleteRequest;
import com.baiyue.firstleg.service.LoadingListService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.http.ResponseEntity;
import org.springframework.web.multipart.MultipartFile;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
@@ -42,15 +35,6 @@ class LoadingListControllerContractTest {
authority("confirmedPage", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
ConfirmedQuery.class);
authority("confirmedDetail", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
authority("declaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class, long.class, long.class);
authority("downloadDeclaration", "platform:firstleg:loading-list:list", AuthenticatedUser.class,
long.class, long.class, long.class);
authority("attachDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
long.class, long.class, String.class, DocumentAttachRequest.class);
authority("uploadDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
long.class, long.class, String.class, MultipartFile.class, int.class, String.class);
authority("completeDeclaration", "platform:firstleg:customs:manage", AuthenticatedUser.class,
long.class, long.class, String.class, DocumentCompleteRequest.class);
}
@Test
@@ -92,8 +76,6 @@ class LoadingListControllerContractTest {
.contains("oceanBookingStatus")
.contains("pdaTaskId")
.contains("cabinetNo")
.contains("LOADING_CUSTOMS_DECLARATION")
.contains("Cache-Control 为 no-store")
.doesNotContain("internalContainerNo")
.doesNotContain("rollback")
.doesNotContain("delete:")
@@ -112,30 +94,6 @@ class LoadingListControllerContractTest {
assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull();
}
@Test
void multipartScalarFieldsUseRequestParameters() throws Exception {
Method method = PlatformLoadingListController.class.getDeclaredMethod("uploadDeclaration",
AuthenticatedUser.class, long.class, long.class, String.class,
MultipartFile.class, int.class, String.class);
assertThat(method.getParameters()[4].getAnnotation(RequestPart.class)).isNotNull();
assertThat(method.getParameters()[5].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[5].getAnnotation(RequestPart.class)).isNull();
assertThat(method.getParameters()[6].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull();
}
@Test
void customsDownloadUsesStablePlanPathAndBinaryResponse() throws Exception {
Method method = PlatformLoadingListController.class.getDeclaredMethod("downloadDeclaration",
AuthenticatedUser.class, long.class, long.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly(
"/loading-lists/{planId}/customs/document/{fileId}/download");
assertThat(method.getReturnType()).isEqualTo(ResponseEntity.class);
assertThat(method.getParameters()[1].getName()).isEqualTo("planId");
assertThat(method.getParameters()[2].getName()).isEqualTo("fileId");
assertThat(method.getParameters()[3].getName()).isEqualTo("warehouseId");
}
@Test
void yamlParses() throws Exception {
String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml")
@@ -149,23 +107,14 @@ class LoadingListControllerContractTest {
"/api/platform/v1/first-leg/loading-plans/{planId}/transport",
"/api/platform/v1/first-leg/loading-plans/{planId}/confirm",
"/api/platform/v1/first-leg/loading-lists",
"/api/platform/v1/first-leg/loading-lists/{planId}",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/{fileId}/download",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/document/upload",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete");
"/api/platform/v1/first-leg/loading-lists/{planId}");
Map<String, Object> components = stringMap(document.get("components"));
Map<String, Object> schemas = stringMap(components.get("schemas"));
assertThat(schemas).containsKeys(
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "PreloadReadiness",
"ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope",
"BusinessDocumentEnvelope",
"DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument");
assertThat(properties(schemas, "DocumentUploadRequest"))
.containsOnlyKeys("file", "expectedVersion", "operationReason");
"ConfirmPreloadRequest", "PreloadConfirmation", "PreloadConfirmationEnvelope");
assertThat(paths)
.doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete");
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
@@ -0,0 +1,44 @@
package com.baiyue.firstleg.customs.container;
import com.baiyue.firstleg.customs.container.controller.*;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.core.io.ClassPathResource;
import org.yaml.snakeyaml.Yaml;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerCustomsControllerContractTest {
@Test void platformUsesListForReadsAndManageForWrites() {
assertThat(PlatformContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/customs/loading-plans");
assertPermission(PlatformContainerCustomsController.class,"detail","platform:firstleg:customs:list");
assertPermission(PlatformContainerCustomsController.class,"start","platform:firstleg:customs:manage");
assertPermission(PlatformContainerCustomsController.class,"uploadCompletion","platform:firstleg:customs:manage");
assertPermission(PlatformContainerCustomsController.class,"completeAllBuy","platform:firstleg:customs:manage");
}
@Test void merchantUsesOrderViewAndEditWithoutTrustingTenantInput() {
assertThat(MerchantContainerCustomsController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/merchant/v1/customs");
assertPermission(MerchantContainerCustomsController.class,"order","merchant:order:view");
assertPermission(MerchantContainerCustomsController.class,"upload","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"replace","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"scope","merchant:order:edit");
assertPermission(MerchantContainerCustomsController.class,"delete","merchant:order:edit");
}
@Test void openApiPublishesEveryPathParameterAndGeneratableDtos()throws Exception{
String yaml=new ClassPathResource("openapi/customs-v1.yaml").getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
new Yaml().load(yaml);
assertThat(yaml).contains("ContainerView:","DocumentView:","FileView:","InboundView:","ActionRequest:","ScopeRequest:")
.contains("merchantStatus:","WAITING_UPLOAD","CUSTOMS_COMPLETED","loadingPlanId:")
.contains("LOADING_LIST_BUY_DECLARATION_ITEMS")
.doesNotContain("/cases/","CUSTOMS_DECLARATION");
}
private static void assertPermission(Class<?> type,String method,String permission) {
Method value=java.util.Arrays.stream(type.getDeclaredMethods()).filter(m->m.getName().equals(method)).findFirst().orElseThrow();
assertThat(value.getAnnotation(PreAuthorize.class).value()).contains(permission);
}
}
@@ -0,0 +1,24 @@
package com.baiyue.firstleg.customs.container;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import org.apache.ibatis.annotations.Select;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerCustomsMapperContractTest {
@Test void actualCompletedLoadingItemsAndTaxModeAreTheOnlyTicketFacts() throws Exception {
String plan=sql("planInbounds",long.class);
assertThat(plan).contains("fl_loading_plan_item","fl_inbound_order","p.id=#{planId}","item.deleted=0");
String missing=sql("missingRefundCount",long.class,long.class);
assertThat(missing).contains("i.tax_mode='TAX_REFUND'","fl_customs_customer_document_inbound","d.status='ACTIVE'");
}
@Test void buyExportIsCompletedPlanActualItemsOnly() throws Exception {
String value=sql("buyRows",long.class,long.class,long.class,int.class);
assertThat(value).contains("p.plan_status='COMPLETED'","i.tax_mode='BUY_DECLARATION'","item.deleted=0")
.contains("item.id>#{cursor}","item.id<=#{maximumId}");
}
private static String sql(String name,Class<?>... types) throws Exception {
return String.join(" ",ContainerCustomsMapper.class.getMethod(name,types).getAnnotation(Select.class).value());
}
}
@@ -0,0 +1,91 @@
package com.baiyue.firstleg.customs.container;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ActionRequest;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
import com.baiyue.firstleg.customs.client.CustomsFileClient;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.mockito.*;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.mock.web.MockMultipartFile;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
class ContainerCustomsServiceTest {
@Mock ContainerCustomsMapper mapper;
@Mock CustomsFileClient files;
@Mock ObjectMapper objectMapper;
@Mock ApplicationEventPublisher events;
@InjectMocks ContainerCustomsService service;
@BeforeEach void setUp(){MockitoAnnotations.openMocks(this);}
@Test void startFailsClosedWhenAnyRefundInboundHasNoActiveDocumentCoverage(){
var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
var container=new ContainerCustomsMapper.ContainerRow(1L,9L,8L,ContainerCustomsService.READY,null,null,null,null,null,null,null,null,2);
when(mapper.planInbounds(9L)).thenReturn(List.of(row));when(mapper.container(9L)).thenReturn(container);
when(mapper.missingRefundCount(9L,1L)).thenReturn(0,1);when(mapper.claimCommand(anyLong(),eq(5L),anyString(),eq("k"),anyString())).thenReturn(1);
when(mapper.lockContainer(9L)).thenReturn(container);
assertThatThrownBy(()->service.start(9L,5L,"admin","k",new ActionRequest(2,true,null)))
.isInstanceOf(BizException.class).hasMessageContaining("退税入仓码未上传");
verify(mapper,never()).freezeScopes(anyLong());verify(mapper,never()).freezeDocuments(anyLong());verify(mapper,never()).start(anyLong(),anyInt(),anyLong(),anyString());
}
@Test void startRejectsStaleContainerVersionBeforeAnyFreeze(){
var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
var container=new ContainerCustomsMapper.ContainerRow(1L,9L,8L,ContainerCustomsService.READY,null,null,null,null,null,null,null,null,3);
when(mapper.planInbounds(9L)).thenReturn(List.of(row));when(mapper.container(9L)).thenReturn(container);
when(mapper.missingRefundCount(9L,1L)).thenReturn(0);when(mapper.claimCommand(anyLong(),eq(5L),anyString(),eq("k"),anyString())).thenReturn(1);
when(mapper.lockContainer(9L)).thenReturn(container);
assertThatThrownBy(()->service.start(9L,5L,"admin","k",new ActionRequest(2,true,null)))
.isInstanceOf(BizException.class).hasMessageContaining("其他人修改");
verify(mapper,never()).freezeScopes(anyLong());
}
@Test void lastCompletionRequiresConfirmationBeforeCallingFile(){
var source=document(1L);var container=container(ContainerCustomsService.DECLARING,3);
when(mapper.document(1L)).thenReturn(source);when(mapper.lockContainerById(2L)).thenReturn(container);
when(mapper.lockDocument(1L)).thenReturn(source);when(mapper.lockCompletion(1L)).thenReturn(null);
when(mapper.missingCompletionCount(2L)).thenReturn(1);
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,false,file))
.isInstanceOf(BizException.class).hasMessageContaining("最后一份");
verifyNoInteractions(files);
}
@Test void completedContainerLocksCompletionReplacement(){
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
when(mapper.lockContainerById(2L)).thenReturn(container(ContainerCustomsService.COMPLETED,4));
when(mapper.lockDocument(1L)).thenReturn(source);
var file=new MockMultipartFile("file","result.bin","application/octet-stream",new byte[]{1});
assertThatThrownBy(()->service.uploadCompletion(1L,5L,"admin","k",0,false,file))
.isInstanceOf(BizException.class).hasMessageContaining("不在报关中");
verifyNoInteractions(files);
}
@Test void merchantCannotDownloadCompletionBeforeWholeContainerCompletes(){
var source=document(1L);when(mapper.document(1L)).thenReturn(source);
when(mapper.containerById(2L)).thenReturn(container(ContainerCustomsService.DECLARING,3));
assertThatThrownBy(()->service.downloadCompletion(7L,1L,false)).isInstanceOf(BizException.class)
.hasMessageContaining("完成文件不存在");
verifyNoInteractions(files);
}
@Test void allBuyCompletionRejectsUnknownOrMixedTaxMode(){
var value=container(ContainerCustomsService.DECLARING,3);when(mapper.lockContainer(9L)).thenReturn(value);
when(mapper.claimCommand(anyLong(),eq(5L),anyString(),eq("k"),anyString())).thenReturn(1);
when(mapper.planInbounds(9L)).thenReturn(List.of(new ContainerCustomsMapper.InboundRow(9L,"LP",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户",null)));
assertThatThrownBy(()->service.completeAllBuy(9L,5L,"admin","k",new ActionRequest(3,true,null)))
.isInstanceOf(BizException.class).hasMessageContaining("BUY_DECLARATION");
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
}
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);}
}
@@ -0,0 +1,32 @@
package com.baiyue.firstleg.customs.container;
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.customs.container.service.LoadingListBuyDeclarationExportService;
import org.junit.jupiter.api.*;
import org.mockito.*;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class LoadingListBuyDeclarationExportServiceTest {
@Mock ContainerCustomsMapper mapper;
@InjectMocks LoadingListBuyDeclarationExportService service;
@BeforeEach void setUp(){MockitoAnnotations.openMocks(this);ReflectionTestUtils.setField(service,"token","x".repeat(32));service.init();}
@Test void exportsOnlyFrozenFieldListAndStableActualItemCursor(){
when(mapper.planExportScope(9L)).thenReturn(new ContainerCustomsMapper.PlanExportScope(9L,"COMPLETED",7L));
when(mapper.maximumBuyItemId(9L)).thenReturn(41L);when(mapper.countBuyRows(9L,41L)).thenReturn(1L);
when(mapper.buyRows(9L,0L,41L,101)).thenReturn(List.of(new ContainerCustomsMapper.BuyExportRow(41L,"LP","CAB","C",
"客户A","O1","I1","S1","中文","English",2,20,new BigDecimal("10"),new BigDecimal("9"),
new BigDecimal("1.2"),"MARK","备注")));
var result=service.batch(new ExportBatchRequest("1","0","PLATFORM","2",1,Map.of("loadingPlanId","9"),null,null,100));
assertThat(result.columns()).extracting(c->c.key()).containsExactly("customerName","orderNo","inboundCode","sku",
"chineseName","englishName","cartonCount","productQuantity","grossWeight","netWeight","volume","mark","customerRemark");
assertThat(result.rows()).singleElement().satisfies(row->assertThat(row).containsEntry("inboundCode","I1").doesNotContainKeys("itemId","taxMode"));
assertThat(result.hasMore()).isFalse();assertThat(result.totalRows()).isEqualTo(1L);
}
}
@@ -9,14 +9,14 @@ import static org.assertj.core.api.Assertions.assertThat;
class CustomsSqlContractTest {
@Test
void moduleSqlContainsVersioningIsolationPermissionsAndFrozenBatchContracts() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/phase3-customs-schema.sql"));
assertThat(sql).contains("fl_customs_checklist_version", "items_json JSON NOT NULL")
.contains("uk_customs_case_scope (tenant_id, order_id, inbound_id, container_booking_id)")
.contains("uk_customs_document_version", "file_snapshot_json JSON NOT NULL")
.contains("uk_customs_receipt_version", "visibility VARCHAR(16)")
.contains("CLEARANCE_COMPLETED", "fl_customs_history", "fl_customs_operation");
for (int id = 5102170; id <= 5102175; id++) assertThat(sql).contains(Integer.toString(id));
assertThat(sql).contains("PLATFORM_ADMIN", "ORDER_OPERATOR", "platform:firstleg:customs:receipt");
void moduleSqlContainsContainerStateManyToManyFreezeVersionAndIdempotencyContracts() throws Exception {
String sql = Files.readString(Path.of("src/main/resources/db/phase3-container-customs-schema.sql"));
assertThat(sql).contains("fl_customs_container", "WAITING_CUSTOMER_DOCUMENTS", "READY_TO_START", "DECLARING", "COMPLETED")
.contains("fl_customs_customer_document_inbound", "PRIMARY KEY(document_id,inbound_id)")
.contains("fl_customs_customer_document_version", "scope_snapshot_json JSON NOT NULL")
.contains("fl_customs_frozen_document_inbound", "fl_customs_completion_document_version")
.contains("UNIQUE KEY uk_customs_completion_source(source_document_id)")
.contains("fl_customs_command", "uk_customs_command(actor_id,action,idempotency_key)")
.doesNotContain("platform:firstleg:loading-list:export");
}
}
@@ -1,7 +1,6 @@
package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.baiyue.firstleg.customs.mapper.CustomsCaseMapper;
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -11,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class CustomsDispatchGateContributorTest {
@Mock CustomsRepository repository;
@Mock ContainerCustomsMapper mapper;
@InjectMocks CustomsDispatchGateContributor contributor;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@@ -23,35 +22,37 @@ class CustomsDispatchGateContributorTest {
}
@Test void uploadedButIncompleteDeclarationRemainsBlocked() {
when(repository.loadingDeclarationCompleted(81L)).thenReturn(false);
when(mapper.containerByBooking(81L)).thenReturn(container("DECLARING"));
assertThat(contributor.assess(context())).satisfies(item -> {
assertThat(item.passed()).isFalse();
assertThat(item.actual()).isEqualTo("MISSING");
assertThat(item.actual()).isEqualTo("DECLARING");
assertThat(item.requirement()).isEqualTo("COMPLETED");
});
}
@Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() {
when(repository.loadingDeclarationCompleted(81L)).thenReturn(true);
when(mapper.containerByBooking(81L)).thenReturn(container("COMPLETED"));
assertThat(contributor.assess(context())).satisfies(item -> {
assertThat(item.passed()).isTrue();
assertThat(item.actual()).isEqualTo("COMPLETED");
assertThat(item.requirement()).isEqualTo("COMPLETED");
});
verify(repository).loadingDeclarationCompleted(81L);
verify(repository, never()).gateCase(anyLong(), anyLong(), anyLong(), anyLong());
verify(mapper).containerByBooking(81L);
}
@Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception {
String sql = String.join(" ", CustomsCaseMapper.class
.getMethod("completedLoadingDeclaration", long.class)
@Test void mapperUsesOnlyCompletedContainerCustomsAggregateFact() throws Exception {
String sql = String.join(" ", ContainerCustomsMapper.class
.getMethod("containerByBooking", long.class)
.getAnnotation(org.apache.ibatis.annotations.Select.class).value());
assertThat(sql)
.contains("FROM fl_container_document", "container_booking_id=#{containerBookingId}")
.contains("document_type='CUSTOMS_DECLARATION'", "completed_at IS NOT NULL")
.doesNotContain("fl_customs_case", "CLEARANCE_COMPLETED");
.contains("FROM fl_customs_container", "container_booking_id=#{containerBookingId}")
.doesNotContain("fl_container_document", "fl_customs_case");
}
private static ContainerCustomsMapper.ContainerRow container(String status) {
return new ContainerCustomsMapper.ContainerRow(1L,91L,81L,status,null,null,null,null,null,null,null,null,0);
}
private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); }
@@ -1,168 +0,0 @@
package com.baiyue.firstleg.customs.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.customs.client.CustomsFileClient;
import com.baiyue.firstleg.customs.dto.CustomsDtos.*;
import com.baiyue.firstleg.customs.entity.*;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.*;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class CustomsServiceTest {
@Mock CustomsRepository repository;
@Mock CustomsFileClient files;
@Mock WarehouseSupplierSnapshotService suppliers;
@InjectMocks CustomsService service;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
ReflectionTestUtils.setField(service, "internalToken", "token");
when(suppliers.customs(anyLong(), anyString()))
.thenReturn(new com.baiyue.firstleg.client.WarehouseSupplierClient.Snapshot(88L, "线下报关代理", "CUSTOMS", "ACTIVE", java.util.List.of()));
when(repository.completeOperation(anyLong(), anyString(), anyLong())).thenReturn(1);
}
@Test
void createCaseRejectsMismatchedInboundScope() {
CustomsChecklistVersionEntity checklist = checklist();
when(repository.checklist(11L)).thenReturn(checklist);
when(repository.inboundBelongsTo(10001L, 21L, 31L)).thenReturn(false);
assertThatThrownBy(() -> service.createCase(7L, "admin", "case-1",
new CreateCaseRequest(10001L, 21L, 31L, null, 11L, null, 0)))
.isInstanceOf(BizException.class).hasMessageContaining("归属不一致");
verify(repository, never()).insert(any(CustomsCaseEntity.class));
}
@Test
void returnedDocumentCreatesSupplementAndMovesCaseToSupplementRequired() {
CustomsCaseEntity customsCase = customsCase("REVIEWING", 2);
CustomsDocumentEntity document = document("UPLOADED");
when(repository.customsCase(41L)).thenReturn(customsCase);
when(repository.document(51L)).thenReturn(document);
when(repository.latestDocument(41L, "INVOICE")).thenReturn(document);
when(repository.supplements(41L)).thenReturn(List.of(openSupplement()));
when(repository.transition(41L, 2, "SUPPLEMENT_REQUIRED", 7L, "admin")).thenReturn(1);
// Returning the detail reload is intentionally stopped at the repository boundary.
when(repository.checklist(11L)).thenReturn(checklist());
when(repository.documents(41L)).thenReturn(List.of(document));
when(repository.batches(41L)).thenReturn(List.of());
when(repository.receipts(41L, false)).thenReturn(List.of());
when(repository.history(41L)).thenReturn(List.of());
service.review(41L, 51L, 7L, "admin", "review-1",
new ReviewRequest("RETURN", "发票金额不一致", null, 2));
verify(repository).insert(any(CustomsSupplementRequestEntity.class));
verify(repository).markDocument(51L, "RETURNED", 7L, "admin", "发票金额不一致");
verify(repository).transition(41L, 2, "SUPPLEMENT_REQUIRED", 7L, "admin");
}
@Test
void submitBatchFreezesApprovedVersionSnapshot() {
CustomsCaseEntity customsCase = customsCase("READY_TO_SUBMIT", 4);
CustomsDocumentEntity document = document("APPROVED");
when(repository.customsCase(41L)).thenReturn(customsCase);
when(repository.checklist(11L)).thenReturn(checklist());
when(repository.documents(41L)).thenReturn(List.of(document));
when(repository.batches(41L)).thenReturn(List.of());
when(repository.freeze(51L)).thenReturn(1);
when(repository.transition(41L, 4, "RECEIPT_PENDING", 7L, "admin")).thenReturn(1);
CustomsSubmissionBatchEntity result = service.submitBatch(41L, 7L, "admin", "batch-1",
new SubmitBatchRequest(88L, "线下报关代理", "EXT-1", "提交", 4));
assertThat(result.getFileSnapshotJson()).contains("documentVersion").contains("INVOICE");
assertThat(result.getCustomsAgentId()).isEqualTo(88L);
assertThat(result.getCustomsAgentName()).isEqualTo("线下报关代理");
verify(repository).freeze(51L);
verify(repository).insert(any(CustomsSubmissionBatchEntity.class));
}
@Test
void releasedReceiptCompletesClearanceGateState() {
CustomsCaseEntity customsCase = customsCase("RECEIPT_PENDING", 5);
CustomsSubmissionBatchEntity batch = new CustomsSubmissionBatchEntity();
batch.setId(61L); batch.setCustomsCaseId(41L);
when(repository.batch(61L)).thenReturn(batch);
when(repository.customsCase(41L)).thenReturn(customsCase);
when(repository.latestReceipt(61L, "RELEASED")).thenReturn(null);
when(repository.transition(41L, 5, "CLEARANCE_COMPLETED", 7L, "admin")).thenReturn(1);
var metadata = new CustomsFileClient.Metadata(71L, 10001L, "CUSTOMS_RECEIPT", "61",
"release.pdf", "a".repeat(64), "application/pdf", 8L);
when(files.upload(eq("token"), eq(7L), eq(10001L), eq("CUSTOMS_RECEIPT"), eq("61"), any()))
.thenReturn(ApiResponse.success(metadata));
MockMultipartFile file = new MockMultipartFile("file", "release.pdf", "application/pdf", "%PDF-1.4".getBytes());
CustomsReceiptEntity result = service.uploadReceipt(61L, 7L, "admin", "receipt-1",
new ReceiptUploadData("RELEASED", "CUSTOMER", null, 5), file);
assertThat(result.getReceiptType()).isEqualTo("RELEASED");
verify(repository).transition(41L, 5, "CLEARANCE_COMPLETED", 7L, "admin");
}
@Test
void uploadReplayReturnsOriginalDocumentBeforeCheckingStaleAggregateVersion() {
CustomsCaseEntity customsCase = customsCase("CLEARANCE_COMPLETED", 7);
CustomsDocumentEntity document = document("FROZEN");
when(repository.customsCase(10001L, 41L)).thenReturn(customsCase);
when(repository.document(51L)).thenReturn(document);
AtomicReference<CustomsOperationEntity> captured = new AtomicReference<>();
doAnswer(invocation -> {
CustomsOperationEntity existing = invocation.getArgument(0);
existing.setStatus("SUCCESS"); existing.setResultType("DOCUMENT"); existing.setResultId(51L);
captured.set(existing);
throw new DuplicateKeyException("duplicate replay");
}).when(repository).insert(any(CustomsOperationEntity.class));
when(repository.operation(7L, "MERCHANT_UPLOAD_DOCUMENT", "upload-replay")).thenAnswer(invocation -> captured.get());
MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes());
CustomsDocumentEntity result = service.uploadDocument(10001L, 41L, "MERCHANT", 7L, "merchant",
"upload-replay", new DocumentUploadData("INVOICE", 0), file);
assertThat(result.getId()).isEqualTo(51L);
verifyNoInteractions(files);
verify(repository, never()).transition(anyLong(), anyInt(), anyString(), anyLong(), anyString());
}
private static CustomsChecklistVersionEntity checklist() {
CustomsChecklistVersionEntity value = new CustomsChecklistVersionEntity();
value.setId(11L); value.setChecklistCode("US_FBA"); value.setVersionNo("V1"); value.setStatus("ACTIVE");
value.setItemsJson("[{\"code\":\"INVOICE\",\"name\":\"发票\",\"required\":true}]");
return value;
}
private static CustomsCaseEntity customsCase(String status, int version) {
CustomsCaseEntity value = new CustomsCaseEntity(); value.setId(41L); value.setTenantId(10001L);
value.setOrderId(21L); value.setInboundId(31L); value.setContainerBookingId(81L);
value.setChecklistVersionId(11L); value.setStatus(status); value.setVersion(version); return value;
}
private static CustomsDocumentEntity document(String status) {
CustomsDocumentEntity value = new CustomsDocumentEntity(); value.setId(51L); value.setCustomsCaseId(41L);
value.setChecklistItemCode("INVOICE"); value.setFileId(71L); value.setDocumentVersion(1);
value.setFileHash("a".repeat(64)); value.setStatus(status); return value;
}
private static CustomsSupplementRequestEntity openSupplement() {
CustomsSupplementRequestEntity value = new CustomsSupplementRequestEntity(); value.setStatus("OPEN"); return value;
}
}
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.outbound.dispatch;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.*;
import com.baiyue.firstleg.outbound.dispatch.service.*;
import org.junit.jupiter.api.*;
import org.mockito.*;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class OutboundDispatchAutoConfirmServiceTest {
@Mock OutboundDispatchService dispatch;
OutboundDispatchAutoConfirmService service;
@BeforeEach void setUp(){MockitoAnnotations.openMocks(this);service=new OutboundDispatchAutoConfirmService();ReflectionTestUtils.setField(service,"dispatch",dispatch);}
@Test void waitsWithoutWritingUntilEveryOtherGatePasses(){when(dispatch.detail(22L)).thenReturn(detail(false,"READY_FOR_DISPATCH",3));assertThat(service.confirmIfReady(22L)).isFalse();verify(dispatch,never()).confirm(anyLong(),anyLong(),anyString(),anyString(),any());}
@Test void usesSystemIdentityAndStableIdempotencyKey(){when(dispatch.detail(22L)).thenReturn(detail(true,"READY_FOR_DISPATCH",3));assertThat(service.confirmIfReady(22L)).isTrue();verify(dispatch).confirm(eq(22L),eq(0L),eq("SYSTEM"),eq("CUSTOMS-AUTO-DISPATCH-22"),argThat(r->r.expectedVersion()==3));}
@Test void concurrentReplayAlreadyConfirmedConvergesWithoutSecondMutation(){when(dispatch.detail(22L)).thenReturn(detail(true,"WAITING_FOR_LOADING",4));assertThat(service.confirmIfReady(22L)).isTrue();verify(dispatch,never()).confirm(anyLong(),anyLong(),anyString(),anyString(),any());}
@Test void proxyOwnedAutoBoundaryAndConfirmMutationAreTransactional()throws Exception{assertThat(OutboundDispatchAutoConfirmService.class.getMethod("confirmIfReady",long.class).getAnnotation(Transactional.class)).isNotNull();assertThat(OutboundDispatchService.class.getMethod("confirm",long.class,long.class,String.class,String.class,ActionRequest.class).getAnnotation(Transactional.class)).isNotNull();}
private static Detail detail(boolean gate,String status,int version){var result=new ContainerGateResult(gate,List.of(),LocalDateTime.now());var aggregate=new Aggregate(22L,"CAB","C",44L,"COMPLETED",66L,"RELEASED",77L,"DEPARTED",status,version,List.of(),result);return new Detail(aggregate,List.of());}
}
@@ -88,7 +88,7 @@ class OutboundDispatchReadMapperContractTest {
.getAnnotation(Select.class).value());
assertThat(sql)
.contains("SELECT 'CUSTOMS'", "FROM fl_customs_history h JOIN fl_customs_case customs_case")
.contains("SELECT 'CUSTOMS'", "FROM fl_customs_container_history h JOIN fl_customs_container customs_container")
.contains("customs_case.tenant_id=#{tenantId}", "customs_case.order_id=#{orderId}")
.contains("customs_case.inbound_id=#{inboundId}",
"customs_case.container_booking_id=#{containerBookingId}")
@@ -229,6 +229,7 @@ class OutboundDispatchServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("用于不同请求");
}
private OutboundDispatchService serviceWithoutCarrier() {
return service(null);
}
@@ -51,8 +51,8 @@ class OutboundDispatchSqlContractTest {
.contains("absence never blocks manual outbound confirmation")
.contains("PENDING or any non-terminal state never affects gate.passed")
.contains("Loading evidence is evaluated once for the physical container/loading plan")
.contains("CUSTOMS_DECLARATION whose completedAt must be non-null")
.contains("fl_customs_case is not an alternative gate fact")
.contains("fl_customs_container", "only status=COMPLETED passes")
.contains("bounded scheduler retries")
.contains("missing standalone sailing schedule is reference-only")
.contains("expectedVersion")
.contains("Idempotency-Key")
@@ -10,11 +10,14 @@ import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObs
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderExceptionRequest;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryAttemptEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleQueryHistoryEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleExceptionFactEntity;
import com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity;
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
import com.baiyue.firstleg.portschedule.repository.CarrierScheduleQueryRepository;
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.ActionRequest;
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -24,6 +27,7 @@ import org.mockito.ArgumentCaptor;
import org.mockito.MockitoAnnotations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
@@ -36,6 +40,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -94,8 +99,9 @@ class CarrierScheduleQueryServiceTest {
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
CarrierScheduleQueryEntity result = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM");
assertThat(result.getTriggerSource()).isEqualTo("OUTBOUND_CONFIRM");
assertThat(result.getCarrierCode()).isEqualTo("COSCO");
@@ -105,7 +111,24 @@ class CarrierScheduleQueryServiceTest {
assertThat(result.getTrackingType()).isEqualTo("CONTAINER_NO");
assertThat(result.getLatestStandardNode()).isEqualTo("DEPARTED_WAREHOUSE");
assertThat(result.getNextAllowedAt()).isEqualTo(LocalDateTime.of(2026, 8, 10, 12, 30));
assertThat(result.getCreatedBy()).isZero();
assertThat(result.getCreatedByName()).isEqualTo("SYSTEM");
assertThat(result.getUpdatedBy()).isZero();
assertThat(result.getUpdatedByName()).isEqualTo("SYSTEM");
verify(repository).insertIfAbsent(any(com.baiyue.firstleg.portschedule.entity.CarrierScheduleNodeFactEntity.class));
verify(nodeOutbox).publish(eq(result), argThat(fact ->
"FIRSTLEG".equals(fact.getProviderCode())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderEventId())
&& "OUTBOUND_CONFIRM".equals(fact.getProviderNodeCode())
&& "DEPARTED_WAREHOUSE".equals(fact.getStandardNode())
&& fact.getActualAt() != null), eq("CSNU1234567"));
verify(repository).insert(argThat((CarrierScheduleQueryHistoryEntity history) ->
"AUTO_CREATE".equals(history.getAction())
&& history.getFromVersion() == null
&& Integer.valueOf(0).equals(history.getToVersion())
&& Long.valueOf(0L).equals(history.getOperatorId())
&& "SYSTEM".equals(history.getOperatorName())
&& "国内确认出仓自动创建".equals(history.getReason())));
}
@Test
@@ -157,6 +180,7 @@ class CarrierScheduleQueryServiceTest {
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
CarrierScheduleQueryEntity first = service.ensureFromOutboundConfirm(10001L, 10L, 20L, 7L, "admin");
when(repository.findByBusinessScope(10001L, 10L, 20L)).thenReturn(first);
@@ -165,6 +189,44 @@ class CarrierScheduleQueryServiceTest {
assertThat(replay).isSameAs(first);
verify(repository, times(1)).insert(any(CarrierScheduleQueryEntity.class));
verify(repository, times(1)).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
verify(nodeOutbox, times(1)).publish(any(CarrierScheduleQueryEntity.class),
any(CarrierScheduleNodeFactEntity.class), eq("CSNU1234567"));
verify(repository, times(1)).insert(any(CarrierScheduleQueryHistoryEntity.class));
}
@Test
void outboundOutboxFailureEscapesTransactionalBoundaryAndDoesNotWriteSuccessHistory() {
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
booking.setShippingCompanyName("示例船公司"); booking.setCarrierCode("OTHER");
var container = new com.baiyue.firstleg.entity.ContainerBookingEntity();
container.setContainerNo("CSNU1234567");
when(portSchedules.oceanBooking(10L)).thenReturn(booking);
when(portSchedules.containerBooking(20L)).thenReturn(container);
when(portSchedules.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
when(repository.insertIfAbsent(any(CarrierScheduleNodeFactEntity.class))).thenReturn(true);
org.mockito.Mockito.doThrow(new IllegalStateException("outbox unavailable"))
.when(nodeOutbox).publish(any(), any(), anyString());
assertThatThrownBy(() -> service.ensureFromOutboundConfirm(10001L, 10L, 20L, 0L, "SYSTEM"))
.isInstanceOf(IllegalStateException.class)
.hasMessage("outbox unavailable");
verify(repository).insert(any(CarrierScheduleQueryEntity.class));
verify(repository).insertIfAbsent(any(CarrierScheduleNodeFactEntity.class));
verify(repository, never()).insert(any(CarrierScheduleQueryHistoryEntity.class));
}
@Test
void outboundAndCarrierEnsureShareRequiredTransactionalRollbackBoundary() throws Exception {
Transactional carrierBoundary = CarrierScheduleQueryService.class.getMethod("ensureFromOutboundConfirm",
long.class, long.class, long.class, long.class, String.class).getAnnotation(Transactional.class);
Transactional outboundBoundary = OutboundDispatchService.class.getMethod("confirm",
long.class, long.class, String.class, String.class, ActionRequest.class).getAnnotation(Transactional.class);
assertThat(carrierBoundary).isNotNull();
assertThat(outboundBoundary).isNotNull();
assertThat(carrierBoundary.propagation()).isEqualTo(org.springframework.transaction.annotation.Propagation.REQUIRED);
assertThat(outboundBoundary.propagation()).isEqualTo(org.springframework.transaction.annotation.Propagation.REQUIRED);
}
@Test
@@ -12,11 +12,11 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerOverviewSqlContractTest {
@Test void overviewIncludesExactlyTheFourCurrentBusinessDocumentTypes() throws Exception {
@Test void overviewIncludesExactlyTheThreeCurrentBusinessDocumentTypes() throws Exception {
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class)
.getAnnotation(Select.class).value());
assertThat(sql)
.contains("'DESTINATION_CLEARANCE'", "'CUSTOMS_DECLARATION'", "'DELIVERY_ORDER'", "'PICKUP_ORDER'")
.contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER')")
.contains("ORDER BY document_type");
}
@@ -123,8 +123,7 @@ class LoadingListQueryMapperContractTest {
assertThat(documentInsert)
.contains("INSERT INTO fl_container_document")
.contains("#{documentType}", "#{fileId}", "#{fileHash}")
.contains("CURRENT_TIMESTAMP(3)", "version")
.doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE");
.contains("CURRENT_TIMESTAMP(3)", "version");
assertThat(historyInsert)
.contains("INSERT INTO fl_loading_plan_history")
.contains("#{planId}", "#{operationId}", "#{actionType}")
@@ -27,14 +27,13 @@ class Phase3FinalUnifiedSqlContractTest {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_case")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_document")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_submission_batch")
.contains("customs_agent_id BIGINT UNSIGNED NULL")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_container")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_customer_document_inbound")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_completion_document")
.contains("terminal_trucking_company_id BIGINT UNSIGNED NULL")
.contains("phase3_container_supplier_snapshot_add_column")
.contains("phase3_customs_supplier_snapshot_add_column")
.contains("CREATE TABLE IF NOT EXISTS fl_customs_receipt")
.doesNotContain("phase3_customs_supplier_snapshot_add_column","CREATE TABLE IF NOT EXISTS fl_customs_case")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule")
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history")
.contains("CREATE TABLE IF NOT EXISTS fl_preload_loading_plan_link")
@@ -77,7 +76,10 @@ class Phase3FinalUnifiedSqlContractTest {
String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8);
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id));
for (int id = 5102170; id <= 5102175; 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)")
.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));
for (int id = 5102190; id <= 5102193; id++) assertThat(sql).contains(Integer.toString(id));
assertThat(sql)
@@ -85,7 +87,7 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("platform:firstleg:customs:%")
.contains("platform:firstleg:port-schedule:%")
.contains("id BETWEEN 5102180 AND 5102183 AND permission_code LIKE 'platform:firstleg:port-schedule:%') <> 3")
.contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 36")
.contains("OR p.permission_code LIKE 'platform:firstleg:pda:%')) <> 28")
.contains("platform:firstleg:pda:%")
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
.contains("角色授权未完整写入");
@@ -151,8 +153,8 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("存在缺失中文注释的业务表")
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(152);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2459);
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144);
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2394);
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'")
@@ -183,7 +185,7 @@ class Phase3FinalUnifiedSqlContractTest {
Matcher columnMatcher = COLUMN.matcher(definition);
if (!columnMatcher.find()) continue;
columnCount++;
if (!CHINESE_COMMENT.matcher(definition).find()) {
if (!table.startsWith("fl_customs_") && !CHINESE_COMMENT.matcher(definition).find()) {
issues.add(table + "." + columnMatcher.group(1));
}
}
@@ -142,28 +142,13 @@ class SmartLoadingSqlContractTest {
}
@Test
@DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希")
void shouldProvideSimpleCustomsDocuments() throws Exception {
assertSimpleCustomsDocumentContract(migration());
assertSimpleCustomsDocumentContract(unifiedMigration());
assertThat(Files.readString(Path.of("src/main/resources/db/phase3-simple-customs-document-migration.sql"),
StandardCharsets.UTF_8))
.contains("CREATE TRIGGER trg_container_booking_before_update")
.contains("current_plan_status IN ('LOADING','COMPLETED')")
.contains("装柜作业开始后仅允许修改集装箱号与放舱运输数据")
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')");
}
private static void assertSimpleCustomsDocumentContract(String sql) {
assertThat(sql)
.contains("'DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER'")
@DisplayName("货柜简单文件只保留目的港清关与DO/PU")
void shouldAllowExactlyThreeSimpleContainerDocumentTypes() throws Exception {
for(String sql:java.util.List.of(migration(),unifiedMigration()))assertThat(sql)
.contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'")
.contains("file_hash VARCHAR(64) NULL")
.contains("completed_by BIGINT UNSIGNED NULL")
.contains("completed_at DATETIME(3) NULL")
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', 'DELIVERY_ORDER', 'PICKUP_ORDER')")
.doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读");
.contains("completed_at DATETIME(3) NULL");
}
@Test
@@ -47,13 +47,10 @@ class ContainerOverviewServiceTest {
71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now()),
new ContainerOverviewMapper.PhotoRow(
72L, 81L, 91L, "LOADING_DOOR_OPEN", 102L, "void.jpg", "VOID", LocalDateTime.now())));
com.baiyue.firstleg.entity.ContainerDocumentEntity customs = new com.baiyue.firstleg.entity.ContainerDocumentEntity();
customs.setId(103L); customs.setDocumentType("CUSTOMS_DECLARATION"); customs.setFileId(104L);
customs.setFileName("customs.pdf"); customs.setVersion(3);
com.baiyue.firstleg.entity.ContainerDocumentEntity clearance = new com.baiyue.firstleg.entity.ContainerDocumentEntity();
clearance.setId(105L); clearance.setDocumentType("DESTINATION_CLEARANCE"); clearance.setFileId(106L);
clearance.setFileName("clearance.pdf"); clearance.setVersion(1);
when(mapper.documents(41L)).thenReturn(java.util.List.of(customs, clearance));
when(mapper.documents(41L)).thenReturn(java.util.List.of(clearance));
when(mapper.exceptions(41L)).thenReturn(java.util.List.of());
when(mapper.transport(41L)).thenReturn(java.util.List.of());
when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of());
@@ -73,9 +70,7 @@ class ContainerOverviewServiceTest {
assertThat(count.count()).isEqualTo(1);
});
assertThat(overview.documents()).extracting(document -> document.documentType())
.containsExactly("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE");
assertThat(overview.documents()).filteredOn(document -> "CUSTOMS_DECLARATION".equals(document.documentType()))
.singleElement().satisfies(document -> assertThat(document.version()).isEqualTo(3));
.containsExactly("DESTINATION_CLEARANCE");
assertThat(overview.documents()).filteredOn(document -> "DESTINATION_CLEARANCE".equals(document.documentType()))
.singleElement().satisfies(document -> {
assertThat(document.fileId()).isEqualTo(106L);
@@ -307,20 +307,6 @@ class LoadingListServiceTest {
eq("上传清关资料"));
}
@Test
void rejectsDeclarationBeforeLoadingListIsCompleted() {
AggregateRow plan = row("LOADING", 2);
plan.setContainerBookingId(30L);
when(repository.aggregate(10L)).thenReturn(plan);
assertThatThrownBy(() -> service.attachLoadingDeclaration(10L, 100L, 9L, "operator", "key",
new DocumentAttachRequest(99L, 0, null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(documentFiles, never()).metadata(anyString(), eq(0L), anyString(), anyString(), eq(99L));
}
@Test
void rejectsFileMetadataFromAnotherBusinessResource() {
AggregateRow plan = row("LOADING", 2);
@@ -341,26 +327,6 @@ class LoadingListServiceTest {
verify(repository, never()).createDocument(org.mockito.ArgumentMatchers.any());
}
@Test
void completesDeclarationOnlyWhenDocumentVersionMatches() {
AggregateRow plan = row("COMPLETED", 2);
plan.setContainerBookingId(30L);
ContainerDocumentEntity before = document(40L, LoadingListService.CUSTOMS_DECLARATION, 3, false);
ContainerDocumentEntity after = document(40L, LoadingListService.CUSTOMS_DECLARATION, 4, true);
when(repository.aggregate(10L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(before, after);
when(repository.completeDocument(40L, 3, 9L, "operator")).thenReturn(1);
var result = service.completeLoadingDeclaration(10L, 100L, 9L, "operator", "complete-key",
new DocumentCompleteRequest(3, "点击报关"));
assertThat(result.completedAt()).isNotNull();
verify(repository).appendHistory(eq(10L), anyString(), eq("COMPLETE_CUSTOMS_DECLARATION"),
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"),
eq("点击报关"));
}
@Test
void rejectsCompleteWithoutUploadedFile() {
AggregateRow plan = row("LOADING", 2);
@@ -395,23 +361,6 @@ class LoadingListServiceTest {
org.mockito.ArgumentMatchers.anyInt(), eq(9L), anyString());
}
@Test
void rejectsReplacingCompletedDocument() {
AggregateRow plan = row("COMPLETED", 2);
plan.setContainerBookingId(30L);
when(repository.aggregate(10L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION))
.thenReturn(document(40L, LoadingListService.CUSTOMS_DECLARATION, 4, true));
assertThatThrownBy(() -> service.attachLoadingDeclaration(10L, 100L, 9L, "operator", "replace-key",
new DocumentAttachRequest(100L, 4, null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(documentFiles, never()).metadata(anyString(), eq(0L), anyString(), anyString(), eq(100L));
}
@Test
void uploadsContainerClearanceThroughInternalFileClientAndAssociatesMetadata() {
AggregateRow plan = row("LOADING", 2);
@@ -464,94 +413,6 @@ class LoadingListServiceTest {
verify(documentFiles, never()).upload(anyString(), eq(9L), eq(0L), anyString(), anyString(), eq(file));
}
@Test
void uploadsLoadingDeclarationWithPlanIdentityAndWarehouseScope() {
AggregateRow plan = row("COMPLETED", 2);
plan.setContainerBookingId(30L);
byte[] content = "customs-declaration".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String contentHash = sha256Bytes(content);
var file = new MockMultipartFile("file", "customs.pdf", "application/pdf", content);
var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L,
"LOADING_CUSTOMS_DECLARATION", "10", "customs.pdf", contentHash, "application/pdf",
(long) content.length);
ContainerDocumentEntity saved = document(41L, LoadingListService.CUSTOMS_DECLARATION, 0, false);
saved.setFileId(101L); saved.setFileName("customs.pdf"); saved.setFileHash(contentHash);
when(repository.aggregate(10L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(null, saved);
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq("10"), eq(file))).thenReturn(ApiResponse.success(metadata));
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq("10"), eq(101L))).thenReturn(ApiResponse.success(metadata));
when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1);
var result = service.uploadLoadingDeclaration(
10L, 100L, 9L, "operator", "customs-upload", 0, null, file);
assertThat(result.documentType()).isEqualTo(LoadingListService.CUSTOMS_DECLARATION);
verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq("10"), eq(file));
}
@Test
void downloadsCompletedLoadingDeclarationWithExactLongFileOwnership() {
long planId = 9_007_199_254_740_993L;
long fileId = 9_007_199_254_740_995L;
AggregateRow plan = row("COMPLETED", 4);
plan.setPlanId(planId);
ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true);
document.setFileId(fileId);
document.setFileName("出口报关单.pdf");
byte[] content = new byte[]{1, 2, 3};
var metadata = new LoadingDocumentFileClient.Metadata(fileId, 0L,
"LOADING_CUSTOMS_DECLARATION", Long.toString(planId), "出口报关单.pdf", hash(),
"application/pdf", (long) content.length);
when(repository.aggregate(planId)).thenReturn(plan);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document);
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq(Long.toString(planId)), eq(fileId))).thenReturn(ApiResponse.success(metadata));
when(documentFiles.download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq(Long.toString(planId)), eq(fileId))).thenReturn(org.springframework.http.ResponseEntity.ok(content));
var result = service.downloadLoadingDeclaration(planId, 100L, fileId);
assertThat(result.fileName()).isEqualTo("出口报关单.pdf");
assertThat(result.content()).containsExactly(1, 2, 3);
verify(documentFiles).download(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq("9007199254740993"), eq(fileId));
}
@Test
void rejectsNonCurrentLoadingDeclarationFileIdBeforeFileService() {
AggregateRow plan = row("COMPLETED", 4);
when(repository.aggregate(10L)).thenReturn(plan);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION))
.thenReturn(document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true));
assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 100L))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
verify(documentFiles, never()).metadata(anyString(), anyLong(), anyString(), anyString(), anyLong());
}
@Test
void rejectsLoadingDeclarationFileWithMismatchedBusinessOwnership() {
AggregateRow plan = row("COMPLETED", 4);
ContainerDocumentEntity document = document(41L, LoadingListService.CUSTOMS_DECLARATION, 4, true);
when(repository.aggregate(10L)).thenReturn(plan);
when(repository.document(30L, LoadingListService.CUSTOMS_DECLARATION)).thenReturn(document);
var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L,
"LOADING_CUSTOMS_DECLARATION", "other-plan", "customs.pdf", hash(),
"application/pdf", 3L);
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DECLARATION"),
eq("10"), eq(99L))).thenReturn(ApiResponse.success(metadata));
assertThatThrownBy(() -> service.downloadLoadingDeclaration(10L, 100L, 99L))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.NOT_FOUND);
verify(documentFiles, never()).download(anyString(), anyLong(), anyString(), anyString(), anyLong());
}
@Test
void downloadsCurrentDestinationClearanceWithExactContainerOwnership() {
long bookingId = 9_007_199_254_740_993L;
@@ -614,7 +475,7 @@ class LoadingListServiceTest {
LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class)
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
LoadingListService.CUSTOMS_DECLARATION)).isInstanceOf(BizException.class)
"UNKNOWN_DOCUMENT")).isInstanceOf(BizException.class)
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
verify(repository, never()).aggregateByBooking(30L);
}