From 6739c834679032f274a245025a835f1f9e9028f5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 17 Aug 2026 13:24:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 54 ++++++++- .../baiyue/firstleg/dto/ContainerDtos.java | 6 +- .../entity/ContainerBookingEntity.java | 3 + .../mapper/ContainerBookingMapper.java | 87 +++++++++++++- .../mapper/LoadingPickTaskMapper.java | 11 +- .../oceanbooking/dto/OceanBookingDtos.java | 24 ++-- ...OceanBookingContainerAllocationMapper.java | 7 +- .../repository/OceanBookingRepository.java | 28 +++++ .../service/OceanBookingService.java | 108 +++++++++++++++-- .../repository/ContainerRepository.java | 21 +++- .../service/ContainerOverviewService.java | 2 +- .../firstleg/service/ContainerService.java | 55 +++++++-- .../db/phase3-ocean-booking-schema.sql | 2 +- .../db/phase3-smart-loading-schema.sql | 50 +++++++- .../main/resources/openapi/firstleg-v1.yaml | 16 +-- .../resources/openapi/ocean-bookings-v1.yaml | 103 ++++++++++++++-- .../ContainerControllerContractTest.java | 9 +- ...ormOceanBookingControllerContractTest.java | 12 +- .../OceanBookingSqlContractTest.java | 2 + .../service/OceanBookingServiceTest.java | 111 +++++++++++++++-- .../LoadingPickPdaTaskSqlContractTest.java | 1 + .../LoadingPickStableLookupContractTest.java | 9 +- .../SmartLoadingSqlContractTest.java | 16 +++ .../service/ContainerOverviewServiceTest.java | 4 +- .../service/ContainerServiceTest.java | 90 +++++++++++++- scripts/admin-test-env-modules/README.md | 2 +- ...r-simulation-association-contract.test.mjs | 37 ++++-- ...er-specification-fixture-contract.test.mjs | 49 ++++++++ .../manual-state-loading-pda.mjs | 17 +-- .../manual-state-ocean-trucking.mjs | 11 +- .../ocean-booking-fixture-contract.test.mjs | 50 +++++--- .../ocean-booking-fixture.mjs | 9 +- .../admin-test-env-modules/ocean-bookings.mjs | 88 +++++++++++--- .../phase3-carrier-schedule-queries.mjs | 2 - .../phase3-container-loading.mjs | 112 +++++++----------- ...se3-container-regression-contract.test.mjs | 5 +- .../admin-test-env-modules/phase3-loading.mjs | 45 +++---- .../phase3-order-splitting-contract.test.mjs | 17 +-- .../phase3-order-splitting.mjs | 35 +++--- .../phase3-port-schedules.mjs | 6 +- .../phase3-producer-chain-contract.test.mjs | 30 ++--- .../service-center-flow.mjs | 4 +- .../full-regression-entry-contract.test.mjs | 14 +-- 43 files changed, 1049 insertions(+), 315 deletions(-) create mode 100644 scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index c672d6abd..1eb3345f2 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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', '创建人名称快照'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index c6f7e4164..61b40f304 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -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, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index 64e0bc571..74cacf378 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -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; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index b2ca7fdb4..28736cedd 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -21,7 +21,8 @@ public interface ContainerBookingMapper extends BaseMapper 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 @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 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}) AND (booking.container_no LIKE CONCAT('%', #{keyword}, '%') diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 92fdc55a7..9feb5df0d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -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 parties, - @Valid @NotEmpty @Size(max = 20) List containerRequirements) {} + @Valid @NotEmpty @Size(max = 20) List 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 parties, - List containerRequirements, - List containerAllocations, + List containers, List histories) {} public record ChannelOption(Long priceBookVersionId, String channelCode, String channelName, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java index ae6b28f6b..cca20bf30 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingContainerAllocationMapper.java @@ -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 {} +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Update; +@Mapper public interface OceanBookingContainerAllocationMapper extends BaseMapper { + @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); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index 10c17d410..2d9fadcac 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -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 parties(long id) { return parties.selectList(new LambdaQueryWrapper().eq(OceanBookingPartyEntity::getOceanBookingId, id).orderByAsc(OceanBookingPartyEntity::getId)); } public List requirements(long id) { return requirements.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerRequirementEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerRequirementEntity::getSortNo)); } public List allocations(long id) { return allocations.selectList(new LambdaQueryWrapper().eq(OceanBookingContainerAllocationEntity::getOceanBookingId, id).orderByAsc(OceanBookingContainerAllocationEntity::getAllocatedAt)); } + public List containers(long id) { + Map 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() .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().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; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 4013f8ecf..679dcac07 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -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 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 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 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 parties(long id, List 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 requirements(long id, List 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 requirements(long id, List 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 requirements, + List 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 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("订舱状态无效"); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java index bbc06aa40..bfcc8904d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/ContainerRepository.java @@ -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() @@ -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; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java index 75985c31b..2733ac4a8 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerOverviewService.java @@ -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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index d99f79d09..7150e0210 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -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) { diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 10fba107c..5fcfb584a 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -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$$ diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index f7972f2e6..4b18eb36e 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -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 $$ diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 4f874a9c8..c6310e9a4 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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 diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 4b8a59e91..8d556eb6a 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -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] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index 26a95827d..5cc4db4a6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -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 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index b1ec85cbc..e98397e74 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -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") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java index 40dc4e06c..fb4510063 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java @@ -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)"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index d9655f66e..292c7b499 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -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 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 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 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> 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 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 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 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 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 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 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 stubSuccessfulCreation() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java index 56b16a3cb..dbe14da5b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickPdaTaskSqlContractTest.java @@ -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"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java index a9f9d7c47..9342ba8a4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickStableLookupContractTest.java @@ -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 ="); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index f1ce3f50f..341f89317 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -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 { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java index 27d925c57..0a7ea777f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java @@ -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}")); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index e84b98a71..054503d6f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -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 activeWarehouse(long warehouseId, String type, String name) { diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index ad37b0ad8..838a0849d 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -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` | diff --git a/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs b/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs index b7233529c..7aca70ebb 100644 --- a/scripts/admin-test-env-modules/container-simulation-association-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-simulation-association-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, /确认订舱并自动生成|确认响应包含自动生成/) }) diff --git a/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs new file mode 100644 index 000000000..378fc107e --- /dev/null +++ b/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs @@ -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) +}) diff --git a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs index 3f99ed65b..e7bb8139e 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -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) { diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 26d68590e..4380ea249 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -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()) { diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs index 1a9e8dcd5..9c91b730c 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture-contract.test.mjs @@ -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) }) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index 1ed071ace..e95afc905 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -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, } } diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 3abec50f5..ff727dc3b 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs index 343543517..3195018da 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.mjs @@ -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 }], } } diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index a285a3541..5c1d7320a 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 8552df0cc..48d86f02e 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -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 门禁/) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index eb321fd9b..9e2ab135f 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index c7285ba93..0757ebca5 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -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', () => { diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index b51c21344..deca8ed8b 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -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, diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index f6e052a05..3a1d51f28 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -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 } diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 0f882e700..3b46bd76a 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -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\)/) }) diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 5c4b55b69..e867c885a 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -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 = { diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index e542ae229..cee8c4dcd 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -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/)