feat(firstleg): close phase3 outbound customs and schedule gates

This commit is contained in:
2026-08-08 22:36:41 +08:00
parent 789d97675b
commit 66d1aa0d3d
81 changed files with 5196 additions and 36 deletions
@@ -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");
}
}
@@ -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;
}
}
@@ -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");
}
}
@@ -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");
}
}
@@ -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;
}
}
@@ -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");
}
};
}
}
@@ -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);
}
}
@@ -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();
}
});
}
}
@@ -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:");
}
}
@@ -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&lt;=CURRENT_TIMESTAMP(3)")
.contains("expires_at&gt;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&lt;=&gt;#{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");
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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); }
}
@@ -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("角色授权未完整写入");
}
}