feat(firstleg): close phase3 outbound customs and schedule gates
This commit is contained in:
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.firstleg.customs.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.baiyue.firstleg.customs.service;
|
||||
|
||||
import com.baiyue.firstleg.customs.entity.CustomsCaseEntity;
|
||||
import com.baiyue.firstleg.customs.repository.CustomsRepository;
|
||||
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.*;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class CustomsDispatchGateContributorTest {
|
||||
@Mock CustomsRepository repository;
|
||||
@InjectMocks CustomsDispatchGateContributor contributor;
|
||||
|
||||
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
|
||||
|
||||
@Test void failsClosedWhenCaseMissingOrScopeMismatches() {
|
||||
assertThat(contributor.assess(context()).passed()).isFalse();
|
||||
assertThat(contributor.assess(new Context(10001L, 10002L, 21L, 31L, 81L, 91L, null)).actual())
|
||||
.isEqualTo("INVALID_CUSTOMER_SCOPE");
|
||||
}
|
||||
|
||||
@Test void passesOnlyReleasedTerminalState() {
|
||||
CustomsCaseEntity value = new CustomsCaseEntity(); value.setStatus("RECEIPT_AVAILABLE");
|
||||
when(repository.gateCase(10001L, 21L, 31L, 81L)).thenReturn(value);
|
||||
assertThat(contributor.assess(context()).passed()).isFalse();
|
||||
value.setStatus("CLEARANCE_COMPLETED");
|
||||
assertThat(contributor.assess(context()).passed()).isTrue();
|
||||
}
|
||||
|
||||
private static Context context() { return new Context(10001L, 10001L, 21L, 31L, 81L, 91L, null); }
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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.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;
|
||||
@InjectMocks CustomsService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
|
||||
ReflectionTestUtils.setField(service, "internalToken", "token");
|
||||
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("线下报关代理", "EXT-1", "提交", 4));
|
||||
|
||||
assertThat(result.getFileSnapshotJson()).contains("documentVersion").contains("INVOICE");
|
||||
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;
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.baiyue.firstleg.loading.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoadingDeliveryOpenApiContractTest {
|
||||
@Test
|
||||
void publishesRealFileFiveEvidenceAndPickingGate() throws Exception {
|
||||
String yaml = new ClassPathResource("openapi/phase3-loading-delivery-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/loading/evidence:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/start:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/loading/picking/complete:")
|
||||
.contains("/api/platform/v1/inbounds/{inboundId}/loading/complete:")
|
||||
.contains("required: [expectedVersion, evidenceType, fileId]")
|
||||
.contains("enum: [PHOTO_STEP_1, PHOTO_STEP_2, PHOTO_STEP_3, PHOTO_STEP_4, SEAL]")
|
||||
.contains("Required when evidenceType is SEAL")
|
||||
.contains("Complete a started PDA picking task");
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.firstleg.loading.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class LoadingEvidenceSqlContractTest {
|
||||
@Test
|
||||
void newSchemaRequiresRealFilesAndUniqueSealWithinCustomer() throws Exception {
|
||||
String sql = new ClassPathResource("db/phase3-loading-delivery-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
|
||||
assertThat(sql)
|
||||
.contains("file_id BIGINT UNSIGNED NOT NULL")
|
||||
.contains("UNIQUE KEY uk_loading_evidence_type (tenant_id, inbound_id, evidence_type)")
|
||||
.contains("UNIQUE KEY uk_loading_evidence_seal (tenant_id, seal_no)")
|
||||
.contains("fl_inbound_loading_pick_task");
|
||||
}
|
||||
}
|
||||
+66
-3
@@ -1,11 +1,14 @@
|
||||
package com.baiyue.firstleg.loading.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
|
||||
import com.baiyue.firstleg.entity.InboundStatusHistoryEntity;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingResponses.LoadingActionResponse;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
|
||||
import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
|
||||
import com.baiyue.firstleg.loading.repository.LoadingRepository;
|
||||
import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper;
|
||||
@@ -20,6 +23,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@@ -41,26 +45,85 @@ class LoadingDeliveryServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesLoadingWithoutEvidenceGate() {
|
||||
void completesLoadingOnlyAfterPickingAndFiveEvidenceFiles() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity();
|
||||
pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5);
|
||||
when(repository.findPickTask(10, 20)).thenReturn(pick);
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of(
|
||||
evidence("PHOTO_STEP_1", 1L, null), evidence("PHOTO_STEP_2", 2L, null),
|
||||
evidence("PHOTO_STEP_3", 3L, null), evidence("PHOTO_STEP_4", 4L, null),
|
||||
evidence("SEAL", 5L, "LOCK-001")));
|
||||
when(files.validate(eq("token"), eq(10L), eq(20L), anyLong())).thenAnswer(invocation -> {
|
||||
long fileId = invocation.getArgument(3);
|
||||
String type = fileId == 5L ? "SEAL" : "PHOTO_STEP_" + fileId;
|
||||
return ApiResponse.success(new LoadingEvidenceFileClient.FileMetadata(
|
||||
fileId, type.toLowerCase() + ".png", "LOADING_" + type, "image/png"));
|
||||
});
|
||||
when(repository.completeLoading(10, 20, 7, 3)).thenReturn(1);
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of());
|
||||
LoadingActionResponse result = service.completeLoading(10, 20, 7, "admin", "k1", new CompleteLoadingRequest(3, null));
|
||||
assertThat(result.mainStatus()).isEqualTo("LOADED");
|
||||
verify(histories).insert(any(InboundStatusHistoryEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCompletionWhenEvidenceIsIncomplete() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
InboundLoadingPickTaskEntity pick = new InboundLoadingPickTaskEntity();
|
||||
pick.setStatus("COMPLETED"); pick.setExpectedCount(5); pick.setPickedCount(5);
|
||||
when(repository.findPickTask(10, 20)).thenReturn(pick);
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of(evidence("SEAL", 5L, "LOCK-001")));
|
||||
|
||||
assertThatThrownBy(() -> service.completeLoading(10, 20, 7, "admin", "k-missing",
|
||||
new CompleteLoadingRequest(3, null))).hasMessageContaining("必须齐全");
|
||||
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evidenceCanBeEditedAndDoesNotChangeMainStatus() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
when(repository.findEvidence(10, 20, "PHOTO_STEP_1")).thenReturn(null);
|
||||
when(repository.listEvidence(10, 20)).thenReturn(List.of());
|
||||
when(files.validate("token", 10, 20, 1)).thenReturn(ApiResponse.success(
|
||||
new LoadingEvidenceFileClient.FileMetadata(1L, "photo.jpg", "LOADING_PHOTO_STEP_1", "image/jpeg")));
|
||||
LoadingActionResponse result = service.upsertEvidence(10, 20, 7, "admin", "k2",
|
||||
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", null, "photo.jpg", null, "updated"));
|
||||
new UpsertEvidenceRequest(0, "PHOTO_STEP_1", 1L, "ignored.jpg", null, "updated"));
|
||||
assertThat(result.mainStatus()).isEqualTo("LOADING");
|
||||
verify(repository).createEvidence(any(LoadingEvidenceEntity.class));
|
||||
verify(repository, never()).completeLoading(anyLong(), anyLong(), anyLong(), anyInt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateSealUsedByAnotherInbound() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
LoadingEvidenceEntity duplicate = evidence("SEAL", 9L, "LOCK-DUP");
|
||||
duplicate.setInboundId(21L);
|
||||
when(repository.findEvidenceBySealNo(10, "LOCK-DUP")).thenReturn(duplicate);
|
||||
|
||||
assertThatThrownBy(() -> service.upsertEvidence(10, 20, 7, "admin", "k-duplicate-seal",
|
||||
new UpsertEvidenceRequest(0, "SEAL", 5L, null, "LOCK-DUP", null)))
|
||||
.hasMessageContaining("锁码已用于其他装柜任务");
|
||||
verify(repository, never()).createEvidence(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCompletingPickingBeforeTaskStarts() {
|
||||
when(repository.claimOperation(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10, 20)).thenReturn(new Snapshot(20L, 10L, "LOADING", 3));
|
||||
when(repository.findPickTask(10, 20)).thenReturn(null);
|
||||
|
||||
assertThatThrownBy(() -> service.completePicking(10, 20, 7, "admin", "k-picking",
|
||||
new PickingRequest(0, 1, 1, null))).hasMessageContaining("尚未开始");
|
||||
verify(repository, never()).createPickTask(any());
|
||||
}
|
||||
|
||||
private static LoadingEvidenceEntity evidence(String type, Long fileId, String sealNo) {
|
||||
LoadingEvidenceEntity value = new LoadingEvidenceEntity();
|
||||
value.setEvidenceType(type); value.setFileId(fileId); value.setSealNo(sealNo);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.baiyue.firstleg.outbound.dispatch;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.outbound.dispatch.dto.OutboundDispatchDtos.*;
|
||||
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchEntity;
|
||||
import com.baiyue.firstleg.outbound.dispatch.entity.OutboundDispatchOperationEntity;
|
||||
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor;
|
||||
import com.baiyue.firstleg.outbound.dispatch.mapper.OutboundDispatchReadMapper;
|
||||
import com.baiyue.firstleg.outbound.dispatch.repository.OutboundDispatchRepository;
|
||||
import com.baiyue.firstleg.outbound.dispatch.service.OutboundDispatchService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class OutboundDispatchServiceTest {
|
||||
private final OutboundDispatchRepository repository = mock(OutboundDispatchRepository.class);
|
||||
|
||||
@Test
|
||||
void gateFailsClosedWhenCustomsAndScheduleModulesAreUnavailable() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
|
||||
stubReadyChain();
|
||||
|
||||
GateResult result = service.checkGate(new Query(null, 11L, 22L));
|
||||
|
||||
assertThat(result.passed()).isFalse();
|
||||
assertThat(result.items()).filteredOn(item -> !item.passed())
|
||||
.extracting(GateItem::code).containsExactlyInAnyOrder("CUSTOMS_CLEARANCE", "SAILING_SCHEDULE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmAtomicallyAdvancesDispatchAndInboundAfterEveryGatePasses() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setOrderId(10L); state.setInboundId(11L);
|
||||
state.setContainerBookingId(22L); state.setStatus("READY_FOR_DISPATCH"); state.setVersion(0);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-1"), anyString())).thenReturn(true);
|
||||
when(repository.confirm(33L, 0, 9L, "admin", "ok")).thenReturn(1);
|
||||
when(repository.confirmInbound(7L, 11L, 9L)).thenReturn(1);
|
||||
when(repository.completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1")).thenReturn(1);
|
||||
when(repository.timeline(any(), any(), any())).thenReturn(List.of());
|
||||
|
||||
service.confirm(9L, "admin", "confirm-1", new ActionRequest(11L, 22L, 0, "ok"));
|
||||
|
||||
verify(repository).confirm(33L, 0, 9L, "admin", "ok");
|
||||
verify(repository).confirmInbound(7L, 11L, 9L);
|
||||
verify(repository).history(argThat(history -> "CONFIRM_DISPATCH".equals(history.getAction())
|
||||
&& "WAITING_FOR_LOADING".equals(history.getToStatus())));
|
||||
verify(repository).completeOperation(33L, 9L, "CONFIRM_DISPATCH", "confirm-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void confirmNeverWritesStateWhenAnyRealGateFails() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "LOADING"));
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("confirm-2"), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(9L, "admin", "confirm-2",
|
||||
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class).hasMessageContaining("门禁");
|
||||
|
||||
verify(repository, never()).confirm(anyLong(), anyInt(), anyLong(), anyString(), any());
|
||||
verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectorsThatMatchMultipleContainerBindingsRequireDisambiguation() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository, List.of());
|
||||
when(repository.bindings(any())).thenReturn(List.of(binding(), binding()));
|
||||
assertThatThrownBy(() -> service.detail(new Query(10L, null, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("多个出仓聚合");
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDispatchVersionIsRejectedWithoutPartialInboundAdvance() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
OutboundDispatchEntity state = new OutboundDispatchEntity();
|
||||
state.setId(33L); state.setTenantId(7L); state.setInboundId(11L); state.setContainerBookingId(22L);
|
||||
state.setStatus("READY_FOR_DISPATCH"); state.setVersion(2);
|
||||
when(repository.dispatch(7L, 11L, 22L)).thenReturn(state);
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("stale"), anyString())).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(9L, "admin", "stale",
|
||||
new ActionRequest(11L, 22L, 1, null))).isInstanceOf(BizException.class).hasMessageContaining("刷新");
|
||||
verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusedIdempotencyKeyWithDifferentHashIsRejected() {
|
||||
OutboundDispatchService service = new OutboundDispatchService(repository,
|
||||
List.of(passing("CUSTOMS_CLEARANCE"), passing("SAILING_SCHEDULE")));
|
||||
stubReadyChain();
|
||||
when(repository.claim(eq(9L), eq("CONFIRM_DISPATCH"), eq("reused"), anyString())).thenReturn(false);
|
||||
OutboundDispatchOperationEntity existing = new OutboundDispatchOperationEntity();
|
||||
existing.setRequestHash("different"); existing.setStatus("SUCCESS");
|
||||
when(repository.operation(9L, "CONFIRM_DISPATCH", "reused")).thenReturn(existing);
|
||||
|
||||
assertThatThrownBy(() -> service.confirm(9L, "admin", "reused",
|
||||
new ActionRequest(11L, 22L, 0, null))).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("用于不同请求");
|
||||
verify(repository, never()).confirmInbound(anyLong(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
private void stubReadyChain() {
|
||||
when(repository.bindings(any())).thenReturn(List.of(binding()));
|
||||
when(repository.pick(44L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(55L, "COMPLETED"));
|
||||
when(repository.ocean(22L, 8L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(66L, "RELEASED"));
|
||||
when(repository.truck(22L, 44L, 8L)).thenReturn(new OutboundDispatchReadMapper.IdStatus(77L, "DEPARTED"));
|
||||
when(repository.evidence(7L, 11L)).thenReturn(new OutboundDispatchReadMapper.Evidence(5, 1));
|
||||
}
|
||||
|
||||
private static OutboundDispatchReadMapper.Binding binding() {
|
||||
return new OutboundDispatchReadMapper.Binding(10L, "O-10", 11L, "I-11", 7L, 8L, "客户A",
|
||||
"LOADED", 22L, "C-22", 44L, "COMPLETED");
|
||||
}
|
||||
|
||||
private static OutboundDispatchGateContributor passing(String code) {
|
||||
return new OutboundDispatchGateContributor() {
|
||||
@Override public String code() { return code; }
|
||||
@Override public GateItem assess(Context context) {
|
||||
return new GateItem(code, code, true, "READY", "READY");
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.firstleg.outbound.dispatch;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OutboundDispatchSqlContractTest {
|
||||
@Test
|
||||
void schemaReusesInboundAndContainerRootsAndProvidesConcurrencyHistoryAndIdempotency() throws Exception {
|
||||
String sql = migration();
|
||||
assertThat(sql)
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch")
|
||||
.contains("UNIQUE KEY uk_outbound_dispatch_owner (tenant_id,inbound_id,container_booking_id)")
|
||||
.contains("FOREIGN KEY (inbound_id) REFERENCES fl_inbound_order(id)")
|
||||
.contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)")
|
||||
.contains("status IN ('READY_FOR_DISPATCH','WAITING_FOR_LOADING')")
|
||||
.contains("version INT UNSIGNED NOT NULL DEFAULT 0")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_history")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_outbound_dispatch_operation")
|
||||
.contains("UNIQUE KEY uk_outbound_dispatch_operation (operator_id,operation,idempotency_key)")
|
||||
.doesNotContain("CREATE TABLE IF NOT EXISTS fl_outbound_order");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionsUseReservedRangeAndGrantBothPlatformRoles() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("5102160,0,'PLATFORM','MENU'")
|
||||
.contains("5102164,0,'PLATFORM','BUTTON'")
|
||||
.contains("platform:firstleg:outbound-dispatch:list")
|
||||
.contains("platform:firstleg:outbound-dispatch:view")
|
||||
.contains("platform:firstleg:outbound-dispatch:check")
|
||||
.contains("platform:firstleg:outbound-dispatch:confirm")
|
||||
.contains("platform:firstleg:outbound-dispatch:reverse")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiPublishesSelectorsGateAndBothVersionedActions() throws Exception {
|
||||
String openApi = resource("openapi/phase3-outbound-dispatch-v1.yaml");
|
||||
assertThat(openApi)
|
||||
.contains("outbound-dispatches:")
|
||||
.contains("outbound-dispatches/detail:")
|
||||
.contains("outbound-dispatches/check-gate:")
|
||||
.contains("outbound-dispatches/confirm-dispatch:")
|
||||
.contains("outbound-dispatches/reverse-dispatch:")
|
||||
.contains("expectedVersion")
|
||||
.contains("Idempotency-Key");
|
||||
}
|
||||
|
||||
private static String migration() throws Exception { return resource("db/phase3-outbound-dispatch-schema.sql"); }
|
||||
private static String resource(String path) throws Exception {
|
||||
return new String(new ClassPathResource(path).getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.firstleg.portschedule.controller;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PlatformPortScheduleControllerContractTest {
|
||||
@Test
|
||||
void exposesDedicatedPlatformResourceAndPermissions() {
|
||||
assertThat(PlatformPortScheduleController.class.getAnnotation(RequestMapping.class).value())
|
||||
.containsExactly("/api/platform/v1/port-schedules");
|
||||
Map<String, String> permissions = Map.of(
|
||||
"page", "platform:firstleg:port-schedule:list",
|
||||
"detail", "platform:firstleg:port-schedule:view",
|
||||
"history", "platform:firstleg:port-schedule:view",
|
||||
"create", "platform:firstleg:port-schedule:create",
|
||||
"update", "platform:firstleg:port-schedule:edit");
|
||||
permissions.forEach((name, permission) -> {
|
||||
Method method = Arrays.stream(PlatformPortScheduleController.class.getDeclaredMethods())
|
||||
.filter(candidate -> candidate.getName().equals(name)).findFirst().orElseThrow();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
if (name.equals("create") || name.equals("update")) {
|
||||
assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.firstleg.portschedule.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PortScheduleOpenApiContractTest {
|
||||
@Test
|
||||
void publishesQueryWriteHistoryAndFreshnessContract() throws Exception {
|
||||
String yaml = new ClassPathResource("openapi/port-schedules-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/port-schedules:")
|
||||
.contains("/api/platform/v1/port-schedules/{portScheduleId}:")
|
||||
.contains("/api/platform/v1/port-schedules/{portScheduleId}/history:")
|
||||
.contains("name: customerId")
|
||||
.contains("name: oceanBookingId")
|
||||
.contains("name: containerBookingId")
|
||||
.contains("name: expired")
|
||||
.contains("name: Idempotency-Key")
|
||||
.contains("sourceType:")
|
||||
.contains("observedAt:")
|
||||
.contains("expiresAt:")
|
||||
.contains("bookingCutoffAt:")
|
||||
.contains("customsCutoffAt:")
|
||||
.contains("portCutoffAt:");
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.firstleg.portschedule.repository;
|
||||
|
||||
import com.baiyue.firstleg.portschedule.mapper.PortScheduleMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PortScheduleMapperContractTest {
|
||||
@Test
|
||||
void pageSupportsAllStableFiltersAndDatabaseTimeExpiry() throws Exception {
|
||||
String sql = PortScheduleMapper.class
|
||||
.getMethod("selectPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
|
||||
com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Query.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
assertThat(sql)
|
||||
.contains("customer_id=#{query.customerId}")
|
||||
.contains("ocean_booking_id=#{query.oceanBookingId}")
|
||||
.contains("container_booking_id=#{query.containerBookingId}")
|
||||
.contains("expires_at<=CURRENT_TIMESTAMP(3)")
|
||||
.contains("expires_at>CURRENT_TIMESTAMP(3)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateUsesVersionAndImmutableAssociationPredicate() throws Exception {
|
||||
String sql = PortScheduleMapper.class
|
||||
.getMethod("updateSchedule", com.baiyue.firstleg.portschedule.entity.PortScheduleEntity.class,
|
||||
int.class, long.class, String.class)
|
||||
.getAnnotation(Update.class).value()[0];
|
||||
assertThat(sql)
|
||||
.contains("version=version+1")
|
||||
.contains("customer_id=#{value.customerId}")
|
||||
.contains("ocean_booking_id=#{value.oceanBookingId}")
|
||||
.contains("container_booking_id<=>#{value.containerBookingId}")
|
||||
.contains("version=#{expectedVersion}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchGateReadsLatestExactCustomerBookingContainerSchedule() throws Exception {
|
||||
String sql = PortScheduleMapper.class
|
||||
.getMethod("latestForDispatch", long.class, long.class, long.class)
|
||||
.getAnnotation(Select.class).value()[0];
|
||||
assertThat(sql)
|
||||
.contains("customer_id=#{customerId}")
|
||||
.contains("ocean_booking_id=#{oceanBookingId}")
|
||||
.contains("container_booking_id=#{containerBookingId}")
|
||||
.contains("ORDER BY observed_at DESC,updated_at DESC,id DESC LIMIT 1");
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.firstleg.portschedule.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PortScheduleSqlContractTest {
|
||||
@Test
|
||||
void definesIndependentScheduleSourceHistoryAndIdempotency() throws Exception {
|
||||
assertThat(sql())
|
||||
.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_port_schedule_operation")
|
||||
.contains("source_type VARCHAR(24) NOT NULL")
|
||||
.contains("observed_at DATETIME(3) NOT NULL")
|
||||
.contains("expires_at DATETIME(3) NOT NULL")
|
||||
.contains("UNIQUE KEY uk_port_schedule_operation (operator_id,idempotency_key)")
|
||||
.contains("港务船期历史不可修改");
|
||||
}
|
||||
|
||||
@Test
|
||||
void enforcesBookingCustomerContainerAndTemporalBoundaries() throws Exception {
|
||||
assertThat(sql())
|
||||
.contains("FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id)")
|
||||
.contains("FOREIGN KEY (container_booking_id) REFERENCES fl_container_booking(id)")
|
||||
.contains("SELECT customer_id FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id")
|
||||
.contains("ocean_booking_id=NEW.ocean_booking_id AND container_booking_id=NEW.container_booking_id")
|
||||
.contains("expires_at > observed_at")
|
||||
.contains("booking_cutoff_at <= etd_at")
|
||||
.contains("customs_cutoff_at <= etd_at")
|
||||
.contains("port_cutoff_at <= etd_at");
|
||||
}
|
||||
|
||||
@Test
|
||||
void reservesDedicatedPlatformPermissionsForBothOperationalRoles() throws Exception {
|
||||
assertThat(sql())
|
||||
.contains("5102180 AND 5102183")
|
||||
.contains("platform:firstleg:port-schedule:list")
|
||||
.contains("platform:firstleg:port-schedule:view")
|
||||
.contains("platform:firstleg:port-schedule:create")
|
||||
.contains("platform:firstleg:port-schedule:edit")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')");
|
||||
}
|
||||
|
||||
private static String sql() throws Exception {
|
||||
return new ClassPathResource("db/phase3-port-schedule-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.baiyue.firstleg.portschedule.service;
|
||||
|
||||
import com.baiyue.firstleg.outbound.dispatch.gate.OutboundDispatchGateContributor.Context;
|
||||
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
|
||||
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PortScheduleDispatchGateContributorTest {
|
||||
@Mock private PortScheduleRepository repository;
|
||||
@InjectMocks private PortScheduleDispatchGateContributor contributor;
|
||||
|
||||
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
|
||||
|
||||
@Test
|
||||
void usesFixedCodeAndFailsClosedBeforeQueryForInvalidContext() {
|
||||
assertThat(contributor.code()).isEqualTo("SAILING_SCHEDULE");
|
||||
assertThat(contributor.assess(new Context(10001L, 10002L, 1L, 2L, 3L, 4L, 5L)).actual())
|
||||
.isEqualTo("INVALID_CUSTOMER_SCOPE");
|
||||
assertThat(contributor.assess(new Context(10001L, 10001L, 1L, 2L, 3L, 4L, null)).actual())
|
||||
.isEqualTo("MISSING_OCEAN_BOOKING");
|
||||
assertThat(contributor.assess(new Context(10001L, 10001L, 1L, 2L, 0L, 4L, 5L)).actual())
|
||||
.isEqualTo("MISSING_CONTAINER_BOOKING");
|
||||
verify(repository, never()).latestForDispatch(10001L, 5L, 3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void queriesExactCustomerOceanAndContainerAndFailsWhenMissing() {
|
||||
Context context = context();
|
||||
|
||||
var result = contributor.assess(context);
|
||||
|
||||
assertThat(result.passed()).isFalse();
|
||||
assertThat(result.actual()).isEqualTo("MISSING");
|
||||
assertThat(result.requirement()).contains("expiresAt", "截港时间", "ETD");
|
||||
verify(repository).latestForDispatch(10001L, 5L, 3L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMismatchedOrIncompleteLatestSchedule() {
|
||||
PortScheduleEntity mismatched = ready();
|
||||
mismatched.setContainerBookingId(99L);
|
||||
when(repository.latestForDispatch(10001L, 5L, 3L)).thenReturn(mismatched);
|
||||
assertThat(contributor.assess(context()).actual()).isEqualTo("ASSOCIATION_MISMATCH");
|
||||
|
||||
PortScheduleEntity incomplete = ready();
|
||||
incomplete.setPortCutoffAt(null);
|
||||
when(repository.latestForDispatch(10001L, 5L, 3L)).thenReturn(incomplete);
|
||||
assertThat(contributor.assess(context()).actual()).isEqualTo("MISSING_REQUIRED_SCHEDULE_DATA");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsExpiredFutureObservationAndPassedOperationalTimes() {
|
||||
PortScheduleEntity schedule = ready();
|
||||
when(repository.latestForDispatch(10001L, 5L, 3L)).thenReturn(schedule);
|
||||
|
||||
schedule.setObservedAt(LocalDateTime.now().plusMinutes(5));
|
||||
assertThat(contributor.assess(context()).actual()).isEqualTo("OBSERVATION_IN_FUTURE");
|
||||
schedule.setObservedAt(LocalDateTime.now().minusMinutes(5));
|
||||
schedule.setExpiresAt(LocalDateTime.now().minusSeconds(1));
|
||||
assertThat(contributor.assess(context()).actual()).isEqualTo("EXPIRED");
|
||||
schedule.setExpiresAt(LocalDateTime.now().plusHours(1));
|
||||
schedule.setPortCutoffAt(LocalDateTime.now().minusSeconds(1));
|
||||
assertThat(contributor.assess(context()).actual()).isEqualTo("PORT_CUTOFF_PASSED");
|
||||
schedule.setPortCutoffAt(LocalDateTime.now().plusHours(1));
|
||||
schedule.setEtdAt(LocalDateTime.now().minusSeconds(1));
|
||||
assertThat(contributor.assess(context()).actual()).isEqualTo("ETD_PASSED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void passesOnlyCurrentLatestScheduleWithFutureCutoffAndEtd() {
|
||||
PortScheduleEntity schedule = ready();
|
||||
when(repository.latestForDispatch(10001L, 5L, 3L)).thenReturn(schedule);
|
||||
|
||||
var result = contributor.assess(context());
|
||||
|
||||
assertThat(result.passed()).isTrue();
|
||||
assertThat(result.code()).isEqualTo("SAILING_SCHEDULE");
|
||||
assertThat(result.actual()).contains("READY:scheduleId=8", "expiresAt=", "portCutoffAt=", "etdAt=");
|
||||
}
|
||||
|
||||
private static Context context() {
|
||||
return new Context(10001L, 10001L, 1L, 2L, 3L, 4L, 5L);
|
||||
}
|
||||
|
||||
private static PortScheduleEntity ready() {
|
||||
PortScheduleEntity value = new PortScheduleEntity();
|
||||
value.setId(8L); value.setCustomerId(10001L); value.setOceanBookingId(5L); value.setContainerBookingId(3L);
|
||||
value.setSourceType("CARRIER"); value.setSourceName("Carrier"); value.setSourceRef("REF-8");
|
||||
value.setObservedAt(LocalDateTime.now().minusMinutes(5));
|
||||
value.setExpiresAt(LocalDateTime.now().plusHours(1));
|
||||
value.setPortCutoffAt(LocalDateTime.now().plusHours(2));
|
||||
value.setEtdAt(LocalDateTime.now().plusHours(3));
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.baiyue.firstleg.portschedule.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.Detail;
|
||||
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.ScheduleData;
|
||||
import com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.UpdateRequest;
|
||||
import com.baiyue.firstleg.portschedule.entity.PortScheduleEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.PortScheduleHistoryEntity;
|
||||
import com.baiyue.firstleg.portschedule.entity.PortScheduleOperationEntity;
|
||||
import com.baiyue.firstleg.portschedule.repository.PortScheduleRepository;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PortScheduleServiceTest {
|
||||
@Mock private PortScheduleRepository repository;
|
||||
@InjectMocks private PortScheduleService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
var field = PortScheduleService.class.getDeclaredField("objectMapper");
|
||||
field.setAccessible(true);
|
||||
field.set(service, new ObjectMapper().findAndRegisterModules());
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString())).thenReturn(1);
|
||||
when(repository.histories(anyLong())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsIndependentObservationWithValidatedBookingAndAllocatedContainer() {
|
||||
when(repository.oceanBooking(10L)).thenReturn(booking(10001L));
|
||||
when(repository.containerBooking(20L)).thenReturn(new ContainerBookingEntity());
|
||||
when(repository.allocation(10L, 20L)).thenReturn(new OceanBookingContainerAllocationEntity());
|
||||
|
||||
Detail result = service.create(7L, "admin", "port-create",
|
||||
new CreateRequest(0, data(20L, future(1), future(2)), "首录"));
|
||||
|
||||
assertThat(result.portSchedule().schedule().getOceanBookingId()).isEqualTo(10L);
|
||||
assertThat(result.portSchedule().schedule().getContainerBookingId()).isEqualTo(20L);
|
||||
assertThat(result.portSchedule().expired()).isFalse();
|
||||
ArgumentCaptor<PortScheduleHistoryEntity> history = ArgumentCaptor.forClass(PortScheduleHistoryEntity.class);
|
||||
verify(repository).insert(history.capture());
|
||||
assertThat(history.getValue().getAction()).isEqualTo("CREATE");
|
||||
assertThat(history.getValue().getFromVersion()).isNull();
|
||||
assertThat(history.getValue().getToVersion()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsContainerThatWasNotAllocatedToBooking() {
|
||||
when(repository.oceanBooking(10L)).thenReturn(booking(10001L));
|
||||
when(repository.containerBooking(20L)).thenReturn(new ContainerBookingEntity());
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", "port-unallocated",
|
||||
new CreateRequest(0, data(20L, future(1), future(2)), null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("未分配至该订舱");
|
||||
|
||||
verify(repository, never()).insert(any(PortScheduleEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStaleUpdateWithoutMutation() {
|
||||
when(repository.find(1L)).thenReturn(schedule(3));
|
||||
|
||||
assertThatThrownBy(() -> service.update(1L, 7L, "admin", "port-stale",
|
||||
new UpdateRequest(2, data(null, future(1), future(2)), "旧版本")))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("刷新后重试");
|
||||
|
||||
verify(repository, never()).update(any(), any(Integer.class), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysCompletedCreateBeforeLinkValidation() throws Exception {
|
||||
PortScheduleEntity value = schedule(0);
|
||||
Detail expected = new Detail(new com.baiyue.firstleg.portschedule.dto.PortScheduleDtos.ScheduleView(value, false), List.of());
|
||||
CreateRequest request = new CreateRequest(0, data(null, future(1), future(2)), "首录");
|
||||
var hash = PortScheduleService.class.getDeclaredMethod("hash", String.class, Long.class, Object.class);
|
||||
hash.setAccessible(true);
|
||||
PortScheduleOperationEntity operation = new PortScheduleOperationEntity();
|
||||
operation.setRequestHash((String) hash.invoke(service, "CREATE", null, request));
|
||||
operation.setStatus("SUCCESS");
|
||||
operation.setResultJson(new ObjectMapper().findAndRegisterModules().writeValueAsString(expected));
|
||||
when(repository.claimOperation(eq(null), eq(7L), eq("CREATE"), eq("port-replay"), anyString())).thenReturn(false);
|
||||
when(repository.operation(7L, "port-replay")).thenReturn(operation);
|
||||
|
||||
Detail replay = service.create(7L, "admin", "port-replay", request);
|
||||
|
||||
assertThat(replay.portSchedule().schedule().getId()).isEqualTo(1L);
|
||||
verify(repository, never()).oceanBooking(anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesObservationExpiryAndNullableOperationalTimes() {
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", "port-expiry",
|
||||
new CreateRequest(0, data(null, future(2), future(1)), null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("expiresAt");
|
||||
}
|
||||
|
||||
private static OceanBookingEntity booking(long customerId) {
|
||||
OceanBookingEntity value = new OceanBookingEntity();
|
||||
value.setId(10L); value.setCustomerId(customerId);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static PortScheduleEntity schedule(int version) {
|
||||
PortScheduleEntity value = new PortScheduleEntity();
|
||||
value.setId(1L); value.setScheduleNo("PS1"); value.setCustomerId(10001L);
|
||||
value.setOceanBookingId(10L); value.setSourceType("CARRIER"); value.setSourceName("Carrier");
|
||||
value.setSourceRef("REF-1"); value.setVesselName("VESSEL"); value.setVoyageNo("V1");
|
||||
value.setDeparturePortCode("CNSZX"); value.setDestinationPortCode("USLAX");
|
||||
value.setObservedAt(future(1)); value.setExpiresAt(future(2)); value.setVersion(version);
|
||||
return value;
|
||||
}
|
||||
|
||||
private static ScheduleData data(Long containerId, LocalDateTime observedAt, LocalDateTime expiresAt) {
|
||||
return new ScheduleData(10001L, 10L, containerId, "CARRIER", "Carrier", "REF-1",
|
||||
"VESSEL", "V1", "CNSZX", "深圳", "USLAX", "洛杉矶",
|
||||
future(5), future(20), future(2), future(3), future(4), observedAt, expiresAt, "验收");
|
||||
}
|
||||
|
||||
private static LocalDateTime future(long days) { return LocalDateTime.now().plusDays(days); }
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class Phase3FinalUnifiedSqlContractTest {
|
||||
private static final Path UNIFIED = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
|
||||
|
||||
@Test
|
||||
void unifiedEntryOwnsPhase3OutboundCustomsPortAndEvidenceHardening() throws Exception {
|
||||
String sql = Files.readString(UNIFIED, StandardCharsets.UTF_8);
|
||||
|
||||
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("CREATE TABLE IF NOT EXISTS fl_customs_receipt")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule")
|
||||
.contains("CREATE TABLE IF NOT EXISTS fl_port_schedule_history")
|
||||
.contains("uk_loading_evidence_seal (tenant_id,seal_no)")
|
||||
.contains("CALL assert_phase3_final_schema()")
|
||||
.contains("trg_port_schedule_history_before_update")
|
||||
.contains("trg_port_schedule_history_before_delete");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permissionRangesDoNotOverlapAndBothPlatformRolesAreGranted() throws Exception {
|
||||
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 = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
assertThat(sql)
|
||||
.contains("platform:firstleg:outbound-dispatch:%")
|
||||
.contains("platform:firstleg:customs:%")
|
||||
.contains("platform:firstleg:port-schedule:%")
|
||||
.contains("role.role_code IN ('PLATFORM_ADMIN','ORDER_OPERATOR')")
|
||||
.contains("角色授权未完整写入");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user