feat(firstleg): align simulated containers with channel groups

This commit is contained in:
2026-09-10 19:24:49 +08:00
parent 5ffd6c0118
commit 155dd9d77d
25 changed files with 192 additions and 85 deletions
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.domain;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SmartLoadingChannelGroupCatalogTest {
@Test
void exposesNinePlanningGroupsAndMapsBothExxBookingChannels() {
assertThat(SmartLoadingChannelGroupCatalog.GROUPS).hasSize(9);
assertThat(SmartLoadingChannelGroupCatalog.require("EXX").channelCodes())
.contains("EXX_TRUCK", "EXX_RAIL_NY", "EXXHK", "EXXHWK", "NYEXX");
assertThat(SmartLoadingChannelGroupCatalog.contains("EXX", "EXX_TRUCK")).isTrue();
assertThat(SmartLoadingChannelGroupCatalog.contains("EXX", "EXX_RAIL_NY")).isTrue();
assertThat(SmartLoadingChannelGroupCatalog.contains("EXX", "MATSON_CLX_TRUCK")).isFalse();
}
}
@@ -80,7 +80,7 @@ class PlatformOceanBookingControllerContractTest {
assertThat(yaml)
.contains("/api/platform/v1/ocean-bookings/link-candidates:")
.contains("x-permission: platform:firstleg:ocean-booking:list")
.contains("warehouseId", "channelCode", "containerType")
.contains("warehouseId", "channelGroupCode", "containerType")
.contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity")
.contains("Capacity and max weight are derived display snapshots and never eligibility fields")
.contains("Returns only linkable requirement candidates")
@@ -49,12 +49,12 @@ class OceanBookingMapperContractTest {
@Test
void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates",
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class)
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class, java.util.Set.class)
.getAnnotation(Select.class).value());
assertThat(sql)
.contains("booking.status='RELEASED'")
.contains("booking.warehouse_id=#{query.warehouseId}")
.contains("booking.channel_code=#{query.channelCode}")
.contains("<foreach collection=\"channelCodes\"", "#{channelCode}")
.contains("requirement.container_type=#{query.containerType}")
.contains("FROM fl_ocean_booking_container_allocation")
.contains("container.record_status='ACTIVE'")
@@ -329,19 +329,21 @@ class OceanBookingServiceTest {
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC",
new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2,
"客户A", "深圳中转仓", "美西海运");
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
PageResult<LinkCandidate> result = service.linkCandidates(new LinkCandidateQuery(701L,
" SEA-US ", " 40HC ", null, null));
" MATSON_CLX ", " 40HC ", null, null));
assertThat(result.records()).containsExactly(candidate);
assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2);
ArgumentCaptor<LinkCandidateQuery> query = ArgumentCaptor.forClass(LinkCandidateQuery.class);
verify(repository).linkCandidates(query.capture());
assertThat(query.getValue().channelCode()).isEqualTo("SEA-US");
@SuppressWarnings("unchecked") ArgumentCaptor<java.util.Set<String>> channels = ArgumentCaptor.forClass(java.util.Set.class);
verify(repository).linkCandidates(query.capture(), channels.capture());
assertThat(query.getValue().channelGroupCode()).isEqualTo("MATSON_CLX");
assertThat(query.getValue().containerType()).isEqualTo("40HC");
assertThat(query.getValue().page()).isEqualTo(1L);
assertThat(query.getValue().size()).isEqualTo(20L);
assertThat(channels.getValue()).contains("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL", "ZBYHK");
verify(repository, never()).find(anyLong());
verify(repository, never()).requirements(anyLong());
verify(repository, never()).containers(anyLong());
@@ -349,15 +351,15 @@ class OceanBookingServiceTest {
@Test
void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() {
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
PageResult<LinkCandidate> result = service.linkCandidates(
new LinkCandidateQuery(701L, "SEA-US", "45HC", null, null));
new LinkCandidateQuery(701L, "MATSON_CLX", "45HC", null, null));
assertThat(result.records()).isEmpty();
assertThat(result.total()).isZero();
assertThat(result.pages()).isZero();
verify(repository, times(1)).linkCandidates(any());
verify(repository, times(1)).linkCandidates(any(), anySet());
assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName)
.doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode");
}
@@ -80,7 +80,9 @@ class ContainerServiceTest {
assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001");
assertThat(saved.getContainerNo()).isNull();
assertThat(saved.getOceanBookingId()).isNull();
assertThat(saved.getChannelName()).isEqualTo("美西海运");
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("76.0000");
@@ -94,7 +96,7 @@ class ContainerServiceTest {
@DisplayName("新增模拟柜拒绝柜型与固定规格不匹配")
void rejectsSimulationWithNonCanonicalSpecification() {
SimulationCreateRequest invalid = new SimulationCreateRequest(
"SEA-US", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000"));
"MATSON_CLX", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000"));
assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid))
.isInstanceOf(BizException.class)
@@ -202,10 +204,10 @@ class ContainerServiceTest {
container.setPlanId(501L);
container.setPlanNo("LP-260818-0001");
container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HC");
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("SEA-US");
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX_RAIL_NY");
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"));
when(repository.find(11L)).thenReturn(container, container);
@@ -311,13 +313,13 @@ class ContainerServiceTest {
void linksMultipleRealContainersToTheSameReleasedBookingRequirement() {
ContainerBookingEntity first = booking("WAIT_LOADING", 2);
first.setId(11L); first.setContainerNo(null); first.setWarehouseId(101L);
first.setChannelCode("SEA-US"); first.setContainerType("40HC");
first.setChannelGroupCode("MATSON_CLX"); first.setChannelCode(null); first.setContainerType("40HC");
ContainerBookingEntity second = booking("WAIT_LOADING", 4);
second.setId(12L); second.setContainerNo(null); second.setWarehouseId(101L);
second.setChannelCode("SEA-US"); second.setContainerType("40HC");
second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HC");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("SEA-US");
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC");
when(repository.find(11L)).thenReturn(first);
@@ -360,19 +362,19 @@ class ContainerServiceTest {
@Test
@DisplayName("首次关联拒绝装柜仓库不一致")
void rejectsLinkWhenWarehouseDiffers() {
assertThreeFieldMismatch(102L, "SEA-US", "40HC");
assertThreeFieldMismatch(102L, "MATSON_CLX_TRUCK", "40HC");
}
@Test
@DisplayName("首次关联拒绝物流渠道不一致")
void rejectsLinkWhenChannelDiffers() {
assertThreeFieldMismatch(101L, "SEA-EU", "40HC");
assertThreeFieldMismatch(101L, "HEDE_TRUCK", "40HC");
}
@Test
@DisplayName("首次关联拒绝柜型不一致")
void rejectsLinkWhenContainerTypeDiffers() {
assertThreeFieldMismatch(101L, "SEA-US", "45HC");
assertThreeFieldMismatch(101L, "MATSON_CLX_TRUCK", "45HC");
}
@Test
@@ -415,7 +417,7 @@ class ContainerServiceTest {
String requirementType) {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelCode("SEA-US");
container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
container.setContainerType("40HC");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
@@ -429,7 +431,7 @@ class ContainerServiceTest {
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致");
.isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道分组或柜型不一致");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
@@ -437,11 +439,11 @@ class ContainerServiceTest {
private void stubLinkContext() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelCode("SEA-US");
container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
container.setContainerType("40HC");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("SEA-US");
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC");
when(repository.find(11L)).thenReturn(container);
@@ -491,10 +493,10 @@ class ContainerServiceTest {
}
private static SimulationCreateRequest simulation() {
return new SimulationCreateRequest("SEA-US", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000"));
return new SimulationCreateRequest("MATSON_CLX", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000"));
}
private static ChannelRow channel() {
ChannelRow channel = new ChannelRow(); channel.setChannelCode("SEA-US"); channel.setChannelName("美西海运"); return channel;
ChannelRow channel = new ChannelRow(); channel.setChannelCode("MATSON_CLX_TRUCK"); channel.setChannelName("美西海运"); return channel;
}
}
@@ -171,11 +171,11 @@ class SmartLoadingServiceTest {
@Test
void groupsCandidateSpecificationsByInboundCodeForQuantityOnlyPlanning() {
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "服装", 3,
"MATSON_CLX_TRUCK", "美森正班海卡", 200L, "FBA1", "FBA仓", "客户", "服装", 3,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"),
null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00"));
Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7,
"MATSON_CLX_TRUCK", "美森正班海卡", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"),
null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00"));
when(repository.booking(99L)).thenReturn(booking());
@@ -515,7 +515,7 @@ class SmartLoadingServiceTest {
@Test
void pagesWorkbenchContainersWithTheSameGlobalFilters() {
WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
100L, "义乌仓", "MATSON_CLX_TRUCK", "美森正班海卡", "40HC",
100L, "义乌仓", "MATSON_CLX", null, null, "40HC",
new BigDecimal("68"), new BigDecimal("28000"),
1L, "LP-1", "EDITING", 2);
WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L,
@@ -530,8 +530,7 @@ class SmartLoadingServiceTest {
@SuppressWarnings("unchecked") ArgumentCaptor<java.util.Set<String>> channels =
ArgumentCaptor.forClass(java.util.Set.class);
verify(repository).workbenchContainers(eq(query), channels.capture());
assertThat(channels.getValue()).containsExactlyInAnyOrder(
"MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL");
assertThat(channels.getValue()).containsExactly("MATSON_CLX");
}
@Test
@@ -1694,7 +1693,7 @@ class SmartLoadingServiceTest {
private static Candidate candidate(long tenantId, long inboundId, long detailId, long orderId, int boxes,
String destinationCode) {
return new Candidate(tenantId, inboundId, detailId, orderId, "O" + orderId, "I" + inboundId,
100L, "FBA", "FBA渠道", 200L, destinationCode, "FBA仓", "客户", "货物", boxes,
100L, "MATSON_CLX_TRUCK", "美森正班海卡", 200L, destinationCode, "FBA仓", "客户", "货物", boxes,
new BigDecimal("2"), new BigDecimal("1.5"),
new BigDecimal("0.5"), null, null, null, null, null, null, null, null);
}
@@ -1771,7 +1770,7 @@ class SmartLoadingServiceTest {
private static ContainerBookingEntity booking() {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1");
value.setChannelCode("FBA");
value.setChannelGroupCode("MATSON_CLX"); value.setChannelCode(null);
value.setContainerNo("MSCU1234567");
value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000"));
return value;