diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 31f1e1a75..847dae411 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -5060,6 +5060,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID', + channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留', channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装', channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID', @@ -5854,6 +5855,7 @@ BEGIN IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_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.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 ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no; 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 ALTER TABLE baiyue_firstleg.fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code; END IF; @@ -16453,6 +16458,7 @@ BEGIN IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_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.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 3e6de5460..a301acdd1 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -85,7 +85,7 @@ ## 国内货柜履约装柜与 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` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。装柜允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责;PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalog.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalog.java new file mode 100644 index 000000000..0e07eefbe --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalog.java @@ -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 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 BY_CODE = indexByCode(); + private static final Map 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 indexByCode() { + Map result = new LinkedHashMap<>(); + GROUPS.forEach(group -> result.put(group.code(), group)); + return Map.copyOf(result); + } + + private static Map indexByChannel() { + Map 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 channelCodes) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java index b20580354..b9ee1f536 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/ContainerDtos.java @@ -83,7 +83,7 @@ public final class ContainerDtos { ) {} @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 @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index acca35f80..939eff487 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -49,7 +49,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} } } 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, Long planId, String planNo, String planStatus, Integer version) {} public record Candidate(Long accountId, Long inboundId, Long inboundDetailId, Long orderId, String orderNo, String inboundCode, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java index 6f6244d5c..1901cacff 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/ContainerBookingEntity.java @@ -24,6 +24,7 @@ public class ContainerBookingEntity { private String sourceType; private String recordStatus; private Long mergedIntoContainerBookingId; + private String channelGroupCode; private String channelCode; private String channelName; private Long oceanBookingId; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index e85a6daa8..1bea8b781 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -321,7 +321,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba @Select(""" """) Page selectLinkCandidates(Page page, - @Param("query") LinkCandidateQuery query); + @Param("query") LinkCandidateQuery query, + @Param("channelCodes") java.util.Set channelCodes); @Update(""" UPDATE fl_ocean_booking SET status=#{toStatus}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index e6c8f1013..b5f5bb663 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -33,8 +33,8 @@ public class OceanBookingRepository { public PageResult page(Query query) { return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query)); } - public PageResult linkCandidates(LinkCandidateQuery query) { - return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query)); + public PageResult linkCandidates(LinkCandidateQuery query, java.util.Set channelCodes) { + return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query, channelCodes)); } public OceanBookingEntity find(long id) { return bookings.selectById(id); } public OceanBookingEntity findForUpdate(long id) { return bookings.selectForUpdate(id); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index a08d56de7..14163fa4d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -10,6 +10,7 @@ import com.baiyue.firstleg.client.WarehousePartnerClient; import com.baiyue.firstleg.client.WarehouseSupplierClient; import com.baiyue.firstleg.oceanbooking.domain.OceanBookingStatus; import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; +import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.*; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository; @@ -71,8 +72,11 @@ public class OceanBookingService { } public PageResult 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(), - query.channelCode().trim(), query.containerType().trim(), query.page(), query.size())); + group.code(), query.containerType().trim(), query.page(), query.size()), group.channelCodes()); } public List channels(long originWarehouseId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index 5ebe83126..26b223597 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -49,9 +49,9 @@ public class SmartLoadingRepository { return units.isEmpty() ? List.of() : candidates.globalCandidatesByUnits(units); } public PageResult workbenchContainers(WorkbenchContainerQuery query, - Set actualChannels) { + Set channelGroups) { return PageResult.from(candidates.workbenchContainers( - new Page<>(query.page(), query.size()), query, actualChannels)); + new Page<>(query.page(), query.size()), query, channelGroups)); } public List channelContainerCounts(long warehouseId) { return candidates.channelContainerCounts(warehouseId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 4fbae5f56..c2565a9ff 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.domain.SmartLoadingStatus; import com.baiyue.firstleg.domain.ContainerSpecificationCatalog; +import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog; import com.baiyue.firstleg.dto.ContainerDtos.FullUpdateRequest; import com.baiyue.firstleg.dto.ContainerDtos.SaveRequest; import com.baiyue.firstleg.dto.ContainerDtos.SimulationCreateRequest; @@ -55,13 +56,16 @@ public class ContainerService { WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(request.warehouseId()); Long versionId = priceBooks.activeVersionId(); if (versionId == null) throw conflict("当前没有 ACTIVE 价格版本"); - ChannelRow channel = priceBooks.channels(versionId, warehouse.id()).stream() - .filter(row -> request.channelCode().trim().equals(row.getChannelCode())).findFirst() - .orElseThrow(() -> conflict("所选物流渠道已失效,请重新选择")); + SmartLoadingChannelGroupCatalog.Definition channelGroup; + try { channelGroup = SmartLoadingChannelGroupCatalog.require(request.channelGroupCode()); } + 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()); value.setBookingNo(documentNumbers.next("CB")); value.setCabinetNo(documentNumbers.next("CAB")); 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.setContainerType(specification.containerType()); value.setCapacityCbm(specification.capacityCbm()); 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); if (containerNo != null && repository.containerNoExists(containerNo, id)) throw conflict("集装箱号已存在"); 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())) { - throw conflict("模拟柜与真实订舱柜仓库、渠道或柜型不一致"); + throw conflict("模拟柜与真实订舱柜仓库、渠道分组或柜型不一致"); } try { if (repository.linkReleasedBooking(id, request.oceanBookingId(), request.requirementId(), containerNo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 5806840b8..3e8b1e67c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.firstleg.dto.SmartLoadingDtos.*; +import com.baiyue.firstleg.domain.SmartLoadingChannelGroupCatalog; import com.baiyue.firstleg.client.OrderCargoClient; import com.baiyue.firstleg.client.PendingReviewOrderClient; import com.baiyue.firstleg.entity.*; @@ -42,17 +43,6 @@ import java.util.*; @Service public class SmartLoadingService { private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.50"); - private static final Map> 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 com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets; @Resource private OrderCargoClient orderCargoClient; @@ -102,7 +92,7 @@ public class SmartLoadingService { public List candidates(long bookingId, long warehouseId) { ContainerBookingEntity booking = requireBooking(bookingId); if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致"); - if (!StringUtils.hasText(booking.getChannelCode())) throw validation("柜次缺少渠道快照,请先完成存量数据回填"); + if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜"); List values = candidates(warehouseId).stream() .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values); @@ -266,7 +256,7 @@ public class SmartLoadingService { && query.createdStart().isAfter(query.createdEnd())) { throw validation("创建开始日期不能晚于结束日期"); } - return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes())); + return repository.workbenchContainers(query, normalizedGroups(query.channelGroupCodes())); } private List inboundCandidates(List details) { @@ -384,13 +374,17 @@ public class SmartLoadingService { private static Set actualChannels(List groups) { Set actual = new LinkedHashSet<>(); for (String value : groups) { - String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); - Set mapped = WORKBENCH_CHANNEL_GROUPS.get(normalized); - if (mapped == null) throw validation("不支持的渠道分组: " + value); - actual.addAll(mapped); + try { actual.addAll(SmartLoadingChannelGroupCatalog.require(value).channelCodes()); } + catch (IllegalArgumentException exception) { throw validation(exception.getMessage()); } } return actual; } + private static Set normalizedGroups(List 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) 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 static boolean sameChannel(ContainerBookingEntity booking, Candidate candidate) { - return StringUtils.hasText(booking.getChannelCode()) && StringUtils.hasText(candidate.channelCode()) - && booking.getChannelCode().equals(candidate.channelCode()); + SmartLoadingChannelGroupCatalog.Definition candidateGroup = + SmartLoadingChannelGroupCatalog.resolveChannel(candidate.channelCode()); + return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null + && booking.getChannelGroupCode().equals(candidateGroup.code()); } private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId()); diff --git a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql index 253376b8c..c7ca8bad3 100644 --- a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql +++ b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql @@ -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 ALTER TABLE fl_container_booking ADD COLUMN channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照' AFTER cabinet_no; END IF; + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='fl_container_booking' AND column_name='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 ALTER TABLE fl_container_booking ADD COLUMN channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照' AFTER channel_code; END IF; @@ -93,6 +96,7 @@ BEGIN IF current_plan_status IN ('LOADING', 'COMPLETED') AND ( NOT (OLD.id <=> NEW.id) OR NOT (OLD.booking_no <=> NEW.booking_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.ocean_booking_id <=> NEW.ocean_booking_id) OR NOT (OLD.ocean_booking_requirement_id <=> NEW.ocean_booking_requirement_id) diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 25e445f75..45ae823fe 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -41,6 +41,7 @@ CREATE TABLE IF NOT EXISTS fl_container_booking ( source_type VARCHAR(24) NOT NULL DEFAULT 'SIMULATION' COMMENT '记录来源:SIMULATION内部模拟柜、OCEAN_BOOKING订舱真实柜', record_status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '记录状态:ACTIVE可操作、MERGED已合并历史', merged_into_container_booking_id BIGINT UNSIGNED NULL COMMENT '真实柜被模拟柜吸收后的目标稳定柜次ID', + channel_group_code VARCHAR(64) NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留', channel_code VARCHAR(64) NULL COMMENT '订舱渠道编码快照;存量回填前禁止混装', channel_name VARCHAR(128) NULL COMMENT '订舱渠道名称快照', ocean_booking_id BIGINT UNSIGNED NULL COMMENT '来源订舱ID', diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 8c9a4e852..f3389f8c6 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -2174,7 +2174,7 @@ components: allowedActions: {type: array, items: {type: string, enum: [SELECT_FLEET, SCHEDULE_PICKUP, RECORD_PICKUP, RECORD_EMPTY_RETURN, SEND_PICKUP_EMAIL]}} ContainerBooking: 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: id: {type: string, pattern: '^[1-9][0-9]*$'} 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 为订舱登记直接生成的真实柜} recordStatus: {type: string, enum: [ACTIVE, MERGED], description: 正常列表和详情仅返回 ACTIVE;真实订舱柜被模拟柜消费后变为 MERGED 且不再作为可操作柜返回} mergedIntoContainerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: MERGED 源真实柜指向保留的模拟柜稳定 containerBookingId;ACTIVE 时为空} - channelCode: {type: string, maxLength: 64, nullable: true, description: Missing legacy snapshots are blocked from loading until explicitly backfilled} - channelName: {type: string, maxLength: 128, nullable: true} + 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: 模拟柜创建时确定的九类计划渠道分组,关联真实订舱后继续保留} + 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} oceanBookingRequirementId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -2444,8 +2445,8 @@ components: pages: {type: string, pattern: '^[0-9]+$'} SmartLoadingWorkbenchContainer: type: object - required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelCode, - channelName, containerType, capacityCbm, maxWeightKg, planStatus, version] + required: [bookingId, bookingNo, cabinetNo, warehouseId, warehouseName, channelGroupCode, + containerType, capacityCbm, maxWeightKg, planStatus, version] properties: bookingId: {type: string, pattern: '^[1-9][0-9]*$', description: containerBookingId 技术关联} bookingNo: {type: string, description: 货柜登记业务号} @@ -2453,8 +2454,9 @@ components: containerNo: {type: string, nullable: true, description: 真实集装箱号,允许在装柜前后补} warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} warehouseName: {type: string} - channelCode: {type: string} - channelName: {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: 智能装柜匹配使用的计划渠道分组} + channelCode: {type: string, nullable: true, description: 关联真实订舱后冻结的实际物流渠道编码} + channelName: {type: string, nullable: true, description: 关联真实订舱后冻结的实际物流渠道名称} containerType: {type: string} capacityCbm: {type: number, nullable: true, description: 柜型基础容积;装柜上限按正式规则计算} maxWeightKg: {type: number, nullable: true, description: 柜型基础载重;装柜上限按正式规则计算} @@ -2706,9 +2708,9 @@ components: reason: {type: string, maxLength: 500} ContainerSimulationCreateRequest: type: object - required: [channelCode, warehouseId, containerType, capacityCbm, maxWeightKg] + required: [channelGroupCode, warehouseId, containerType, capacityCbm, maxWeightKg] 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} containerType: {type: string, enum: [20GP, 40GP, 40HC, 40NR, 45HC], description: 与订舱共用的平台固定柜型} capacityCbm: {type: number, minimum: 0, exclusiveMinimum: true, description: 必须等于柜型固定容量} diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 1ef8cff2c..f0c12cb69 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -107,7 +107,8 @@ paths: summary: Page released booking requirement candidates for one container description: >- 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 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. @@ -115,7 +116,7 @@ paths: x-permission: platform:firstleg:ocean-booking:list parameters: - {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: 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}} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalogTest.java b/firstleg/src/test/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalogTest.java new file mode 100644 index 000000000..100d3dd35 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/domain/SmartLoadingChannelGroupCatalogTest.java @@ -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(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index ac8b24244..c58a9e2cf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -80,7 +80,7 @@ class PlatformOceanBookingControllerContractTest { assertThat(yaml) .contains("/api/platform/v1/ocean-bookings/link-candidates:") .contains("x-permission: platform:firstleg:ocean-booking:list") - .contains("warehouseId", "channelCode", "containerType") + .contains("warehouseId", "channelGroupCode", "containerType") .contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity") .contains("Capacity and max weight are derived display snapshots and never eligibility fields") .contains("Returns only linkable requirement candidates") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 622b9844c..156feea9c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -49,12 +49,12 @@ class OceanBookingMapperContractTest { @Test void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception { 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()); assertThat(sql) .contains("booking.status='RELEASED'") .contains("booking.warehouse_id=#{query.warehouseId}") - .contains("booking.channel_code=#{query.channelCode}") + .contains("(List.of(candidate), 1, 20, 1, 1)); + when(repository.linkCandidates(any(), anySet())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); PageResult result = service.linkCandidates(new LinkCandidateQuery(701L, - " SEA-US ", " 40HC ", null, null)); + " MATSON_CLX ", " 40HC ", null, null)); assertThat(result.records()).containsExactly(candidate); assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); ArgumentCaptor query = ArgumentCaptor.forClass(LinkCandidateQuery.class); - verify(repository).linkCandidates(query.capture()); - assertThat(query.getValue().channelCode()).isEqualTo("SEA-US"); + @SuppressWarnings("unchecked") ArgumentCaptor> channels = ArgumentCaptor.forClass(java.util.Set.class); + verify(repository).linkCandidates(query.capture(), channels.capture()); + assertThat(query.getValue().channelGroupCode()).isEqualTo("MATSON_CLX"); assertThat(query.getValue().containerType()).isEqualTo("40HC"); assertThat(query.getValue().page()).isEqualTo(1L); 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()).requirements(anyLong()); verify(repository, never()).containers(anyLong()); @@ -349,15 +351,15 @@ class OceanBookingServiceTest { @Test 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 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.total()).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) .doesNotContain("mismatchReasons", "disabledReason", "warehouseId", "channelCode"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 09e195623..b173ab652 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -80,7 +80,9 @@ class ContainerServiceTest { assertThat(saved.getCabinetNo()).isEqualTo("CAB-260818-0001"); assertThat(saved.getContainerNo()).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.getWarehouseName()).isEqualTo("上海中转仓"); assertThat(saved.getCapacityCbm()).isEqualByComparingTo("76.0000"); @@ -94,7 +96,7 @@ class ContainerServiceTest { @DisplayName("新增模拟柜拒绝柜型与固定规格不匹配") void rejectsSimulationWithNonCanonicalSpecification() { 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)) .isInstanceOf(BizException.class) @@ -202,10 +204,10 @@ class ContainerServiceTest { container.setPlanId(501L); container.setPlanNo("LP-260818-0001"); 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")); 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(); 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); @@ -311,13 +313,13 @@ class ContainerServiceTest { void linksMultipleRealContainersToTheSameReleasedBookingRequirement() { ContainerBookingEntity first = booking("WAIT_LOADING", 2); 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); 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(); 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(); requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC"); when(repository.find(11L)).thenReturn(first); @@ -360,19 +362,19 @@ class ContainerServiceTest { @Test @DisplayName("首次关联拒绝装柜仓库不一致") void rejectsLinkWhenWarehouseDiffers() { - assertThreeFieldMismatch(102L, "SEA-US", "40HC"); + assertThreeFieldMismatch(102L, "MATSON_CLX_TRUCK", "40HC"); } @Test @DisplayName("首次关联拒绝物流渠道不一致") void rejectsLinkWhenChannelDiffers() { - assertThreeFieldMismatch(101L, "SEA-EU", "40HC"); + assertThreeFieldMismatch(101L, "HEDE_TRUCK", "40HC"); } @Test @DisplayName("首次关联拒绝柜型不一致") void rejectsLinkWhenContainerTypeDiffers() { - assertThreeFieldMismatch(101L, "SEA-US", "45HC"); + assertThreeFieldMismatch(101L, "MATSON_CLX_TRUCK", "45HC"); } @Test @@ -415,7 +417,7 @@ class ContainerServiceTest { String requirementType) { ContainerBookingEntity container = booking("WAIT_LOADING", 2); container.setContainerNo(null); - container.setWarehouseId(101L); container.setChannelCode("SEA-US"); + container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null); container.setContainerType("40HC"); OceanBookingEntity oceanBooking = new OceanBookingEntity(); oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED"); @@ -429,7 +431,7 @@ class ContainerServiceTest { assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin", new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2))) - .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道或柜型不一致"); + .isInstanceOf(BizException.class).hasMessageContaining("仓库、渠道分组或柜型不一致"); verify(repository, never()).linkReleasedBooking(anyLong(), anyLong(), anyLong(), anyString(), any(), anyInt(), anyLong(), anyString()); } @@ -437,11 +439,11 @@ class ContainerServiceTest { private void stubLinkContext() { ContainerBookingEntity container = booking("WAIT_LOADING", 2); container.setContainerNo(null); - container.setWarehouseId(101L); container.setChannelCode("SEA-US"); + container.setWarehouseId(101L); container.setChannelGroupCode("MATSON_CLX"); container.setChannelCode(null); container.setContainerType("40HC"); OceanBookingEntity oceanBooking = new OceanBookingEntity(); 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(); requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC"); when(repository.find(11L)).thenReturn(container); @@ -491,10 +493,10 @@ class ContainerServiceTest { } 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() { - 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; } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index fbae44ef2..c465a7e8f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -171,11 +171,11 @@ class SmartLoadingServiceTest { @Test void groupsCandidateSpecificationsByInboundCodeForQuantityOnlyPlanning() { 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"), 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, - "FBA", "FBA渠道", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7, + "MATSON_CLX_TRUCK", "美森正班海卡", 200L, "FBA1", "FBA仓", "客户", "鞋类", 7, 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")); when(repository.booking(99L)).thenReturn(booking()); @@ -515,7 +515,7 @@ class SmartLoadingServiceTest { @Test void pagesWorkbenchContainersWithTheSameGlobalFilters() { 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"), 1L, "LP-1", "EDITING", 2); WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L, @@ -530,8 +530,7 @@ class SmartLoadingServiceTest { @SuppressWarnings("unchecked") ArgumentCaptor> channels = ArgumentCaptor.forClass(java.util.Set.class); verify(repository).workbenchContainers(eq(query), channels.capture()); - assertThat(channels.getValue()).containsExactlyInAnyOrder( - "MATSON_CLX_TRUCK", "MATSON_CLX_PARCEL"); + assertThat(channels.getValue()).containsExactly("MATSON_CLX"); } @Test @@ -1694,7 +1693,7 @@ class SmartLoadingServiceTest { private static Candidate candidate(long tenantId, long inboundId, long detailId, long orderId, int boxes, String destinationCode) { 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("0.5"), null, null, null, null, null, null, null, null); } @@ -1771,7 +1770,7 @@ class SmartLoadingServiceTest { private static ContainerBookingEntity booking() { ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1"); - value.setChannelCode("FBA"); + value.setChannelGroupCode("MATSON_CLX"); value.setChannelCode(null); value.setContainerNo("MSCU1234567"); value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000")); return value; diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index b595214c9..bc13ba95a 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -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') Assert-Contains $oceanBookingOpenApi 'never represents linked or ACTIVE containers' '订舱柜量语义' 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') $adminOceanBookingView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue')