This commit is contained in:
2026-09-18 09:59:07 +08:00
parent 13a8dde0a5
commit 4f59f7bd28
45 changed files with 358 additions and 157 deletions
@@ -168,9 +168,6 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
@Param("version") int version, @Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Select("SELECT * FROM fl_container_booking WHERE container_no=#{containerNo} AND record_status='ACTIVE'")
ContainerBookingEntity selectActiveByContainerNo(@Param("containerNo") String containerNo);
@Update("""
UPDATE fl_container_booking SET unloading_completed_at=#{unloadingCompletedAt},
unloading_photo_uploaded_at=#{photoUploadedAt}, estimated_empty_reported_at=#{estimatedEmptyReportedAt},
@@ -39,7 +39,7 @@ public final class OceanBookingDtos {
}
}
public record LinkCandidate(Long oceanBookingId, String bookingNo, Long requirementId,
public record LinkCandidate(Long oceanBookingId, String bookingNo, String billOfLadingNo, Long requirementId,
String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg,
int requiredQuantity, long allocatedQuantity, long remainingQuantity,
String customerName, String warehouseName, String channelName) {}
@@ -55,7 +55,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
@Select("""
<script>
SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id,
SELECT booking.id ocean_booking_id,booking.booking_no,booking.bill_of_lading_no,requirement.id requirement_id,
requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg,
requirement.quantity required_quantity,
COALESCE(allocated.allocated_quantity,0) allocated_quantity,
@@ -38,6 +38,12 @@ public class OceanBookingRepository {
}
public OceanBookingEntity find(long id) { return bookings.selectById(id); }
public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); }
public boolean billOfLadingNoExists(String billOfLadingNo, Long excludedId) {
if (billOfLadingNo == null) return false;
return bookings.selectCount(new LambdaQueryWrapper<OceanBookingEntity>()
.eq(OceanBookingEntity::getBillOfLadingNo, billOfLadingNo)
.ne(excludedId != null, OceanBookingEntity::getId, excludedId)) > 0;
}
public void insert(OceanBookingEntity value) { bookings.insert(value); }
public int transition(long id, String from, String to, int version, long operatorId, String operatorName) {
return bookings.transition(id, from, to, version, operatorId, operatorName);
@@ -102,9 +108,6 @@ public class OceanBookingRepository {
public void replaceParties(long id, List<OceanBookingPartyEntity> values) { parties.delete(new LambdaQueryWrapper<OceanBookingPartyEntity>().eq(OceanBookingPartyEntity::getOceanBookingId, id)); values.forEach(parties::insert); }
public void replaceRequirements(long id, List<OceanBookingContainerRequirementEntity> values) { requirements.delete(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id)); values.forEach(requirements::insert); }
public OceanBookingContainerRequirementEntity requirement(long id, long requirementId) { return requirements.selectOne(new LambdaQueryWrapper<OceanBookingContainerRequirementEntity>().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).eq(OceanBookingContainerRequirementEntity::getId, requirementId)); }
public boolean containerNoExists(String containerNo) {
return containerBookings.containerNoExists(containerNo, null);
}
public void insertContainer(ContainerBookingEntity value) { containerBookings.insert(value); }
public void insert(OceanBookingContainerAllocationEntity value) { allocations.insert(value); }
public int reassignAllocation(long sourceId, long targetId) {
@@ -24,6 +24,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import feign.FeignException;
@@ -110,9 +111,11 @@ public class OceanBookingService {
value.setBookingNo(documentNumbers.next("OB"));
value.setStatus(OceanBookingStatus.DRAFT.name());
copy(value, request.data(), resolved);
requireUniqueBillOfLadingNo(value.getBillOfLadingNo(), null);
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
repository.insert(value);
try { repository.insert(value); }
catch (DuplicateKeyException exception) { throw duplicateBillOfLadingNo(); }
repository.replaceParties(value.getId(), parties(value.getId(), request.data().parties()));
List<OceanBookingContainerRequirementEntity> requirements = requirements(
value.getId(), request.data().containerRequirements());
@@ -132,7 +135,10 @@ public class OceanBookingService {
validateDraftStructure(request.data());
ResolvedDraft resolved = resolveDraft(request.data());
copy(before, request.data(), resolved);
if (repository.updateDraft(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
requireUniqueBillOfLadingNo(before.getBillOfLadingNo(), id);
try {
if (repository.updateDraft(before, request.expectedVersion(), operatorId, operatorName) != 1) throw concurrent();
} catch (DuplicateKeyException exception) { throw duplicateBillOfLadingNo(); }
repository.replaceParties(id, parties(id, request.data().parties()));
repository.replaceRequirements(id, requirements(id, request.data().containerRequirements()));
OceanBookingEntity after = required(id);
@@ -294,6 +300,14 @@ public class OceanBookingService {
v.setRemark(trim(d.remark()));
}
private void requireUniqueBillOfLadingNo(String billOfLadingNo, Long excludedId) {
if (repository.billOfLadingNoExists(billOfLadingNo, excludedId)) throw duplicateBillOfLadingNo();
}
private static BizException duplicateBillOfLadingNo() {
return conflict("提单号已存在,请检查是否重复创建订舱");
}
private void validateMasterData(OceanBookingEntity booking) {
resolveCustomer(booking.getCustomerId(), booking.getCustomerName());
resolveCarrier(booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getCarrierCode());
@@ -22,9 +22,10 @@ public class ContainerRepository {
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public boolean departed(long id) { return mapper.hasActiveOutboundDeparture(id) > 0; }
public boolean containerNoExists(String no, Long excludedId) {
public boolean containerNoExists(long oceanBookingId, String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
.eq(ContainerBookingEntity::getOceanBookingId, oceanBookingId)
.eq(ContainerBookingEntity::getContainerNo, no)
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
@@ -93,7 +93,9 @@ public class ContainerService {
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "订舱箱型需求不存在");
long allocated = oceanBookings.activeLinkedCount(request.oceanBookingId(), request.requirementId());
if (allocated >= requirement.getQuantity()) throw conflict("该订舱箱型需求已达到数量上限");
if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
if (containerNo != null && repository.containerNoExists(request.oceanBookingId(), containerNo, id)) {
throw conflict("当前提单下集装箱号已存在");
}
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(container.getChannelGroupCode(), booking.getChannelCode())
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
@@ -105,7 +107,7 @@ public class ContainerService {
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在");
throw conflict("当前提单下集装箱号已存在");
}
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity(); allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(request.oceanBookingId()); allocation.setRequirementId(request.requirementId()); allocation.setContainerBookingId(id); allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now()); oceanBookings.insert(allocation);
return required(id);
@@ -126,13 +128,15 @@ public class ContainerService {
throw conflict("已进入PDA、证据、单证或不可逆装柜阶段,不能补录真实柜号");
}
String containerNo = Iso6346ContainerNumber.require(requestedContainerNo);
if (repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
if (repository.containerNoExists(container.getOceanBookingId(), containerNo, id)) {
throw conflict("当前提单下集装箱号已存在");
}
try {
if (repository.assignRealContainerNo(id, containerNo, expectedVersion, operatorId, operatorName) != 1) {
throw conflict("真实柜号已被补录或货柜版本发生变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在");
throw conflict("当前提单下集装箱号已存在");
}
return required(id);
}
@@ -463,9 +463,10 @@ public class LoadingListService {
&& java.util.Objects.equals(before.getVoyageNo(), voyageNo)) {
return repository.preloadDetail(before);
}
if (containerNo != null
&& containerRepository.containerNoExists(containerNo, before.getContainerBookingId())) {
throw conflict("集装箱号已存在");
if (containerNo != null && before.getOceanBookingId() != null
&& containerRepository.containerNoExists(before.getOceanBookingId(), containerNo,
before.getContainerBookingId())) {
throw conflict("当前提单下集装箱号已存在");
}
try {
int updated = initialContainerFill
@@ -477,7 +478,7 @@ public class LoadingListService {
throw conflict("预装计划、PDA领取或物理作业状态已变化,请刷新后重试");
}
} catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在");
throw conflict("当前提单下集装箱号已存在");
}
repository.appendHistory(planId, operationId, "UPDATE_PRELOAD_TRANSPORT",
before.getPlanStatus(), before.getPlanStatus(), json(snapshot(before)),
@@ -36,6 +36,7 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_ocean_booking_no (booking_no),
UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no),
KEY idx_ocean_booking_customer (customer_id,status,updated_at,id),
KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at),
CONSTRAINT chk_ocean_booking_customer CHECK (customer_id > 0),
@@ -52,6 +53,18 @@ ALTER TABLE fl_ocean_booking
MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照',
MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照';
SET @ocean_add_bill_unique = (
SELECT IF(COUNT(*) = 0,
'ALTER TABLE fl_ocean_booking ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)',
'SELECT 1')
FROM information_schema.statistics
WHERE table_schema=DATABASE() AND table_name='fl_ocean_booking'
AND index_name='uk_ocean_booking_bill_of_lading'
);
PREPARE ocean_bill_unique_stmt FROM @ocean_add_bill_unique;
EXECUTE ocean_bill_unique_stmt;
DEALLOCATE PREPARE ocean_bill_unique_stmt;
-- Recreate triggers and the current two-state constraint on repeatable initialization.
DROP TRIGGER IF EXISTS trg_ocean_booking_before_update;
DROP TRIGGER IF EXISTS trg_ocean_party_before_insert;
@@ -36,7 +36,7 @@ CREATE TABLE IF NOT EXISTS fl_document_sequence (
CREATE TABLE IF NOT EXISTS fl_container_booking (
id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)',
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '国内唯一权威真实集装箱号;ISO 6346标准格式;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
container_no VARCHAR(64) NULL COMMENT '国内柜次真实集装箱号权威快照;ISO 6346标准格式;同一订舱内唯一,不同提单允许复用;订舱关联可选写入、提柜后可原子补录、预装纠错命令可修改',
cabinet_no VARCHAR(64) NOT NULL COMMENT '平台唯一内部柜号',
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
@@ -92,7 +92,8 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
PRIMARY KEY (id),
UNIQUE KEY uk_container_booking_no (booking_no),
UNIQUE KEY uk_container_booking_cabinet (cabinet_no),
UNIQUE KEY uk_container_booking_container_no (container_no),
UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no),
KEY idx_container_booking_container_no (container_no,updated_at,id),
KEY idx_container_booking_merge (record_status,merged_into_container_booking_id),
KEY idx_container_booking_warehouse (warehouse_id, updated_at, id),
KEY idx_container_booking_voyage (voyage_no, updated_at),
@@ -203,9 +204,17 @@ BEGIN
ALTER TABLE fl_container_booking ADD COLUMN pickup_code_updated_at DATETIME(3) NULL
COMMENT 'PU码最后填写时间' AFTER pickup_code_updated_by_name;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
IF EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='uk_container_booking_container_no') THEN
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (container_no);
ALTER TABLE fl_container_booking DROP INDEX uk_container_booking_container_no;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='uk_container_booking_ocean_container') THEN
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='idx_container_booking_container_no') THEN
ALTER TABLE fl_container_booking ADD KEY idx_container_booking_container_no (container_no,updated_at,id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND index_name='idx_container_booking_merge') THEN
@@ -457,7 +457,7 @@ paths:
/api/platform/v1/containers/{id}/ocean-booking-link:
post:
summary: 将内部模拟柜关联到已发布订舱需求
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别、校验位和全局唯一性。存在跨仓、跨渠道、柜型规格不一致、超量、重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
description: 需要 platform:firstleg:container:edit;请求指定 RELEASED oceanBookingId 和未满配 requirementId,containerNo 可空。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,原子写入订舱、运输和范围快照并创建 allocation;提柜后由拖车链调用柜次服务原子补录真实柜号。若关联请求已提供 containerNo,服务端仍规范化并校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一;不同提单允许复用同一集装箱号。存在跨仓、跨渠道、柜型规格不一致、超量、同提单重复柜号、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 关联后的同一模拟柜 containerBookingId/cabinetNo;列表仍只有这一条 ACTIVE 操作身份}}
@@ -2765,7 +2765,7 @@ components:
properties:
oceanBookingId: {type: integer, format: int64, minimum: 1, description: RELEASED订舱ID}
requirementId: {type: integer, format: int64, minimum: 1, description: 未满配的订舱柜型需求ID}
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和全局唯一性}
containerNo: {type: string, nullable: true, maxLength: 64, description: 可选;港口提柜后可由拖车链另行原子补录。非空时服务端移除格式空白、转大写并校验ISO 6346结构、设备类别、校验位和同一订舱内唯一性}
expectedVersion: {type: integer, minimum: 0}
ContainerFeeAdjustRequest:
type: object
@@ -167,7 +167,7 @@ components:
customerName: {type: string, nullable: true, description: 可选名称提示;有客户ID时由服务端校验并保存客户主数据名称,清空客户时同时置null}
channelCode: {type: string, nullable: true, description: 固定计划物流渠道组编码}
warehouseId: {type: integer, format: int64, nullable: true}
billOfLadingNo: {type: string, nullable: true}
billOfLadingNo: {type: string, nullable: true, description: 全局唯一提单号;草稿未取得时可为空}
shippingCompanyId: {type: integer, format: int64, nullable: true}
shippingCompanyName: {type: string, nullable: true}
carrierCode:
@@ -267,10 +267,11 @@ components:
carrierCodes: {type: array, items: {type: string, enum: [MATSON, SML, ZIM, COSCO, EMC, HEDE, OTHER]}}
LinkCandidate:
type: object
required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName]
required: [oceanBookingId, bookingNo, billOfLadingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName]
properties:
oceanBookingId: {type: string, pattern: '^[0-9]+$'}
bookingNo: {type: string}
billOfLadingNo: {type: string, description: 已发布订舱的全局唯一提单号}
requirementId: {type: string, pattern: '^[0-9]+$'}
containerType: {type: string}
capacityCbm: {type: number}
@@ -106,7 +106,7 @@ class ContainerControllerContractTest {
.contains("未满配 requirementId")
.contains("containerNo 可空")
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
.contains("校验 ISO 6346 结构、设备类别、校验位和全局唯一性")
.contains("校验 ISO 6346 结构、设备类别和校验位,并保证同一订舱内唯一")
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
.doesNotContain("ContainerOceanBookingUnlinkRequest")
.doesNotContain("ContainerTransportUpdateRequest")
@@ -52,6 +52,7 @@ class OceanBookingMapperContractTest {
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class)
.getAnnotation(Select.class).value());
assertThat(sql)
.contains("booking.bill_of_lading_no")
.contains("booking.status='RELEASED'")
.contains("booking.warehouse_id=#{query.warehouseId}")
.contains("booking.channel_code=#{query.channelGroupCode}")
@@ -4,6 +4,8 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@@ -23,6 +25,8 @@ class OceanBookingSqlContractTest {
.contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
.contains("ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)")
.doesNotContain("service_term")
.doesNotContain("freight_term")
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
@@ -33,6 +37,14 @@ class OceanBookingSqlContractTest {
.doesNotContain("ALTER TABLE fl_container_booking");
}
@Test
void billOfLadingNumberIsUniqueInModuleAndUnifiedInitialization() throws Exception {
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
for (String source : new String[] {sql(), unified}) {
assertThat(source).contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)");
}
}
@Test
void enforcesStateIdempotencyAndAuditContracts() throws Exception {
assertThat(sql())
@@ -326,7 +326,7 @@ class OceanBookingServiceTest {
@Test
void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() {
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HQ",
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", "BL-001", 201L, "40HQ",
new BigDecimal("68"), new BigDecimal("19500"), 3, 1, 2,
"客户A", "深圳中转仓", "美森正班");
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
@@ -347,6 +347,18 @@ class OceanBookingServiceTest {
verify(repository, never()).containers(anyLong());
}
@Test
void rejectsDuplicateBillOfLadingNumberBeforeCreatingDraft() {
stubMasterData();
when(repository.billOfLadingNoExists("BL-001", null)).thenReturn(true);
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-bill",
new CreateRequest(0, completeDraft())))
.isInstanceOf(BizException.class).hasMessageContaining("提单号已存在");
verify(repository, never()).insert(any(OceanBookingEntity.class));
}
@Test
void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() {
when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -106,16 +106,18 @@ class ContainerBookingMapperContractTest {
}
@Test
@DisplayName("初始化与统一增量锁定真实柜号权威、唯一和ISO结构约束")
@DisplayName("初始化与统一增量锁定真实柜号权威、订舱内唯一和ISO结构约束")
void databaseContractsFreezeAuthoritativeContainerNumber() throws Exception {
String module = Files.readString(Path.of("src/main/resources/db/phase3-smart-loading-schema.sql"));
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
assertThat(module.toLowerCase())
.contains("国内唯一权威真实集装箱号")
.contains("国内柜次真实集装箱号权威快照")
.contains("同一订舱内唯一,不同提单允许复用")
.contains("提柜后可原子补录");
for (String source : new String[] {module, unified}) {
assertThat(source.toLowerCase())
.contains("unique key uk_container_booking_container_no (container_no)")
.contains("unique key uk_container_booking_ocean_container (ocean_booking_id,container_no)")
.contains("key idx_container_booking_container_no (container_no,updated_at,id)")
.contains("constraint chk_container_booking_iso6346")
.contains("^[a-z]{3}[ujz][0-9]{7}$");
}
@@ -68,7 +68,8 @@ class SmartLoadingSqlContractTest {
.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 DEFAULT NULL")
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
.contains("UNIQUE KEY uk_container_booking_ocean_container (ocean_booking_id,container_no)")
.contains("KEY idx_container_booking_container_no (container_no,updated_at,id)")
.contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)")
.contains("CREATE TRIGGER trg_loading_plan_before_update")
.contains("装柜状态只允许单向推进")
@@ -246,7 +246,7 @@ class ContainerServiceTest {
assertThat(result.getContainerNo()).isNull();
assertThat(result.getCabinetNo()).isEqualTo("柜-001");
verify(repository, never()).containerNoExists(anyString(), anyLong());
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));
@@ -303,12 +303,12 @@ class ContainerServiceTest {
}
@Test
@DisplayName("提柜补录真实柜号仍拒绝全局重复号")
@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("MSCU6639870", 11L)).thenReturn(true);
when(repository.containerNoExists(21L, "MSCU6639870", 11L)).thenReturn(true);
assertThatThrownBy(() -> service.assignRealContainerNo(
11L, "MSCU6639870", 3, 9L, "admin"))
@@ -331,10 +331,10 @@ class ContainerServiceTest {
}
@Test
@DisplayName("首次关联规范化后按全局唯一真实柜号拒绝冲突")
@DisplayName("首次关联规范化后按同一订舱唯一真实柜号拒绝冲突")
void rejectsNormalizedContainerNumberConflict() {
stubLinkContext();
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
when(repository.containerNoExists(21L, "MSCU6639870", 11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "ms cu6639870", 2)))
@@ -276,19 +276,19 @@ class LoadingListServiceTest {
}
@Test
void rejectsGlobalUniqueConflictIncludingConcurrentConstraintRace() {
void rejectsSameBookingContainerConflictIncludingConcurrentConstraintRace() {
AggregateRow before = row("EDITING", 3);
before.setContainerNo("MSCU6639870");
when(repository.aggregate(10L)).thenReturn(before);
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(true);
when(containerRepository.containerNoExists(90L, "CSQU3054383", 30L)).thenReturn(true);
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "unique-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "唯一冲突")))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
when(containerRepository.containerNoExists("CSQU3054383", 30L)).thenReturn(false);
when(containerRepository.containerNoExists(90L, "CSQU3054383", 30L)).thenReturn(false);
when(repository.updateTransport(10L, 3, "CSQU3054383", null, 9L, "operator"))
.thenThrow(new org.springframework.dao.DuplicateKeyException("uk_container_booking_container_no"));
.thenThrow(new org.springframework.dao.DuplicateKeyException("uk_container_booking_ocean_container"));
assertThatThrownBy(() -> service.updateTransport(10L, 100L, 9L, "operator", "race-key",
new TransportUpdateRequest("CSQU3054383", null, 3, "并发唯一冲突")))
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
@@ -961,6 +961,7 @@ class LoadingListServiceTest {
row.setPlanStatus(status);
row.setWarehouseId(100L);
row.setChannelCode("OA_LA_TRUCK");
row.setOceanBookingId(90L);
row.setOutboundConfirmedAt(java.time.LocalDateTime.now());
row.setVersion(version);
return row;