Merge remote-tracking branch 'origin/test' into dev
This commit is contained in:
+19
-10
@@ -18,30 +18,35 @@ class ContainerControllerContractTest {
|
||||
@Test
|
||||
@DisplayName("查询、新增和编辑按钮权限彼此独立")
|
||||
void separatesButtonPermissions() {
|
||||
assertPermission("page", "platform:firstleg:container:list");
|
||||
assertPermission("detail", "platform:firstleg:container:list");
|
||||
assertPermission("domesticPage", "platform:firstleg:container:list");
|
||||
assertPermission("overseasPage", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("domesticDetail", "platform:firstleg:container:list");
|
||||
assertPermission("overseasDetail", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("create", "platform:firstleg:container:create");
|
||||
assertPermission("linkOceanBooking", "platform:firstleg:container:edit");
|
||||
assertPermission("updateDraft", "platform:firstleg:container:edit");
|
||||
assertPermission("uploadClearance", "platform:firstleg:customs:manage");
|
||||
assertPermission("uploadClearance", "platform:firstleg:terminal-drayage:document:edit");
|
||||
assertPermission("overview", "platform:firstleg:container:list");
|
||||
assertPermission("overseasOverview", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("updatePickupCode", "platform:firstleg:terminal-drayage:document:edit");
|
||||
assertPermission("downloadPdaPhoto", "platform:firstleg:container:list");
|
||||
assertPermission("markException", "platform:firstleg:container:edit");
|
||||
assertPermission("cancelException", "platform:firstleg:container:edit");
|
||||
assertPermission("logisticsDocument", "platform:firstleg:container:list");
|
||||
assertPermission("uploadLogisticsDocument", "platform:firstleg:container:edit");
|
||||
assertPermission("downloadDocument", "platform:firstleg:container:list");
|
||||
assertPermission("downloadClearance", "platform:firstleg:customs:manage");
|
||||
assertPermission("logisticsDocument", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("uploadLogisticsDocument", "platform:firstleg:terminal-drayage:document:edit");
|
||||
assertPermission("downloadDocument", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("downloadClearance", "platform:firstleg:terminal-drayage:list");
|
||||
}
|
||||
|
||||
@Test void overviewOpenApiFreezesContainerIdBasedPdaContract() throws Exception {
|
||||
String openapi = new ClassPathResource("openapi/phase3-container-overview-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
|
||||
"/api/platform/v1/containers/overseas/{id}/overview:",
|
||||
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
|
||||
"/clearance/document/{fileId}/download:",
|
||||
"DESTINATION_CLEARANCE、DELIVERY_ORDER、PICKUP_ORDER",
|
||||
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:",
|
||||
"/api/platform/v1/containers/{id}/pickup-code:", "PickupCodeRequest",
|
||||
"enum: [DELIVERY_ORDER]", "Idempotency-Key", "photoCounts:",
|
||||
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
|
||||
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}",
|
||||
"downloadPath: {type: string, nullable: true",
|
||||
@@ -59,6 +64,9 @@ class ContainerControllerContractTest {
|
||||
|
||||
assertThat(section)
|
||||
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
|
||||
.contains("/api/platform/v1/containers/domestic:")
|
||||
.contains("/api/platform/v1/containers/overseas:")
|
||||
.contains("scope, in: query, required: true")
|
||||
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
|
||||
.contains("RELEASED oceanBookingId")
|
||||
.contains("未满配 requirementId")
|
||||
@@ -72,7 +80,7 @@ class ContainerControllerContractTest {
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
|
||||
.contains("multipart/form-data")
|
||||
.contains("ContainerDocumentUploadRequest")
|
||||
.contains("platform:firstleg:container:list")
|
||||
.contains("platform:firstleg:terminal-drayage:list")
|
||||
.contains("EDITING")
|
||||
.contains("WAIT_LOADING")
|
||||
.contains("LOADING")
|
||||
@@ -80,6 +88,7 @@ class ContainerControllerContractTest {
|
||||
.doesNotContain("tenantId")
|
||||
.doesNotContain("accountId");
|
||||
assertThat(openapi)
|
||||
.contains("ContainerWorkbenchItem:", "allowedActions")
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
|
||||
.contains("Content-Disposition 与 Cache-Control no-store")
|
||||
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
|
||||
|
||||
+12
@@ -14,6 +14,18 @@ class CarrierScheduleQueryOpenApiContractTest {
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(yaml)
|
||||
.contains("/api/platform/v1/carrier-schedule-queries:")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/container-actual-facts:")
|
||||
.contains("name: containerBookingIds")
|
||||
.contains("maxItems: 200")
|
||||
.contains("uniqueItems: true")
|
||||
.contains("ContainerActualFacts:")
|
||||
.contains("departedWarehouseAt:")
|
||||
.contains("departedOriginPortAt:")
|
||||
.contains("arrivedDestinationPortAt:")
|
||||
.contains("pickupReadyAt:")
|
||||
.contains("pickedUpAt:")
|
||||
.contains("emptyReturnedAt:")
|
||||
.contains("Estimated facts are never returned or inferred")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-results:")
|
||||
.contains("/api/platform/v1/carrier-schedule-queries/{queryId}/manual-nodes:")
|
||||
.contains("x-permission: platform:firstleg:port-schedule-query:manual")
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ class PlatformCarrierScheduleQueryControllerContractTest {
|
||||
.containsExactly("/api/platform/v1/carrier-schedule-queries");
|
||||
Map<String, String> permissions = Map.of(
|
||||
"carriers", "platform:firstleg:port-schedule-query:list",
|
||||
"containerActualFacts", "platform:firstleg:port-schedule-query:list",
|
||||
"page", "platform:firstleg:port-schedule-query:list",
|
||||
"detail", "platform:firstleg:port-schedule-query:list",
|
||||
"history", "platform:firstleg:port-schedule-query:list",
|
||||
|
||||
+25
@@ -9,10 +9,31 @@ import org.springframework.core.io.ClassPathResource;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CarrierScheduleQuerySqlContractTest {
|
||||
@Test
|
||||
void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception {
|
||||
String sql = String.join("\n", CarrierScheduleQueryMapper.class
|
||||
.getMethod("selectContainerActualFacts", List.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql)
|
||||
.contains("container_booking_id IN")
|
||||
.contains("f.actual_at IS NOT NULL")
|
||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='PICKED_UP'")
|
||||
.contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'")
|
||||
.contains("ORDER BY q.container_booking_id")
|
||||
.doesNotContain("estimated_at");
|
||||
assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void annotationSqlUsesExecutableComparisonOperators() throws Exception {
|
||||
String selectDue = String.join("\n", CarrierScheduleQueryMapper.class
|
||||
@@ -149,4 +170,8 @@ class CarrierScheduleQuerySqlContractTest {
|
||||
return new ClassPathResource("db/phase3-carrier-schedule-query-schema.sql")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static int count(String source, String needle) {
|
||||
return source.split(java.util.regex.Pattern.quote(needle), -1).length - 1;
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
|
||||
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.CreateRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualResultRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ProviderObservationRequest;
|
||||
@@ -72,6 +73,35 @@ class CarrierScheduleQueryServiceTest {
|
||||
when(portSchedules.containerBooking(20L)).thenReturn(container);
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsBatchActualFactsFromOneRepositoryCall() {
|
||||
ContainerActualFacts facts = new ContainerActualFacts(20L, 30L, 2, "READY",
|
||||
"PICKUP_READY", NOW, NOW.minusDays(12), NOW.minusDays(10), NOW.minusDays(2),
|
||||
NOW, null, null);
|
||||
when(repository.containerActualFacts(List.of(20L, 21L))).thenReturn(List.of(facts));
|
||||
|
||||
assertThat(service.containerActualFacts(List.of(20L, 21L))).containsExactly(facts);
|
||||
verify(repository).containerActualFacts(List.of(20L, 21L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidActualFactBatchBeforeRepositoryAccess() {
|
||||
assertThatThrownBy(() -> service.containerActualFacts(null))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("1到200");
|
||||
assertThatThrownBy(() -> service.containerActualFacts(List.of()))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("1到200");
|
||||
assertThatThrownBy(() -> service.containerActualFacts(java.util.Collections.nCopies(201, 1L)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("1到200");
|
||||
assertThatThrownBy(() -> service.containerActualFacts(java.util.Arrays.asList(1L, null)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("正数");
|
||||
assertThatThrownBy(() -> service.containerActualFacts(List.of(1L, 0L)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("正数");
|
||||
assertThatThrownBy(() -> service.containerActualFacts(List.of(1L, 1L)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("重复");
|
||||
|
||||
verify(repository, never()).containerActualFacts(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsTaskOnlyForBookingCustomerAndAllocatedContainer() {
|
||||
OceanBookingEntity booking = new OceanBookingEntity(); booking.setCustomerId(10001L);
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ class ContainerBookingMapperContractTest {
|
||||
void filtersPageByStableCustomerId() throws Exception {
|
||||
String page = String.join("\n", ContainerBookingMapper.class
|
||||
.getDeclaredMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class)
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(page)
|
||||
.contains("fl_ocean_booking_container_allocation customer_allocation")
|
||||
|
||||
+3
-2
@@ -12,11 +12,12 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ContainerOverviewSqlContractTest {
|
||||
@Test void overviewIncludesExactlyTheThreeCurrentBusinessDocumentTypes() throws Exception {
|
||||
@Test void overviewIncludesExactlyTheTwoCurrentBusinessDocumentTypes() throws Exception {
|
||||
String sql = String.join(" ", ContainerOverviewMapper.class.getDeclaredMethod("documents", long.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
assertThat(sql)
|
||||
.contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER','PICKUP_ORDER')")
|
||||
.contains("document_type IN ('DESTINATION_CLEARANCE','DELIVERY_ORDER')")
|
||||
.doesNotContain("PICKUP_ORDER")
|
||||
.contains("ORDER BY document_type");
|
||||
}
|
||||
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baiyue.firstleg.mapper.ContainerBookingMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
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 ContainerWorkbenchSqlContractTest {
|
||||
@Test
|
||||
void pagesDomesticAndOverseasScopesFromOwnedActualFactsWithoutCopyingShipNodeSql() throws Exception {
|
||||
String sql = String.join("\n", ContainerBookingMapper.class
|
||||
.getMethod("selectBookingPage", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class,
|
||||
com.baiyue.firstleg.dto.ContainerDtos.Query.class, String.class)
|
||||
.getAnnotation(Select.class).value());
|
||||
|
||||
assertThat(sql).contains("#{workbench}='DOMESTIC'", "#{workbench}='OVERSEAS'",
|
||||
"#{query.scope}='ACTIVE'", "#{query.scope}='HISTORY'", "fl_outbound_dispatch",
|
||||
"confirmed_at IS NOT NULL", "b.actual_empty_reported_at IS NULL",
|
||||
"b.actual_empty_reported_at IS NOT NULL", "outbound_dispatch_status")
|
||||
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact");
|
||||
}
|
||||
|
||||
@Test
|
||||
void baselineDocumentsTheSingleSeventyTwoHourEmptyReturnRule() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
|
||||
assertThat(sql).contains("预计还空时间(卸柜完成时间加72小时,UTC)")
|
||||
.doesNotContain("卸柜照片上传成功时间加两天");
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -162,10 +162,11 @@ class SmartLoadingSqlContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("货柜简单文件只保留目的港清关与DO/PU")
|
||||
void shouldAllowExactlyThreeSimpleContainerDocumentTypes() throws Exception {
|
||||
for(String sql:java.util.List.of(migration(),unifiedMigration()))assertThat(sql)
|
||||
.contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', 'PICKUP_ORDER'")
|
||||
@DisplayName("货柜简单文件只保留目的港清关与DO,PU改为数字码")
|
||||
void shouldAllowExactlyTwoSimpleContainerDocumentTypes() throws Exception {
|
||||
assertThat(migration())
|
||||
.contains("'DESTINATION_CLEARANCE', 'DELIVERY_ORDER'")
|
||||
.contains("pickup_code VARCHAR(64) NULL")
|
||||
.contains("file_hash VARCHAR(64) NULL")
|
||||
.contains("completed_by BIGINT UNSIGNED NULL")
|
||||
.contains("completed_at DATETIME(3) NULL");
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.baiyue.firstleg.service;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest;
|
||||
import com.baiyue.firstleg.dto.ContainerOverviewDtos.PickupCodeRequest;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
@@ -154,6 +155,22 @@ class ContainerOverviewServiceTest {
|
||||
eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}"));
|
||||
}
|
||||
|
||||
@Test void pickupCodePreservesLeadingZerosAndUsesContainerVersion() {
|
||||
ContainerBookingEntity locked = new ContainerBookingEntity(); locked.setId(41L); locked.setVersion(3);
|
||||
ContainerBookingEntity refreshed = new ContainerBookingEntity(); refreshed.setId(41L); refreshed.setVersion(4); refreshed.setPickupCode("00123456");
|
||||
when(containers.findActiveForUpdate(41L)).thenReturn(locked);
|
||||
when(containers.updatePickupCode(41L, "00123456", 3, 7L, "operator")).thenReturn(1);
|
||||
when(containers.find(41L)).thenReturn(refreshed);
|
||||
when(mapper.documents(41L)).thenReturn(java.util.List.of());
|
||||
when(mapper.exceptions(41L)).thenReturn(java.util.List.of());
|
||||
when(mapper.transport(41L)).thenReturn(java.util.List.of());
|
||||
|
||||
var result = service.updatePickupCode(41L, 7L, "operator", new PickupCodeRequest("00123456", 3));
|
||||
|
||||
assertThat(result.container().pickupCode()).isEqualTo("00123456");
|
||||
assertThat(result.container().version()).isEqualTo(4);
|
||||
}
|
||||
|
||||
@Test void exactReplayReturnsWithoutLockingOrWriting() {
|
||||
String hash = hash("MARK|41|3|broken seal");
|
||||
when(mapper.exceptionOperation(41L, 7L, "mark-1")).thenReturn(new ContainerOverviewMapper.ExceptionRow(
|
||||
|
||||
@@ -632,13 +632,13 @@ class LoadingListServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void genericContainerDocumentsOnlyAcceptDeliveryAndPickupOrders() {
|
||||
void genericContainerDocumentsOnlyAcceptDeliveryOrders() {
|
||||
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
|
||||
LoadingListService.DESTINATION_CLEARANCE)).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
|
||||
.hasMessageContaining("DELIVERY_ORDER");
|
||||
assertThatThrownBy(() -> service.containerLogisticsDocument(30L,
|
||||
"UNKNOWN_DOCUMENT")).isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("DELIVERY_ORDER或PICKUP_ORDER");
|
||||
.hasMessageContaining("DELIVERY_ORDER");
|
||||
verify(repository, never()).aggregateByBooking(30L);
|
||||
}
|
||||
|
||||
@@ -670,36 +670,6 @@ class LoadingListServiceTest {
|
||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void pickupOrderUpdateUsesCurrentVersionAndReturnsUpdatedDocument() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
byte[] content = "pickup-order".getBytes(java.nio.charset.StandardCharsets.UTF_8);
|
||||
var file = new MockMultipartFile("file", "pu.pdf", "application/pdf", content);
|
||||
String contentHash = sha256Bytes(content);
|
||||
ContainerDocumentEntity current = document(52L, LoadingListService.PICKUP_ORDER, 2, false);
|
||||
ContainerDocumentEntity saved = document(52L, LoadingListService.PICKUP_ORDER, 3, false);
|
||||
saved.setFileId(102L); saved.setFileName("pu.pdf"); saved.setFileHash(contentHash);
|
||||
var metadata = new LoadingDocumentFileClient.Metadata(102L, 0L,
|
||||
"CONTAINER_PICKUP_ORDER", "30", "pu.pdf", contentHash, "application/pdf", (long) content.length);
|
||||
when(repository.aggregateByBooking(30L)).thenReturn(plan);
|
||||
when(repository.history(anyString())).thenReturn(null);
|
||||
when(repository.document(30L, LoadingListService.PICKUP_ORDER)).thenReturn(current, saved);
|
||||
when(documentFiles.upload(anyString(), eq(9L), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
|
||||
eq("30"), eq(file))).thenReturn(ApiResponse.success(metadata));
|
||||
when(documentFiles.metadata(anyString(), eq(0L), eq("CONTAINER_PICKUP_ORDER"),
|
||||
eq("30"), eq(102L))).thenReturn(ApiResponse.success(metadata));
|
||||
when(repository.replaceDocument(any())).thenReturn(1);
|
||||
|
||||
var result = service.uploadContainerLogisticsDocument(30L, LoadingListService.PICKUP_ORDER,
|
||||
9L, "operator", "pu-update", 2, null, file);
|
||||
|
||||
assertThat(result.fileId()).isEqualTo(102L);
|
||||
assertThat(result.version()).isEqualTo(3);
|
||||
verify(repository).replaceDocument(any());
|
||||
verify(repository).appendHistory(eq(10L), anyString(), eq("UPLOAD_PICKUP_ORDER"),
|
||||
eq("COMPLETED"), eq("COMPLETED"), anyString(), anyString(), eq(9L), eq("operator"), isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staleDeliveryOrderVersionReturnsConflictBeforeUploadingAnotherFile() {
|
||||
AggregateRow plan = row("COMPLETED", 4);
|
||||
|
||||
+2
-2
@@ -41,7 +41,7 @@ class OverseasContainerEventConsumerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void derivesEstimatedEmptyReportedAtFromPhotoUploadAndPersistsInbox() {
|
||||
void derivesEstimatedEmptyReportedAtFromUnloadCompletionAndPersistsInbox() {
|
||||
ContainerBookingEntity container = container();
|
||||
when(containers.selectActiveBookingForUpdate(1001L)).thenReturn(container);
|
||||
Instant completedAt = Instant.parse("2026-08-20T03:00:00Z");
|
||||
@@ -52,7 +52,7 @@ class OverseasContainerEventConsumerTest {
|
||||
|
||||
verify(containers).applyEmptyReport(eq(1001L), eq(LocalDateTime.parse("2026-08-20T03:00:00")),
|
||||
eq(LocalDateTime.parse("2026-08-20T02:30:00")),
|
||||
eq(LocalDateTime.parse("2026-08-22T02:30:00")), isNull(), eq(7L));
|
||||
eq(LocalDateTime.parse("2026-08-23T03:00:00")), isNull(), eq(7L));
|
||||
ArgumentCaptor<ContainerEventInboxEntity> captor = ArgumentCaptor.forClass(ContainerEventInboxEntity.class);
|
||||
verify(inbox).insert(captor.capture());
|
||||
assertThat(captor.getValue().getAggregateId()).isEqualTo("1001");
|
||||
|
||||
+25
-2
@@ -1,6 +1,7 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
@@ -8,6 +9,17 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class TerminalDrayageOpenApiContractTest {
|
||||
@Test
|
||||
void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception {
|
||||
assertPermission("views", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("detail", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("history", "platform:firstleg:terminal-drayage:list");
|
||||
assertPermission("selectFleet", "platform:firstleg:terminal-drayage:fleet:edit");
|
||||
assertPermission("schedulePickup", "platform:firstleg:terminal-drayage:pickup-appointment:edit");
|
||||
assertPermission("recordPickup", "platform:firstleg:terminal-drayage:pickup:confirm");
|
||||
assertPermission("recordEmptyReturn", "platform:firstleg:terminal-drayage:empty-return:confirm");
|
||||
}
|
||||
|
||||
@Test
|
||||
void freezesActionsPermissionsAndServerWarningFields() throws Exception {
|
||||
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
|
||||
@@ -16,9 +28,20 @@ class TerminalDrayageOpenApiContractTest {
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup-appointment:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/pickup:",
|
||||
"/api/platform/v1/terminal-drayages/{containerBookingId}/empty-return:",
|
||||
"platform:firstleg:container:list", "platform:firstleg:container:edit",
|
||||
"platform:firstleg:terminal-drayage:list",
|
||||
"platform:firstleg:terminal-drayage:fleet:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup-appointment:edit",
|
||||
"platform:firstleg:terminal-drayage:pickup:confirm",
|
||||
"platform:firstleg:terminal-drayage:empty-return:confirm",
|
||||
"expectedVersion", "Idempotency-Key", "estimatedEmptyReturnAt",
|
||||
"emptyReturnWarning", "deadlineAt", "overdueHours", "overdueDays");
|
||||
"emptyReturnWarning", "actualFacts", "deadlineAt", "overdueHours", "overdueDays");
|
||||
assertThat(yaml).contains("description: 卸柜完成时间加72小时");
|
||||
}
|
||||
|
||||
private static void assertPermission(String methodName, String permission) {
|
||||
var method = java.util.Arrays.stream(PlatformTerminalDrayageController.class.getDeclaredMethods())
|
||||
.filter(value -> methodName.equals(value.getName())).findFirst().orElseThrow();
|
||||
assertThat(method.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -22,7 +22,8 @@ class TerminalDrayageSqlContractTest {
|
||||
"服务端按卸柜完成时间加72小时计算的预计还柜时间");
|
||||
assertThat(sql).doesNotContain("ON DELETE CASCADE");
|
||||
assertThat(mapper).contains("WHERE b.id=#{containerBookingId} FOR UPDATE",
|
||||
"WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}");
|
||||
"WHERE container_booking_id=#{value.containerBookingId} AND version=#{expectedVersion}")
|
||||
.doesNotContain("fl_carrier_schedule_query", "fl_carrier_schedule_node_fact");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
package com.baiyue.firstleg.terminaldrayage.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.Query;
|
||||
import com.baiyue.firstleg.dto.ContainerDtos.WorkbenchScope;
|
||||
import com.baiyue.firstleg.entity.ContainerBookingEntity;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.repository.ContainerRepository;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.Warning;
|
||||
import com.baiyue.firstleg.terminaldrayage.service.TerminalDrayageService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class ContainerWorkbenchServiceTest {
|
||||
private ContainerRepository repository;
|
||||
private CarrierScheduleQueryService carrierSchedules;
|
||||
private TerminalDrayageService terminalDrayages;
|
||||
private ContainerWorkbenchService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = mock(ContainerRepository.class);
|
||||
carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
terminalDrayages = mock(TerminalDrayageService.class);
|
||||
service = new ContainerWorkbenchService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "carrierSchedules", carrierSchedules);
|
||||
ReflectionTestUtils.setField(service, "terminalDrayages", terminalDrayages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void domesticPageKeepsServerPaginationAndUsesOneActualFactBatch() {
|
||||
ContainerBookingEntity container = container();
|
||||
container.setPlanStatus("COMPLETED");
|
||||
container.setCustomsDeclarationDocumentStatus("COMPLETED");
|
||||
when(repository.page(eq("DOMESTIC"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
|
||||
when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of());
|
||||
|
||||
var result = service.domesticPage(query(WorkbenchScope.ACTIVE), Set.of(
|
||||
"platform:firstleg:customs:manage", "platform:firstleg:outbound-dispatch:confirm"));
|
||||
|
||||
assertThat(result.total()).isEqualTo(1);
|
||||
assertThat(result.records().get(0).workbenchStage()).isEqualTo("WAIT_OUTBOUND");
|
||||
assertThat(result.records().get(0).actualFacts()).isNull();
|
||||
assertThat(result.records().get(0).allowedActions())
|
||||
.containsExactly("MANAGE_CUSTOMS", "CONFIRM_OUTBOUND");
|
||||
verify(carrierSchedules, times(1)).containerActualFacts(List.of(11L));
|
||||
verifyNoInteractions(terminalDrayages);
|
||||
}
|
||||
|
||||
@Test
|
||||
void overseasPageConsumesTerminalAggregateOnceAndFiltersActionsByPermission() {
|
||||
ContainerBookingEntity container = container();
|
||||
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
|
||||
ContainerActualFacts facts = facts("PICKED_UP");
|
||||
View raw = new View(11L, "MSCU1234567", 21L, "UNLOADED", 3, 91L, "车队",
|
||||
LocalDateTime.of(2026, 9, 4, 8, 0), LocalDateTime.of(2026, 9, 4, 9, 0),
|
||||
LocalDateTime.of(2026, 9, 8, 8, 0), null, container.getUnloadingCompletedAt(),
|
||||
new Warning("PENDING", LocalDateTime.of(2026, 9, 8, 8, 0), 0, 0),
|
||||
List.of("RECORD_EMPTY_RETURN"), facts, LocalDateTime.of(2026, 9, 5, 9, 0));
|
||||
Set<String> permissions = Set.of("platform:firstleg:terminal-drayage:empty-return:confirm");
|
||||
when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
|
||||
when(terminalDrayages.views(List.of(11L))).thenReturn(List.of(raw));
|
||||
when(terminalDrayages.authorize(List.of(raw), permissions)).thenReturn(List.of(raw));
|
||||
|
||||
var result = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions);
|
||||
|
||||
assertThat(result.records().get(0).workbenchStage()).isEqualTo("UNLOADED");
|
||||
assertThat(result.records().get(0).actualFacts()).isSameAs(facts);
|
||||
assertThat(result.records().get(0).allowedActions()).containsExactly("RECORD_EMPTY_RETURN");
|
||||
verify(terminalDrayages, times(1)).views(List.of(11L));
|
||||
verifyNoInteractions(carrierSchedules);
|
||||
}
|
||||
|
||||
private static Query query(WorkbenchScope scope) {
|
||||
return new Query(null, null, null, null, null, null, null, null, null, null, null,
|
||||
null, null, null, scope, 1, 20);
|
||||
}
|
||||
|
||||
private static ContainerBookingEntity container() {
|
||||
ContainerBookingEntity value = new ContainerBookingEntity();
|
||||
value.setId(11L); value.setContainerNo("MSCU1234567"); value.setPlanId(31L);
|
||||
value.setPlanNo("LP-260908-0001"); value.setPlanStatus("COMPLETED");
|
||||
value.setDestinationClearanceDocumentStatus("MISSING");
|
||||
value.setDeliveryOrderDocumentStatus("MISSING"); value.setOutboundDispatchStatus("NOT_CREATED");
|
||||
return value;
|
||||
}
|
||||
|
||||
private static ContainerActualFacts facts(String node) {
|
||||
LocalDateTime now = LocalDateTime.of(2026, 9, 4, 9, 0);
|
||||
return new ContainerActualFacts(11L, 41L, 3, "READY", node, now,
|
||||
now.minusDays(5), now.minusDays(4), now.minusDays(1), now.minusHours(2), now, null);
|
||||
}
|
||||
}
|
||||
+46
-3
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.terminaldrayage.service;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.client.WarehouseSupplierClient;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ManualNodeRequest;
|
||||
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
|
||||
import com.baiyue.firstleg.portschedule.service.CarrierScheduleQueryService;
|
||||
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
|
||||
import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest;
|
||||
@@ -21,6 +22,9 @@ import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.List;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -41,12 +45,18 @@ class TerminalDrayageServiceTest {
|
||||
private WarehouseSupplierSnapshotService suppliers;
|
||||
private CarrierScheduleQueryService carrierSchedules;
|
||||
private TerminalDrayageService service;
|
||||
private Deque<ContainerActualFacts> facts;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(TerminalDrayageMapper.class);
|
||||
suppliers = mock(WarehouseSupplierSnapshotService.class);
|
||||
carrierSchedules = mock(CarrierScheduleQueryService.class);
|
||||
facts = new ArrayDeque<>();
|
||||
when(carrierSchedules.containerActualFacts(any())).thenAnswer(ignored -> {
|
||||
if (facts.isEmpty()) return List.of();
|
||||
return List.of(facts.size() > 1 ? facts.removeFirst() : facts.peekFirst());
|
||||
});
|
||||
service = new TerminalDrayageService(mapper, suppliers, carrierSchedules, new ObjectMapper().findAndRegisterModules());
|
||||
service.setClock(Clock.fixed(Instant.parse("2026-09-05T02:00:00Z"), ZoneOffset.UTC));
|
||||
}
|
||||
@@ -167,6 +177,32 @@ class TerminalDrayageServiceTest {
|
||||
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
|
||||
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
|
||||
new Context(11L, "MSCU1234567", 21L, "ACTIVE", null)));
|
||||
when(mapper.findAll(List.of(11L))).thenReturn(List.of());
|
||||
|
||||
assertThat(service.views(List.of(11L))).isEmpty();
|
||||
verify(carrierSchedules).containerActualFacts(List.of(11L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowedActionsAreReducedByFrozenPermissionMatrix() {
|
||||
Context context = context("PICKUP_READY", null);
|
||||
TerminalDrayageEntity row = row(3);
|
||||
row.setPickupAppointmentAt(LocalDateTime.of(2026, 9, 2, 9, 0));
|
||||
when(mapper.context(11L)).thenReturn(context);
|
||||
when(mapper.find(11L)).thenReturn(row);
|
||||
|
||||
var raw = service.detail(11L);
|
||||
var authorized = service.authorize(raw,
|
||||
Set.of("platform:firstleg:terminal-drayage:pickup:confirm"));
|
||||
|
||||
assertThat(raw.allowedActions()).contains("SELECT_FLEET", "SCHEDULE_PICKUP", "RECORD_PICKUP");
|
||||
assertThat(authorized.allowedActions()).containsExactly("RECORD_PICKUP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutWriting() {
|
||||
Context context = context("ARRIVED_DESTINATION_PORT", null);
|
||||
@@ -249,10 +285,17 @@ class TerminalDrayageServiceTest {
|
||||
case "ARRIVED_DESTINATION_PORT" -> 3;
|
||||
default -> 2;
|
||||
};
|
||||
return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt,
|
||||
31L, node, ARRIVED_AT, sequenceVersion, ARRIVED_AT,
|
||||
facts.addLast(new ContainerActualFacts(11L, 31L, sequenceVersion, "READY", node, ARRIVED_AT,
|
||||
ARRIVED_AT.minusDays(5), ARRIVED_AT.minusDays(4),
|
||||
switch (node) {
|
||||
case "IN_TRANSIT" -> null;
|
||||
default -> ARRIVED_AT;
|
||||
},
|
||||
"PICKUP_READY".equals(node) || "PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node)
|
||||
? ARRIVED_AT.plusHours(2) : null,
|
||||
"PICKED_UP".equals(node) || "EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(1) : null,
|
||||
"EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null);
|
||||
"EMPTY_RETURNED".equals(node) ? ARRIVED_AT.plusDays(3) : null));
|
||||
return new Context(11L, "MSCU1234567", 21L, "ACTIVE", unloadedAt);
|
||||
}
|
||||
|
||||
private TerminalDrayageEntity row(int version) {
|
||||
|
||||
Reference in New Issue
Block a user