优化
This commit is contained in:
+30
-12
@@ -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); }
|
||||
}
|
||||
|
||||
+2
-1
@@ -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")
|
||||
|
||||
+15
@@ -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))
|
||||
|
||||
+4
-4
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user