This commit is contained in:
2026-08-17 13:24:48 +08:00
parent ac6cf9c904
commit 6739c83467
43 changed files with 1049 additions and 315 deletions
@@ -4766,6 +4766,9 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订',
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已合并历史',
merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
@@ -4808,13 +4811,58 @@ 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),
KEY idx_container_booking_container (container_no),
UNIQUE KEY uk_container_booking_container_no (container_no),
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),
CONSTRAINT chk_container_booking_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')),
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns;
DELIMITER $$
CREATE PROCEDURE phase3_container_real_unit_columns()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='source_type') THEN
ALTER TABLE fl_container_booking ADD COLUMN source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'
COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜' AFTER cabinet_no;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='record_status') THEN
ALTER TABLE fl_container_booking ADD COLUMN record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'
COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史' AFTER source_type;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='merged_into_container_booking_id') THEN
ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL
COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status;
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_container_no') THEN
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (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_merge') THEN
ALTER TABLE fl_container_booking ADD KEY idx_container_booking_merge (record_status,merged_into_container_booking_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_source') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_source
CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING'));
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_record_status') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
CHECK (record_status IN ('ACTIVE','MERGED'));
END IF;
END$$
DELIMITER ;
CALL phase3_container_real_unit_columns();
DROP PROCEDURE phase3_container_real_unit_columns;
-- 触发器创建前先收敛旧库货柜供应商ID列;历史名称无法反推ID,保持NULL。
DROP PROCEDURE IF EXISTS phase3_container_supplier_snapshot_add_column;
DELIMITER $$
@@ -9067,7 +9115,7 @@ BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱';
END IF;
IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) NOT IN
('CONFIRMED','CHANGE_PENDING') THEN
('CONFIRMED','RELEASED') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次';
END IF;
END$$
@@ -12112,7 +12160,7 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'booking_no
CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'cabinet_no', '平台唯一内部柜号');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'capacity_cbm', '货柜容量m3');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'container_no', '集装箱号;确认前允许修订');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'container_type', '箱型,例如20GP、40GP、40HQ');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'container_type', '平台固定柜型:20GP、40GP、40HC、40NR、45HC');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'created_at', '创建时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'created_by', '创建人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_container_booking', 'created_by_name', '创建人名称快照');
@@ -100,9 +100,9 @@ public final class ContainerDtos {
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将内部模拟柜关联到真实订舱箱型分配")
public record OceanBookingLinkRequest(@NotNull @Positive Long oceanBookingId,
@NotNull @Positive Long requirementId, @NotNull @PositiveOrZero Integer expectedVersion) {}
@Schema(name = "ContainerOceanBookingLinkRequest", description = "将内部模拟柜原地合并为指定真实订舱柜")
public record OceanBookingLinkRequest(@NotNull @Positive Long realContainerBookingId,
@NotNull @PositiveOrZero Integer expectedVersion) {}
@Schema(name = "ContainerOceanBookingUnlinkRequest", description = "解除空模拟柜的真实订舱关联")
public record OceanBookingUnlinkRequest(@NotNull @PositiveOrZero Integer expectedVersion,
@@ -20,6 +20,9 @@ public class ContainerBookingEntity {
private String bookingNo;
private String containerNo;
private String cabinetNo;
private String sourceType;
private String recordStatus;
private Long mergedIntoContainerBookingId;
private String channelCode;
private String channelName;
private Long oceanBookingId;
@@ -21,7 +21,8 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
) THEN 'WAIT_LOADING'
ELSE p.plan_status END AS plan_status
FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
WHERE (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
WHERE b.record_status = 'ACTIVE'
AND (#{query.containerNo} IS NULL OR b.container_no = #{query.containerNo})
AND (#{query.cabinetNo} IS NULL OR b.cabinet_no = #{query.cabinetNo})
AND (#{query.billOfLadingNo} IS NULL OR b.bill_of_lading_no = #{query.billOfLadingNo})
AND (#{query.customerId} IS NULL OR b.customer_id = #{query.customerId} OR EXISTS (
@@ -68,13 +69,90 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
) THEN 'WAIT_LOADING'
ELSE p.plan_status END AS plan_status
FROM fl_container_booking b LEFT JOIN fl_loading_plan p ON p.container_booking_id = b.id
WHERE b.id = #{id}
WHERE b.id = #{id} AND b.record_status = 'ACTIVE'
""")
ContainerBookingEntity selectBooking(@Param("id") long id);
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} FOR UPDATE")
ContainerBookingEntity selectBookingForUpdate(@Param("id") long id);
@Select("SELECT * FROM fl_container_booking WHERE id=#{id} AND record_status='ACTIVE' FOR UPDATE")
ContainerBookingEntity selectActiveBookingForUpdate(@Param("id") long id);
@Select("SELECT * FROM fl_container_booking WHERE merged_into_container_booking_id=#{targetId} AND record_status='MERGED' FOR UPDATE")
ContainerBookingEntity selectMergedSourceForUpdate(@Param("targetId") long targetId);
@Update("""
UPDATE fl_container_booking SET record_status='MERGED',
merged_into_container_booking_id=#{targetId}, container_no=NULL,
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{sourceId} AND source_type='OCEAN_BOOKING' AND record_status='ACTIVE'
AND merged_into_container_booking_id IS NULL AND container_no IS NOT NULL
""")
int consumeRealSource(@Param("sourceId") long sourceId, @Param("targetId") long targetId,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking target
JOIN fl_container_booking source ON source.id=#{sourceId}
SET target.container_no=#{containerNo}, target.ocean_booking_id=source.ocean_booking_id,
target.ocean_booking_requirement_id=source.ocean_booking_requirement_id,
target.customer_id=source.customer_id, target.customer_name=source.customer_name,
target.bill_of_lading_no=source.bill_of_lading_no,
target.vessel_name=source.vessel_name, target.voyage_no=source.voyage_no,
target.shipping_company_id=source.shipping_company_id,
target.shipping_company_name=source.shipping_company_name,
target.departure_port_code=source.departure_port_code,
target.departure_port_name=source.departure_port_name,
target.destination_port_code=source.destination_port_code,
target.destination_port_name=source.destination_port_name,
target.estimated_sailing_at=source.estimated_sailing_at,
target.estimated_port_arrival_at=source.estimated_port_arrival_at,
target.updated_by=#{operatorId}, target.updated_by_name=#{operatorName},
target.updated_at=CURRENT_TIMESTAMP(3), target.version=target.version+1
WHERE target.id=#{targetId} AND target.version=#{expectedVersion}
AND target.source_type='SIMULATION' AND target.record_status='ACTIVE'
AND target.ocean_booking_id IS NULL AND source.record_status='MERGED'
AND source.merged_into_container_booking_id=target.id
""")
int mergeRealSource(@Param("targetId") long targetId, @Param("sourceId") long sourceId,
@Param("containerNo") String containerNo,
@Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET container_no=NULL, ocean_booking_id=NULL,
ocean_booking_requirement_id=NULL, customer_id=NULL, customer_name=NULL,
bill_of_lading_no=NULL, vessel_name=NULL, voyage_no=NULL,
shipping_company_id=NULL, shipping_company_name=NULL,
departure_port_code=NULL, departure_port_name=NULL,
destination_port_code=NULL, destination_port_name=NULL,
estimated_sailing_at=NULL, estimated_port_arrival_at=NULL,
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{targetId} AND version=#{expectedVersion} AND source_type='SIMULATION'
AND record_status='ACTIVE' AND ocean_booking_id IS NOT NULL
""")
int clearMergedLink(@Param("targetId") long targetId, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET record_status='ACTIVE',
merged_into_container_booking_id=NULL, container_no=#{containerNo},
updated_by=#{operatorId}, updated_by_name=#{operatorName},
updated_at=CURRENT_TIMESTAMP(3), version=version+1
WHERE id=#{sourceId} AND source_type='OCEAN_BOOKING' AND record_status='MERGED'
AND merged_into_container_booking_id=#{targetId} AND container_no IS NULL
""")
int restoreMergedSource(@Param("sourceId") long sourceId, @Param("targetId") long targetId,
@Param("containerNo") String containerNo,
@Param("operatorId") long operatorId,
@Param("operatorName") String operatorName);
@Update("""
UPDATE fl_container_booking SET
container_no=#{value.containerNo}, cabinet_no=#{value.cabinetNo},
@@ -99,10 +177,7 @@ public interface ContainerBookingMapper extends BaseMapper<ContainerBookingEntit
""")
int updateDraft(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("UPDATE fl_container_booking SET ocean_booking_id=#{value.oceanBookingId},ocean_booking_requirement_id=#{value.oceanBookingRequirementId},customer_id=#{value.customerId},customer_name=#{value.customerName},updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{value.id} AND version=#{expectedVersion} AND ocean_booking_id IS NULL")
int updateLink(@Param("value") ContainerBookingEntity value, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Update("UPDATE fl_container_booking SET ocean_booking_id=NULL,ocean_booking_requirement_id=NULL,customer_id=NULL,customer_name=NULL,updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 WHERE id=#{id} AND version=#{expectedVersion}")
int clearLink(@Param("id") long id, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName);
@Select("SELECT COUNT(*) FROM fl_loading_plan WHERE container_booking_id=#{id}") int hasLoadingPlan(@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") int hasActiveLoadingItems(@Param("id") long id);
@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);
@@ -24,7 +24,8 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
JOIN fl_loading_plan plan ON plan.id = task.loading_plan_id
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
WHERE booking.container_no = #{containerNo} AND task.task_status = 'LOADING'
WHERE booking.container_no = #{containerNo} AND booking.record_status = 'ACTIVE'
AND task.task_status = 'LOADING'
AND booking.warehouse_id = #{warehouseId}
LIMIT 1
""")
@@ -33,12 +34,14 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND booking.cabinet_no=#{cabinetNo} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
+ "AND booking.cabinet_no=#{cabinetNo} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
LoadingPickTaskEntity selectLoadingByCabinet(@Param("cabinetNo") String cabinetNo, @Param("warehouseId") long warehouseId);
@Select("SELECT task.* FROM fl_loading_pick_task task JOIN fl_loading_plan plan ON plan.id=task.loading_plan_id "
+ "JOIN fl_container_booking booking ON booking.id=plan.container_booking_id "
+ "WHERE task.task_status='LOADING' AND task.loading_plan_id=#{loadingPlanId} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
+ "WHERE task.task_status='LOADING' AND booking.record_status='ACTIVE' "
+ "AND task.loading_plan_id=#{loadingPlanId} AND booking.warehouse_id=#{warehouseId} LIMIT 1")
LoadingPickTaskEntity selectLoadingByPlan(@Param("loadingPlanId") long loadingPlanId, @Param("warehouseId") long warehouseId);
@Select("""
@@ -56,7 +59,7 @@ public interface LoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity>
JOIN fl_container_booking booking ON booking.id = plan.container_booking_id
JOIN fl_loading_pick_item pick_item ON pick_item.pick_task_id = task.id
LEFT JOIN fl_loading_pick_assignment assignment ON assignment.pick_task_id = task.id
WHERE task.task_status = 'LOADING'
WHERE task.task_status = 'LOADING' AND booking.record_status = 'ACTIVE'
AND (assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId})
<if test="keyword != null and keyword != ''">
AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%')
@@ -1,7 +1,5 @@
package com.baiyue.firstleg.oceanbooking.dto;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingPartyEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity;
@@ -33,17 +31,16 @@ public final class OceanBookingDtos {
@Size(max = 128) String contactName,
@Size(max = 64) String contactPhone) {}
public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType,
@NotBlank @Size(max = 32) String containerKind,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg,
@NotNull @Positive Integer quantity) {}
public record ContainerRequest(@NotBlank @Size(max = 64) String containerNo,
@NotBlank @Size(max = 32) String containerType,
@NotBlank @Size(max = 32) String containerKind,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
public record DraftData(@Positive Long customerId,
@Size(max = 128) String customerName,
@NotBlank @Size(max = 64) String channelCode,
@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 128) String warehouseName,
@NotBlank @Size(max = 128) String billOfLadingNo,
@Positive Long shippingCompanyId,
@Size(max = 128) String shippingCompanyName,
@@ -62,7 +59,7 @@ public final class OceanBookingDtos {
@NotNull LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark,
@Valid @NotEmpty @Size(max = 3) List<PartyRequest> parties,
@Valid @NotEmpty @Size(max = 20) List<ContainerRequirementRequest> containerRequirements) {}
@Valid @NotEmpty @Size(max = 20) List<ContainerRequest> containers) {}
public record CreateRequest(@NotNull @Min(0) Integer expectedVersion,
@Valid @NotNull DraftData data) {}
@@ -81,10 +78,15 @@ public final class OceanBookingDtos {
LocalDateTime estimatedSailingAt,
LocalDateTime estimatedArrivalAt,
@NotBlank @Size(max = 1000) String reason) {}
public record RealContainer(Long containerBookingId, String cabinetNo, String containerNo,
Long requirementId, Long allocationId, String containerType,
String containerKind, BigDecimal capacityCbm, BigDecimal maxWeightKg,
Long warehouseId, String warehouseName, String channelCode,
String channelName, Long customerId, String customerName) {}
public record Detail(OceanBookingEntity oceanBooking,
List<OceanBookingPartyEntity> parties,
List<OceanBookingContainerRequirementEntity> containerRequirements,
List<OceanBookingContainerAllocationEntity> containerAllocations,
List<RealContainer> containers,
List<OceanBookingStatusHistoryEntity> histories) {}
public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName,
@@ -2,4 +2,9 @@ package com.baiyue.firstleg.oceanbooking.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper<OceanBookingContainerAllocationEntity> {}
import org.apache.ibatis.annotations.Param;
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);
}
@@ -4,14 +4,19 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.RealContainer;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
import com.baiyue.firstleg.oceanbooking.entity.*;
import com.baiyue.firstleg.oceanbooking.mapper.*;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.ContainerRepository;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Repository
public class OceanBookingRepository {
@@ -49,6 +54,22 @@ 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 List<RealContainer> containers(long id) {
Map<Long, OceanBookingContainerRequirementEntity> requirementById = requirements(id).stream()
.collect(Collectors.toMap(OceanBookingContainerRequirementEntity::getId, Function.identity()));
return allocations(id).stream().map(allocation -> {
OceanBookingContainerRequirementEntity requirement = requirementById.get(allocation.getRequirementId());
ContainerBookingEntity container = containerBookings.find(allocation.getContainerBookingId());
if (requirement == null || container == null) {
throw new IllegalStateException("订舱真实柜聚合关系不完整");
}
return new RealContainer(container.getId(), container.getCabinetNo(), container.getContainerNo(),
requirement.getId(), allocation.getId(), requirement.getContainerType(),
requirement.getContainerKind(), requirement.getCapacityCbm(), requirement.getMaxWeightKg(),
container.getWarehouseId(), container.getWarehouseName(), container.getChannelCode(),
container.getChannelName(), container.getCustomerId(), container.getCustomerName());
}).toList();
}
public OceanBookingContainerAllocationEntity allocationByContainer(long containerBookingId) {
return allocations.selectOne(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>()
.eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId));
@@ -61,7 +82,14 @@ public class OceanBookingRepository {
return containerBookings.syncReleasedTransport(id, request.billOfLadingNo().trim(), request.vesselName().trim(),
request.voyageNo().trim(), request.estimatedSailingAt(), operatorId, operatorName);
}
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) {
return allocations.reassign(sourceId, targetId);
}
public void deleteAllocation(long containerBookingId) { allocations.delete(new LambdaQueryWrapper<OceanBookingContainerAllocationEntity>().eq(OceanBookingContainerAllocationEntity::getContainerBookingId, containerBookingId)); }
public void insert(OceanBookingStatusHistoryEntity value) { histories.insert(value); }
public boolean claimOperation(Long id, long operatorId, String action, String key, String hash) { return operations.claim(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(), id, operatorId, action, key, hash) == 1; }
@@ -2,8 +2,11 @@ package com.baiyue.firstleg.oceanbooking.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus;
import com.baiyue.firstleg.domain.ContainerSpecificationCatalog;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
@@ -18,6 +21,8 @@ import com.fasterxml.jackson.core.JsonProcessingException;
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;
@@ -41,6 +46,9 @@ public class OceanBookingService {
@Resource private ObjectMapper objectMapper;
@Resource private LoadingPickService loadingPickService;
@Resource private WarehouseSupplierSnapshotService suppliers;
@Resource private WarehouseMasterDataClient warehouses;
@Value("${baiyue.security.internal-service-token:}") private String internalToken;
@Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId;
public PageResult<OceanBookingEntity> page(Query query) {
String status = trim(query.status());
@@ -75,24 +83,34 @@ public class OceanBookingService {
String hash = hash(action, null, request);
Detail replay = claimOrReplay(null, operatorId, action, key, hash);
if (replay != null) return replay;
ChannelOption channel = requireActiveChannel(request.data().warehouseId(), request.data().channelCode());
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.data().warehouseId());
ChannelOption channel = requireActiveChannel(warehouse.id(), request.data().channelCode());
var bookingAgent = suppliers.booking(request.data().bookingAgentId(), request.data().bookingAgentName());
request.data().containers().forEach(container -> {
if (repository.containerNoExists(container.containerNo().trim())) {
throw conflict("集装箱号已存在:" + container.containerNo().trim());
}
});
OceanBookingEntity value = new OceanBookingEntity();
value.setId(IdWorker.getId());
value.setBookingNo("OB" + value.getId());
// Child-row triggers still require DRAFT while the aggregate is assembled.
// The status transition is in the same transaction, so callers only observe CONFIRMED.
value.setStatus(OceanBookingStatus.DRAFT.name());
copy(value, request.data(), channel.channelName(), bookingAgent == null ? null : bookingAgent.name());
copy(value, request.data(), warehouse.warehouseName(), channel.channelName(),
bookingAgent == null ? null : bookingAgent.name());
value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0);
repository.insert(value);
repository.replaceParties(value.getId(), parties(value.getId(), request.data().parties()));
repository.replaceRequirements(value.getId(), requirements(value.getId(), request.data().containerRequirements()));
List<OceanBookingContainerRequirementEntity> requirements = requirements(
value.getId(), request.data().containers());
repository.replaceRequirements(value.getId(), requirements);
if (repository.transition(value.getId(), OceanBookingStatus.DRAFT.name(),
OceanBookingStatus.CONFIRMED.name(), null, 0, operatorId, operatorName) != 1) {
throw concurrent();
}
createRealContainers(value, requirements, request.data().containers(), operatorId, operatorName);
OceanBookingEntity confirmed = required(value.getId());
addHistory(value.getId(), OceanBookingStatus.DRAFT.name(), OceanBookingStatus.CONFIRMED.name(), action,
null, value, confirmed, operatorId, operatorName);
@@ -196,7 +214,7 @@ public class OceanBookingService {
history.setOperatorId(operatorId); history.setOperatorName(operatorName); repository.insert(history);
}
private Detail detail(OceanBookingEntity value) { return new Detail(value, repository.parties(value.getId()), repository.requirements(value.getId()), repository.allocations(value.getId()), repository.histories(value.getId())); }
private Detail detail(OceanBookingEntity value) { return new Detail(value, repository.parties(value.getId()), repository.containers(value.getId()), repository.histories(value.getId())); }
private OceanBookingEntity required(long id) { OceanBookingEntity value = repository.find(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); return value; }
private OceanBookingEntity requiredVersion(long id, int version) { OceanBookingEntity value = required(id); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; }
private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); }
@@ -219,6 +237,13 @@ public class OceanBookingService {
if (!PARTY_TYPES.contains(type) || !types.add(type)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY");
}
if (!types.equals(PARTY_TYPES)) throw badRequest("订舱方必须各包含一条SHIPPER、CONSIGNEE和NOTIFY_PARTY");
Set<String> containerNos = new HashSet<>();
for (ContainerRequest container : data.containers()) {
String containerNo = container.containerNo().trim();
if (!containerNos.add(containerNo)) throw badRequest("同一订舱内集装箱号不能重复");
ContainerSpecificationCatalog.requireMatch(container.containerType(),
container.capacityCbm(), container.maxWeightKg());
}
}
private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); }
private static void validateRegisteredTransport(OceanBookingEntity booking, ReleaseRequest request) {
@@ -252,10 +277,11 @@ public class OceanBookingService {
return value.trim();
}
private static void copy(OceanBookingEntity v, DraftData d, String resolvedChannelName, String resolvedBookingAgentName) {
private static void copy(OceanBookingEntity v, DraftData d, String resolvedWarehouseName,
String resolvedChannelName, String resolvedBookingAgentName) {
v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName()));
v.setChannelCode(d.channelCode().trim()); v.setChannelName(resolvedChannelName);
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(d.warehouseName().trim());
v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolvedWarehouseName);
v.setBillOfLadingNo(d.billOfLadingNo().trim());
v.setShippingCompanyId(d.shippingCompanyId()); v.setShippingCompanyName(trim(d.shippingCompanyName()));
v.setBookingAgentId(d.bookingAgentId()); v.setBookingAgentName(trim(resolvedBookingAgentName));
@@ -268,7 +294,75 @@ public class OceanBookingService {
}
private static List<OceanBookingPartyEntity> parties(long id, List<PartyRequest> requests) { return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequirementRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); }
private static List<OceanBookingContainerRequirementEntity> requirements(long id, List<ContainerRequest> requests) { int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(1); v.setSortNo(sort[0]++); return v; }).toList(); }
private void createRealContainers(OceanBookingEntity booking,
List<OceanBookingContainerRequirementEntity> requirements,
List<ContainerRequest> requests, long operatorId,
String operatorName) {
for (int index = 0; index < requests.size(); index++) {
ContainerRequest request = requests.get(index);
OceanBookingContainerRequirementEntity requirement = requirements.get(index);
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(IdWorker.getId()); container.setBookingNo("CB" + container.getId());
container.setCabinetNo("CAB-" + container.getId());
container.setContainerNo(request.containerNo().trim());
container.setSourceType("OCEAN_BOOKING"); container.setRecordStatus("ACTIVE");
container.setChannelCode(booking.getChannelCode()); container.setChannelName(booking.getChannelName());
container.setOceanBookingId(booking.getId());
container.setOceanBookingRequirementId(requirement.getId());
container.setCustomerId(booking.getCustomerId()); container.setCustomerName(booking.getCustomerName());
container.setWarehouseId(booking.getWarehouseId()); container.setWarehouseName(booking.getWarehouseName());
container.setBillOfLadingNo(booking.getBillOfLadingNo());
container.setVesselName(booking.getVesselName()); container.setVoyageNo(booking.getVoyageNo());
container.setContainerType(requirement.getContainerType());
container.setShippingCompanyId(booking.getShippingCompanyId());
container.setShippingCompanyName(booking.getShippingCompanyName());
container.setDeparturePortCode(booking.getDeparturePortCode());
container.setDeparturePortName(booking.getDeparturePortName());
container.setDestinationPortCode(booking.getDestinationPortCode());
container.setDestinationPortName(booking.getDestinationPortName());
container.setCapacityCbm(requirement.getCapacityCbm());
container.setMaxWeightKg(requirement.getMaxWeightKg());
container.setEstimatedSailingAt(booking.getEstimatedSailingAt());
container.setEstimatedPortArrivalAt(booking.getEstimatedArrivalAt());
container.setCreatedBy(operatorId); container.setCreatedByName(operatorName);
container.setUpdatedBy(operatorId); container.setUpdatedByName(operatorName); container.setVersion(0);
try { repository.insertContainer(container); }
catch (DuplicateKeyException exception) {
throw conflict("集装箱号已存在:" + request.containerNo().trim());
}
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId());
allocation.setRequirementId(requirement.getId());
allocation.setContainerBookingId(container.getId()); allocation.setAllocatedBy(operatorId);
allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now());
repository.insert(allocation);
}
}
private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) {
try {
ApiResponse<WarehouseMasterDataClient.Detail> response = warehouses.active(
internalToken, publicWarehouseTenantId, warehouseId);
if (response == null || !response.successful() || response.data() == null
|| response.data().warehouse() == null) {
throw new BizException(ResultCode.NOT_FOUND, "国内中转仓不存在或已停用");
}
WarehouseMasterDataClient.Warehouse warehouse = response.data().warehouse();
if (!Long.valueOf(warehouseId).equals(warehouse.id())
|| !Long.valueOf(publicWarehouseTenantId).equals(warehouse.tenantId())
|| !"ACTIVE".equals(warehouse.status()) || !"DOMESTIC".equals(warehouse.warehouseType())
|| !StringUtils.hasText(warehouse.warehouseName())) {
throw new BizException(ResultCode.VALIDATION_FAILED, "订舱只能选择启用中的国内中转仓");
}
return warehouse;
} catch (BizException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "国内中转仓服务暂时不可用");
}
}
private String hash(String action, Long id, Object request) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest((action + '|' + id + '|' + objectMapper.writeValueAsString(request)).getBytes(StandardCharsets.UTF_8))); } catch (Exception ex) { throw new IllegalStateException("无法计算订舱请求摘要", ex); } }
private String json(Object value) { if (value == null) return null; try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException ex) { throw new IllegalStateException("无法保存订舱历史快照", ex); } }
private static OceanBookingStatus parseStatus(String status) { try { return OceanBookingStatus.valueOf(status); } catch (RuntimeException ex) { throw badRequest("订舱状态无效"); } }
@@ -17,6 +17,10 @@ public class ContainerRepository {
}
public ContainerBookingEntity find(long id) { return mapper.selectBooking(id); }
public ContainerBookingEntity findForUpdate(long id) { return mapper.selectBookingForUpdate(id); }
public ContainerBookingEntity findActiveForUpdate(long id) { return mapper.selectActiveBookingForUpdate(id); }
public ContainerBookingEntity mergedSourceForUpdate(long targetId) {
return mapper.selectMergedSourceForUpdate(targetId);
}
public boolean containerNoExists(String no, Long excludedId) {
if (no == null) return false;
return mapper.selectCount(new LambdaQueryWrapper<ContainerBookingEntity>()
@@ -29,11 +33,24 @@ public class ContainerRepository {
.ne(excludedId != null, ContainerBookingEntity::getId, excludedId)) > 0;
}
public void insert(ContainerBookingEntity value) { mapper.insert(value); }
public int consumeRealSource(long sourceId, long targetId, long operatorId, String operatorName) {
return mapper.consumeRealSource(sourceId, targetId, operatorId, operatorName);
}
public int mergeRealSource(long targetId, long sourceId, String containerNo, int version,
long operatorId, String operatorName) {
return mapper.mergeRealSource(targetId, sourceId, containerNo, version, operatorId, operatorName);
}
public int clearMergedLink(long targetId, int version, long operatorId, String operatorName) {
return mapper.clearMergedLink(targetId, version, operatorId, operatorName);
}
public int restoreMergedSource(long sourceId, long targetId, String containerNo,
long operatorId, String operatorName) {
return mapper.restoreMergedSource(sourceId, targetId, containerNo, operatorId, operatorName);
}
public int updateDraft(ContainerBookingEntity value, int version, long operatorId, String operatorName) {
return mapper.updateDraft(value, version, operatorId, operatorName);
}
public int updateLink(ContainerBookingEntity value, int version, long operatorId, String operatorName) { return mapper.updateLink(value, version, operatorId, operatorName); }
public int clearLink(long id, int version, long operatorId, String operatorName) { return mapper.clearLink(id, version, operatorId, operatorName); }
public boolean hasLoadingPlan(long id) { return mapper.hasLoadingPlan(id) > 0; }
public boolean hasActiveLoadingItems(long id) { return mapper.hasActiveLoadingItems(id) > 0; }
public boolean hasPickTask(long id) { return mapper.hasPickTask(id) > 0; }
public boolean hasEvidence(long id) { return mapper.hasEvidence(id) > 0; }
@@ -75,7 +75,7 @@ public class ContainerOverviewService {
if (!requestHash.equals(replay.requestHash())) throw conflict("Idempotency-Key已用于不同异常操作");
return;
}
ContainerBookingEntity booking = containers.findForUpdate(id);
ContainerBookingEntity booking = containers.findActiveForUpdate(id);
if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "货柜不存在");
if (!request.expectedVersion().equals(booking.getVersion())) throw conflict("货柜版本已变化,请刷新后重试");
replay = mapper.exceptionOperation(id, operatorId, operationId);
@@ -66,6 +66,7 @@ public class ContainerService {
.orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择"));
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
value.setBookingNo("CB" + value.getId()); value.setCabinetNo("CAB-" + value.getId());
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName());
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm());
@@ -78,10 +79,27 @@ public class ContainerService {
public ContainerBookingEntity linkOceanBooking(long id, long operatorId, String operatorName, OceanBookingLinkRequest request) {
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireFirstLinkMutable(container);
if (container.getOceanBookingId() != null) throw conflict("模拟柜已关联真实订舱柜");
OceanBookingEntity booking = oceanBookings.find(request.oceanBookingId());
ContainerBookingEntity source = repository.findForUpdate(request.realContainerBookingId());
if (source == null || !"OCEAN_BOOKING".equals(source.getSourceType())
|| !"ACTIVE".equals(source.getRecordStatus()) || source.getMergedIntoContainerBookingId() != null
|| source.getContainerNo() == null || source.getOceanBookingId() == null
|| source.getOceanBookingRequirementId() == null) {
throw conflict("真实订舱柜不存在、已被消费或不可关联");
}
if (repository.hasLoadingPlan(source.getId()) || repository.hasPickTask(source.getId())
|| repository.hasEvidence(source.getId()) || repository.hasDocuments(source.getId())) {
throw conflict("真实订舱柜已有装柜计划、PDA、证据或单证事实,不能被模拟柜消费");
}
OceanBookingEntity booking = oceanBookings.find(source.getOceanBookingId());
if (booking == null || !("CONFIRMED".equals(booking.getStatus()) || "RELEASED".equals(booking.getStatus()))) throw conflict("只能关联已确认的真实订舱柜");
OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(booking.getId(), request.requirementId());
OceanBookingContainerRequirementEntity requirement = oceanBookings.requirement(
booking.getId(), source.getOceanBookingRequirementId());
if (requirement == null) throw new BizException(ResultCode.NOT_FOUND, "真实订舱柜箱型分配不存在");
var allocation = oceanBookings.allocationByContainer(source.getId());
if (allocation == null || !java.util.Objects.equals(allocation.getOceanBookingId(), booking.getId())
|| !java.util.Objects.equals(allocation.getRequirementId(), requirement.getId())) {
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())
@@ -89,17 +107,21 @@ public class ContainerService {
|| container.getMaxWeightKg() == null || requirement.getMaxWeightKg() == null
|| container.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0
|| container.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) throw conflict("模拟柜与真实订舱柜仓库、渠道、箱型或容量不一致");
long allocated = oceanBookings.allocations(booking.getId()).stream()
.filter(a -> request.requirementId().equals(a.getRequirementId()) && a.getContainerBookingId() != null).count();
if (requirement.getQuantity() == null || requirement.getQuantity() <= 0 || allocated >= requirement.getQuantity()) {
throw conflict("真实订舱柜箱型分配已关联完毕");
if (!java.util.Objects.equals(source.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(source.getChannelCode(), booking.getChannelCode())
|| !java.util.Objects.equals(source.getContainerType(), requirement.getContainerType())
|| source.getCapacityCbm() == null || source.getMaxWeightKg() == null
|| source.getCapacityCbm().compareTo(requirement.getCapacityCbm()) != 0
|| source.getMaxWeightKg().compareTo(requirement.getMaxWeightKg()) != 0) {
throw conflict("真实订舱柜仓库、渠道或规格快照不一致");
}
if (booking.getCustomerId() != null && repository.hasMismatchedCustomerItems(id, booking.getCustomerId())) throw conflict("模拟柜已排货客户与真实订舱客户范围不一致");
container.setOceanBookingId(booking.getId()); container.setOceanBookingRequirementId(requirement.getId());
container.setCustomerId(booking.getCustomerId()); container.setCustomerName(booking.getCustomerName());
if (repository.updateLink(container, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试");
com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity allocation = new com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity();
allocation.setId(IdWorker.getId()); allocation.setOceanBookingId(booking.getId()); allocation.setRequirementId(requirement.getId()); allocation.setContainerBookingId(id); allocation.setAllocatedBy(operatorId); allocation.setAllocatedByName(operatorName); allocation.setAllocatedAt(LocalDateTime.now()); oceanBookings.insert(allocation);
if (repository.consumeRealSource(source.getId(), id, operatorId, operatorName) != 1
|| repository.mergeRealSource(id, source.getId(), source.getContainerNo(),
request.expectedVersion(), operatorId, operatorName) != 1
|| oceanBookings.reassignAllocation(source.getId(), id) != 1) {
throw conflict("真实订舱柜已被消费或货柜版本发生变化,请刷新后重试");
}
return required(id);
}
@@ -107,8 +129,15 @@ public class ContainerService {
public ContainerBookingEntity unlinkOceanBooking(long id, long operatorId, String operatorName, OceanBookingUnlinkRequest request) {
ContainerBookingEntity container = required(id); requireVersion(container, request.expectedVersion()); requireUnlinkMutable(container);
if (container.getOceanBookingId() == null) throw conflict("模拟柜尚未关联真实订舱柜");
oceanBookings.deleteAllocation(id);
if (repository.clearLink(id, request.expectedVersion(), operatorId, operatorName) != 1) throw conflict("货柜状态或版本已变化,请刷新后重试"); return required(id);
ContainerBookingEntity source = repository.mergedSourceForUpdate(id);
if (source == null) throw conflict("真实订舱柜合并来源不存在,不能解除关联");
String containerNo = container.getContainerNo();
if (repository.clearMergedLink(id, request.expectedVersion(), operatorId, operatorName) != 1
|| repository.restoreMergedSource(source.getId(), id, containerNo, operatorId, operatorName) != 1
|| oceanBookings.reassignAllocation(id, source.getId()) != 1) {
throw conflict("货柜状态或版本已变化,请刷新后重试");
}
return required(id);
}
private void requireFirstLinkMutable(ContainerBookingEntity container) {
@@ -292,7 +292,7 @@ BEGIN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='箱型需求不属于当前订舱';
END IF;
IF (SELECT status FROM fl_ocean_booking WHERE id=NEW.ocean_booking_id) NOT IN
('CONFIRMED','CHANGE_PENDING') THEN
('CONFIRMED','RELEASED') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='当前订舱状态不可分配柜次';
END IF;
END$$
@@ -31,6 +31,9 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
booking_no VARCHAR(40) NOT NULL COMMENT '平台唯一货柜预订单号',
container_no VARCHAR(64) NULL COMMENT '集装箱号;确认前允许修订',
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已合并历史',
merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
@@ -72,14 +75,59 @@ 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),
KEY idx_container_booking_container (container_no),
UNIQUE KEY uk_container_booking_container_no (container_no),
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),
KEY idx_container_booking_transit_summary (actual_sailing_at, actual_warehouse_arrival_at, ocean_booking_id, container_type),
CONSTRAINT chk_container_booking_source CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING')),
CONSTRAINT chk_container_booking_record_status CHECK (record_status IN ('ACTIVE','MERGED')),
CONSTRAINT chk_container_booking_capacity CHECK (capacity_cbm IS NULL OR capacity_cbm > 0),
CONSTRAINT chk_container_booking_weight CHECK (max_weight_kg IS NULL OR max_weight_kg > 0)
) ENGINE=InnoDB COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划';
DROP PROCEDURE IF EXISTS phase3_container_real_unit_columns;
DELIMITER $$
CREATE PROCEDURE phase3_container_real_unit_columns()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='source_type') THEN
ALTER TABLE fl_container_booking ADD COLUMN source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION'
COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜' AFTER cabinet_no;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='record_status') THEN
ALTER TABLE fl_container_booking ADD COLUMN record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE'
COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史' AFTER source_type;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE()
AND table_name='fl_container_booking' AND column_name='merged_into_container_booking_id') THEN
ALTER TABLE fl_container_booking ADD COLUMN merged_into_container_booking_id BIGINT UNSIGNED NULL
COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID' AFTER record_status;
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_container_no') THEN
ALTER TABLE fl_container_booking ADD UNIQUE KEY uk_container_booking_container_no (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_merge') THEN
ALTER TABLE fl_container_booking ADD KEY idx_container_booking_merge (record_status,merged_into_container_booking_id);
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_source') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_source
CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING'));
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.table_constraints WHERE constraint_schema=DATABASE()
AND table_name='fl_container_booking' AND constraint_name='chk_container_booking_record_status') THEN
ALTER TABLE fl_container_booking ADD CONSTRAINT chk_container_booking_record_status
CHECK (record_status IN ('ACTIVE','MERGED'));
END IF;
END$$
DELIMITER ;
CALL phase3_container_real_unit_columns();
DROP PROCEDURE phase3_container_real_unit_columns;
-- 触发器创建前先收敛旧库货柜供应商ID列;历史名称无法反推ID,保持NULL。
DROP PROCEDURE IF EXISTS phase3_container_supplier_snapshot_add_column;
DELIMITER $$
@@ -250,11 +250,11 @@ paths:
'403': {description: 无新增按钮权限}
/api/platform/v1/containers/{id}/ocean-booking-link:
post:
summary: 将内部模拟柜关联到真实已确认订舱柜
description: 需要 platform:firstleg:container:edit;首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items;存在 PDA、有效装柜证据、业务单证或已进入 LOADING/COMPLETED 等不可逆事实时拒绝。
summary: 将内部模拟柜原地合并为指定真实订舱柜
description: 需要 platform:firstleg:container:edit;请求指定未消费且尚无装柜计划/PDA/证据/单证的 realContainerBookingId。首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜,保留原 containerBookingId/cabinetNo/loadingPlanId/items,将真实柜 containerNo、订舱、运输和范围快照原子复制到模拟柜并把 allocation 改指模拟柜;原真实柜置 MERGED 且不再出现在正常货柜列表。存在跨仓、跨渠道、柜型规格不一致、PDA、有效装柜证据、业务单证或 LOADING/COMPLETED 等事实时拒绝。
parameters: [{$ref: '#/components/parameters/ContainerBookingId'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ContainerOceanBookingLinkRequest'}}}}
responses: {'200': {description: 关联后的同一 containerBookingId/cabinetNo}}
responses: {'200': {description: 合并后的同一模拟柜 containerBookingId/cabinetNo;列表仅保留这一条 ACTIVE 操作身份}}
delete:
summary: 解除内部模拟柜的真实订舱关联
description: 需要 platform:firstleg:container:edit;解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜;任何排货、PDA、有效装柜证据或业务单证事实均拒绝。
@@ -1212,13 +1212,16 @@ components:
pages: {type: string, pattern: '^[0-9]+$'}
ContainerBooking:
type: object
required: [id, bookingNo, cabinetNo, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, version]
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, version]
properties:
id: {type: string, pattern: '^[1-9][0-9]*$'}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
bookingNo: {type: string, maxLength: 40}
containerNo: {type: string, maxLength: 64, nullable: true}
cabinetNo: {type: string, maxLength: 64}
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回}
mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空}
channelCode: {type: string, maxLength: 64, nullable: true, description: Missing legacy snapshots are blocked from loading until explicitly backfilled}
channelName: {type: string, maxLength: 128, nullable: true}
oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
@@ -1456,10 +1459,9 @@ components:
maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定限重}
ContainerOceanBookingLinkRequest:
type: object
required: [oceanBookingId, requirementId, expectedVersion]
required: [realContainerBookingId, expectedVersion]
properties:
oceanBookingId: {type: integer, format: int64, minimum: 1}
requirementId: {type: integer, format: int64, minimum: 1}
realContainerBookingId: {type: integer, format: int64, minimum: 1, description: 订舱创建的 OCEAN_BOOKING/ACTIVE 真实柜ID;成功合并后该来源记录转为 MERGED}
expectedVersion: {type: integer, minimum: 0}
ContainerOceanBookingUnlinkRequest:
type: object
@@ -21,10 +21,10 @@ paths:
responses: {'200': {description: Operational real booking page}}
post:
summary: Register an offline-confirmed ocean booking with complete transport snapshots
description: 'Requires platform:firstleg:ocean-booking:create. The booking has already been completed offline, so registration persists it directly as CONFIRMED. RELEASED remains a separate later action.'
description: 'Requires platform:firstleg:ocean-booking:create. Registers every real container unit atomically, creates one stable fl_container_booking plus one requirement/allocation per container, and persists the booking directly as CONFIRMED. RELEASED remains a separate later action.'
parameters: [{$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/CreateRequest'}}}}
responses: {'200': {description: CONFIRMED real booking detail}}
responses: {'200': {description: CONFIRMED real booking detail, content: {application/json: {schema: {$ref: '#/components/schemas/Detail'}}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/release:
post:
summary: Release an already registered booking and synchronize associated cabinet transport snapshots
@@ -32,6 +32,33 @@ paths:
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}}
responses: {'200': {description: RELEASED detail}}
/api/platform/v1/ocean-bookings/{oceanBookingId}:
get:
summary: Get an ocean booking with its real container units
description: Requires platform:firstleg:ocean-booking:list.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}]
responses: {'200': {description: Booking detail, content: {application/json: {schema: {$ref: '#/components/schemas/Detail'}}}}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/cancel:
post:
summary: Cancel a confirmed or change-pending real booking
description: Requires platform:firstleg:ocean-booking:cancel. A non-blank reason is required; RELEASED bookings are irreversible.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: CANCELLED detail}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/change-request:
post:
summary: Request a voyage change for a confirmed booking
description: Requires platform:firstleg:ocean-booking:change. Current transport snapshots remain active until change-confirm succeeds.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ChangeRequest'}}}}
responses: {'200': {description: CHANGE_PENDING detail}}
/api/platform/v1/ocean-bookings/{oceanBookingId}/change-confirm:
post:
summary: Confirm a pending voyage change
description: Requires platform:firstleg:ocean-booking:change.
parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}]
requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}}
responses: {'200': {description: CONFIRMED detail with the pending voyage snapshots applied}}
components:
parameters:
OceanBookingId: {name: oceanBookingId, in: path, required: true, schema: {type: integer, format: int64, minimum: 1}}
@@ -45,13 +72,14 @@ components:
data: {$ref: '#/components/schemas/DraftData'}
DraftData:
type: object
required: [channelCode, warehouseId, warehouseName, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containerRequirements]
required: [channelCode, warehouseId, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containers]
properties:
customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional customer snapshot; must be supplied together with customerName.}
customerName: {type: string, maxLength: 128, nullable: true, description: Optional customer snapshot; must be supplied together with customerId.}
channelCode: {type: string, maxLength: 64, description: Selected from GET /api/platform/v1/ocean-bookings/channels; server validates it against the current ACTIVE price book and freezes channelName.}
warehouseId: {type: integer, format: int64, minimum: 1}
warehouseName: {type: string, maxLength: 128}
warehouseId: {type: integer, format: int64, minimum: 1, description: ACTIVE domestic transit warehouse ID; warehouseName is resolved and frozen server-side.}
shippingCompanyId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional shipping-company master-data ID; the carrier is independent from the logistics channel and booking-agent Supplier.}
shippingCompanyName: {type: string, maxLength: 128, nullable: true, description: Optional shipping-company name snapshot.}
bookingAgentId: {type: integer, format: int64, minimum: 1, nullable: true, description: Optional ACTIVE Warehouse supplier ID validated as BOOKING with INTERNATIONAL scope.}
bookingAgentName: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint only; the persisted snapshot always comes from Warehouse and cannot be supplied without bookingAgentId.}
serviceTerm: {type: string, maxLength: 32, description: Required free-text Service Term snapshot, for example CY-CY.}
@@ -59,19 +87,62 @@ components:
billOfLadingNo: {type: string, maxLength: 128, description: Required offline-confirmed bill of lading snapshot.}
vesselName: {type: string, maxLength: 128, description: Required offline-confirmed vessel snapshot.}
voyageNo: {type: string, maxLength: 64, description: Required offline-confirmed voyage snapshot.}
departurePortCode: {type: string, maxLength: 32, nullable: true}
departurePortName: {type: string, maxLength: 128, nullable: true}
destinationPortCode: {type: string, maxLength: 32, nullable: true}
destinationPortName: {type: string, maxLength: 128, nullable: true}
placeOfDelivery: {type: string, maxLength: 128, nullable: true}
estimatedSailingAt: {type: string, format: date-time, description: Required offline-confirmed ETD snapshot.}
estimatedArrivalAt: {type: string, format: date-time, description: Required offline-confirmed ETA snapshot; cannot be earlier than ETD.}
parties: {type: array, minItems: 3, items: {type: object}}
containerRequirements: {type: array, minItems: 1, maxItems: 20, items: {$ref: '#/components/schemas/ContainerRequirement'}}
ContainerRequirement:
remark: {type: string, maxLength: 1000, nullable: true}
parties: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/PartyRequest'}}
containers: {type: array, minItems: 1, maxItems: 20, items: {$ref: '#/components/schemas/ContainerRequest'}}
PartyRequest:
type: object
required: [containerType, containerKind, capacityCbm, maxWeightKg, quantity]
required: [partyType, partyName]
properties:
partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}
partyName: {type: string, maxLength: 255}
address: {type: string, maxLength: 1000, nullable: true}
contactName: {type: string, maxLength: 128, nullable: true}
contactPhone: {type: string, maxLength: 64, nullable: true}
ContainerRequest:
type: object
required: [containerNo, containerType, containerKind, capacityCbm, maxWeightKg]
additionalProperties: false
properties:
containerNo: {type: string, minLength: 1, maxLength: 64, description: Required real container number; unique across all ACTIVE and unmerged container records.}
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: Platform-controlled container type}
containerKind: {type: string, maxLength: 32}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: Must match the fixed platform specification for containerType}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: Must match the fixed platform specification for containerType}
quantity: {type: integer, minimum: 1}
Detail:
type: object
required: [oceanBooking, parties, containers, histories]
properties:
oceanBooking: {type: object, description: Ocean booking aggregate and frozen transport snapshots.}
parties: {type: array, items: {type: object}}
containers: {type: array, items: {$ref: '#/components/schemas/RealContainer'}}
histories: {type: array, items: {type: object}}
RealContainer:
type: object
required: [containerBookingId, cabinetNo, containerNo, requirementId, allocationId, containerType, containerKind, capacityCbm, maxWeightKg, warehouseId, warehouseName, channelCode, channelName]
properties:
containerBookingId: {type: integer, format: int64, minimum: 1, description: Current ACTIVE operational container identity.}
cabinetNo: {type: string, maxLength: 64, description: Stable server-generated internal cabinet number.}
containerNo: {type: string, maxLength: 64, description: Unique real container number.}
requirementId: {type: integer, format: int64, minimum: 1}
allocationId: {type: integer, format: int64, minimum: 1}
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC]}
containerKind: {type: string, maxLength: 32}
capacityCbm: {type: number}
maxWeightKg: {type: number}
warehouseId: {type: integer, format: int64, minimum: 1}
warehouseName: {type: string, maxLength: 128}
channelCode: {type: string, maxLength: 64}
channelName: {type: string, maxLength: 128}
customerId: {type: integer, format: int64, minimum: 1, nullable: true}
customerName: {type: string, maxLength: 128, nullable: true}
ChannelOption:
type: object
required: [priceBookVersionId, channelCode, channelName, priceModel, pricingTaxModes]
@@ -84,7 +155,17 @@ components:
ActionRequest:
type: object
required: [expectedVersion]
properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 1000}}
properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, maxLength: 1000, nullable: true}}
ChangeRequest:
type: object
required: [expectedVersion, vesselName, voyageNo, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
vesselName: {type: string, maxLength: 128}
voyageNo: {type: string, maxLength: 64}
estimatedSailingAt: {type: string, format: date-time, nullable: true}
estimatedArrivalAt: {type: string, format: date-time, nullable: true}
reason: {type: string, maxLength: 1000}
ReleaseRequest:
type: object
required: [expectedVersion, billOfLadingNo, vesselName, voyageNo, estimatedSailingAt, estimatedArrivalAt]
@@ -59,9 +59,11 @@ class ContainerControllerContractTest {
assertThat(section)
.contains("/api/platform/v1/containers/{id}/transport:")
.contains("/api/platform/v1/containers/{id}/ocean-booking-link:")
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 柜")
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
.contains("尚无装柜计划/PDA/证据/单证的 realContainerBookingId")
.contains("解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜")
.contains("任何排货、PDA、有效装柜证据或业务单证事实均拒绝")
.contains("realContainerBookingId")
.contains("/api/platform/v1/containers/{id}/clearance/document/upload:")
.contains("multipart/form-data")
.contains("ContainerDocumentUploadRequest")
@@ -72,6 +74,11 @@ class ContainerControllerContractTest {
.contains("COMPLETED")
.doesNotContain("tenantId")
.doesNotContain("accountId");
assertThat(openapi)
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
.contains("mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true");
}
@Test
@@ -51,8 +51,16 @@ class PlatformOceanBookingControllerContractTest {
String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"));
assertThat(yaml).contains("/api/platform/v1/ocean-bookings/channels:",
"originWarehouseId", "platform:firstleg:ocean-booking:list", "ChannelOption")
.contains("required: [channelCode, warehouseId, warehouseName, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containerRequirements]")
.contains("registration persists it directly as CONFIRMED")
.contains("required: [channelCode, warehouseId, billOfLadingNo, vesselName, voyageNo, serviceTerm, freightTerm, estimatedSailingAt, estimatedArrivalAt, parties, containers]")
.contains("containerNo: {type: string, maxLength: 64")
.contains("containers: {type: array, minItems: 1, maxItems: 20")
.contains("required: [containerBookingId, cabinetNo, containerNo, requirementId, allocationId")
.contains("warehouseName is resolved and frozen server-side")
.contains("shippingCompanyId:", "departurePortCode:", "destinationPortCode:",
"placeOfDelivery:", "remark:", "PartyRequest:")
.contains("/{oceanBookingId}/cancel:", "/{oceanBookingId}/change-request:",
"/{oceanBookingId}/change-confirm:")
.contains("persists the booking directly as CONFIRMED")
.contains("RELEASED remains a separate later action")
.contains("request must repeat the same bill of lading, vessel, voyage, ETD and ETA values; mismatches are rejected")
.contains("CONFIRMED, CHANGE_PENDING, RELEASED, CANCELLED")
@@ -38,6 +38,8 @@ class OceanBookingSqlContractTest {
.contains("OLD.status IN ('RELEASED','CANCELLED')")
.contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)")
.contains("request_hash CHAR(64) NOT NULL")
.contains("('CONFIRMED','RELEASED') THEN")
.doesNotContain("('CONFIRMED','CHANGE_PENDING') THEN")
.contains("订舱状态历史不可修改")
.contains("UNIQUE KEY uk_ocean_allocation_container (container_booking_id)");
}
@@ -1,9 +1,11 @@
package com.baiyue.firstleg.oceanbooking.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ActionRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ChangeRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequirementRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ContainerRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.CreateRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Detail;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DraftData;
@@ -12,6 +14,7 @@ import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.PartyRequest;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.ReleaseRequest;
import com.baiyue.firstleg.service.LoadingPickService;
import com.baiyue.firstleg.service.WarehouseSupplierSnapshotService;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerRequirementEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
@@ -44,6 +47,7 @@ class OceanBookingServiceTest {
@Mock private PriceBookQueryRepository priceBooks;
@Mock private LoadingPickService loadingPickService;
@Mock private WarehouseSupplierSnapshotService suppliers;
@Mock private WarehouseMasterDataClient warehouses;
@InjectMocks private OceanBookingService service;
@BeforeEach
@@ -52,10 +56,13 @@ class OceanBookingServiceTest {
var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true); field.set(service, new ObjectMapper().findAndRegisterModules());
when(repository.parties(anyLong())).thenReturn(List.of());
when(repository.requirements(anyLong())).thenReturn(List.of());
when(repository.containers(anyLong())).thenReturn(List.of());
when(repository.allocations(anyLong())).thenReturn(List.of());
when(repository.histories(anyLong())).thenReturn(List.of());
when(suppliers.booking(any(), any())).thenReturn(null);
when(warehouses.active(any(), eq(0L), eq(701L))).thenReturn(ApiResponse.success(
new WarehouseMasterDataClient.Detail(new WarehouseMasterDataClient.Warehouse(
701L, 0L, "SZX", "Warehouse深圳中转仓", "DOMESTIC", "ACTIVE"))));
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.completeOperation(anyLong(), anyLong(), anyString(), anyString(), anyString())).thenReturn(1);
when(repository.transition(anyLong(), eq("DRAFT"), eq("CONFIRMED"), isNull(), eq(0),
@@ -179,6 +186,7 @@ class OceanBookingServiceTest {
assertThat(result.oceanBooking().getStatus()).isEqualTo("CONFIRMED");
assertThat(result.oceanBooking().getChannelCode()).isEqualTo("SEA-US");
assertThat(result.oceanBooking().getChannelName()).isEqualTo("价格簿美西海运");
assertThat(result.oceanBooking().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001");
assertThat(result.oceanBooking().getVesselName()).isEqualTo("VESSEL");
assertThat(result.oceanBooking().getVoyageNo()).isEqualTo("V001");
@@ -194,6 +202,80 @@ class OceanBookingServiceTest {
assertThat(history.getValue().getAction()).isEqualTo("CREATE");
verify(repository).transition(inserted.get().getId(), "DRAFT", "CONFIRMED", null, 0, 9L,
"admin");
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository).insertContainer(containers.capture());
assertThat(containers.getValue().getContainerNo()).isEqualTo("MSCU-001");
assertThat(containers.getValue().getSourceType()).isEqualTo("OCEAN_BOOKING");
assertThat(containers.getValue().getRecordStatus()).isEqualTo("ACTIVE");
assertThat(containers.getValue().getWarehouseName()).isEqualTo("Warehouse深圳中转仓");
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocation =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(repository).insert(allocation.capture());
assertThat(allocation.getValue().getContainerBookingId()).isEqualTo(containers.getValue().getId());
}
@Test
void createsOneRequirementContainerAndAllocationPerRealContainerUnit() {
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
stubSuccessfulCreation();
DraftData source = draft();
List<ContainerRequest> requested = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
new ContainerRequest("MSCU-002", "20GP", "DRY", new BigDecimal("33"), new BigDecimal("28000")));
service.create(9L, "admin", "create-two-containers",
new CreateRequest(0, withContainers(source, requested)));
@SuppressWarnings("unchecked")
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> requirements =
ArgumentCaptor.forClass(List.class);
verify(repository).replaceRequirements(anyLong(), requirements.capture());
assertThat(requirements.getValue()).hasSize(2)
.allSatisfy(requirement -> assertThat(requirement.getQuantity()).isEqualTo(1));
assertThat(requirements.getValue()).extracting(OceanBookingContainerRequirementEntity::getContainerType)
.containsExactly("40HC", "20GP");
ArgumentCaptor<ContainerBookingEntity> containers = ArgumentCaptor.forClass(ContainerBookingEntity.class);
verify(repository, times(2)).insertContainer(containers.capture());
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getContainerNo)
.containsExactly("MSCU-001", "MSCU-002");
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getId).doesNotHaveDuplicates();
assertThat(containers.getAllValues()).extracting(ContainerBookingEntity::getCabinetNo).doesNotHaveDuplicates();
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocations =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(repository, times(2)).insert(allocations.capture());
assertThat(allocations.getAllValues()).extracting(OceanBookingContainerAllocationEntity::getContainerBookingId)
.containsExactlyElementsOf(containers.getAllValues().stream().map(ContainerBookingEntity::getId).toList());
}
@Test
void rejectsDuplicateContainerNumbersWithinOneRegistrationBeforeWriting() {
DraftData source = draft();
List<ContainerRequest> duplicated = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")),
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-in-request",
new CreateRequest(0, withContainers(source, duplicated))))
.isInstanceOf(BizException.class).hasMessageContaining("不能重复");
verify(repository, never()).insert(any(OceanBookingEntity.class));
verifyNoInteractions(priceBooks);
}
@Test
void rejectsContainerNumberAlreadyRegisteredBeforeCreatingAggregate() {
when(priceBooks.activeVersionId()).thenReturn(51L);
when(priceBooks.channels(51L, 701L)).thenReturn(List.of(channel("SEA-US", "价格簿美西海运")));
when(repository.containerNoExists("MSCU-001")).thenReturn(true);
assertThatThrownBy(() -> service.create(9L, "admin", "duplicate-existing",
new CreateRequest(0, draft())))
.isInstanceOf(BizException.class).hasMessageContaining("集装箱号已存在");
verify(repository, never()).insert(any(OceanBookingEntity.class));
}
@Test
@@ -240,12 +322,12 @@ class OceanBookingServiceTest {
void rejectsOfflineBookingWhenEtaIsEarlierThanEtd() {
DraftData source = draft();
DraftData invalid = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
source.warehouseId(), source.warehouseName(), source.billOfLadingNo(), source.shippingCompanyId(),
source.warehouseId(), source.billOfLadingNo(), source.shippingCompanyId(),
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
source.serviceTerm(), source.freightTerm(), source.estimatedArrivalAt(), source.estimatedSailingAt(),
source.remark(), source.parties(), source.containerRequirements());
source.remark(), source.parties(), source.containers());
assertThatThrownBy(() -> service.create(9L, "admin", "invalid-range", new CreateRequest(0, invalid)))
.isInstanceOf(BizException.class).hasMessageContaining("预计到港时间不能早于预计开船时间");
@@ -257,12 +339,12 @@ class OceanBookingServiceTest {
void rejectsOfflineBookingWithoutCompleteTransportSnapshot() {
DraftData source = draft();
DraftData missingBill = new DraftData(source.customerId(), source.customerName(), source.channelCode(),
source.warehouseId(), source.warehouseName(), null, source.shippingCompanyId(),
source.warehouseId(), null, source.shippingCompanyId(),
source.shippingCompanyName(), source.bookingAgentId(), source.bookingAgentName(),
source.vesselName(), source.voyageNo(), source.departurePortCode(), source.departurePortName(),
source.destinationPortCode(), source.destinationPortName(), source.placeOfDelivery(),
source.serviceTerm(), source.freightTerm(), source.estimatedSailingAt(), source.estimatedArrivalAt(),
source.remark(), source.parties(), source.containerRequirements());
source.remark(), source.parties(), source.containers());
assertThatThrownBy(() -> service.create(9L, "admin", "missing-transport",
new CreateRequest(0, missingBill))).isInstanceOf(BizException.class)
@@ -308,12 +390,21 @@ class OceanBookingServiceTest {
new PartyRequest("SHIPPER", "发货人", null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null));
List<ContainerRequirementRequest> requirements = List.of(
new ContainerRequirementRequest("40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000"), 1));
return new DraftData(customerId, customerName, "SEA-US", 701L, "深圳仓", "BL-001",
List<ContainerRequest> containers = List.of(
new ContainerRequest("MSCU-001", "40HC", "DRY", new BigDecimal("76"), new BigDecimal("28000")));
return new DraftData(customerId, customerName, "SEA-US", 701L, "BL-001",
81L, "船公司", null, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶",
null, serviceTerm, freightTerm, LocalDateTime.of(2026, 9, 1, 0, 0),
LocalDateTime.of(2026, 9, 20, 0, 0), null, parties, requirements);
LocalDateTime.of(2026, 9, 20, 0, 0), null, parties, containers);
}
private static DraftData withContainers(DraftData source, List<ContainerRequest> containers) {
return new DraftData(source.customerId(), source.customerName(), source.channelCode(), source.warehouseId(),
source.billOfLadingNo(), source.shippingCompanyId(), source.shippingCompanyName(),
source.bookingAgentId(), source.bookingAgentName(), source.vesselName(), source.voyageNo(),
source.departurePortCode(), source.departurePortName(), source.destinationPortCode(),
source.destinationPortName(), source.placeOfDelivery(), source.serviceTerm(), source.freightTerm(),
source.estimatedSailingAt(), source.estimatedArrivalAt(), source.remark(), source.parties(), containers);
}
private AtomicReference<OceanBookingEntity> stubSuccessfulCreation() {
@@ -28,6 +28,7 @@ class LoadingPickPdaTaskSqlContractTest {
assertThat(sql)
.contains("task.task_status = 'LOADING'")
.contains("booking.record_status = 'ACTIVE'")
.contains("assignment.pick_task_id IS NULL OR assignment.assignee_id = #{operatorId}")
.contains("booking.warehouse_id AS warehouseId")
.doesNotContain("tenantId");
@@ -14,8 +14,11 @@ class LoadingPickStableLookupContractTest {
.getAnnotation(Select.class).value()[0];
String legacy = LoadingPickTaskMapper.class.getMethod("selectLoadingByContainerAndAccount", String.class, long.class)
.getAnnotation(Select.class).value()[0];
assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}").doesNotContain("task.container_no");
assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}").doesNotContain("task.container_no");
assertThat(legacy).contains("booking.container_no = #{containerNo}").doesNotContain("task.container_no =");
assertThat(cabinet).contains("booking.cabinet_no=#{cabinetNo}", "booking.record_status='ACTIVE'")
.doesNotContain("task.container_no");
assertThat(plan).contains("task.loading_plan_id=#{loadingPlanId}", "booking.record_status='ACTIVE'")
.doesNotContain("task.container_no");
assertThat(legacy).contains("booking.container_no = #{containerNo}",
"booking.record_status = 'ACTIVE'").doesNotContain("task.container_no =");
}
}
@@ -61,6 +61,8 @@ class SmartLoadingSqlContractTest {
@Test
@DisplayName("活动明细唯一锁、乐观锁和双层幂等字段完整")
void shouldProvideConcurrencyAndIdempotencyBoundaries() throws Exception {
assertContainerLifecycleSchema(migration());
assertContainerLifecycleSchema(unifiedMigration());
assertThat(migration())
.contains("active_inbound_detail_id BIGINT UNSIGNED GENERATED ALWAYS AS")
.contains("CASE WHEN deleted = 0 THEN inbound_detail_id ELSE NULL END")
@@ -73,6 +75,20 @@ class SmartLoadingSqlContractTest {
.contains("version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'");
}
private static void assertContainerLifecycleSchema(String sql) {
assertThat(sql)
.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 NULL")
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
.contains("KEY idx_container_booking_merge (record_status,merged_into_container_booking_id)")
.contains("CHECK (source_type IN ('SIMULATION','OCEAN_BOOKING'))")
.contains("CHECK (record_status IN ('ACTIVE','MERGED'))")
.contains("column_name='source_type'")
.contains("column_name='record_status'")
.contains("column_name='merged_into_container_booking_id'");
}
@Test
@DisplayName("清关与报关采用柜次级文件、一键完成和真实文件哈希")
void shouldProvideSimpleCustomsDocuments() throws Exception {
@@ -112,11 +112,11 @@ class ContainerOverviewServiceTest {
@Test void markLocksContainerAndPersistsIdempotentImmutableHistory() {
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); booking.setVersion(3);
when(containers.findForUpdate(41L)).thenReturn(booking);
when(containers.findActiveForUpdate(41L)).thenReturn(booking);
service.mark(41L, 7L, "operator", "mark-1", new ExceptionRequest(3, "broken seal"));
verify(containers).findForUpdate(41L);
verify(containers).findActiveForUpdate(41L);
verify(mapper).insertException(anyLong(), eq(41L), eq("MARK"), eq("broken seal"), eq(7L),
eq("operator"), eq("mark-1"), matches("[0-9a-f]{64}"));
}
@@ -20,6 +20,7 @@ import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
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;
@@ -285,23 +286,90 @@ class ContainerServiceTest {
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"));
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
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"));
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocations(21L)).thenReturn(java.util.List.of());
when(repository.updateLink(any(), org.mockito.ArgumentMatchers.eq(2), org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq("admin"))).thenReturn(1);
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, 2));
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
assertThat(result).isSameAs(container);
assertThat(container.getCabinetNo()).isEqualTo("柜-001");
assertThat(container.getOceanBookingId()).isEqualTo(21L);
verify(repository, never()).hasActiveLoadingItems(11L);
verify(oceanBookings).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity.class));
verify(repository).consumeRealSource(41L, 11L, 9L, "admin");
verify(repository).mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin");
verify(oceanBookings).reassignAllocation(41L, 11L);
}
@Test
@DisplayName("首次关联允许消费已放舱的真实订舱柜")
void linksReleasedBooking() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setContainerType("40HC");
container.setCapacityCbm(new BigDecimal("76.0000")); container.setMaxWeightKg(new BigDecimal("28000.000"));
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("SEA-US");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setContainerType("40HC");
requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
OceanBookingContainerAllocationEntity allocation = new OceanBookingContainerAllocationEntity();
allocation.setOceanBookingId(21L); allocation.setRequirementId(31L); allocation.setContainerBookingId(41L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.allocationByContainer(41L)).thenReturn(allocation);
when(repository.consumeRealSource(41L, 11L, 9L, "admin")).thenReturn(1);
when(repository.mergeRealSource(11L, 41L, "MSCU7654321", 2, 9L, "admin")).thenReturn(1);
when(oceanBookings.reassignAllocation(41L, 11L)).thenReturn(1);
service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2));
verify(oceanBookings).reassignAllocation(41L, 11L);
}
@Test
@DisplayName("已合并的真实订舱柜不可再次关联")
void rejectsConsumedRealContainer() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
ContainerBookingEntity consumed = realContainer(41L, 21L, 31L);
consumed.setRecordStatus("MERGED"); consumed.setMergedIntoContainerBookingId(99L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(consumed);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(41L, 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已被消费");
verifyNoInteractions(oceanBookings);
}
@Test
@DisplayName("已有装柜计划的真实订舱柜不可被模拟柜消费")
void rejectsRealContainerWithLoadingPlan() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
ContainerBookingEntity realContainer = realContainer(41L, 21L, 31L);
when(repository.find(11L)).thenReturn(container);
when(repository.findForUpdate(41L)).thenReturn(realContainer);
when(repository.hasLoadingPlan(41L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(41L, 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已有装柜计划");
verifyNoInteractions(oceanBookings);
}
@Test
@@ -311,11 +379,21 @@ class ContainerServiceTest {
when(repository.find(11L)).thenReturn(container);
when(repository.hasEvidence(11L)).thenReturn(true);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, 2)))
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(41L, 2)))
.isInstanceOf(BizException.class).hasMessageContaining("证据");
verifyNoInteractions(oceanBookings);
}
private static ContainerBookingEntity realContainer(long id, long oceanBookingId, long requirementId) {
ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(id); value.setContainerNo("MSCU7654321"); value.setCabinetNo("CAB-REAL");
value.setSourceType("OCEAN_BOOKING"); value.setRecordStatus("ACTIVE");
value.setOceanBookingId(oceanBookingId); value.setOceanBookingRequirementId(requirementId);
value.setWarehouseId(101L); value.setChannelCode("SEA-US"); value.setContainerType("40HC");
value.setCapacityCbm(new BigDecimal("76.0000")); value.setMaxWeightKg(new BigDecimal("28000.000"));
return value;
}
private static ApiResponse<WarehouseMasterDataClient.Detail> activeWarehouse(long warehouseId,
String type,
String name) {
+1 -1
View File
@@ -46,7 +46,7 @@ export async function run(context) {
| 公共主数据与往来单位 | `master-data.mjs` | `master-data-partner-contract.test.mjs` |
| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` |
| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-customs.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` |
| 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` |
| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` |
| 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` |
@@ -31,28 +31,42 @@ test('simulation creation uses server-owned identity and formal master data', ()
assert.match(firstlegOpenApi, /服务端生成稳定 containerBookingId\/cabinetNo,不要求真实柜号或订舱/)
})
test('first link and unlink gates are intentionally asymmetric', () => {
test('link consumes one active real booking container through the frozen request', () => {
assert.match(controller, /@PostMapping\("\/\{id\}\/ocean-booking-link"\)/)
assert.match(controller, /@DeleteMapping\("\/\{id\}\/ocean-booking-link"\)/)
assert.match(containerDtos, /OceanBookingLinkRequest\(@NotNull @Positive Long realContainerBookingId,[\s\S]*expectedVersion/)
assert.match(containerService, /requireFirstLinkMutable\(container\)/)
assert.match(containerService, /WAIT_LOADING && value != SmartLoadingStatus\.EDITING/)
assert.match(containerService, /repository\.findForUpdate\(request\.realContainerBookingId\(\)\)/)
assert.match(containerService, /"OCEAN_BOOKING"\.equals\(source\.getSourceType\(\)\)/)
assert.match(containerService, /"ACTIVE"\.equals\(source\.getRecordStatus\(\)\)/)
assert.match(containerService, /"CONFIRMED"\.equals\(booking\.getStatus\(\)\)[\s\S]*"RELEASED"\.equals\(booking\.getStatus\(\)\)/)
assert.match(containerService, /hasPickTask[\s\S]*hasEvidence[\s\S]*hasDocuments/)
assert.match(containerService, /requireUnlinkMutable\(container\)/)
assert.match(containerService, /status\(container\) != SmartLoadingStatus\.WAIT_LOADING \|\| repository\.hasActiveLoadingItems/)
assert.match(firstlegOpenApi, /首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 柜/)
assert.match(firstlegOpenApi, /解绑或换绑仅允许完全空的 WAIT_LOADING 模拟柜/)
assert.match(firstlegOpenApi, /realContainerBookingId/)
assert.match(firstlegOpenApi, /原真实柜置 MERGED 且不再出现在正常货柜列表/)
})
test('real booking association locks scope capacity and stable internal identity', () => {
test('real booking merge locks scope and changes all identities atomically', () => {
for (const field of ['warehouseId', 'channelCode', 'containerType', 'capacityCbm', 'maxWeightKg']) {
assert.match(containerService, new RegExp(`container\\.get${field[0].toUpperCase()}${field.slice(1)}\\(\\)`), field)
}
assert.match(containerService, /hasMismatchedCustomerItems/)
assert.match(containerService, /真实订舱柜箱型分配已关联/)
assert.match(containerService, /setOceanBookingId[\s\S]*setOceanBookingRequirementId/)
assert.match(containerService, /consumeRealSource\(source\.getId\(\), id/)
assert.match(containerService, /mergeRealSource\(id, source\.getId\(\), source\.getContainerNo\(\)/)
assert.match(containerService, /reassignAllocation\(source\.getId\(\), id\)/)
assert.doesNotMatch(containerService.match(/linkOceanBooking[\s\S]*?return required\(id\);/)?.[0] || '', /container\.setId\(|container\.setCabinetNo\(/)
})
test('booking creation atomically registers one real operational container per input row', () => {
assert.match(oceanOpenApi, /containers: \{type: array, minItems: 1, maxItems: 20/)
assert.match(oceanOpenApi, /required: \[containerNo, containerType, containerKind, capacityCbm, maxWeightKg\]/)
assert.match(oceanService, /request\.data\(\)\.containers\(\)\.forEach/)
assert.match(oceanService, /同一订舱内集装箱号不能重复/)
assert.match(oceanService, /createRealContainers\(value, requirements, request\.data\(\)\.containers\(\)/)
assert.match(oceanService, /setSourceType\("OCEAN_BOOKING"\)/)
assert.match(oceanService, /setRecordStatus\("ACTIVE"\)/)
assert.match(oceanService, /setQuantity\(1\)/)
})
test('HTTP fixture follows simulation arrange link PDA sequence without auto-container fallback', () => {
const ordered = [
'新增内部模拟柜',
@@ -64,9 +78,10 @@ test('HTTP fixture follows simulation arrange link PDA sequence without auto-con
assert.ok(ordered.every((value, index) => index === 0 || value > ordered[index - 1]), `invalid sequence: ${ordered.join(',')}`)
assert.match(loadingFixture, /ocean-booking-link/)
assert.match(loadingFixture, /关联前后内部柜次、计划和排货明细身份稳定/)
assert.match(loadingFixture, /同一真实订舱箱型容量拒绝重复关联/)
assert.match(loadingFixture, /已消费 MERGED 真实柜拒绝再次关联/)
assert.match(loadingFixture, /已有排货后拒绝解绑或换绑/)
assert.match(loadingFixture, /空模拟柜解绑后允许重新关联/)
assert.match(loadingFixture, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(loadingFixture, /订舱真实柜 allocation 已原子改指稳定模拟柜身份/)
assert.doesNotMatch(loadingFixture, /确认订舱并自动生成|确认响应包含自动生成/)
})
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { test } from 'node:test'
const productionFixtures = [
'manual-state-loading-pda.mjs',
'ocean-booking-fixture.mjs',
'ocean-bookings.mjs',
'phase3-carrier-schedule-queries.mjs',
'phase3-container-loading.mjs',
'phase3-loading.mjs',
'phase3-order-splitting.mjs',
'phase3-port-schedules.mjs',
]
function source(name) {
return readFileSync(new URL(name, import.meta.url), 'utf8')
}
test('HTTP fixtures use the canonical primary and mismatch container specifications', () => {
const primarySources = productionFixtures.map(source).join('\n')
const containerFlow = source('phase3-container-loading.mjs')
assert.match(primarySources, /containerType: '40HC'[\s\S]*?capacityCbm: 76[\s\S]*?maxWeightKg: 28000/)
assert.match(containerFlow, /containerType: '20GP'[\s\S]*?capacityCbm: 33[\s\S]*?maxWeightKg: 28000/)
})
test('HTTP fixtures do not reintroduce retired aliases or arbitrary specifications', () => {
for (const name of productionFixtures) {
const fixture = source(name)
assert.doesNotMatch(fixture, /40HQ|45HQ/, name)
assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|68|28)(?:\D|$)/, name)
assert.doesNotMatch(fixture, /maxWeightKg:\s*(?:100000|22000)(?:\D|$)/, name)
}
})
test('the frozen catalog is complete and unambiguous', () => {
// Keep every future fixture producer constrained to the same product catalog as Admin and Firstleg.
const catalog = Object.freeze({
'20GP': Object.freeze({ capacityCbm: 33, maxWeightKg: 28000 }),
'40GP': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }),
'40HC': Object.freeze({ capacityCbm: 76, maxWeightKg: 28000 }),
'40NR': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }),
'45HC': Object.freeze({ capacityCbm: 86, maxWeightKg: 28000 }),
})
assert.deepEqual(Object.keys(catalog), ['20GP', '40GP', '40HC', '40NR', '45HC'])
assert.equal(new Set(Object.keys(catalog)).size, 5)
})
@@ -97,11 +97,12 @@ async function readInbound(context, fixture) {
async function createConfirmedContainer(context, source, label) {
const value = suffix(context, label)
const containerNo = `MAN-${value}`.slice(0, 64)
const summary = source.detail.summary
const draft = {
customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户',
channelCode: summary.channelCode, warehouseId: source.warehouseId,
warehouseName: summary.warehouseName, billOfLadingNo: `BL-${value}`.slice(0, 128),
billOfLadingNo: `BL-${value}`.slice(0, 128),
shippingCompanyId: null, shippingCompanyName: null,
bookingAgentId: null, bookingAgentName: null, departurePortCode: 'CNSZX',
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
@@ -112,12 +113,12 @@ async function createConfirmedContainer(context, source, label) {
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${value}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68,
maxWeightKg: 28000, quantity: 1 }],
containers: [{ containerNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76,
maxWeightKg: 28000 }],
}
const simulationResponse = await context.request('POST', CONTAINERS, {
channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HQ',
capacityCbm: 68, maxWeightKg: 28000,
channelCode: summary.channelCode, warehouseId: source.warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id)
@@ -130,10 +131,10 @@ async function createConfirmedContainer(context, source, label) {
context.assert(MODULE, `${label} 一次登记直接返回 CONFIRMED`, createdResponse,
confirmed?.oceanBooking?.status === 'CONFIRMED',
`status=${confirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
if (!requirement?.id) throw new Error(`${label} 确认订舱未返回箱型需求`)
const requirement = confirmed?.containers?.find?.((item) => item.containerNo === containerNo)
if (!requirement?.containerBookingId) throw new Error(`${label} 确认订舱未返回真实柜身份`)
const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version),
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulation.version),
})
const linked = context.apiSuccess(MODULE, `${label} 关联模拟柜与真实订舱`, linkedResponse, '', OPTIONS)
if (id(linked?.id) !== containerId || linked?.cabinetNo !== simulation.cabinetNo) {
@@ -22,7 +22,7 @@ function oceanPayload(context, template, index) {
return { expectedVersion: 0, data: {
customerId: source.customerId, customerName: source.customerName,
channelCode: source.channelCode,
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
warehouseId: source.warehouseId,
billOfLadingNo: `BL-${suffix(context, index)}`, vesselName: `人工轮-${suffix(context, index)}`,
voyageNo: `V${suffix(context, index)}`,
shippingCompanyId: source.shippingCompanyId,
@@ -36,8 +36,9 @@ function oceanPayload(context, template, index) {
remark: `${context.marker} 人工列表状态 ${index}`,
parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) =>
({ partyType, partyName, address, contactName, contactPhone })),
containerRequirements: template.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) =>
({ containerType, containerKind, capacityCbm, maxWeightKg, quantity })),
containers: template.containers.map(({ containerType, containerKind, capacityCbm, maxWeightKg }, containerIndex) =>
({ containerNo: `MAN-${suffix(context, index)}-${String(containerIndex + 1).padStart(2, '0')}`.slice(0, 64),
containerType, containerKind, capacityCbm, maxWeightKg })),
} }
}
@@ -138,8 +139,8 @@ export async function run(context, matrix = context.createdResources.manualState
if (!sourceOceanId) throw new Error('缺少现有订舱生命周期模块生成的模板资源')
const templateResponse = await context.request('GET', `${OCEAN_BASE}/${sourceOceanId}`)
const template = context.apiSuccess(MODULE, '读取订舱状态矩阵模板', templateResponse, '', OPTIONS)
if (!template?.oceanBooking || !template?.parties?.length || !template?.containerRequirements?.length) {
throw new Error('订舱模板缺少渠道、仓库、参与方或箱型需求')
if (!template?.oceanBooking || !template?.parties?.length || !template?.containers?.length) {
throw new Error('订舱模板缺少渠道、仓库、参与方或真实柜明细')
}
matrix.pages.oceanBookings = { requiredStatuses: OCEAN_LIST_STATUSES, fixtures: {} }
for (const [index, status] of OCEAN_STATUSES.entries()) {
@@ -31,23 +31,25 @@ test('ocean booking fixture blocks creation instead of inventing missing master
assert.match(steps[0][4], /missing=customerId,customerName,channelCode,channelName,warehouseId,warehouseName/)
})
test('shared booking data omits server-owned channelName and includes offline-confirmed transport snapshots', () => {
test('shared booking data omits server-owned names and registers unique real containers', () => {
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', {
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
})
for (const key of ['customerId', 'customerName', 'channelCode', 'warehouseId', 'warehouseName']) {
for (const key of ['customerId', 'customerName', 'channelCode', 'warehouseId']) {
assert.ok(draft[key], `${key} is required`)
}
assert.equal(draft.channelName, undefined)
assert.equal(draft.warehouseName, undefined)
assert.equal(draft.shippingCompanyId, null)
assert.equal(draft.shippingCompanyName, null)
assert.equal(draft.bookingAgentId, null)
assert.equal(draft.bookingAgentName, null)
assert.equal(draft.serviceTerm, 'CY-CY')
assert.equal(draft.freightTerm, 'NINGBO')
assert.equal(draft.containerRequirements[0].capacityCbm, 68)
assert.equal(draft.containerRequirements[0].maxWeightKg, 28000)
assert.deepEqual(draft.containers, [{ containerNo: 'TST-3-01', containerType: '40HC',
containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }])
assert.equal(Object.hasOwn(draft, 'containerRequirements'), false)
assert.equal(draft.billOfLadingNo, 'BL-3')
assert.equal(draft.vesselName, '验收船-3')
assert.equal(draft.voyageNo, 'V-3')
@@ -78,8 +80,22 @@ test('shared booking draft can omit customer identity without inventing placehol
assert.equal(Object.hasOwn(draft, 'customerName'), false)
assert.equal(draft.channelCode, 'SEA-US')
assert.equal(draft.warehouseId, '41')
assert.equal(draft.containerRequirements[0].capacityCbm, 68)
assert.equal(draft.containerRequirements[0].maxWeightKg, 28000)
assert.equal(draft.containers[0].containerType, '40HC')
assert.equal(draft.containers[0].capacityCbm, 76)
assert.equal(draft.containers[0].maxWeightKg, 28000)
})
test('shared booking builder accepts explicit isolated multi-container rows without quantity', () => {
const containers = [
{ containerNo: 'TST-MULTI-01', containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 },
{ containerNo: 'TST-MULTI-02', containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 },
]
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', {
customerId: '21', customerName: 'IT 测试客户', channelCode: 'SEA-US', channelName: '美西海运',
warehouseId: '41', warehouseName: '深圳仓',
}, { containers })
assert.deepEqual(draft.containers, containers)
assert.equal(draft.containers.some((item) => Object.hasOwn(item, 'quantity')), false)
})
test('platform channel producer queries the selected origin warehouse and publishes current-marker resources', async () => {
@@ -106,18 +122,22 @@ test('platform channel producer queries the selected origin warehouse and publis
test('all direct ocean booking payload builders omit channelName under the frozen DraftData contract', () => {
const regions = [
['manual-state-loading-pda.mjs', 'const draft = {', 'const createResponse'],
['manual-state-ocean-trucking.mjs', 'function oceanPayload', 'async function createOcean'],
['phase3-loading.mjs', 'const bookingDraft = {', 'const bookingCreate'],
['phase3-order-splitting.mjs', 'function bookingPayload', 'async function loadOneFragment'],
['phase3-container-loading.mjs', 'const bookingDraft = {', "context.assert(MODULE, '订舱渠道与仓库快照完整'"],
['manual-state-loading-pda.mjs', 'const draft = {', 'const createResponse', false],
['manual-state-ocean-trucking.mjs', 'function oceanPayload', 'async function createOcean', false],
['phase3-loading.mjs', 'const bookingDraft = {', 'const bookingCreate', false],
['phase3-order-splitting.mjs', 'function bookingPayload', 'async function loadOneFragment', true],
['phase3-container-loading.mjs', 'const bookingDraft = {', "context.assert(MODULE, '订舱渠道与仓库快照完整'", false],
]
for (const [name, start, end] of regions) {
for (const [name, start, end, usesSharedBuilder] of regions) {
const value = readFileSync(new URL(name, import.meta.url), 'utf8')
const region = value.slice(value.indexOf(start), value.indexOf(end))
assert.doesNotMatch(region, /channelName\s*:/, name)
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(region, new RegExp(`${field}\\s*:`), `${name} must register ${field}`)
if (usesSharedBuilder) {
assert.match(region, /oceanBookingDraft\(/, `${name} must use the shared formal booking builder`)
} else {
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(region, new RegExp(`${field}\\s*:`), `${name} must register ${field}`)
}
}
}
const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8')
@@ -140,5 +160,5 @@ test('all independent ocean booking producers use the shared master-data contrac
}
const container = readFileSync(new URL('phase3-container-loading.mjs', import.meta.url), 'utf8')
for (const snippet of ['channelCode', 'publishPlatformLogisticsChannels', 'warehouseId', 'warehouseName',
'capacityCbm: 100', 'maxWeightKg: 100000']) assert.match(container, new RegExp(snippet), snippet)
"containerType: '40HC'", 'capacityCbm: 76', 'maxWeightKg: 28000']) assert.match(container, new RegExp(snippet), snippet)
})
@@ -53,7 +53,12 @@ export async function resolveOceanBookingMaster(context, module) {
}
export function oceanBookingDraft(context, suffix, master, labels = {}) {
const { channelName: _readOnlyChannelName, ...writableMaster } = master
const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master
const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING'
const containers = labels.containers || [{
containerNo: `TST-${normalizedSuffix}-01`.slice(0, 64),
containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000,
}]
return {
...writableMaster,
billOfLadingNo: labels.billOfLadingNo || `BL-${suffix}`,
@@ -70,7 +75,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
containers,
}
}
@@ -16,7 +16,15 @@ async function request(context, method, path, body, headers = {}) {
} catch (error) { return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } }
}
function data(r) { return String(r?.body?.code) === '200' ? r.body.data : null }
async function createBooking(context, suffix, key, master) { return request(context, 'POST', BASE, { expectedVersion: 0, data: oceanBookingDraft(context, suffix, master) }, { 'Idempotency-Key': key }) }
async function createBooking(context, suffix, key, master) {
const containers = [
{ containerNo: `TST-${suffix}-01`.slice(0, 64), containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 },
{ containerNo: `TST-${suffix}-02`.slice(0, 64), containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 },
]
return request(context, 'POST', BASE,
{ expectedVersion: 0, data: oceanBookingDraft(context, suffix, master, { containers }) },
{ 'Idempotency-Key': key })
}
function step(context, name, r, pass, evidence, options = OPTIONS) { context.addStep(MODULE, name, r, pass, evidence, { ...options, deploymentBlocker: options.deploymentBlocker && !pass }) }
async function createConfirmedUnassignedBooking(context, suffix, master) {
@@ -37,10 +45,10 @@ async function createConfirmedUnassignedBooking(context, suffix, master) {
const detailResponse = await request(context, 'GET', path)
const detail = context.apiSuccess(MODULE, '回查未指定客户订舱持久化结果', detailResponse, '', OPTIONS)
const bookingNo = id(detail?.oceanBooking?.bookingNo || created?.oceanBooking?.bookingNo)
context.assert(MODULE, '未指定客户订舱保持空客户且 CONFIRMED 不自动生成内部柜', detailResponse,
context.assert(MODULE, '未指定客户订舱保持空客户且原子生成一条真实柜', detailResponse,
detail?.oceanBooking?.status === 'CONFIRMED' && detail?.oceanBooking?.customerId == null
&& detail?.oceanBooking?.customerName == null && detail?.containerAllocations?.length === 0 && Boolean(bookingNo),
`status=${detail?.oceanBooking?.status || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'null'}, bookingNo=${bookingNo || 'missing'}, allocations=${detail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
&& detail?.oceanBooking?.customerName == null && detail?.containers?.length === 1 && Boolean(bookingNo),
`status=${detail?.oceanBooking?.status || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'null'}, bookingNo=${bookingNo || 'missing'}, containers=${detail?.containers?.length ?? 'n/a'}`, OPTIONS)
const listResponse = await request(context, 'GET', `${BASE}?page=1&size=20&keyword=${encodeURIComponent(bookingNo)}`)
const page = context.apiSuccess(MODULE, '按真实订舱号查询未指定客户订舱', listResponse, `bookingNo=${bookingNo}`, OPTIONS)
@@ -48,7 +56,8 @@ async function createConfirmedUnassignedBooking(context, suffix, master) {
page?.records?.some?.((item) => id(item.id) === bookingId && item.customerId == null && item.customerName == null),
`bookingId=${bookingId}, bookingNo=${bookingNo}, records=${page?.records?.length ?? 'n/a'}`, OPTIONS)
const resource = { id: bookingId, marker: context.marker, customerId: null, customerName: null,
bookingNo, channelCode: draft.channelCode, warehouseId: draft.warehouseId, containerBookingId: null,
bookingNo, channelCode: draft.channelCode, warehouseId: draft.warehouseId,
containerBookingId: id(detail?.containers?.[0]?.containerBookingId) || null,
finalStatus: detail?.oceanBooking?.status || null }
context.createdResources.oceanBookingUnassignedCustomer = resource
return resource
@@ -62,11 +71,11 @@ async function releaseLoadingBooking(context, containerFlow, suffix) {
let flow = data(flowResponse)
context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse,
`oceanBookingId=${flowOceanBookingId}`, OPTIONS)
const allocationMatches = flow?.containerAllocations?.some?.((item) =>
const allocationMatches = flow?.containers?.some?.((item) =>
id(item?.containerBookingId) === id(containerFlow.containerBookingId))
context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse,
allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId),
`oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
`oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, containers=${flow?.containers?.length ?? 'n/a'}`, OPTIONS)
if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') {
const transportFields = ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
const frozenTransport = Object.fromEntries(transportFields.map((field) => [field, flow?.oceanBooking?.[field]]))
@@ -84,7 +93,7 @@ async function releaseLoadingBooking(context, containerFlow, suffix) {
}
context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse,
flow?.oceanBooking?.status === 'RELEASED'
&& flow?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)),
&& flow?.containers?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)),
`status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS)
return flow?.oceanBooking?.status === 'RELEASED' && allocationMatches ? {
id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker,
@@ -114,6 +123,24 @@ export async function run(context) {
}
const page = context.apiSuccess(MODULE, '订舱列表分页查询', list, '', OPTIONS)
context.assert(MODULE, '订舱列表分页结构正确', list, Array.isArray(page?.records) && Number.isFinite(Number(page?.total)), `records=${page?.records?.length ?? 'n/a'}`, OPTIONS)
const duplicateNo = `TST-${suffix}-DUP`.slice(0, 64)
const duplicateDraft = oceanBookingDraft(context, `${suffix}D`, master, { containers: [
{ containerNo: duplicateNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 },
{ containerNo: duplicateNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 },
] })
const duplicateCreate = await request(context, 'POST', BASE, { expectedVersion: 0, data: duplicateDraft },
{ 'Idempotency-Key': `${context.marker}-ocean-duplicate-in-request`.slice(0, 128) })
step(context, '同一订舱请求重复真实柜号原子拒绝', duplicateCreate, failure(duplicateCreate),
`containerNo=${duplicateNo}, HTTP=${duplicateCreate.http}, code=${codeOf(duplicateCreate)}`)
const duplicateListResponse = await request(context, 'GET', `${BASE}?page=1&size=5`)
const duplicatePage = context.apiSuccess(MODULE, '回查请求内重复柜号失败后的订舱列表', duplicateListResponse, '', OPTIONS)
const duplicateContainerResponse = await request(context, 'GET',
`/api/platform/v1/containers?page=1&size=20&keyword=${encodeURIComponent(duplicateNo)}`)
const duplicateContainers = context.apiSuccess(MODULE, '回查请求内重复柜号失败后的货柜列表', duplicateContainerResponse, '', OPTIONS)
context.assert(MODULE, '请求内重复柜号失败后订舱和货柜零写入', duplicateContainerResponse,
Number(duplicatePage?.total) === Number(page?.total)
&& Array.isArray(duplicateContainers?.records) && duplicateContainers.records.length === 0,
`bookingTotalBefore=${page?.total ?? 'n/a'}, bookingTotalAfter=${duplicatePage?.total ?? 'n/a'}, containers=${duplicateContainers?.records?.length ?? 'n/a'}`, OPTIONS)
const statuses = ['CONFIRMED', 'CHANGE_PENDING', 'RELEASED', 'CANCELLED']
for (const status of statuses) { const r = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); const d = data(r); step(context, `订舱 ${status} 状态筛选`, r, r.http === 200 && codeOf(r) === '200' && Array.isArray(d?.records) && d.records.every((x) => x.status === status), `HTTP=${r.http}, code=${codeOf(r)}, records=${d?.records?.length ?? 'n/a'}`) }
for (const status of ['DRAFT', 'SUBMITTED']) { const r = await request(context, 'GET', `${BASE}?page=1&size=5&status=${status}`); step(context, `正式订舱列表拒绝 ${status} 状态`, r, failure(r), `HTTP=${r.http}, code=${codeOf(r)}`) }
@@ -126,18 +153,39 @@ export async function run(context) {
context.apiSuccess(MODULE, '创建隔离海运订舱', createResponse, `key=${key}`, OPTIONS)
context.assert(MODULE, '线下已确认订舱 DTO、运输快照和关联字段完整', createResponse,
Boolean(bookingId) && detail?.oceanBooking?.status === 'CONFIRMED'
&& detail?.parties?.length === 3 && detail?.containerRequirements?.length === 1
&& detail?.parties?.length === 3 && detail?.containers?.length === 2
&& ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']
.every((field) => Boolean(detail?.oceanBooking?.[field])),
`bookingId=${bookingId || 'missing'}, status=${detail?.oceanBooking?.status || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, requirements=${detail?.containerRequirements?.length ?? 'n/a'}`, OPTIONS)
`bookingId=${bookingId || 'missing'}, status=${detail?.oceanBooking?.status || 'missing'}, parties=${detail?.parties?.length ?? 'n/a'}, containers=${detail?.containers?.length ?? 'n/a'}`, OPTIONS)
context.assert(MODULE, '指定客户订舱持久化本轮客户范围快照', createResponse,
id(detail?.oceanBooking?.customerId) === id(master.customerId) && detail?.oceanBooking?.customerName === master.customerName,
`expectedCustomerId=${id(master.customerId)}, actualCustomerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
const expectedContainerNos = [`TST-${suffix}-01`.slice(0, 64), `TST-${suffix}-02`.slice(0, 64)]
context.assert(MODULE, '一单两柜逐柜原子生成独立需求分配和 ACTIVE 操作身份', createResponse,
detail?.containers?.length === 2 && expectedContainerNos.every((containerNo) => {
const item = detail.containers.find((value) => value.containerNo === containerNo)
return Boolean(item?.containerBookingId && item?.cabinetNo && item?.requirementId && item?.allocationId)
&& id(item?.warehouseId) === id(master.warehouseId) && item?.channelCode === master.channelCode
&& id(item?.customerId) === id(master.customerId)
}),
`expected=${expectedContainerNos.join('|')}, actual=${detail?.containers?.map?.((item) => item.containerNo).join('|') || 'none'}`, OPTIONS)
if (!bookingId) return
context.createdResources.oceanBooking = { id: bookingId, customerId: master.customerId, marker: context.marker,
containerBookingId: null, loadingPlanId: null, finalStatus: 'CONFIRMED' }
const replay = await createBooking(context, suffix, key, master)
context.assert(MODULE, '创建 Idempotency-Key 重放返回同一订舱', replay, String(data(replay)?.oceanBooking?.id) === bookingId, `first=${bookingId}, replay=${id(data(replay)?.oceanBooking?.id)}`, OPTIONS)
const existingContainerNo = detail?.containers?.[0]?.containerNo
const globalDuplicateDraft = oceanBookingDraft(context, `${suffix}G`, master, { containers: [{
containerNo: existingContainerNo, containerType: detail?.containers?.[0]?.containerType,
containerKind: detail?.containers?.[0]?.containerKind, capacityCbm: detail?.containers?.[0]?.capacityCbm,
maxWeightKg: detail?.containers?.[0]?.maxWeightKg,
}] })
const globalDuplicate = await request(context, 'POST', BASE,
{ expectedVersion: 0, data: globalDuplicateDraft },
{ 'Idempotency-Key': `${context.marker}-ocean-global-duplicate`.slice(0, 128) })
step(context, '数据库全局重复真实柜号原子拒绝', globalDuplicate,
Boolean(existingContainerNo) && failure(globalDuplicate),
`containerNo=${existingContainerNo || 'missing'}, HTTP=${globalDuplicate.http}, code=${codeOf(globalDuplicate)}`)
const path = `${BASE}/${bookingId}`
let version = Number(detail.oceanBooking.version)
const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-ocean-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d }
@@ -164,13 +212,15 @@ export async function run(context) {
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
`bookingCustomerId=${id(changeConfirmed?.oceanBooking?.customerId) || 'missing'}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}`, OPTIONS)
const requirement = changeConfirmed?.containerRequirements?.find?.((item) => item?.containerType === '40HQ')
context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', changed,
Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS)
if (!requirement?.id || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return
context.assert(MODULE, '独立订舱生命周期不占用本轮已装货柜次', changed,
changeConfirmed?.containerAllocations?.length === 0 && Number.isInteger(version),
`allocations=${changeConfirmed?.containerAllocations?.length ?? 'n/a'}, version=${version}`, OPTIONS)
const realContainer = changeConfirmed?.containers?.find?.((item) => item?.containerType === '40HC')
context.assert(MODULE, '独立订舱存在完整匹配柜型的真实柜身份', changed,
Boolean(realContainer?.containerBookingId && realContainer?.containerNo && realContainer?.requirementId && realContainer?.allocationId),
`containerBookingId=${id(realContainer?.containerBookingId) || 'missing'}`, OPTIONS)
if (!realContainer?.containerBookingId || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return
context.assert(MODULE, '独立订舱真实柜不占用本轮已装货柜次身份', changed,
!changeConfirmed?.containers?.some?.((item) => id(item?.containerBookingId) === id(containerFlow?.containerBookingId))
&& Number.isInteger(version),
`containers=${changeConfirmed?.containers?.length ?? 'n/a'}, version=${version}`, OPTIONS)
}
const released = await request(context, 'POST', `${path}/release`, {
expectedVersion: version, ...frozenTransport, reason: context.marker,
@@ -182,8 +232,8 @@ export async function run(context) {
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
context.assert(MODULE, '独立订舱状态历史、审计操作者和终态完整', final,
fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 4 && fd.histories.every((h) => h.operatorName)
&& fd?.containerAllocations?.length === 0,
`status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
&& fd?.containers?.length === 2,
`status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, containers=${fd?.containers?.length ?? 'n/a'}`, OPTIONS)
let downstreamBooking = {
id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker,
containerBookingId: allocatedContainerBookingId || null,
@@ -42,7 +42,6 @@ async function request(context, method, path, body, headers = {}) {
function oceanDraft(context, suffix, master) {
return {
...oceanBookingDraft(context, suffix, master),
shippingCompanyName: 'ZIM', bookingAgentName: `船司查询代理-${suffix}`,
departurePortCode: 'CNSZX',
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
@@ -52,7 +51,6 @@ function oceanDraft(context, suffix, master) {
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
}
}
@@ -112,10 +112,12 @@ export async function run(context) {
OPTIONS)
if (!selectedChannel) return
const channelName = selectedChannel.channelName
const primaryRequirement = { containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100, maxWeightKg: 100000, quantity: 1 }
const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 }
const realContainerNo = `TST-${suffix}-01`.slice(0, 64)
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
const bookingDraft = {
customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`,
channelCode, warehouseId, warehouseName,
channelCode, warehouseId,
billOfLadingNo: `BL-${suffix}`.slice(0, 128),
shippingCompanyId: null, shippingCompanyName: null,
bookingAgentId: null, bookingAgentName: null,
@@ -127,8 +129,10 @@ export async function run(context) {
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }],
containerRequirements: [primaryRequirement,
{ containerType: '20GP', containerKind: 'FCL', capacityCbm: 28, maxWeightKg: 22000, quantity: 1 }],
containers: [
{ containerNo: realContainerNo, ...primaryRequirement },
{ containerNo: wrongContainerNo, containerType: '20GP', containerKind: 'FCL', capacityCbm: 33, maxWeightKg: 28000 },
],
}
context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
`channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS)
@@ -324,30 +328,16 @@ export async function run(context) {
`changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(pushSameKeyDifferentBody)}; newKey=${responseEvidence(pushNewKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS)
if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return
const realContainerNo = `TST-${suffix}`.slice(0, 64)
const transportResponse = await request(context, 'PATCH',
`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo: realContainerNo, voyageNo: `V-${suffix}`.slice(0, 64),
expectedVersion: Number(postPushPlan.version), operationReason: context.marker,
}, { 'Idempotency-Key': `${context.marker}-preload-bind-container-no`.slice(0, 128) })
const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定本轮真实柜号', transportResponse, '', OPTIONS)
context.assert(MODULE, '预装补号保持柜次和计划身份稳定', transportResponse,
transported?.summary?.containerNo === realContainerNo
&& id(transported?.summary?.planId) === planId
&& id(transported?.summary?.containerBookingId) === containerId,
`containerNo=${transported?.summary?.containerNo || 'missing'}, containerBookingId=${id(transported?.summary?.containerBookingId) || 'missing'}, loadingPlanId=${id(transported?.summary?.planId) || 'missing'}`, OPTIONS)
if (transported?.summary?.containerNo !== realContainerNo) return
const unlinkedTaskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
const unlinkedTaskAbsent = unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404'
context.addStep(MODULE, '已补号但未关联订舱不生成 PDA 任务', unlinkedTaskResponse,
context.addStep(MODULE, '无号且未关联订舱不生成 PDA 任务', unlinkedTaskResponse,
unlinkedTaskAbsent, responseEvidence(unlinkedTaskResponse), OPTIONS)
if (!unlinkedTaskAbsent) return
const transportedContainerResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const transportedContainer = context.apiSuccess(MODULE, '预装补号后读取模拟柜最新版本', transportedContainerResponse, '', OPTIONS)
if (!Number.isInteger(Number(transportedContainer?.version))) return
const simulationBeforeLinkResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const simulationBeforeLink = context.apiSuccess(MODULE, '预装确认前读取模拟柜最新版本', simulationBeforeLinkResponse, '', OPTIONS)
if (!Number.isInteger(Number(simulationBeforeLink?.version))) return
const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` })
@@ -363,8 +353,8 @@ export async function run(context) {
&& String(frozenTransport[field]) === String(bookingDraft[field]))
context.assert(MODULE, '一次登记直接返回 CONFIRMED 且尚未占用模拟柜', bookingCreate,
confirmedBooking?.oceanBooking?.status === 'CONFIRMED'
&& Array.isArray(confirmedBooking?.containerAllocations) && confirmedBooking.containerAllocations.length === 0,
`status=${confirmedBooking?.oceanBooking?.status || 'missing'}, allocations=${confirmedBooking?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
&& Array.isArray(confirmedBooking?.containers) && confirmedBooking.containers.length === 2,
`status=${confirmedBooking?.oceanBooking?.status || 'missing'}, containers=${confirmedBooking?.containers?.length ?? 'n/a'}`, OPTIONS)
context.assert(MODULE, '已确认订舱回显完整且一致的冻结运输快照', bookingCreate,
frozenTransportMatches,
transportFields.map((field) => `${field}=${frozenTransport[field] || 'missing'}`).join(', '), OPTIONS)
@@ -376,50 +366,18 @@ export async function run(context) {
records(confirmedList).some((item) => id(item.id) === bookingId && item.status === 'CONFIRMED'),
`bookingId=${bookingId}, records=${records(confirmedList).length}`, OPTIONS)
const requirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType === primaryRequirement.containerType)
const wrongTypeRequirement = confirmedBooking?.containerRequirements?.find?.((item) => item.containerType !== primaryRequirement.containerType)
if (!requirement?.id || !wrongTypeRequirement?.id) return
const requirement = confirmedBooking?.containers?.find?.((item) => item.containerNo === realContainerNo)
const wrongTypeRequirement = confirmedBooking?.containers?.find?.((item) => item.containerNo === wrongContainerNo)
if (!requirement?.containerBookingId || !wrongTypeRequirement?.containerBookingId) return
const wrongTypeLinkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(wrongTypeRequirement.id), expectedVersion: Number(transportedContainer.version),
realContainerBookingId: id(wrongTypeRequirement.containerBookingId), expectedVersion: Number(simulationBeforeLink.version),
})
context.addStep(MODULE, '箱型不匹配拒绝关联真实订舱柜', wrongTypeLinkResponse,
failure(wrongTypeLinkResponse), `HTTP=${wrongTypeLinkResponse.http}, code=${codeOf(wrongTypeLinkResponse)}`, OPTIONS)
const emptySimulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode, warehouseId, containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
})
let emptySimulation = context.apiSuccess(MODULE, '新增空模拟柜验证解绑换绑', emptySimulationResponse, '', OPTIONS)
const emptyContainerId = id(emptySimulation?.id)
if (!emptyContainerId) return
const emptyLink = await request(context, 'POST', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(emptySimulation.version),
})
emptySimulation = context.apiSuccess(MODULE, '空模拟柜首次关联真实订舱', emptyLink,
responseEvidence(emptyLink), OPTIONS)
if (!emptySimulation) return
const emptyUnlink = await request(context, 'DELETE', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, {
expectedVersion: Number(emptySimulation.version), reason: context.marker,
})
emptySimulation = context.apiSuccess(MODULE, '空模拟柜允许解除关联', emptyUnlink,
responseEvidence(emptyUnlink), OPTIONS)
if (!emptySimulation) return
const emptyRelink = await request(context, 'POST', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(emptySimulation.version),
})
emptySimulation = context.apiSuccess(MODULE, '空模拟柜解绑后允许重新关联', emptyRelink,
responseEvidence(emptyRelink), OPTIONS)
if (!emptySimulation) return
const emptyFinalUnlink = await request(context, 'DELETE', `${CONTAINERS}/${emptyContainerId}/ocean-booking-link`, {
expectedVersion: Number(emptySimulation.version), reason: `${context.marker}-release-capacity`,
})
const emptyReleased = context.apiSuccess(MODULE, '空模拟柜最终解绑释放真实订舱容量', emptyFinalUnlink,
responseEvidence(emptyFinalUnlink), OPTIONS)
if (!emptyReleased) return
const beforeLinkItems = JSON.stringify(assigned.items?.map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), packageCount: Number(item.packageCount) })) || [])
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(transportedContainer.version),
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulationBeforeLink.version),
})
const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse,
responseEvidence(linkResponse), OPTIONS)
@@ -429,19 +387,38 @@ export async function run(context) {
const afterLinkItems = JSON.stringify(afterLinkPlan?.items?.map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), packageCount: Number(item.packageCount) })) || [])
context.assert(MODULE, '关联前后内部柜次、计划和排货明细身份稳定', linkResponse,
id(linkedContainer?.id) === containerId && linkedContainer?.cabinetNo === simulation.cabinetNo
&& id(linkedContainer?.oceanBookingId) === bookingId && id(afterLinkPlan?.id) === planId
&& id(linkedContainer?.oceanBookingId) === bookingId && linkedContainer?.containerNo === realContainerNo
&& id(afterLinkPlan?.id) === planId
&& beforeLinkItems === afterLinkItems,
`containerBookingId=${containerId}, cabinetNo=${linkedContainer?.cabinetNo || 'missing'}, loadingPlanId=${id(afterLinkPlan?.id) || 'missing'}`, OPTIONS)
const activeContainersResponse = await context.request('GET',
`${CONTAINERS}?page=1&size=50&keyword=${encodeURIComponent(realContainerNo)}`)
const activeContainers = context.apiSuccess(MODULE, '合并后按真实柜号回查正常货柜列表', activeContainersResponse, '', OPTIONS)
const activeRealRows = records(activeContainers).filter((item) => item.containerNo === realContainerNo)
context.assert(MODULE, '合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份', activeContainersResponse,
activeRealRows.length === 1 && id(activeRealRows[0]?.id) === containerId
&& !records(activeContainers).some((item) => id(item?.id) === id(requirement.containerBookingId)),
`activeIds=${activeRealRows.map((item) => id(item?.id)).join('|') || 'none'}, mergedSourceId=${id(requirement.containerBookingId)}`, OPTIONS)
const mergedBookingResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${bookingId}`)
const mergedBooking = context.apiSuccess(MODULE, '合并后回查订舱真实柜当前操作身份', mergedBookingResponse, '', OPTIONS)
const mergedContainer = mergedBooking?.containers?.find?.((item) => item.containerNo === realContainerNo)
context.assert(MODULE, '订舱真实柜 allocation 已原子改指稳定模拟柜身份', mergedBookingResponse,
id(mergedContainer?.containerBookingId) === containerId
&& id(mergedContainer?.requirementId) === id(requirement.requirementId)
&& id(mergedContainer?.allocationId) === id(requirement.allocationId),
`containerBookingId=${id(mergedContainer?.containerBookingId) || 'missing'}, requirementId=${id(mergedContainer?.requirementId) || 'missing'}, allocationId=${id(mergedContainer?.allocationId) || 'missing'}`, OPTIONS)
const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode, warehouseId, containerType: primaryRequirement.containerType,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg,
})
const duplicateSimulation = context.apiSuccess(MODULE, '新增重复容量占用负向模拟柜', duplicateSimulationResponse, '', OPTIONS)
const duplicateLinkResponse = await request(context, 'POST', `${CONTAINERS}/${id(duplicateSimulation?.id)}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(duplicateSimulation?.version),
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(duplicateSimulation?.version),
})
context.addStep(MODULE, '同一真实订舱箱型容量拒绝重复关联', duplicateLinkResponse,
context.addStep(MODULE, '已消费 MERGED 真实柜拒绝再次关联', duplicateLinkResponse,
failure(duplicateLinkResponse), `HTTP=${duplicateLinkResponse.http}, code=${codeOf(duplicateLinkResponse)}`, OPTIONS)
const unlinkAfterLoadingResponse = await request(context, 'DELETE', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
@@ -464,13 +441,14 @@ export async function run(context) {
const releasedBooking = context.apiSuccess(MODULE, '订舱携带已冻结运输快照放舱并满足 PDA 门禁', releaseResponse, '', OPTIONS)
context.assert(MODULE, '放舱后订舱进入 RELEASED 且柜次分配保持稳定', releaseResponse,
releasedBooking?.oceanBooking?.status === 'RELEASED'
&& releasedBooking?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerId)
&& releasedBooking?.containers?.some?.((item) => id(item.containerBookingId) === containerId
&& item.containerNo === realContainerNo)
&& transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field])),
`status=${releasedBooking?.oceanBooking?.status || 'missing'}, containerBookingId=${containerId}, transportFrozen=${transportFields.every((field) => String(releasedBooking?.oceanBooking?.[field]) === String(frozenTransport[field]))}`, OPTIONS)
if (releasedBooking?.oceanBooking?.status !== 'RELEASED') return
const preloadPageResponse = await context.request('GET',
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`)
`${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(realContainerNo)}&page=1&size=20`)
const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS)
context.assert(MODULE, '预装清单包含本轮已推送 EDITING 计划', preloadPageResponse,
records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'EDITING'),
@@ -486,7 +464,7 @@ export async function run(context) {
`${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
context.assert(MODULE, '补号关联放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
context.assert(MODULE, '真实柜合并放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING'
&& confirmable?.summary?.readiness?.canConfirm === true
&& confirmable?.summary?.readiness?.readyForPda === false
@@ -85,10 +85,11 @@ test('existing container number replacement keeps container plan and PDA identit
assert.match(containerLoadingAcceptance, /pdaIdentityStable: true/)
})
test('PDA task opens only after no-number push, transport, release and plan-level confirmation', () => {
test('PDA task opens only after no-number push, real-container merge, release and plan-level confirmation', () => {
assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/)
assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/)
assert.match(containerLoadingAcceptance, /预装 EDITING 清单绑定本轮真实柜号/)
assert.match(containerLoadingAcceptance, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(containerLoadingAcceptance, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(containerLoadingAcceptance, /订舱已关联但未 RELEASED 不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /ocean-bookings\/\$\{bookingId\}\/release/)
assert.match(containerLoadingAcceptance, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
@@ -120,11 +120,12 @@ export async function run(context) {
const base = `/api/platform/v1/inbounds/${inboundId}`
const suffix = context.marker.replace(/\D/g, '').slice(-10)
const warehouseId = id(initial?.summary?.warehouseId)
const containerNo = `P3B-${suffix}`.slice(0, 64)
const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading'
const pdaBase = '/api/platform/v1/first-leg/pda-loading'
const bookingDraft = {
customerId: tenantId, customerName: initial?.summary?.customerName,
warehouseId, warehouseName: initial?.summary?.warehouseName,
warehouseId,
channelCode: initial?.summary?.channelCode,
billOfLadingNo: `P3BL-${suffix}`.slice(0, 128),
vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64),
@@ -135,12 +136,12 @@ export async function run(context) {
parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `P3-NOTIFY-${suffix}` }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100,
maxWeightKg: 100000, quantity: 1 }],
containers: [{ containerNo, containerType: '40HC', containerKind: 'FCL', capacityCbm: 76,
maxWeightKg: 28000 }],
}
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
channelCode: initial?.summary?.channelCode, warehouseId, containerType: '40HQ',
capacityCbm: 100, maxWeightKg: 100000,
channelCode: initial?.summary?.channelCode, warehouseId, containerType: '40HC',
capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS)
const containerBookingId = id(simulation?.id)
@@ -159,7 +160,6 @@ export async function run(context) {
`candidate=${Boolean(candidate)}, warehouseId=${warehouseId || 'missing'}`, OPTIONS)
if (!candidate?.inboundDetailId || !warehouseId) return
const containerNo = `P3B-${suffix}`
const loadingPlanResponse = await request(context, 'POST', `${smartLoadingBase}/plans`, {
bookingId: containerBookingId, remark: context.marker,
@@ -278,21 +278,9 @@ export async function run(context) {
`changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(sameKeyDifferentBody)}; newKey=${responseEvidence(newKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS)
if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return
const transportResponse = await request(context, 'PATCH',
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, {
containerNo, voyageNo: `P3V-${suffix}`, expectedVersion: Number(postPushPlan.version),
operationReason: `${context.marker}-bind-container-no`,
}, { 'Idempotency-Key': `${context.marker}-preload-transport`.slice(0, 128) })
const transported = context.apiSuccess(MODULE, '预装 EDITING 清单绑定真实柜号', transportResponse, '', OPTIONS)
context.assert(MODULE, '预装补号保持 loadingPlanId/containerBookingId/cabinetNo 身份稳定', transportResponse,
transported?.summary?.containerNo === containerNo && id(transported?.summary?.planId) === loadingPlanId
&& id(transported?.summary?.containerBookingId) === containerBookingId,
`containerNo=${transported?.summary?.containerNo || 'missing'}, loadingPlanId=${id(transported?.summary?.planId) || 'missing'}`, OPTIONS)
if (transported?.summary?.containerNo !== containerNo) return
const unlinkedTaskResponse = await context.request('GET',
`${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`)
context.addStep(MODULE, '已补号但未关联订舱仍无 PDA 任务', unlinkedTaskResponse,
context.addStep(MODULE, '无号且未关联订舱仍无 PDA 任务', unlinkedTaskResponse,
unlinkedTaskResponse.http === 200 && codeOf(unlinkedTaskResponse) === 'A0404',
`HTTP=${unlinkedTaskResponse.http}, code=${codeOf(unlinkedTaskResponse)}`, OPTIONS)
@@ -306,18 +294,18 @@ export async function run(context) {
const frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field]))
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
const requirement = bookingConfirmed?.containers?.find?.((item) => item.containerNo === containerNo)
context.assert(MODULE, 'CONFIRMED 订舱回显完整且冻结五项运输快照', bookingCreateResponse,
Boolean(bookingId) && frozenTransportMatches && Boolean(requirement?.id)
&& bookingConfirmed?.containerAllocations?.length === 0,
Boolean(bookingId) && frozenTransportMatches && Boolean(requirement?.containerBookingId)
&& bookingConfirmed?.containers?.length === 1,
`bookingId=${bookingId || 'missing'}, status=${bookingConfirmed?.oceanBooking?.status || 'missing'}`, OPTIONS)
if (!bookingId || !requirement?.id || !frozenTransportMatches) return
if (!bookingId || !requirement?.containerBookingId || !frozenTransportMatches) return
const latestContainerResponse = await context.request('GET', `/api/platform/v1/containers/${containerBookingId}`)
const latestContainer = context.apiSuccess(MODULE, '补号后读取模拟柜最新版本用于订舱关联', latestContainerResponse, '', OPTIONS)
const latestContainer = context.apiSuccess(MODULE, '合并前读取模拟柜最新版本', latestContainerResponse, '', OPTIONS)
const linkResponse = await request(context, 'POST', `/api/platform/v1/containers/${containerBookingId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), expectedVersion: Number(latestContainer?.version),
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(latestContainer?.version),
})
const linked = context.apiSuccess(MODULE, '预装补号后关联三期 CONFIRMED 订舱', linkResponse, '', OPTIONS)
const linked = context.apiSuccess(MODULE, '模拟柜原地合并三期 CONFIRMED 真实订舱柜', linkResponse, '', OPTIONS)
context.assert(MODULE, '订舱关联保持柜次计划与真实柜号身份', linkResponse,
id(linked?.id) === containerBookingId && linked?.cabinetNo === simulation.cabinetNo
&& linked?.containerNo === containerNo && id(linked?.oceanBookingId) === bookingId,
@@ -337,7 +325,8 @@ export async function run(context) {
const bookingReleased = context.apiSuccess(MODULE, '三期订舱携带已冻结运输快照并放舱', releaseResponse, '', OPTIONS)
context.assert(MODULE, '三期订舱 RELEASED 后保持模拟柜关联身份', releaseResponse,
bookingReleased?.oceanBooking?.status === 'RELEASED'
&& bookingReleased?.containerAllocations?.some?.((item) => id(item.containerBookingId) === containerBookingId)
&& bookingReleased?.containers?.some?.((item) => id(item.containerBookingId) === containerBookingId
&& item.containerNo === containerNo)
&& transportFields.every((field) => String(bookingReleased?.oceanBooking?.[field]) === String(frozenTransport[field])),
`status=${bookingReleased?.oceanBooking?.status || 'missing'}, containerBookingId=${containerBookingId}`, OPTIONS)
if (bookingReleased?.oceanBooking?.status !== 'RELEASED') return
@@ -352,7 +341,7 @@ export async function run(context) {
`/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`)
const confirmable = context.apiSuccess(MODULE, '计划级确认前读取即时版本与就绪门禁', confirmableResponse, '', OPTIONS)
const confirmableVersion = Number(confirmable?.summary?.version)
context.assert(MODULE, '补号关联放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
context.assert(MODULE, '真实柜合并放舱后整份计划允许确认且 PDA 尚未创建', confirmableResponse,
Number.isInteger(confirmableVersion) && confirmable?.summary?.status === 'EDITING'
&& confirmable?.summary?.readiness?.canConfirm === true
&& confirmable?.summary?.readiness?.readyForPda === false
@@ -14,6 +14,7 @@ test('each remainder uses a simulation linked to a confirmed then released booki
assert.match(createPayload, /customerId: source\.tenantId/)
assert.match(createPayload, /channelCode: source\.channelCode/)
assert.match(source, /ocean-booking-link/)
assert.match(source, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(source, /bookingDetail\.oceanBooking\.status === 'CONFIRMED'/)
assert.match(source, /`\$\{bookingPath\}\/release`/)
const releaseStart = source.indexOf('const releaseResponse')
@@ -39,18 +40,18 @@ test('release stops unless creation response and detail expose the same complete
assert.match(source, /if \(!transportFrozen\) return null/)
})
test('PDA opens only after a real container number and uses stable plan identity', () => {
test('PDA opens only after merging a registered real container and uses stable plan identity', () => {
const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`")
const transportIndex = source.indexOf("`${CONTAINERS}/${encodeURIComponent(containerId)}/transport`")
const linkIndex = source.indexOf("`${CONTAINERS}/${containerId}/ocean-booking-link`")
const releaseIndex = source.indexOf("`${bookingPath}/release`")
const confirmIndex = source.indexOf("`/api/platform/v1/first-leg/loading-plans/${encodeURIComponent(planId)}/confirm`")
const taskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`")
const canonicalTaskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`", taskIndex + 1)
assert.ok(transportIndex >= 0 && releaseIndex > transportIndex && pushIndex > releaseIndex
assert.ok(linkIndex >= 0 && releaseIndex > linkIndex && pushIndex > releaseIndex
&& taskIndex > pushIndex && confirmIndex > taskIndex && canonicalTaskIndex > confirmIndex)
assert.match(source, /containerNo: realContainerNo/)
assert.match(source, /const transportStable = transported\?\.containerNo === realContainerNo/)
assert.match(source, /transported\?\.cabinetNo === simulation\.cabinetNo/)
assert.match(source, /const realContainerNo = requirement\?\.containerNo/)
assert.match(source, /const transportStable = linked\?\.containerNo === realContainerNo/)
assert.match(source, /linked\?\.cabinetNo === simulation\.cabinetNo/)
assert.match(source, /if \(!transportStable\) return null/)
assert.match(source, /计划确认前不生成 PDA 任务/)
assert.match(source, /summary\?\.readiness\?\.canConfirm === true/)
@@ -92,8 +93,8 @@ test('booking freezes the source customer channel and warehouse scope', () => {
assert.match(source, /customerId: source\.tenantId/)
assert.match(source, /channelCode: source\.channelCode/)
assert.match(source, /warehouseId: source\.warehouseId/)
assert.match(source, /containerType === '40HQ'/)
assert.match(source, /containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000/)
assert.match(source, /containerType === '40HC'/)
assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/)
})
test('booking fixture never invents supplier names without formal IDs', () => {
@@ -148,7 +148,7 @@ async function loadOneFragment(context, state) {
const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state
const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100)
const simulationResponse = await request(context, 'POST', CONTAINERS, {
channelCode: state.channelCode, warehouseId, containerType: '40HQ', capacityCbm: 68, maxWeightKg: 28000,
channelCode: state.channelCode, warehouseId, containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000,
})
const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id)
@@ -164,12 +164,13 @@ async function loadOneFragment(context, state) {
const bookingConfirmed = bookingCreated
context.assert(MODULE, `${sourceOrderNo} 一次登记直接返回 CONFIRMED`, bookingCreate,
bookingConfirmed?.oceanBooking?.status === 'CONFIRMED'
&& bookingConfirmed?.containerAllocations?.length === 0,
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, allocations=${bookingConfirmed?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ')
if (!requirement?.id) return null
&& bookingConfirmed?.containers?.length === 1,
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 'n/a'}`, OPTIONS)
const requirement = bookingConfirmed?.containers?.find?.((item) => item.containerType === '40HC')
const realContainerNo = requirement?.containerNo
if (!requirement?.containerBookingId || !realContainerNo) return null
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId, requirementId: id(requirement.id), expectedVersion: Number(simulation.version),
realContainerBookingId: id(requirement.containerBookingId), expectedVersion: Number(simulation.version),
})
const linked = context.apiSuccess(MODULE, `关联 ${sourceOrderNo} 的模拟柜与真实订舱`, linkResponse, '', OPTIONS)
context.assert(MODULE, `${sourceOrderNo} 关联后保持稳定内部柜次`, linkResponse,
@@ -213,29 +214,21 @@ async function loadOneFragment(context, state) {
isConflict(staleAssign) && hasTrace(staleAssign),
`HTTP=${staleAssign.http}, code=${codeOf(staleAssign)}, requestId=${staleAssign.requestId || 'missing'}, traceId=${staleAssign.traceId || 'missing'}`, OPTIONS)
const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`)
const containerDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 的自动柜次`, containerDetailResponse, '', OPTIONS)
const realContainerNo = `SPLIT-${operationBase.replace(/[^A-Za-z0-9]/g, '').slice(-18)}`.slice(0, 64)
const transportResponse = await request(context, 'PATCH', `${CONTAINERS}/${encodeURIComponent(containerId)}/transport`, {
containerNo: realContainerNo, voyageNo: `V-${sequence}`,
version: Number(containerDetail?.version), reason: context.marker,
})
const transported = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 补充真实柜号并开放 PDA`, transportResponse, '', OPTIONS)
const transportStable = transported?.containerNo === realContainerNo && id(transported?.id) === containerId
&& transported?.cabinetNo === simulation.cabinetNo
context.assert(MODULE, `${sourceOrderNo} 真实柜号已绑定到稳定柜次`, transportResponse,
const transportStable = linked?.containerNo === realContainerNo && id(linked?.id) === containerId
&& linked?.cabinetNo === simulation.cabinetNo
context.assert(MODULE, `${sourceOrderNo} 合并后真实柜号落在稳定模拟柜身份`, linkResponse,
transportStable,
`containerBookingId=${containerId}, containerNo=${transported?.containerNo || 'missing'}, cabinetNo=${transported?.cabinetNo || 'missing'}`, OPTIONS)
`containerBookingId=${containerId}, containerNo=${linked?.containerNo || 'missing'}, cabinetNo=${linked?.cabinetNo || 'missing'}`, OPTIONS)
if (!transportStable) return null
const bookingPath = `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`
let bookingDetailResponse = await context.request('GET', bookingPath)
let bookingDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 柜次所属真实订舱`, bookingDetailResponse, '', OPTIONS)
const allocationMatches = bookingDetail?.containerAllocations?.some?.((item) =>
const allocationMatches = bookingDetail?.containers?.some?.((item) =>
id(item?.containerBookingId) === containerId)
context.assert(MODULE, `${sourceOrderNo} 订舱分配保持稳定柜次`, bookingDetailResponse,
id(bookingDetail?.oceanBooking?.id) === oceanBookingId && allocationMatches,
`oceanBookingId=${id(bookingDetail?.oceanBooking?.id) || 'missing'}, containerBookingId=${containerId}, allocations=${bookingDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
`oceanBookingId=${id(bookingDetail?.oceanBooking?.id) || 'missing'}, containerBookingId=${containerId}, containers=${bookingDetail?.containers?.length ?? 'n/a'}`, OPTIONS)
if (!allocationMatches || id(bookingDetail?.oceanBooking?.id) !== oceanBookingId) return null
const createdTransport = bookingTransportSnapshot(bookingCreated?.oceanBooking)
@@ -262,7 +255,7 @@ async function loadOneFragment(context, state) {
bookingDetailResponse = await context.request('GET', bookingPath)
bookingDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 的 RELEASED 订舱`, bookingDetailResponse, '', OPTIONS)
}
const releasedAllocationMatches = bookingDetail?.containerAllocations?.some?.((item) =>
const releasedAllocationMatches = bookingDetail?.containers?.some?.((item) =>
id(item?.containerBookingId) === containerId)
context.assert(MODULE, `${sourceOrderNo} 仅在 RELEASED 且柜次分配稳定后推送`, bookingDetailResponse,
bookingDetail?.oceanBooking?.status === 'RELEASED' && releasedAllocationMatches,
@@ -42,7 +42,6 @@ async function request(context, method, path, body, headers = {}) {
function oceanDraft(context, suffix, master) {
return {
...oceanBookingDraft(context, suffix, master),
shippingCompanyName: `船期船司-${suffix}`, bookingAgentName: `船期代理-${suffix}`,
departurePortCode: 'CNSZX',
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO',
@@ -52,7 +51,6 @@ function oceanDraft(context, suffix, master) {
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` },
],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }],
}
}
@@ -65,7 +63,7 @@ async function ensureOceanBooking(context, suffix, master) {
return detail?.oceanBooking ? {
id: existingId,
customerId: id(detail.oceanBooking.customerId),
allocatedContainerBookingIds: (detail.containerAllocations || [])
allocatedContainerBookingIds: (detail.containers || [])
.map((item) => id(item?.containerBookingId)).filter(Boolean),
} : null
}
@@ -78,7 +76,7 @@ async function ensureOceanBooking(context, suffix, master) {
`oceanBookingId=${oceanBookingId || 'missing'}, customerId=${id(detail?.oceanBooking?.customerId) || 'missing'}`, OPTIONS)
return oceanBookingId ? {
id: oceanBookingId, customerId: id(detail.oceanBooking.customerId),
allocatedContainerBookingIds: (detail.containerAllocations || [])
allocatedContainerBookingIds: (detail.containers || [])
.map((item) => id(item?.containerBookingId)).filter(Boolean),
} : null
}
@@ -90,9 +90,8 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
'无真实柜号推送预装成功并冻结柜次明细',
'无号 push 成功后 PDA 查询明确 A0404',
'push exact replay 对计划版本历史关联和任务零漂移',
'预装 EDITING 清单绑定真实柜号',
'已补号但未关联订舱仍无 PDA 任务',
'预装补号后关联三期 CONFIRMED 订舱',
'无号且未关联订舱仍无 PDA 任务',
'模拟柜原地合并三期 CONFIRMED 真实订舱柜',
'订舱已关联但未 RELEASED 仍无 PDA 任务',
'三期订舱携带已冻结运输快照并放舱',
'订舱 RELEASED 但预装未 CONFIRMED 仍无 PDA 任务',
@@ -244,38 +243,36 @@ test('container loading arranges a simulation before linking a confirmed real bo
"const planResponse = await request(context, 'POST', `${SMART}/plans`",
"const assignResponse = await request",
'无真实柜号推送预装计划成功',
'预装 EDITING 清单绑定本轮真实柜号',
"const bookingCreate = await request",
"const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`",
])
assert.match(value, /channelCode, warehouseId, warehouseName/)
assert.match(value, /publishPlatformLogisticsChannels\(context, warehouseId, MODULE\)/)
assert.match(value, /warehouseId, warehouseName/)
const draft = value.slice(value.indexOf('const bookingDraft = {'), value.indexOf('context.assert(MODULE, \'订舱渠道与仓库快照完整\''))
assert.doesNotMatch(draft, /warehouseName\s*:/)
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(draft, new RegExp(`${field}:`))
}
assert.doesNotMatch(value, /loading-booking-(?:submit|confirm)|ocean-bookings\/.*\/(?:submit|confirm)/)
assert.match(value, /containerAllocations\.length === 0/)
assert.match(value, /expectedVersion: Number\(postPushPlan\.version\), operationReason: context\.marker/)
assert.match(value, /confirmedBooking\.containers\.length === 2/)
assert.match(value, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(value, /function responseEvidence\(response\)[\s\S]*HTTP=\$\{response\?\.http \?\? 'missing'\}[\s\S]*code=\$\{codeOf\(response\)\}[\s\S]*requestId=\$\{response\?\.requestId \|\| 'missing'\}[\s\S]*traceId=\$\{response\?\.traceId \|\| 'missing'\}/)
assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜首次关联真实订舱', emptyLink,\s+responseEvidence\(emptyLink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyUnlink = await request/)
assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜允许解除关联', emptyUnlink,\s+responseEvidence\(emptyUnlink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyRelink = await request/)
assert.match(value, /emptySimulation = context\.apiSuccess\(MODULE, '空模拟柜解绑后允许重新关联', emptyRelink,\s+responseEvidence\(emptyRelink\), OPTIONS\)\s+if \(!emptySimulation\) return\s+const emptyFinalUnlink = await request/)
assert.match(value, /const emptyReleased = context\.apiSuccess\(MODULE, '空模拟柜最终解绑释放真实订舱容量', emptyFinalUnlink,\s+responseEvidence\(emptyFinalUnlink\), OPTIONS\)\s+if \(!emptyReleased\) return\s+const beforeLinkItems/)
assert.match(value, /合并后列表仅保留模拟柜原地转成的一条 ACTIVE 操作身份/)
assert.match(value, /订舱真实柜 allocation 已原子改指稳定模拟柜身份/)
assert.match(value, /已消费 MERGED 真实柜拒绝再次关联/)
assertOrder(value, [
"const linkedContainer = context.apiSuccess(MODULE, '首次关联真实已确认订舱柜', linkResponse,",
'if (!linkedContainer) return',
"const afterLinkPlanResponse = await context.request('GET'",
"const duplicateSimulationResponse = await request(context, 'POST', CONTAINERS",
])
assert.equal(value.match(/responseEvidence\((?:emptyLink|emptyUnlink|emptyRelink|emptyFinalUnlink|linkResponse)\)/g)?.length, 5)
assert.match(value, /realContainerBookingId: id\(requirement\.containerBookingId\)/)
assert.match(value, /关联前后内部柜次、计划和排货明细身份稳定/)
assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/)
assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/)
assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/)
assert.ok(value.includes('`${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId='))
assert.match(value, /预装 EDITING 清单绑定本轮真实柜号/)
assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装计划成功'), value.indexOf('const linkResponse')),
/\/transport/)
assert.match(value, /订舱携带已冻结运输快照放舱并满足 PDA 门禁/)
assert.match(value, /无真实柜号推送预装计划成功/)
assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
@@ -360,8 +357,7 @@ test('container loading enforces channel isolation and stable PDA identity acros
'人工分配本轮候选货物',
'无真实柜号推送预装计划成功',
'无号 push 成功后 PDA 查询 A0404',
'预装 EDITING 清单绑定本轮真实柜号',
'已补号但未关联订舱不生成 PDA 任务',
'无号且未关联订舱不生成 PDA 任务',
'首次关联真实已确认订舱柜',
'订舱已关联但未 RELEASED 不生成 PDA 任务',
'订舱携带已冻结运输快照放舱并满足 PDA 门禁',
@@ -501,7 +497,7 @@ test('downstream producers prefer the phase3Loading container fixture', () => {
assert.doesNotMatch(serviceCenter, /链路承运商-/)
assert.doesNotMatch(serviceCenter, /customerName: `服务中心链路客户-/)
const schedules = source('./phase3-port-schedules.mjs')
assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containerAllocations \|\| \[\]\)/)
assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containers \|\| \[\]\)/)
assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/)
assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/)
})
@@ -64,10 +64,10 @@ export async function run(context) {
return
}
if (linkContainer) {
const allocationMatches = oceanDetail?.containerAllocations?.some?.((item) =>
const allocationMatches = oceanDetail?.containers?.some?.((item) =>
id(item?.containerBookingId) === id(containerFlow.containerBookingId))
context.assert(MODULE, '已放舱订舱保留本轮柜次分配', oceanResponse, allocationMatches,
`containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${oceanDetail?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
`containerBookingId=${id(containerFlow.containerBookingId)}, containers=${oceanDetail?.containers?.length ?? 'n/a'}`, OPTIONS)
if (!allocationMatches) return
}
const payload = {
@@ -56,7 +56,7 @@ test('test-environment full acceptance keeps assigned and unassigned customer bo
assert.match(bookingModule, /oceanBookingDraftWithoutCustomer/)
assert.match(bookingModule, /createdResources\.oceanBookingUnassignedCustomer/)
assert.match(bookingModule, /customerId == null[\s\S]*customerName == null/)
assert.match(bookingModule, /status === 'CONFIRMED'[\s\S]*containerAllocations\?\.length === 0/)
assert.match(bookingModule, /status === 'CONFIRMED'[\s\S]*containers\?\.length === 1/)
assert.match(bookingModule, /keyword=\$\{encodeURIComponent\(bookingNo\)\}/)
assert.doesNotMatch(bookingModule, /keyword=\$\{encodeURIComponent\(context\.marker\)\}/)
assert.match(bookingModule, /指定客户订舱持久化本轮客户范围快照/)
@@ -73,13 +73,12 @@ test('test-environment loading chains push without a number and open PDA only af
const pushIndex = bridgeLoading.indexOf('无真实柜号推送预装成功并冻结柜次明细')
const absentIndex = bridgeLoading.indexOf('无号 push 成功后 PDA 查询明确 A0404')
const transportIndex = bridgeLoading.indexOf('预装 EDITING 清单绑定真实柜号')
const linkIndex = bridgeLoading.indexOf('预装补号后关联三期 CONFIRMED 订舱')
const linkIndex = bridgeLoading.indexOf('模拟柜原地合并三期 CONFIRMED 真实订舱柜')
const releaseIndex = bridgeLoading.indexOf('三期订舱携带已冻结运输快照并放舱')
const confirmIndex = bridgeLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
const pdaIndex = bridgeLoading.indexOf('读取柜次级 PDA 唯一事实任务')
assert.ok(pushIndex >= 0 && absentIndex > pushIndex && transportIndex > absentIndex
&& linkIndex > transportIndex && releaseIndex > linkIndex && confirmIndex > releaseIndex && pdaIndex > confirmIndex)
assert.ok(pushIndex >= 0 && absentIndex > pushIndex
&& linkIndex > absentIndex && releaseIndex > linkIndex && confirmIndex > releaseIndex && pdaIndex > confirmIndex)
assert.match(bridgeLoading, /codeOf\(missingContainerTaskResponse\) === 'A0404'/)
assert.match(bridgeLoading, /push exact replay 对计划版本历史关联和任务零漂移/)
assert.match(bridgeLoading, /bookingReleased\?\.oceanBooking\?\.status === 'RELEASED'/)
@@ -89,13 +88,12 @@ test('test-environment loading chains push without a number and open PDA only af
assert.doesNotMatch(bridgeLoading, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
const containerPushIndex = containerLoading.indexOf('无真实柜号推送预装计划成功')
const containerTransportIndex = containerLoading.indexOf('预装 EDITING 清单绑定本轮真实柜号')
const containerLinkIndex = containerLoading.indexOf('首次关联真实已确认订舱柜')
const containerReleaseIndex = containerLoading.indexOf('订舱携带已冻结运输快照放舱并满足 PDA 门禁')
const containerConfirmIndex = containerLoading.indexOf('计划级原子确认整份预装并触发唯一 PDA 任务')
const containerPdaIndex = containerLoading.indexOf('查询本轮柜次 PDA 拣货任务')
assert.ok(containerPushIndex >= 0 && containerTransportIndex > containerPushIndex
&& containerLinkIndex > containerTransportIndex && containerReleaseIndex > containerLinkIndex
assert.ok(containerPushIndex >= 0 && containerLinkIndex > containerPushIndex
&& containerReleaseIndex > containerLinkIndex
&& containerConfirmIndex > containerReleaseIndex
&& containerPdaIndex > containerConfirmIndex)
assert.match(containerLoading, /loading-plans\/\$\{planId\}\/confirm/)