This commit is contained in:
2026-09-04 18:47:57 +08:00
parent 7c5ed85a36
commit 77817b4c94
20 changed files with 240 additions and 92 deletions
@@ -23,11 +23,14 @@ public interface PendingReviewOrderClient {
@RequestParam(required = false) String inboundCode,
@RequestParam(required = false) String createdStart,
@RequestParam(required = false) String createdEnd,
@RequestParam(required = false) String expectedArrivalStart,
@RequestParam(required = false) String expectedArrivalEnd,
@RequestParam(required = false) List<Long> excludedOrderIds,
@RequestParam long page,
@RequestParam long size);
record Row(Long orderId, String orderNo, String inboundCode, Long customerId,
String customerName, Long warehouseId, String channelCode, String channelName,
int totalPackageCount, LocalDateTime createdAt, String mainStatus) {}
int totalPackageCount, LocalDateTime createdAt, LocalDateTime expectedArrivalAt,
String mainStatus) {}
}
@@ -8,6 +8,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){}
@NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes,
@Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode,
LocalDate createdStart, LocalDate createdEnd,
LocalDate expectedArrivalStart, LocalDate expectedArrivalEnd,
@Size(max=4) List<@NotNull CandidateOrderStatus> orderStatuses,
@Min(1) long page, @Min(1) @Max(200) long size) {
public InboundCandidateQuery {
@@ -82,9 +82,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
SELECT tenant_id,measurement_id,inbound_detail_id,SUM(package_count) package_count,
COALESCE(SUM(COALESCE(total_volume_cbm,COALESCE(unit_volume_cbm,0)*package_count))
/ NULLIF(SUM(package_count),0),0) unit_volume_cbm
FROM fl_inbound_measurement_item
WHERE inbound_detail_id IS NOT NULL AND package_count>0
GROUP BY tenant_id,measurement_id,inbound_detail_id
FROM fl_inbound_measurement_item
WHERE inbound_detail_id IS NOT NULL AND package_count>0
GROUP BY tenant_id,measurement_id,inbound_detail_id
) measured
JOIN fl_inbound_measurement batch ON batch.id=measured.measurement_id
AND batch.tenant_id=measured.tenant_id
@@ -104,7 +104,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
GROUP BY carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,
batch.total_volume_cbm,source.total_package_count
) i ON i.tenant_id=o.tenant_id AND i.inbound_id=o.id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.process_status='WAIT_SPLIT'
WHERE (#{warehouseId} IS NULL OR o.warehouse_id=#{warehouseId}) AND o.process_status='WAIT_SPLIT'
AND o.actual_gross_weight_kg IS NOT NULL AND o.actual_net_weight_kg IS NOT NULL
AND (o.inbound_role='MASTER' OR (o.split_purpose='REMAINDER' AND o.split_status='NOT_SPLIT'))
AND i.package_count>COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li
@@ -118,7 +118,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
AND split_item.source_inbound_detail_id=i.inbound_detail_id),0)
ORDER BY o.inbounded_at ASC, o.id ASC, i.inbound_detail_id ASC
""")
List<Candidate> candidates(@Param("warehouseId") long warehouseId);
List<Candidate> candidates(@Param("warehouseId") Long warehouseId);
@Select("""
<script>
@@ -194,6 +194,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
</if>
<if test="query.createdStart != null">AND MIN(eligible.createdAt)&gt;=#{query.createdStart}</if>
<if test="query.createdEnd != null">AND MIN(eligible.createdAt)&lt;DATE_ADD(#{query.createdEnd},INTERVAL 1 DAY)</if>
<if test="query.expectedArrivalStart != null">AND MIN(eligible.expectedArrivalAt)&gt;=#{query.expectedArrivalStart}</if>
<if test="query.expectedArrivalEnd != null">AND MIN(eligible.expectedArrivalAt)&lt;DATE_ADD(#{query.expectedArrivalEnd},INTERVAL 1 DAY)</if>
ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId
</script>
""")
@@ -363,9 +365,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
SELECT tenant_id,measurement_id,inbound_detail_id,SUM(package_count) package_count,
COALESCE(SUM(COALESCE(total_volume_cbm,COALESCE(unit_volume_cbm,0)*package_count))
/ NULLIF(SUM(package_count),0),0) unit_volume_cbm
FROM fl_inbound_measurement_item
WHERE inbound_detail_id IS NOT NULL AND package_count>0
GROUP BY tenant_id,measurement_id,inbound_detail_id
FROM fl_inbound_measurement_item
WHERE inbound_detail_id IS NOT NULL AND package_count>0
GROUP BY tenant_id,measurement_id,inbound_detail_id
) measured
JOIN fl_inbound_measurement batch ON batch.id=measured.measurement_id
AND batch.tenant_id=measured.tenant_id
@@ -385,7 +387,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
GROUP BY carton.tenant_id,carton.inbound_id,batch.id,carton.inbound_detail_id,
batch.total_volume_cbm,source.total_package_count
) i ON i.tenant_id=o.tenant_id AND i.inbound_id=o.id AND i.measurement_id=m.id
WHERE o.warehouse_id=#{warehouseId} AND o.id=#{inboundId}
WHERE o.id=#{inboundId}
AND (o.process_status='WAIT_SPLIT' OR (o.process_status='LOADING' AND EXISTS (
SELECT 1 FROM fl_loading_plan_item same_plan_item
WHERE same_plan_item.loading_plan_id=#{planId}
@@ -398,7 +400,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
AND i.inbound_detail_id=#{detailId}
ORDER BY m.id DESC LIMIT 1 FOR UPDATE
""")
Candidate candidate(@Param("planId") long planId,@Param("warehouseId") long warehouseId,
Candidate candidate(@Param("planId") long planId,
@Param("inboundId") long inboundId,@Param("detailId") long detailId);
@Select("""
@@ -39,7 +39,7 @@ public class SmartLoadingRepository {
@Resource private SmartLoadingCandidateMapper candidates;
@Resource private InboundOrderMapper inbounds;
public List<Candidate> candidates(long warehouseId) { return candidates.candidates(warehouseId); }
public List<Candidate> candidates(Long warehouseId) { return candidates.candidates(warehouseId); }
public PageResult<CandidateUnit> globalCandidateUnits(InboundCandidateQuery query,
Set<String> actualChannels) {
return PageResult.from(candidates.globalCandidateUnits(
@@ -62,8 +62,8 @@ public class SmartLoadingRepository {
public List<Long> occupiedOrderIds(InboundCandidateQuery query, Set<String> actualChannels) {
return candidates.occupiedOrderIds(query, actualChannels);
}
public Candidate candidate(long planId, long warehouseId, long inboundId, long detailId) {
return candidates.candidate(planId, warehouseId, inboundId, detailId);
public Candidate candidate(long planId, long inboundId, long detailId) {
return candidates.candidate(planId, inboundId, detailId);
}
public Integer measuredPackageCount(long accountId, long inboundId, long detailId) {
return candidates.measuredPackageCount(accountId, inboundId, detailId);
@@ -113,6 +113,16 @@ public class SmartLoadingService {
return grouped.values().stream().map(this::inboundCandidate).toList();
}
private List<InboundCandidate> recommendationCandidates(ContainerBookingEntity booking) {
List<Candidate> values = repository.candidates(null).stream()
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
Map<Long, List<Candidate>> grouped = enrichColoadCandidates(booking,
repository.planByBooking(booking.getId()), values).stream()
.collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new,
java.util.stream.Collectors.toList()));
return grouped.values().stream().map(this::inboundCandidate).toList();
}
/** Global workbench candidates. A co-load group is one paging unit and can never be split across pages. */
@Transactional(readOnly = true)
public PageResult<InboundCandidate> inboundCandidates(InboundCandidateQuery query) {
@@ -120,6 +130,10 @@ public class SmartLoadingService {
&& query.createdStart().isAfter(query.createdEnd())) {
throw validation("创建开始日期不能晚于结束日期");
}
if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null
&& query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) {
throw validation("预计到货开始日期不能晚于结束日期");
}
Set<String> actualChannels = actualChannels(query.channelGroupCodes());
EnumSet<CandidateOrderStatus> selected = query.orderStatuses().isEmpty()
? EnumSet.allOf(CandidateOrderStatus.class)
@@ -188,6 +202,7 @@ public class SmartLoadingService {
try {
response = pendingReviewOrders.page(internalToken, List.copyOf(actualChannels), query.warehouseId(),
query.customerId(), query.inboundCode(), isoDate(query.createdStart()), isoDate(query.createdEnd()),
isoDate(query.expectedArrivalStart()), isoDate(query.expectedArrivalEnd()),
repository.occupiedOrderIds(query, actualChannels), page, size);
} catch (RuntimeException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "待审核订单服务暂时不可用");
@@ -201,7 +216,7 @@ public class SmartLoadingService {
row.warehouseId(), row.channelCode(), row.channelName(), null, null, null,
row.customerName(), null, row.totalPackageCount(), null, null, null, null,
null, null, null, null, false, true, null,
"PENDING_REVIEW", null, List.of())).toList();
"PENDING_REVIEW", row.expectedArrivalAt(), List.of())).toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
}
@@ -238,7 +253,8 @@ public class SmartLoadingService {
List<CandidateOrderStatus> statuses,
long page, long size) {
return new InboundCandidateQuery(source.channelGroupCodes(), source.warehouseId(), source.customerId(),
source.inboundCode(), source.createdStart(), source.createdEnd(), statuses, page, size);
source.inboundCode(), source.createdStart(), source.createdEnd(), source.expectedArrivalStart(),
source.expectedArrivalEnd(), statuses, page, size);
}
public PageResult<WorkbenchContainer> containers(WorkbenchContainerQuery query) {
@@ -414,8 +430,7 @@ public class SmartLoadingService {
if (replay != null) return replay;
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), request.inboundId(),
request.inboundDetailId());
Candidate candidate = repository.candidate(planId, request.inboundId(), request.inboundDetailId());
if (candidate == null) throw conflict("入库货物不存在或尚未完成入库");
if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId());
requireSameCustomerScope(booking, candidate);
@@ -786,7 +801,7 @@ public class SmartLoadingService {
LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName);
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Set<Long> visitedGroups = new HashSet<>();
for (InboundCandidate candidate : inboundCandidates(plan.getContainerBookingId(), booking.getWarehouseId())) {
for (InboundCandidate candidate : recommendationCandidates(booking)) {
if (candidate.coLoadGroupId() != null) {
if (!visitedGroups.add(candidate.coLoadGroupId()) || StringUtils.hasText(candidate.blockReason())) {
continue;
@@ -845,8 +860,7 @@ public class SmartLoadingService {
throw conflict("计划箱数不能低于已拣货箱数");
}
ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId());
Candidate candidate = repository.candidate(planId, booking.getWarehouseId(), item.getInboundId(),
item.getInboundDetailId());
Candidate candidate = repository.candidate(planId, item.getInboundId(), item.getInboundDetailId());
int currentPackageCount = item.getPackageCount() == null ? 0 : item.getPackageCount();
OrderCargoClient.CargoBinding cargo;
int maxPackageCount;
@@ -1128,12 +1142,12 @@ public class SmartLoadingService {
}
private List<Candidate> availableCandidates(ContainerBookingEntity booking) {
return repository.candidates(booking.getWarehouseId()).stream()
return repository.candidates(null).stream()
.filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList();
}
private List<Candidate> manualCandidates(ContainerBookingEntity booking) {
return repository.candidates(booking.getWarehouseId()).stream()
return repository.candidates(null).stream()
.filter(candidate -> sameCustomerScope(booking, candidate)).toList();
}
@@ -1291,7 +1305,7 @@ public class SmartLoadingService {
try {
ApiResponse<PageResult<PendingReviewOrderClient.Row>> response = pendingReviewOrders.page(
internalToken, null, null, request.customerId(), request.inboundCode(), null, null,
null, 1, 2);
null, null, null, 1, 2);
if (response == null || !response.successful() || response.data() == null) throw unavailable();
return response.data().records().stream()
.filter(row -> Objects.equals(row.orderId(), request.orderId())
@@ -1374,7 +1388,6 @@ public class SmartLoadingService {
private record DeclaredCargo(OrderCargoClient.CargoBinding cargo) {}
private void checkRouteAndCapacity(ContainerBookingEntity b,List<LoadingPlanItemEntity> lines,Candidate c,
int count, boolean requireSameDestination){
if (!Objects.equals(b.getWarehouseId(), c.warehouseId())) throw validation("装柜仓库不一致");
if (requireSameDestination && StringUtils.hasText(b.getDestinationWarehouseCode())
&& !Objects.equals(b.getDestinationWarehouseCode(), c.destinationWarehouseCode())) {
throw validation("目的仓/港不一致");
@@ -487,14 +487,14 @@ paths:
summary: 全局分页查询智能装柜候选
description: >-
需要 platform:firstleg:smart-loading:list 权限。查询不绑定柜次;渠道、仓库、客户、入仓码、
订单创建日期和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个
订单创建日期、预计到货时间和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个
分页单元返回,groupMembers 包含完整成员,不会跨页或因单个成员命中搜索条件而拆散。
平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。
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%,超过即拒绝。
剩余箱数、同柜组和容量等事实决定;容积和重量均允许达到柜型额定值的150%,超过即拒绝。
待审核及待测量可以先加入清单;确认预装/PDA 前
仍必须完成审核与测量并进入 WAIT_SPLIT。同柜不可拆组仅在全部成员命中当前状态集合时作为一个分页单位返回。
parameters:
@@ -518,6 +518,8 @@ 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: expectedArrivalStart, in: query, required: false, schema: {type: string, format: date}}
- {name: expectedArrivalEnd, in: query, required: false, schema: {type: string, format: date}}
- name: orderStatuses
in: query
required: false
@@ -598,7 +600,7 @@ paths:
summary: 向预装计划新增入库单明细
description: >-
EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task,
校验仓库、客户范围、可用箱数、容积和限重后原子新增/累加明细;人工加入允许订单渠道与柜次渠道不同,
校验客户范围、可用箱数、容积和限重后原子新增/累加明细;人工加入允许订单国内仓及渠道与柜次不同,
智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId,
递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时,
锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。
@@ -618,7 +620,8 @@ paths:
description: >-
需要 platform:firstleg:smart-loading:assign 权限。操作员只选择某个入仓码的本次计划箱数;
服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单;
人工分配不限制订单渠道与柜次渠道一致,渠道只约束智能推荐;国内仓、占用、同柜组和容量门禁不变。
人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道;
占用、同柜组和容量门禁不变。
柜内总容积和总重量分别允许达到柜型额定值的150%,超过即拒绝。
只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。
parameters:
@@ -36,13 +36,13 @@ class PlatformSmartLoadingControllerContractTest {
.contains("name: warehouseId")
.contains("name: createdStart")
.contains("name: createdEnd")
.contains("name: expectedArrivalStart")
.contains("name: expectedArrivalEnd")
.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: 当前是否可加入柜次");
}
@@ -85,8 +85,8 @@ class PlatformSmartLoadingControllerContractTest {
.contains("items[].version 是递增后的 itemVersion")
.contains("数量调整或动态增量不会覆盖已有 warehouseRemark")
.contains("柜内总容积和总重量分别允许达到柜型额定值的150%")
.contains("人工分配不限制订单渠道与柜次渠道一致")
.contains("渠道只约束智能推荐")
.contains("人工分配不限制订单国内仓及渠道与柜次一致")
.contains("智能推荐允许跨国内仓,但仍只选择柜次渠道")
.contains("canAssign: {type: boolean}")
.contains("canEdit: {type: boolean}")
.contains("pickedPackageCount: {type: integer, minimum: 0}")
@@ -39,7 +39,7 @@ class SmartLoadingCandidateMapperContractTest {
@Test
void selectsLatestApprovedBatch() throws Exception {
Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("candidates", long.class);
Method list = SmartLoadingCandidateMapper.class.getDeclaredMethod("candidates", Long.class);
String sql = String.join(" ", list.getAnnotation(Select.class).value());
assertThat(sql)
@@ -106,7 +106,7 @@ class SmartLoadingCandidateMapperContractTest {
}
@Test
void globalCandidateDateFilterUsesTheRepresentativeOrderCreationDate() throws Exception {
void globalCandidateDateFiltersUseRepresentativeCreationAndExpectedArrivalDates() throws Exception {
Method units = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidateUnits",
Page.class, InboundCandidateQuery.class, Set.class);
String sql = String.join(" ", units.getAnnotation(Select.class).value());
@@ -115,7 +115,8 @@ class SmartLoadingCandidateMapperContractTest {
.contains("o.created_at createdAt")
.contains("MIN(eligible.createdAt)&gt;=#{query.createdStart}")
.contains("MIN(eligible.createdAt)&lt;DATE_ADD(#{query.createdEnd},INTERVAL 1 DAY)")
.doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd");
.contains("MIN(eligible.expectedArrivalAt)&gt;=#{query.expectedArrivalStart}")
.contains("MIN(eligible.expectedArrivalAt)&lt;DATE_ADD(#{query.expectedArrivalEnd},INTERVAL 1 DAY)");
}
@Test
@@ -146,6 +147,7 @@ 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"),
java.time.LocalDate.parse("2026-09-05"), java.time.LocalDate.parse("2026-10-05"),
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)
@@ -153,6 +155,7 @@ class SmartLoadingCandidateMapperContractTest {
assertThat(renderedUnits)
.contains("o.channel_code IN")
.contains("MIN(eligible.createdAt)>=", "MIN(eligible.createdAt)<DATE_ADD")
.contains("MIN(eligible.expectedArrivalAt)>=", "MIN(eligible.expectedArrivalAt)<DATE_ADD")
.contains("eligible.candidateStatus='WAIT_MEASURE'")
.contains("eligible.candidateStatus='WAIT_SPLIT'")
.contains("MAX(CASE WHEN LOWER(eligible.inboundCode)")
@@ -188,9 +191,9 @@ class SmartLoadingCandidateMapperContractTest {
}
@Test
void detailUsesSameBatchRule() throws Exception {
void detailUsesSameBatchRuleWithoutRestrictingTheCabinetWarehouse() throws Exception {
Method detail = SmartLoadingCandidateMapper.class.getDeclaredMethod(
"candidate", long.class, long.class, long.class, long.class);
"candidate", long.class, long.class, long.class);
String sql = String.join(" ", detail.getAnnotation(Select.class).value());
assertThat(sql)
@@ -199,7 +202,7 @@ class SmartLoadingCandidateMapperContractTest {
.contains("o.expected_arrival_at expectedArrivalAt")
.contains("m.status='APPROVED'")
.contains("SELECT MAX(m2.id)")
.contains("o.warehouse_id=#{warehouseId}")
.doesNotContain("o.warehouse_id=#{warehouseId}")
.contains("o.process_status='WAIT_SPLIT'")
.contains("o.process_status='LOADING'")
.contains("same_plan_item.loading_plan_id=#{planId}")
@@ -196,6 +196,7 @@ class SmartLoadingServiceTest {
"MATSON_MAX_TRUCK", "美森加班海卡");
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), null, null, 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()))
@@ -219,14 +220,14 @@ class SmartLoadingServiceTest {
@Test
void combinesAllUnfinishedStatusesBeforeReturningTheRequestedPage() {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), null, null, null, null, null, List.of(), 1, 20);
List.of("EXX"), null, null, 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");
LocalDateTime.parse("2026-09-08T09:00:00"), "PENDING_REVIEW");
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), anyList(), anyLong(), anyLong())).thenAnswer(invocation -> {
long page = invocation.getArgument(8);
long size = invocation.getArgument(9);
isNull(), isNull(), isNull(), isNull(), anyList(), anyLong(), anyLong())).thenAnswer(invocation -> {
long page = invocation.getArgument(10);
long size = invocation.getArgument(11);
return ApiResponse.success(new PageResult<>(page == 1 ? List.of(pending) : List.of(),
page, size, 1, 1));
});
@@ -256,13 +257,14 @@ class SmartLoadingServiceTest {
CandidateOrderStatus.WAIT_MEASURE, CandidateOrderStatus.WAIT_SPLIT,
CandidateOrderStatus.LOADING));
verify(pendingReviewOrders).page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), anyList(), eq(1L), eq(20L));
isNull(), isNull(), isNull(), isNull(), anyList(), eq(1L), eq(20L));
}
@Test
void statusDoesNotBlockAssignmentButAnActiveContainerOccupationDoes() {
InboundCandidateQuery query = new InboundCandidateQuery(List.of("EXX"), null, null, null,
null, null, List.of(CandidateOrderStatus.WAIT_MEASURE, CandidateOrderStatus.LOADING), 1, 20);
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),
@@ -285,7 +287,7 @@ class SmartLoadingServiceTest {
@Test
void keepsAnOccupiedLoadingGroupAsOneNonAssignablePagingUnit() {
InboundCandidateQuery query = new InboundCandidateQuery(List.of("EXX"), null, null, null,
null, null, List.of(CandidateOrderStatus.LOADING), 1, 20);
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),
@@ -310,15 +312,17 @@ class SmartLoadingServiceTest {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), 193000000000200002L, null, "EXXHK2609020361",
LocalDate.parse("2026-09-02"), LocalDate.parse("2026-09-02"),
LocalDate.parse("2026-09-08"), LocalDate.parse("2026-09-09"),
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"),
"PENDING_REVIEW");
LocalDateTime.parse("2026-09-08T10:00:00"), "PENDING_REVIEW");
when(repository.occupiedOrderIds(eq(query), anySet())).thenReturn(List.of(998L));
when(pendingReviewOrders.page(anyString(), anyList(), eq(193000000000200002L),
isNull(), eq("EXXHK2609020361"), eq("2026-09-02"),
eq("2026-09-02"), eq(List.of(998L)), eq(1L), eq(20L)))
eq("2026-09-02"), eq("2026-09-08"), eq("2026-09-09"),
eq(List.of(998L)), eq(1L), eq(20L)))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(row), 1, 20, 1, 1)));
var result = service.inboundCandidates(query);
@@ -331,6 +335,7 @@ class SmartLoadingServiceTest {
assertThat(candidate.canAssign()).isTrue();
assertThat(candidate.blockReason()).isNull();
assertThat(candidate.availablePackageCount()).isEqualTo(100);
assertThat(candidate.expectedArrivalAt()).isEqualTo(LocalDateTime.parse("2026-09-08T10:00:00"));
});
verify(repository, never()).globalCandidateUnits(any(), anySet());
verify(repository, never()).globalCandidatesByUnits(anyList());
@@ -344,7 +349,7 @@ class SmartLoadingServiceTest {
String inboundCode = "EXXHK2609020361";
var pending = new PendingReviewOrderClient.Row(orderId, "FL-361", inboundCode,
customerId, "客户", 100L, "SLOW", "慢船", 5,
LocalDateTime.parse("2026-09-02T15:31:36"), "PENDING_REVIEW");
LocalDateTime.parse("2026-09-02T15:31:36"), null, "PENDING_REVIEW");
var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand(
customerId, orderId, "FL-361", inboundCode, customerId, "客户", 100L, "义乌仓",
null, null, null, "FIRST_LEG", "SLOW", "慢船", null, null, null,
@@ -354,7 +359,7 @@ class SmartLoadingServiceTest {
var cargo = new OrderCargoClient.CargoBinding(20L, orderId, 1, "货物", 5,
new BigDecimal("50"), new BigDecimal("2.5"));
when(pendingReviewOrders.page(anyString(), isNull(), isNull(), eq(customerId),
eq(inboundCode), isNull(), isNull(), isNull(), eq(1L), eq(2L)))
eq(inboundCode), isNull(), isNull(), isNull(), isNull(), isNull(), eq(1L), eq(2L)))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(pending), 1, 2, 1, 1)));
when(cargoClient.inboundSnapshot(anyString(), eq(customerId), eq(orderId)))
.thenReturn(ApiResponse.success(snapshot));
@@ -430,19 +435,75 @@ class SmartLoadingServiceTest {
7L, "u", "wait-measure-1");
}
@Test
void assignsWaitSplitInboundToCabinetFromAnotherDomesticWarehouse() {
Candidate crossWarehouse = withWarehouse(candidate(1), 101L);
InboundOrderEntity projection = new InboundOrderEntity();
projection.setId(10L);
projection.setTenantId(9L);
projection.setWarehouseId(101L);
projection.setProcessStatus("WAIT_SPLIT");
AtomicInteger version = new AtomicInteger();
List<LoadingPlanItemEntity> saved = new ArrayList<>();
when(repository.inbound(10L)).thenReturn(projection);
when(repository.plan(1L)).thenAnswer(invocation -> plan("EDITING", version.get()));
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidates(null)).thenReturn(List.of(crossWarehouse));
when(repository.candidate(1L, 10L, 20L)).thenReturn(crossWarehouse);
when(repository.activeItemsByInbound(1L, 9L, 10L)).thenReturn(List.of());
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved));
when(repository.touch(eq(1L), anyInt(), eq(7L), eq("u"))).thenAnswer(invocation -> {
int expected = invocation.getArgument(1);
if (expected != version.get()) return 0;
version.incrementAndGet();
return 1;
});
doAnswer(invocation -> {
LoadingPlanItemEntity item = invocation.getArgument(0);
item.setId(800L);
saved.add(item);
return null;
}).when(repository).insert(any(LoadingPlanItemEntity.class));
Plan result = service.assignInbound(1L,
new AssignInboundRequest(10L, 1, 0, "assign-inbound-cross-warehouse"), 7L, "u");
assertThat(result.version()).isEqualTo(1);
assertThat(saved).singleElement().satisfies(item -> {
assertThat(item.getInboundId()).isEqualTo(10L);
assertThat(item.getPackageCount()).isEqualTo(1);
});
verify(repository).candidates(null);
}
@Test
void failsClosedWhenPendingReviewOrderSourceIsUnavailable() {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), null, null, null, null, null,
null, null,
List.of(CandidateOrderStatus.PENDING_REVIEW), 1, 20);
when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(),
isNull(), isNull(), anyList(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline"));
isNull(), isNull(), isNull(), isNull(), anyList(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline"));
assertThatThrownBy(() -> service.inboundCandidates(query))
.isInstanceOf(BizException.class)
.hasMessage("待审核订单服务暂时不可用");
}
@Test
void rejectsReversedExpectedArrivalRangeBeforeQueryingCandidates() {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("EXX"), null, null, null, null, null,
LocalDate.parse("2026-09-10"), LocalDate.parse("2026-09-09"),
List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 20);
assertThatThrownBy(() -> service.inboundCandidates(query))
.isInstanceOf(BizException.class)
.hasMessage("预计到货开始日期不能晚于结束日期");
verify(repository, never()).globalCandidateUnits(any(), anySet());
}
@Test
void pagesWorkbenchContainersWithTheSameGlobalFilters() {
WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null,
@@ -473,7 +534,8 @@ 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"), List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 1);
LocalDate.parse("2026-09-01"), null, null,
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()))
@@ -495,6 +557,7 @@ class SmartLoadingServiceTest {
void excludesIncompleteColoadGroupInsteadOfPublishingPartialMember() {
InboundCandidateQuery query = new InboundCandidateQuery(
List.of("MATSON_CLX"), 100L, null, null, 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));
@@ -593,9 +656,9 @@ class SmartLoadingServiceTest {
when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get()));
when(repository.booking(99L)).thenReturn(booking());
when(repository.inbound(10L)).thenReturn(inbound(9L, 10L));
when(repository.candidates(100L)).thenReturn(List.of(first, second));
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(first);
when(repository.candidate(1L, 100L, 11L, 21L)).thenReturn(second);
when(repository.candidates(null)).thenReturn(List.of(first, second));
when(repository.candidate(1L, 10L, 20L)).thenReturn(first);
when(repository.candidate(1L, 11L, 21L)).thenReturn(second);
when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved));
when(repository.activeItemsByInbound(anyLong(), anyLong())).thenReturn(List.of());
when(repository.activeItemsByInbound(anyLong(), anyLong(), anyLong())).thenReturn(List.of());
@@ -636,7 +699,7 @@ class SmartLoadingServiceTest {
org.mockito.ArgumentMatchers.startsWith("LOCK-"), eq(7L), eq("u"));
verify(loadingModelBridge, times(2)).bindIncrementalAllocation(eq(1L), anyLong(), anyLong(),
eq(7L), eq("u"), anyString());
verify(repository, times(1)).candidates(100L);
verify(repository, times(1)).candidates(null);
}
@Test
@@ -654,7 +717,7 @@ class SmartLoadingServiceTest {
when(repository.planByBooking(99L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(small);
when(repository.items(1L)).thenReturn(List.of());
when(repository.candidates(100L)).thenReturn(List.of(first, second, third));
when(repository.candidates(null)).thenReturn(List.of(first, second, third));
when(coloadGroups.findByInbound(anyLong(), anyLong())).thenReturn(group);
Plan result = service.recommend(1L, new RecommendRequest(0, "recommend-group-too-large"), 7L, "u");
@@ -681,9 +744,9 @@ class SmartLoadingServiceTest {
when(repository.planByBooking(99L)).thenAnswer(invocation -> plan("EDITING", version.get()));
when(repository.booking(99L)).thenReturn(small);
when(repository.items(1L)).thenAnswer(invocation -> List.copyOf(saved));
when(repository.candidates(100L)).thenReturn(List.of(ordinary, groupFirst, groupSecond, later));
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(ordinary);
when(repository.candidate(1L, 100L, 13L, 23L)).thenReturn(later);
when(repository.candidates(null)).thenReturn(List.of(ordinary, groupFirst, groupSecond, later));
when(repository.candidate(1L, 10L, 20L)).thenReturn(ordinary);
when(repository.candidate(1L, 13L, 23L)).thenReturn(later);
when(repository.activeItemsByInbound(anyLong(), anyLong(), anyLong())).thenReturn(List.of());
when(repository.activeItem(anyLong(), anyLong(), anyLong())).thenReturn(null);
when(repository.touch(eq(1L), anyInt(), eq(7L), eq("u"))).thenAnswer(invocation -> {
@@ -732,7 +795,7 @@ class SmartLoadingServiceTest {
when(repository.booking(99L)).thenReturn(small);
when(repository.inbound(11L)).thenReturn(inbound(9L, 11L));
when(repository.items(1L)).thenReturn(List.of());
when(repository.candidates(100L)).thenReturn(List.of(first, second));
when(repository.candidates(null)).thenReturn(List.of(first, second));
when(repository.activeItemsByInbound(anyLong(), anyLong())).thenReturn(List.of());
when(coloadGroups.findByInbound(9L, 11L)).thenReturn(group);
when(coloadGroups.findByInbound(9L, 12L)).thenReturn(group);
@@ -1006,7 +1069,7 @@ class SmartLoadingServiceTest {
LoadingPlanEntity plan = plan("EDITING", 0);
when(repository.plan(1)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(5));
when(repository.candidate(1, 10, 20)).thenReturn(candidate(5));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(item(7, 9, 20, 4));
BizException error = catchThrowableOfType(() -> service.assign(1,
@@ -1023,7 +1086,7 @@ class SmartLoadingServiceTest {
linked.setCustomerId(88L);
when(repository.plan(1L)).thenReturn(plan);
when(repository.booking(99L)).thenReturn(linked);
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(5));
when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(5));
BizException error = catchThrowableOfType(() -> service.assign(1L,
new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class);
@@ -1032,6 +1095,20 @@ class SmartLoadingServiceTest {
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any());
}
@Test
void manuallyAssignsCargoFromAnotherDomesticWarehouse() {
stubAssignment();
when(repository.candidate(1L, 10L, 20L)).thenReturn(
withWarehouse(candidate(10), 101L));
Plan result = service.assign(1L,
new AssignRequest(10L, 20L, 1, 0, "assign-cross-warehouse"), 7L, "u");
assertThat(result.version()).isEqualTo(1);
verify(repository).insert(any(LoadingPlanItemEntity.class));
verify(repository).touch(1L, 0, 7L, "u");
}
@Test
void allowsVolumeAndWeightWithinOneHundredFiftyPercent() {
stubAssignment();
@@ -1121,7 +1198,7 @@ class SmartLoadingServiceTest {
when(repository.booking(99)).thenReturn(booking());
when(repository.touch(1, 5, 7, "u")).thenReturn(1);
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10));
when(repository.candidate(1, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of()));
@@ -1132,7 +1209,7 @@ class SmartLoadingServiceTest {
Plan result = service.assign(1, new AssignRequest(10L, 20L, 1, 5, "assign-loading"), 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(repository).candidate(1L, 100L, 10L, 20L);
verify(repository).candidate(1L, 10L, 20L);
var order = inOrder(loadingPickService, splitPreloadService, repository, loadingModelBridge);
order.verify(loadingPickService).lockMutable(1L);
order.verify(splitPreloadService).ensureForRunningLoadingAllocation(9L, 10L, 7L, "u");
@@ -1152,7 +1229,7 @@ class SmartLoadingServiceTest {
when(repository.booking(99)).thenReturn(booking());
when(repository.touch(1, 5, 7, "u")).thenReturn(1);
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10));
when(repository.candidate(1, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(loadingPickService.mutationSnapshot(1L)).thenReturn(
new LoadingPickService.MutationSnapshot(77L, "LOADING", 6, Map.of()));
@@ -1182,7 +1259,7 @@ class SmartLoadingServiceTest {
Plan result = service.assign(1, request, 7, "u");
assertThat(result.status()).isEqualTo("LOADING");
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong());
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong());
verify(cargoClient, never()).detail(anyString(), anyLong(), anyLong(), anyLong());
}
@@ -1401,7 +1478,7 @@ class SmartLoadingServiceTest {
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10));
when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(10));
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.update(line)).thenReturn(1);
@@ -1420,7 +1497,7 @@ class SmartLoadingServiceTest {
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 94));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(-25));
when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(-25));
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 93, 7L, "u")).thenReturn(1);
when(repository.update(line)).thenReturn(1);
@@ -1439,7 +1516,7 @@ class SmartLoadingServiceTest {
when(repository.plan(1L)).thenReturn(plan, plan("LOADING", 3));
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10));
when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(10));
when(repository.items(1L)).thenReturn(List.of(line));
when(repository.touch(1L, 2, 7L, "u")).thenReturn(1);
when(repository.update(line)).thenReturn(1);
@@ -1462,7 +1539,7 @@ class SmartLoadingServiceTest {
when(repository.plan(1L)).thenReturn(plan);
when(repository.item(1L, 8L)).thenReturn(line);
when(repository.booking(99L)).thenReturn(booking());
when(repository.candidate(1L, 100L, 10L, 20L)).thenReturn(candidate(10));
when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(10));
when(repository.items(1L)).thenReturn(List.of(line));
Plan result = service.adjust(1L, 8L,
@@ -1488,7 +1565,7 @@ class SmartLoadingServiceTest {
new AdjustRequest(1, 2, 0, "adjust-picked"), 7L, "u"))
.hasMessageContaining("不能低于已拣货箱数");
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong(), anyLong());
verify(repository, never()).candidate(anyLong(), anyLong(), anyLong());
verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), anyString());
}
@@ -1550,7 +1627,7 @@ class SmartLoadingServiceTest {
when(repository.plan(1)).thenReturn(plan, plan("EDITING", 1));
when(repository.booking(99)).thenReturn(booking());
when(repository.items(1)).thenReturn(List.of());
when(repository.candidate(1, 100, 10, 20)).thenReturn(candidate(10));
when(repository.candidate(1, 10, 20)).thenReturn(candidate(10));
when(repository.activeItem(1L, 9L, 20L)).thenReturn(null);
when(repository.touch(1, 0, 7, "u")).thenReturn(1);
}
@@ -1622,6 +1699,16 @@ class SmartLoadingServiceTest {
value.orderStatusSnapshot(), value.expectedArrivalAt());
}
private static Candidate withWarehouse(Candidate value, long warehouseId) {
return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(),
value.orderNo(), value.inboundCode(), 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(), value.locked(), value.blockReason(),
value.orderStatusSnapshot(), value.expectedArrivalAt());
}
private static Candidate withArrival(Candidate value, String expectedArrivalAt) {
return new Candidate(value.accountId(), value.inboundId(), value.inboundDetailId(), value.orderId(),
value.orderNo(), value.inboundCode(), value.warehouseId(), value.channelCode(), value.channelName(),