This commit is contained in:
2026-08-17 13:24:48 +08:00
parent ac6cf9c904
commit 6739c83467
43 changed files with 1049 additions and 315 deletions
@@ -59,9 +59,11 @@ class ContainerControllerContractTest {
assertThat(section)
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 柜")
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
.contains("尚无装柜计划/PDA/证据/单证的 realContainerBookingId")
.contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜")
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝")
.contains("realContainerBookingId")
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
.contains("multipart/form-data")
.contains("ContainerDocumentUploadRequest")
@@ -72,6 +74,11 @@ class ContainerControllerContractTest {
.contains("COMPLETED")
.doesNotContain("tenantId")
.doesNotContain("accountId");
assertThat(openapi)
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
.contains("mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true");
}
@Test
@@ -51,8 +51,16 @@ class PlatformOceanBookingControllerContractTest {
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:",
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
.contains("required: [channelCode, warehouseId, warehouseName, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containerRequirements]")
.contains("registration persists it directly as CONFIRMED")
.contains("required: [channelCode, warehouseId, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containers]")
.contains("containerNo: {type: string, maxLength: 64")
.contains("containers: {type: array, minItems: 1, maxItems: 20")
.contains("required: [containerBookingId, cabinetNo, containerNo, requirementId, allocationId")
.contains("warehouseName is resolved and frozen server-side")
.contains("shippingCompanyId:", "departurePortCode:", "destinationPortCode:",
"placeOfDelivery:", "remark:", "PartyRequest:")
.contains("/{oceanBookingId}/cancel:", "/{oceanBookingId}/change-request:",
"/{oceanBookingId}/change-confirm:")
.contains("persists the booking directly as CONFIRMED")
.contains("RELEASED remains a separate later action")
.contains("request must repeat the same bill of lading, vessel, voyage, ETD and ETA values; mismatches are rejected")
.contains("CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED")
@@ -38,6 +38,8 @@ class OceanBookingSqlContractTest {
.contains("OLD.status IN ('RELEASED','CANCELLED')")
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
.contains("request_hash CHAR(64) NOT NULL")
.contains("('CONFIRMED','RELEASED') THEN")
.doesNotContain("('CONFIRMED','CHANGE_PENDING') THEN")
.contains("订舱状态历史不可修改")
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)");
}
@@ -1,9 +1,11 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequirementRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData;
@@ -12,6 +14,7 @@ import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
import com.baiyue.firstleg.service.LoadingPickService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
@@ -44,6 +47,7 @@ class OceanBookingServiceTest {
@Mock private PriceBookQueryRepository priceBooks;
@Mock private LoadingPickService loadingPickService;
@Mock private WarehouseSupplierSnapshotService suppliers;
@Mock private WarehouseMasterDataClient warehouses;
@InjectMocks private OceanBookingService service;
@BeforeEach
@@ -52,10 +56,13 @@ class OceanBookingServiceTest {
var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true); field.set(service, new ObjectMapper().findAndRegisterModules());
when(repository.parties(anyLong())).thenReturn(List.of());
when(repository.requirements(anyLong())).thenReturn(List.of());
when(repository.containers(anyLong())).thenReturn(List.of());
when(repository.allocations(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
when(suppliers.booking(any(), any())).thenReturn(null);
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", "Warehouse深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
when(repository.transition(anyLong(), eq("DRAFT"), eq("CONFIRMED"), isNull(), eq(0),
@@ -179,6 +186,7 @@ class OceanBookingServiceTest {
assertThat(result.oceanBooking().getStatus()).isEqualTo("CONFIRMED");
assertThat(result.oceanBooking().getChannelCode()).isEqualTo("SEA-US");
assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
assertThat(result.oceanBooking().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
assertThat(result.oceanBooking().getVesselName()).isEqualTo("VESSEL");
assertThat(result.oceanBooking().getVoyageNo()).isEqualTo("V001");
@@ -194,6 +202,80 @@ class OceanBookingServiceTest {
assertThat(history.getValue().getAction()).isEqualTo("CREATE");
verify(repository).transition(inserted.get().getId(), "DRAFT", "CONFIRMED", null, 0, 9L,
"admin");
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insertContainer(containers.capture());
assertThat(containers.getValue().getContainerNo()).isEqualTo("MSCU-001");
assertThat(containers.getValue().getSourceType()).isEqualTo("OCEAN_BOOKING");
assertThat(containers.getValue().getRecordStatus()).isEqualTo("ACTIVE");
assertThat(containers.getValue().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocation =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(repository).insert(allocation.capture());
assertThat(allocation.getValue().getContainerBookingId()).isEqualTo(containers.getValue().getId());
}
@Test
void createsOneRequirementContainerAndAllocationPerRealContainerUnit() {
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
stubSuccessfulCreation();
DraftData source = draft();
List<ContainerRequest> requested = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
new ContainerRequest("MSCU-002", "20GP", "DRY", new BigDecimal("33"), new BigDecimal("28000")));
service.create(9L, "admin", "create-two-containers",
new CreateRequest(0, withContainers(source, requested)));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> requirements =
ArgumentCaptor.forClass(List.class);
verify(repository).replaceRequirements(anyLong(), requirements.capture());
assertThat(requirements.getValue()).hasSize(2)
.allSatisfy(requirement -> assertThat(requirement.getQuantity()).isEqualTo(1));
assertThat(requirements.getValue()).extracting(OceanBookingContainerRequirementEntity::getContainerType)
.containsExactly("40HC", "20GP");
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository, times(2)).insertContainer(containers.capture());
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getContainerNo)
.containsExactly("MSCU-001", "MSCU-002");
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getId).doesNotHaveDuplicates();
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getCabinetNo).doesNotHaveDuplicates();
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocations =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(repository, times(2)).insert(allocations.capture());
assertThat(allocations.getAllValues()).extracting(OceanBookingContainerAllocationEntity::getContainerBookingId)
.containsExactlyElementsOf(containers.getAllValues().stream().map(ContainerBookingEntity::getId).toList());
}
@Test
void rejectsDuplicateContainerNumbersWithinOneRegistrationBeforeWriting() {
DraftData source = draft();
List<ContainerRequest> duplicated = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-in-request",
new CreateRequest(0, withContainers(source, duplicated))))
.isInstanceOf(BizException.class).hasMessageContaining("不能重复");
verify(repository, never()).insert(any(OceanBookingEntity.class));
verifyNoInteractions(priceBooks);
}
@Test
void rejectsContainerNumberAlreadyRegisteredBeforeCreatingAggregate() {
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
when(repository.containerNoExists("MSCU-001")).thenReturn(true);
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-existing",
new CreateRequest(0, draft())))
.isInstanceOf(BizException.class).hasMessageContaining("集装箱号已存在");
verify(repository, never()).insert(any(OceanBookingEntity.class));
}
@Test
@@ -240,12 +322,12 @@ class OceanBookingServiceTest {
void rejectsOfflineBookingWhenEtaIsEarlierThanEtd() {
DraftData source = draft();
DraftData invalid = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
source.warehouseId(), source.warehouseName(), source.billOfLadingNo(), source.shippingCompanyId(),
source.warehouseId(), source.billOfLadingNo(), source.shippingCompanyId(),
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
source.serviceTerm(), source.freightTerm(), source.estimatedArrivalAt(), source.estimatedSailingAt(),
source.remark(), source.parties(), source.containerRequirements());
source.remark(), source.parties(), source.containers());
assertThatThrownBy(() -> service.create(9L, "admin", "invalid-range", new CreateRequest(0, invalid)))
.isInstanceOf(BizException.class).hasMessageContaining("预计到港时间不能早于预计开船时间");
@@ -257,12 +339,12 @@ class OceanBookingServiceTest {
void rejectsOfflineBookingWithoutCompleteTransportSnapshot() {
DraftData source = draft();
DraftData missingBill = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
source.warehouseId(), source.warehouseName(), null, source.shippingCompanyId(),
source.warehouseId(), null, source.shippingCompanyId(),
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
source.serviceTerm(), source.freightTerm(), source.estimatedSailingAt(), source.estimatedArrivalAt(),
source.remark(), source.parties(), source.containerRequirements());
source.remark(), source.parties(), source.containers());
assertThatThrownBy(() -> service.create(9L, "admin", "missing-transport",
new CreateRequest(0, missingBill))).isInstanceOf(BizException.class)
@@ -308,12 +390,21 @@ class OceanBookingServiceTest {
new PartyRequest("SHIPPER", "发货人", null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null));
List<ContainerRequirementRequest> requirements = List.of(
new ContainerRequirementRequest("40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000"), 1));
return new DraftData(customerId, customerName, "SEA-US", 701L, "深圳仓", "BL-001",
List<ContainerRequest> containers = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
return new DraftData(customerId, customerName, "SEA-US", 701L, "BL-001",
81L, "船公司", null, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
null, serviceTerm, freightTerm, LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), null, parties, requirements);
LocalDateTime.of(2026, 9, 20, 0, 0), null, parties, containers);
}
private static DraftData withContainers(DraftData source, List<ContainerRequest> containers) {
return new DraftData(source.customerId(), source.customerName(), source.channelCode(), source.warehouseId(),
source.billOfLadingNo(), source.shippingCompanyId(), source.shippingCompanyName(),
source.bookingAgentId(), source.bookingAgentName(), source.vesselName(), source.voyageNo(),
source.departurePortCode(), source.departurePortName(), source.destinationPortCode(),
source.destinationPortName(), source.placeOfDelivery(), source.serviceTerm(), source.freightTerm(),
source.estimatedSailingAt(), source.estimatedArrivalAt(), source.remark(), source.parties(), containers);
}
private AtomicReference<OceanBookingEntity> stubSuccessfulCreation() {
@@ -28,6 +28,7 @@ class LoadingPickPdaTaskSqlContractTest {
assertThat(sql)
.contains("task.task_status = 'LOADING'")
.contains("booking.record_status = 'ACTIVE'")
.contains("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}")
.contains("booking.warehouse_id AS warehouseId")
.doesNotContain("tenantId");
@@ -14,8 +14,11 @@ class LoadingPickStableLookupContractTest {
.getAnnotation(Select.class).value()[0];
String legacy = LoadingPickTaskMapper.class.getMethod("selectLoadingByContainerAndAccount", String.class, long.class)
.getAnnotation(Select.class).value()[0];
assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}").doesNotContain("task.container_no");
assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}").doesNotContain("task.container_no");
assertThat(legacy).contains("booking.container_no = #{containerNo}").doesNotContain("task.container_no =");
assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}", "booking.record_status='ACTIVE'")
.doesNotContain("task.container_no");
assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}", "booking.record_status='ACTIVE'")
.doesNotContain("task.container_no");
assertThat(legacy).contains("booking.container_no = #{containerNo}",
"booking.record_status = 'ACTIVE'").doesNotContain("task.container_no =");
}
}
@@ -61,6 +61,8 @@ class SmartLoadingSqlContractTest {
@Test
@DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整")
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
assertContainerLifecycleSchema(migration());
assertContainerLifecycleSchema(unifiedMigration());
assertThat(migration())
.contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS")
.contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END")
@@ -73,6 +75,20 @@ class SmartLoadingSqlContractTest {
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
}
private static void assertContainerLifecycleSchema(String sql) {
assertThat(sql)
.contains("source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'")
.contains("record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'")
.contains("merged_into_container_booking_id BIGINT UNSIGNED NULL")
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
.contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)")
.contains("CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING'))")
.contains("CHECK (record_status IN ('ACTIVE','MERGED'))")
.contains("column_name='source_type'")
.contains("column_name='record_status'")
.contains("column_name='merged_into_container_booking_id'");
}
@Test
@DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希")
void shouldProvideSimpleCustomsDocuments() throws Exception {
@@ -112,11 +112,11 @@ class ContainerOverviewServiceTest {
@Test void markLocksContainerAndPersistsIdempotentImmutableHistory() {
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); booking.setVersion(3);
when(containers.findForUpdate(41L)).thenReturn(booking);
when(containers.findActiveForUpdate(41L)).thenReturn(booking);
service.mark(41L, 7L, "operator", "mark-1", new ExceptionRequest(3, "broken seal"));
verify(containers).findForUpdate(41L);
verify(containers).findActiveForUpdate(41L);
verify(mapper).insertException(anyLong(), eq(41L), eq("MARK"), eq("broken seal"), eq(7L),
eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}"));
}
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
@@ -285,23 +286,90 @@ class ContainerServiceTest {
ContainerBookingEntity container = booking("EDITING", 2);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocations(21L)).thenReturn(java.util.List.of());
when(repository.updateLink(any(), org.mockito.ArgumentMatchers.eq(2), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, 2));
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
assertThat(result).isSameAs(container);
assertThat(container.getCabinetNo()).isEqualTo("柜-001");
assertThat(container.getOceanBookingId()).isEqualTo(21L);
verify(repository, never()).hasActiveLoadingItems(11L);
verify(oceanBookings).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
verify(repository).consumeRealSource(41L, 11L, 9L, "admin");
verify(repository).mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin");
verify(oceanBookings).reassignAllocation(41L, 11L);
}
@Test
@DisplayName("首次关联允许消费已放舱的真实订舱柜")
void linksReleasedBooking() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setContainerType("40HC");
requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
verify(oceanBookings).reassignAllocation(41L, 11L);
}
@Test
@DisplayName("已合并的真实订舱柜不可再次关联")
void rejectsConsumedRealContainer() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
ContainerBookingEntity consumed = realContainer(41L, 21L, 31L);
consumed.setRecordStatus("MERGED"); consumed.setMergedIntoContainerBookingId(99L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(consumed);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(41L, 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已被消费");
verifyNoInteractions(oceanBookings);
}
@Test
@DisplayName("已有装柜计划的真实订舱柜不可被模拟柜消费")
void rejectsRealContainerWithLoadingPlan() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(repository.hasLoadingPlan(41L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(41L, 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已有装柜计划");
verifyNoInteractions(oceanBookings);
}
@Test
@@ -311,11 +379,21 @@ class ContainerServiceTest {
when(repository.find(11L)).thenReturn(container);
when(repository.hasEvidence(11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, 2)))
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2)))
.isInstanceOf(BizException.class).hasMessageContaining("证据");
verifyNoInteractions(oceanBookings);
}
private static ContainerBookingEntity realContainer(long id, long oceanBookingId, long requirementId) {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(id); value.setContainerNo("MSCU7654321"); value.setCabinetNo("CAB-REAL");
value.setSourceType("OCEAN_BOOKING"); value.setRecordStatus("ACTIVE");
value.setOceanBookingId(oceanBookingId); value.setOceanBookingRequirementId(requirementId);
value.setWarehouseId(101L); value.setChannelCode("SEA-US"); value.setContainerType("40HC");
value.setCapacityCbm(new BigDecimal("76.0000")); value.setMaxWeightKg(new BigDecimal("28000.000"));
return value;
}
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
String type,
String name) {