This commit is contained in:
2026-09-15 10:37:34 +08:00
parent 4f13ee2ec3
commit 88e2fb0eb1
49 changed files with 465 additions and 236 deletions
@@ -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<Specification> 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<String, Specification> VALUES = ORDERED_VALUES.stream()
.collect(Collectors.toUnmodifiableMap(Specification::containerType, Function.identity()));
private ContainerSpecificationCatalog() {}
public static List<Specification> 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) {}
}
@@ -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("统一定义");
}
}
@@ -9919,8 +9919,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
place_of_delivery 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_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
remark VARCHAR(1000) 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; EXECUTE ocean_drop_status_stmt;
DEALLOCATE PREPARE ocean_drop_status_stmt; DEALLOCATE PREPARE ocean_drop_status_stmt;
ALTER TABLE fl_ocean_booking 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 ADD CONSTRAINT chk_ocean_booking_status CHECK
(status IN ('DRAFT','RELEASED')); (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', '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_arrival_at', '当前确认预计到港时间');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'estimated_sailing_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', 'id', '海运订舱ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'place_of_delivery', '最终交货地快照'); 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', '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_id', '船公司逻辑ID快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_name', '船公司名称快照'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'shipping_company_name', '船公司名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT草稿、RELEASED已发布且永久只读'); CALL queue_column_comment('baiyue_firstleg', 'fl_ocean_booking', 'status', '订舱状态:DRAFT草稿、RELEASED已发布且永久只读');
@@ -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<String, Specification> 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) {}
}
@@ -72,8 +72,6 @@ public final class OceanBookingDtos {
@Size(max = 32) String destinationPortCode, @Size(max = 32) String destinationPortCode,
@Size(max = 128) String destinationPortName, @Size(max = 128) String destinationPortName,
@Size(max = 128) String placeOfDelivery, @Size(max = 128) String placeOfDelivery,
@Size(max = 32) String serviceTerm,
@Size(max = 32) String freightTerm,
LocalDateTime estimatedSailingAt, LocalDateTime estimatedSailingAt,
LocalDateTime estimatedArrivalAt, LocalDateTime estimatedArrivalAt,
@Size(max = 1000) String remark, @Size(max = 1000) String remark,
@@ -37,8 +37,6 @@ public class OceanBookingEntity {
private String destinationPortCode; private String destinationPortCode;
private String destinationPortName; private String destinationPortName;
private String placeOfDelivery; private String placeOfDelivery;
private String serviceTerm;
private String freightTerm;
private LocalDateTime estimatedSailingAt; private LocalDateTime estimatedSailingAt;
private LocalDateTime estimatedArrivalAt; private LocalDateTime estimatedArrivalAt;
private String remark; private String remark;
@@ -25,7 +25,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
""") """)
int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion, int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion,
@Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @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); 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}") @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); int deleteDraft(@Param("id") long id, @Param("version") int version);
@@ -75,8 +75,8 @@ public class OceanBookingPrintService {
fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode())); fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode()));
fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode())); fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode()));
fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery())); fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery()));
fields.put("serviceTerm", text(booking.getServiceTerm())); fields.put("serviceTerm", "");
fields.put("freightTerm", text(booking.getFreightTerm())); fields.put("freightTerm", "");
fields.put("totalPackagesInWords", ""); fields.put("totalPackagesInWords", "");
if (SHIPPING_INSTRUCTION.equals(template)) { if (SHIPPING_INSTRUCTION.equals(template)) {
fields.put("toCompany", text(booking.getBookingAgentName())); fields.put("toCompany", text(booking.getBookingAgentName()));
@@ -9,7 +9,7 @@ import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.client.WarehousePartnerClient; import com.baiyue.firstleg.client.WarehousePartnerClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient; import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus; 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.domain.SmartLoadingChannelGroupCatalog;
import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*;
import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.entity.*;
@@ -75,8 +75,9 @@ public class OceanBookingService {
SmartLoadingChannelGroupCatalog.Definition group; SmartLoadingChannelGroupCatalog.Definition group;
try { group = SmartLoadingChannelGroupCatalog.require(query.channelGroupCode()); } try { group = SmartLoadingChannelGroupCatalog.require(query.channelGroupCode()); }
catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); } catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); }
String containerType = ContainerSpecificationCatalog.require(query.containerType()).containerType();
return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), 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<ChannelOption> channels(long originWarehouseId) { public List<ChannelOption> channels(long originWarehouseId) {
@@ -228,8 +229,7 @@ public class OceanBookingService {
List<OceanBookingContainerRequirementEntity> requirements) { List<OceanBookingContainerRequirementEntity> requirements) {
if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null
|| !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName()) || !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName())
|| !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm()) || !StringUtils.hasText(booking.getVoyageNo()) || booking.getEstimatedSailingAt() == null
|| !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null
|| booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null || booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null
|| (!StringUtils.hasText(booking.getDeparturePortName()) && !StringUtils.hasText(booking.getDeparturePortCode())) || (!StringUtils.hasText(booking.getDeparturePortName()) && !StringUtils.hasText(booking.getDeparturePortCode()))
|| (!StringUtils.hasText(booking.getDestinationPortName()) && !StringUtils.hasText(booking.getDestinationPortCode())) || (!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.setVesselName(trim(d.vesselName())); v.setVoyageNo(trim(d.voyageNo()));
v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName())); v.setDeparturePortCode(trim(d.departurePortCode())); v.setDeparturePortName(trim(d.departurePortName()));
v.setDestinationPortCode(trim(d.destinationPortCode())); v.setDestinationPortName(trim(d.destinationPortName())); 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.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt());
v.setRemark(trim(d.remark())); v.setRemark(trim(d.remark()));
} }
@@ -6,7 +6,7 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.client.WarehouseMasterDataClient;
import com.baiyue.firstleg.domain.SmartLoadingStatus; 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.domain.SmartLoadingChannelGroupCatalog;
import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest;
import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest;
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.terminaldrayage.service;
import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException; 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.dto.ContainerDtos.Query;
import com.baiyue.firstleg.entity.ContainerBookingEntity; import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts; import com.baiyue.firstleg.portschedule.dto.CarrierScheduleQueryDtos.ContainerActualFacts;
@@ -85,8 +86,12 @@ public class ContainerWorkbenchService {
} }
private static Query normalize(Query query) { 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()), 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()), query.shippingCompanyId(), trim(query.departurePortCode()), trim(query.destinationPortCode()),
trim(query.destinationWarehouseCode()), query.status(), query.estimatedSailingFrom(), trim(query.destinationWarehouseCode()), query.status(), query.estimatedSailingFrom(),
query.estimatedSailingTo(), query.scope(), query.page(), query.size()); query.estimatedSailingTo(), query.scope(), query.page(), query.size());
@@ -24,8 +24,6 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking (
destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照',
destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照', destination_port_name VARCHAR(128) NULL COMMENT '目的港名称快照',
place_of_delivery 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_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间',
estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间',
remark VARCHAR(1000) 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; EXECUTE ocean_drop_status_stmt;
DEALLOCATE PREPARE ocean_drop_status_stmt; DEALLOCATE PREPARE ocean_drop_status_stmt;
ALTER TABLE fl_ocean_booking 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 ADD CONSTRAINT chk_ocean_booking_status CHECK
(status IN ('DRAFT','RELEASED')); (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 ( CREATE TABLE IF NOT EXISTS fl_ocean_booking_container_requirement (
id BIGINT UNSIGNED NOT NULL COMMENT '订舱箱型需求ID', id BIGINT UNSIGNED NOT NULL COMMENT '订舱箱型需求ID',
ocean_booking_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 '所需集装箱箱种编码', container_kind VARCHAR(32) NOT NULL COMMENT '所需集装箱箱种编码',
capacity_cbm DECIMAL(18,4) NOT NULL COMMENT '该需求柜型可用容量快照m3', 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', max_weight_kg DECIMAL(18,3) NOT NULL COMMENT '统一货重上限快照kg:19500',
quantity INT UNSIGNED NOT NULL COMMENT '该箱型箱种所需柜数', quantity INT UNSIGNED NOT NULL COMMENT '该箱型箱种所需柜数',
sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序', sort_no INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '订舱需求展示顺序',
PRIMARY KEY (id), PRIMARY KEY (id),
@@ -53,7 +53,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号', bill_of_lading_no VARCHAR(128) NULL COMMENT '提单号',
vessel_name VARCHAR(128) NULL COMMENT '船名快照', vessel_name VARCHAR(128) NULL COMMENT '船名快照',
voyage_no VARCHAR(64) 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_id BIGINT UNSIGNED NULL COMMENT '船司ID快照',
shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照', shipping_company_name VARCHAR(128) NULL COMMENT '船司名称快照',
departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照',
@@ -577,7 +577,7 @@ paths:
LOADING 全部未完成状态,已完成装柜永不返回。状态映射为:Order SUBMITTED=PENDING_REVIEW; LOADING 全部未完成状态,已完成装柜永不返回。状态映射为:Order SUBMITTED=PENDING_REVIEW;
Firstleg WAIT_MEASURE/MEASURING/MEASURE_PENDING/MEASURE_REJECTED/WAIT_PRICING/FEE_CONFIRMING/INBOUNDING Firstleg WAIT_MEASURE/MEASURING/MEASURE_PENDING/MEASURE_REJECTED/WAIT_PRICING/FEE_CONFIRMING/INBOUNDING
=WAIT_MEASURE;WAIT_SPLIT=WAIT_SPLIT;LOADING=LOADING。状态只用于搜索和展示,canAssign 由有效柜次占用、 =WAIT_MEASURE;WAIT_SPLIT=WAIT_SPLIT;LOADING=LOADING。状态只用于搜索和展示,canAssign 由有效柜次占用、
剩余箱数、同柜组和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。 剩余箱数、同柜组和容量等事实决定;容积和重量均允许达到柜型基准值的150%,超过即拒绝。
待审核及待测量可以先加入清单;确认预装/PDA 前 待审核及待测量可以先加入清单;确认预装/PDA 前
仍必须完成审核与测量并进入 WAIT_SPLIT。同柜不可拆组仅在全部成员命中当前状态集合时作为一个分页单位返回。 仍必须完成审核与测量并进入 WAIT_SPLIT。同柜不可拆组仅在全部成员命中当前状态集合时作为一个分页单位返回。
parameters: parameters:
@@ -719,7 +719,7 @@ paths:
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单; 服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道; 人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道;
占用、同柜组和容量门禁不变。 占用、同柜组和容量门禁不变。
柜内总容积和总重量分别允许达到柜型额定值的150%,超过即拒绝。 柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。 只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
parameters: parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {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} billOfLadingNo: {type: string, maxLength: 128, nullable: true}
vesselName: {type: string, maxLength: 128, nullable: true} vesselName: {type: string, maxLength: 128, nullable: true}
voyageNo: {type: string, maxLength: 64, 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} shippingCompanyId: {type: string, nullable: true}
shippingCompanyName: {type: string, maxLength: 128, nullable: true} shippingCompanyName: {type: string, maxLength: 128, nullable: true}
departurePortCode: {type: string, maxLength: 32, nullable: true} departurePortCode: {type: string, maxLength: 32, nullable: true}
@@ -2223,8 +2223,8 @@ components:
destinationPortName: {type: string, maxLength: 128, nullable: true} destinationPortName: {type: string, maxLength: 128, nullable: true}
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
destinationWarehouseName: {type: string, maxLength: 128, nullable: true} destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量} capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重} maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}
terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING} 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} terminalTruckingCompany: {type: string, maxLength: 128, nullable: true, description: Server-frozen Warehouse supplier name snapshot}
loadingAt: {type: string, format: date-time, nullable: true} loadingAt: {type: string, format: date-time, nullable: true}
@@ -2265,7 +2265,7 @@ components:
billOfLadingNo: {type: string, maxLength: 128, nullable: true} billOfLadingNo: {type: string, maxLength: 128, nullable: true}
vesselName: {type: string, maxLength: 128, nullable: true} vesselName: {type: string, maxLength: 128, nullable: true}
voyageNo: {type: string, maxLength: 64, 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} shippingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
shippingCompanyName: {type: string, maxLength: 128, nullable: true} shippingCompanyName: {type: string, maxLength: 128, nullable: true}
departurePortCode: {type: string, maxLength: 32, nullable: true} departurePortCode: {type: string, maxLength: 32, nullable: true}
@@ -2274,8 +2274,8 @@ components:
destinationPortName: {type: string, maxLength: 128, nullable: true} destinationPortName: {type: string, maxLength: 128, nullable: true}
destinationWarehouseCode: {type: string, maxLength: 64, nullable: true} destinationWarehouseCode: {type: string, maxLength: 64, nullable: true}
destinationWarehouseName: {type: string, maxLength: 128, nullable: true} destinationWarehouseName: {type: string, maxLength: 128, nullable: true}
capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定容量} capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}
maxWeightKg: {type: number, exclusiveMinimum: true, minimum: 0, description: 必须等于柜型固定限重} maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}
terminalTruckingCompanyId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: ACTIVE Warehouse Supplier ID validated as TRUCKING} 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} 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} loadingAt: {type: string, format: date-time, nullable: true}
@@ -2742,9 +2742,9 @@ components:
properties: 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: 智能装柜使用的九类计划渠道分组} 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} warehouseId: {type: integer, format: int64, minimum: 1}
containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 与订舱共用的平台固定柜型} containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, description: 与订舱共用的平台统一柜型}
capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量} capacityCbm: {type: number, enum: [58, 68, 86], description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}
maxWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定限重} maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}
ContainerOceanBookingLinkRequest: ContainerOceanBookingLinkRequest:
type: object type: object
required: [oceanBookingId, requirementId, expectedVersion] required: [oceanBookingId, requirementId, expectedVersion]
@@ -117,7 +117,7 @@ paths:
parameters: parameters:
- {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - {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: 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: 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}} - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}}
responses: responses:
@@ -191,15 +191,13 @@ components:
destinationPortCode: {type: string, nullable: true} destinationPortCode: {type: string, nullable: true}
destinationPortName: {type: string, nullable: true} destinationPortName: {type: string, nullable: true}
placeOfDelivery: {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} estimatedSailingAt: {type: string, format: date-time, nullable: true}
estimatedArrivalAt: {type: string, format: date-time, nullable: true} estimatedArrivalAt: {type: string, format: date-time, nullable: true}
remark: {type: string, nullable: true} remark: {type: string, nullable: true}
parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}} parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}}
containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}} 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}}} 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}}} 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}}} 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]} DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]}
@@ -84,7 +84,7 @@ class PlatformSmartLoadingControllerContractTest {
.contains("成功响应中的 version 是递增后的 planVersion") .contains("成功响应中的 version 是递增后的 planVersion")
.contains("items[].version 是递增后的 itemVersion") .contains("items[].version 是递增后的 itemVersion")
.contains("数量调整或动态增量不会覆盖已有协作备注") .contains("数量调整或动态增量不会覆盖已有协作备注")
.contains("柜内总容积和总重量分别允许达到柜型额定值的150%") .contains("柜内总容积和总重量分别允许达到柜型基准值的150%")
.contains("人工分配不限制订单国内仓及渠道与柜次一致") .contains("人工分配不限制订单国内仓及渠道与柜次一致")
.contains("智能推荐允许跨国内仓,但仍只选择柜次渠道") .contains("智能推荐允许跨国内仓,但仍只选择柜次渠道")
.contains("canAssign: {type: boolean}") .contains("canAssign: {type: boolean}")
@@ -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("容量和限重");
}
}
@@ -23,8 +23,8 @@ class OceanBookingSqlContractTest {
.contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") .contains("customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'") .contains("MODIFY COLUMN customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照'")
.contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") .contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'")
.contains("service_term VARCHAR(32) NULL COMMENT '运输服务条款快照;发布前必填'") .doesNotContain("service_term")
.contains("freight_term VARCHAR(32) NULL COMMENT '海运费支付条款快照;发布前必填'") .doesNotContain("freight_term")
.contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码") .contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码")
.contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')") .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'") .contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'")
@@ -67,8 +67,6 @@ class OceanBookingPrintServiceTest {
Detail detail = detail("DRAFT"); Detail detail = detail("DRAFT");
detail.oceanBooking().setBillOfLadingNo(null); detail.oceanBooking().setBillOfLadingNo(null);
detail.oceanBooking().setPlaceOfDelivery(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 = new Detail(detail.oceanBooking(), List.of(detail.parties().get(0)),
detail.containerRequirements(), List.of(), List.of(), List.of()); detail.containerRequirements(), List.of(), List.of(), List.of());
when(bookings.detail(10L)).thenReturn(detail); when(bookings.detail(10L)).thenReturn(detail);
@@ -102,8 +100,7 @@ class OceanBookingPrintServiceTest {
booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status); booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status);
booking.setBillOfLadingNo("BL-1"); booking.setVesselName("Vessel"); booking.setVoyageNo("V001"); booking.setBillOfLadingNo("BL-1"); booking.setVesselName("Vessel"); booking.setVoyageNo("V001");
booking.setDeparturePortName("Ningbo"); booking.setDestinationPortName("LA"); booking.setDeparturePortName("Ningbo"); booking.setDestinationPortName("LA");
booking.setPlaceOfDelivery("LA DC"); booking.setServiceTerm("CY-CY"); booking.setPlaceOfDelivery("LA DC"); booking.setBookingAgentName("Agent");
booking.setFreightTerm("PREPAID"); booking.setBookingAgentName("Agent");
booking.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0)); booking.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
OceanBookingPartyEntity shipper = new OceanBookingPartyEntity(); OceanBookingPartyEntity shipper = new OceanBookingPartyEntity();
shipper.setPartyType("SHIPPER"); shipper.setPartyName("Shipper"); shipper.setAddress("Address"); shipper.setPartyType("SHIPPER"); shipper.setPartyName("Shipper"); shipper.setAddress("Address");
@@ -113,7 +110,7 @@ class OceanBookingPrintServiceTest {
notify.setPartyType("NOTIFY_PARTY"); notify.setPartyName("Notify"); notify.setPartyType("NOTIFY_PARTY"); notify.setPartyName("Notify");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setContainerType("40HQ"); requirement.setContainerKind("DRY"); requirement.setQuantity(2); 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()); return new Detail(booking, List.of(shipper, consignee, notify), List.of(requirement), List.of(), List.of(), List.of());
} }
} }
@@ -160,7 +160,7 @@ class OceanBookingServiceTest {
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class); ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture()); verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture());
assertThat(captor.getValue()).singleElement().satisfies(value -> { assertThat(captor.getValue()).singleElement().satisfies(value -> {
assertThat(value.getContainerType()).isEqualTo("40HC"); assertThat(value.getContainerType()).isEqualTo("40HQ");
assertThat(value.getQuantity()).isEqualTo(2); assertThat(value.getQuantity()).isEqualTo(2);
}); });
verify(repository, never()).insertContainer(any()); verify(repository, never()).insertContainer(any());
@@ -326,13 +326,13 @@ class OceanBookingServiceTest {
@Test @Test
void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() { void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() {
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC", LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HQ",
new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2, new BigDecimal("68"), new BigDecimal("19500"), 3, 1, 2,
"客户A", "深圳中转仓", "美西海运"); "客户A", "深圳中转仓", "美西海运");
when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
PageResult<LinkCandidate> result = service.linkCandidates(new LinkCandidateQuery(701L, PageResult<LinkCandidate> result = service.linkCandidates(new LinkCandidateQuery(701L,
" MATSON_CLX ", " 40HC ", null, null)); " MATSON_CLX ", " 40HQ ", null, null));
assertThat(result.records()).containsExactly(candidate); assertThat(result.records()).containsExactly(candidate);
assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2);
@@ -340,7 +340,7 @@ class OceanBookingServiceTest {
@SuppressWarnings("unchecked") ArgumentCaptor<java.util.Set<String>> channels = ArgumentCaptor.forClass(java.util.Set.class); @SuppressWarnings("unchecked") ArgumentCaptor<java.util.Set<String>> channels = ArgumentCaptor.forClass(java.util.Set.class);
verify(repository).linkCandidates(query.capture(), channels.capture()); verify(repository).linkCandidates(query.capture(), channels.capture());
assertThat(query.getValue().channelGroupCode()).isEqualTo("MATSON_CLX"); 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().page()).isEqualTo(1L);
assertThat(query.getValue().size()).isEqualTo(20L); assertThat(query.getValue().size()).isEqualTo(20L);
assertThat(channels.getValue()).contains("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL", "ZBYHK"); 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)); when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
PageResult<LinkCandidate> result = service.linkCandidates( PageResult<LinkCandidate> 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.records()).isEmpty();
assertThat(result.total()).isZero(); assertThat(result.total()).isZero();
@@ -402,19 +402,19 @@ class OceanBookingServiceTest {
private static DraftData partialDraft(String remark) { private static DraftData partialDraft(String remark) {
return new DraftData(null, null, null, null, null, null, null, null, null, null, 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() { private static DraftData completeDraft() {
return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", "MATSON", return new DraftData(100L, "客户", "SEA-US", 701L, "BL-001", 71L, "船公司", "MATSON",
81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶", 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, LocalDateTime.of(2026, 9, 20, 0, 0), null,
List.of(new PartyRequest("SHIPPER", "发货人", null, null, null), List.of(new PartyRequest("SHIPPER", "发货人", null, null, null),
new PartyRequest("CONSIGNEE", "收货人", null, null, null), new PartyRequest("CONSIGNEE", "收货人", null, null, null),
new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)), new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)),
List.of(new ContainerRequirementRequest("40HC", "DRY", 2, List.of(new ContainerRequirementRequest("40HQ", "DRY", 2,
new BigDecimal("76"), new BigDecimal("28000")))); new BigDecimal("68"), new BigDecimal("19500"))));
} }
private static OceanBookingEntity completeBooking(String status, int version) { private static OceanBookingEntity completeBooking(String status, int version) {
@@ -426,7 +426,6 @@ class OceanBookingServiceTest {
value.setDeparturePortCode("CNSZX"); value.setDeparturePortName("深圳"); value.setDeparturePortCode("CNSZX"); value.setDeparturePortName("深圳");
value.setDestinationPortCode("USLAX"); value.setDestinationPortName("洛杉矶"); value.setDestinationPortCode("USLAX"); value.setDestinationPortName("洛杉矶");
value.setPlaceOfDelivery("LAX"); value.setPlaceOfDelivery("LAX");
value.setServiceTerm("CY-CY"); value.setFreightTerm("PREPAID");
value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0)); value.setEstimatedSailingAt(LocalDateTime.of(2026, 9, 1, 0, 0));
value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0)); value.setEstimatedArrivalAt(LocalDateTime.of(2026, 9, 20, 0, 0));
return value; return value;
@@ -447,9 +446,9 @@ class OceanBookingServiceTest {
private static OceanBookingContainerRequirementEntity requirement() { private static OceanBookingContainerRequirementEntity requirement() {
var value = new OceanBookingContainerRequirementEntity(); var value = new OceanBookingContainerRequirementEntity();
value.setId(11L); value.setContainerType("40HC"); value.setContainerKind("DRY"); value.setId(11L); value.setContainerType("40HQ"); value.setContainerKind("DRY");
value.setQuantity(1); value.setCapacityCbm(new BigDecimal("76")); value.setQuantity(1); value.setCapacityCbm(new BigDecimal("68"));
value.setMaxWeightKg(new BigDecimal("28000")); return value; value.setMaxWeightKg(new BigDecimal("19500")); return value;
} }
private String hash(String action, Long id, Object request) throws Exception { private String hash(String action, Long id, Object request) throws Exception {
@@ -85,7 +85,7 @@ class ContainerServiceTest {
assertThat(saved.getChannelName()).isNull(); assertThat(saved.getChannelName()).isNull();
assertThat(saved.getWarehouseId()).isEqualTo(101L); assertThat(saved.getWarehouseId()).isEqualTo(101L);
assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓"); assertThat(saved.getWarehouseName()).isEqualTo("上海中转仓");
assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000"); assertThat(saved.getCapacityCbm()).isEqualByComparingTo("68.0000");
assertThat(saved.getPlanId()).isEqualTo(501L); assertThat(saved.getPlanId()).isEqualTo(501L);
assertThat(saved.getPlanNo()).isEqualTo("LP-260818-0001"); assertThat(saved.getPlanNo()).isEqualTo("LP-260818-0001");
assertThat(saved.getPlanStatus()).isEqualTo("EDITING"); assertThat(saved.getPlanStatus()).isEqualTo("EDITING");
@@ -96,11 +96,11 @@ class ContainerServiceTest {
@DisplayName("新增模拟柜拒绝柜型与固定规格不匹配") @DisplayName("新增模拟柜拒绝柜型与固定规格不匹配")
void rejectsSimulationWithNonCanonicalSpecification() { void rejectsSimulationWithNonCanonicalSpecification() {
SimulationCreateRequest invalid = new SimulationCreateRequest( 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)) assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.hasMessageContaining("容量和限重"); .hasMessageContaining("容量和货重上限");
verify(repository, never()).insert(any()); verify(repository, never()).insert(any());
} }
@@ -204,12 +204,12 @@ class ContainerServiceTest {
container.setPlanId(501L); container.setPlanId(501L);
container.setPlanNo("LP-260818-0001"); container.setPlanNo("LP-260818-0001");
container.setContainerNo(null); 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")); container.setCapacityCbm(new BigDecimal("85.5000")); container.setMaxWeightKg(new BigDecimal("27500.000"));
OceanBookingEntity booking = new OceanBookingEntity(); OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX_RAIL_NY"); booking.setId(21L); booking.setStatus("RELEASED"); booking.setWarehouseId(101L); booking.setChannelCode("EXX_RAIL_NY");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); 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(repository.find(11L)).thenReturn(container, container);
when(oceanBookings.find(21L)).thenReturn(booking); when(oceanBookings.find(21L)).thenReturn(booking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
@@ -313,15 +313,15 @@ class ContainerServiceTest {
void linksMultipleRealContainersToTheSameReleasedBookingRequirement() { void linksMultipleRealContainersToTheSameReleasedBookingRequirement() {
ContainerBookingEntity first = booking("WAIT_LOADING", 2); ContainerBookingEntity first = booking("WAIT_LOADING", 2);
first.setId(11L); first.setContainerNo(null); first.setWarehouseId(101L); 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); ContainerBookingEntity second = booking("WAIT_LOADING", 4);
second.setId(12L); second.setContainerNo(null); second.setWarehouseId(101L); 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(); OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK"); oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); 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(11L)).thenReturn(first);
when(repository.find(12L)).thenReturn(second); when(repository.find(12L)).thenReturn(second);
when(oceanBookings.find(21L)).thenReturn(oceanBooking); when(oceanBookings.find(21L)).thenReturn(oceanBooking);
@@ -362,19 +362,19 @@ class ContainerServiceTest {
@Test @Test
@DisplayName("首次关联拒绝装柜仓库不一致") @DisplayName("首次关联拒绝装柜仓库不一致")
void rejectsLinkWhenWarehouseDiffers() { void rejectsLinkWhenWarehouseDiffers() {
assertThreeFieldMismatch(102L, "MATSON_CLX_TRUCK", "40HC"); assertThreeFieldMismatch(102L, "MATSON_CLX_TRUCK", "40HQ");
} }
@Test @Test
@DisplayName("首次关联拒绝物流渠道不一致") @DisplayName("首次关联拒绝物流渠道不一致")
void rejectsLinkWhenChannelDiffers() { void rejectsLinkWhenChannelDiffers() {
assertThreeFieldMismatch(101L, "HEDE_TRUCK", "40HC"); assertThreeFieldMismatch(101L, "HEDE_TRUCK", "40HQ");
} }
@Test @Test
@DisplayName("首次关联拒绝柜型不一致") @DisplayName("首次关联拒绝柜型不一致")
void rejectsLinkWhenContainerTypeDiffers() { void rejectsLinkWhenContainerTypeDiffers() {
assertThreeFieldMismatch(101L, "MATSON_CLX_TRUCK", "45HC"); assertThreeFieldMismatch(101L, "MATSON_CLX_TRUCK", "45HQ");
} }
@Test @Test
@@ -418,7 +418,7 @@ class ContainerServiceTest {
ContainerBookingEntity container = booking("WAIT_LOADING", 2); ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null); container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null); container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
container.setContainerType("40HC"); container.setContainerType("40HQ");
OceanBookingEntity oceanBooking = new OceanBookingEntity(); OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
oceanBooking.setWarehouseId(bookingWarehouseId); oceanBooking.setChannelCode(bookingChannel); oceanBooking.setWarehouseId(bookingWarehouseId); oceanBooking.setChannelCode(bookingChannel);
@@ -440,12 +440,12 @@ class ContainerServiceTest {
ContainerBookingEntity container = booking("WAIT_LOADING", 2); ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null); container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null); container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
container.setContainerType("40HC"); container.setContainerType("40HQ");
OceanBookingEntity oceanBooking = new OceanBookingEntity(); OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK"); oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("MATSON_CLX_TRUCK");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity(); 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(repository.find(11L)).thenReturn(container);
when(oceanBookings.find(21L)).thenReturn(oceanBooking); when(oceanBookings.find(21L)).thenReturn(oceanBooking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement); when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
@@ -485,15 +485,15 @@ class ContainerServiceTest {
private static SaveRequest request() { private static SaveRequest request() {
return new SaveRequest(101L, "上海仓", "BOL-001", "柏悦轮", return new SaveRequest(101L, "上海仓", "BOL-001", "柏悦轮",
"V001", "40HC", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港", "V001", "40HQ", 201L, "船司A", "CNSHA", "上海港", "USLAX", "洛杉矶港",
"ONT8", "ONT8", new BigDecimal("76.0000"), new BigDecimal("28000.000"), 202L, "拖车公司A", "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, 10, 8, 0), LocalDateTime.of(2026, 8, 12, 8, 0), null,
LocalDateTime.of(2026, 8, 25, 8, 0), null, LocalDateTime.of(2026, 8, 25, 8, 0), null,
LocalDateTime.of(2026, 8, 27, 8, 0), null); LocalDateTime.of(2026, 8, 27, 8, 0), null);
} }
private static SimulationCreateRequest simulation() { 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() { private static ChannelRow channel() {
@@ -552,8 +552,8 @@ class SmartLoadingServiceTest {
@Test @Test
void pagesWorkbenchContainersWithTheSameGlobalFilters() { void pagesWorkbenchContainersWithTheSameGlobalFilters() {
WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null, WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
100L, "义乌仓", "MATSON_CLX", null, null, "40HC", 100L, "义乌仓", "MATSON_CLX", null, null, "40HQ",
new BigDecimal("68"), new BigDecimal("28000"), new BigDecimal("68"), new BigDecimal("19500"),
1L, "LP-1", "EDITING", 2); 1L, "LP-1", "EDITING", 2);
WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L, WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L,
9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"), 9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"),
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.looseboard.service; 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.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -203,7 +205,9 @@ public class LocalLooseboardService {
order.setOriginAddress(trim(originAddress)); order.setOriginAddress(trim(originAddress));
order.setDestination(destination.trim()); order.setDestination(destination.trim());
order.setDestinationAddress(trim(destinationAddress)); 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.setPackageCount(packageCount);
order.setTotalWeightKg(totalWeightKg); order.setTotalWeightKg(totalWeightKg);
order.setTotalVolumeCbm(totalVolumeCbm); order.setTotalVolumeCbm(totalVolumeCbm);
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.common.core.api.PageResult; 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.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder; 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.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null);
order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress()));
order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); 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.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount()));
order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0);
order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); 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())); } if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); }
setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress); setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress);
setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress); 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.palletCount() != null) order.setPalletCount(request.palletCount());
if (request.packageCount() != null) order.setPackageCount(request.packageCount()); if (request.packageCount() != null) order.setPackageCount(request.packageCount());
if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm()); if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm());
@@ -2005,7 +2005,7 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order (
origin_address VARCHAR(500) NULL COMMENT '起运地址', origin_address VARCHAR(500) NULL COMMENT '起运地址',
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination VARCHAR(255) NOT NULL COMMENT '目的地',
destination_address VARCHAR(500) 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 '客户申报托盘数', pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数',
package_count INT NULL COMMENT '可选箱数,零或空值均有效', package_count INT NULL COMMENT '可选箱数,零或空值均有效',
received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', 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 '运输类型', 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 '起运详细地址', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址',
destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_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_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)',
total_volume_cbm DECIMAL(18,6) 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 '预算币种', budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种',
@@ -2704,7 +2704,7 @@ components:
transportType: { type: string, nullable: true } transportType: { type: string, nullable: true }
origin: { type: string } origin: { type: string }
destination: { type: string } destination: { type: string }
containerType: { type: string, nullable: true } containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 }
palletCount: { type: integer } palletCount: { type: integer }
packageCount: { type: integer } packageCount: { type: integer }
receivedPalletCount: { type: integer } receivedPalletCount: { type: integer }
@@ -2760,7 +2760,7 @@ components:
transportType: { type: string } transportType: { type: string }
origin: { type: string } origin: { type: string }
destination: { type: string } destination: { type: string }
containerType: { type: string, nullable: true } containerType: { type: string, enum: [40GP, 40HQ, 45HQ], description: 平台统一柜型 }
packageCount: { type: integer } packageCount: { type: integer }
totalWeightKg: { type: number } totalWeightKg: { type: number }
totalVolumeCbm: { type: number } totalVolumeCbm: { type: number }
@@ -3101,7 +3101,7 @@ components:
originAddress: { type: string, nullable: true, maxLength: 500 } originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 } destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 } 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 } palletCount: { type: integer, minimum: 1 }
packageCount: { type: integer, nullable: true, minimum: 0 } packageCount: { type: integer, nullable: true, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
@@ -3123,7 +3123,7 @@ components:
originAddress: { type: string, nullable: true, maxLength: 500 } originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 } destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 } 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 } palletCount: { type: integer, minimum: 1 }
packageCount: { type: integer, minimum: 0 } packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
@@ -3154,7 +3154,7 @@ components:
originAddress: { type: string, nullable: true, maxLength: 500 } originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 } destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 } 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 } packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 }
@@ -3177,7 +3177,7 @@ components:
originAddress: { type: string, nullable: true, maxLength: 500 } originAddress: { type: string, nullable: true, maxLength: 500 }
destination: { type: string, maxLength: 255 } destination: { type: string, maxLength: 255 }
destinationAddress: { type: string, nullable: true, maxLength: 500 } 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 } packageCount: { type: integer, minimum: 0 }
totalWeightKg: { type: number, minimum: 0 } totalWeightKg: { type: number, minimum: 0 }
totalVolumeCbm: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 }
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.looseboard.service; package com.baiyue.lastleg.looseboard.service;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; 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.LocalLooseboardHistory;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder;
import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation;
@@ -85,6 +86,7 @@ class LocalLooseboardServiceTest {
assertEquals("100", detail.summary().customerId()); assertEquals("100", detail.summary().customerId());
assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status()); assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status());
assertEquals("SHARED_TRUCK", detail.summary().transportMode()); assertEquals("SHARED_TRUCK", detail.summary().transportMode());
assertEquals("40HQ", detail.summary().containerType());
verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.<LastlegOrder>argThat(value -> verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.<LastlegOrder>argThat(value ->
"LOCAL_LOOSEBOARD".equals(value.getOrderType()) "LOCAL_LOOSEBOARD".equals(value.getOrderType())
&& value.getSourceOrderNo().equals("LB-WH-20260901-000001") && value.getSourceOrderNo().equals("LB-WH-20260901-000001")
@@ -94,6 +96,22 @@ class LocalLooseboardServiceTest {
"CREATE".equals(value.getOperationType()))); "CREATE".equals(value.getOperationType())));
} }
@Test
void createDefaultsTheContainerTypeAndRejectsRemovedTypes() {
AtomicReference<LocalLooseboardOrder> 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 @Test
void ordinaryUpdateCannotChangeAcceptedOrder() { void ordinaryUpdateCannotChangeAcceptedOrder() {
LocalLooseboardOrder order = order(LocalLooseboardService.DISPATCHED, 0L); LocalLooseboardOrder order = order(LocalLooseboardService.DISPATCHED, 0L);
@@ -174,8 +192,12 @@ class LocalLooseboardServiceTest {
} }
private LocalLooseboardRequests.Create create(String key) { 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", 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"), new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"),
"USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key); "USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key);
} }
@@ -1,5 +1,7 @@
package com.baiyue.lastleg.trucking.service; 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.dto.UsTruckOrderRequests;
import com.baiyue.lastleg.trucking.entity.UsTruckOrder; import com.baiyue.lastleg.trucking.entity.UsTruckOrder;
import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; 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.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; 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.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.lenient;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class UsTruckOrderServiceTest { class UsTruckOrderServiceTest {
@@ -38,6 +43,7 @@ class UsTruckOrderServiceTest {
@Mock UsTruckOrderPalletMapper pallets; @Mock UsTruckOrderPalletMapper pallets;
@Mock UsTruckOrderStatusHistoryMapper histories; @Mock UsTruckOrderStatusHistoryMapper histories;
@Mock UsTruckOrderOperationMapper operations; @Mock UsTruckOrderOperationMapper operations;
@Mock LastlegOrderNoGenerator orderNoGenerator;
private final List<UsTruckOrderPallet> palletRows = new ArrayList<>(); private final List<UsTruckOrderPallet> palletRows = new ArrayList<>();
private final List<UsTruckOrderStatusHistory> historyRows = new ArrayList<>(); private final List<UsTruckOrderStatusHistory> historyRows = new ArrayList<>();
private final Map<String, UsTruckOrderOperation> operationRows = new HashMap<>(); private final Map<String, UsTruckOrderOperation> operationRows = new HashMap<>();
@@ -52,20 +58,21 @@ class UsTruckOrderServiceTest {
ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "histories", histories);
ReflectionTestUtils.setField(service, "operations", operations); ReflectionTestUtils.setField(service, "operations", operations);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator);
order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2); 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(orders.findNo("100", "TK1")).thenAnswer(call -> order);
when(pallets.listOrder("100", 1L)).thenAnswer(call -> palletRows); when(pallets.listOrder(anyString(), anyLong())).thenAnswer(call -> palletRows);
when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows); lenient().when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows);
when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> { when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> {
historyRows.add(call.getArgument(0)); return 1; 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; 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))); .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; UsTruckOrderOperation row = call.getArgument(0); operationRows.put(row.getIdempotencyKey(), row); return 1;
}); });
UsTruckOrderStatusHistory created = new UsTruckOrderStatusHistory(); UsTruckOrderStatusHistory created = new UsTruckOrderStatusHistory();
@@ -73,6 +80,21 @@ class UsTruckOrderServiceTest {
created.setToStatus(UsTruckOrderService.PENDING_REVIEW); historyRows.add(created); 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 @Test
void completeLifecycleUsesFactsIdempotencyOperatorsAndCannotReverseTerminal() { void completeLifecycleUsesFactsIdempotencyOperatorsAndCannotReverseTerminal() {
var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", "approve"); var review = new UsTruckOrderRequests.Review(0L, "APPROVE", "review-1", "approve");
@@ -165,6 +187,12 @@ class UsTruckOrderServiceTest {
return new UsTruckOrderRequests.Command(version, key, key); 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) { private UsTruckOrder order(String status, long version, int pallets) {
UsTruckOrder value = new UsTruckOrder(); UsTruckOrder value = new UsTruckOrder();
value.setId(1L); value.setCustomerId("100"); value.setOrderNo("TK1"); value.setId(1L); value.setCustomerId("100"); value.setOrderNo("TK1");
+2
View File
@@ -42,6 +42,8 @@ export async function run(context) {
- `dropship-pallet-matrix` 通过总入口 `--modules dropship-pallet-matrix` 执行,自动依赖客户资质准备。通过正式 HTTP 创建五票订单,覆盖单票多托、单票单托、单票移箱合托、多票共享开放托盘;会创建并保留测试数据,不自动清理。报告 `createdResources.dropshipPalletMatrix` 保存逐票箱唛、托盘及各阶段证据,失败即停止并保留阻断;重跑创建新批次,不借用历史资源。`fullFlowVerified=false`,不能代替海外收货及一件代发专用出库的完整验收。 - `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`,不因空的任务托盘列表新增资源。 - 一件代发任务 `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 上传、重放及零漂移;缺渠道不猜测适用性。 - `phase3-container-documents` 从本轮货柜详情读取渠道:海铁验证 DO 上传被明确拒绝且聚合中没有 DO,其他渠道继续验证 DO 上传、重放及零漂移;缺渠道不猜测适用性。
- `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。 - `phase4-overseas-pda-operations` 用三个独立逐箱任务分别验证正常换标、跳过和异常,异常不得重复使用前两箱。前一步失败时保留失败并停止该链,不继续使用空响应版本。
- 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。 - 海外 PDA 按真实柜号去重执行签收,缺少一件代发生产者不得用 FBA 生产者替代;原生产矩阵失败仍保留。P4 解绑样本保留为 `UNBOUND`,P5 只从本轮已完成同柜、同目的仓的入仓码中选择三箱实物托盘库存,排除 `UNBOUND` 和重复仓位,不借用历史订单。
@@ -93,8 +93,8 @@ async function createSecondGroup(context, third, fourth) {
async function createAssignedPushedPlan(context, fixture, groupId, suffix) { async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, { const simulationResponse = await context.request('POST', CONTAINERS, {
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC', channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HQ',
capacityCbm: 76, maxWeightKg: 28000, capacityCbm: 68, maxWeightKg: 19500,
}) })
const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse, const simulation = context.apiSuccess(MODULE, `${suffix} 创建组装柜模拟柜`, simulationResponse,
evidence(simulationResponse), OPTIONS) evidence(simulationResponse), OPTIONS)
@@ -119,8 +119,8 @@ async function createAssignedPushedPlan(context, fixture, groupId, suffix) {
async function createNormalPushedPlan(context, fixture, suffix) { async function createNormalPushedPlan(context, fixture, suffix) {
const simulationResponse = await context.request('POST', CONTAINERS, { const simulationResponse = await context.request('POST', CONTAINERS, {
channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HC', channelGroupCode: channelGroup(fixture.channelCode), warehouseId: fixture.warehouseId, containerType: '40HQ',
capacityCbm: 76, maxWeightKg: 28000, capacityCbm: 68, maxWeightKg: 19500,
}) })
const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse, const simulation = context.apiSuccess(MODULE, `${suffix} 创建普通单模拟柜`, simulationResponse,
evidence(simulationResponse), OPTIONS) evidence(simulationResponse), OPTIONS)
@@ -174,8 +174,8 @@ async function createReleasedBookingAndLink(context, fixture, chains) {
const draft = oceanBookingDraft(context, suffix, master, { const draft = oceanBookingDraft(context, suffix, master, {
bookingAgentId: id(agent.id), bookingAgentName: agent.name, bookingAgentId: id(agent.id), bookingAgentName: agent.name,
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON',
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: chains.length, containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: chains.length,
capacityCbm: 76, maxWeightKg: 28000 }], capacityCbm: 68, maxWeightKg: 19500 }],
}) })
const createResponse = await context.request('POST', '/api/platform/v1/ocean-bookings', const createResponse = await context.request('POST', '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: draft }, undefined, { 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) }) }, undefined, { 'Idempotency-Key': `${context.marker}-coload-pda-booking-release`.slice(0, 128) })
const released = context.apiSuccess(MODULE, 'PDA链放舱并冻结两柜需求', releaseResponse, const released = context.apiSuccess(MODULE, 'PDA链放舱并冻结两柜需求', releaseResponse,
evidence(releaseResponse), OPTIONS) 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, context.assert(MODULE, '放舱需求数量覆盖短拣与足量两柜', releaseResponse,
released?.oceanBooking?.status === 'RELEASED' && Number(requirement?.quantity) === chains.length, released?.oceanBooking?.status === 'RELEASED' && Number(requirement?.quantity) === chains.length,
`status=${released?.oceanBooking?.status || 'missing'}, quantity=${requirement?.quantity ?? 'missing'}`, OPTIONS) `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) `status=${queried.data?.status || 'missing'}, members=${summaryInboundCodes(queried.data).join('|')}`, OPTIONS)
const simulationResponse = await context.request('POST', '/api/platform/v1/containers', { const simulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '20GP', channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40GP',
capacityCbm: 33, maxWeightKg: 28000, capacityCbm: 58, maxWeightKg: 19500,
}) })
const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用20GP模拟柜', simulationResponse, const simulation = context.apiSuccess(MODULE, '创建容量拒绝专用40GP模拟柜', simulationResponse,
evidence(simulationResponse), OPTIONS) evidence(simulationResponse), OPTIONS)
let bookingId = id(simulation?.id) let bookingId = id(simulation?.id)
if (!bookingId) return if (!bookingId) return
@@ -573,7 +573,7 @@ export async function run(context) {
context.assert(MODULE, '推荐候选包含当前目标同柜组且成员身份精确', isolatedCandidateResponse, context.assert(MODULE, '推荐候选包含当前目标同柜组且成员身份精确', isolatedCandidateResponse,
candidateGroupIds.includes(groupId) candidateGroupIds.includes(groupId)
&& JSON.stringify(targetCandidateInbounds) === JSON.stringify([first.inboundId, second.inboundId].sort()), && 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 const allCandidateVolume = isolatedCandidates
.reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0) .reduce((sum, item) => sum + Number(item?.availableVolumeCbm || 0), 0)
context.assert(MODULE, '推荐候选总量形成容量竞争场景', isolatedCandidateResponse, 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) `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', { const recommendSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC', channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HQ',
capacityCbm: 76, maxWeightKg: 28000, capacityCbm: 68, maxWeightKg: 19500,
}) })
const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HC模拟柜', const recommendSimulation = context.apiSuccess(MODULE, '创建智能推荐整组成功专用40HQ模拟柜',
recommendSimulationResponse, evidence(recommendSimulationResponse), OPTIONS) recommendSimulationResponse, evidence(recommendSimulationResponse), OPTIONS)
const recommendBookingId = id(recommendSimulation?.id) const recommendBookingId = id(recommendSimulation?.id)
if (!recommendBookingId) return if (!recommendBookingId) return
@@ -647,10 +647,10 @@ export async function run(context) {
} }
const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', { const workingSimulationResponse = await context.request('POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HC', channelGroupCode: channelGroup(first.channelCode), warehouseId: first.warehouseId, containerType: '40HQ',
capacityCbm: 76, maxWeightKg: 28000, capacityCbm: 68, maxWeightKg: 19500,
}) })
const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HC模拟柜', workingSimulationResponse, const workingSimulation = context.apiSuccess(MODULE, '创建PDA主链40HQ模拟柜', workingSimulationResponse,
evidence(workingSimulationResponse), OPTIONS) evidence(workingSimulationResponse), OPTIONS)
bookingId = id(workingSimulation?.id) bookingId = id(workingSimulation?.id)
if (!bookingId) return if (!bookingId) return
@@ -966,8 +966,8 @@ export async function run(context) {
const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse, const uiGroup = context.apiSuccess(MODULE, '为Admin页面保留ACTIVE未分配跨目的仓组', uiGroupResponse,
evidence(uiGroupResponse), OPTIONS) evidence(uiGroupResponse), OPTIONS)
const uiSimulationResponse = await context.request('POST', CONTAINERS, { const uiSimulationResponse = await context.request('POST', CONTAINERS, {
channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HC', channelGroupCode: channelGroup(fifth.channelCode), warehouseId: fifth.warehouseId, containerType: '40HQ',
capacityCbm: 76, maxWeightKg: 28000, capacityCbm: 68, maxWeightKg: 19500,
}) })
const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse, const uiSimulation = context.apiSuccess(MODULE, '为Admin页面保留可编辑智能装柜柜次', uiSimulationResponse,
evidence(uiSimulationResponse), OPTIONS) evidence(uiSimulationResponse), OPTIONS)
@@ -21,15 +21,15 @@ test('HTTP fixtures use the canonical primary and mismatch container specificati
const primarySources = productionFixtures.map(source).join('\n') const primarySources = productionFixtures.map(source).join('\n')
const containerFlow = source('phase3-container-loading.mjs') const containerFlow = source('phase3-container-loading.mjs')
assert.match(primarySources, /containerType: '40HC'[\s\S]*?capacityCbm: 76[\s\S]*?maxWeightKg: 28000/) assert.match(primarySources, /containerType: '40HQ'[\s\S]*?capacityCbm: 68[\s\S]*?maxWeightKg: 19500/)
assert.match(containerFlow, /containerType: '20GP'[\s\S]*?capacityCbm: 33[\s\S]*?maxWeightKg: 28000/) assert.match(containerFlow, /containerType: '40GP'[\s\S]*?capacityCbm: 58[\s\S]*?maxWeightKg: 19500/)
}) })
test('HTTP fixtures do not reintroduce retired aliases or arbitrary specifications', () => { test('HTTP fixtures do not reintroduce retired aliases or arbitrary specifications', () => {
for (const name of productionFixtures) { for (const name of productionFixtures) {
const fixture = source(name) const fixture = source(name)
assert.doesNotMatch(fixture, /40HQ|45HQ/, name) assert.doesNotMatch(fixture, /20GP|40HC|40NR|45HC/, name)
assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|68|28)(?:\D|$)/, name) assert.doesNotMatch(fixture, /capacityCbm:\s*(?:100|76|67|33|28)(?:\D|$)/, name)
assert.doesNotMatch(fixture, /maxWeightKg:\s*(?:100000|22000)(?:\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', () => { test('the frozen catalog is complete and unambiguous', () => {
// Keep every future fixture producer constrained to the same product catalog as Admin and Firstleg. // Keep every future fixture producer constrained to the same product catalog as Admin and Firstleg.
const catalog = Object.freeze({ const catalog = Object.freeze({
'20GP': Object.freeze({ capacityCbm: 33, maxWeightKg: 28000 }), '40GP': Object.freeze({ capacityCbm: 58, maxWeightKg: 19500 }),
'40GP': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }), '40HQ': Object.freeze({ capacityCbm: 68, maxWeightKg: 19500 }),
'40HC': Object.freeze({ capacityCbm: 76, maxWeightKg: 28000 }), '45HQ': Object.freeze({ capacityCbm: 86, maxWeightKg: 19500 }),
'40NR': Object.freeze({ capacityCbm: 67, maxWeightKg: 28000 }),
'45HC': Object.freeze({ capacityCbm: 86, maxWeightKg: 28000 }),
}) })
assert.deepEqual(Object.keys(catalog), ['20GP', '40GP', '40HC', '40NR', '45HC']) assert.deepEqual(Object.keys(catalog), ['40GP', '40HQ', '45HQ'])
assert.equal(new Set(Object.keys(catalog)).size, 5) assert.equal(new Set(Object.keys(catalog)).size, 3)
}) })
@@ -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
}
@@ -3,6 +3,26 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { sameOutboundTask } from './dropship-pallet-matrix.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', () => { 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' } 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 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', () => { test('dedicated outbound uses exact source cartons and verifies completion and stock deduction', () => {
assert.match(source, /await outboundOverseas\(group, picker.token\)/) assert.match(source, /await outboundOverseas\(group, picker.token\)/)
@@ -11,6 +11,7 @@ import { run as completeCustoms } from './phase3-customs.mjs'
import { run as verifyCarrierQuery } from './phase3-carrier-schedule-queries.mjs' import { run as verifyCarrierQuery } from './phase3-carrier-schedule-queries.mjs'
import { runUntilPickupReady } from './phase4-carrier-nodes.mjs' import { runUntilPickupReady } from './phase4-carrier-nodes.mjs'
import { resolvePickerAccess, createPicker, disablePicker, uploadPhotos } from './phase4-overseas-pda.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 MODULE = 'dropship-pallet-matrix'
const BASE = '/api/platform/v1/first-leg/pda-dropship' 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.orderFulfillmentType = 'ONE_CLICK_DROPSHIP'
context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' } context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' }
context.context.waitSplitVariant = { skuCount: 1, packageCountPerSku: 4, packageCounts: [4], palletCount } 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 context.options.prepareDropshipManualCount = true
const start = context.steps.length const start = context.steps.length
await context.executeModule(`${MODULE}-${label}-order`, () => createOrder(context)) await context.executeModule(`${MODULE}-${label}-order`, () => createOrder(context))
@@ -111,7 +115,7 @@ export async function run(context) {
const summary = group[0].summary const summary = group[0].summary
const cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', { const cabinet = await command('创建本场景模拟柜', 'POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(summary.channelCode), warehouseId: String(summary.warehouseId), 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}`) let plan = await command('读取自动生成装柜计划', 'GET', `${smart}/plans/by-booking/${cabinet.id}`)
const path = `${smart}/plans/${plan.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)) .reduce((sum, item) => sum + Number(item.availablePackages), 0) === record.expectedCartonMarks.length))
const body = { recipient: `HTTP acceptance ${context.marker}`, items: inputs, const body = { recipient: `HTTP acceptance ${context.marker}`, items: inputs,
remark: context.marker, idempotencyKey: `${context.marker}-outbound-create` } 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) const task = await call('创建一件代发专用出库', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body)
for (const record of group) record.outbound = { task, selected, completed: false } for (const record of group) record.outbound = { task, selected, completed: false }
const replay = await call('专用出库创建同键重放', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body) const replay = await call('专用出库创建同键重放', 'POST', `${base}/one-click-dropship?customerId=${customerId}`, body)
check(`${label}专用出库重放不重复建单`, null, sameOutboundTask(replay, task)) check(`${label}专用出库重放不重复建单`, null, sameOutboundTask(replay, task))
await outboundTaskGuards({ ...edge, task })
const pdaPath = `/api/pda/v1/last-leg/outbound-tasks/${task.taskNo}` const pdaPath = `/api/pda/v1/last-leg/outbound-tasks/${task.taskNo}`
const detail = await call('PDA读取专用出库任务', 'GET', pdaPath, undefined, true) const detail = await call('PDA读取专用出库任务', 'GET', pdaPath, undefined, true)
check(`${label}PDA出库明细完整`, null, detail.items?.length === inputs.length) check(`${label}PDA出库明细完整`, null, detail.items?.length === inputs.length)
@@ -438,6 +445,11 @@ export async function run(context) {
const pick = { outboundPalletItemId: item.outboundPalletItemId, const pick = { outboundPalletItemId: item.outboundPalletItemId,
inventoryPositionId: candidate.inventoryPositionId, packages: inputs[index].packages, inventoryPositionId: candidate.inventoryPositionId, packages: inputs[index].packages,
cartonMarks: inputs[index].cartonMarks, idempotencyKey: `${context.marker}-outbound-pick-${index}` } 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 picked = await call('PDA专用出库拣货', 'POST', `${pdaPath}/pick`, pick, true)
const repeated = 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)) check(`${label}PDA出库拣货重放不重复扣减`, null, sameOutboundTask(repeated, picked))
@@ -142,7 +142,7 @@ async function provisionCrossCustomerPlan(context) {
|| first.channelCode !== second.channelCode) return null || first.channelCode !== second.channelCode) return null
const containerResponse = await context.request('POST', '/api/platform/v1/containers', { const containerResponse = await context.request('POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(first.channelCode), warehouseId: id(first.receivingWarehouseId), 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, const container = context.apiSuccess(MODULE, '创建跨客户费用分摊专用模拟柜', containerResponse,
evidence(containerResponse), OPTIONS) evidence(containerResponse), OPTIONS)
@@ -82,8 +82,8 @@ test('正式 DTO/OpenAPI 暴露矩阵使用的稳定字段', () => {
test('字段校验器接受正式完整投影并精确暴露缺字段', () => { test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
const samples = [ 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'], [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: '40HC', status: 'EDITING', skuCount: 1, totalPackageCount: 1, totalVolumeCbm: 1, totalGrossWeightKg: 10, createdAt: at }, undefined, 'customers'], [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'], [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'], [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'], [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'],
@@ -118,18 +118,18 @@ async function createConfirmedContainer(context, source, label) {
bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX', bookingAgentId: id(agent.id), bookingAgentName: agent.name, departurePortCode: 'CNSZX',
vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64), vesselName: `人工船-${value}`.slice(0, 128), voyageNo: `V-${value}`.slice(0, 64),
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', 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', estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
remark: `${context.marker}-${label}`, remark: `${context.marker}-${label}`,
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` }, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${value}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${value}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }], { partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }],
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68,
maxWeightKg: 28000 }], maxWeightKg: 19500 }],
} }
const simulationResponse = await context.request('POST', CONTAINERS, { const simulationResponse = await context.request('POST', CONTAINERS, {
channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HC', channelGroupCode: channelGroup(summary.channelCode), warehouseId: source.warehouseId, containerType: '40HQ',
capacityCbm: 76, maxWeightKg: 28000, capacityCbm: 68, maxWeightKg: 19500,
}) })
const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS) const simulation = context.apiSuccess(MODULE, `${label} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id) const containerId = id(simulation?.id)
@@ -157,7 +157,7 @@ async function createConfirmedContainer(context, source, label) {
context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse, context.assert(MODULE, `${label} 订舱为 RELEASED 且不预生成真实柜`, releasedResponse,
confirmed?.oceanBooking?.status === 'RELEASED' && (!confirmed?.containers || confirmed.containers.length === 0), confirmed?.oceanBooking?.status === 'RELEASED' && (!confirmed?.containers || confirmed.containers.length === 0),
`status=${confirmed?.oceanBooking?.status || 'missing'}`, OPTIONS) `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} 放舱订舱未返回柜型需求`) if (!requirement?.id) throw new Error(`${label} 放舱订舱未返回柜型需求`)
const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { const linkedResponse = await context.request('POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo, oceanBookingId: bookingId, requirementId: id(requirement.id), containerNo,
@@ -46,7 +46,7 @@ function oceanPayload(context, template, index) {
bookingAgentName: source.bookingAgentId ? source.bookingAgentName : null, bookingAgentName: source.bookingAgentId ? source.bookingAgentName : null,
departurePortCode: source.departurePortCode, departurePortName: source.departurePortName, departurePortCode: source.departurePortCode, departurePortName: source.departurePortName,
destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName, 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', estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00',
remark: `${context.marker} 人工列表状态 ${index}`, remark: `${context.marker} 人工列表状态 ${index}`,
parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) => parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) =>
@@ -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) const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-3' }, '3', master)
assert.equal(draft.channelName, undefined) assert.equal(draft.channelName, undefined)
assert.equal(draft.warehouseName, 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(Object.hasOwn(draft, 'containers'), false)
assert.equal(JSON.stringify(draft).includes('containerNo'), false) assert.equal(JSON.stringify(draft).includes('containerNo'), false)
assert.equal(draft.serviceTerm, 'CY-CY') assert.equal('serviceTerm' in draft, false)
assert.equal(draft.freightTerm, 'NINGBO') assert.equal('freightTerm' in draft, false)
assert.equal(draft.carrierCode, 'OTHER') assert.equal(draft.carrierCode, 'OTHER')
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) assert.ok(draft[field]) 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', () => { test('explicit requirement rows preserve quantity and fixed catalog facts', () => {
const requirements = [ const requirements = [
{ containerType: '40HC', containerKind: 'FCL', quantity: 2, capacityCbm: 76, maxWeightKg: 28000 }, { containerType: '40HQ', containerKind: 'FCL', quantity: 2, capacityCbm: 68, maxWeightKg: 19500 },
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 }, { containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 },
] ]
const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', master, { containerRequirements: requirements }) const draft = oceanBookingDraft({ marker: 'ADMIN-RUN-MULTI' }, 'multi', master, { containerRequirements: requirements })
assert.deepEqual(draft.containerRequirements, requirements) assert.deepEqual(draft.containerRequirements, requirements)
@@ -69,7 +69,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master const { channelName: _readOnlyChannelName, warehouseName: _readOnlyWarehouseName, ...writableMaster } = master
const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING' const normalizedSuffix = text(suffix).replace(/[^A-Za-z0-9]/g, '').slice(-32) || 'BOOKING'
const containerRequirements = labels.containerRequirements || [{ 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 { return {
...writableMaster, ...writableMaster,
@@ -81,7 +81,7 @@ export function oceanBookingDraft(context, suffix, master, labels = {}) {
bookingAgentName: labels.bookingAgentId ? labels.bookingAgentName ?? null : null, bookingAgentName: labels.bookingAgentId ? labels.bookingAgentName ?? null : null,
vesselName: labels.vesselName || `验收船-${suffix}`, voyageNo: labels.voyageNo || `V-${suffix}`, vesselName: labels.vesselName || `验收船-${suffix}`, voyageNo: labels.voyageNo || `V-${suffix}`,
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX',
destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX',
estimatedSailingAt: labels.estimatedSailingAt || '2026-09-01T08:00:00', estimatedSailingAt: labels.estimatedSailingAt || '2026-09-01T08:00:00',
estimatedArrivalAt: labels.estimatedArrivalAt || '2026-09-20T08:00:00', estimatedArrivalAt: labels.estimatedArrivalAt || '2026-09-20T08:00:00',
remark: context.marker, remark: context.marker,
@@ -27,8 +27,8 @@ function fullDraft(context, suffix, master, labels) {
return oceanBookingDraft(context, suffix, master, { return oceanBookingDraft(context, suffix, master, {
...labels, ...labels,
containerRequirements: labels.containerRequirements || [ containerRequirements: labels.containerRequirements || [
{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, maxWeightKg: 28000 }, { containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68, maxWeightKg: 19500 },
{ containerType: '20GP', containerKind: 'FCL', quantity: 1, capacityCbm: 33, maxWeightKg: 28000 }, { containerType: '40GP', containerKind: 'FCL', quantity: 1, capacityCbm: 58, maxWeightKg: 19500 },
], ],
}) })
} }
+2 -1
View File
@@ -449,7 +449,8 @@ function buildPayload(context, profile, master, productImageFileId, customerAddr
const carton = dropship ? selectedBoxes[0] : box const carton = dropship ? selectedBoxes[0] : box
const cartonVolumeCbm = carton.lengthCm * carton.widthCm * carton.heightCm / 1_000_000 const cartonVolumeCbm = carton.lengthCm * carton.widthCm * carton.heightCm / 1_000_000
return { 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`, cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON', hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
packingGroupNo: dropship ? mixedPackingGroupNo : null, packingGroupNo: dropship ? mixedPackingGroupNo : null,
@@ -173,7 +173,7 @@ export async function run(context) {
OPTIONS) OPTIONS)
if (!selectedChannel) return if (!selectedChannel) return
const channelName = selectedChannel.channelName 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 realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`)
const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64) const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64)
const bookingAgent = await resolveOceanBookingAgent(context, MODULE, 'MATSON', OPTIONS) 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), vesselName: `验收船-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳', departurePortCode: 'CNSZX', departurePortName: '深圳',
destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', 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, estimatedArrivalAt: '2026-09-20T08:00:00', remark: context.marker,
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` },
@@ -203,7 +203,7 @@ export async function run(context) {
containerRequirements: [ containerRequirements: [
{ containerType: primaryRequirement.containerType, containerKind: primaryRequirement.containerKind, quantity: 1, { containerType: primaryRequirement.containerType, containerKind: primaryRequirement.containerKind, quantity: 1,
capacityCbm: primaryRequirement.capacityCbm, maxWeightKg: primaryRequirement.maxWeightKg }, 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), context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName),
@@ -203,18 +203,18 @@ export async function run(context) {
bookingAgentId: id(supplier.id), bookingAgentName: supplier.name, bookingAgentId: id(supplier.id), bookingAgentName: supplier.name,
vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64), vesselName: `P3VESSEL-${suffix}`.slice(0, 128), voyageNo: `P3V-${suffix}`.slice(0, 64),
departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', 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', estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00',
remark: context.marker, remark: context.marker,
parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` }, parties: [{ partyType: 'SHIPPER', partyName: `P3-SHIPPER-${suffix}` },
{ partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `P3-CONSIGNEE-${suffix}` },
{ partyType: 'NOTIFY_PARTY', partyName: `P3-NOTIFY-${suffix}` }], { partyType: 'NOTIFY_PARTY', partyName: `P3-NOTIFY-${suffix}` }],
containerRequirements: [{ containerType: '40HC', containerKind: 'FCL', quantity: 1, capacityCbm: 76, containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', quantity: 1, capacityCbm: 68,
maxWeightKg: 28000 }], maxWeightKg: 19500 }],
} }
const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', { const simulationResponse = await request(context, 'POST', '/api/platform/v1/containers', {
channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HC', channelGroupCode: channelGroup(initial?.summary?.channelCode), warehouseId, containerType: '40HQ',
capacityCbm: 76, maxWeightKg: 28000, capacityCbm: 68, maxWeightKg: 19500,
}) })
const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS) const simulation = context.apiSuccess(MODULE, '新增三期内部模拟柜', simulationResponse, '', OPTIONS)
const containerBookingId = id(simulation?.id) 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 frozenTransportValid = transportFields.every((field) => frozenTransport[field] != null && String(frozenTransport[field]).trim() !== '')
const frozenTransportMatches = frozenTransportValid const frozenTransportMatches = frozenTransportValid
&& transportFields.every((field) => String(frozenTransport[field]) === String(bookingDraft[field])) && 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, context.assert(MODULE, 'RELEASED 订舱回显完整冻结运输快照且无真实柜号', releaseResponse,
Boolean(bookingId) && bookingConfirmed?.oceanBooking?.status === 'RELEASED' && frozenTransportMatches Boolean(bookingId) && bookingConfirmed?.oceanBooking?.status === 'RELEASED' && frozenTransportMatches
&& Boolean(requirement?.id) && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0), && Boolean(requirement?.id) && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
@@ -102,8 +102,8 @@ test('booking freezes the source customer channel and warehouse scope', () => {
assert.match(source, /customerId: source\.tenantId/) assert.match(source, /customerId: source\.tenantId/)
assert.match(source, /channelCode: source\.channelCode/) assert.match(source, /channelCode: source\.channelCode/)
assert.match(source, /warehouseId: source\.warehouseId/) assert.match(source, /warehouseId: source\.warehouseId/)
assert.match(source, /containerType === '40HC'/) assert.match(source, /containerType === '40HQ'/)
assert.match(source, /containerType: '40HC', capacityCbm: 76, maxWeightKg: 28000/) assert.match(source, /containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500/)
}) })
test('split chain starts from the partial inbound and derives quantities from the real tree', () => { test('split chain starts from the partial inbound and derives quantities from the real tree', () => {
@@ -188,7 +188,7 @@ async function loadOneFragment(context, state) {
if (!carrier?.id) return null if (!carrier?.id) return null
const simulationResponse = await request(context, 'POST', CONTAINERS, { const simulationResponse = await request(context, 'POST', CONTAINERS, {
channelGroupCode: channelGroup(state.channelCode), warehouseId, 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 simulation = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 新增内部模拟柜`, simulationResponse, '', OPTIONS)
const containerId = id(simulation?.id) const containerId = id(simulation?.id)
@@ -221,7 +221,7 @@ async function loadOneFragment(context, state) {
bookingConfirmed?.oceanBooking?.status === 'RELEASED' bookingConfirmed?.oceanBooking?.status === 'RELEASED'
&& (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0), && (!bookingConfirmed?.containers || bookingConfirmed.containers.length === 0),
`status=${bookingConfirmed?.oceanBooking?.status || 'missing'}, containers=${bookingConfirmed?.containers?.length ?? 0}`, OPTIONS) `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}`) const realContainerNo = iso6346ContainerNumber(`${context.marker}-split-${sequence}`)
if (!requirement?.id) return null if (!requirement?.id) return null
const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, { const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`, {
@@ -46,7 +46,7 @@ function oceanDraft(context, suffix, master) {
...oceanBookingDraft(context, suffix, master), ...oceanBookingDraft(context, suffix, master),
departurePortCode: 'CNSZX', departurePortCode: 'CNSZX',
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', placeOfDelivery: 'LAX',
remark: context.marker, remark: context.marker,
parties: [ parties: [
{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'SHIPPER', partyName: `发货方-${suffix}` },
@@ -316,7 +316,7 @@ function looseboardPayload(context) {
return { return {
transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE',
origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`, 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: '散板验收联系人', totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人',
contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10), contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10),
remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255), remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255),
@@ -144,7 +144,7 @@ async function fixture(context) {
const draft = oceanBookingDraft(context, suffix, master, { const draft = oceanBookingDraft(context, suffix, master, {
bookingAgentId: bookingAgent.supplierId, bookingAgentName: bookingAgent.supplierName, bookingAgentId: bookingAgent.supplierId, bookingAgentName: bookingAgent.supplierName,
shippingCompanyId: id(carrier.id), shippingCompanyName: carrier.name, carrierCode: 'MATSON', 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 }, const createdResponse = await request(context, 'POST', BOOKINGS, { expectedVersion: 0, data: draft },
{ 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) }) { 'Idempotency-Key': `${context.marker}-truck-booking-create`.slice(0, 128) })