fix: align ocean booking link eligibility

This commit is contained in:
2026-08-25 20:13:48 +08:00
parent 6e55d94f33
commit ead9565a37
14 changed files with 142 additions and 48 deletions
@@ -68,11 +68,21 @@ class PlatformOceanBookingControllerContractTest {
assertThat(yaml)
.contains("/api/platform/v1/ocean-bookings/link-candidates:")
.contains("x-permission: platform:firstleg:ocean-booking:list")
.contains("warehouseId", "channelCode", "containerType", "capacityCbm", "maxWeightKg")
.contains("warehouseId", "channelCode", "containerType")
.contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity")
.contains("Capacity and max weight are derived display snapshots and never eligibility fields")
.contains("booking detail calls are not used");
}
@Test
void openApiDefinesContainerCountAsRequirementQuantityTotal() throws Exception {
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
assertThat(yaml)
.contains("containerCount is the sum of quantity across all container requirements")
.contains("never represents linked or ACTIVE containers")
.contains("Sum of all container requirement quantities; independent of allocation, linking or unlinking.");
}
private static Method method(String name) {
return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods())
.filter(method -> method.getName().equals(name)).findFirst().orElseThrow();
@@ -1,6 +1,7 @@
package com.baiyue.firstleg.oceanbooking.repository;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper;
import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingContainerAllocationMapper;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@@ -21,7 +22,10 @@ class OceanBookingMapperContractTest {
.contains("booking.status=#{query.status}")
.contains("booking.booking_no LIKE")
.contains("party.party_type='SHIPPER'", "party.party_type='CONSIGNEE'")
.contains("fl_ocean_booking_container_allocation", "container.record_status='ACTIVE'", "container_count")
.contains("COALESCE((SELECT SUM(requirement.quantity)")
.contains("FROM fl_ocean_booking_container_requirement requirement")
.contains("requirement.ocean_booking_id=booking.id),0) container_count")
.doesNotContain("fl_ocean_booking_container_allocation allocation\n JOIN fl_container_booking")
.contains("ORDER BY booking.updated_at DESC,booking.id DESC");
}
@@ -48,12 +52,28 @@ class OceanBookingMapperContractTest {
.contains("booking.warehouse_id=#{query.warehouseId}")
.contains("booking.channel_code=#{query.channelCode}")
.contains("requirement.container_type=#{query.containerType}")
.contains("requirement.capacity_cbm=#{query.capacityCbm}")
.contains("requirement.max_weight_kg=#{query.maxWeightKg}")
.contains("FROM fl_ocean_booking_container_allocation")
.contains("GROUP BY ocean_booking_id,requirement_id")
.contains("container.record_status='ACTIVE'")
.contains("container.ocean_booking_id=allocation.ocean_booking_id")
.contains("container.ocean_booking_requirement_id=allocation.requirement_id")
.contains("GROUP BY allocation.ocean_booking_id,allocation.requirement_id")
.contains("requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity")
.contains("requirement.quantity>COALESCE(allocated.allocated_quantity,0)")
.doesNotContain("fl_container_booking", "SELECT booking.*");
.doesNotContain("fl_container_booking", "SELECT booking.*",
"requirement.capacity_cbm=#{query.capacityCbm}",
"requirement.max_weight_kg=#{query.maxWeightKg}");
}
@Test
void finalLinkLimitCountsOnlyActiveContainersStillPointingAtTheSameRequirement() throws Exception {
String sql = String.join("\n", OceanBookingContainerAllocationMapper.class
.getDeclaredMethod("countActiveLinked", long.class, long.class)
.getAnnotation(Select.class).value());
assertThat(sql)
.contains("container.record_status='ACTIVE'")
.contains("container.ocean_booking_id=allocation.ocean_booking_id")
.contains("container.ocean_booking_requirement_id=allocation.requirement_id")
.contains("allocation.ocean_booking_id=#{oceanBookingId}")
.contains("allocation.requirement_id=#{requirementId}");
}
}
@@ -171,6 +171,22 @@ class OceanBookingServiceTest {
verify(repository, times(2)).page(any());
}
@Test
void pagePreservesRequirementTotalContainerCountsForSingleMultipleAndEmptyDrafts() {
OceanBookingEntity one45Hc = booking("RELEASED", 1); one45Hc.setContainerCount(1L);
OceanBookingEntity multipleTypes = booking("RELEASED", 1); multipleTypes.setId(2L);
multipleTypes.setContainerCount(4L);
OceanBookingEntity emptyDraft = booking("DRAFT", 0); emptyDraft.setId(3L);
emptyDraft.setContainerCount(0L);
when(repository.page(any())).thenReturn(new PageResult<>(
List.of(one45Hc, multipleTypes, emptyDraft), 1, 20, 3, 1));
PageResult<OceanBookingEntity> result = service.page(new Query(null, null, null, 1, 20));
assertThat(result.records()).extracting(OceanBookingEntity::getContainerCount)
.containsExactly(1L, 4L, 0L);
}
@Test
void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() {
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC",
@@ -179,7 +195,7 @@ class OceanBookingServiceTest {
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
PageResult<LinkCandidate> result = service.linkCandidates(new LinkCandidateQuery(701L,
" SEA-US ", " 40HC ", new BigDecimal("76.0"), new BigDecimal("28000.00"), null, null));
" SEA-US ", " 40HC ", null, null));
assertThat(result.records()).containsExactly(candidate);
assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2);
@@ -53,15 +53,6 @@ class ContainerBookingMapperContractTest {
.contains("customer_item.account_id = #{query.customerId}");
}
@Test
@DisplayName("首次关联客户隔离查询使用原生不等号")
void customerMismatchQueryUsesNativeSqlOperator() throws Exception {
String sql = ContainerBookingMapper.class
.getDeclaredMethod("hasMismatchedCustomerItems", long.class, long.class)
.getAnnotation(Select.class).value()[0];
assertThat(sql).contains("i.account_id<>#{customerId}").doesNotContain("&lt;&gt;");
}
@Test
@DisplayName("关联订舱 SQL 的预计到港参数与 Mapper 参数名一致")
void linkReleasedBookingUsesDeclaredEstimatedArrivalParameter() {
@@ -288,7 +288,7 @@ class ContainerServiceTest {
void linksReleasedBookingAfterPreloading() {
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"));
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");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
@@ -296,7 +296,7 @@ class ContainerServiceTest {
when(repository.find(11L)).thenReturn(container, container);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocations(21L)).thenReturn(List.of());
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L);
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU7654321"),
same(booking), eq(2), eq(9L), eq("admin"))).thenReturn(1);
@@ -311,6 +311,24 @@ class ContainerServiceTest {
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
}
@Test
@DisplayName("首次关联拒绝装柜仓库不一致")
void rejectsLinkWhenWarehouseDiffers() {
assertThreeFieldMismatch(102L, "SEA-US", "40HC");
}
@Test
@DisplayName("首次关联拒绝物流渠道不一致")
void rejectsLinkWhenChannelDiffers() {
assertThreeFieldMismatch(101L, "SEA-EU", "40HC");
}
@Test
@DisplayName("首次关联拒绝柜型不一致")
void rejectsLinkWhenContainerTypeDiffers() {
assertThreeFieldMismatch(101L, "SEA-US", "45HC");
}
@Test
@DisplayName("未发布订舱不可关联")
void rejectsDraftBooking() {
@@ -333,12 +351,10 @@ class ContainerServiceTest {
booking.setId(21L); booking.setStatus("RELEASED");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(1);
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setRequirementId(31L);
when(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocations(21L)).thenReturn(List.of(allocation));
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(1L);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU7654321", 2)))
@@ -347,6 +363,28 @@ class ContainerServiceTest {
any(), anyInt(), anyLong(), anyString());
}
private void assertThreeFieldMismatch(long bookingWarehouseId, String bookingChannel,
String requirementType) {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setWarehouseId(101L); container.setChannelCode("SEA-US");
container.setContainerType("40HC");
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, "MSCU7654321", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString());
}
@Test
@DisplayName("完全空的模拟柜直接解绑并删除当前allocation,不依赖MERGED来源柜")
void unlinksEmptySimulationDirectly() {