From 77817b4c9471326a2905abe0f9ca8a06a870843d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 18:47:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 2 +- firstleg/AGENTS.md | 2 +- .../client/PendingReviewOrderClient.java | 5 +- .../baiyue/firstleg/dto/SmartLoadingDtos.java | 1 + .../mapper/SmartLoadingCandidateMapper.java | 22 +-- .../repository/SmartLoadingRepository.java | 6 +- .../firstleg/service/SmartLoadingService.java | 35 ++-- .../main/resources/openapi/firstleg-v1.yaml | 11 +- ...ormSmartLoadingControllerContractTest.java | 8 +- ...artLoadingCandidateMapperContractTest.java | 15 +- .../service/SmartLoadingServiceTest.java | 157 ++++++++++++++---- order/AGENTS.md | 2 +- .../order/dto/PendingReviewOrderDtos.java | 9 + .../order/repository/OrderRepository.java | 4 + .../PendingReviewOrderQueryService.java | 12 +- .../src/main/resources/openapi/order-v1.yaml | 10 +- ...ernalPendingReviewOrderControllerTest.java | 2 +- ...PendingReviewOrderOpenApiContractTest.java | 3 + .../PendingReviewOrderRepositoryTest.java | 3 + .../PendingReviewOrderQueryServiceTest.java | 23 ++- 20 files changed, 240 insertions(+), 92 deletions(-) diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 0b9fa5b7a..7ea688916 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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 diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 414e482ec..29dc65e1b 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -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 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java index 8e1f4f6ae..bad194527 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java @@ -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 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) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java index 351b6007b..d8b8048fc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -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 { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index 7bc5d954c..440aa326d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -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 candidates(@Param("warehouseId") long warehouseId); + List candidates(@Param("warehouseId") Long warehouseId); @Select(""" """) @@ -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(""" diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java index 8b8812efa..e3e8ff37d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -39,7 +39,7 @@ public class SmartLoadingRepository { @Resource private SmartLoadingCandidateMapper candidates; @Resource private InboundOrderMapper inbounds; - public List candidates(long warehouseId) { return candidates.candidates(warehouseId); } + public List candidates(Long warehouseId) { return candidates.candidates(warehouseId); } public PageResult globalCandidateUnits(InboundCandidateQuery query, Set actualChannels) { return PageResult.from(candidates.globalCandidateUnits( @@ -62,8 +62,8 @@ public class SmartLoadingRepository { public List occupiedOrderIds(InboundCandidateQuery query, Set 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); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 443e56184..98fe56224 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -113,6 +113,16 @@ public class SmartLoadingService { return grouped.values().stream().map(this::inboundCandidate).toList(); } + private List recommendationCandidates(ContainerBookingEntity booking) { + List values = repository.candidates(null).stream() + .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); + Map> 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 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 actualChannels = actualChannels(query.channelGroupCodes()); EnumSet 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 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 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 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 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 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> 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 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("目的仓/港不一致"); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 4b3dfa547..4ba1afcb7 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -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: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java index 733b2c9d8..a60e5844e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -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}") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 09c94f567..fef64a28d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -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)>=#{query.createdStart}") .contains("MIN(eligible.createdAt)<DATE_ADD(#{query.createdEnd},INTERVAL 1 DAY)") - .doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd"); + .contains("MIN(eligible.expectedArrivalAt)>=#{query.expectedArrivalStart}") + .contains("MIN(eligible.expectedArrivalAt)<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)=", "MIN(eligible.expectedArrivalAt) { - 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 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 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(), diff --git a/order/AGENTS.md b/order/AGENTS.md index 72a7cb27d..e9beb0c3d 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -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` 幂等、按订单版本单调推进,渠道变化才使未确认价格预估失效。 diff --git a/order/src/main/java/com/baiyue/order/dto/PendingReviewOrderDtos.java b/order/src/main/java/com/baiyue/order/dto/PendingReviewOrderDtos.java index b6daee25c..6ddd55506 100644 --- a/order/src/main/java/com/baiyue/order/dto/PendingReviewOrderDtos.java +++ b/order/src/main/java/com/baiyue/order/dto/PendingReviewOrderDtos.java @@ -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 ) { } diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index 59584ff51..e9987383c 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -217,6 +217,8 @@ public class OrderRepository { Long customerId, String inboundCode, LocalDateTime createdFrom, LocalDateTime createdBefore, + LocalDateTime expectedArrivalFrom, + LocalDateTime expectedArrivalBefore, List excludedOrderIds, long page, long size) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() @@ -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) diff --git a/order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java index dadf998cd..bffdbd54e 100644 --- a/order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java @@ -25,7 +25,7 @@ public class PendingReviewOrderQueryService { public PageResult 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 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 orderIds = source.records().stream().map(order -> order.getId()).toList(); Map 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()); } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 89cbee885..3d8b8ee5a 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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 diff --git a/order/src/test/java/com/baiyue/order/controller/InternalPendingReviewOrderControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalPendingReviewOrderControllerTest.java index f3cc3019d..a2464af2e 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalPendingReviewOrderControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalPendingReviewOrderControllerTest.java @@ -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(List.of(), 1, 20, 0, 0); when(service.page(query)).thenReturn(page); diff --git a/order/src/test/java/com/baiyue/order/controller/PendingReviewOrderOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/PendingReviewOrderOpenApiContractTest.java index 158216b7f..fd268142d 100644 --- a/order/src/test/java/com/baiyue/order/controller/PendingReviewOrderOpenApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/PendingReviewOrderOpenApiContractTest.java @@ -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]*$'}"); diff --git a/order/src/test/java/com/baiyue/order/repository/PendingReviewOrderRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/PendingReviewOrderRepositoryTest.java index 6c743b27f..4bfbcee1e 100644 --- a/order/src/test/java/com/baiyue/order/repository/PendingReviewOrderRepositoryTest.java +++ b/order/src/test/java/com/baiyue/order/repository/PendingReviewOrderRepositoryTest.java @@ -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 = 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)"); diff --git a/order/src/test/java/com/baiyue/order/service/PendingReviewOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/PendingReviewOrderQueryServiceTest.java index 3b12f447b..4c48243bb 100644 --- a/order/src/test/java/com/baiyue/order/service/PendingReviewOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/PendingReviewOrderQueryServiceTest.java @@ -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> 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) {