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
@@ -171,8 +171,6 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Select("SELECT COUNT(*) FROM fl_loading_pick_task t JOIN fl_loading_plan p ON p.id=t.loading_plan_id WHERE p.container_booking_id=#{id}") int hasPickTask(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_evidence e JOIN fl_loading_plan_item i ON i.inbound_id=e.inbound_id AND i.account_id=e.tenant_id JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0 AND e.status='ACTIVE'") int hasEvidence(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_container_document WHERE container_booking_id=#{id}") int hasDocuments(@Param("id") long id);
@Select("SELECT COUNT(*) FROM fl_loading_plan_item i JOIN fl_loading_plan p ON p.id=i.loading_plan_id WHERE p.container_booking_id=#{id} AND i.deleted=0 AND i.account_id<>#{customerId}") int hasMismatchedCustomerItems(@Param("id") long id, @Param("customerId") long customerId);
@Update("""
UPDATE fl_container_booking SET container_no=#{containerNo}, voyage_no=#{voyageNo},
updated_by=#{operatorId}, updated_by_name=#{operatorName},
@@ -12,7 +12,6 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.DecimalMin;
import java.time.LocalDateTime;
import java.math.BigDecimal;
@@ -30,8 +29,6 @@ public final class OceanBookingDtos {
public record LinkCandidateQuery(@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 32) String containerType,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg,
@Min(1) Long page,
@Min(1) @Max(200) Long size) {
public LinkCandidateQuery {
@@ -3,8 +3,21 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper<OceanBookingContainerAllocationEntity> {
@Update("UPDATE fl_ocean_booking_container_allocation SET container_booking_id=#{targetId} WHERE container_booking_id=#{sourceId}")
int reassign(@Param("sourceId") long sourceId, @Param("targetId") long targetId);
@Select("""
SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation
JOIN fl_container_booking container ON container.id=allocation.container_booking_id
AND container.record_status='ACTIVE'
AND container.ocean_booking_id=allocation.ocean_booking_id
AND container.ocean_booking_requirement_id=allocation.requirement_id
WHERE allocation.ocean_booking_id=#{oceanBookingId}
AND allocation.requirement_id=#{requirementId}
""")
long countActiveLinked(@Param("oceanBookingId") long oceanBookingId,
@Param("requirementId") long requirementId);
}
@@ -26,9 +26,9 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
WHERE party.ocean_booking_id=booking.id AND party.party_type='SHIPPER' LIMIT 1) shipper_name,
(SELECT party.party_name FROM fl_ocean_booking_party party
WHERE party.ocean_booking_id=booking.id AND party.party_type='CONSIGNEE' LIMIT 1) consignee_name,
(SELECT COUNT(*) FROM fl_ocean_booking_container_allocation allocation
JOIN fl_container_booking container ON container.id=allocation.container_booking_id
WHERE allocation.ocean_booking_id=booking.id AND container.record_status='ACTIVE') container_count
COALESCE((SELECT SUM(requirement.quantity)
FROM fl_ocean_booking_container_requirement requirement
WHERE requirement.ocean_booking_id=booking.id),0) container_count
FROM fl_ocean_booking booking
WHERE (#{query.customerId} IS NULL OR booking.customer_id=#{query.customerId})
AND booking.status IN ('DRAFT','RELEASED')
@@ -54,17 +54,19 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
JOIN fl_ocean_booking_container_requirement requirement
ON requirement.ocean_booking_id=booking.id
LEFT JOIN (
SELECT ocean_booking_id,requirement_id,COUNT(*) allocated_quantity
FROM fl_ocean_booking_container_allocation
GROUP BY ocean_booking_id,requirement_id
SELECT allocation.ocean_booking_id,allocation.requirement_id,COUNT(*) allocated_quantity
FROM fl_ocean_booking_container_allocation allocation
JOIN fl_container_booking container ON container.id=allocation.container_booking_id
AND container.record_status='ACTIVE'
AND container.ocean_booking_id=allocation.ocean_booking_id
AND container.ocean_booking_requirement_id=allocation.requirement_id
GROUP BY allocation.ocean_booking_id,allocation.requirement_id
) allocated ON allocated.ocean_booking_id=booking.id
AND allocated.requirement_id=requirement.id
WHERE booking.status='RELEASED'
AND booking.warehouse_id=#{query.warehouseId}
AND booking.channel_code=#{query.channelCode}
AND requirement.container_type=#{query.containerType}
AND requirement.capacity_cbm=#{query.capacityCbm}
AND requirement.max_weight_kg=#{query.maxWeightKg}
AND requirement.quantity>COALESCE(allocated.allocated_quantity,0)
ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC
""")
@@ -49,6 +49,9 @@ public class OceanBookingRepository {
public List<OceanBookingPartyEntity> parties(long id) { return parties.selectList(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); }
public List<OceanBookingContainerRequirementEntity> requirements(long id) { return requirements.selectList(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); }
public List<OceanBookingContainerAllocationEntity> allocations(long id) { return allocations.selectList(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); }
public long activeLinkedCount(long oceanBookingId, long requirementId) {
return allocations.countActiveLinked(oceanBookingId, requirementId);
}
public List<RealContainer> containers(long id) {
Map<Long, OceanBookingContainerRequirementEntity> requirementById = requirements(id).stream()
.collect(Collectors.toMap(OceanBookingContainerRequirementEntity::getId, Function.identity()));
@@ -57,8 +57,7 @@ public class OceanBookingService {
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) {
return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(),
query.channelCode().trim(), query.containerType().trim(), query.capacityCbm(),
query.maxWeightKg(), query.page(), query.size()));
query.channelCode().trim(), query.containerType().trim(), query.page(), query.size()));
}
public List<ChannelOption> channels(long originWarehouseId) {
@@ -46,7 +46,6 @@ public class ContainerRepository {
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; }
public boolean hasDocuments(long id) { return mapper.hasDocuments(id) > 0; }
public boolean hasMismatchedCustomerItems(long id, long customerId) { return mapper.hasMismatchedCustomerItems(id, customerId) > 0; }
public int updateTransport(long id, String containerNo, String voyageNo, int version,
long operatorId, String operatorName) {
return mapper.updateTransport(id, containerNo, voyageNo, version, operatorId, operatorName);
@@ -85,17 +85,14 @@ public class ContainerService {
if (booking == null || !"RELEASED".equals(booking.getStatus())) throw conflict("只能关联已发布订舱");
OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(request.oceanBookingId(), request.requirementId());
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在");
long allocated = oceanBookings.allocations(request.oceanBookingId()).stream().filter(a -> request.requirementId().equals(a.getRequirementId())).count();
long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId());
if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限");
if (repository.containerNoExists(request.containerNo().trim(), id)) throw conflict("集装箱号已存在");
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode())
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())
|| container.getCapacityCbm() == null || requirement.getCapacityCbm() == null
|| container.getMaxWeightKg() == null || requirement.getMaxWeightKg() == null
|| container.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0
|| container.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) throw conflict("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致");
if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致");
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致");
}
if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), request.containerNo().trim(), booking,
request.expectedVersion(), operatorId, operatorName) != 1) {
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
@@ -2,7 +2,12 @@ openapi: 3.0.3
info: {title: Ocean Booking API, version: 2.0.0}
paths:
/api/platform/v1/ocean-bookings:
get: {summary: List DRAFT and RELEASED bookings, responses: {'200': {description: Booking page}}}
get:
summary: List DRAFT and RELEASED bookings
description: containerCount is the sum of quantity across all container requirements for the booking. It is zero when no requirement exists and never represents linked or ACTIVE containers.
responses:
'200':
description: Booking page; each row uses the frozen requirement-total containerCount semantic
post:
summary: Create an editable DRAFT
description: Creates only the booking aggregate and container requirements; no containerNo, container row, or allocation is created.
@@ -31,14 +36,12 @@ paths:
/api/platform/v1/ocean-bookings/link-candidates:
get:
summary: Page released booking requirement candidates for one container
description: Requires platform:firstleg:ocean-booking:list. Returns only exact warehouse, channel and fixed container specification matches with remainingQuantity greater than zero. Allocation counts are calculated by one bounded set query; booking detail calls are not used.
description: Requires platform:firstleg:ocean-booking:list. Eligibility matches only warehouseId, channelCode and containerType, with remainingQuantity greater than zero. Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one bounded set query; booking detail calls are not used.
x-permission: platform:firstleg:ocean-booking:list
parameters:
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}}
- {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}}
- {name: capacityCbm, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}}
- {name: maxWeightKg, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}}
- {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}}
responses:
@@ -145,6 +148,14 @@ components:
code: {type: string, example: '200'}
message: {type: string, nullable: true}
data: {$ref: '#/components/schemas/LinkCandidatePage'}
OceanBookingListRow:
type: object
properties:
containerCount:
type: integer
format: int64
minimum: 0
description: Sum of all container requirement quantities; independent of allocation, linking or unlinking.
Detail:
type: object
required: [oceanBooking, parties, containerRequirements, containers, histories]
@@ -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() {