Files
baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java
T

537 lines
28 KiB
Java

package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.OceanBookingLinkRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.pricebookquery.application.model.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.pricebookquery.application.port.in.PriceBookQueryUseCase;
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;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.math.BigDecimal;
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.*;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@DisplayName("货柜基础资料服务")
@SpringJUnitConfig(ContainerService.class)
class ContainerServiceTest {
@MockBean private ContainerRepository repository;
@MockBean private WarehouseMasterDataClient warehouses;
@MockBean private PriceBookQueryUseCase priceBooks;
@MockBean private OceanBookingRepository oceanBookings;
@MockBean private WarehouseSupplierSnapshotService suppliers;
@MockBean private FirstlegDocumentNumberGenerator documentNumbers;
@MockBean private SmartLoadingService smartLoadingService;
@Resource private ContainerService service;
@BeforeEach
void setUpWarehouse() {
when(documentNumbers.nextCabinetNo()).thenReturn("D0001");
when(smartLoadingService.createForContainer(anyLong(), anyLong(), anyString())).thenAnswer(invocation ->
new com.baiyue.firstleg.dto.SmartLoadingDtos.Plan(501L, "LP-260818-0001", "EDITING",
invocation.getArgument(0, Long.class), null, null, 0, true, true, null,
"NOT_STARTED", null, null, null, List.of(), List.of(), List.of()));
when(warehouses.active(anyString(), anyLong(), anyLong())).thenAnswer(invocation ->
activeWarehouse(invocation.getArgument(2, Long.class), "DOMESTIC", "上海中转仓"));
when(suppliers.overseasTrucking(202L, "拖车公司A"))
.thenReturn(new WarehouseSupplierClient.Snapshot(202L, "拖车公司A", "TRUCKING", "ACTIVE", List.of(), "OVERSEAS"));
}
@Test
@DisplayName("新增模拟柜从有效渠道冻结快照并生成稳定内部编号")
void createsSimulationContainerBooking() {
ChannelRow channel = channel();
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel));
ContainerBookingEntity created = service.createSimulation(9L, "admin", simulation());
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insert(captor.capture());
ContainerBookingEntity saved = captor.getValue();
assertThat(created).isSameAs(saved);
assertThat(saved.getCabinetNo()).isEqualTo("D0001");
verify(documentNumbers).nextCabinetNo();
verify(documentNumbers, never()).next("CB");
assertThat(saved.getContainerNo()).isNull();
assertThat(saved.getOceanBookingId()).isNull();
assertThat(saved.getChannelGroupCode()).isEqualTo("MATSON_CLX");
assertThat(saved.getChannelCode()).isNull();
assertThat(saved.getChannelName()).isNull();
assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
assertThat(saved.getPlanId()).isEqualTo(501L);
assertThat(saved.getPlanNo()).isEqualTo("LP-260818-0001");
assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
verify(smartLoadingService).createForContainer(saved.getId(), 9L, "admin");
}
@Test
@DisplayName("新增模拟柜拒绝柜型与固定规格不匹配")
void rejectsSimulationWithNonCanonicalSpecification() {
SimulationCreateRequest invalid = new SimulationCreateRequest(
"MATSON_CLX", 101L, "40HQ", new BigDecimal("100"), new BigDecimal("19500"));
assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid))
.isInstanceOf(BizException.class)
.hasMessageContaining("容量和货重上限");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("智能装柜草稿阶段完整编辑时保留稳定柜次号")
void updatesAllFieldsWhileEditing() {
ContainerBookingEntity current = booking("EDITING", 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request()));
assertThat(current.getCabinetNo()).isEqualTo("柜-001");
verify(repository).updateDraft(current, 2, 9L, "admin");
}
@Test
@DisplayName("待装柜阶段完整编辑时保留稳定柜次号")
void updatesAllFieldsWhileWaitingForLoading() {
ContainerBookingEntity current = booking("WAIT_LOADING", 2);
when(repository.find(11L)).thenReturn(current);
when(repository.updateDraft(any(), org.mockito.ArgumentMatchers.eq(2),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
service.updateDraft(11L, 9L, "admin", new FullUpdateRequest(2, request()));
assertThat(current.getCabinetNo()).isEqualTo("柜-001");
verify(repository).updateDraft(current, 2, 9L, "admin");
}
@Test
@DisplayName("进入预装柜中后拒绝完整编辑")
void rejectsFullUpdateAfterPreloadPush() {
when(repository.find(11L)).thenReturn(booking("LOADING", 2));
assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request())))
.isInstanceOf(BizException.class)
.hasMessageContaining("基础资料不可完整编辑");
verify(repository, never()).updateDraft(any(), any(Integer.class),
any(Long.class), any(String.class));
}
@Test
@DisplayName("旧版本编辑请求触发乐观锁冲突")
void rejectsStaleVersion() {
when(repository.find(11L)).thenReturn(booking("EDITING", 3));
assertThatThrownBy(() -> service.updateDraft(
11L, 9L, "admin", new FullUpdateRequest(2, request())))
.isInstanceOf(BizException.class)
.hasMessageContaining("刷新后重试");
}
@Test
@DisplayName("虚拟柜拒绝选择非国内中转仓")
void rejectsNonDomesticWarehouse() {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenReturn(activeWarehouse(101L, "FBA", "美国目的仓"));
assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation()))
.isInstanceOf(BizException.class)
.hasMessageContaining("国内中转仓");
verify(repository, never()).insert(any());
}
@Test
@DisplayName("虚拟柜仓库名称以仓库主数据为准")
void replacesRequestedWarehouseName() {
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 101L)).thenReturn(java.util.List.of(channel()));
service.createSimulation(9L, "admin", simulation());
ArgumentCaptor<ContainerBookingEntity> captor = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insert(captor.capture());
assertThat(captor.getValue().getWarehouseName()).isEqualTo("上海中转仓");
}
@Test
@DisplayName("仓库主数据服务不可用时拒绝创建虚拟柜")
void rejectsUnavailableWarehouseService() {
when(warehouses.active(anyString(), anyLong(), anyLong()))
.thenThrow(new IllegalStateException("warehouse service unavailable"));
assertThatThrownBy(() -> service.createSimulation(9L, "admin", simulation()))
.isInstanceOf(BizException.class)
.hasMessageContaining("暂时不可用");
verify(repository, never()).insert(any());
}
@ParameterizedTest
@ValueSource(strings = {"WAIT_LOADING", "EDITING"})
@DisplayName("空计划及加入入仓单后的编辑计划均允许首次关联且保持清单身份")
void linksReleasedBookingAfterPreloading(String planStatus) {
ContainerBookingEntity container = booking(planStatus, 2);
container.setSourceType("SIMULATION");
container.setPlanId(501L);
container.setPlanNo("LP-260818-0001");
container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HQ");
container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000"));
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HQ"); requirement.setCapacityCbm(new BigDecimal("68.0000")); requirement.setMaxWeightKg(new BigDecimal("19500.000"));
when(repository.find(11L)).thenReturn(container, container);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"),
same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, " ms cu 663987 0 ", 2));
assertThat(result).isSameAs(container);
assertThat(container.getCabinetNo()).isEqualTo("柜-001");
assertThat(container.getPlanId()).isEqualTo(501L);
assertThat(container.getPlanNo()).isEqualTo("LP-260818-0001");
assertThat(container.getPlanStatus()).isEqualTo(planStatus);
assertThat(container.getSourceType()).isEqualTo("SIMULATION");
verify(repository, never()).hasActiveLoadingItems(11L);
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"),
same(booking), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("模拟柜允许不带真实柜号原子关联已发布订舱需求")
void linksReleasedBookingWithoutRealContainerNumber() {
stubLinkContext();
ContainerBookingEntity container = repository.find(11L);
when(repository.find(11L)).thenReturn(container, container);
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, null, 2));
assertThat(result.getContainerNo()).isNull();
assertThat(result.getCabinetNo()).isEqualTo("柜-001");
verify(repository, never()).containerNoExists(anyLong(), anyString(), anyLong());
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("预装已录入真实柜号时关联订舱保留原柜号")
void linksReleasedBookingWhilePreservingExistingRealContainerNumber() {
ContainerBookingEntity container = stubLinkContext();
container.setContainerNo("MSCU6639870");
when(repository.find(11L)).thenReturn(container, container);
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, null, 2));
assertThat(result.getContainerNo()).isEqualTo("MSCU6639870");
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"),
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("关联订舱时拒绝用不同柜号覆盖预装已录入柜号")
void rejectsDifferentRealContainerNumberWhenLinkingReleasedBooking() {
ContainerBookingEntity container = stubLinkContext();
container.setContainerNo("MSCU6639870");
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "TSTU1503306", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("与当前柜次已录入柜号不一致");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
verifyNoInteractions(oceanBookings);
}
@Test
@DisplayName("提柜后按柜次版本原子补录真实柜号")
void assignsRealContainerNumberAfterPickup() {
ContainerBookingEntity linked = booking("EDITING", 3);
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
ContainerBookingEntity assigned = booking("EDITING", 4);
assigned.setContainerNo("MSCU6639870"); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L);
when(repository.find(11L)).thenReturn(linked, assigned);
when(repository.assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin")).thenReturn(1);
ContainerBookingEntity result = service.assignRealContainerNo(
11L, " ms cu 663987 0 ", 3, 9L, "admin");
assertThat(result.getContainerNo()).isEqualTo("MSCU6639870");
verify(repository).assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin");
}
@Test
@DisplayName("提柜补录真实柜号拒绝同一订舱内重复号")
void rejectsDuplicateRealContainerNumberAfterPickup() {
ContainerBookingEntity linked = booking("EDITING", 3);
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
when(repository.find(11L)).thenReturn(linked);
when(repository.containerNoExists(21L, "MSCU6639870", 11L)).thenReturn(true);
assertThatThrownBy(() -> service.assignRealContainerNo(
11L, "MSCU6639870", 3, 9L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("首次关联拒绝错误ISO 6346校验位")
void rejectsInvalidContainerNumberCheckDigit() {
stubLinkContext();
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639871", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("校验位");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("首次关联规范化后按同一订舱唯一真实柜号拒绝冲突")
void rejectsNormalizedContainerNumberConflict() {
stubLinkContext();
when(repository.containerNoExists(21L, "MSCU6639870", 11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "ms cu6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
}
@Test
@DisplayName("同一订舱箱型数量允许绑定多个不同真实柜号")
void linksMultipleRealContainersToTheSameReleasedBookingRequirement() {
ContainerBookingEntity first = booking("WAIT_LOADING", 2);
first.setId(11L); first.setContainerNo(null); first.setWarehouseId(101L);
first.setChannelGroupCode("MATSON_CLX"); first.setChannelCode(null); first.setContainerType("40HQ");
ContainerBookingEntity second = booking("WAIT_LOADING", 4);
second.setId(12L); second.setContainerNo(null); second.setWarehouseId(101L);
second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HQ");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ");
when(repository.find(11L)).thenReturn(first);
when(repository.find(12L)).thenReturn(second);
when(oceanBookings.find(21L)).thenReturn(oceanBooking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L, 1L);
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"),
same(oceanBooking), eq(2), eq(9L), eq("admin"))).thenReturn(1);
when(repository.linkReleasedBooking(eq(12L), eq(21L), eq(31L), eq("TSTU1503306"),
same(oceanBooking), eq(4), eq(9L), eq("admin"))).thenReturn(1);
service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2));
service.linkOceanBooking(12L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "TSTU1503306", 4));
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocations =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(oceanBookings, org.mockito.Mockito.times(2)).insert(allocations.capture());
assertThat(allocations.getAllValues()).extracting(OceanBookingContainerAllocationEntity::getContainerBookingId)
.containsExactly(11L, 12L);
}
@Test
@DisplayName("已经归属其他订舱的真实柜不能改绑")
void rejectsContainerAlreadyOwnedByAnotherBooking() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
container.setOceanBookingId(22L);
when(repository.find(11L)).thenReturn(container);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已关联真实订舱柜");
verifyNoInteractions(oceanBookings);
}
@Test
@DisplayName("首次关联拒绝装柜仓库不一致")
void rejectsLinkWhenWarehouseDiffers() {
assertThreeFieldMismatch(102L, "MATSON_CLX", "40HQ");
}
@Test
@DisplayName("首次关联拒绝物流渠道不一致")
void rejectsLinkWhenChannelDiffers() {
assertThreeFieldMismatch(101L, "HEDE", "40HQ");
}
@Test
@DisplayName("首次关联拒绝柜型不一致")
void rejectsLinkWhenContainerTypeDiffers() {
assertThreeFieldMismatch(101L, "MATSON_CLX", "45HQ");
}
@Test
@DisplayName("未发布订舱不可关联")
void rejectsDraftBooking() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("DRAFT");
when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(booking);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已发布");
}
@Test
@DisplayName("订舱箱型需求达到数量上限时拒绝关联")
void rejectsRequirementOverAllocation() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(1);
when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("数量上限");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel,
String requirementType) {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
container.setContainerType("40HQ");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
oceanBooking.setWarehouseId(bookingWarehouseId); oceanBooking.setChannelCode(bookingChannel);
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType(requirementType);
when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(oceanBooking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道分组或柜型不一致");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
private ContainerBookingEntity stubLinkContext() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
container.setContainerType("40HQ");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ");
when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(oceanBooking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
return container;
}
@Test
@DisplayName("首次关联遇到有效装柜证据时拒绝")
void rejectsLinkWhenEvidenceExists() {
ContainerBookingEntity container = booking("EDITING", 2);
container.setContainerNo(null);
when(repository.find(11L)).thenReturn(container);
when(repository.hasEvidence(11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("证据");
verifyNoInteractions(oceanBookings);
}
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
String type,
String name) {
return ApiResponse.success(new WarehouseMasterDataClient.Detail(
new WarehouseMasterDataClient.Warehouse(warehouseId, 0L, "CN-SHA-01", null, name, type, "ACTIVE")));
}
private static ContainerBookingEntity booking(String status, int version) {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(11L);
value.setContainerNo("MSCU1234567");
value.setCabinetNo("柜-001");
value.setPlanStatus(status);
value.setVersion(version);
return value;
}
private static SaveRequest request() {
return new SaveRequest(101L, "上海仓", "BOL-001", "柏悦轮",
"V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("19500.000"), 202L, "拖车公司A",
LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
LocalDateTime.of(2026, 8, 25, 8, 0), null,
LocalDateTime.of(2026, 8, 27, 8, 0), null);
}
private static SimulationCreateRequest simulation() {
return new SimulationCreateRequest("MATSON_CLX", 101L, "40HQ", new BigDecimal("68.0000"), new BigDecimal("19500.000"));
}
private static ChannelRow channel() {
ChannelRow channel = new ChannelRow(); channel.setChannelCode("MATSON_CLX_TRUCK"); channel.setChannelName("美西海运"); return channel;
}
}