This commit is contained in:
2026-08-26 13:58:33 +08:00
parent fc0a6ea7ca
commit 4398e8b347
78 changed files with 920 additions and 1549 deletions
@@ -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.customs.mapper.CustomsCaseMapper;
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -22,19 +22,37 @@ class CustomsDispatchGateContributorTest {
.isEqualTo("INVALID_CUSTOMER_SCOPE");
}
@Test void passesOnlyCanonicalClearanceCompletedCase() {
CustomsCaseEntity value = new CustomsCaseEntity();
assertThat(contributor.assess(context()).passed()).isFalse();
value.setStatus("CLEARANCE_COMPLETED");
when(repository.clearedContainerCase(81L)).thenReturn(value);
assertThat(contributor.assess(context()))
.satisfies(item -> {
assertThat(item.passed()).isTrue();
assertThat(item.requirement()).isEqualTo("CLEARANCE_COMPLETED");
});
verify(repository, times(2)).clearedContainerCase(81L);
@Test void uploadedButIncompleteDeclarationRemainsBlocked() {
when(repository.loadingDeclarationCompleted(81L)).thenReturn(false);
assertThat(contributor.assess(context())).satisfies(item -> {
assertThat(item.passed()).isFalse();
assertThat(item.actual()).isEqualTo("MISSING");
assertThat(item.requirement()).isEqualTo("COMPLETED");
});
}
@Test void completedLoadingDeclarationPassesWithoutReadingCustomsCase() {
when(repository.loadingDeclarationCompleted(81L)).thenReturn(true);
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());
}
@Test void mapperUsesOnlyCompletedLoadingDeclarationDocumentFact() throws Exception {
String sql = String.join(" ", CustomsCaseMapper.class
.getMethod("completedLoadingDeclaration", 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");
}
private static Context context() { return new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null); }
}
@@ -51,7 +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 clearance is also resolved once at container scope")
.contains("CUSTOMS_DECLARATION whose completedAt must be non-null")
.contains("fl_customs_case is not an alternative gate fact")
.contains("missing standalone sailing schedule is reference-only")
.contains("expectedVersion")
.contains("Idempotency-Key")
@@ -116,6 +116,21 @@ class LoadingListQueryMapperContractTest {
.contains("operator_id");
}
@Test
void deliveryAndPickupUseTheCanonicalDocumentInsertAndPlanHistoryMapper() throws Exception {
String documentInsert = sql("insertDocument", Insert.class);
String historyInsert = sql("insertHistory", Insert.class);
assertThat(documentInsert)
.contains("INSERT INTO fl_container_document")
.contains("#{documentType}", "#{fileId}", "#{fileHash}")
.contains("CURRENT_TIMESTAMP(3)", "version")
.doesNotContain("CUSTOMS_DECLARATION", "DESTINATION_CLEARANCE");
assertThat(historyInsert)
.contains("INSERT INTO fl_loading_plan_history")
.contains("#{planId}", "#{operationId}", "#{actionType}")
.contains("#{beforeSnapshot}", "#{afterSnapshot}");
}
private static String sql(String methodName, Class<?> annotationType) throws Exception {
Method method = java.util.Arrays.stream(LoadingListQueryMapper.class.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(methodName))
@@ -151,8 +151,8 @@ class SmartLoadingSqlContractTest {
.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')")
.contains("OLD.document_type NOT IN ('DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION')");
.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) {
@@ -161,8 +161,8 @@ class SmartLoadingSqlContractTest {
.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')")
.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 = '装柜完成后货柜全部只读");
}
@@ -36,7 +36,9 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@@ -597,6 +599,129 @@ class LoadingListServiceTest {
verify(repository, never()).aggregateByBooking(30L);
}
@Test
void firstDeliveryOrderUploadPersistsDocumentAndImmutablePlanHistory() {
AggregateRow plan = row("COMPLETED", 4);
byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content);
String contentHash = sha256Bytes(content);
var metadata = new LoadingDocumentFileClient.Metadata(101L, 0L,
"CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length);
ContainerDocumentEntity saved = document(51L, LoadingListService.DELIVERY_ORDER, 0, false);
saved.setFileId(101L); saved.setFileName("do.pdf"); saved.setFileHash(contentHash);
when(repository.aggregateByBooking(30L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null, saved);
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
eq("30"), eq(101L))).thenReturn(ApiResponse.success(metadata));
when(repository.createDocument(any())).thenReturn(1);
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER,
9L, "operator", "do-first", 0, null, file);
assertThat(result).extracting(value -> value.documentType(), value -> value.fileId(), value -> value.version())
.containsExactly(LoadingListService.DELIVERY_ORDER, 101L, 0);
verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_DELIVERY_ORDER"),
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
verify(documentFiles, never()).delete(anyString(), anyLong(), anyLong(), anyString(), anyString(), anyLong());
}
@Test
void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() {
AggregateRow plan = row("COMPLETED", 4);
byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content);
String contentHash = sha256Bytes(content);
ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false);
ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false);
saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash);
var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L,
"CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length);
when(repository.aggregateByBooking(30L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved);
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata));
when(repository.replaceDocument(any())).thenReturn(1);
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER,
9L, "operator", "pu-update", 2, null, file);
assertThat(result.fileId()).isEqualTo(102L);
assertThat(result.version()).isEqualTo(3);
verify(repository).replaceDocument(any());
verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"),
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
}
@Test
void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() {
AggregateRow plan = row("COMPLETED", 4);
when(repository.aggregateByBooking(30L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
when(repository.document(30L, LoadingListService.DELIVERY_ORDER))
.thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 2, false));
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", "do".getBytes());
assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-stale", 1, null, file))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any());
}
@Test
void sameDeliveryOrderUploadKeyReplaysWithoutUploadingAgain() {
AggregateRow plan = row("COMPLETED", 4);
byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content);
String requestHash = sha256("UPLOAD|DELIVERY_ORDER|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.DELIVERY_ORDER))
.thenReturn(document(51L, LoadingListService.DELIVERY_ORDER, 0, false));
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.DELIVERY_ORDER,
9L, "operator", "do-replay", 0, null, file);
assertThat(result.documentType()).isEqualTo(LoadingListService.DELIVERY_ORDER);
verify(documentFiles, never()).upload(anyString(), anyLong(), anyLong(), anyString(), anyString(), any());
}
@Test
void databaseFailureAfterFileUploadTriggersExactOwnershipCompensationAndBusinessConflict() {
AggregateRow plan = row("COMPLETED", 4);
byte[] content = "delivery-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
var file = new MockMultipartFile("file", "do.pdf", "application/pdf", content);
String contentHash = sha256Bytes(content);
var metadata = new LoadingDocumentFileClient.Metadata(103L, 0L,
"CONTAINER_DELIVERY_ORDER", "30", "do.pdf", contentHash, "application/pdf", (long) content.length);
when(repository.aggregateByBooking(30L)).thenReturn(plan);
when(repository.history(anyString())).thenReturn(null);
when(repository.document(30L, LoadingListService.DELIVERY_ORDER)).thenReturn(null);
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
eq("30"), eq(103L))).thenReturn(ApiResponse.success(metadata));
when(repository.createDocument(any())).thenThrow(
new org.springframework.dao.DataIntegrityViolationException("trigger rejected"));
when(documentFiles.delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
eq("30"), eq(103L))).thenReturn(ApiResponse.success(null));
assertThatThrownBy(() -> service.uploadContainerLogisticsDocument(30L,
LoadingListService.DELIVERY_ORDER, 9L, "operator", "do-db-fail", 0, null, file))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode()).isEqualTo(ResultCode.CONFLICT);
verify(documentFiles).delete(anyString(), eq(9L), eq(0L), eq("CONTAINER_DELIVERY_ORDER"),
eq("30"), eq(103L));
}
@Test
void confirmsEveryLinkedPreloadAndCreatesSinglePdaTaskAtomically() {
AggregateRow before = row("EDITING", 3);