This commit is contained in:
2026-08-13 09:49:55 +08:00
parent f3366cb2fa
commit 95849f248f
69 changed files with 1224 additions and 221 deletions
@@ -21,6 +21,7 @@ class ContainerControllerContractTest {
assertPermission("create", "platform:firstleg:container:create");
assertPermission("updateDraft", "platform:firstleg:container:edit");
assertPermission("updateTransport", "platform:firstleg:container:edit");
assertPermission("uploadClearance", "platform:firstleg:customs:manage");
}
@Test
@@ -33,6 +34,9 @@ class ContainerControllerContractTest {
assertThat(section)
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
.contains("multipart/form-data")
.contains("ContainerDocumentUploadRequest")
.contains("platform:firstleg:container:list")
.contains("EDITING")
.contains("WAIT_LOADING")
@@ -13,6 +13,7 @@ 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.multipart.MultipartFile;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
@@ -37,6 +38,8 @@ class LoadingListControllerContractTest {
authority("declaration", "platform:firstleg:loading-list:view", AuthenticatedUser.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);
}
@@ -101,6 +104,7 @@ class LoadingListControllerContractTest {
"/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/upload",
"/api/platform/v1/first-leg/loading-lists/{planId}/customs/complete");
Map<String, Object> components = stringMap(document.get("components"));
Map<String, Object> schemas = stringMap(components.get("schemas"));
@@ -108,7 +112,9 @@ class LoadingListControllerContractTest {
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "BusinessDocumentEnvelope",
"DocumentAttachRequest", "DocumentCompleteRequest", "BusinessDocument");
"DocumentAttachRequest", "DocumentUploadRequest", "DocumentCompleteRequest", "BusinessDocument");
assertThat(properties(schemas, "DocumentUploadRequest"))
.containsOnlyKeys("file", "expectedVersion", "operationReason");
assertThat(paths)
.doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete");
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
@@ -12,7 +12,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class UnifiedInboundOpenApiContractTest {
@Test
void documentsInternalUnifiedOrderEndpointsAndLegacyWaitSplitMapping() throws Exception {
void documentsInternalUnifiedOrderEndpointsAndSevenStatuses() throws Exception {
String yaml = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml));
@@ -25,13 +25,13 @@ class UnifiedInboundOpenApiContractTest {
Map<String, Object> schemas = stringMap(stringMap(document.get("components")).get("schemas"));
Map<String, Object> responseStatus = stringMap(schemas.get("UnifiedMainStatus"));
assertThat(stringList(responseStatus.get("enum"))).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED");
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
assertThat(responseStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED");
Map<String, Object> query = stringMap(schemas.get("UnifiedInboundQuery"));
Map<String, Object> queryStatus = stringMap(stringMap(query.get("properties")).get("mainStatus"));
assertThat(stringList(queryStatus.get("enum"))).contains("WAIT_SPLIT");
assertThat(queryStatus.get("description").toString()).contains("INBOUNDED");
assertThat(queryStatus.get("description").toString()).contains("WAIT_SPLIT", "INBOUNDED", "独立");
Map<String, Object> inbound = stringMap(schemas.get("UnifiedInboundOrderSummary"));
assertThat(stringMap(inbound.get("properties"))).containsKeys(
@@ -1,7 +1,7 @@
package com.baiyue.firstleg.customs.service;
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
import com.baiyue.firstleg.customs.repository.CustomsRepository;
import com.baiyue.firstleg.entity.ContainerDocumentEntity;
import com.baiyue.firstleg.repository.LoadingListRepository;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
class CustomsDispatchGateContributorTest {
@Mock CustomsRepository repository;
@Mock LoadingListRepository repository;
@InjectMocks CustomsDispatchGateContributor contributor;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@@ -22,12 +22,16 @@ class CustomsDispatchGateContributorTest {
.isEqualTo("INVALID_CUSTOMER_SCOPE");
}
@Test void passesOnlyReleasedTerminalState() {
CustomsCaseEntity value = new CustomsCaseEntity(); value.setStatus("RECEIPT_AVAILABLE");
when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value);
@Test void passesOnlyCompletedSimpleDeclaration() {
ContainerDocumentEntity value = new ContainerDocumentEntity();
when(repository.document(81L, "CUSTOMS_DECLARATION")).thenReturn(value);
assertThat(contributor.assess(context()).passed()).isFalse();
value.setStatus("CLEARANCE_COMPLETED");
assertThat(contributor.assess(context()).passed()).isTrue();
value.setCompletedAt(java.time.LocalDateTime.now());
assertThat(contributor.assess(context()))
.satisfies(item -> {
assertThat(item.passed()).isTrue();
assertThat(item.requirement()).isEqualTo("COMPLETED");
});
}
private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); }
@@ -7,7 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat;
class LoadingCompletionGateSqlContractTest {
@Test
void requiresEveryLinkedContainerToPassBookingTruckAndCustomsReceiptGates() throws Exception {
void requiresEveryLinkedContainerToPassBookingAndTruckGates() throws Exception {
Select select = LoadingCompletionGateMapper.class
.getDeclaredMethod("snapshot", long.class, long.class).getAnnotation(Select.class);
String sql = String.join(" ", select.value()).replaceAll("\\s+", " ");
@@ -19,13 +19,9 @@ class LoadingCompletionGateSqlContractTest {
.contains("b.status='RELEASED'")
.contains("fl_truck_appointment")
.contains("t.status='COMPLETED'")
.contains("fl_customs_case")
.contains("fl_customs_receipt")
.contains("c.status='CLEARANCE_COMPLETED'")
.contains("r.receipt_type='RELEASED'")
.contains("r.file_id IS NOT NULL")
.contains("newer.receipt_version>r.receipt_version")
.contains("l.tenant_id=#{tenantId} AND l.inbound_id=#{inboundId}")
.doesNotContain("truckReady, FROM")
.doesNotContain("fl_customs_case", "fl_customs_receipt", "customsReady")
.doesNotContain("&lt;", "&gt;");
}
}
@@ -79,7 +79,7 @@ class LoadingDeliveryServiceTest {
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireBookingTransportAndCustoms(10, 20);
verify(completionGate).requireBookingAndTransport(10, 20);
verify(histories).insert(any(InboundStatusHistoryEntity.class));
}
@@ -113,7 +113,7 @@ class LoadingDeliveryServiceTest {
assertThat(result.mainStatus()).isEqualTo("LOADED");
verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireBookingTransportAndCustoms(10, 20);
verify(completionGate).requireBookingAndTransport(10, 20);
verify(repository).completeLoadedChild(10, 20, 7, 3);
verify(repository).aggregateRootLoaded(10, 20, 7);
verify(histories).insert(argThat((InboundStatusHistoryEntity history) ->
@@ -172,7 +172,7 @@ class LoadingDeliveryServiceTest {
verify(repository).completeLoadedChild(10, 20, 7, 5);
verify(repository).aggregateRootLoaded(10, 10, 7);
verify(loadingModelBridge).requireCompleted(10, 20);
verify(completionGate).requireBookingTransportAndCustoms(10, 20);
verify(completionGate).requireBookingAndTransport(10, 20);
verify(histories).insert(any(InboundStatusHistoryEntity.class));
verify(repository).completeOperation(10L, 20L, 7L, "COMPLETE_LOADING", "slice-replay", resultJson.get());
}
@@ -44,6 +44,8 @@ class ContainerBookingMapperContractTest {
.contains("capacity_cbm")
.contains("p.plan_status!='EDITING'")
.contains("p.plan_status='COMPLETED'")
.contains("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task")
.contains("version=#{expectedVersion}")
.doesNotContain("&lt;")
.doesNotContain("&gt;");
@@ -0,0 +1,31 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.mapper.InboundOrderMapper;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class InboundOrderStatusSqlContractTest {
@Test
void advancesThroughPricingBeforeOpeningWaitSplit() {
assertThat(sql("approveMeasurement"))
.contains("process_status = 'WAIT_PRICING'")
.doesNotContain("inbounded_at =");
assertThat(sql("completeInbound"))
.contains("process_status = 'WAIT_SPLIT'")
.contains("inbounded_at = CURRENT_TIMESTAMP(3)")
.contains("process_status = 'INBOUNDING'");
}
private static String sql(String methodName) {
Method method = Arrays.stream(InboundOrderMapper.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
.findFirst()
.orElseThrow();
return String.join(" ", method.getAnnotation(Update.class).value());
}
}
@@ -18,11 +18,13 @@ class InboundStatusUnificationSqlContractTest {
assertThat(module)
.contains("CREATE PROCEDURE assert_inbounded_compatibility()")
.contains("二、三期对外统一使用 INBOUNDED")
.contains("新完成入库记录使用 WAIT_SPLIT")
.contains("存量 INBOUNDED")
.doesNotContain("UPDATE fl_inbound_order")
.doesNotContain("UPDATE fl_inbound_status_history");
assertThat(unified)
.contains("二、三期对外统一使用 INBOUNDED")
.contains("新完成入库记录使用 WAIT_SPLIT")
.contains("存量 INBOUNDED")
.doesNotContain("SET process_status = 'INBOUNDED'")
.doesNotContain("SET from_status = 'INBOUNDED'")
.doesNotContain("SET to_status = 'INBOUNDED'");
@@ -60,6 +60,8 @@ class LoadingListQueryMapperContractTest {
.contains("version = version + 1");
assertThat(transport)
.contains("SET b.container_no = #{containerNo}, b.voyage_no = #{voyageNo}")
.contains("#{containerNo} IS NOT NULL")
.contains("fl_loading_pick_task")
.doesNotContain("DELETE")
.doesNotContain("fl_loading_plan_item");
assertThat(advance)
@@ -84,7 +84,7 @@ class Phase3FinalUnifiedSqlContractTest {
"-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql",
"firstleg/src/main/resources/db/phase3-carrier-schedule-query-schema.sql");
assertBlock(unified, "-- 来源:iam/src/main/resources/db/phase3-warehouse-roles.sql",
"-- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。",
"-- 新完成入库记录使用 WAIT_SPLIT;存量 INBOUNDED 继续作为可分柜兼容状态,统一迁移不改写主表或历史状态。",
"iam/src/main/resources/db/phase3-warehouse-roles.sql");
}
@@ -21,7 +21,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("m2.status='APPROVED'")
.contains("i.inbound_detail_id IS NOT NULL")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='INBOUNDED'")
.contains("o.process_status IN ('WAIT_SPLIT','INBOUNDED')")
.contains("SELECT SUM(li.package_count)")
.contains("li.deleted=0")
.contains("ORDER BY o.inbounded_at ASC, o.id ASC, i.id ASC");
@@ -38,7 +38,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='INBOUNDED'")
.contains("o.process_status IN ('WAIT_SPLIT','INBOUNDED')")
.contains("SELECT SUM(li.package_count)")
.contains("FOR UPDATE");
}
@@ -73,6 +73,30 @@ class SmartLoadingSqlContractTest {
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
}
@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("IF current_plan_status='COMPLETED' THEN")
.contains("NEW.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')")
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')");
}
private static void assertSimpleCustomsDocumentContract(String sql) {
assertThat(sql)
.contains("'DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION', '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')")
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')")
.doesNotContain("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION') THEN\n SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '装柜完成后货柜全部只读");
}
@Test
@DisplayName("平台菜单按钮权限使用统一前缀并授权运营角色")
void shouldProvidePlatformPermissions() throws Exception {
@@ -8,8 +8,10 @@ import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.TransportUpdateRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.mapper.ContainerTransportHistoryMapper;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.repository.LoadingPickRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
@@ -37,6 +39,7 @@ class ContainerServiceTest {
@MockBean private ContainerRepository repository;
@MockBean private WarehouseMasterDataClient warehouses;
@MockBean private SmartLoadingRepository smartLoadingRepository;
@MockBean private LoadingPickRepository loadingPickRepository;
@MockBean private LoadingPickService loadingPickService;
@MockBean private ContainerTransportHistoryMapper transportHistories;
@Resource private ContainerService service;
@@ -145,6 +148,25 @@ class ContainerServiceTest {
org.mockito.ArgumentMatchers.eq("admin"), any());
}
@Test
@DisplayName("PDA任务创建后禁止清空真实集装箱号")
void rejectsClearingContainerNumberAfterPdaTaskStarts() {
ContainerBookingEntity current = booking("LOADING", 2);
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(31L);
when(repository.find(11L)).thenReturn(current);
when(smartLoadingRepository.planByBooking(11L)).thenReturn(plan);
when(loadingPickRepository.taskByPlan(31L)).thenReturn(new LoadingPickTaskEntity());
assertThatThrownBy(() -> service.updateTransport(11L, 9L, "admin",
new TransportUpdateRequest(null, "V002", 2)))
.isInstanceOf(BizException.class)
.hasMessageContaining("不能清空真实集装箱号");
verify(repository, never()).updateTransport(any(Long.class), any(), any(),
any(Integer.class), any(Long.class), any());
}
@Test
@DisplayName("已确认货柜全部只读")
void rejectsConfirmedUpdate() {
@@ -114,7 +114,7 @@ class FbaPricingServiceTest {
void calculatesAtWaitSplitWithoutRollingBackProcessStatus() {
readyRepository();
InboundPricingEntity inbound = inbound("100.000", "1.0000");
inbound.setProcessStatus("INBOUNDED");
inbound.setProcessStatus("WAIT_SPLIT");
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(priceBookEstimator.estimate(any())).thenReturn(
estimate("KG", "167.000", "2.000000", "334.00", "0.00"));
@@ -122,8 +122,8 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.calculate(TENANT, OPERATOR, INBOUND, NOW, "wait-split");
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).markInboundCalculated(TENANT, INBOUND, "INBOUNDED", "KG",
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundCalculated(TENANT, INBOUND, "WAIT_SPLIT", "KG",
new BigDecimal("167.000"));
}
@@ -131,7 +131,7 @@ class FbaPricingServiceTest {
@DisplayName("待分柜边界确认费用后仍保持主流程状态")
void confirmsAtWaitSplitWithoutRollingBackProcessStatus() {
InboundPricingEntity inbound = inbound("10.000", "1.0000");
inbound.setProcessStatus("INBOUNDED");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity fee = savedFee("CALCULATED");
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(fee);
@@ -148,7 +148,7 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.confirm(
TENANT, OPERATOR, INBOUND, "confirm-wait-split");
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).markInboundConfirmed(TENANT, INBOUND);
}
@@ -24,6 +24,7 @@ import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.mock.web.MockMultipartFile;
import java.util.List;
@@ -102,6 +103,23 @@ class LoadingListServiceTest {
verify(repository, never()).updateTransport(eq(10L), eq(5), anyString(), anyString(), eq(9L), anyString());
}
@Test
void shouldRejectClearingContainerNumberAfterPdaTaskStarts() {
AggregateRow before = row("LOADING", 3);
when(repository.history(anyString())).thenReturn(null);
when(repository.aggregate(10L)).thenReturn(before);
when(pickRepository.taskByPlan(10L)).thenReturn(new com.baiyue.firstleg.entity.LoadingPickTaskEntity());
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "key-1",
new TransportUpdateRequest(null, "023E", 3, "清空测试")))
.isInstanceOf(BizException.class)
.hasMessageContaining("不能清空真实集装箱号");
verify(repository, never()).updateTransport(eq(10L), eq(3),
org.mockito.ArgumentMatchers.nullable(String.class),
org.mockito.ArgumentMatchers.nullable(String.class), eq(9L),
org.mockito.ArgumentMatchers.nullable(String.class));
}
@Test
void shouldCompleteLoadingAfterAllPdaItemsPicked() {
AggregateRow before = row("LOADING", 2);
@@ -206,14 +224,16 @@ class LoadingListServiceTest {
.thenReturn(null, document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false));
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata(
99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "30", "clearance.pdf", "abc", "application/pdf", 20L)));
99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "30", "clearance.pdf", hash(), "application/pdf", 20L)));
when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1);
var result = service.attachContainerClearance(30L, 9L, "operator", "key-clearance",
new DocumentAttachRequest(99L, 0, "上传清关资料"));
assertThat(result.fileName()).isEqualTo("clearance.pdf");
verify(repository).appendHistory(eq(10L), anyString(), eq("ATTACH_DESTINATION_CLEARANCE"),
verify(repository).appendHistory(eq(10L),
eq("PL-" + sha256("ATTACH_DESTINATION_CLEARANCE|30|key-clearance")),
eq("ATTACH_DESTINATION_CLEARANCE"),
eq("LOADING"), eq("LOADING"), anyString(), anyString(), eq(9L), eq("operator"),
eq("上传清关资料"));
}
@@ -241,7 +261,7 @@ class LoadingListServiceTest {
when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null);
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
eq("30"), eq(99L))).thenReturn(ApiResponse.success(new LoadingDocumentFileClient.Metadata(
99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "different-booking", "clearance.pdf", "abc",
99L, 0L, "CONTAINER_CLEARANCE_DOCUMENT", "different-booking", "clearance.pdf", hash(),
"application/pdf", 20L)));
assertThatThrownBy(() -> service.attachContainerClearance(30L, 9L, "operator", "key",
@@ -306,6 +326,121 @@ 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);
plan.setContainerBookingId(30L);
byte[] content = "clearance-document".getBytes(java.nio.charset.StandardCharsets.UTF_8);
String contentHash = sha256Bytes(content);
var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", content);
var metadata = new LoadingDocumentFileClient.Metadata(99L, 0L,
"CONTAINER_CLEARANCE_DOCUMENT", "30", "clearance.pdf", contentHash, "application/pdf",
(long) content.length);
when(repository.aggregateByBooking(30L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
ContainerDocumentEntity saved = document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false);
saved.setFileHash(contentHash);
when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE)).thenReturn(null, saved);
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
eq("30"), eq(99L))).thenReturn(ApiResponse.success(metadata));
when(repository.createDocument(org.mockito.ArgumentMatchers.any())).thenReturn(1);
var result = service.uploadContainerClearance(
30L, 9L, "operator", "upload-key", 0, "直接上传", file);
assertThat(result.fileHash()).isEqualTo(contentHash);
verify(documentFiles).upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_CLEARANCE_DOCUMENT"),
eq("30"), eq(file));
verify(repository).appendHistory(eq(10L),
eq("PL-" + sha256("UPLOAD_DESTINATION_CLEARANCE|30|upload-key")),
eq("UPLOAD_DESTINATION_CLEARANCE"), eq("LOADING"), eq("LOADING"), anyString(),
anyString(), eq(9L), eq("operator"), eq("直接上传"));
}
@Test
void multipartReplayDoesNotUploadFileAgain() {
AggregateRow plan = row("LOADING", 2);
plan.setContainerBookingId(30L);
byte[] content = "clearance-document".getBytes(java.nio.charset.StandardCharsets.UTF_8);
var file = new MockMultipartFile("file", "clearance.pdf", "application/pdf", content);
String requestHash = sha256("UPLOAD|DESTINATION_CLEARANCE|30|0|null|" + sha256Bytes(content));
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot("{\"requestHash\":\"" + requestHash + "\"}");
when(repository.aggregateByBooking(30L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(replay);
when(repository.document(30L, LoadingListService.DESTINATION_CLEARANCE))
.thenReturn(document(40L, LoadingListService.DESTINATION_CLEARANCE, 0, false));
service.uploadContainerClearance(30L, 9L, "operator", "upload-key", 0, null, file);
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_DOCUMENT", "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_DOCUMENT"),
eq("10"), eq(file))).thenReturn(ApiResponse.success(metadata));
when(documentFiles.metadata(anyString(), eq(0L), eq("LOADING_CUSTOMS_DOCUMENT"),
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_DOCUMENT"),
eq("10"), eq(file));
}
@Test
void rejectsReusedIdempotencyKeyWithDifferentAttachRequest() {
AggregateRow plan = row("LOADING", 2);
plan.setContainerBookingId(30L);
HistoryRow replay = new HistoryRow();
replay.setAfterSnapshot("{\"requestHash\":\"different\"}");
when(repository.aggregateByBooking(30L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(replay);
assertThatThrownBy(() -> service.attachContainerClearance(30L, 9L, "operator", "reused-key",
new DocumentAttachRequest(100L, 0, null)))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).createDocument(org.mockito.ArgumentMatchers.any());
}
private static AggregateRow row(String status, int version) {
AggregateRow row = new AggregateRow();
row.setPlanId(10L);
@@ -319,10 +454,31 @@ class LoadingListServiceTest {
private static ContainerDocumentEntity document(long id, String type, int version, boolean completed) {
ContainerDocumentEntity value = new ContainerDocumentEntity();
value.setId(id); value.setContainerBookingId(30L); value.setDocumentType(type);
value.setFileId(99L); value.setFileName("clearance.pdf"); value.setFileHash("abc");
value.setFileId(99L); value.setFileName("clearance.pdf"); value.setFileHash(hash());
value.setUploadedBy(9L); value.setUploadedByName("operator"); value.setVersion(version);
value.setCreatedAt(java.time.LocalDateTime.now());
if (completed) value.setCompletedAt(java.time.LocalDateTime.now());
return value;
}
private static String hash() { return "a".repeat(64); }
private static String sha256Bytes(byte[] value) {
try {
return java.util.HexFormat.of().formatHex(
java.security.MessageDigest.getInstance("SHA-256").digest(value));
} catch (java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
private static String sha256(String value) {
try {
byte[] digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(digest);
} catch (java.security.NoSuchAlgorithmException exception) {
throw new IllegalStateException(exception);
}
}
}
@@ -78,7 +78,7 @@ class MeasurementReviewServiceTest {
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING");
assertThat(history.getValue().getToStatus()).isEqualTo("INBOUNDED");
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_PRICING");
}
@Test
@@ -28,10 +28,10 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.mainStatus("MEASURING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_PENDING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_REJECTED")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_MEASURE");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("WAIT_SPLIT");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING");
assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED");
@@ -50,21 +50,21 @@ class UnifiedInboundQueryServiceTest {
.isEqualTo("CONFIRM_PRELOAD");
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "CONFIRMED")).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "WAIT_SPLIT", null))
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null))
.isEqualTo("SMART_SPLIT");
assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull();
}
@Test
void inboundedFilterIncludesHistoricalWaitSplitRows() throws Exception {
void inboundedAndWaitSplitFiltersRemainDistinct() throws Exception {
var method = UnifiedInboundQueryService.class.getDeclaredMethod("internalStatuses", String.class);
method.setAccessible(true);
assertThat(method.invoke(new UnifiedInboundQueryService(), "INBOUNDED"))
.isEqualTo(java.util.List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "WAIT_SPLIT"));
.isEqualTo(java.util.List.of("INBOUNDED"));
assertThat(method.invoke(new UnifiedInboundQueryService(), "WAIT_SPLIT"))
.isEqualTo(java.util.List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "WAIT_SPLIT"));
.isEqualTo(java.util.List.of("WAIT_SPLIT"));
}
@Test
@@ -47,19 +47,19 @@ class SplitPreloadServiceTest {
@Test
void smartSplitCreatesEditablePendingPlan() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "INBOUNDED", 3));
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3));
when(repository.findPlan(10, 20)).thenReturn(null);
SmartSplitRequest request = new SmartSplitRequest(0,
List.of(new PreloadItem("C1", "A", 2, BigDecimal.ONE, null)), "r");
SplitActionResponse result = service.smartSplit(10, 20, 7, "admin", "k2", request);
assertThat(result.mainStatus()).isEqualTo("INBOUNDED");
assertThat(result.mainStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).createPlan(any(PreloadPlanEntity.class));
}
@Test
void confirmsPlanAndMovesInboundToLoading() {
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString())).thenReturn(true);
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "INBOUNDED", 4));
when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 4));
PreloadPlanEntity plan = new PreloadPlanEntity();
plan.setTenantId(10L); plan.setInboundId(20L); plan.setStatus("CONFIRMING"); plan.setVersion(1);
plan.setItemsJson("[]");