diff --git a/common-core/src/main/java/com/baiyue/common/core/container/ContainerSpecificationCatalog.java b/common-core/src/main/java/com/baiyue/common/core/container/ContainerSpecificationCatalog.java new file mode 100644 index 000000000..7a955bdc8 --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/container/ContainerSpecificationCatalog.java @@ -0,0 +1,56 @@ +package com.baiyue.common.core.container; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** Platform-wide container types and practical loading limits. */ +public final class ContainerSpecificationCatalog { + public static final String DEFAULT_CONTAINER_TYPE = "40HQ"; + + private static final List ORDERED_VALUES = List.of( + new Specification("40GP", new BigDecimal("56"), new BigDecimal("58"), new BigDecimal("19500"), + "美线电商较少使用,通常优先选择40HQ"), + new Specification("40HQ", new BigDecimal("65"), new BigDecimal("68"), new BigDecimal("19500"), + "轻泡货首选,适合家居、玩具、日用品"), + new Specification("45HQ", new BigDecimal("82"), new BigDecimal("86"), new BigDecimal("19500"), + "抛货量大时考虑,底盘和海外仓预约资源较少") + ); + private static final Map VALUES = ORDERED_VALUES.stream() + .collect(Collectors.toUnmodifiableMap(Specification::containerType, Function.identity())); + + private ContainerSpecificationCatalog() {} + + public static List values() { + return ORDERED_VALUES; + } + + public static Specification require(String containerType) { + String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT); + Specification specification = VALUES.get(normalized); + if (specification == null) { + throw new BizException(ResultCode.VALIDATION_FAILED, "柜型仅支持40GP、40HQ、45HQ"); + } + return specification; + } + + public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) { + Specification specification = require(containerType); + if (capacityCbm == null || maxWeightKg == null + || specification.capacityCbm().compareTo(capacityCbm) != 0 + || specification.maxWeightKg().compareTo(maxWeightKg) != 0) { + throw new BizException(ResultCode.VALIDATION_FAILED, + "柜型容量和货重上限必须使用平台统一定义"); + } + return specification; + } + + public record Specification(String containerType, BigDecimal recommendedMinCbm, BigDecimal capacityCbm, + BigDecimal maxWeightKg, String description) {} +} diff --git a/common-core/src/test/java/com/baiyue/common/core/container/ContainerSpecificationCatalogTest.java b/common-core/src/test/java/com/baiyue/common/core/container/ContainerSpecificationCatalogTest.java new file mode 100644 index 000000000..058c744c9 --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/container/ContainerSpecificationCatalogTest.java @@ -0,0 +1,33 @@ +package com.baiyue.common.core.container; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ContainerSpecificationCatalogTest { + @Test + void exposesTheThreeCanonicalContainerTypesInBusinessOrder() { + assertThat(ContainerSpecificationCatalog.values()) + .extracting(ContainerSpecificationCatalog.Specification::containerType) + .containsExactly("40GP", "40HQ", "45HQ"); + assertThat(ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE).isEqualTo("40HQ"); + } + + @Test + void exposesPracticalLoadingRangesAndRejectsRemovedTypes() { + var specification = ContainerSpecificationCatalog.requireMatch( + "40hq", new BigDecimal("68.000"), new BigDecimal("19500.000")); + + assertThat(specification.containerType()).isEqualTo("40HQ"); + assertThat(specification.recommendedMinCbm()).isEqualByComparingTo("65"); + assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HC")) + .isInstanceOf(BizException.class).hasMessageContaining("40GP、40HQ、45HQ"); + assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch( + "40HQ", new BigDecimal("76"), new BigDecimal("19500"))) + .isInstanceOf(BizException.class).hasMessageContaining("统一定义"); + } +} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index dc92b6d01..bf509dfe9 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -9919,8 +9919,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照', - service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', - freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', remark VARCHAR(1000) NULL COMMENT '平台内部备注', @@ -9997,8 +9995,6 @@ PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check; EXECUTE ocean_drop_status_stmt; DEALLOCATE PREPARE ocean_drop_status_stmt; ALTER TABLE fl_ocean_booking - MODIFY COLUMN service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', - MODIFY COLUMN freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', ADD CONSTRAINT chk_ocean_booking_status CHECK (status IN ('DRAFT','RELEASED')); @@ -14879,11 +14875,9 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_po CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'destination_port_name', '目的港名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_arrival_at', '当前确认预计到港时间'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_sailing_at', '当前确认预计开船时间'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'freight_term', '海运费支付条款快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'id', '海运订舱ID'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'place_of_delivery', '最终交货地快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'remark', '平台内部备注'); -CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'service_term', '运输服务条款快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_id', '船公司逻辑ID快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_name', '船公司名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT草稿、RELEASED已发布且永久只读'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java deleted file mode 100644 index c5d101c7d..000000000 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalog.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.baiyue.firstleg.domain; - -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; - -import java.math.BigDecimal; -import java.util.Locale; -import java.util.Map; - -/** Platform-controlled container specifications shared by booking and simulation flows. */ -public final class ContainerSpecificationCatalog { - private static final Map VALUES = Map.of( - "20GP", new Specification("20GP", new BigDecimal("33"), new BigDecimal("28000")), - "40GP", new Specification("40GP", new BigDecimal("67"), new BigDecimal("28000")), - "40HC", new Specification("40HC", new BigDecimal("76"), new BigDecimal("28000")), - "40NR", new Specification("40NR", new BigDecimal("67"), new BigDecimal("28000")), - "45HC", new Specification("45HC", new BigDecimal("86"), new BigDecimal("28000")) - ); - - private ContainerSpecificationCatalog() {} - - public static Specification require(String containerType) { - String normalized = containerType == null ? "" : containerType.trim().toUpperCase(Locale.ROOT); - Specification specification = VALUES.get(normalized); - if (specification == null) { - throw new BizException(ResultCode.VALIDATION_FAILED, "柜型必须使用平台固定规格"); - } - return specification; - } - - public static Specification requireMatch(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) { - Specification specification = require(containerType); - if (capacityCbm == null || maxWeightKg == null - || specification.capacityCbm().compareTo(capacityCbm) != 0 - || specification.maxWeightKg().compareTo(maxWeightKg) != 0) { - throw new BizException(ResultCode.VALIDATION_FAILED, - "柜型容量和限重必须使用平台固定规格"); - } - return specification; - } - - public record Specification(String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg) {} -} 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 ab58dda00..616b98671 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 @@ -72,8 +72,6 @@ public final class OceanBookingDtos { @Size(max = 32) String destinationPortCode, @Size(max = 128) String destinationPortName, @Size(max = 128) String placeOfDelivery, - @Size(max = 32) String serviceTerm, - @Size(max = 32) String freightTerm, LocalDateTime estimatedSailingAt, LocalDateTime estimatedArrivalAt, @Size(max = 1000) String remark, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java index 4e79948fd..f4d3caef3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java @@ -37,8 +37,6 @@ public class OceanBookingEntity { private String destinationPortCode; private String destinationPortName; private String placeOfDelivery; - private String serviceTerm; - private String freightTerm; private LocalDateTime estimatedSailingAt; private LocalDateTime estimatedArrivalAt; private String remark; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index bd29c36c3..63ce5cd76 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -25,7 +25,7 @@ public interface OceanBookingMapper extends BaseMapper { """) int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); - @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},service_term=#{value.serviceTerm},freight_term=#{value.freightTerm},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") + @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int deleteDraft(@Param("id") long id, @Param("version") int version); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java index ca7a1b1dd..2f4dcf7cf 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java @@ -75,8 +75,8 @@ public class OceanBookingPrintService { fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode())); fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode())); fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery())); - fields.put("serviceTerm", text(booking.getServiceTerm())); - fields.put("freightTerm", text(booking.getFreightTerm())); + fields.put("serviceTerm", ""); + fields.put("freightTerm", ""); fields.put("totalPackagesInWords", ""); if (SHIPPING_INSTRUCTION.equals(template)) { fields.put("toCompany", text(booking.getBookingAgentName())); 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 14163fa4d..d973a9a68 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 @@ -9,7 +9,7 @@ import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.client.WarehousePartnerClient; import com.baiyue.firstleg.client.WarehouseSupplierClient; import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus; -import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; +import com.baiyue.common.core.container.ContainerSpecificationCatalog; import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; @@ -75,8 +75,9 @@ public class OceanBookingService { SmartLoadingChannelGroupCatalog.Definition group; try { group = SmartLoadingChannelGroupCatalog.require(query.channelGroupCode()); } catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); } + String containerType = ContainerSpecificationCatalog.require(query.containerType()).containerType(); return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), - group.code(), query.containerType().trim(), query.page(), query.size()), group.channelCodes()); + group.code(), containerType, query.page(), query.size()), group.channelCodes()); } public List channels(long originWarehouseId) { @@ -228,8 +229,7 @@ public class OceanBookingService { List requirements) { if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null || !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName()) - || !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm()) - || !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null + || !StringUtils.hasText(booking.getVoyageNo()) || booking.getEstimatedSailingAt() == null || booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null || (!StringUtils.hasText(booking.getDeparturePortName()) && !StringUtils.hasText(booking.getDeparturePortCode())) || (!StringUtils.hasText(booking.getDestinationPortName()) && !StringUtils.hasText(booking.getDestinationPortCode())) @@ -287,7 +287,7 @@ public class OceanBookingService { v.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo())); v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName())); - v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setServiceTerm(trim(d.serviceTerm())); v.setFreightTerm(trim(d.freightTerm())); + v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt()); v.setRemark(trim(d.remark())); } 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 c2565a9ff..57a358a62 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -6,7 +6,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.domain.SmartLoadingStatus; -import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; +import com.baiyue.common.core.container.ContainerSpecificationCatalog; import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java index 333213814..7594856e1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerWorkbenchService.java @@ -3,6 +3,7 @@ package com.baiyue.firstleg.terminaldrayage.service; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.container.ContainerSpecificationCatalog; import com.baiyue.firstleg.dto.ContainerDtos.Query; import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; @@ -85,8 +86,12 @@ public class ContainerWorkbenchService { } private static Query normalize(Query query) { + String containerType = trim(query.containerType()); + if (containerType != null) { + containerType = ContainerSpecificationCatalog.require(containerType).containerType(); + } return new Query(trim(query.containerNo()), trim(query.cabinetNo()), trim(query.billOfLadingNo()), - trim(query.keyword()), query.customerId(), query.warehouseId(), trim(query.containerType()), + trim(query.keyword()), query.customerId(), query.warehouseId(), containerType, query.shippingCompanyId(), trim(query.departurePortCode()), trim(query.destinationPortCode()), trim(query.destinationWarehouseCode()), query.status(), query.estimatedSailingFrom(), query.estimatedSailingTo(), query.scope(), query.page(), query.size()); 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 06742b374..556af84e4 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -24,8 +24,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', place_of_delivery VARCHAR(128) NULL COMMENT '最终交货地快照', - service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', - freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', remark VARCHAR(1000) NULL COMMENT '平台内部备注', @@ -76,8 +74,6 @@ PREPARE ocean_drop_status_stmt FROM @ocean_drop_status_check; EXECUTE ocean_drop_status_stmt; DEALLOCATE PREPARE ocean_drop_status_stmt; ALTER TABLE fl_ocean_booking - MODIFY COLUMN service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填', - MODIFY COLUMN freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填', ADD CONSTRAINT chk_ocean_booking_status CHECK (status IN ('DRAFT','RELEASED')); @@ -110,10 +106,10 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party ( CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement ( id BIGINT UNSIGNED NOT NULL COMMENT '订舱箱型需求ID', ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '所属海运订舱ID', - container_type VARCHAR(32) NOT NULL COMMENT '所需集装箱尺寸类型编码', + container_type VARCHAR(32) NOT NULL COMMENT '平台统一柜型:40GP、40HQ、45HQ;默认40HQ', container_kind VARCHAR(32) NOT NULL COMMENT '所需集装箱箱种编码', - capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '该需求柜型可用容量快照m3', - max_weight_kg DECIMAL(18,3) NOT NULL COMMENT '该需求柜型最大承重快照kg', + capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '统一实装上限快照m3:40GP=58、40HQ=68、45HQ=86', + max_weight_kg DECIMAL(18,3) NOT NULL COMMENT '统一货重上限快照kg:19500', quantity INT UNSIGNED NOT NULL COMMENT '该箱型箱种所需柜数', sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序', PRIMARY KEY (id), 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 dba77f523..b4a5b92c6 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', vessel_name VARCHAR(128) NULL COMMENT '船名快照', voyage_no VARCHAR(64) NULL COMMENT '航次;确认前允许修订', - container_type VARCHAR(32) NOT NULL COMMENT '平台固定柜型:20GP、40GP、40HC、40NR、45HC', + container_type VARCHAR(32) NOT NULL COMMENT '平台统一柜型:40GP、40HQ、45HQ;默认40HQ', shipping_company_id BIGINT UNSIGNED NULL COMMENT '船司ID快照', shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照', departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 9477cf80a..023e9849a 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -577,7 +577,7 @@ paths: LOADING 全部未完成状态,已完成装柜永不返回。状态映射为:Order SUBMITTED=PENDING_REVIEW; Firstleg WAIT_MEASURE/MEASURING/MEASURE_PENDING/MEASURE_REJECTED/WAIT_PRICING/FEE_CONFIRMING/INBOUNDING =WAIT_MEASURE;WAIT_SPLIT=WAIT_SPLIT;LOADING=LOADING。状态只用于搜索和展示,canAssign 由有效柜次占用、 - 剩余箱数、同柜组和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。 + 剩余箱数、同柜组和容量等事实决定;容积和重量均允许达到柜型基准值的150%,超过即拒绝。 待审核及待测量可以先加入清单;确认预装/PDA 前 仍必须完成审核与测量并进入 WAIT_SPLIT。同柜不可拆组仅在全部成员命中当前状态集合时作为一个分页单位返回。 parameters: @@ -719,7 +719,7 @@ paths: 服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单; 人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道; 占用、同柜组和容量门禁不变。 - 柜内总容积和总重量分别允许达到柜型额定值的150%,超过即拒绝。 + 柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。 只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。 parameters: - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -2214,7 +2214,7 @@ components: billOfLadingNo: {type: string, maxLength: 128, nullable: true} vesselName: {type: string, maxLength: 128, nullable: true} voyageNo: {type: string, maxLength: 64, nullable: true} - containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 平台固定柜型} + containerType: {type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型;默认40HQ} shippingCompanyId: {type: string, nullable: true} shippingCompanyName: {type: string, maxLength: 128, nullable: true} departurePortCode: {type: string, maxLength: 32, nullable: true} @@ -2223,8 +2223,8 @@ components: destinationPortName: {type: string, maxLength: 128, nullable: true} destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} destinationWarehouseName: {type: string, maxLength: 128, nullable: true} - capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量} - maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重} + capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86} + maxWeightKg: {type: number, enum: [19500], description: 统一货重上限} terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING} terminalTruckingCompany: {type: string, maxLength: 128, nullable: true, description: Server-frozen Warehouse supplier name snapshot} loadingAt: {type: string, format: date-time, nullable: true} @@ -2265,7 +2265,7 @@ components: billOfLadingNo: {type: string, maxLength: 128, nullable: true} vesselName: {type: string, maxLength: 128, nullable: true} voyageNo: {type: string, maxLength: 64, nullable: true} - containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 平台固定柜型} + containerType: {type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型;默认40HQ} shippingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} shippingCompanyName: {type: string, maxLength: 128, nullable: true} departurePortCode: {type: string, maxLength: 32, nullable: true} @@ -2274,8 +2274,8 @@ components: destinationPortName: {type: string, maxLength: 128, nullable: true} destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} destinationWarehouseName: {type: string, maxLength: 128, nullable: true} - capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量} - maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重} + capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86} + maxWeightKg: {type: number, enum: [19500], description: 统一货重上限} terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING} terminalTruckingCompany: {type: string, maxLength: 128, nullable: true, description: Optional consistency hint; persisted name always comes from Warehouse and cannot replace the ID} loadingAt: {type: string, format: date-time, nullable: true} @@ -2742,9 +2742,9 @@ components: properties: channelGroupCode: {type: string, maxLength: 64, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 智能装柜使用的九类计划渠道分组} warehouseId: {type: integer, format: int64, minimum: 1} - containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 与订舱共用的平台固定柜型} - capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量} - maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定限重} + containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, description: 与订舱共用的平台统一柜型} + capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86} + maxWeightKg: {type: number, enum: [19500], description: 统一货重上限} ContainerOceanBookingLinkRequest: type: object required: [oceanBookingId, requirementId, expectedVersion] diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 35fbed98f..d4d9b1962 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -117,7 +117,7 @@ paths: parameters: - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - {name: channelGroupCode, in: query, required: true, schema: {type: string, maxLength: 64}} - - {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}} + - {name: containerType, in: query, required: true, schema: {type: string, enum: [40GP, 40HQ, 45HQ]}} - {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}} responses: @@ -191,15 +191,13 @@ components: destinationPortCode: {type: string, nullable: true} destinationPortName: {type: string, nullable: true} placeOfDelivery: {type: string, nullable: true} - serviceTerm: {type: string, nullable: true} - freightTerm: {type: string, nullable: true} estimatedSailingAt: {type: string, format: date-time, nullable: true} estimatedArrivalAt: {type: string, format: date-time, nullable: true} remark: {type: string, nullable: true} parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}} containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}} PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}}} - ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0}, maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0}}} + ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}} ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}} ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}} DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index 988875e3e..0a904edec 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -84,7 +84,7 @@ class PlatformSmartLoadingControllerContractTest { .contains("成功响应中的 version 是递增后的 planVersion") .contains("items[].version 是递增后的 itemVersion") .contains("数量调整或动态增量不会覆盖已有协作备注") - .contains("柜内总容积和总重量分别允许达到柜型额定值的150%") + .contains("柜内总容积和总重量分别允许达到柜型基准值的150%") .contains("人工分配不限制订单国内仓及渠道与柜次一致") .contains("智能推荐允许跨国内仓,但仍只选择柜次渠道") .contains("canAssign: {type: boolean}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java b/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java deleted file mode 100644 index a4848da93..000000000 --- a/firstleg/src/test/java/com/baiyue/firstleg/domain/ContainerSpecificationCatalogTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.baiyue.firstleg.domain; - -import com.baiyue.common.core.exception.BizException; -import org.junit.jupiter.api.Test; - -import java.math.BigDecimal; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class ContainerSpecificationCatalogTest { - @Test - void exposesCanonicalSpecificationsAndRejectsAliases() { - var specification = ContainerSpecificationCatalog.requireMatch( - "40HC", new BigDecimal("76.000"), new BigDecimal("28000.000")); - - assertThat(specification.containerType()).isEqualTo("40HC"); - assertThatThrownBy(() -> ContainerSpecificationCatalog.require("40HQ")) - .isInstanceOf(BizException.class).hasMessageContaining("固定规格"); - assertThatThrownBy(() -> ContainerSpecificationCatalog.requireMatch( - "40HC", new BigDecimal("68"), new BigDecimal("28000"))) - .isInstanceOf(BizException.class).hasMessageContaining("容量和限重"); - } -} 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 71cbe0043..c854925a8 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 @@ -23,8 +23,8 @@ class OceanBookingSqlContractTest { .contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") .contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'") .contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") - .contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'") - .contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'") + .doesNotContain("service_term") + .doesNotContain("freight_term") .contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码") .contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')") .contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java index a27196d45..c8cbbfd66 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java @@ -67,8 +67,6 @@ class OceanBookingPrintServiceTest { Detail detail = detail("DRAFT"); detail.oceanBooking().setBillOfLadingNo(null); detail.oceanBooking().setPlaceOfDelivery(null); - detail.oceanBooking().setServiceTerm(null); - detail.oceanBooking().setFreightTerm(null); detail = new Detail(detail.oceanBooking(), List.of(detail.parties().get(0)), detail.containerRequirements(), List.of(), List.of(), List.of()); when(bookings.detail(10L)).thenReturn(detail); @@ -102,8 +100,7 @@ class OceanBookingPrintServiceTest { booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status); booking.setBillOfLadingNo("BL-1"); booking.setVesselName("Vessel"); booking.setVoyageNo("V001"); booking.setDeparturePortName("Ningbo"); booking.setDestinationPortName("LA"); - booking.setPlaceOfDelivery("LA DC"); booking.setServiceTerm("CY-CY"); - booking.setFreightTerm("PREPAID"); booking.setBookingAgentName("Agent"); + booking.setPlaceOfDelivery("LA DC"); booking.setBookingAgentName("Agent"); booking.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0)); OceanBookingPartyEntity shipper = new OceanBookingPartyEntity(); shipper.setPartyType("SHIPPER"); shipper.setPartyName("Shipper"); shipper.setAddress("Address"); @@ -113,7 +110,7 @@ class OceanBookingPrintServiceTest { notify.setPartyType("NOTIFY_PARTY"); notify.setPartyName("Notify"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); requirement.setContainerType("40HQ"); requirement.setContainerKind("DRY"); requirement.setQuantity(2); - requirement.setCapacityCbm(new BigDecimal("68")); requirement.setMaxWeightKg(new BigDecimal("28000")); + requirement.setCapacityCbm(new BigDecimal("68")); requirement.setMaxWeightKg(new BigDecimal("19500")); return new Detail(booking, List.of(shipper, consignee, notify), List.of(requirement), List.of(), List.of(), List.of()); } } 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 773b5efa3..3c31579a6 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 @@ -160,7 +160,7 @@ class OceanBookingServiceTest { ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture()); assertThat(captor.getValue()).singleElement().satisfies(value -> { - assertThat(value.getContainerType()).isEqualTo("40HC"); + assertThat(value.getContainerType()).isEqualTo("40HQ"); assertThat(value.getQuantity()).isEqualTo(2); }); verify(repository, never()).insertContainer(any()); @@ -326,13 +326,13 @@ class OceanBookingServiceTest { @Test void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() { - LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC", - new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2, + LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HQ", + new BigDecimal("68"), new BigDecimal("19500"), 3, 1, 2, "客户A", "深圳中转仓", "美西海运"); when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); PageResult result = service.linkCandidates(new LinkCandidateQuery(701L, - " MATSON_CLX ", " 40HC ", null, null)); + " MATSON_CLX ", " 40HQ ", null, null)); assertThat(result.records()).containsExactly(candidate); assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); @@ -340,7 +340,7 @@ class OceanBookingServiceTest { @SuppressWarnings("unchecked") ArgumentCaptor> channels = ArgumentCaptor.forClass(java.util.Set.class); verify(repository).linkCandidates(query.capture(), channels.capture()); assertThat(query.getValue().channelGroupCode()).isEqualTo("MATSON_CLX"); - assertThat(query.getValue().containerType()).isEqualTo("40HC"); + assertThat(query.getValue().containerType()).isEqualTo("40HQ"); assertThat(query.getValue().page()).isEqualTo(1L); assertThat(query.getValue().size()).isEqualTo(20L); assertThat(channels.getValue()).contains("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL", "ZBYHK"); @@ -354,7 +354,7 @@ class OceanBookingServiceTest { when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); PageResult result = service.linkCandidates( - new LinkCandidateQuery(701L, "MATSON_CLX", "45HC", null, null)); + new LinkCandidateQuery(701L, "MATSON_CLX", "45HQ", null, null)); assertThat(result.records()).isEmpty(); assertThat(result.total()).isZero(); @@ -402,19 +402,19 @@ class OceanBookingServiceTest { private static DraftData partialDraft(String remark) { return new DraftData(null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, null, null, null, remark, null, null); + null, null, null, null, null, null, null, null, null, remark, null, null); } private static DraftData completeDraft() { return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", "MATSON", 81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶", - "LAX", "CY-CY", "PREPAID", LocalDateTime.of(2026, 9, 1, 0, 0), + "LAX", LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), null, List.of(new PartyRequest("SHIPPER", "发货人", null, null, null), new PartyRequest("CONSIGNEE", "收货人", null, null, null), new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)), - List.of(new ContainerRequirementRequest("40HC", "DRY", 2, - new BigDecimal("76"), new BigDecimal("28000")))); + List.of(new ContainerRequirementRequest("40HQ", "DRY", 2, + new BigDecimal("68"), new BigDecimal("19500")))); } private static OceanBookingEntity completeBooking(String status, int version) { @@ -426,7 +426,6 @@ class OceanBookingServiceTest { value.setDeparturePortCode("CNSZX"); value.setDeparturePortName("深圳"); value.setDestinationPortCode("USLAX"); value.setDestinationPortName("洛杉矶"); value.setPlaceOfDelivery("LAX"); - value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID"); value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0)); value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0)); return value; @@ -447,9 +446,9 @@ class OceanBookingServiceTest { private static OceanBookingContainerRequirementEntity requirement() { var value = new OceanBookingContainerRequirementEntity(); - value.setId(11L); value.setContainerType("40HC"); value.setContainerKind("DRY"); - value.setQuantity(1); value.setCapacityCbm(new BigDecimal("76")); - value.setMaxWeightKg(new BigDecimal("28000")); return value; + value.setId(11L); value.setContainerType("40HQ"); value.setContainerKind("DRY"); + value.setQuantity(1); value.setCapacityCbm(new BigDecimal("68")); + value.setMaxWeightKg(new BigDecimal("19500")); return value; } private String hash(String action, Long id, Object request) throws Exception { 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 b173ab652..b5f2fbf24 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -85,7 +85,7 @@ class ContainerServiceTest { assertThat(saved.getChannelName()).isNull(); assertThat(saved.getWarehouseId()).isEqualTo(101L); assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓"); - assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000"); + assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000"); assertThat(saved.getPlanId()).isEqualTo(501L); assertThat(saved.getPlanNo()).isEqualTo("LP-260818-0001"); assertThat(saved.getPlanStatus()).isEqualTo("EDITING"); @@ -96,11 +96,11 @@ class ContainerServiceTest { @DisplayName("新增模拟柜拒绝柜型与固定规格不匹配") void rejectsSimulationWithNonCanonicalSpecification() { SimulationCreateRequest invalid = new SimulationCreateRequest( - "MATSON_CLX", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000")); + "MATSON_CLX", 101L, "40HQ", new BigDecimal("100"), new BigDecimal("19500")); assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid)) .isInstanceOf(BizException.class) - .hasMessageContaining("容量和限重"); + .hasMessageContaining("容量和货重上限"); verify(repository, never()).insert(any()); } @@ -204,12 +204,12 @@ class ContainerServiceTest { container.setPlanId(501L); container.setPlanNo("LP-260818-0001"); container.setContainerNo(null); - container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HC"); + container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HQ"); container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000")); OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX_RAIL_NY"); 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")); + requirement.setId(31L); requirement.setQuantity(1); requirement.setContainerType("40HQ"); requirement.setCapacityCbm(new BigDecimal("68.0000")); requirement.setMaxWeightKg(new BigDecimal("19500.000")); when(repository.find(11L)).thenReturn(container, container); when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); @@ -313,15 +313,15 @@ class ContainerServiceTest { void linksMultipleRealContainersToTheSameReleasedBookingRequirement() { ContainerBookingEntity first = booking("WAIT_LOADING", 2); first.setId(11L); first.setContainerNo(null); first.setWarehouseId(101L); - first.setChannelGroupCode("MATSON_CLX"); first.setChannelCode(null); first.setContainerType("40HC"); + first.setChannelGroupCode("MATSON_CLX"); first.setChannelCode(null); first.setContainerType("40HQ"); ContainerBookingEntity second = booking("WAIT_LOADING", 4); second.setId(12L); second.setContainerNo(null); second.setWarehouseId(101L); - second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HC"); + second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HQ"); OceanBookingEntity oceanBooking = new OceanBookingEntity(); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); - requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC"); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ"); when(repository.find(11L)).thenReturn(first); when(repository.find(12L)).thenReturn(second); when(oceanBookings.find(21L)).thenReturn(oceanBooking); @@ -362,19 +362,19 @@ class ContainerServiceTest { @Test @DisplayName("首次关联拒绝装柜仓库不一致") void rejectsLinkWhenWarehouseDiffers() { - assertThreeFieldMismatch(102L, "MATSON_CLX_TRUCK", "40HC"); + assertThreeFieldMismatch(102L, "MATSON_CLX_TRUCK", "40HQ"); } @Test @DisplayName("首次关联拒绝物流渠道不一致") void rejectsLinkWhenChannelDiffers() { - assertThreeFieldMismatch(101L, "HEDE_TRUCK", "40HC"); + assertThreeFieldMismatch(101L, "HEDE_TRUCK", "40HQ"); } @Test @DisplayName("首次关联拒绝柜型不一致") void rejectsLinkWhenContainerTypeDiffers() { - assertThreeFieldMismatch(101L, "MATSON_CLX_TRUCK", "45HC"); + assertThreeFieldMismatch(101L, "MATSON_CLX_TRUCK", "45HQ"); } @Test @@ -418,7 +418,7 @@ class ContainerServiceTest { ContainerBookingEntity container = booking("WAIT_LOADING", 2); container.setContainerNo(null); container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null); - container.setContainerType("40HC"); + container.setContainerType("40HQ"); OceanBookingEntity oceanBooking = new OceanBookingEntity(); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); oceanBooking.setWarehouseId(bookingWarehouseId); oceanBooking.setChannelCode(bookingChannel); @@ -440,12 +440,12 @@ class ContainerServiceTest { ContainerBookingEntity container = booking("WAIT_LOADING", 2); container.setContainerNo(null); container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null); - container.setContainerType("40HC"); + container.setContainerType("40HQ"); OceanBookingEntity oceanBooking = new OceanBookingEntity(); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK"); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); - requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC"); + requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HQ"); when(repository.find(11L)).thenReturn(container); when(oceanBookings.find(21L)).thenReturn(oceanBooking); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); @@ -485,15 +485,15 @@ class ContainerServiceTest { private static SaveRequest request() { return new SaveRequest(101L, "上海仓", "BOL-001", "柏悦轮", - "V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", - "ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A", + "V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", + "ONT8", "ONT8", new BigDecimal("68.0000"), new BigDecimal("19500.000"), 202L, "拖车公司A", LocalDateTime.of(2026, 8, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null, LocalDateTime.of(2026, 8, 25, 8, 0), null, LocalDateTime.of(2026, 8, 27, 8, 0), null); } private static SimulationCreateRequest simulation() { - return new SimulationCreateRequest("MATSON_CLX", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000")); + return new SimulationCreateRequest("MATSON_CLX", 101L, "40HQ", new BigDecimal("68.0000"), new BigDecimal("19500.000")); } private static ChannelRow channel() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 53181f21c..7ed1f0e21 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -552,8 +552,8 @@ class SmartLoadingServiceTest { @Test void pagesWorkbenchContainersWithTheSameGlobalFilters() { WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null, - 100L, "义乌仓", "MATSON_CLX", null, null, "40HC", - new BigDecimal("68"), new BigDecimal("28000"), + 100L, "义乌仓", "MATSON_CLX", null, null, "40HQ", + new BigDecimal("68"), new BigDecimal("19500"), 1L, "LP-1", "EDITING", 2); WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L, 9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index 30b6936be..19e63fcac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.looseboard.service; +import com.baiyue.common.core.container.ContainerSpecificationCatalog; + import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -203,7 +205,9 @@ public class LocalLooseboardService { order.setOriginAddress(trim(originAddress)); order.setDestination(destination.trim()); order.setDestinationAddress(trim(destinationAddress)); - order.setContainerType(trim(containerType)); + order.setContainerType(StringUtils.hasText(containerType) + ? ContainerSpecificationCatalog.require(containerType).containerType() + : ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE); order.setPackageCount(packageCount); order.setTotalWeightKg(totalWeightKg); order.setTotalVolumeCbm(totalVolumeCbm); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index f6aae16a0..c02177466 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.container.ContainerSpecificationCatalog; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; import com.baiyue.lastleg.trucking.entity.UsTruckOrder; @@ -84,7 +85,9 @@ public class UsTruckOrderService { order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null); order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); - order.setContainerType(StringUtils.hasText(request.containerType()) ? norm(request.containerType()) : null); + order.setContainerType(StringUtils.hasText(request.containerType()) + ? ContainerSpecificationCatalog.require(request.containerType()).containerType() + : ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE); order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount())); order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); @@ -138,7 +141,8 @@ public class UsTruckOrderService { if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); } setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress); setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress); - setText(request.containerType(), v -> order.setContainerType(norm(v))); + setText(request.containerType(), v -> order.setContainerType( + ContainerSpecificationCatalog.require(v).containerType())); if (request.palletCount() != null) order.setPalletCount(request.palletCount()); if (request.packageCount() != null) order.setPackageCount(request.packageCount()); if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm()); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 450a185cc..ce97e00d2 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2005,7 +2005,7 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( origin_address VARCHAR(500) NULL COMMENT '起运地址', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址', - container_type VARCHAR(32) NULL COMMENT '历史兼容设备类型,零担托盘业务不采集', + container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', @@ -2083,7 +2083,7 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( pallet_required VARCHAR(8) NOT NULL COMMENT '是否需要打盘:YES或NO', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', - container_type VARCHAR(32) NULL COMMENT '柜型', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', + container_type VARCHAR(32) NOT NULL DEFAULT '40HQ' COMMENT '平台统一柜型:40GP、40HQ、45HQ', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index f741a809f..f1f99cc00 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -2704,7 +2704,7 @@ components: transportType: { type: string, nullable: true } origin: { type: string } destination: { type: string } - containerType: { type: string, nullable: true } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 } palletCount: { type: integer } packageCount: { type: integer } receivedPalletCount: { type: integer } @@ -2760,7 +2760,7 @@ components: transportType: { type: string } origin: { type: string } destination: { type: string } - containerType: { type: string, nullable: true } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 } packageCount: { type: integer } totalWeightKg: { type: number } totalVolumeCbm: { type: number } @@ -3101,7 +3101,7 @@ components: originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, nullable: true, maxLength: 32 } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 } palletCount: { type: integer, minimum: 1 } packageCount: { type: integer, nullable: true, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } @@ -3123,7 +3123,7 @@ components: originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, nullable: true, maxLength: 32 } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 } palletCount: { type: integer, minimum: 1 } packageCount: { type: integer, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } @@ -3154,7 +3154,7 @@ components: originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, nullable: true, maxLength: 32 } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 } packageCount: { type: integer, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 } @@ -3177,7 +3177,7 @@ components: originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, nullable: true, maxLength: 32 } + containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 } packageCount: { type: integer, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index cc5d4f4aa..c09d2c968 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.looseboard.service; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation; @@ -85,6 +86,7 @@ class LocalLooseboardServiceTest { assertEquals("100", detail.summary().customerId()); assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status()); assertEquals("SHARED_TRUCK", detail.summary().transportMode()); + assertEquals("40HQ", detail.summary().containerType()); verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.argThat(value -> "LOCAL_LOOSEBOARD".equals(value.getOrderType()) && value.getSourceOrderNo().equals("LB-WH-20260901-000001") @@ -94,6 +96,22 @@ class LocalLooseboardServiceTest { "CREATE".equals(value.getOperationType()))); } + @Test + void createDefaultsTheContainerTypeAndRejectsRemovedTypes() { + AtomicReference saved = new AtomicReference<>(); + when(orders.insert(any(LocalLooseboardOrder.class))).thenAnswer(call -> { + saved.set(call.getArgument(0)); + return 1; + }); + when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get()); + when(histories.listOrder(anyString(), any())).thenReturn(List.of()); + + var detail = service.create("100", "7", create("default-type", null)); + + assertEquals("40HQ", detail.summary().containerType()); + assertThrows(BizException.class, () -> service.create("100", "7", create("retired-type", "40HC"))); + } + @Test void ordinaryUpdateCannotChangeAcceptedOrder() { LocalLooseboardOrder order = order(LocalLooseboardService.DISPATCHED, 0L); @@ -174,8 +192,12 @@ class LocalLooseboardServiceTest { } private LocalLooseboardRequests.Create create(String key) { + return create(key, "40HQ"); + } + + private LocalLooseboardRequests.Create create(String key, String containerType) { return new LocalLooseboardRequests.Create("SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", - "Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12, + "Los Angeles", "Address A", "Ontario", "Address B", containerType, 12, new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), "USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index a64b113c6..971e5975a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.trucking.service; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.entity.UsTruckOrder; import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; @@ -18,6 +20,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashMap; @@ -31,6 +35,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.lenient; @ExtendWith(MockitoExtension.class) class UsTruckOrderServiceTest { @@ -38,6 +43,7 @@ class UsTruckOrderServiceTest { @Mock UsTruckOrderPalletMapper pallets; @Mock UsTruckOrderStatusHistoryMapper histories; @Mock UsTruckOrderOperationMapper operations; + @Mock LastlegOrderNoGenerator orderNoGenerator; private final List palletRows = new ArrayList<>(); private final List historyRows = new ArrayList<>(); private final Map operationRows = new HashMap<>(); @@ -52,20 +58,21 @@ class UsTruckOrderServiceTest { ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2); - when(orders.lockNo("100", "TK1")).thenAnswer(call -> order); + lenient().when(orders.lockNo("100", "TK1")).thenAnswer(call -> order); when(orders.findNo("100", "TK1")).thenAnswer(call -> order); - when(pallets.listOrder("100", 1L)).thenAnswer(call -> palletRows); - when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows); + when(pallets.listOrder(anyString(), anyLong())).thenAnswer(call -> palletRows); + lenient().when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows); when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> { historyRows.add(call.getArgument(0)); return 1; }); - when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> { + lenient().when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> { UsTruckOrderPallet row = call.getArgument(0); palletRows.add(row); return 1; }); - when(operations.findKey(anyString(), anyLong(), anyString())) + lenient().when(operations.findKey(anyString(), anyLong(), anyString())) .thenAnswer(call -> operationRows.get(call.getArgument(2))); - when(operations.insert(any(UsTruckOrderOperation.class))).thenAnswer(call -> { + lenient().when(operations.insert(any(UsTruckOrderOperation.class))).thenAnswer(call -> { UsTruckOrderOperation row = call.getArgument(0); operationRows.put(row.getIdempotencyKey(), row); return 1; }); UsTruckOrderStatusHistory created = new UsTruckOrderStatusHistory(); @@ -73,6 +80,21 @@ class UsTruckOrderServiceTest { created.setToStatus(UsTruckOrderService.PENDING_REVIEW); historyRows.add(created); } + @Test + void createDefaultsTheContainerTypeAndRejectsRemovedTypes() { + when(orderNoGenerator.next(anyString(), anyString(), anyLong())).thenReturn("TK1"); + when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows); + when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { + order = call.getArgument(0); + return 1; + }); + + var detail = service.create("100", "operator", create(null, "default-type")); + + assertEquals("40HQ", detail.summary().containerType()); + assertThrows(BizException.class, () -> service.create("100", "operator", create("40HC", "retired-type"))); + } + @Test void completeLifecycleUsesFactsIdempotencyOperatorsAndCannotReverseTerminal() { var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", "approve"); @@ -165,6 +187,12 @@ class UsTruckOrderServiceTest { return new UsTruckOrderRequests.Command(version, key, key); } + private UsTruckOrderRequests.Create create(String containerType, String key) { + return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null, "Ontario", null, + containerType, 1, 0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice", + "+1-555-0100", LocalDate.now().plusDays(1), null, key); + } + private UsTruckOrder order(String status, long version, int pallets) { UsTruckOrder value = new UsTruckOrder(); value.setId(1L); value.setCustomerId("100"); value.setOrderNo("TK1"); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index e44e2811d..f89e321df 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -42,6 +42,8 @@ export async function run(context) { - `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。 - 一件代发任务 `Task.pallets` 只列实际参与绑箱的托盘;未使用的预生成托盘通过 `/api/platform/v1/first-leg/pda-dropship/inbound-orders/{inboundId}/pallets?accountId=...` 读取,客户范围取本轮任务。脚本同时核对 `generatedPalletCount/palletSetupReady`,不因空的任务托盘列表新增资源。 + +- `dropship-outbound-edge-cases.mjs` 是 `dropship-pallet-matrix` 内部探针,不单独运行:使用本轮库存验证超量、错误 SKU、不存在/重复箱唛、箱数不符、同键异载荷、未拣齐完成、并发同键创建、未拣取消和重放。单票单托额外先拣一箱再拣剩余箱;部分拣货被拒时仍保留失败及部署阻断,只有回查冻结事实无变化才继续整条明细拣货以采集后续证据,不能将此计为分批通过。多票共托使用同一 SKU,来源区分必须依赖入仓码。成功数据保留,未拣并发探针出库单正常取消。本扩展不代表跨客户、跨仓、多 SKU 混箱、逐箱不等件数、跨区故障注入均已覆盖,结论以当轮真实报告为准。 - `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。 - `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。 - 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。 diff --git a/scripts/admin-test-env-modules/coload-groups.mjs b/scripts/admin-test-env-modules/coload-groups.mjs index e8ef138b2..360fd46c5 100644 --- a/scripts/admin-test-env-modules/coload-groups.mjs +++ b/scripts/admin-test-env-modules/coload-groups.mjs @@ -93,8 +93,8 @@ async function createSecondGroup(context, third, fourth) { async function createAssignedPushedPlan(context, fixture, groupId, suffix) { const simulationResponse = await context.request('POST', CONTAINERS, { - channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse, evidence(simulationResponse), OPTIONS) @@ -119,8 +119,8 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) { async function createNormalPushedPlan(context, fixture, suffix) { const simulationResponse = await context.request('POST', CONTAINERS, { - channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse, evidence(simulationResponse), OPTIONS) @@ -174,8 +174,8 @@ async function createReleasedBookingAndLink(context, fixture, chains) { const draft = oceanBookingDraft(context, suffix, master, { bookingAgentId: id(agent.id), bookingAgentName: agent.name, shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', - containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: chains.length, - capacityCbm: 76, maxWeightKg: 28000 }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: chains.length, + capacityCbm: 68, maxWeightKg: 19500 }], }) const createResponse = await context.request('POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0, data: draft }, undefined, @@ -205,7 +205,7 @@ async function createReleasedBookingAndLink(context, fixture, chains) { }, undefined, { 'Idempotency-Key': `${context.marker}-coload-pda-booking-release`.slice(0, 128) }) const released = context.apiSuccess(MODULE, 'PDA链放舱并冻结两柜需求', releaseResponse, evidence(releaseResponse), OPTIONS) - const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '40HC') + const requirement = (released?.containerRequirements || []).find((item) => item?.containerType === '40HQ') context.assert(MODULE, '放舱需求数量覆盖短拣与足量两柜', releaseResponse, released?.oceanBooking?.status === 'RELEASED' && Number(requirement?.quantity) === chains.length, `status=${released?.oceanBooking?.status || 'missing'}, quantity=${requirement?.quantity ?? 'missing'}`, OPTIONS) @@ -510,10 +510,10 @@ export async function run(context) { `status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS) const simulationResponse = await context.request('POST', '/api/platform/v1/containers', { - channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '20GP', - capacityCbm: 33, maxWeightKg: 28000, + channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40GP', + capacityCbm: 58, maxWeightKg: 19500, }) - const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse, + const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用40GP模拟柜', simulationResponse, evidence(simulationResponse), OPTIONS) let bookingId = id(simulation?.id) if (!bookingId) return @@ -573,7 +573,7 @@ export async function run(context) { context.assert(MODULE, '推荐候选包含当前目标同柜组且成员身份精确', isolatedCandidateResponse, candidateGroupIds.includes(groupId) && JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()), - `groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=33cbm/28000kg`, OPTIONS) + `groupId=${groupId}, candidateGroupIds=${candidateGroupIds.join('|') || 'none'}, members=${targetCandidateInbounds.join('|')}, capacity=58cbm/19500kg`, OPTIONS) const allCandidateVolume = isolatedCandidates .reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0) context.assert(MODULE, '推荐候选总量形成容量竞争场景', isolatedCandidateResponse, @@ -601,10 +601,10 @@ export async function run(context) { `targetGroupId=${groupId}, assignedMembers=${capacityGroupItems.map((item) => id(item?.inboundId)).join('|') || 'none'}, before=${planSignature(beforeRecommend)}, after=${planSignature(capacityRecommendation)}`, OPTIONS) const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', { - channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) - const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜', + const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HQ模拟柜', recommendSimulationResponse, evidence(recommendSimulationResponse), OPTIONS) const recommendBookingId = id(recommendSimulation?.id) if (!recommendBookingId) return @@ -647,10 +647,10 @@ export async function run(context) { } const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', { - channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) - const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse, + const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HQ模拟柜', workingSimulationResponse, evidence(workingSimulationResponse), OPTIONS) bookingId = id(workingSimulation?.id) if (!bookingId) return @@ -966,8 +966,8 @@ export async function run(context) { const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse, evidence(uiGroupResponse), OPTIONS) const uiSimulationResponse = await context.request('POST', CONTAINERS, { - channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse, evidence(uiSimulationResponse), OPTIONS) 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 index 378fc107e..e12dd869b 100644 --- a/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-specification-fixture-contract.test.mjs @@ -21,15 +21,15 @@ test('HTTP fixtures use the canonical primary and mismatch container specificati 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/) + assert.match(primarySources, /containerType: '40HQ'[\s\S]*?capacityCbm: 68[\s\S]*?maxWeightKg: 19500/) + assert.match(containerFlow, /containerType: '40GP'[\s\S]*?capacityCbm: 58[\s\S]*?maxWeightKg: 19500/) }) 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, /20GP|40HC|40NR|45HC/, name) + assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|76|67|33|28)(?:\D|$)/, name) assert.doesNotMatch(fixture, /maxWeightKg:\s*(?:100000|22000)(?:\D|$)/, name) } }) @@ -37,13 +37,11 @@ test('HTTP fixtures do not reintroduce retired aliases or arbitrary specificatio 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 }), + '40GP': Object.freeze({ capacityCbm: 58, maxWeightKg: 19500 }), + '40HQ': Object.freeze({ capacityCbm: 68, maxWeightKg: 19500 }), + '45HQ': Object.freeze({ capacityCbm: 86, maxWeightKg: 19500 }), }) - assert.deepEqual(Object.keys(catalog), ['20GP', '40GP', '40HC', '40NR', '45HC']) - assert.equal(new Set(Object.keys(catalog)).size, 5) + assert.deepEqual(Object.keys(catalog), ['40GP', '40HQ', '45HQ']) + assert.equal(new Set(Object.keys(catalog)).size, 3) }) diff --git a/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs new file mode 100644 index 000000000..195eca670 --- /dev/null +++ b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs @@ -0,0 +1,103 @@ +// Internal probes invoked only by the registered dropship matrix and its fresh fixtures. +// A failed expectation remains a deployment blocker. Continue other probes only when +// authoritative task/inventory snapshots prove the failed request changed no state. +const MODULE = 'dropship-pallet-matrix' +const OPTIONS = { scenario: 'dropship-outbound-edge-cases', deploymentBlocker: true } +const BASE = '/api/platform/v1/last-leg/outbound-orders' + +function record(edge, name, response, passed, evidence = '') { + edge.context.assert(MODULE, `${edge.label}${name}`, response, passed, evidence, OPTIONS) +} + +async function request(edge, method, path, body, pda = false) { + return edge.context.requestOverseas(method, path, body, {}, pda ? edge.token : edge.context.overseasToken) +} + +async function candidates(edge) { + const rows = await edge.call('边界测试回查本轮库存候选', 'GET', edge.candidatePath) + return rows.filter(row => edge.group.some(x => x.source.inboundCode === row.inboundCode)) + .map(row => ({ position: row.inventoryPositionId, sku: row.skuCode, + inbound: row.inboundCode, packages: row.availablePackages })) + .sort((a, b) => `${a.position}:${a.sku}`.localeCompare(`${b.position}:${b.sku}`)) +} + +async function taskSnapshot(edge) { + return edge.call('边界测试回查出库冻结事实', 'GET', `${BASE}/${edge.task.outboundNo}`) +} + +export async function outboundCreationGuards(edge) { + const before = await candidates(edge) + const first = edge.body.items[0] + const cases = [ + ['超量创建', { ...first, packages: first.packages + 100, cartonMarks: [] }], + ['错误SKU创建', { ...first, sku: `WRONG-${edge.context.marker}` }], + ['不存在箱唛创建', { ...first, packages: 1, cartonMarks: [`MISSING-${edge.context.marker}`] }], + ['重复箱唛创建', { ...first, packages: 2, cartonMarks: [first.cartonMarks[0], first.cartonMarks[0]] }], + ['箱数与箱唛不符创建', { ...first, packages: first.packages + 1 }], + ] + for (const [index, [name, input]] of cases.entries()) { + const response = await request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`, + { ...edge.body, items: [input], idempotencyKey: `${edge.body.idempotencyKey}-invalid-${index}` }) + record(edge, `${name}返回A0409`, response, response.body?.code === 'A0409') + const after = await candidates(edge) + edge.check(`${edge.label}${name}后库存候选零漂移`, null, JSON.stringify(before) === JSON.stringify(after)) + } + // Two real concurrent requests, same fixture and same key; no parallel fixture workers. + const body = { ...edge.body, idempotencyKey: `${edge.body.idempotencyKey}-race-cancel` } + const create = () => request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`, body) + const responses = await Promise.all([create(), create()]) + const tasks = responses.filter(r => r.body?.code === '200').map(r => r.body.data) + record(edge, '并发同键创建均成功且只生成一个任务', responses[1], + tasks.length === 2 && tasks[0].taskNo === tasks[1].taskNo && tasks[0].outboundNo === tasks[1].outboundNo) + edge.check(`${edge.label}并发创建至少一个可回查结果`, null, tasks.length > 0) + for (const outboundNo of new Set(tasks.map(t => t.outboundNo))) { + for (let replay = 0; replay < 2; replay++) { + const cancelled = await request(edge, 'POST', `${BASE}/${outboundNo}/cancel`, { reason: '本轮未拣取消及重放验收' }) + edge.check(`${edge.label}未拣取消及重复取消成功`, cancelled, cancelled.body?.code === '200') + } + const detail = await edge.call('回查取消任务状态', 'GET', `${BASE}/${outboundNo}`) + edge.check(`${edge.label}取消后明细全部取消`, null, detail.outbound?.status === 'CANCELLED' + && detail.pallets.every(p => p.status === 'CANCELLED' && Number(p.pickedPackageCount) === 0)) + } + edge.check(`${edge.label}取消后逐票SKU可用箱数恢复`, null, + JSON.stringify(before) === JSON.stringify(await candidates(edge))) +} + +export async function outboundTaskGuards(edge) { + const before = await taskSnapshot(edge) + const changed = await request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`, + { ...edge.body, recipient: `${edge.body.recipient}-changed` }) + record(edge, '出库创建同键异载荷返回A0409', changed, changed.body?.code === 'A0409') + edge.check(`${edge.label}同键异载荷后出库事实零漂移`, null, + JSON.stringify(before) === JSON.stringify(await taskSnapshot(edge))) + const path = `/api/pda/v1/last-leg/outbound-tasks/${edge.task.taskNo}` + const incomplete = await request(edge, 'POST', `${path}/complete`, + { idempotencyKey: `${edge.body.idempotencyKey}-incomplete` }, true) + record(edge, '未拣齐禁止完成出库', incomplete, incomplete.body?.code === 'A0409') + edge.check(`${edge.label}未拣齐完成失败后零漂移`, null, + JSON.stringify(before) === JSON.stringify(await taskSnapshot(edge))) +} + +export async function probePartialPick(edge) { + const before = await taskSnapshot(edge) + const path = `/api/pda/v1/last-leg/outbound-tasks/${edge.task.taskNo}/pick` + const partial = { ...edge.pick, packages: 1, cartonMarks: edge.pick.cartonMarks.slice(0, 1), + idempotencyKey: `${edge.pick.idempotencyKey}-partial` } + const result = await request(edge, 'POST', path, partial, true) + record(edge, '四箱任务先拣一箱成功', result, result.body?.code === '200') + const after = await taskSnapshot(edge) + if (result.body?.code !== '200') { + edge.check(`${edge.label}分批拣货失败后无库存明细变化`, null, JSON.stringify(before) === JSON.stringify(after)) + // Keep the failed assertion; whole-item picking below only continues later coverage. + return 0 + } + edge.check(`${edge.label}分批首箱只累计一箱`, null, + after.pallets.reduce((sum, p) => sum + Number(p.pickedPackageCount), 0) === 1) + const replay = await request(edge, 'POST', path, partial, true) + record(edge, '分批首箱同键重放成功', replay, replay.body?.code === '200') + edge.check(`${edge.label}分批重放不重复扣减`, null, JSON.stringify(after) === JSON.stringify(await taskSnapshot(edge))) + const cancel = await request(edge, 'POST', `${BASE}/${edge.task.outboundNo}/cancel`, { reason: '已拣取消拦截验收' }) + record(edge, '部分已拣禁止取消', cancel, cancel.body?.code === 'A0409') + edge.check(`${edge.label}已拣取消失败后零漂移`, null, JSON.stringify(after) === JSON.stringify(await taskSnapshot(edge))) + return 1 +} diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs index 5648b8a69..f2a5d76b8 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix-contract.test.mjs @@ -3,6 +3,26 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' import { sameOutboundTask } from './dropship-pallet-matrix.mjs' +import { probePartialPick } from './dropship-outbound-edge-cases.mjs' + +test('partial-pick rejection stays failed; a changed snapshot aborts further picking', async () => { + const failures = [] + const snapshot = { pallets: [{ pickedPackageCount: 0 }] } + const edge = { + label: 'SINGLE-SINGLE', task: { outboundNo: 'OD', taskNo: 'PDA' }, + pick: { packages: 4, cartonMarks: ['A', 'B', 'C', 'D'], idempotencyKey: 'pick' }, + context: { assert: (_module, name, _response, passed) => { if (!passed) failures.push(name) }, + requestOverseas: async () => ({ body: { code: 'A0409' } }) }, + check: (_name, _response, passed) => assert.ok(passed), + call: async () => structuredClone(snapshot), + } + assert.equal(await probePartialPick(edge), 0) + assert.equal(failures.length, 1) + assert.match(failures[0], /四箱任务先拣一箱成功/) + let calls = 0 + edge.call = async () => ({ pallets: [{ pickedPackageCount: calls++ }] }) + await assert.rejects(probePartialPick(edge)) +}) test('outbound replay preserves business identity and database millisecond timestamps', () => { const task = { id: '1', taskNo: 'PDA1', outboundNo: 'OD1', status: 'PENDING', pushedAt: '2026-09-14T20:24:45.920278604' } @@ -15,6 +35,34 @@ test('outbound replay preserves business identity and database millisecond times }) const source = readFileSync(new URL('./dropship-pallet-matrix.mjs', import.meta.url), 'utf8') +const edges = readFileSync(new URL('./dropship-outbound-edge-cases.mjs', import.meta.url), 'utf8') + +test('outbound edge probes preserve failures and require zero drift before continuing', () => { + assert.match(edges, /deploymentBlocker: true/) + assert.match(edges, /四箱任务先拣一箱成功/) + assert.match(edges, /分批拣货失败后无库存明细变化/) + assert.match(edges, /JSON.stringify\(before\) === JSON.stringify\(after\)/) + assert.match(source, /await probePartialPick/) + assert.match(source, /pick.packages -= count/) + assert.match(source, /pick.cartonMarks = pick.cartonMarks.slice\(count\)/) +}) + +test('outbound creation probes cover concurrent idempotency, invalid cartons and cancellation', () => { + assert.match(edges, /Promise.all\(\[create\(\), create\(\)\]\)/) + for (const scenario of ['超量创建', '错误SKU创建', '不存在箱唛创建', '重复箱唛创建', + '箱数与箱唛不符创建', '出库创建同键异载荷返回A0409', '未拣齐禁止完成出库', '部分已拣禁止取消']) { + assert.ok(edges.includes(scenario)) + } + assert.match(edges, /取消后逐票SKU可用箱数恢复/) + assert.match(source, /await outboundCreationGuards\(edge\)/) + assert.match(source, /await outboundTaskGuards\(\{ ...edge, task \}\)/) +}) + +test('two inbound orders explicitly share a SKU without losing their separate identities', () => { + assert.match(source, /label.startsWith\('MULTI-MERGE-'\)/) + assert.match(source, /skuCodes = \[`SKU-SHARED-\$\{marker\}`/) + assert.match(source, /row.inboundCode === candidate.inboundCode/) +}) test('dedicated outbound uses exact source cartons and verifies completion and stock deduction', () => { assert.match(source, /await outboundOverseas\(group, picker.token\)/) diff --git a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs index 1f939fefe..4e765e8fc 100644 --- a/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs +++ b/scripts/admin-test-env-modules/dropship-pallet-matrix.mjs @@ -11,6 +11,7 @@ import { run as completeCustoms } from './phase3-customs.mjs' import { run as verifyCarrierQuery } from './phase3-carrier-schedule-queries.mjs' import { runUntilPickupReady } from './phase4-carrier-nodes.mjs' import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.mjs' +import { outboundCreationGuards, outboundTaskGuards, probePartialPick } from './dropship-outbound-edge-cases.mjs' const MODULE = 'dropship-pallet-matrix' const BASE = '/api/platform/v1/first-leg/pda-dropship' @@ -49,6 +50,9 @@ export async function run(context) { context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP' context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' } context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 4, packageCounts: [4], palletCount } + if (label.startsWith('MULTI-MERGE-')) { + context.context.waitSplitVariant.skuCodes = [`SKU-SHARED-${marker}`.slice(0, 64)] + } context.options.prepareDropshipManualCount = true const start = context.steps.length await context.executeModule(`${MODULE}-${label}-order`, () => createOrder(context)) @@ -111,7 +115,7 @@ export async function run(context) { const summary = group[0].summary const cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', { channelGroupCode: channelGroup(summary.channelCode), warehouseId: String(summary.warehouseId), - containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, + containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, }) let plan = await command('读取自动生成装柜计划', 'GET', `${smart}/plans/by-booking/${cabinet.id}`) const path = `${smart}/plans/${plan.id}` @@ -422,10 +426,13 @@ export async function run(context) { .reduce((sum, item) => sum + Number(item.availablePackages), 0) === record.expectedCartonMarks.length)) const body = { recipient: `HTTP acceptance ${context.marker}`, items: inputs, remark: context.marker, idempotencyKey: `${context.marker}-outbound-create` } + const edge = { context, group, token, customerId, warehouseId, candidatePath, body, label, check, call } + await outboundCreationGuards(edge) const task = await call('创建一件代发专用出库', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body) for (const record of group) record.outbound = { task, selected, completed: false } const replay = await call('专用出库创建同键重放', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body) check(`${label}专用出库重放不重复建单`, null, sameOutboundTask(replay, task)) + await outboundTaskGuards({ ...edge, task }) const pdaPath = `/api/pda/v1/last-leg/outbound-tasks/${task.taskNo}` const detail = await call('PDA读取专用出库任务', 'GET', pdaPath, undefined, true) check(`${label}PDA出库明细完整`, null, detail.items?.length === inputs.length) @@ -438,6 +445,11 @@ export async function run(context) { const pick = { outboundPalletItemId: item.outboundPalletItemId, inventoryPositionId: candidate.inventoryPositionId, packages: inputs[index].packages, cartonMarks: inputs[index].cartonMarks, idempotencyKey: `${context.marker}-outbound-pick-${index}` } + if (label === 'SINGLE-SINGLE' && index === 0) { + const count = await probePartialPick({ ...edge, task, pick }) + pick.packages -= count + pick.cartonMarks = pick.cartonMarks.slice(count) + } const picked = await call('PDA专用出库拣货', 'POST', `${pdaPath}/pick`, pick, true) const repeated = await call('PDA出库拣货同键重放', 'POST', `${pdaPath}/pick`, pick, true) check(`${label}PDA出库拣货重放不重复扣减`, null, sameOutboundTask(repeated, picked)) diff --git a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs index 0343a9ff2..5179848e3 100644 --- a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs +++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs @@ -142,7 +142,7 @@ async function provisionCrossCustomerPlan(context) { || first.channelCode !== second.channelCode) return null const containerResponse = await context.request('POST', '/api/platform/v1/containers', { channelGroupCode: channelGroup(first.channelCode), warehouseId: id(first.receivingWarehouseId), - containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, + containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, }) const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse, evidence(containerResponse), OPTIONS) diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs index 276c88666..b5d98d353 100644 --- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -82,8 +82,8 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => { test('字段校验器接受正式完整投影并精确暴露缺字段', () => { const samples = [ - [containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'], - [preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HC', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'], + [containerFieldGaps, { id: ids.container, cabinetNo: 'CAB-1', containerNo: 'MSCU1234567', warehouseId: ids.id, warehouseName: '宁波仓', channelCode: 'EXX', containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, planStatus: 'EDITING' }, { requireContainerNo: true }, 'warehouseName'], + [preloadFieldGaps, { planId: ids.plan, containerBookingId: ids.container, preloadNo: 'PL-1', cabinetNo: 'CAB-1', customers: [{ accountId: ids.id, customerName: '测试客户' }], warehouseName: '宁波仓', containerType: '40HQ', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'], [loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'], [oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'], [portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'], 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 d13797cd0..80742c422 100644 --- a/scripts/admin-test-env-modules/manual-state-loading-pda.mjs +++ b/scripts/admin-test-env-modules/manual-state-loading-pda.mjs @@ -118,18 +118,18 @@ async function createConfirmedContainer(context, source, label) { bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX', vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + placeOfDelivery: 'LAX', estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', remark: `${context.marker}-${label}`, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${value}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }], - containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, - maxWeightKg: 28000 }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, + maxWeightKg: 19500 }], } const simulationResponse = await context.request('POST', CONTAINERS, { - channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS) const containerId = id(simulation?.id) @@ -157,7 +157,7 @@ async function createConfirmedContainer(context, source, label) { context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse, confirmed?.oceanBooking?.status === 'RELEASED' && (!confirmed?.containers || confirmed.containers.length === 0), `status=${confirmed?.oceanBooking?.status || 'missing'}`, OPTIONS) - const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC') + const requirement = confirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') if (!requirement?.id) throw new Error(`${label} 放舱订舱未返回柜型需求`) const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo, 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 18dcbe782..c5949eb2d 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -46,7 +46,7 @@ function oceanPayload(context, template, index) { bookingAgentName: source.bookingAgentId ? source.bookingAgentName : null, departurePortCode: source.departurePortCode, departurePortName: source.departurePortName, destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName, - placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm, + placeOfDelivery: source.placeOfDelivery, estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00', remark: `${context.marker} 人工列表状态 ${index}`, parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) => 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 f3f4830b4..ff5986b42 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 @@ -23,11 +23,11 @@ test('shared DRAFT uses requirements and never registers a real container number const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', master) assert.equal(draft.channelName, undefined) assert.equal(draft.warehouseName, undefined) - assert.deepEqual(draft.containerRequirements, [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }]) + assert.deepEqual(draft.containerRequirements, [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 }]) assert.equal(Object.hasOwn(draft, 'containers'), false) assert.equal(JSON.stringify(draft).includes('containerNo'), false) - assert.equal(draft.serviceTerm, 'CY-CY') - assert.equal(draft.freightTerm, 'NINGBO') + assert.equal('serviceTerm' in draft, false) + assert.equal('freightTerm' in draft, false) assert.equal(draft.carrierCode, 'OTHER') for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field]) }) @@ -41,8 +41,8 @@ test('shared DRAFT permits an omitted customer without inventing placeholders', test('explicit requirement rows preserve quantity and fixed catalog facts', () => { const requirements = [ - { containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }, - { containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 }, + { containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }, + { containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 }, ] const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', master, { containerRequirements: requirements }) assert.deepEqual(draft.containerRequirements, requirements) diff --git a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs index e2a26d8f1..81effcb90 100644 --- a/scripts/admin-test-env-modules/ocean-booking-fixture.mjs +++ b/scripts/admin-test-env-modules/ocean-booking-fixture.mjs @@ -69,7 +69,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING' const containerRequirements = labels.containerRequirements || [{ - containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000, + containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500, }] return { ...writableMaster, @@ -81,7 +81,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) { bookingAgentName: labels.bookingAgentId ? labels.bookingAgentName ?? null : null, vesselName: labels.vesselName || `验收船-${suffix}`, voyageNo: labels.voyageNo || `V-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', - destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', estimatedSailingAt: labels.estimatedSailingAt || '2026-09-01T08:00:00', estimatedArrivalAt: labels.estimatedArrivalAt || '2026-09-20T08:00:00', remark: context.marker, diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index a4797d0d7..e96c81c03 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -27,8 +27,8 @@ function fullDraft(context, suffix, master, labels) { return oceanBookingDraft(context, suffix, master, { ...labels, containerRequirements: labels.containerRequirements || [ - { containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }, - { containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 }, + { containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 }, + { containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 }, ], }) } diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index a86d46759..c6226477f 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -449,7 +449,8 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr const carton = dropship ? selectedBoxes[0] : box const cartonVolumeCbm = carton.lengthCm * carton.widthCm * carton.heightCm / 1_000_000 return { - skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64), + skuNo: context.context?.waitSplitVariant?.skuCodes?.[index] + || `SKU-${box.boxType}-${context.marker}`.slice(0, 64), cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`, hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON', packingGroupNo: dropship ? mixedPackingGroupNo : null, diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 174eb2f69..1dfa26c19 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -173,7 +173,7 @@ export async function run(context) { OPTIONS) if (!selectedChannel) return const channelName = selectedChannel.channelName - const primaryRequirement = { containerType: '40HC', containerKind: 'FCL', capacityCbm: 76, maxWeightKg: 28000 } + const primaryRequirement = { containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 19500 } const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`) const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64) const bookingAgent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS) @@ -195,7 +195,7 @@ export async function run(context) { vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', - serviceTerm: 'CY-CY', freightTerm: 'NINGBO', estimatedSailingAt: '2026-09-01T08:00:00', + estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, @@ -203,7 +203,7 @@ export async function run(context) { containerRequirements: [ { containerType: primaryRequirement.containerType, containerKind: primaryRequirement.containerKind, quantity: 1, capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg }, - { containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 }, + { containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 }, ], } context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName), diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 985840bbb..4456dbea4 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -203,18 +203,18 @@ export async function run(context) { bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', - destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', remark: context.marker, parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` }, { partyType: 'NOTIFY_PARTY', partyName: `P3-NOTIFY-${suffix}` }], - containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, - maxWeightKg: 28000 }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, + maxWeightKg: 19500 }], } const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', { - channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HC', - capacityCbm: 76, maxWeightKg: 28000, + channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HQ', + capacityCbm: 68, maxWeightKg: 19500, }) const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS) const containerBookingId = id(simulation?.id) @@ -618,7 +618,7 @@ 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 === '40HC') + const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') context.assert(MODULE, 'RELEASED 订舱回显完整冻结运输快照且无真实柜号', releaseResponse, Boolean(bookingId) && bookingConfirmed?.oceanBooking?.status === 'RELEASED' && frozenTransportMatches && Boolean(requirement?.id) && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0), 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 41ee5ace2..7858d015b 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 @@ -102,8 +102,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 === '40HC'/) - assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/) + assert.match(source, /containerType === '40HQ'/) + assert.match(source, /containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500/) }) test('split chain starts from the partial inbound and derives quantities from the real tree', () => { diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index a7a624b68..29cb09fc0 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -188,7 +188,7 @@ async function loadOneFragment(context, state) { if (!carrier?.id) return null const simulationResponse = await request(context, 'POST', CONTAINERS, { channelGroupCode: channelGroup(state.channelCode), warehouseId, - containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000, + containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500, }) const simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS) const containerId = id(simulation?.id) @@ -221,7 +221,7 @@ async function loadOneFragment(context, state) { bookingConfirmed?.oceanBooking?.status === 'RELEASED' && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0), `status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS) - const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HC') + const requirement = bookingConfirmed?.containerRequirements?.find?.((item) => item.containerType === '40HQ') const realContainerNo = iso6346ContainerNumber(`${context.marker}-split-${sequence}`) if (!requirement?.id) return null const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { diff --git a/scripts/admin-test-env-modules/phase3-port-schedules.mjs b/scripts/admin-test-env-modules/phase3-port-schedules.mjs index e6c5ccd86..ff109fa1f 100644 --- a/scripts/admin-test-env-modules/phase3-port-schedules.mjs +++ b/scripts/admin-test-env-modules/phase3-port-schedules.mjs @@ -46,7 +46,7 @@ function oceanDraft(context, suffix, master) { ...oceanBookingDraft(context, suffix, master), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', + placeOfDelivery: 'LAX', remark: context.marker, parties: [ { partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index a4cf2fbfc..d0a361cdf 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -316,7 +316,7 @@ function looseboardPayload(context) { return { transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`, - destinationAddress: 'Ontario', containerType: '20GP', packageCount: 3, totalWeightKg: 80, + destinationAddress: 'Ontario', containerType: '40GP', packageCount: 3, totalWeightKg: 80, totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人', contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10), remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255), diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 836f4424d..da7403740 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -144,7 +144,7 @@ async function fixture(context) { const draft = oceanBookingDraft(context, suffix, master, { bookingAgentId: bookingAgent.supplierId, bookingAgentName: bookingAgent.supplierName, shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', - containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 }], }) const createdResponse = await request(context, 'POST', BOOKINGS, { expectedVersion: 0, data: draft }, { 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) })