Merge remote-tracking branch 'origin/test' into dev

This commit is contained in:
2026-09-08 20:22:49 +08:00
81 changed files with 2343 additions and 361 deletions
@@ -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]")
@@ -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")
@@ -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",
@@ -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;
}
}
@@ -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);
@@ -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")
@@ -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");
}
@@ -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("卸柜照片上传成功时间加两天");
}
}
@@ -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);
@@ -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");
@@ -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 + "')");
}
}
@@ -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
@@ -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);
}
}
@@ -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) {