This commit is contained in:
2026-09-03 21:48:40 +08:00
parent 2b461cc6e8
commit eca2c94aa3
7 changed files with 222 additions and 49 deletions
+1 -1
View File
@@ -76,7 +76,7 @@
## 三期装柜与 PDA 资源结算
- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选只返回具备实际装柜资格的 `WAIT_SPLIT` 数据,同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元,不能跨页或拆分。分配到柜时继续复核国内中转仓、实际渠道、容量、组锁和版本;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入由有效柜次占用、剩余箱数、国内中转仓、实际渠道、容量、组锁和版本等事实决定;容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。同柜不可拆组必须成员完整、满箱且作为单个分页与分配单元,不能跨页或拆分。推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。当前契约中未传模式且 `locationIds=null` 时按 AUTO 处理。
@@ -87,6 +87,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
FROM (
SELECT o.tenant_id accountId,o.id inboundId,o.inbound_code inboundCode,
o.expected_arrival_at expectedArrivalAt,o.created_at createdAt,
CASE WHEN o.process_status='WAIT_SPLIT' THEN 'WAIT_SPLIT'
WHEN o.process_status='LOADING' THEN 'LOADING' ELSE 'WAIT_MEASURE' END candidateStatus,
CASE WHEN o.process_status='WAIT_SPLIT' THEN
COALESCE((SELECT SUM(mi.package_count) FROM fl_inbound_measurement m
JOIN fl_inbound_measurement_item mi
@@ -132,7 +134,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
ON current_member.tenant_id=eligible.accountId AND current_member.inbound_id=eligible.inboundId
AND current_member.active_flag=1
LEFT JOIN fl_coload_group current_group
ON current_group.id=current_member.group_id AND current_group.group_status IN ('ACTIVE','LOCKED')
ON current_group.id=current_member.group_id AND current_group.group_status IN ('ACTIVE','LOCKED')
WHERE eligible.candidateStatus<>'WAIT_SPLIT' OR eligible.availablePackageCount>0
GROUP BY current_group.id,
CASE WHEN current_group.id IS NULL THEN eligible.accountId END,
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END
@@ -41,7 +41,7 @@ import java.util.*;
/** 智能装柜的自动计划、候选、推荐、人工分配和删除编排。 */
@Service
public class SmartLoadingService {
private static final BigDecimal CAPACITY_LIMIT_RATIO = new BigDecimal("1.10");
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"),
@@ -278,7 +278,8 @@ public class SmartLoadingService {
.sorted(Comparator.comparing(InboundCandidate::inboundId)).toList();
InboundCandidate first = ordered.get(0);
int packages = ordered.stream().map(InboundCandidate::availablePackageCount).mapToInt(Integer::intValue).sum();
if (packages != metadata.groupTotalBoxes()) return null;
boolean occupied = ordered.stream().anyMatch(candidate -> Boolean.TRUE.equals(candidate.locked()));
if (!occupied && packages != metadata.groupTotalBoxes()) return null;
boolean hasVolume = ordered.stream().anyMatch(candidate -> candidate.availableVolumeCbm() != null);
BigDecimal volume = hasVolume ? ordered.stream().map(InboundCandidate::availableVolumeCbm)
.filter(Objects::nonNull).reduce(BigDecimal.ZERO, BigDecimal::add).setScale(4, RoundingMode.HALF_UP) : null;
@@ -299,7 +300,7 @@ public class SmartLoadingService {
first.destinationWarehouseId(), first.destinationWarehouseCode(), first.destinationWarehouseName(),
customers, goods, packages, gross, net, averageVolume, volume, metadata.coLoadGroupId(), metadata.groupNo(),
metadata.memberCount(), metadata.groupTotalBoxes(),
ordered.stream().anyMatch(candidate -> Boolean.TRUE.equals(candidate.locked())),
occupied,
ordered.stream().allMatch(candidate -> Boolean.TRUE.equals(candidate.canAssign())),
ordered.stream().map(InboundCandidate::blockReason).filter(StringUtils::hasText).findFirst().orElse(null),
groupStatus(ordered),
@@ -1017,7 +1018,7 @@ public class SmartLoadingService {
result.put(member.inboundId(), details);
}
if (!groupFits(booking, planItems, group, available)) {
throw validation("同柜不可拆组整体超过货柜容积或重量的110%上限");
throw validation("同柜不可拆组整体超过货柜容积或重量的150%上限");
}
return result;
}
@@ -1270,7 +1271,7 @@ public class SmartLoadingService {
if (b.getCapacityCbm()==null || b.getMaxWeightKg()==null) throw validation("货柜容量和最大承重未配置");
if (v.compareTo(b.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO))>0
|| w.compareTo(b.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO))>0) {
throw validation("超过货柜容积或重量的110%上限");
throw validation("超过货柜容积或重量的150%上限");
}
}
private void checkPlanCapacity(ContainerBookingEntity booking, List<LoadingPlanItemEntity> items) {
@@ -1280,12 +1281,12 @@ public class SmartLoadingService {
BigDecimal weight = items.stream().map(x -> unit(x.getGrossWeightKg()))
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (weight.compareTo(booking.getMaxWeightKg().multiply(CAPACITY_LIMIT_RATIO)) > 0) {
throw validation("超过货柜重量的110%上限");
throw validation("超过货柜重量的150%上限");
}
BigDecimal volume = items.stream().map(x -> unit(x.getVolumeCbm()))
.reduce(BigDecimal.ZERO, BigDecimal::add);
if (volume.compareTo(booking.getCapacityCbm().multiply(CAPACITY_LIMIT_RATIO)) > 0) {
throw validation("超过货柜容积的110%上限");
throw validation("超过货柜容积的150%上限");
}
}
private int fittingBoxes(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c){
@@ -486,13 +486,16 @@ paths:
summary: 全局分页查询智能装柜候选
description: >-
需要 platform:firstleg:smart-loading:list 权限。查询不绑定柜次;渠道、仓库、客户、入仓码、
预计到货日期和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个
订单创建日期和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个
分页单元返回,groupMembers 包含完整成员,不会跨页或因单个成员命中搜索条件而拆散。
平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。
WAIT_SPLIT 返回 Firstleg 可用箱数;PENDING_REVIEW 返回 Order 权威待审核订单并允许先加入装柜清单。
订单审核状态只用于筛选和展示,不直接决定能否占位;已被有效柜次占用、LOADED、COMPLETED、
已锁定或成员不完整的组永不返回。分配时仍由目标 planId 接口复核真实渠道、仓库、容量、
组锁和并发版本;确认预装/PDA 前必须完成审核与测量并进入 WAIT_SPLIT。
orderStatuses 可重复传参且最多四项;未传或空数组表示 PENDING_REVIEW、WAIT_MEASURE、WAIT_SPLIT、
LOADING 全部未完成状态,已完成装柜永不返回。状态映射为:Order SUBMITTED=PENDING_REVIEW;
Firstleg WAIT_MEASURE/MEASURING/MEASURE_PENDING/MEASURE_REJECTED/WAIT_PRICING/FEE_CONFIRMING/INBOUNDING
=WAIT_MEASURE;WAIT_SPLIT=WAIT_SPLIT;LOADING=LOADING。状态只用于搜索和展示,canAssign 由有效柜次占用、
剩余箱数、同柜组、仓库、渠道和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。
待审核及待测量可以先加入清单;确认预装/PDA 前
仍必须完成审核与测量并进入 WAIT_SPLIT。同柜不可拆组仅在全部成员命中当前状态集合时作为一个分页单位返回。
parameters:
- name: channelGroupCodes
in: query
@@ -514,7 +517,15 @@ paths:
- {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}}
- {name: createdStart, in: query, required: false, schema: {type: string, format: date}}
- {name: createdEnd, in: query, required: false, schema: {type: string, format: date}}
- {name: orderStatus, in: query, required: false, schema: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}}
- name: orderStatuses
in: query
required: false
description: 可重复传参,未传或空数组表示全部四种未完成状态
schema:
type: array
maxItems: 4
uniqueItems: true
items: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
responses:
@@ -525,8 +536,8 @@ paths:
get:
summary: 分页查询智能装柜工作台柜次
description: >-
需要 platform:firstleg:smart-loading:list 权限。与全局候选使用相同的渠道、仓库、客户、入仓码、
订单创建日期及订单状态筛选;客户及入仓事实以柜内任一有效装柜明细命中为准,查询不会修改或拆散同柜组。
需要 platform:firstleg:smart-loading:list 权限。柜次查询只使用渠道、仓库、客户、入仓码及订单创建日期;
候选的 orderStatuses 不作用于柜次列表。客户及入仓事实以柜内任一有效装柜明细命中为准。
只返回 WAIT_LOADING、EDITING 或 LOADING 的 ACTIVE 柜次,COMPLETED 永不返回。
parameters:
- name: channelGroupCodes
@@ -543,7 +554,6 @@ paths:
- {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}}
- {name: createdStart, in: query, required: false, schema: {type: string, format: date}}
- {name: createdEnd, in: query, required: false, schema: {type: string, format: date}}
- {name: orderStatus, in: query, required: false, schema: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}}
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
responses:
@@ -605,7 +615,8 @@ paths:
summary: 按入仓码分配计划装柜箱数
description: >-
需要 platform:firstleg:smart-loading:assign 权限。操作员只选择某个入仓码的本次计划箱数;
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单。
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
柜内总容积和总重量分别允许达到柜型额定值的150%,超过即拒绝。
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
parameters:
- {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
@@ -2007,7 +2018,7 @@ components:
type: object
required: [accountId, customerId, inboundId, orderId, orderNo, inboundCode, warehouseId, channelCode,
channelName, customerName, goodsSummary, availablePackageCount, unitGrossWeightKg,
unitNetWeightKg, averageUnitVolumeCbm, availableVolumeCbm, locked, groupMembers]
unitNetWeightKg, averageUnitVolumeCbm, availableVolumeCbm, locked, canAssign, groupMembers]
properties:
accountId: {type: string, pattern: '^[1-9][0-9]*$'}
customerId: {type: string, pattern: '^[1-9][0-9]*$', description: 与 accountId 相同的客户技术关联ID}
@@ -2015,7 +2026,7 @@ components:
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
inboundCode: {type: string}
orderStatusSnapshot: {type: string, nullable: true}
orderStatusSnapshot: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
channelCode: {type: string}
@@ -2025,7 +2036,7 @@ components:
destinationWarehouseName: {type: string, nullable: true}
customerName: {type: string}
goodsSummary: {type: string, nullable: true}
availablePackageCount: {type: integer, minimum: 1, description: 普通行剩余箱数;组行完整成员箱数合计}
availablePackageCount: {type: integer, minimum: 0, description: 普通行剩余箱数;组行完整成员箱数合计}
unitGrossWeightKg: {type: number, minimum: 0, nullable: true}
unitNetWeightKg: {type: number, minimum: 0, nullable: true}
averageUnitVolumeCbm: {type: number, minimum: 0, nullable: true}
@@ -2035,6 +2046,7 @@ components:
memberCount: {type: integer, minimum: 2, nullable: true}
groupTotalBoxes: {type: integer, minimum: 1, nullable: true}
locked: {type: boolean}
canAssign: {type: boolean, description: 当前是否可加入柜次;与搜索状态解耦,以占用及业务约束事实计算}
blockReason: {type: string, nullable: true}
groupMembers:
type: array
@@ -2049,7 +2061,7 @@ components:
orderNo: {type: string}
inboundCode: {type: string}
customerName: {type: string}
availablePackageCount: {type: integer, minimum: 1}
availablePackageCount: {type: integer, minimum: 0}
orderStatusSnapshot: {type: string, nullable: true}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
SmartLoadingWorkbenchContainerPageEnvelope:
@@ -34,10 +34,15 @@ class PlatformSmartLoadingControllerContractTest {
.contains("name: warehouseId")
.contains("name: createdStart")
.contains("name: createdEnd")
.contains("name: orderStatuses")
.contains("maxItems: 4")
.contains("PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING")
.contains("SmartLoadingInboundCandidatePageEnvelope")
.doesNotContain("name: orderStatus,")
.doesNotContain("name: expectedArrivalStart")
.doesNotContain("name: expectedArrivalEnd")
.doesNotContain("name: bookingId");
assertThat(yaml).contains("canAssign: {type: boolean, description: 当前是否可加入柜次");
}
@Test void everyMutationDeclaresDedicatedPermission() throws Exception {
authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class);
@@ -75,6 +80,7 @@ class PlatformSmartLoadingControllerContractTest {
.contains("成功响应中的 version 是递增后的 planVersion")
.contains("items[].version 是递增后的 itemVersion")
.contains("数量调整或动态增量不会覆盖已有 warehouseRemark")
.contains("柜内总容积和总重量分别允许达到柜型额定值的150%")
.contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}")
@@ -71,10 +71,12 @@ class SmartLoadingCandidateMapperContractTest {
assertThat(unitSql)
.contains("#{query.warehouseId} IS NULL OR o.warehouse_id=#{query.warehouseId}")
.contains("o.channel_code IN")
.contains("o.process_status='WAIT_SPLIT'")
.contains("query.orderStatuses")
.contains("status.name() == 'WAIT_MEASURE'")
.contains("status.name() == 'WAIT_SPLIT'")
.contains("status.name() == 'LOADING'")
.contains("current_group.id groupId")
.contains("group_member_count.group_id=current_group.id")
.contains("availablePackageCount=current_member.full_package_count")
.doesNotContain("fl_container_booking")
.doesNotContain("bookingId");
assertThat(detailSql)
@@ -82,6 +84,8 @@ class SmartLoadingCandidateMapperContractTest {
.contains("current_group.id=#{unit.groupId}")
.contains("o.tenant_id=#{unit.accountId}")
.contains("o.id=#{unit.inboundId}")
.contains("'WAIT_SPLIT' orderStatusSnapshot")
.contains("THEN 'LOADING' ELSE 'WAIT_MEASURE' END orderStatusSnapshot")
.doesNotContain("fl_container_booking");
}
@@ -93,8 +97,8 @@ class SmartLoadingCandidateMapperContractTest {
assertThat(sql)
.contains("o.created_at createdAt")
.contains("DATE(MIN(eligible.createdAt))&gt;=#{query.createdStart}")
.contains("DATE(MIN(eligible.createdAt))&lt;=#{query.createdEnd}")
.contains("o.created_at&gt;=#{query.createdStart}")
.contains("o.created_at&lt;DATE_ADD(#{query.createdEnd},INTERVAL 1 DAY)")
.doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd");
}
@@ -126,13 +130,15 @@ class SmartLoadingCandidateMapperContractTest {
String unitScript = String.join(" ", units.getAnnotation(Select.class).value());
var query = new InboundCandidateQuery(List.of("MATSON_CLX"), 100L, 9L, "IN-",
java.time.LocalDate.parse("2026-09-01"), java.time.LocalDate.parse("2026-09-30"),
null, 1, 20);
List.of(com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus.WAIT_MEASURE,
com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus.WAIT_SPLIT), 1, 20);
String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class)
.getBoundSql(Map.of("query", query, "actualChannels", Set.of("MATSON_CLX_TRUCK"))).getSql();
assertThat(renderedUnits)
.contains("o.channel_code IN")
.contains("DATE(MIN(eligible.createdAt))>=", "DATE(MIN(eligible.createdAt))<=")
.doesNotContain("DATE(MIN(eligible.expectedArrivalAt))");
.contains("o.created_at>=", "o.created_at<DATE_ADD")
.contains("o.process_status IN ('WAIT_MEASURE'")
.contains("o.process_status='WAIT_SPLIT'");
Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod(
"globalCandidatesByUnits", List.class);
@@ -148,7 +154,7 @@ class SmartLoadingCandidateMapperContractTest {
@Test
void workbenchContainerQueryExcludesCompletedAndMatchesRelatedInboundFacts() throws Exception {
Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("workbenchContainers",
Page.class, WorkbenchContainerQuery.class, Set.class, String.class);
Page.class, WorkbenchContainerQuery.class, Set.class);
String sql = String.join(" ", list.getAnnotation(Select.class).value());
assertThat(sql)
@@ -158,7 +164,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("fl_inbound_order filter_inbound")
.contains("filter_item.inbound_code LIKE")
.contains("filter_inbound.created_at")
.contains("filter_inbound.process_status='LOADING'")
.doesNotContain("query.orderStatus", "orderStatuses")
.doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd")
.doesNotContain("p.plan_status IN ('EDITING','LOADING','COMPLETED')");
}
@@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.OrderCargoClient;
import com.baiyue.firstleg.client.PendingReviewOrderClient;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignInboundRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignPendingReviewRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate;
@@ -14,6 +15,7 @@ import com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus;
import com.baiyue.firstleg.dto.SmartLoadingDtos.DeleteRequest;
import com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidateQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.InboundCandidate;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainer;
import com.baiyue.firstleg.dto.SmartLoadingDtos.WorkbenchContainerQuery;
import com.baiyue.firstleg.dto.SmartLoadingDtos.Plan;
@@ -79,6 +81,7 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.atLeastOnce;
@SpringJUnitConfig(SmartLoadingServiceTest.Config.class)
class SmartLoadingServiceTest {
@@ -192,7 +195,8 @@ class SmartLoadingServiceTest {
Candidate max = withChannel(candidate(19L, 11L, 21L, 31L, 6),
"MATSON_MAX_TRUCK", "美森加班海卡");
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), null, null, null, null, null, null, 1, 20);
List.of("MATSON_CLX"), null, null, null, null, null,
List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20);
CandidateUnit unit = new CandidateUnit(null, 9L, 10L, null, 10L);
when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(List.of(unit), 1, 20, 1, 1));
@@ -212,12 +216,97 @@ class SmartLoadingServiceTest {
verify(repository, never()).booking(anyLong());
}
@Test
void combinesAllUnfinishedStatusesBeforeReturningTheRequestedPage() {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), null, null, null, null, null, List.of(), 1, 20);
var pending = new PendingReviewOrderClient.Row(31L, "O31", "I31", 9L, "客户",
100L, "EXX_TRUCK", "EXX海卡", 3, LocalDateTime.parse("2026-09-01T09:00:00"),
"PENDING_REVIEW");
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), anyLong(), anyLong())).thenAnswer(invocation -> {
long page = invocation.getArgument(7);
long size = invocation.getArgument(8);
return ApiResponse.success(new PageResult<>(page == 1 ? List.of(pending) : List.of(),
page, size, 1, 1));
});
Candidate measured = withStatus(candidate(9L, 10L, 20L, 30L, 4), "WAIT_SPLIT", false);
CandidateUnit unit = new CandidateUnit(null, 9L, 10L, null, 10L);
when(repository.globalCandidateUnits(any(), anySet())).thenAnswer(invocation -> {
InboundCandidateQuery requested = invocation.getArgument(0);
return new PageResult<>(requested.page() == 1 ? List.of(unit) : List.of(),
requested.page(), requested.size(), 1, 1);
});
when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(measured));
var result = service.inboundCandidates(query);
assertThat(result.total()).isEqualTo(2);
assertThat(result.records()).extracting(InboundCandidate::orderStatusSnapshot)
.containsExactly("PENDING_REVIEW", "WAIT_SPLIT");
assertThat(result.records()).extracting(InboundCandidate::canAssign)
.containsExactly(true, true);
ArgumentCaptor<InboundCandidateQuery> firstlegQueries =
ArgumentCaptor.forClass(InboundCandidateQuery.class);
verify(repository, atLeastOnce()).globalCandidateUnits(firstlegQueries.capture(), anySet());
assertThat(firstlegQueries.getAllValues()).allSatisfy(requested ->
assertThat(requested.orderStatuses()).containsExactly(
CandidateOrderStatus.WAIT_MEASURE, CandidateOrderStatus.WAIT_SPLIT,
CandidateOrderStatus.LOADING));
}
@Test
void statusDoesNotBlockAssignmentButAnActiveContainerOccupationDoes() {
InboundCandidateQuery query = new InboundCandidateQuery(List.of("EXX"), null, null, null,
null, null, List.of(CandidateOrderStatus.WAIT_MEASURE, CandidateOrderStatus.LOADING), 1, 20);
Candidate waiting = withStatus(candidate(9L, 10L, 20L, 30L, 4), "WAIT_MEASURE", false);
Candidate loading = withStatus(candidate(19L, 11L, 21L, 31L, 0), "LOADING", true);
List<CandidateUnit> units = List.of(new CandidateUnit(null, 9L, 10L, null, 10L),
new CandidateUnit(null, 19L, 11L, null, 11L));
when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(units, 1, 20, 2, 1));
when(repository.globalCandidatesByUnits(units)).thenReturn(List.of(waiting, loading));
var result = service.inboundCandidates(query);
assertThat(result.records()).filteredOn(candidate -> "WAIT_MEASURE".equals(candidate.orderStatusSnapshot()))
.singleElement().satisfies(candidate -> assertThat(candidate.canAssign()).isTrue());
assertThat(result.records()).filteredOn(candidate -> "LOADING".equals(candidate.orderStatusSnapshot()))
.singleElement().satisfies(candidate -> {
assertThat(candidate.canAssign()).isFalse();
assertThat(candidate.blockReason()).isEqualTo("该入仓码已被有效柜次占用");
});
}
@Test
void keepsAnOccupiedLoadingGroupAsOneNonAssignablePagingUnit() {
InboundCandidateQuery query = new InboundCandidateQuery(List.of("EXX"), null, null, null,
null, null, List.of(CandidateOrderStatus.LOADING), 1, 20);
Candidate first = withStatus(withGroup(candidate(9L, 10L, 20L, 30L, 0), 2, 10),
"LOADING", true);
Candidate second = withStatus(withGroup(candidate(19L, 11L, 21L, 31L, 0), 2, 10),
"LOADING", true);
CandidateUnit unit = new CandidateUnit(700L, null, null, null, 10L);
when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(List.of(unit), 1, 20, 1, 1));
when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(first, second));
var result = service.inboundCandidates(query);
assertThat(result.records()).singleElement().satisfies(candidate -> {
assertThat(candidate.coLoadGroupId()).isEqualTo(700L);
assertThat(candidate.groupMembers()).hasSize(2);
assertThat(candidate.canAssign()).isFalse();
assertThat(candidate.orderStatusSnapshot()).isEqualTo("LOADING");
});
}
@Test
void pagesPendingReviewOrdersFromTheOrderOwnedReadModelAsAssignableRows() {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), 193000000000200002L, null, "EXXHK2609020361",
LocalDate.parse("2026-09-02"), LocalDate.parse("2026-09-02"),
CandidateOrderStatus.PENDING_REVIEW, 1, 20);
List.of(CandidateOrderStatus.PENDING_REVIEW), 1, 20);
var row = new PendingReviewOrderClient.Row(2095052002489335810L, "FL-361",
"EXXHK2609020361", 2092797167699816450L, "客户", 193000000000200002L,
"EXX_TRUCK", "EXX海卡", 100, LocalDateTime.parse("2026-09-02T15:31:36"),
@@ -234,6 +323,7 @@ class SmartLoadingServiceTest {
assertThat(candidate.inboundId()).isNull();
assertThat(candidate.inboundCode()).isEqualTo("EXXHK2609020361");
assertThat(candidate.orderStatusSnapshot()).isEqualTo("PENDING_REVIEW");
assertThat(candidate.canAssign()).isTrue();
assertThat(candidate.blockReason()).isNull();
assertThat(candidate.availablePackageCount()).isEqualTo(100);
});
@@ -290,11 +380,55 @@ class SmartLoadingServiceTest {
verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1");
}
@Test
void assignsWaitMeasureInboundUsingDeclaredFactsWithoutTreatingStatusAsAGate() {
long customerId = 9L;
long inboundId = 10L;
long orderId = 30L;
InboundOrderEntity projection = new InboundOrderEntity();
projection.setId(inboundId);
projection.setTenantId(customerId);
projection.setOrderId(orderId);
projection.setInboundCode("I10");
projection.setWarehouseId(100L);
projection.setChannelCode("FBA");
projection.setProcessStatus("WAIT_MEASURE");
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
customerId, orderId, "O30", "I10", customerId, "客户", 100L, "义乌仓",
null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null,
null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5);
var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5,
null, null, null);
var cargo = new OrderCargoClient.CargoBinding(20L, orderId, 1, "货物", 5,
new BigDecimal("50"), new BigDecimal("2.5"));
when(repository.inbound(inboundId)).thenReturn(projection);
when(repository.inboundForUpdate(customerId, inboundId)).thenReturn(projection);
when(repository.plan(1L)).thenReturn(plan("EDITING", 0), plan("EDITING", 1));
when(repository.booking(99L)).thenReturn(booking());
when(repository.items(1L)).thenReturn(List.of());
when(repository.touch(1L, 0, 7L, "u")).thenReturn(1);
when(cargoClient.inboundSnapshot(anyString(), eq(customerId), eq(orderId)))
.thenReturn(ApiResponse.success(snapshot));
when(cargoClient.listForCartonLabels(anyString(), eq(customerId), eq(orderId)))
.thenReturn(ApiResponse.success(List.of(label)));
when(cargoClient.detail(anyString(), eq(customerId), eq(orderId), eq(20L)))
.thenReturn(ApiResponse.success(cargo));
Plan result = service.assignInbound(1L,
new AssignInboundRequest(inboundId, 5, 0, "wait-measure-1"), 7L, "u");
assertThat(result.version()).isEqualTo(1);
verify(repository).insert(any(LoadingPlanItemEntity.class));
verify(splitPreloadService).ensurePendingForPreAllocation(customerId, inboundId, 7L, "u");
verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, inboundId,
7L, "u", "wait-measure-1");
}
@Test
void failsClosedWhenPendingReviewOrderSourceIsUnavailable() {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), null, null, null, null, null,
CandidateOrderStatus.PENDING_REVIEW, 1, 20);
List.of(CandidateOrderStatus.PENDING_REVIEW), 1, 20);
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline"));
@@ -310,7 +444,7 @@ class SmartLoadingServiceTest {
1L, "LP-1", "EDITING", 2);
WorkbenchContainerQuery query = new WorkbenchContainerQuery(List.of("MATSON_CLX"), 100L,
9L, "IN-10", LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-30"),
CandidateOrderStatus.WAIT_SPLIT, 1, 20);
1, 20);
when(repository.workbenchContainers(eq(query), any())).thenReturn(
new com.baiyue.common.core.api.PageResult<>(List.of(container), 1, 20, 1, 1));
@@ -332,7 +466,7 @@ class SmartLoadingServiceTest {
"MATSON_CLX_TRUCK", "美森正班海卡"), "2026-09-10T09:00:00"), 2, 10);
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), 100L, null, null, LocalDate.parse("2026-09-01"),
LocalDate.parse("2026-09-01"), null, 1, 1);
LocalDate.parse("2026-09-01"), List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 1);
CandidateUnit unit = new CandidateUnit(700L, null, null,
LocalDateTime.parse("2026-09-01T09:00:00"), 10L);
when(repository.globalCandidateUnits(eq(query), anySet()))
@@ -353,7 +487,8 @@ class SmartLoadingServiceTest {
@Test
void excludesIncompleteColoadGroupInsteadOfPublishingPartialMember() {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), 100L, null, null, null, null, null, 1, 20);
List.of("MATSON_CLX"), 100L, null, null, null, null,
List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20);
when(repository.globalCandidateUnits(eq(query), anySet()))
.thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0));
@@ -582,7 +717,7 @@ class SmartLoadingServiceTest {
GroupSnapshot group = group("ACTIVE", null,
new Member(9L, 31L, 11L, "IN-11", 4), new Member(9L, 32L, 12L, "IN-12", 4));
ContainerBookingEntity small = booking();
small.setCapacityCbm(new BigDecimal("3"));
small.setCapacityCbm(new BigDecimal("2.6"));
when(repository.plan(1L)).thenReturn(plan("EDITING", 0));
when(repository.planByBooking(99L)).thenReturn(plan("EDITING", 0));
when(repository.booking(99L)).thenReturn(small);
@@ -597,7 +732,7 @@ class SmartLoadingServiceTest {
11L, 0, "manual-group-too-large"), 7L, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(error).hasMessageContaining("110%上限");
assertThat(error).hasMessageContaining("150%上限");
verify(coloadGroups, never()).lockWholeGroupForAssignment(anyLong(), anyLong(), anyLong(),
anyString(), anyLong(), anyString());
}
@@ -800,32 +935,32 @@ class SmartLoadingServiceTest {
}
@Test
void allowsVolumeAndWeightWithinOneHundredTenPercent() {
void allowsVolumeAndWeightWithinOneHundredFiftyPercent() {
stubAssignment();
ContainerBookingEntity tolerant = booking();
tolerant.setCapacityCbm(new BigDecimal("0.91"));
tolerant.setMaxWeightKg(new BigDecimal("3.64"));
tolerant.setCapacityCbm(new BigDecimal("0.6666667"));
tolerant.setMaxWeightKg(new BigDecimal("2.6666667"));
when(repository.booking(99L)).thenReturn(tolerant);
service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-110-ok"), 7, "u");
service.assign(1, new AssignRequest(10L, 20L, 2, 0, "assign-150-ok"), 7, "u");
verify(splitPreloadService).ensurePendingForLoadingAllocation(9L, 10L, 7L, "u");
verify(repository).touch(1, 0, 7, "u");
}
@Test
void rejectsVolumeOrWeightAboveOneHundredTenPercent() {
void rejectsVolumeOrWeightAboveOneHundredFiftyPercent() {
stubAssignment();
ContainerBookingEntity exceeded = booking();
exceeded.setCapacityCbm(new BigDecimal("0.90"));
exceeded.setMaxWeightKg(new BigDecimal("3.63"));
exceeded.setCapacityCbm(new BigDecimal("0.6666"));
exceeded.setMaxWeightKg(new BigDecimal("2.6666"));
when(repository.booking(99L)).thenReturn(exceeded);
BizException error = catchThrowableOfType(() -> service.assign(1,
new AssignRequest(10L, 20L, 2, 0, "assign-110-exceeded"), 7, "u"), BizException.class);
new AssignRequest(10L, 20L, 2, 0, "assign-150-exceeded"), 7, "u"), BizException.class);
assertThat(error.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(error.getMessage()).contains("110%");
assertThat(error.getMessage()).contains("150%");
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
}
@@ -1363,6 +1498,16 @@ class SmartLoadingServiceTest {
false, null, value.orderStatusSnapshot(), value.expectedArrivalAt());
}
private static Candidate withStatus(Candidate value, String status, boolean locked) {
return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(),
value.orderNo(), value.inboundCode(), value.warehouseId(), value.channelCode(), value.channelName(),
value.destinationWarehouseId(), value.destinationWarehouseCode(), value.destinationWarehouseName(),
value.customerName(), value.goodsName(), value.availablePackageCount(), value.unitGrossWeightKg(),
value.unitNetWeightKg(), value.unitVolumeCbm(), value.coLoadGroupId(), value.groupNo(),
value.memberCount(), value.groupTotalBoxes(), locked, value.blockReason(), status,
value.expectedArrivalAt());
}
private static ChannelRow channel(String channelCode, String channelName) {
ChannelRow value = new ChannelRow();
value.setChannelCode(channelCode); value.setChannelName(channelName);