feat(firstleg): align simulated containers with channel groups

This commit is contained in:
2026-09-10 19:24:49 +08:00
parent 5ffd6c0118
commit 155dd9d77d
25 changed files with 192 additions and 85 deletions
@@ -5060,6 +5060,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID', merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID',
channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装', channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID', ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
@@ -5854,6 +5855,7 @@ BEGIN
IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_code <=> NEW.channel_code)
OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.channel_name <=> NEW.channel_name)
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
@@ -16397,6 +16399,9 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_code') THEN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_code') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no; ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no;
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_group_code') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留' AFTER merged_into_container_booking_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_name') THEN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='baiyue_firstleg' AND table_name='fl_container_booking' AND column_name='channel_name') THEN
ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code; ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code;
END IF; END IF;
@@ -16453,6 +16458,7 @@ BEGIN
IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)
+1 -1
View File
@@ -85,7 +85,7 @@
## 国内货柜履约装柜与 PDA 资源结算 ## 国内货柜履约装柜与 PDA 资源结算
- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 - 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。装柜允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责;PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。装柜允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责;PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
@@ -0,0 +1,64 @@
package com.baiyue.firstleg.domain;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/** Stable planning groups used by simulated containers and smart-loading filters. */
public final class SmartLoadingChannelGroupCatalog {
private SmartLoadingChannelGroupCatalog() {}
public static final List<Definition> GROUPS = List.of(
group("MATSON_CLX", "美森正班", "MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL", "ZBYHK", "ZBYHWK", "ZBYHP"),
group("MATSON_MAX", "美森加班", "MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL", "BYHK", "BYHWK", "BYHP"),
group("EXX", "EXX", "EXX_TRUCK", "EXX_RAIL_NY", "EXXHK", "EXXHWK", "NYEXX"),
group("HEDE", "合德", "HEDE_TRUCK", "HEDE_PARCEL", "HDHK", "HDHWK", "HDHP"),
group("OA_LA", "洛杉矶普船", "OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_PARCEL", "PCHK", "PCHWK", "PCKK", "PCHP"),
group("OA_CHICAGO", "芝加哥普船", "OA_CHICAGO_RAIL", "CPHT"),
group("OA_HOUSTON", "休斯顿普船", "OA_HOUSTON_TRUCK", "HPHK"),
group("OA_SAVANNAH", "萨凡纳普船", "OA_SAVANNAH_TRUCK", "SPHK"),
group("OA_NEW_YORK_TRUCK", "纽约普船海卡", "OA_NEW_YORK_TRUCK", "NPHK", "NPHWK")
);
private static final Map<String, Definition> BY_CODE = indexByCode();
private static final Map<String, Definition> BY_CHANNEL = indexByChannel();
public static Definition require(String value) {
Definition result = BY_CODE.get(normalize(value));
if (result == null) throw new IllegalArgumentException("不支持的渠道分组: " + value);
return result;
}
public static Definition resolveChannel(String channelCode) {
return BY_CHANNEL.get(normalize(channelCode));
}
public static boolean contains(String groupCode, String channelCode) {
Definition group = BY_CODE.get(normalize(groupCode));
return group != null && group.channelCodes().contains(normalize(channelCode));
}
private static Definition group(String code, String name, String... channelCodes) {
return new Definition(code, name, Set.of(channelCodes));
}
private static String normalize(String value) {
return value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
}
private static Map<String, Definition> indexByCode() {
Map<String, Definition> result = new LinkedHashMap<>();
GROUPS.forEach(group -> result.put(group.code(), group));
return Map.copyOf(result);
}
private static Map<String, Definition> indexByChannel() {
Map<String, Definition> result = new LinkedHashMap<>();
GROUPS.forEach(group -> group.channelCodes().forEach(channel -> result.put(channel, group)));
return Map.copyOf(result);
}
public record Definition(String code, String name, Set<String> channelCodes) {}
}
@@ -83,7 +83,7 @@ public final class ContainerDtos {
) {} ) {}
@Schema(name = "ContainerSimulationCreateRequest", description = "新增内部模拟柜;内部柜号由服务端生成") @Schema(name = "ContainerSimulationCreateRequest", description = "新增内部模拟柜;内部柜号由服务端生成")
public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelCode, public record SimulationCreateRequest(@NotBlank @Size(max = 64) String channelGroupCode,
@NotNull @Positive Long warehouseId, @NotBlank @Size(max = 32) String containerType, @NotNull @Positive Long warehouseId, @NotBlank @Size(max = 32) String containerType,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm,
@NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {} @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {}
@@ -49,7 +49,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
} }
} }
public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo, public record WorkbenchContainer(Long bookingId, String bookingNo, String cabinetNo, String containerNo,
Long warehouseId, String warehouseName, String channelCode, String channelName, String containerType, Long warehouseId, String warehouseName, String channelGroupCode, String channelCode, String channelName, String containerType,
BigDecimal capacityCbm, BigDecimal maxWeightKg, BigDecimal capacityCbm, BigDecimal maxWeightKg,
Long planId, String planNo, String planStatus, Integer version) {} Long planId, String planNo, String planStatus, Integer version) {}
public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode,
@@ -24,6 +24,7 @@ public class ContainerBookingEntity {
private String sourceType; private String sourceType;
private String recordStatus; private String recordStatus;
private Long mergedIntoContainerBookingId; private Long mergedIntoContainerBookingId;
private String channelGroupCode;
private String channelCode; private String channelCode;
private String channelName; private String channelName;
private Long oceanBookingId; private Long oceanBookingId;
@@ -321,7 +321,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
@Select(""" @Select("""
<script> <script>
SELECT b.id bookingId,b.booking_no bookingNo,b.cabinet_no cabinetNo,b.container_no containerNo, SELECT b.id bookingId,b.booking_no bookingNo,b.cabinet_no cabinetNo,b.container_no containerNo,
b.warehouse_id warehouseId,b.warehouse_name warehouseName,b.channel_code channelCode, b.warehouse_id warehouseId,b.warehouse_name warehouseName,b.channel_group_code channelGroupCode,b.channel_code channelCode,
b.channel_name channelName,b.container_type containerType,b.capacity_cbm capacityCbm, b.channel_name channelName,b.container_type containerType,b.capacity_cbm capacityCbm,
b.max_weight_kg maxWeightKg,p.id planId,p.plan_no planNo, b.max_weight_kg maxWeightKg,p.id planId,p.plan_no planNo,
CASE WHEN p.id IS NULL OR NOT EXISTS (SELECT 1 FROM fl_loading_plan_item state_item CASE WHEN p.id IS NULL OR NOT EXISTS (SELECT 1 FROM fl_loading_plan_item state_item
@@ -331,8 +331,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
LEFT JOIN fl_loading_plan p ON p.container_booking_id=b.id LEFT JOIN fl_loading_plan p ON p.container_booking_id=b.id
WHERE b.record_status='ACTIVE' WHERE b.record_status='ACTIVE'
AND (p.id IS NULL OR p.plan_status IN ('EDITING','LOADING')) AND (p.id IS NULL OR p.plan_status IN ('EDITING','LOADING'))
AND b.channel_code IN AND b.channel_group_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach> <foreach collection="channelGroups" item="channelGroup" open="(" separator="," close=")">#{channelGroup}</foreach>
<if test="query.warehouseId != null">AND b.warehouse_id=#{query.warehouseId}</if> <if test="query.warehouseId != null">AND b.warehouse_id=#{query.warehouseId}</if>
<if test="query.customerId != null or query.inboundCode != null or query.createdStart != null or query.createdEnd != null"> <if test="query.customerId != null or query.inboundCode != null or query.createdStart != null or query.createdEnd != null">
AND EXISTS ( AND EXISTS (
@@ -351,7 +351,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
""") """)
Page<WorkbenchContainer> workbenchContainers(Page<WorkbenchContainer> page, Page<WorkbenchContainer> workbenchContainers(Page<WorkbenchContainer> page,
@Param("query") WorkbenchContainerQuery query, @Param("query") WorkbenchContainerQuery query,
@Param("actualChannels") Set<String> actualChannels); @Param("channelGroups") Set<String> channelGroups);
@Select(""" @Select("""
SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId, SELECT o.tenant_id accountId,o.id inboundId,i.inbound_detail_id inboundDetailId,
@@ -29,7 +29,7 @@ public final class OceanBookingDtos {
} }
public record LinkCandidateQuery(@NotNull @Positive Long warehouseId, public record LinkCandidateQuery(@NotNull @Positive Long warehouseId,
@NotBlank @Size(max = 64) String channelCode, @NotBlank @Size(max = 64) String channelGroupCode,
@NotBlank @Size(max = 32) String containerType, @NotBlank @Size(max = 32) String containerType,
@Min(1) Long page, @Min(1) Long page,
@Min(1) @Max(200) Long size) { @Min(1) @Max(200) Long size) {
@@ -54,6 +54,7 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
Page<OceanBookingEntity> selectPage(Page<OceanBookingEntity> page, @Param("query") Query query); Page<OceanBookingEntity> selectPage(Page<OceanBookingEntity> page, @Param("query") Query query);
@Select(""" @Select("""
<script>
SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id, SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id,
requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg, requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg,
requirement.quantity required_quantity, requirement.quantity required_quantity,
@@ -75,13 +76,16 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
AND allocated.requirement_id=requirement.id AND allocated.requirement_id=requirement.id
WHERE booking.status='RELEASED' WHERE booking.status='RELEASED'
AND booking.warehouse_id=#{query.warehouseId} AND booking.warehouse_id=#{query.warehouseId}
AND booking.channel_code=#{query.channelCode} AND booking.channel_code IN
<foreach collection="channelCodes" item="channelCode" open="(" separator="," close=")">#{channelCode}</foreach>
AND requirement.container_type=#{query.containerType} AND requirement.container_type=#{query.containerType}
AND requirement.quantity>COALESCE(allocated.allocated_quantity,0) AND requirement.quantity>COALESCE(allocated.allocated_quantity,0)
ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC
</script>
""") """)
Page<LinkCandidate> selectLinkCandidates(Page<LinkCandidate> page, Page<LinkCandidate> selectLinkCandidates(Page<LinkCandidate> page,
@Param("query") LinkCandidateQuery query); @Param("query") LinkCandidateQuery query,
@Param("channelCodes") java.util.Set<String> channelCodes);
@Update(""" @Update("""
UPDATE fl_ocean_booking SET status=#{toStatus}, UPDATE fl_ocean_booking SET status=#{toStatus},
@@ -33,8 +33,8 @@ public class OceanBookingRepository {
public PageResult<OceanBookingEntity> page(Query query) { public PageResult<OceanBookingEntity> page(Query query) {
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query)); return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
} }
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) { public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query, java.util.Set<String> channelCodes) {
return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query)); return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query, channelCodes));
} }
public OceanBookingEntity find(long id) { return bookings.selectById(id); } public OceanBookingEntity find(long id) { return bookings.selectById(id); }
public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); } public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); }
@@ -10,6 +10,7 @@ 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.firstleg.domain.ContainerSpecificationCatalog;
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.*;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
@@ -71,8 +72,11 @@ public class OceanBookingService {
} }
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) { public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) {
SmartLoadingChannelGroupCatalog.Definition group;
try { group = SmartLoadingChannelGroupCatalog.require(query.channelGroupCode()); }
catch (IllegalArgumentException exception) { throw badRequest(exception.getMessage()); }
return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(),
query.channelCode().trim(), query.containerType().trim(), query.page(), query.size())); group.code(), query.containerType().trim(), query.page(), query.size()), group.channelCodes());
} }
public List<ChannelOption> channels(long originWarehouseId) { public List<ChannelOption> channels(long originWarehouseId) {
@@ -49,9 +49,9 @@ public class SmartLoadingRepository {
return units.isEmpty() ? List.of() : candidates.globalCandidatesByUnits(units); return units.isEmpty() ? List.of() : candidates.globalCandidatesByUnits(units);
} }
public PageResult<WorkbenchContainer> workbenchContainers(WorkbenchContainerQuery query, public PageResult<WorkbenchContainer> workbenchContainers(WorkbenchContainerQuery query,
Set<String> actualChannels) { Set<String> channelGroups) {
return PageResult.from(candidates.workbenchContainers( return PageResult.from(candidates.workbenchContainers(
new Page<>(query.page(), query.size()), query, actualChannels)); new Page<>(query.page(), query.size()), query, channelGroups));
} }
public List<ChannelContainerCountRow> channelContainerCounts(long warehouseId) { public List<ChannelContainerCountRow> channelContainerCounts(long warehouseId) {
return candidates.channelContainerCounts(warehouseId); return candidates.channelContainerCounts(warehouseId);
@@ -7,6 +7,7 @@ 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.firstleg.domain.ContainerSpecificationCatalog;
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;
import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest;
@@ -55,13 +56,16 @@ public class ContainerService {
WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId()); WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId());
Long versionId = priceBooks.activeVersionId(); Long versionId = priceBooks.activeVersionId();
if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本"); if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本");
ChannelRow channel = priceBooks.channels(versionId, warehouse.id()).stream() SmartLoadingChannelGroupCatalog.Definition channelGroup;
.filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst() try { channelGroup = SmartLoadingChannelGroupCatalog.require(request.channelGroupCode()); }
.orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择")); catch (IllegalArgumentException exception) { throw conflict(exception.getMessage()); }
boolean activeGroup = priceBooks.channels(versionId, warehouse.id()).stream()
.map(ChannelRow::getChannelCode).anyMatch(channelGroup.channelCodes()::contains);
if (!activeGroup) throw conflict("所选物流渠道分组在当前仓库已失效,请重新选择");
ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId()); ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(IdWorker.getId());
value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.next("CAB")); value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.next("CAB"));
value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE"); value.setSourceType("SIMULATION"); value.setRecordStatus("ACTIVE");
value.setChannelCode(channel.getChannelCode()); value.setChannelName(channel.getChannelName()); value.setChannelGroupCode(channelGroup.code()); value.setChannelCode(null); value.setChannelName(null);
value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName()); value.setWarehouseId(warehouse.id()); value.setWarehouseName(warehouse.warehouseName());
value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm()); value.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm());
value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setMaxWeightKg(specification.maxWeightKg()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName);
@@ -85,9 +89,9 @@ public class ContainerService {
String containerNo = requestedContainerNo == null ? null : Iso6346ContainerNumber.require(requestedContainerNo); String containerNo = requestedContainerNo == null ? null : Iso6346ContainerNumber.require(requestedContainerNo);
if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在"); if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在");
if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId()) if (!java.util.Objects.equals(container.getWarehouseId(), booking.getWarehouseId())
|| !java.util.Objects.equals(container.getChannelCode(), booking.getChannelCode()) || !SmartLoadingChannelGroupCatalog.contains(container.getChannelGroupCode(), booking.getChannelCode())
|| !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) { || !java.util.Objects.equals(container.getContainerType(), requirement.getContainerType())) {
throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致"); throw conflict("模拟柜与真实订舱柜仓库、渠道分组或柜型不一致");
} }
try { try {
if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), containerNo, if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), containerNo,
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import com.baiyue.firstleg.dto.SmartLoadingDtos.*;
import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog;
import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient; import com.baiyue.firstleg.client.PendingReviewOrderClient;
import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.entity.*;
@@ -42,17 +43,6 @@ import java.util.*;
@Service @Service
public class SmartLoadingService { public class SmartLoadingService {
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.50"); private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.50");
private static final Map<String, Set<String>> WORKBENCH_CHANNEL_GROUPS = Map.of(
"MATSON_CLX", Set.of("MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL"),
"MATSON_MAX", Set.of("MATSON_MAX_TRUCK", "MATSON_MAX_PARCEL"),
"EXX", Set.of("EXX_TRUCK", "EXX_RAIL_NY"),
"HEDE", Set.of("HEDE_TRUCK", "HEDE_PARCEL"),
"OA_LA", Set.of("OA_LA_TRUCK", "OA_LA_FULL_TRUCK", "OA_PARCEL"),
"OA_CHICAGO", Set.of("OA_CHICAGO_RAIL"),
"OA_HOUSTON", Set.of("OA_HOUSTON_TRUCK"),
"OA_SAVANNAH", Set.of("OA_SAVANNAH_TRUCK"),
"OA_NEW_YORK_TRUCK", Set.of("OA_NEW_YORK_TRUCK")
);
@Resource private SmartLoadingRepository repository; @Resource private SmartLoadingRepository repository;
@Resource private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets; @Resource private com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets;
@Resource private OrderCargoClient orderCargoClient; @Resource private OrderCargoClient orderCargoClient;
@@ -102,7 +92,7 @@ public class SmartLoadingService {
public List<Candidate> candidates(long bookingId, long warehouseId) { public List<Candidate> candidates(long bookingId, long warehouseId) {
ContainerBookingEntity booking = requireBooking(bookingId); ContainerBookingEntity booking = requireBooking(bookingId);
if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致"); if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致");
if (!StringUtils.hasText(booking.getChannelCode())) throw validation("柜次缺少渠道快照,请先完成存量数据回填"); if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜");
List<Candidate> values = candidates(warehouseId).stream() List<Candidate> values = candidates(warehouseId).stream()
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values); return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values);
@@ -266,7 +256,7 @@ public class SmartLoadingService {
&& query.createdStart().isAfter(query.createdEnd())) { && query.createdStart().isAfter(query.createdEnd())) {
throw validation("创建开始日期不能晚于结束日期"); throw validation("创建开始日期不能晚于结束日期");
} }
return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes())); return repository.workbenchContainers(query, normalizedGroups(query.channelGroupCodes()));
} }
private List<InboundCandidate> inboundCandidates(List<Candidate> details) { private List<InboundCandidate> inboundCandidates(List<Candidate> details) {
@@ -384,13 +374,17 @@ public class SmartLoadingService {
private static Set<String> actualChannels(List<String> groups) { private static Set<String> actualChannels(List<String> groups) {
Set<String> actual = new LinkedHashSet<>(); Set<String> actual = new LinkedHashSet<>();
for (String value : groups) { for (String value : groups) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); try { actual.addAll(SmartLoadingChannelGroupCatalog.require(value).channelCodes()); }
Set<String> mapped = WORKBENCH_CHANNEL_GROUPS.get(normalized); catch (IllegalArgumentException exception) { throw validation(exception.getMessage()); }
if (mapped == null) throw validation("不支持的渠道分组: " + value);
actual.addAll(mapped);
} }
return actual; return actual;
} }
private static Set<String> normalizedGroups(List<String> groups) {
return groups.stream().map(value -> {
try { return SmartLoadingChannelGroupCatalog.require(value).code(); }
catch (IllegalArgumentException exception) { throw validation(exception.getMessage()); }
}).collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
}
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Plan draftByBooking(long bookingId) { public Plan draftByBooking(long bookingId) {
@@ -1689,8 +1683,10 @@ public class SmartLoadingService {
} }
private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;} private ContainerBookingEntity requireBooking(long id){ContainerBookingEntity b=repository.booking(id);if(b==null)throw new BizException(ResultCode.NOT_FOUND,"货柜预订不存在");return b;}
private static boolean sameChannel(ContainerBookingEntity booking, Candidate candidate) { private static boolean sameChannel(ContainerBookingEntity booking, Candidate candidate) {
return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(candidate.channelCode()) SmartLoadingChannelGroupCatalog.Definition candidateGroup =
&& booking.getChannelCode().equals(candidate.channelCode()); SmartLoadingChannelGroupCatalog.resolveChannel(candidate.channelCode());
return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null
&& booking.getChannelGroupCode().equals(candidateGroup.code());
} }
private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) {
return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId()); return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId());
@@ -34,6 +34,9 @@ BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_code') THEN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_code') THEN
ALTER TABLE fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no; ALTER TABLE fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no;
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_group_code') THEN
ALTER TABLE fl_container_booking ADD COLUMN channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留' AFTER merged_into_container_booking_id;
END IF;
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_name') THEN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='channel_name') THEN
ALTER TABLE fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code; ALTER TABLE fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code;
END IF; END IF;
@@ -93,6 +96,7 @@ BEGIN
IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( IF current_plan_status IN ('LOADING', 'COMPLETED') AND (
NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no) NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_no)
OR NOT (OLD.cabinet_no <=> NEW.cabinet_no) OR NOT (OLD.cabinet_no <=> NEW.cabinet_no)
OR NOT (OLD.channel_group_code <=> NEW.channel_group_code)
OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name) OR NOT (OLD.channel_code <=> NEW.channel_code) OR NOT (OLD.channel_name <=> NEW.channel_name)
OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_id <=> NEW.ocean_booking_id)
OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id)
@@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking (
source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜',
record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史',
merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID', merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID',
channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留',
channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装', channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装',
channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照',
ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID', ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID',
@@ -2174,7 +2174,7 @@ components:
allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}} allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}}
ContainerBooking: ContainerBooking:
type: object type: object
required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version] required: [id, bookingNo, cabinetNo, sourceType, recordStatus, warehouseId, warehouseName, channelGroupCode, containerType, capacityCbm, maxWeightKg, planStatus, exceptionActive, destinationClearanceDocumentStatus, customsDeclarationDocumentStatus, deliveryOrderDocumentStatus, version]
properties: properties:
id: {type: string, pattern: '^[1-9][0-9]*$'} id: {type: string, pattern: '^[1-9][0-9]*$'}
planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空} planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空}
@@ -2185,8 +2185,9 @@ components:
sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜} sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜}
recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回} recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回}
mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空} mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空}
channelCode: {type: string, maxLength: 64, nullable: true, description: Missing legacy snapshots are blocked from loading until explicitly backfilled} 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: 模拟柜创建时确定的九类计划渠道分组,关联真实订舱后继续保留}
channelName: {type: string, maxLength: 128, nullable: true} channelCode: {type: string, maxLength: 64, nullable: true, description: 关联真实订舱后冻结的实际物流渠道编码}
channelName: {type: string, maxLength: 128, nullable: true, description: 关联真实订舱后冻结的实际物流渠道名称}
oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} oceanBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
oceanBookingRequirementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} oceanBookingRequirementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -2444,8 +2445,8 @@ components:
pages: {type: string, pattern: '^[0-9]+$'} pages: {type: string, pattern: '^[0-9]+$'}
SmartLoadingWorkbenchContainer: SmartLoadingWorkbenchContainer:
type: object type: object
required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelCode, required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelGroupCode,
channelName, containerType, capacityCbm, maxWeightKg, planStatus, version] containerType, capacityCbm, maxWeightKg, planStatus, version]
properties: properties:
bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联} bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联}
bookingNo: {type: string, description: 货柜登记业务号} bookingNo: {type: string, description: 货柜登记业务号}
@@ -2453,8 +2454,9 @@ components:
containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补} containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
warehouseName: {type: string} warehouseName: {type: string}
channelCode: {type: string} channelGroupCode: {type: string, enum: [MATSON_CLX, MATSON_MAX, EXX, HEDE, OA_LA, OA_CHICAGO, OA_HOUSTON, OA_SAVANNAH, OA_NEW_YORK_TRUCK], description: 智能装柜匹配使用的计划渠道分组}
channelName: {type: string} channelCode: {type: string, nullable: true, description: 关联真实订舱后冻结的实际物流渠道编码}
channelName: {type: string, nullable: true, description: 关联真实订舱后冻结的实际物流渠道名称}
containerType: {type: string} containerType: {type: string}
capacityCbm: {type: number, nullable: true, description: 柜型基础容积;装柜上限按正式规则计算} capacityCbm: {type: number, nullable: true, description: 柜型基础容积;装柜上限按正式规则计算}
maxWeightKg: {type: number, nullable: true, description: 柜型基础载重;装柜上限按正式规则计算} maxWeightKg: {type: number, nullable: true, description: 柜型基础载重;装柜上限按正式规则计算}
@@ -2706,9 +2708,9 @@ components:
reason: {type: string, maxLength: 500} reason: {type: string, maxLength: 500}
ContainerSimulationCreateRequest: ContainerSimulationCreateRequest:
type: object type: object
required: [channelCode, warehouseId, containerType, capacityCbm, maxWeightKg] required: [channelGroupCode, warehouseId, containerType, capacityCbm, maxWeightKg]
properties: properties:
channelCode: {type: string, maxLength: 64, description: 当前 ACTIVE 价格簿中的真实物流渠道} 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: [20GP, 40GP, 40HC, 40NR, 45HC], description: 与订舱共用的平台固定柜型}
capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量} capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量}
@@ -107,7 +107,8 @@ paths:
summary: Page released booking requirement candidates for one container summary: Page released booking requirement candidates for one container
description: >- description: >-
Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates: Requires platform:firstleg:ocean-booking:list. Returns only linkable requirement candidates:
RELEASED, remainingQuantity greater than zero, and exact warehouseId, channelCode and containerType. RELEASED, remainingQuantity greater than zero, exact warehouseId and containerType, and a concrete
booking channel belonging to channelGroupCode.
Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity Capacity and max weight are derived display snapshots and never eligibility fields. Allocated quantity
counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one counts only ACTIVE containers still pointing to the same booking requirement and is calculated by one
bounded set query; booking detail calls are not used. An empty result is a normal successful page. bounded set query; booking detail calls are not used. An empty result is a normal successful page.
@@ -115,7 +116,7 @@ paths:
x-permission: platform:firstleg:ocean-booking:list x-permission: platform:firstleg:ocean-booking:list
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: channelCode, 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, maxLength: 32}}
- {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}}
@@ -0,0 +1,17 @@
package com.baiyue.firstleg.domain;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class SmartLoadingChannelGroupCatalogTest {
@Test
void exposesNinePlanningGroupsAndMapsBothExxBookingChannels() {
assertThat(SmartLoadingChannelGroupCatalog.GROUPS).hasSize(9);
assertThat(SmartLoadingChannelGroupCatalog.require("EXX").channelCodes())
.contains("EXX_TRUCK", "EXX_RAIL_NY", "EXXHK", "EXXHWK", "NYEXX");
assertThat(SmartLoadingChannelGroupCatalog.contains("EXX", "EXX_TRUCK")).isTrue();
assertThat(SmartLoadingChannelGroupCatalog.contains("EXX", "EXX_RAIL_NY")).isTrue();
assertThat(SmartLoadingChannelGroupCatalog.contains("EXX", "MATSON_CLX_TRUCK")).isFalse();
}
}
@@ -80,7 +80,7 @@ class PlatformOceanBookingControllerContractTest {
assertThat(yaml) assertThat(yaml)
.contains("/api/platform/v1/ocean-bookings/link-candidates:") .contains("/api/platform/v1/ocean-bookings/link-candidates:")
.contains("x-permission: platform:firstleg:ocean-booking:list") .contains("x-permission: platform:firstleg:ocean-booking:list")
.contains("warehouseId", "channelCode", "containerType") .contains("warehouseId", "channelGroupCode", "containerType")
.contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity") .contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity")
.contains("Capacity and max weight are derived display snapshots and never eligibility fields") .contains("Capacity and max weight are derived display snapshots and never eligibility fields")
.contains("Returns only linkable requirement candidates") .contains("Returns only linkable requirement candidates")
@@ -49,12 +49,12 @@ class OceanBookingMapperContractTest {
@Test @Test
void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception { void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception {
String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates", String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates",
com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class) com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class, java.util.Set.class)
.getAnnotation(Select.class).value()); .getAnnotation(Select.class).value());
assertThat(sql) assertThat(sql)
.contains("booking.status='RELEASED'") .contains("booking.status='RELEASED'")
.contains("booking.warehouse_id=#{query.warehouseId}") .contains("booking.warehouse_id=#{query.warehouseId}")
.contains("booking.channel_code=#{query.channelCode}") .contains("<foreach collection=\"channelCodes\"", "#{channelCode}")
.contains("requirement.container_type=#{query.containerType}") .contains("requirement.container_type=#{query.containerType}")
.contains("FROM fl_ocean_booking_container_allocation") .contains("FROM fl_ocean_booking_container_allocation")
.contains("container.record_status='ACTIVE'") .contains("container.record_status='ACTIVE'")
@@ -329,19 +329,21 @@ class OceanBookingServiceTest {
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC", LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC",
new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2, new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2,
"客户A", "深圳中转仓", "美西海运"); "客户A", "深圳中转仓", "美西海运");
when(repository.linkCandidates(any())).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,
" SEA-US ", " 40HC ", null, null)); " MATSON_CLX ", " 40HC ", 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);
ArgumentCaptor<LinkCandidateQuery> query = ArgumentCaptor.forClass(LinkCandidateQuery.class); ArgumentCaptor<LinkCandidateQuery> query = ArgumentCaptor.forClass(LinkCandidateQuery.class);
verify(repository).linkCandidates(query.capture()); @SuppressWarnings("unchecked") ArgumentCaptor<java.util.Set<String>> channels = ArgumentCaptor.forClass(java.util.Set.class);
assertThat(query.getValue().channelCode()).isEqualTo("SEA-US"); verify(repository).linkCandidates(query.capture(), channels.capture());
assertThat(query.getValue().channelGroupCode()).isEqualTo("MATSON_CLX");
assertThat(query.getValue().containerType()).isEqualTo("40HC"); assertThat(query.getValue().containerType()).isEqualTo("40HC");
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");
verify(repository, never()).find(anyLong()); verify(repository, never()).find(anyLong());
verify(repository, never()).requirements(anyLong()); verify(repository, never()).requirements(anyLong());
verify(repository, never()).containers(anyLong()); verify(repository, never()).containers(anyLong());
@@ -349,15 +351,15 @@ class OceanBookingServiceTest {
@Test @Test
void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() { void linkCandidatesReturnsNormalEmptyPageWithOneSetQueryAndNoDiagnosticFields() {
when(repository.linkCandidates(any())).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, "SEA-US", "45HC", null, null)); new LinkCandidateQuery(701L, "MATSON_CLX", "45HC", null, null));
assertThat(result.records()).isEmpty(); assertThat(result.records()).isEmpty();
assertThat(result.total()).isZero(); assertThat(result.total()).isZero();
assertThat(result.pages()).isZero(); assertThat(result.pages()).isZero();
verify(repository, times(1)).linkCandidates(any()); verify(repository, times(1)).linkCandidates(any(), anySet());
assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName) assertThat(LinkCandidate.class.getRecordComponents()).extracting(java.lang.reflect.RecordComponent::getName)
.doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode"); .doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode");
} }
@@ -80,7 +80,9 @@ class ContainerServiceTest {
assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001"); assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001");
assertThat(saved.getContainerNo()).isNull(); assertThat(saved.getContainerNo()).isNull();
assertThat(saved.getOceanBookingId()).isNull(); assertThat(saved.getOceanBookingId()).isNull();
assertThat(saved.getChannelName()).isEqualTo("美西海运"); assertThat(saved.getChannelGroupCode()).isEqualTo("MATSON_CLX");
assertThat(saved.getChannelCode()).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("76.0000");
@@ -94,7 +96,7 @@ class ContainerServiceTest {
@DisplayName("新增模拟柜拒绝柜型与固定规格不匹配") @DisplayName("新增模拟柜拒绝柜型与固定规格不匹配")
void rejectsSimulationWithNonCanonicalSpecification() { void rejectsSimulationWithNonCanonicalSpecification() {
SimulationCreateRequest invalid = new SimulationCreateRequest( SimulationCreateRequest invalid = new SimulationCreateRequest(
"SEA-US", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000")); "MATSON_CLX", 101L, "40HC", new BigDecimal("100"), new BigDecimal("28000"));
assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid)) assertThatThrownBy(() -> service.createSimulation(9L, "admin", invalid))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
@@ -202,10 +204,10 @@ 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.setChannelCode("SEA-US"); container.setContainerType("40HC"); container.setWarehouseId(101L); container.setChannelGroupCode("EXX"); container.setChannelCode(null); container.setContainerType("40HC");
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("SEA-US"); 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("40HC"); requirement.setCapacityCbm(new BigDecimal("76.0000")); requirement.setMaxWeightKg(new BigDecimal("28000.000"));
when(repository.find(11L)).thenReturn(container, container); when(repository.find(11L)).thenReturn(container, container);
@@ -311,13 +313,13 @@ 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.setChannelCode("SEA-US"); first.setContainerType("40HC"); first.setChannelGroupCode("MATSON_CLX"); first.setChannelCode(null); first.setContainerType("40HC");
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.setChannelCode("SEA-US"); second.setContainerType("40HC"); second.setChannelGroupCode("MATSON_CLX"); second.setChannelCode(null); second.setContainerType("40HC");
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("SEA-US"); 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("40HC");
when(repository.find(11L)).thenReturn(first); when(repository.find(11L)).thenReturn(first);
@@ -360,19 +362,19 @@ class ContainerServiceTest {
@Test @Test
@DisplayName("首次关联拒绝装柜仓库不一致") @DisplayName("首次关联拒绝装柜仓库不一致")
void rejectsLinkWhenWarehouseDiffers() { void rejectsLinkWhenWarehouseDiffers() {
assertThreeFieldMismatch(102L, "SEA-US", "40HC"); assertThreeFieldMismatch(102L, "MATSON_CLX_TRUCK", "40HC");
} }
@Test @Test
@DisplayName("首次关联拒绝物流渠道不一致") @DisplayName("首次关联拒绝物流渠道不一致")
void rejectsLinkWhenChannelDiffers() { void rejectsLinkWhenChannelDiffers() {
assertThreeFieldMismatch(101L, "SEA-EU", "40HC"); assertThreeFieldMismatch(101L, "HEDE_TRUCK", "40HC");
} }
@Test @Test
@DisplayName("首次关联拒绝柜型不一致") @DisplayName("首次关联拒绝柜型不一致")
void rejectsLinkWhenContainerTypeDiffers() { void rejectsLinkWhenContainerTypeDiffers() {
assertThreeFieldMismatch(101L, "SEA-US", "45HC"); assertThreeFieldMismatch(101L, "MATSON_CLX_TRUCK", "45HC");
} }
@Test @Test
@@ -415,7 +417,7 @@ class ContainerServiceTest {
String requirementType) { String requirementType) {
ContainerBookingEntity container = booking("WAIT_LOADING", 2); ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null); container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
container.setContainerType("40HC"); container.setContainerType("40HC");
OceanBookingEntity oceanBooking = new OceanBookingEntity(); OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
@@ -429,7 +431,7 @@ class ContainerServiceTest {
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2))) new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致"); .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道分组或柜型不一致");
verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(),
any(), anyInt(), anyLong(), anyString()); any(), anyInt(), anyLong(), anyString());
} }
@@ -437,11 +439,11 @@ class ContainerServiceTest {
private void stubLinkContext() { private void stubLinkContext() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2); ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null); container.setContainerNo(null);
container.setWarehouseId(101L); container.setChannelCode("SEA-US"); container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null);
container.setContainerType("40HC"); container.setContainerType("40HC");
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("SEA-US"); 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("40HC");
when(repository.find(11L)).thenReturn(container); when(repository.find(11L)).thenReturn(container);
@@ -491,10 +493,10 @@ class ContainerServiceTest {
} }
private static SimulationCreateRequest simulation() { private static SimulationCreateRequest simulation() {
return new SimulationCreateRequest("SEA-US", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000")); return new SimulationCreateRequest("MATSON_CLX", 101L, "40HC", new BigDecimal("76.0000"), new BigDecimal("28000.000"));
} }
private static ChannelRow channel() { private static ChannelRow channel() {
ChannelRow channel = new ChannelRow(); channel.setChannelCode("SEA-US"); channel.setChannelName("美西海运"); return channel; ChannelRow channel = new ChannelRow(); channel.setChannelCode("MATSON_CLX_TRUCK"); channel.setChannelName("美西海运"); return channel;
} }
} }
@@ -171,11 +171,11 @@ class SmartLoadingServiceTest {
@Test @Test
void groupsCandidateSpecificationsByInboundCodeForQuantityOnlyPlanning() { void groupsCandidateSpecificationsByInboundCodeForQuantityOnlyPlanning() {
Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L, Candidate first = new Candidate(9L, 10L, 20L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "服装", 3, "MATSON_CLX_TRUCK", "美森正班海卡", 200L, "FBA1", "FBA仓", "客户", "服装", 3,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"), new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.2"),
null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00")); null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00"));
Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L, Candidate second = new Candidate(9L, 10L, 21L, 30L, "O", "I", 100L,
"FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7, "MATSON_CLX_TRUCK", "美森正班海卡", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"), new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("0.5"),
null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00")); null, null, null, null, null, null, "WAIT_SPLIT", LocalDateTime.parse("2026-09-10T09:30:00"));
when(repository.booking(99L)).thenReturn(booking()); when(repository.booking(99L)).thenReturn(booking());
@@ -515,7 +515,7 @@ 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_TRUCK", "美森正班海卡", "40HC", 100L, "义乌仓", "MATSON_CLX", null, null, "40HC",
new BigDecimal("68"), new BigDecimal("28000"), new BigDecimal("68"), new BigDecimal("28000"),
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,
@@ -530,8 +530,7 @@ class SmartLoadingServiceTest {
@SuppressWarnings("unchecked") ArgumentCaptor<java.util.Set<String>> channels = @SuppressWarnings("unchecked") ArgumentCaptor<java.util.Set<String>> channels =
ArgumentCaptor.forClass(java.util.Set.class); ArgumentCaptor.forClass(java.util.Set.class);
verify(repository).workbenchContainers(eq(query), channels.capture()); verify(repository).workbenchContainers(eq(query), channels.capture());
assertThat(channels.getValue()).containsExactlyInAnyOrder( assertThat(channels.getValue()).containsExactly("MATSON_CLX");
"MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL");
} }
@Test @Test
@@ -1694,7 +1693,7 @@ class SmartLoadingServiceTest {
private static Candidate candidate(long tenantId, long inboundId, long detailId, long orderId, int boxes, private static Candidate candidate(long tenantId, long inboundId, long detailId, long orderId, int boxes,
String destinationCode) { String destinationCode) {
return new Candidate(tenantId, inboundId, detailId, orderId, "O" + orderId, "I" + inboundId, return new Candidate(tenantId, inboundId, detailId, orderId, "O" + orderId, "I" + inboundId,
100L, "FBA", "FBA渠道", 200L, destinationCode, "FBA仓", "客户", "货物", boxes, 100L, "MATSON_CLX_TRUCK", "美森正班海卡", 200L, destinationCode, "FBA仓", "客户", "货物", boxes,
new BigDecimal("2"), new BigDecimal("1.5"), new BigDecimal("2"), new BigDecimal("1.5"),
new BigDecimal("0.5"), null, null, null, null, null, null, null, null); new BigDecimal("0.5"), null, null, null, null, null, null, null, null);
} }
@@ -1771,7 +1770,7 @@ class SmartLoadingServiceTest {
private static ContainerBookingEntity booking() { private static ContainerBookingEntity booking() {
ContainerBookingEntity value = new ContainerBookingEntity(); ContainerBookingEntity value = new ContainerBookingEntity();
value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1"); value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1");
value.setChannelCode("FBA"); value.setChannelGroupCode("MATSON_CLX"); value.setChannelCode(null);
value.setContainerNo("MSCU1234567"); value.setContainerNo("MSCU1234567");
value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000")); value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000"));
return value; return value;
+1 -1
View File
@@ -589,7 +589,7 @@ Assert-Contains $backendAgents 'RestClientTraceInterceptor.java' '后端同步 T
$oceanBookingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml') $oceanBookingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml')
Assert-Contains $oceanBookingOpenApi 'never represents linked or ACTIVE containers' '订舱柜量语义' Assert-Contains $oceanBookingOpenApi 'never represents linked or ACTIVE containers' '订舱柜量语义'
Assert-Contains $oceanBookingOpenApi '/api/platform/v1/ocean-bookings/link-candidates:' '订舱关联候选契约' Assert-Contains $oceanBookingOpenApi '/api/platform/v1/ocean-bookings/link-candidates:' '订舱关联候选契约'
Assert-Contains $oceanBookingOpenApi 'exact warehouseId, channelCode and containerType' '订舱关联候选筛选语义' Assert-Contains $oceanBookingOpenApi 'booking channel belonging to channelGroupCode' '订舱关联候选筛选语义'
$adminOceanBookingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts') $adminOceanBookingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts')
$adminOceanBookingView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue') $adminOceanBookingView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue')