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());