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
@@ -13709,7 +13709,6 @@ CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'created_at', '
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'created_by', '创建人ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'created_by_name', '创建人名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'current_hold_reason', '当前扣货理由快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'last_overseas_hold_version', '海外暂扣事件最新聚合版本,用于阻止乱序覆盖');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'customer_id', '客户逻辑ID');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'customer_name', '客户名称快照');
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'customs_inspection', '是否商检');
@@ -16646,6 +16645,7 @@ END$$
DELIMITER ;
CALL add_inbound_overseas_hold_version();
DROP PROCEDURE IF EXISTS add_inbound_overseas_hold_version;
CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_order', 'last_overseas_hold_version', '海外暂扣事件最新聚合版本,用于阻止乱序覆盖');
-- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。
ALTER TABLE fl_inbound_outbox
+1 -1
View File
@@ -80,7 +80,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 全部明细处理完成后自动完成。
- 智能装柜全局工作台由服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `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、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
@@ -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(),
+1 -1
View File
@@ -36,7 +36,7 @@
- `inbound_code` 是跨客户全局唯一业务码;生成模式由 `InboundCodeModeProvider` 控制,序列与唯一约束不得退化为客户内唯一。修改格式、长度或生成时机时同步检查 Order SQL、Firstleg 入库快照、箱唛箱码、前端展示和全局唯一性契约测试。
- 下单和后续关键状态操作不得信任客户端仓库名称、类型或启用状态;公共仓库必须通过 Warehouse 内部接口按 ID 实时校验。私人地址不得伪装为公共目的仓,非私人目的地必须引用类型匹配的公共仓库;Warehouse 不可用时失败关闭,不回退到旧快照或模拟数据。
- Warehouse 往来单位、Firstleg 入库协作只通过客户端 DTO/内部令牌;修改契约需同步消费者。
- 智能装柜待审核候选只返回 Order 权威 `SUBMITTED` 订单,并按服务身份、客户/仓库/渠道、入仓码、创建日期和分页条件查询;Firstleg 可据此提前占位,但不得把该内部查询或申报数量当作审核、测量或可物理装柜完成的事实。
- 智能装柜待审核候选只返回 Order 权威 `SUBMITTED` 订单,并按服务身份、客户/仓库/渠道、入仓码、创建日期、预计到货日期和分页条件查询;Firstleg 可据此提前占位,但不得把该内部查询或申报数量当作审核、测量或可物理装柜完成的事实。
- 中文批量模板按登录客户生成取货/私人地址、国内收货仓、目的仓和渠道下拉;仓库目录必须翻页读取全部候选,不得只取第一页。导入时重新读取并校验动态目录、客户归属和启用状态,不信任模板隐藏页;文件上限 5MB、有效数据最多 50 行,仅创建草稿,`PARTIAL` 允许单行失败、`ATOMIC` 任一行失败不得落单。
- 平台只能在 `DRAFT/REJECTED/SUBMITTED/APPROVED` 版本化修改客户原始申报;不得修改客户、国内收货仓、订单号、入仓码、状态或仓库/PDA/设备实测、测量审核和计费事实,不得增删或重排货物行。国内收货仓或中转仓变化必须重新下单。
- 只有渠道发生变化才同时调用 Firstleg 履约可逆性与重新计价门禁,任一内部校验不可用或失败均不写入;成功后 Outbox 必须携带完整申报快照和字段变化。Firstleg 消费按 `eventId + payload hash` 幂等、按订单版本单调推进,渠道变化才使未确认价格预估失效。
@@ -23,6 +23,8 @@ public final class PendingReviewOrderDtos {
@Size(max = 40) String inboundCode,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdStart,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdEnd,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expectedArrivalStart,
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate expectedArrivalEnd,
List<@Min(1) Long> excludedOrderIds,
@Min(1) long page,
@Min(1) @Max(200) long size
@@ -31,6 +33,12 @@ public final class PendingReviewOrderDtos {
public boolean isCreatedRangeValid() {
return createdStart == null || createdEnd == null || !createdStart.isAfter(createdEnd);
}
@AssertTrue(message = "预计到货日期起始日不能晚于结束日")
public boolean isExpectedArrivalRangeValid() {
return expectedArrivalStart == null || expectedArrivalEnd == null
|| !expectedArrivalStart.isAfter(expectedArrivalEnd);
}
}
public record Row(
@@ -44,6 +52,7 @@ public final class PendingReviewOrderDtos {
String channelName,
int totalPackageCount,
LocalDateTime createdAt,
LocalDateTime expectedArrivalAt,
String mainStatus
) {
}
@@ -217,6 +217,8 @@ public class OrderRepository {
Long customerId, String inboundCode,
LocalDateTime createdFrom,
LocalDateTime createdBefore,
LocalDateTime expectedArrivalFrom,
LocalDateTime expectedArrivalBefore,
List<Long> excludedOrderIds,
long page, long size) {
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
@@ -228,6 +230,8 @@ public class OrderRepository {
.eq(StringUtils.hasText(inboundCode), OrderEntity::getInboundCode, inboundCode)
.ge(createdFrom != null, OrderEntity::getCreatedAt, createdFrom)
.lt(createdBefore != null, OrderEntity::getCreatedAt, createdBefore)
.ge(expectedArrivalFrom != null, OrderEntity::getExpectedArrivalAt, expectedArrivalFrom)
.lt(expectedArrivalBefore != null, OrderEntity::getExpectedArrivalAt, expectedArrivalBefore)
.notIn(excludedOrderIds != null && !excludedOrderIds.isEmpty(), OrderEntity::getId,
excludedOrderIds)
.orderByDesc(OrderEntity::getCreatedAt)
@@ -25,7 +25,7 @@ public class PendingReviewOrderQueryService {
public PageResult<PendingReviewOrderDtos.Row> page(PendingReviewOrderDtos.Query query) {
if (query == null || query.page() < 1 || query.size() < 1 || query.size() > 200
|| !query.isCreatedRangeValid()) {
|| !query.isCreatedRangeValid() || !query.isExpectedArrivalRangeValid()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "待审核订单查询参数不正确");
}
List<String> channelCodes = normalizeChannelCodes(query.channelCodes());
@@ -33,9 +33,13 @@ public class PendingReviewOrderQueryService {
: query.createdStart().atStartOfDay(BUSINESS_ZONE).toLocalDateTime();
LocalDateTime createdBefore = query.createdEnd() == null ? null
: query.createdEnd().plusDays(1).atStartOfDay(BUSINESS_ZONE).toLocalDateTime();
LocalDateTime expectedArrivalFrom = query.expectedArrivalStart() == null ? null
: query.expectedArrivalStart().atStartOfDay(BUSINESS_ZONE).toLocalDateTime();
LocalDateTime expectedArrivalBefore = query.expectedArrivalEnd() == null ? null
: query.expectedArrivalEnd().plusDays(1).atStartOfDay(BUSINESS_ZONE).toLocalDateTime();
var source = orders.pendingReviewPage(channelCodes, query.warehouseId(), query.customerId(),
normalize(query.inboundCode()), createdFrom, createdBefore, query.excludedOrderIds(),
query.page(), query.size());
normalize(query.inboundCode()), createdFrom, createdBefore, expectedArrivalFrom,
expectedArrivalBefore, query.excludedOrderIds(), query.page(), query.size());
List<Long> orderIds = source.records().stream().map(order -> order.getId()).toList();
Map<Long, Integer> packageCounts = orders.findCargoByOrderIds(null, orderIds).stream()
.collect(Collectors.groupingBy(OrderCargoEntity::getOrderId,
@@ -44,7 +48,7 @@ public class PendingReviewOrderQueryService {
order.getId(), order.getOrderNo(), order.getInboundCode(), order.getCustomerId(),
order.getCustomerName(), order.getReceivingWarehouseId(), order.getChannelCode(),
order.getChannelName(), packageCounts.getOrDefault(order.getId(), 0), order.getCreatedAt(),
"PENDING_REVIEW")).toList();
order.getExpectedArrivalAt(), "PENDING_REVIEW")).toList();
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
}
@@ -12,8 +12,9 @@ paths:
summary: Firstleg 分页读取 Order 权威待审核订单
description: >-
仅接受内部服务令牌。只返回 ord_order 中未删除且 status=SUBMITTED 的头程订单,
mainStatus 固定为 PENDING_REVIEW。createdStart/createdEnd 按 Asia/Shanghai 自然日闭区间过滤
ord_order.created_at,稳定按 created_at DESC、id DESC 分页;不读取或修复 Firstleg 投影。
mainStatus 固定为 PENDING_REVIEW。createdStart/createdEnd 和 expectedArrivalStart/expectedArrivalEnd
均按 Asia/Shanghai 自然日闭区间分别过滤 ord_order.created_at 与 expected_arrival_at;
稳定按 created_at DESC、id DESC 分页;不读取或修复 Firstleg 投影。
excludedOrderIds 由 Firstleg 根据当前有效装柜计划提供,用于在数据库分页前排除已经占柜的订单。
parameters:
- name: X-Internal-Service-Token
@@ -26,6 +27,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: excludedOrderIds, in: query, required: false, style: form, explode: true, schema: {type: array, items: {type: string, pattern: '^[1-9][0-9]*$'}}}
- {name: page, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}}
- {name: size, in: query, required: true, schema: {type: integer, format: int64, minimum: 1, maximum: 200}}
@@ -1047,7 +1050,7 @@ components:
pages: {type: integer, format: int64, minimum: 0}
PendingReviewOrderRow:
type: object
required: [orderId, orderNo, inboundCode, customerId, customerName, warehouseId, channelCode, channelName, totalPackageCount, createdAt, mainStatus]
required: [orderId, orderNo, inboundCode, customerId, customerName, warehouseId, channelCode, channelName, totalPackageCount, createdAt, expectedArrivalAt, mainStatus]
properties:
orderId: {type: string, pattern: '^[1-9][0-9]*$'}
orderNo: {type: string}
@@ -1059,6 +1062,7 @@ components:
channelName: {type: string}
totalPackageCount: {type: integer, minimum: 0}
createdAt: {type: string, format: date-time}
expectedArrivalAt: {type: string, format: date-time, nullable: true}
mainStatus: {type: string, enum: [PENDING_REVIEW]}
FirstLegBatchDraftResponse:
type: object
@@ -26,7 +26,7 @@ class InternalPendingReviewOrderControllerTest {
@Test
void acceptsOnlyTheConfiguredInternalServiceToken() {
var query = new PendingReviewOrderDtos.Query(List.of("SEA-US"), null, null, null, null, null,
List.of(101L), 1, 20);
null, null, List.of(101L), 1, 20);
var page = new PageResult<PendingReviewOrderDtos.Row>(List.of(), 1, 20, 0, 0);
when(service.page(query)).thenReturn(page);
@@ -16,8 +16,11 @@ class PendingReviewOrderOpenApiContractTest {
.contains("channelCodes")
.contains("createdStart")
.contains("createdEnd")
.contains("expectedArrivalStart")
.contains("expectedArrivalEnd")
.contains("excludedOrderIds")
.contains("PendingReviewOrderRow:")
.contains("expectedArrivalAt: {type: string, format: date-time, nullable: true}")
.contains("mainStatus: {type: string, enum: [PENDING_REVIEW]}")
.contains("orderId: {type: string, pattern: '^[1-9][0-9]*$'}")
.contains("warehouseId: {type: string, pattern: '^[1-9][0-9]*$'}");
@@ -39,6 +39,7 @@ class PendingReviewOrderRepositoryTest {
when(orderMapper.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(new Page<>());
repository.pendingReviewPage(List.of("SEA-US", "AIR-US"), 11L, 22L, "RK-1",
LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 4, 0, 0),
LocalDateTime.of(2026, 9, 5, 0, 0), LocalDateTime.of(2026, 9, 8, 0, 0),
List.of(101L, 102L), 3, 40);
ArgumentCaptor<Page<OrderEntity>> page = ArgumentCaptor.forClass(Page.class);
@@ -54,6 +55,8 @@ class PendingReviewOrderRepositoryTest {
.contains(".eq(OrderEntity::getStatus, OrderStatus.SUBMITTED)")
.contains(".in(channelCodes != null && !channelCodes.isEmpty(), OrderEntity::getChannelCode, channelCodes)")
.contains(".lt(createdBefore != null, OrderEntity::getCreatedAt, createdBefore)")
.contains(".ge(expectedArrivalFrom != null, OrderEntity::getExpectedArrivalAt, expectedArrivalFrom)")
.contains(".lt(expectedArrivalBefore != null, OrderEntity::getExpectedArrivalAt, expectedArrivalBefore)")
.contains(".notIn(excludedOrderIds != null && !excludedOrderIds.isEmpty(), OrderEntity::getId,")
.contains(".orderByDesc(OrderEntity::getCreatedAt)")
.contains(".orderByDesc(OrderEntity::getId)");
@@ -36,16 +36,19 @@ class PendingReviewOrderQueryServiceTest {
@Test
void convertsShanghaiNaturalDatesToClosedDayRangeAndNormalizesFilters() {
when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong()))
when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong()))
.thenReturn(new PageResult<>(List.of(), 2, 20, 0, 0));
service.page(new PendingReviewOrderDtos.Query(List.of(" SEA-US ", "SEA-US"), 11L, 22L,
" RK-1 ", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 3), List.of(101L), 2, 20));
" RK-1 ", LocalDate.of(2026, 9, 1), LocalDate.of(2026, 9, 3),
LocalDate.of(2026, 9, 5), LocalDate.of(2026, 9, 7), List.of(101L), 2, 20));
@SuppressWarnings("unchecked") ArgumentCaptor<List<String>> channels = ArgumentCaptor.forClass(List.class);
verify(repository).pendingReviewPage(channels.capture(), eq(11L), eq(22L), eq("RK-1"),
eq(LocalDateTime.of(2026, 9, 1, 0, 0)),
eq(LocalDateTime.of(2026, 9, 4, 0, 0)), eq(List.of(101L)), eq(2L), eq(20L));
eq(LocalDateTime.of(2026, 9, 4, 0, 0)),
eq(LocalDateTime.of(2026, 9, 5, 0, 0)),
eq(LocalDateTime.of(2026, 9, 8, 0, 0)), eq(List.of(101L)), eq(2L), eq(20L));
assertThat(channels.getValue()).containsExactly("SEA-US");
}
@@ -56,19 +59,21 @@ class PendingReviewOrderQueryServiceTest {
order.setCustomerId(22L); order.setCustomerName("测试客户"); order.setReceivingWarehouseId(33L);
order.setChannelCode("SEA-US"); order.setChannelName("美线");
order.setCreatedAt(LocalDateTime.of(2026, 9, 3, 10, 30));
order.setExpectedArrivalAt(LocalDateTime.of(2026, 9, 8, 15, 0));
OrderCargoEntity first = cargo(101L, 2);
OrderCargoEntity second = cargo(101L, 3);
when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong()))
when(repository.pendingReviewPage(any(), any(), any(), any(), any(), any(), any(), any(), any(), anyLong(), anyLong()))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(repository.findCargoByOrderIds(null, List.of(101L))).thenReturn(List.of(first, second));
var result = service.page(new PendingReviewOrderDtos.Query(null, null, null, " ", null, null,
List.of(), 1, 20));
null, null, List.of(), 1, 20));
assertThat(result.records()).singleElement().satisfies(row -> {
assertThat(row.orderId()).isEqualTo(101L);
assertThat(row.warehouseId()).isEqualTo(33L);
assertThat(row.totalPackageCount()).isEqualTo(5);
assertThat(row.expectedArrivalAt()).isEqualTo(LocalDateTime.of(2026, 9, 8, 15, 0));
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
});
}
@@ -76,9 +81,13 @@ class PendingReviewOrderQueryServiceTest {
@Test
void rejectsReversedDatesAndBlankChannelValues() {
assertThatThrownBy(() -> service.page(new PendingReviewOrderDtos.Query(null, null, null, null,
LocalDate.of(2026, 9, 4), LocalDate.of(2026, 9, 3), List.of(), 1, 20))).isInstanceOf(BizException.class);
LocalDate.of(2026, 9, 4), LocalDate.of(2026, 9, 3), null, null,
List.of(), 1, 20))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.page(new PendingReviewOrderDtos.Query(null, null, null, null,
null, null, LocalDate.of(2026, 9, 9), LocalDate.of(2026, 9, 8),
List.of(), 1, 20))).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.page(new PendingReviewOrderDtos.Query(List.of(" "), null, null,
null, null, null, List.of(), 1, 20))).isInstanceOf(BizException.class);
null, null, null, null, null, List.of(), 1, 20))).isInstanceOf(BizException.class);
}
private static OrderCargoEntity cargo(long orderId, int packageCount) {