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
@@ -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 = "新增内部模拟柜;内部柜号由服务端生成")
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) {}
@@ -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,
@@ -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;
@@ -321,7 +321,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
@Select("""
<script>
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.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
@@ -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
WHERE b.record_status='ACTIVE'
AND (p.id IS NULL OR p.plan_status IN ('EDITING','LOADING'))
AND b.channel_code IN
<foreach collection="actualChannels" item="channel" open="(" separator="," close=")">#{channel}</foreach>
AND b.channel_group_code IN
<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.customerId != null or query.inboundCode != null or query.createdStart != null or query.createdEnd != null">
AND EXISTS (
@@ -351,7 +351,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
""")
Page<WorkbenchContainer> workbenchContainers(Page<WorkbenchContainer> page,
@Param("query") WorkbenchContainerQuery query,
@Param("actualChannels") Set<String> actualChannels);
@Param("channelGroups") Set<String> channelGroups);
@Select("""
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,
@NotBlank @Size(max = 64) String channelCode,
@NotBlank @Size(max = 64) String channelGroupCode,
@NotBlank @Size(max = 32) String containerType,
@Min(1) Long page,
@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);
@Select("""
<script>
SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id,
requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg,
requirement.quantity required_quantity,
@@ -75,13 +76,16 @@ public interface OceanBookingMapper extends BaseMapper<OceanBookingEntity> {
AND allocated.requirement_id=requirement.id
WHERE booking.status='RELEASED'
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.quantity>COALESCE(allocated.allocated_quantity,0)
ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC
</script>
""")
Page<LinkCandidate> selectLinkCandidates(Page<LinkCandidate> page,
@Param("query") LinkCandidateQuery query);
@Param("query") LinkCandidateQuery query,
@Param("channelCodes") java.util.Set<String> channelCodes);
@Update("""
UPDATE fl_ocean_booking SET status=#{toStatus},
@@ -33,8 +33,8 @@ public class OceanBookingRepository {
public PageResult<OceanBookingEntity> page(Query query) {
return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query));
}
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query) {
return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query));
public PageResult<LinkCandidate> linkCandidates(LinkCandidateQuery query, java.util.Set<String> 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); }
@@ -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<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(),
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) {
@@ -49,9 +49,9 @@ public class SmartLoadingRepository {
return units.isEmpty() ? List.of() : candidates.globalCandidatesByUnits(units);
}
public PageResult<WorkbenchContainer> workbenchContainers(WorkbenchContainerQuery query,
Set<String> actualChannels) {
Set<String> channelGroups) {
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) {
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.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,
@@ -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<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 com.baiyue.firstleg.mapper.DropshipPalletizationMapper dropshipPallets;
@Resource private OrderCargoClient orderCargoClient;
@@ -102,7 +92,7 @@ public class SmartLoadingService {
public List<Candidate> 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<Candidate> 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<InboundCandidate> inboundCandidates(List<Candidate> details) {
@@ -384,13 +374,17 @@ public class SmartLoadingService {
private static Set<String> actualChannels(List<String> groups) {
Set<String> actual = new LinkedHashSet<>();
for (String value : groups) {
String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT);
Set<String> 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<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)
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());
@@ -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)
@@ -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',
@@ -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: 必须等于柜型固定容量}
@@ -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}}
@@ -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)
.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")
@@ -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("<foreach collection=\"channelCodes\"", "#{channelCode}")
.contains("requirement.container_type=#{query.containerType}")
.contains("FROM fl_ocean_booking_container_allocation")
.contains("container.record_status='ACTIVE'")
@@ -329,19 +329,21 @@ class OceanBookingServiceTest {
LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC",
new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2,
"客户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,
" SEA-US ", " 40HC ", null, null));
" MATSON_CLX ", " 40HC ", null, null));
assertThat(result.records()).containsExactly(candidate);
assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2);
ArgumentCaptor<LinkCandidateQuery> query = ArgumentCaptor.forClass(LinkCandidateQuery.class);
verify(repository).linkCandidates(query.capture());
assertThat(query.getValue().channelCode()).isEqualTo("SEA-US");
@SuppressWarnings("unchecked") ArgumentCaptor<java.util.Set<String>> 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<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.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");
}
@@ -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;
}
}
@@ -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<java.util.Set<String>> 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;