feat(firstleg): align simulated containers with channel groups
This commit is contained in:
@@ -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)
|
||||
|
||||
+1
-1
@@ -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 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
|
||||
|
||||
+64
@@ -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) {
|
||||
|
||||
+6
-2
@@ -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},
|
||||
|
||||
+2
-2
@@ -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); }
|
||||
|
||||
+5
-1
@@ -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}}
|
||||
|
||||
+17
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+2
-2
@@ -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'")
|
||||
|
||||
+9
-7
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user