diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java new file mode 100644 index 000000000..0a5d4090f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/PendingReviewOrderClient.java @@ -0,0 +1,33 @@ +package com.baiyue.firstleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** Order-owned pending-review rows for the SmartLoading read model. */ +@FeignClient(name = "order-service", contextId = "firstlegPendingReviewOrderClient", + url = "${baiyue.clients.order.url:}", path = "/internal/order/v1/pending-review-orders") +public interface PendingReviewOrderClient { + @GetMapping + ApiResponse> page( + @RequestHeader("X-Internal-Service-Token") String token, + @RequestParam(required = false) List channelCodes, + @RequestParam(required = false) Long warehouseId, + @RequestParam(required = false) Long customerId, + @RequestParam(required = false) String inboundCode, + @RequestParam(required = false) LocalDate createdStart, + @RequestParam(required = false) LocalDate createdEnd, + @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) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java index e12b0974f..c859de9e7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java @@ -24,6 +24,8 @@ public class PlatformSmartLoadingController { public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assign(planId,r,u.userId(),u.username()));} @PostMapping("/plans/{planId}/inbound-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") public ApiResponse assignInbound(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignInboundRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignInbound(planId,r,u.userId(),u.username()));} + @PostMapping("/plans/{planId}/pending-review-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") + public ApiResponse assignPendingReview(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignPendingReviewRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignPendingReview(planId,r,u.userId(),u.username()));} @PostMapping("/plans/{planId}/group-assignments") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:assign')") public ApiResponse assignGroup(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long planId,@Valid @RequestBody AssignGroupRequest r){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.assignGroup(planId,r,u.userId(),u.username()));} @DeleteMapping("/plans/{planId}/group-assignments/{groupId}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:delete')") 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 2ba8638dd..2be703b5d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/SmartLoadingDtos.java @@ -7,7 +7,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record InboundCandidateQuery( @NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes, @Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode, - LocalDate expectedArrivalStart, LocalDate expectedArrivalEnd, CandidateOrderStatus orderStatus, + LocalDate createdStart, LocalDate createdEnd, CandidateOrderStatus orderStatus, @Min(1) long page, @Min(1) @Max(200) long size) { public InboundCandidateQuery { channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes); @@ -32,7 +32,7 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record WorkbenchContainerQuery( @NotEmpty @Size(max=9) List<@NotBlank String> channelGroupCodes, @Positive Long warehouseId, @Positive Long customerId, @Size(max=40) String inboundCode, - LocalDate expectedArrivalStart, LocalDate expectedArrivalEnd, CandidateOrderStatus orderStatus, + LocalDate createdStart, LocalDate createdEnd, CandidateOrderStatus orderStatus, @Min(1) long page, @Min(1) @Max(200) long size) { public WorkbenchContainerQuery { channelGroupCodes = channelGroupCodes == null ? List.of() : List.copyOf(channelGroupCodes); @@ -53,6 +53,10 @@ public final class SmartLoadingDtos { private SmartLoadingDtos(){} public record AssignInboundRequest(@NotNull @Positive Long inboundId, @NotNull @Positive Integer packageCount,@NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=128) String operationId) {} + public record AssignPendingReviewRequest(@NotNull @Positive Long customerId, + @NotNull @Positive Long orderId,@NotBlank @Size(max=40) String inboundCode, + @NotNull @Positive Integer packageCount, + @NotNull @Min(0) Integer expectedVersion,@NotBlank @Size(max=128) String operationId) {} public record AssignGroupRequest(@NotNull @Positive Long inboundId, @NotNull @Min(0) Integer expectedVersion, @NotBlank @Size(max=128) String operationId) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java index 979de001e..d969bb2d9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/bridge/mapper/LoadingModelBridgeMapper.java @@ -27,9 +27,12 @@ public interface LoadingModelBridgeMapper extends BaseMapper0") + @Select("SELECT item.account_id tenantId,item.inbound_id inboundId," + + "COALESCE(SUM(item.package_count),0) allocatedPackageCount,MAX(inbound.process_status) processStatus " + + "FROM fl_loading_plan_item item JOIN fl_inbound_order inbound " + + "ON inbound.tenant_id=item.account_id AND inbound.id=item.inbound_id AND inbound.deleted=0 " + + "WHERE item.loading_plan_id=#{loadingPlanId} AND item.deleted=0 " + + "GROUP BY item.account_id,item.inbound_id HAVING SUM(item.package_count)>0") List allocatedScopes(@Param("loadingPlanId") long loadingPlanId); @Update("UPDATE fl_preload_plan SET updated_by=#{operatorId},updated_by_name=#{operatorName}," @@ -87,5 +90,6 @@ public interface LoadingModelBridgeMapper extends BaseMapper scopes = repository.allocatedScopes(loadingPlanId); if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物,不能开始PDA作业"); + if ("EDITING".equals(loadingPlan.status()) + && scopes.stream().anyMatch(scope -> !"WAIT_SPLIT".equals(scope.processStatus()))) { + throw conflict("装柜清单仍有订单未完成审核和测量,不能开始PDA作业"); + } if (!"EDITING".equals(loadingPlan.status())) { if (Set.of("LOADING", "COMPLETED").contains(loadingPlan.status()) && scopes.stream().allMatch(scope -> repository.byPair( @@ -172,6 +176,9 @@ public class LoadingModelBridgeService { } List scopes = repository.allocatedScopes(loadingPlanId); if (scopes.isEmpty()) throw conflict("装柜计划尚未分配入库单货物"); + if (scopes.stream().anyMatch(scope -> !"WAIT_SPLIT".equals(scope.processStatus()))) { + throw conflict("装柜清单仍有订单未完成审核和测量,不能确认预装"); + } List links = repository.byLoadingPlan(loadingPlanId); boolean complete = scopes.stream().allMatch(scope -> links.stream().anyMatch(link -> Objects.equals(link.tenantId(), scope.tenantId()) 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 5a14f5a69..fbc264aed 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -85,11 +85,11 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba CASE WHEN current_group.id IS NULL THEN eligible.inboundId END inboundId, MIN(eligible.expectedArrivalAt) sortArrivalAt,MIN(eligible.inboundId) sortInboundId FROM ( - SELECT detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt, + SELECT detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt,detail.createdAt, SUM(detail.availablePackageCount) availablePackageCount FROM ( SELECT o.tenant_id accountId,o.id inboundId,o.inbound_code inboundCode, - o.expected_arrival_at expectedArrivalAt,i.inbound_detail_id, + o.expected_arrival_at expectedArrivalAt,o.created_at createdAt,i.inbound_detail_id, SUM(i.package_count) -COALESCE((SELECT SUM(li.package_count) FROM fl_loading_plan_item li WHERE li.account_id=o.tenant_id AND li.inbound_id=o.id @@ -111,10 +111,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba 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')) - GROUP BY o.tenant_id,o.id,o.inbound_code,o.expected_arrival_at,i.inbound_detail_id + GROUP BY o.tenant_id,o.id,o.inbound_code,o.expected_arrival_at,o.created_at,i.inbound_detail_id HAVING availablePackageCount>0 ) detail - GROUP BY detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt + GROUP BY detail.accountId,detail.inboundId,detail.inboundCode,detail.expectedArrivalAt,detail.createdAt ) eligible LEFT JOIN fl_coload_group_member current_member ON current_member.tenant_id=eligible.accountId AND current_member.inbound_id=eligible.inboundId @@ -136,10 +136,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba AND SUM(CASE WHEN LOWER(eligible.inboundCode) LIKE CONCAT('%',LOWER(#{query.inboundCode}),'%') THEN 1 ELSE 0 END)>0 - - AND MIN(eligible.expectedArrivalAt) IS NOT NULL - AND DATE(MIN(eligible.expectedArrivalAt))>=#{query.expectedArrivalStart} - AND DATE(MIN(eligible.expectedArrivalAt))<=#{query.expectedArrivalEnd} + + AND DATE(MIN(eligible.createdAt))>=#{query.createdStart} + AND DATE(MIN(eligible.createdAt))<=#{query.createdEnd} ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId @@ -217,7 +216,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba AND b.channel_code IN #{channel} AND b.warehouse_id=#{query.warehouseId} - + AND EXISTS ( SELECT 1 FROM fl_loading_plan_item filter_item JOIN fl_inbound_order filter_inbound ON filter_inbound.tenant_id=filter_item.account_id @@ -225,8 +224,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba WHERE filter_item.loading_plan_id=p.id AND filter_item.deleted=0 AND filter_item.account_id=#{query.customerId} AND filter_item.inbound_code LIKE CONCAT('%',#{query.inboundCode},'%') - AND filter_inbound.expected_arrival_at >= #{query.expectedArrivalStart} - AND filter_inbound.expected_arrival_at < DATE_ADD(#{query.expectedArrivalEnd},INTERVAL 1 DAY) + AND filter_inbound.created_at >= #{query.createdStart} + AND filter_inbound.created_at < DATE_ADD(#{query.createdEnd},INTERVAL 1 DAY) AND filter_inbound.order_status_snapshot IN ('SUBMITTED','PENDING_REVIEW') 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 c54ef51e5..e506de4f4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/SmartLoadingRepository.java @@ -68,6 +68,12 @@ public class SmartLoadingRepository { .eq(InboundOrderEntity::getTenantId, accountId) .eq(InboundOrderEntity::getId, inboundId)); } + public InboundOrderEntity inboundForUpdate(long accountId, long inboundId) { + return inbounds.selectOne(new LambdaQueryWrapper() + .eq(InboundOrderEntity::getTenantId, accountId) + .eq(InboundOrderEntity::getId, inboundId) + .last("FOR UPDATE")); + } public List activeItemsByInbound(long planId, long accountId, long inboundId) { return items.selectList(new LambdaQueryWrapper() .eq(LoadingPlanItemEntity::getLoadingPlanId, planId) 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 97bdfe3cf..7837693b7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -7,6 +7,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.firstleg.dto.SmartLoadingDtos.*; import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.client.PendingReviewOrderClient; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.repository.SmartLoadingRepository; import com.baiyue.firstleg.loading.bridge.service.LoadingModelBridgeService; @@ -54,6 +55,8 @@ public class SmartLoadingService { ); @Resource private SmartLoadingRepository repository; @Resource private OrderCargoClient orderCargoClient; + @Resource private PendingReviewOrderClient pendingReviewOrders; + @Resource private InboundOrderCreationService inboundOrderCreationService; @Resource private LoadingPickService loadingPickService; @Resource private LoadingModelBridgeService loadingModelBridge; @Resource private SplitPreloadService splitPreloadService; @@ -113,11 +116,14 @@ public class SmartLoadingService { /** 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) { - if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null - && query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) { - throw validation("预计到货开始日期不能晚于结束日期"); + if (query.createdStart() != null && query.createdEnd() != null + && query.createdStart().isAfter(query.createdEnd())) { + throw validation("创建开始日期不能晚于结束日期"); } Set actualChannels = actualChannels(query.channelGroupCodes()); + if (query.orderStatus() == CandidateOrderStatus.PENDING_REVIEW) { + return pendingReviewCandidates(query, actualChannels); + } if (query.orderStatus() != null && query.orderStatus() != CandidateOrderStatus.WAIT_SPLIT) { return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); } @@ -149,10 +155,33 @@ public class SmartLoadingService { return new PageResult<>(records, unitPage.page(), unitPage.size(), unitPage.total(), unitPage.pages()); } + private PageResult pendingReviewCandidates(InboundCandidateQuery query, + Set actualChannels) { + ApiResponse> response; + try { + response = pendingReviewOrders.page(internalToken, List.copyOf(actualChannels), query.warehouseId(), + query.customerId(), query.inboundCode(), query.createdStart(), query.createdEnd(), + query.page(), query.size()); + } catch (RuntimeException exception) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "待审核订单服务暂时不可用"); + } + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "待审核订单查询响应无效"); + } + PageResult source = response.data(); + List records = source.records().stream().map(row -> + new InboundCandidate(row.customerId(), null, row.orderId(), row.orderNo(), row.inboundCode(), + row.warehouseId(), row.channelCode(), row.channelName(), null, null, null, + row.customerName(), null, row.totalPackageCount(), null, null, null, null, + null, null, null, null, false, null, + "PENDING_REVIEW", null, List.of())).toList(); + return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); + } + public PageResult containers(WorkbenchContainerQuery query) { - if (query.expectedArrivalStart() != null && query.expectedArrivalEnd() != null - && query.expectedArrivalStart().isAfter(query.expectedArrivalEnd())) { - throw validation("预计到货开始日期不能晚于结束日期"); + if (query.createdStart() != null && query.createdEnd() != null + && query.createdStart().isAfter(query.createdEnd())) { + throw validation("创建开始日期不能晚于结束日期"); } return repository.workbenchContainers(query, actualChannels(query.channelGroupCodes())); } @@ -349,6 +378,86 @@ public class SmartLoadingService { return assignInbound(planId, request, operatorId, operatorName, false); } + /** + * Reserves declared cartons in a cabinet before order review. Review status is not an allocation + * gate; confirmation/PDA remains blocked until the resulting inbound reaches WAIT_SPLIT. + */ + @Transactional + @BusinessAudit(event = "SMART_LOADING_PENDING_REVIEW_ASSIGN", resourceType = "LOADING_PLAN", + resourceId = "#planId", description = "待审核订单加入装柜清单") + public Plan assignPendingReview(long planId, AssignPendingReviewRequest request, + long operatorId, String operatorName) { + String operation = operation(request.operationId()); + String requestHash = sha256("ASSIGN_PENDING_REVIEW|" + planId + '|' + request.customerId() + + '|' + request.orderId() + '|' + request.inboundCode() + '|' + request.packageCount() + + '|' + request.expectedVersion()); + Plan replay = replay(operation, planId, "ADD_PENDING_REVIEW_ORDER", null, requestHash); + if (replay != null) return replay; + + PendingReviewOrderClient.Row pending = requirePendingReviewOrder(request); + var snapshot = loadInboundSnapshot(request.customerId(), request.orderId()); + if (!Objects.equals(snapshot.tenantId(), request.customerId()) + || !Objects.equals(snapshot.orderId(), request.orderId()) + || !Objects.equals(snapshot.inboundCode(), request.inboundCode())) { + throw conflict("待审核订单快照已变化,请刷新后重试"); + } + long inboundId = inboundOrderCreationService.prepareFromDraft(snapshot); + LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); + if (!"EDITING".equals(plan.getPlanStatus())) { + throw conflict("待审核订单只能加入编辑中的装柜清单"); + } + ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); + InboundOrderEntity lockedInbound = repository.inboundForUpdate(request.customerId(), inboundId); + if (lockedInbound == null || !Set.of("PREPARING", "WAIT_MEASURE", "WAIT_SPLIT") + .contains(lockedInbound.getProcessStatus())) { + throw conflict("订单当前状态不能加入装柜清单"); + } + if (!repository.activeItemsByInbound(request.customerId(), inboundId).isEmpty()) { + throw conflict("该入仓码已被有效柜次占用"); + } + + List declared = loadDeclaredCargo(request.customerId(), request.orderId()); + List candidates = declared.stream().map(value -> declaredCandidate( + pending, snapshot, inboundId, value.cargo())).toList(); + List allocations = proportionalAllocations(candidates, request.packageCount()); + List current = new ArrayList<>(repository.items(planId)); + List created = new ArrayList<>(); + int sequence = current.size(); + for (InboundAllocation allocation : allocations) { + if (allocation.packageCount() == 0) continue; + Candidate candidate = allocation.candidate(); + requireSameChannel(booking, candidate); + requireSameCustomerScope(booking, candidate); + checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true); + OrderCargoClient.CargoBinding cargo = declared.stream() + .map(DeclaredCargo::cargo) + .filter(value -> Objects.equals(value.cargoId(), candidate.inboundDetailId())) + .findFirst().orElseThrow(() -> conflict("订单货物明细已变化,请刷新后重试")); + LoadingPlanItemEntity item = snapshot(planId, candidate, cargo, allocation.packageCount(), + operatorId, operatorName, ++sequence); + current.add(item); + created.add(item); + } + if (created.isEmpty()) throw validation("订单没有可加入装柜清单的箱数"); + splitPreloadService.ensurePendingForPreAllocation(request.customerId(), inboundId, + operatorId, operatorName); + touch(plan, operatorId, operatorName); + try { + created.forEach(repository::insert); + } catch (DuplicateKeyException exception) { + throw conflict("该入仓码已被并发加入其他柜次"); + } + history(planId, null, "ADD_PENDING_REVIEW_ORDER", plan.getPlanStatus(), plan.getPlanStatus(), + operation, json(values("version", request.expectedVersion(), "orderId", request.orderId(), + "inboundCode", request.inboundCode())), + json(values("requestHash", requestHash, "inboundId", inboundId, + "packageCount", request.packageCount(), "provisional", true)), operatorId, operatorName); + loadingModelBridge.bindIncrementalAllocation(planId, request.customerId(), inboundId, + operatorId, operatorName, operation); + created.forEach(item -> loadingPickService.sync(planId, item)); + return view(repository.plan(planId), booking); + } + private Plan assignInbound(long planId, AssignInboundRequest request, long operatorId, String operatorName, boolean wholeGroupAssignment) { String operation = operation(request.operationId()); @@ -902,6 +1011,85 @@ public class SmartLoadingService { throw unavailable(); } } + + private PendingReviewOrderClient.Row requirePendingReviewOrder(AssignPendingReviewRequest request) { + try { + ApiResponse> response = pendingReviewOrders.page( + internalToken, null, null, request.customerId(), request.inboundCode(), 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()) + && Objects.equals(row.customerId(), request.customerId()) + && Objects.equals(row.inboundCode(), request.inboundCode()) + && "PENDING_REVIEW".equals(row.mainStatus())) + .findFirst().orElseThrow(() -> conflict("订单状态或入仓码已变化,请刷新候选后重试")); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + private com.baiyue.firstleg.dto.OrderApprovedInboundCommand loadInboundSnapshot(long customerId, + long orderId) { + try { + ApiResponse response = + orderCargoClient.inboundSnapshot(internalToken, customerId, orderId); + if (response == null || !response.successful() || response.data() == null) throw unavailable(); + return response.data(); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + private List loadDeclaredCargo(long customerId, long orderId) { + try { + ApiResponse> response = + orderCargoClient.listForCartonLabels(internalToken, customerId, orderId); + if (response == null || !response.successful() || response.data() == null + || response.data().isEmpty()) throw conflict("订单没有可加入装柜清单的货物"); + List result = new ArrayList<>(); + for (OrderCargoClient.CargoLabelBinding label : response.data()) { + if (label.cargoId() == null || label.packageCount() == null || label.packageCount() <= 0) { + throw conflict("订单货物箱数不完整,请先修正订单"); + } + ApiResponse detail = orderCargoClient.detail( + internalToken, customerId, orderId, label.cargoId()); + if (detail == null || !detail.successful() || detail.data() == null + || !Objects.equals(detail.data().cargoId(), label.cargoId()) + || !Objects.equals(detail.data().orderId(), orderId)) { + throw conflict("订单货物明细已变化,请刷新后重试"); + } + result.add(new DeclaredCargo(detail.data())); + } + return List.copyOf(result); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + throw unavailable(); + } + } + + private Candidate declaredCandidate(PendingReviewOrderClient.Row pending, + com.baiyue.firstleg.dto.OrderApprovedInboundCommand snapshot, + long inboundId, OrderCargoClient.CargoBinding cargo) { + int boxes = cargo.originalPackageCount() == null ? 0 : cargo.originalPackageCount(); + if (boxes <= 0) throw conflict("订单货物箱数不完整,请先修正订单"); + BigDecimal divisor = BigDecimal.valueOf(boxes); + BigDecimal unitGross = unit(cargo.originalGrossWeightKg()).divide(divisor, 6, RoundingMode.HALF_UP); + BigDecimal unitNet = unit(cargo.netWeightKg()).divide(divisor, 6, RoundingMode.HALF_UP); + BigDecimal unitVolume = unit(cargo.originalVolumeCbm()).divide(divisor, 6, RoundingMode.HALF_UP); + return new Candidate(snapshot.tenantId(), inboundId, cargo.cargoId(), snapshot.orderId(), + snapshot.orderNo(), snapshot.inboundCode(), snapshot.warehouseId(), snapshot.channelCode(), + snapshot.channelName(), snapshot.destinationWarehouseId(), snapshot.destinationCode(), + snapshot.destinationName(), snapshot.customerName(), cargo.cargoName(), boxes, + unitGross, unitNet, unitVolume, null, null, null, null, false, null, + pending.mainStatus(), snapshot.expectedArrivalAt()); + } + + private record DeclaredCargo(OrderCargoClient.CargoBinding cargo) {} private void checkRouteAndCapacity(ContainerBookingEntity b,List lines,Candidate c, int count, boolean requireSameDestination){ requireSameChannel(b, c); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java index d89c86427..14c5fdf5b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/split/service/SplitPreloadService.java @@ -72,6 +72,36 @@ public class SplitPreloadService { ensureLoadingAllocationLifecycle(tenantId, inboundId, operatorId, operatorName, false); } + /** Creates a provisional preload lifecycle for an order selected before review/measurement. */ + @Transactional + public void ensurePendingForPreAllocation(long tenantId, long inboundId, long operatorId, + String operatorName) { + InboundSnapshot inbound = requireInbound(tenantId, inboundId); + if (!Set.of("PREPARING", "WAIT_MEASURE", "WAIT_SPLIT").contains(inbound.processStatus())) { + throw new BizException(ResultCode.CONFLICT, "订单当前状态不能加入装柜清单"); + } + PreloadPlanEntity plan = repository.findPlan(tenantId, inboundId); + if (plan != null) { + if (!PreloadStatus.PENDING.equals(plan.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "预装计划已进入确认流程,不能修改智能装柜分配"); + } + return; + } + plan = new PreloadPlanEntity(); + plan.setTenantId(tenantId); plan.setInboundId(inboundId); plan.setStatus(PreloadStatus.PENDING); + plan.setVersion(0); plan.setItemsJson("[]"); plan.setRemark("订单审核前加入装柜清单自动建立"); + plan.setCreatedBy(operatorId); plan.setCreatedByName(operatorName); + plan.setUpdatedBy(operatorId); plan.setUpdatedByName(operatorName); + try { + repository.createPlan(plan); + } catch (DuplicateKeyException exception) { + PreloadPlanEntity concurrent = repository.findPlan(tenantId, inboundId); + if (concurrent == null || !PreloadStatus.PENDING.equals(concurrent.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "预装计划已被并发修改,请刷新后重试"); + } + } + } + /** * A running PDA task may restore or add an unpicked allocation after the plan-level preload * confirmation. A running PDA task owns the mutation lock, so CONFIRMING and CONFIRMED diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index b9a13c2d5..366bc432a 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -489,9 +489,10 @@ paths: 预计到货日期和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个 分页单元返回,groupMembers 包含完整成员,不会跨页或因单个成员命中搜索条件而拆散。 平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。 - 只返回当前具备实际装柜候选资格的 WAIT_SPLIT 数据;PENDING_REVIEW、WAIT_MEASURE、LOADING - 是统一筛选枚举,但在候选接口中返回空页。LOADED、COMPLETED 与已锁定或成员不完整的组永不返回。 - 分配时仍由目标 planId 接口复核真实渠道、仓库、容量、组锁和并发版本。 + WAIT_SPLIT 返回 Firstleg 可用箱数;PENDING_REVIEW 返回 Order 权威待审核订单并允许先加入装柜清单。 + 订单审核状态只用于筛选和展示,不直接决定能否占位;已被有效柜次占用、LOADED、COMPLETED、 + 已锁定或成员不完整的组永不返回。分配时仍由目标 planId 接口复核真实渠道、仓库、容量、 + 组锁和并发版本;确认预装/PDA 前必须完成审核与测量并进入 WAIT_SPLIT。 parameters: - name: channelGroupCodes in: query @@ -511,8 +512,8 @@ paths: - {name: warehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}} - - {name: expectedArrivalStart, in: query, required: false, schema: {type: string, format: date}} - - {name: expectedArrivalEnd, in: query, required: false, schema: {type: string, format: date}} + - {name: createdStart, in: query, required: false, schema: {type: string, format: date}} + - {name: createdEnd, in: query, required: false, schema: {type: string, format: date}} - {name: orderStatus, in: query, required: false, schema: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}} - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} @@ -525,7 +526,7 @@ paths: summary: 分页查询智能装柜工作台柜次 description: >- 需要 platform:firstleg:smart-loading:list 权限。与全局候选使用相同的渠道、仓库、客户、入仓码、 - 预计到货日期及订单状态筛选;客户及入仓事实以柜内任一有效装柜明细命中为准,查询不会修改或拆散同柜组。 + 订单创建日期及订单状态筛选;客户及入仓事实以柜内任一有效装柜明细命中为准,查询不会修改或拆散同柜组。 只返回 WAIT_LOADING、EDITING 或 LOADING 的 ACTIVE 柜次,COMPLETED 永不返回。 parameters: - name: channelGroupCodes @@ -540,8 +541,8 @@ paths: - {name: warehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 40}} - - {name: expectedArrivalStart, in: query, required: false, schema: {type: string, format: date}} - - {name: expectedArrivalEnd, in: query, required: false, schema: {type: string, format: date}} + - {name: createdStart, in: query, required: false, schema: {type: string, format: date}} + - {name: createdEnd, in: query, required: false, schema: {type: string, format: date}} - {name: orderStatus, in: query, required: false, schema: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_SPLIT, LOADING]}} - {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}} - {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}} @@ -613,6 +614,22 @@ paths: content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingInboundAssignRequest'}}} responses: '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} + /api/platform/v1/first-leg/smart-loading/plans/{planId}/pending-review-assignments: + post: + summary: 将待审核订单加入装柜清单 + description: >- + 需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以 + customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING + 入仓投影,并按订单申报箱数、重量和体积建立柜次占位。已被其他有效柜次占用、仓库/渠道不一致、 + 超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得 + 确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。 + parameters: + - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/SmartLoadingPendingReviewAssignRequest'}}} + responses: + '200': {description: 成功或业务失败,data 为最新 SmartLoadingPlan} /api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}: patch: summary: 调整预装计划明细箱数 @@ -1903,6 +1920,16 @@ components: packageCount: {type: integer, minimum: 1, description: 该入仓码本次计划装柜总箱数} expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion} operationId: {type: string, minLength: 1, maxLength: 128} + SmartLoadingPendingReviewAssignRequest: + type: object + required: [customerId, orderId, inboundCode, packageCount, expectedVersion, operationId] + properties: + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + inboundCode: {type: string, minLength: 1, maxLength: 40} + packageCount: {type: integer, minimum: 1, description: 待审核订单本次占用的申报箱数} + expectedVersion: {type: integer, minimum: 0, description: 当前 planVersion} + operationId: {type: string, minLength: 1, maxLength: 128} SmartLoadingAdjustRequest: type: object required: [packageCount, expectedPlanVersion, expectedItemVersion, operationId] @@ -1984,7 +2011,7 @@ components: properties: accountId: {type: string, pattern: '^[1-9][0-9]*$'} customerId: {type: string, pattern: '^[1-9][0-9]*$', description: 与 accountId 相同的客户技术关联ID} - inboundId: {type: string, pattern: '^[1-9][0-9]*$', description: 普通候选ID;组聚合行中为可用于整组分配的稳定代表成员ID} + inboundId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 待审核订单尚无入仓投影时为空;普通可装柜候选为入仓技术ID} orderId: {type: string, pattern: '^[1-9][0-9]*$'} orderNo: {type: string} inboundCode: {type: string} @@ -1997,12 +2024,12 @@ components: destinationWarehouseCode: {type: string, nullable: true} destinationWarehouseName: {type: string, nullable: true} customerName: {type: string} - goodsSummary: {type: string} + goodsSummary: {type: string, nullable: true} availablePackageCount: {type: integer, minimum: 1, description: 普通行剩余箱数;组行完整成员箱数合计} - unitGrossWeightKg: {type: number, minimum: 0} - unitNetWeightKg: {type: number, minimum: 0} - averageUnitVolumeCbm: {type: number, minimum: 0} - availableVolumeCbm: {type: number, minimum: 0} + unitGrossWeightKg: {type: number, minimum: 0, nullable: true} + unitNetWeightKg: {type: number, minimum: 0, nullable: true} + averageUnitVolumeCbm: {type: number, minimum: 0, nullable: true} + availableVolumeCbm: {type: number, minimum: 0, nullable: true} coLoadGroupId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true} groupNo: {type: string, nullable: true} memberCount: {type: integer, minimum: 2, nullable: true} 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 62efc97da..810d74ba5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/PlatformSmartLoadingControllerContractTest.java @@ -32,12 +32,17 @@ class PlatformSmartLoadingControllerContractTest { yaml.indexOf("/api/platform/v1/first-leg/loading-associations/summary:")); assertThat(contract).contains("name: channelGroupCodes") .contains("name: warehouseId") + .contains("name: createdStart") + .contains("name: createdEnd") .contains("SmartLoadingInboundCandidatePageEnvelope") + .doesNotContain("name: expectedArrivalStart") + .doesNotContain("name: expectedArrivalEnd") .doesNotContain("name: bookingId"); } @Test void everyMutationDeclaresDedicatedPermission() throws Exception { authority("assign","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignRequest.class); authority("assignInbound","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignInboundRequest.class); + authority("assignPendingReview","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignPendingReviewRequest.class); authority("assignGroup","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,AssignGroupRequest.class); authority("removeGroup","platform:firstleg:smart-loading:delete",AuthenticatedUser.class,long.class,long.class,RemoveGroupRequest.class); authority("recommend","platform:firstleg:smart-loading:assign",AuthenticatedUser.class,long.class,RecommendRequest.class); @@ -55,7 +60,10 @@ class PlatformSmartLoadingControllerContractTest { assertThat(yaml) .contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/assignments:") .contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/inbound-assignments:") + .contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/pending-review-assignments:") .contains("SmartLoadingInboundAssignRequest:") + .contains("SmartLoadingPendingReviewAssignRequest:") + .contains("订单审核状态不作为清单占位门禁") .contains("/api/platform/v1/first-leg/smart-loading/plans/{planId}/items/{itemId}:") .contains("SmartLoadingAssignRequest:") .contains("SmartLoadingAdjustRequest:") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java index 55d570cc0..f4282bc20 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/bridge/service/LoadingModelBridgeServiceTest.java @@ -47,7 +47,7 @@ class LoadingModelBridgeServiceTest { @Test void bindsEveryAllocatedInboundBeforeLoadingStarts() { when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3)); - when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8))); + when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"))); when(repository.preload(10, 20)).thenReturn(new PreloadSnapshot(30L, 10L, 20L, "PENDING", 2)); when(repository.allocatedPackages(10, 20, 40)).thenReturn(8); when(repository.touchPreload(30, 10, 20, 2, 7, "admin")).thenReturn(1); @@ -69,7 +69,7 @@ class LoadingModelBridgeServiceTest { var plan = new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 9); when(repository.loadingPlan(40)).thenReturn(plan); when(repository.allocatedScopes(40)).thenReturn(List.of( - new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); + new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"), new AllocationScope(11L, 21L, 4, "WAIT_SPLIT"))); when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity()); when(repository.byPair(11, 21, 40)).thenReturn(new PreloadLoadingPlanLinkEntity()); Link first = link("PENDING", 3, "EDITING", 9, 8); @@ -88,7 +88,7 @@ class LoadingModelBridgeServiceTest { void editingReplaySkipsExistingPairAndCreatesOnlyMissingPair() { when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "EDITING", 3)); when(repository.allocatedScopes(40)).thenReturn(List.of( - new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); + new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"), new AllocationScope(11L, 21L, 4, "WAIT_SPLIT"))); when(repository.byPair(10, 20, 40)).thenReturn(new PreloadLoadingPlanLinkEntity()); when(repository.byPair(11, 21, 40)).thenReturn(null); when(repository.preload(11, 21)).thenReturn(new PreloadSnapshot(31L, 11L, 21L, "PENDING", 5)); @@ -115,7 +115,7 @@ class LoadingModelBridgeServiceTest { @Test void refusesPostHocBindingAfterLoadingStarts() { when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4)); - when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8))); + when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"))); assertThatThrownBy(() -> service.bindAllocatedBeforeLoading(40, 7, "admin", "push-1")) .hasMessageContaining("禁止在LOADING后补关联"); @@ -126,7 +126,7 @@ class LoadingModelBridgeServiceTest { @Test void loadingReplayOnlyReadsAlreadyCompleteLinks() { when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot(40L, 50L, "C1", "LOADING", 4)); - when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8))); + when(repository.allocatedScopes(40)).thenReturn(List.of(new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"))); PreloadLoadingPlanLinkEntity existing = new PreloadLoadingPlanLinkEntity(); when(repository.byPair(10, 20, 40)).thenReturn(existing); Link linked = link("PENDING", 3, "LOADING", 4, 8); @@ -171,7 +171,7 @@ class LoadingModelBridgeServiceTest { when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot( 40L, 50L, "C1", "EDITING", 3)); when(repository.allocatedScopes(40)).thenReturn(List.of( - new AllocationScope(10L, 20L, 8), new AllocationScope(11L, 21L, 4))); + new AllocationScope(10L, 20L, 8, "WAIT_SPLIT"), new AllocationScope(11L, 21L, 4, "WAIT_SPLIT"))); when(repository.byLoadingPlan(40)).thenReturn(List.of( link("PENDING", 2, "EDITING", 3, 8))); @@ -179,6 +179,19 @@ class LoadingModelBridgeServiceTest { .hasMessageContaining("关联不完整"); } + @Test + void confirmationGateRejectsProvisionalPendingReviewAllocation() { + when(repository.loadingPlan(40)).thenReturn(new LoadingPlanSnapshot( + 40L, 50L, "C1", "EDITING", 3)); + when(repository.allocatedScopes(40)).thenReturn(List.of( + new AllocationScope(10L, 20L, 8, "PREPARING"))); + + assertThatThrownBy(() -> service.requireAllocatedLinksForConfirmation(40)) + .hasMessageContaining("未完成审核和测量"); + + verify(repository, never()).byLoadingPlan(anyLong()); + } + @Test void terminalGateRequiresEveryLinkedContainerPlanCompleted() { when(repository.byInbound(10, 20)).thenReturn(List.of( 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 145c7aaff..c43f615d8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -86,35 +86,34 @@ class SmartLoadingCandidateMapperContractTest { } @Test - void globalCandidateDateFilterUsesTheSameRepresentativeArrivalAsTheReturnedUnit() throws Exception { + void globalCandidateDateFilterUsesTheRepresentativeOrderCreationDate() throws Exception { Method units = SmartLoadingCandidateMapper.class.getDeclaredMethod("globalCandidateUnits", Page.class, InboundCandidateQuery.class, Set.class); String sql = String.join(" ", units.getAnnotation(Select.class).value()); assertThat(sql) - .contains("MIN(eligible.expectedArrivalAt) sortArrivalAt") - .contains("MIN(eligible.expectedArrivalAt) IS NOT NULL") - .contains("DATE(MIN(eligible.expectedArrivalAt))>=#{query.expectedArrivalStart}") - .contains("DATE(MIN(eligible.expectedArrivalAt))<=#{query.expectedArrivalEnd}") - .doesNotContain("SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL"); + .contains("o.created_at createdAt") + .contains("DATE(MIN(eligible.createdAt))>=#{query.createdStart}") + .contains("DATE(MIN(eligible.createdAt))<=#{query.createdEnd}") + .doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd"); } @Test - void representativeArrivalControlsWhetherTheWholeGroupMatchesTheClosedDateRange() { - List groupArrivals = List.of( + void representativeCreationDateControlsWhetherTheWholeGroupMatchesTheClosedDateRange() { + List groupCreatedTimes = List.of( LocalDateTime.parse("2026-09-01T09:00:00"), LocalDateTime.parse("2026-09-10T09:00:00")); - LocalDate representative = groupArrivals.stream().min(LocalDateTime::compareTo) + LocalDate representative = groupCreatedTimes.stream().min(LocalDateTime::compareTo) .orElseThrow().toLocalDate(); assertThat(matchesClosedRange(representative, LocalDate.parse("2026-09-10"), LocalDate.parse("2026-09-10"))) - .as("another member matching must not admit a group whose MIN arrival is outside") + .as("another member matching must not admit a group whose MIN creation date is outside") .isFalse(); assertThat(matchesClosedRange(representative, LocalDate.parse("2026-09-01"), LocalDate.parse("2026-09-01"))) - .as("a matching MIN arrival admits the whole group") + .as("a matching MIN creation date admits the whole group") .isTrue(); } @@ -131,9 +130,9 @@ class SmartLoadingCandidateMapperContractTest { String renderedUnits = driver.createSqlSource(configuration, unitScript, Map.class) .getBoundSql(Map.of("query", query, "actualChannels", Set.of("MATSON_CLX_TRUCK"))).getSql(); assertThat(renderedUnits) - .contains("o.channel_code IN", "MIN(eligible.expectedArrivalAt) IS NOT NULL") - .contains("DATE(MIN(eligible.expectedArrivalAt))>=", "DATE(MIN(eligible.expectedArrivalAt))<=") - .doesNotContain("SUM(CASE WHEN eligible.expectedArrivalAt IS NOT NULL"); + .contains("o.channel_code IN") + .contains("DATE(MIN(eligible.createdAt))>=", "DATE(MIN(eligible.createdAt))<=") + .doesNotContain("DATE(MIN(eligible.expectedArrivalAt))"); Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod( "globalCandidatesByUnits", List.class); @@ -158,8 +157,9 @@ class SmartLoadingCandidateMapperContractTest { .contains("fl_loading_plan_item filter_item") .contains("fl_inbound_order filter_inbound") .contains("filter_item.inbound_code LIKE") - .contains("filter_inbound.expected_arrival_at") + .contains("filter_inbound.created_at") .contains("filter_inbound.process_status='LOADING'") + .doesNotContain("query.expectedArrivalStart", "query.expectedArrivalEnd") .doesNotContain("p.plan_status IN ('EDITING','LOADING','COMPLETED')"); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java index ad60942b4..ba21cdcd6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/CustomerDimensionWeightingServiceTest.java @@ -38,6 +38,18 @@ class CustomerDimensionWeightingServiceTest { assertThat(weighted.unitVolumeCbm()).isEqualByComparingTo("0.003"); } + @Test + void deviceMeasurementsUseActualDimensionsAndAddEachSideWhenRuleMatches() { + var weighted = service.load(10001L).resolveDeviceMeasurement( + new BigDecimal("10"), new BigDecimal("10"), new BigDecimal("10")); + + assertThat(weighted.boxType()).isEqualTo(CustomerDimensionBoxType.SMALL); + assertThat(weighted.outerLengthCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.outerWidthCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.outerHeightCm()).isEqualByComparingTo("14.000"); + assertThat(weighted.calculationRuleVersion()).isEqualTo("CUSTOMER_DIMENSION_SMALL_V3"); + } + @Test void cartonRotationDoesNotChangeTheMatchedRule() { var weighted = service.load(10001L).resolve( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 540ceb163..c120c08e4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -5,7 +5,9 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.OrderCargoClient; +import com.baiyue.firstleg.client.PendingReviewOrderClient; import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignRequest; +import com.baiyue.firstleg.dto.SmartLoadingDtos.AssignPendingReviewRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.AdjustRequest; import com.baiyue.firstleg.dto.SmartLoadingDtos.Candidate; import com.baiyue.firstleg.dto.SmartLoadingDtos.CandidateOrderStatus; @@ -68,6 +70,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; @@ -81,6 +84,8 @@ import static org.mockito.Mockito.times; class SmartLoadingServiceTest { @MockBean private SmartLoadingRepository repository; @MockBean private OrderCargoClient cargoClient; + @MockBean private PendingReviewOrderClient pendingReviewOrders; + @MockBean private InboundOrderCreationService inboundOrderCreationService; @MockBean private LoadingPickService loadingPickService; @MockBean private LoadingModelBridgeService loadingModelBridge; @MockBean private SplitPreloadService splitPreloadService; @@ -207,6 +212,97 @@ class SmartLoadingServiceTest { verify(repository, never()).booking(anyLong()); } + @Test + void pagesPendingReviewOrdersFromTheOrderOwnedReadModelAsAssignableRows() { + InboundCandidateQuery query = new InboundCandidateQuery( + List.of("EXX"), 193000000000200002L, null, "EXXHK2609020361", + LocalDate.parse("2026-09-02"), LocalDate.parse("2026-09-02"), + CandidateOrderStatus.PENDING_REVIEW, 1, 20); + var row = new PendingReviewOrderClient.Row(2095052002489335810L, "FL-361", + "EXXHK2609020361", 2092797167699816450L, "客户", 193000000000200002L, + "EXX_TRUCK", "EXX海卡", 100, LocalDateTime.parse("2026-09-02T15:31:36"), + "PENDING_REVIEW"); + when(pendingReviewOrders.page(anyString(), anyList(), eq(193000000000200002L), + isNull(), eq("EXXHK2609020361"), eq(LocalDate.parse("2026-09-02")), + eq(LocalDate.parse("2026-09-02")), eq(1L), eq(20L))) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(row), 1, 20, 1, 1))); + + var result = service.inboundCandidates(query); + + assertThat(result.records()).singleElement().satisfies(candidate -> { + assertThat(candidate.orderId()).isEqualTo(2095052002489335810L); + assertThat(candidate.inboundId()).isNull(); + assertThat(candidate.inboundCode()).isEqualTo("EXXHK2609020361"); + assertThat(candidate.orderStatusSnapshot()).isEqualTo("PENDING_REVIEW"); + assertThat(candidate.blockReason()).isNull(); + assertThat(candidate.availablePackageCount()).isEqualTo(100); + }); + verify(repository, never()).globalCandidateUnits(any(), anySet()); + verify(repository, never()).globalCandidatesByUnits(anyList()); + } + + @Test + void assignsPendingReviewOrderAsAProvisionalContainerAllocation() { + long customerId = 2092797167699816450L; + long orderId = 2095052002489335810L; + String inboundCode = "EXXHK2609020361"; + var pending = new PendingReviewOrderClient.Row(orderId, "FL-361", inboundCode, + customerId, "客户", 100L, "FBA", "FBA渠道", 5, + LocalDateTime.parse("2026-09-02T15:31:36"), "PENDING_REVIEW"); + var snapshot = new com.baiyue.firstleg.dto.OrderApprovedInboundCommand( + customerId, orderId, "FL-361", inboundCode, customerId, "客户", 100L, "义乌仓", + null, null, null, "FIRST_LEG", "FBA", "FBA渠道", null, null, null, + null, null, false, "FBA", 200L, "FBA1", "FBA仓", null, null, null, 5); + var label = new OrderCargoClient.CargoLabelBinding(20L, orderId, 1, "货物", 5, + null, null, null); + var cargo = new OrderCargoClient.CargoBinding(20L, orderId, 1, "货物", 5, + new BigDecimal("50"), new BigDecimal("2.5")); + when(pendingReviewOrders.page(anyString(), isNull(), isNull(), eq(customerId), + eq(inboundCode), 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)); + when(inboundOrderCreationService.prepareFromDraft(snapshot)).thenReturn(10L); + when(cargoClient.listForCartonLabels(anyString(), eq(customerId), eq(orderId))) + .thenReturn(ApiResponse.success(List.of(label))); + when(cargoClient.detail(anyString(), eq(customerId), eq(orderId), eq(20L))) + .thenReturn(ApiResponse.success(cargo)); + when(repository.plan(1L)).thenReturn(plan("EDITING", 0), plan("EDITING", 1)); + when(repository.booking(99L)).thenReturn(booking()); + InboundOrderEntity lockedInbound = new InboundOrderEntity(); + lockedInbound.setProcessStatus("PREPARING"); + when(repository.inboundForUpdate(customerId, 10L)).thenReturn(lockedInbound); + when(repository.items(1L)).thenReturn(List.of()); + when(repository.touch(1L, 0, 7L, "u")).thenReturn(1); + + Plan result = service.assignPendingReview(1L, + new AssignPendingReviewRequest(customerId, orderId, inboundCode, 5, 0, "pending-1"), + 7L, "u"); + + assertThat(result.version()).isEqualTo(1); + ArgumentCaptor item = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).insert(item.capture()); + assertThat(item.getValue().getInboundId()).isEqualTo(10L); + assertThat(item.getValue().getInboundDetailId()).isEqualTo(20L); + assertThat(item.getValue().getPackageCount()).isEqualTo(5); + assertThat(item.getValue().getGrossWeightKg()).isEqualByComparingTo("50"); + verify(splitPreloadService).ensurePendingForPreAllocation(customerId, 10L, 7L, "u"); + verify(loadingModelBridge).bindIncrementalAllocation(1L, customerId, 10L, 7L, "u", "pending-1"); + } + + @Test + void failsClosedWhenPendingReviewOrderSourceIsUnavailable() { + InboundCandidateQuery query = new InboundCandidateQuery( + List.of("EXX"), null, null, null, null, null, + CandidateOrderStatus.PENDING_REVIEW, 1, 20); + when(pendingReviewOrders.page(anyString(), anyList(), isNull(), isNull(), isNull(), + isNull(), isNull(), eq(1L), eq(20L))).thenThrow(new RuntimeException("offline")); + + assertThatThrownBy(() -> service.inboundCandidates(query)) + .isInstanceOf(BizException.class) + .hasMessage("待审核订单服务暂时不可用"); + } + @Test void pagesWorkbenchContainersWithTheSameGlobalFilters() { WorkbenchContainer container = new WorkbenchContainer(99L, "CB-1", "CAB-1", null, diff --git a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java index 00e101f49..ceca203bf 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/split/service/SplitPreloadServiceTest.java @@ -69,6 +69,20 @@ class SplitPreloadServiceTest { assertThat(captor.getValue().getRemark()).isEqualTo("智能装柜分配自动建立"); } + @Test + void pendingReviewPreAllocationCreatesAProvisionalPreloadLifecycle() { + when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "PREPARING", 0)); + when(repository.findPlan(10, 20)).thenReturn(null); + + service.ensurePendingForPreAllocation(10, 20, 7, "admin"); + + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(PreloadPlanEntity.class); + verify(repository).createPlan(captor.capture()); + assertThat(captor.getValue().getStatus()).isEqualTo("PENDING"); + assertThat(captor.getValue().getRemark()).contains("审核前"); + } + @Test void loadingAllocationDoesNotOverwriteConfirmedPreload() { when(repository.findInbound(10, 20)).thenReturn(new InboundSnapshot(20L, 10L, "WAIT_SPLIT", 3)); diff --git a/order/src/main/java/com/baiyue/order/controller/InternalPendingReviewOrderController.java b/order/src/main/java/com/baiyue/order/controller/InternalPendingReviewOrderController.java new file mode 100644 index 000000000..a24209fc6 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/InternalPendingReviewOrderController.java @@ -0,0 +1,49 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.PendingReviewOrderDtos; +import com.baiyue.order.service.PendingReviewOrderQueryService; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** Firstleg 智能装柜读取尚未审核订单的内部入口。 */ +@RestController +@RequestMapping("/internal/order/v1/pending-review-orders") +public class InternalPendingReviewOrderController { + @Resource private PendingReviewOrderQueryService service; + @Value("${baiyue.security.internal-service-token}") private String configuredToken; + private byte[] expectedToken; + + @PostConstruct + void initializeToken() { + expectedToken = configuredToken.getBytes(StandardCharsets.UTF_8); + } + + @GetMapping + public ApiResponse> page( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @ModelAttribute PendingReviewOrderDtos.Query query) { + requireToken(token); + return ApiResponse.success(service.page(query)); + } + + private void requireToken(String token) { + byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expectedToken, supplied)) { + throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); + } + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/PendingReviewOrderDtos.java b/order/src/main/java/com/baiyue/order/dto/PendingReviewOrderDtos.java new file mode 100644 index 000000000..33a1394c1 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/PendingReviewOrderDtos.java @@ -0,0 +1,49 @@ +package com.baiyue.order.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** Order 向履约服务提供的待审核订单权威只读契约。 */ +public final class PendingReviewOrderDtos { + private PendingReviewOrderDtos() { + } + + public record Query( + List<@Size(max = 64) String> channelCodes, + @Min(1) Long warehouseId, + @Min(1) Long customerId, + @Size(max = 40) String inboundCode, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdStart, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate createdEnd, + @Min(1) long page, + @Min(1) @Max(200) long size + ) { + @AssertTrue(message = "创建日期起始日不能晚于结束日") + public boolean isCreatedRangeValid() { + return createdStart == null || createdEnd == null || !createdStart.isAfter(createdEnd); + } + } + + public record Row( + @Schema(type = "string", pattern = "^[1-9][0-9]*$") Long orderId, + String orderNo, + String inboundCode, + @Schema(type = "string", pattern = "^[1-9][0-9]*$") Long customerId, + String customerName, + @Schema(type = "string", pattern = "^[1-9][0-9]*$") Long warehouseId, + String channelCode, + String channelName, + int totalPackageCount, + LocalDateTime createdAt, + 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 c2aa906b2..eb793e6d3 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -210,6 +210,26 @@ public class OrderRepository { return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); } + /** Order 权威待审核页;只允许查询未删除的 SUBMITTED 头程订单。 */ + public PageResult pendingReviewPage(List channelCodes, Long warehouseId, + Long customerId, String inboundCode, + LocalDateTime createdFrom, + LocalDateTime createdBefore, + long page, long size) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(OrderEntity::getDeleted, false) + .eq(OrderEntity::getStatus, OrderStatus.SUBMITTED) + .in(channelCodes != null && !channelCodes.isEmpty(), OrderEntity::getChannelCode, channelCodes) + .eq(warehouseId != null, OrderEntity::getReceivingWarehouseId, warehouseId) + .eq(customerId != null, OrderEntity::getCustomerId, customerId) + .eq(StringUtils.hasText(inboundCode), OrderEntity::getInboundCode, inboundCode) + .ge(createdFrom != null, OrderEntity::getCreatedAt, createdFrom) + .lt(createdBefore != null, OrderEntity::getCreatedAt, createdBefore) + .orderByDesc(OrderEntity::getCreatedAt) + .orderByDesc(OrderEntity::getId); + return PageResult.from(orderMapper.selectPage(new Page<>(page, size), wrapper)); + } + public PageResult merchantInboundPage(long tenantId, MerchantInboundOrderQuery query) { com.baiyue.order.entity.OrderStatus effectiveStatus = query.operationStatus() == null ? query.status() : query.operationStatus(); diff --git a/order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java new file mode 100644 index 000000000..768a11a49 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/PendingReviewOrderQueryService.java @@ -0,0 +1,65 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.PendingReviewOrderDtos; +import com.baiyue.order.entity.OrderCargoEntity; +import com.baiyue.order.repository.OrderRepository; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** 为 Firstleg 智能装柜提供 Order 权威待审核订单页。 */ +@Service +public class PendingReviewOrderQueryService { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + @Resource private OrderRepository orders; + + public PageResult page(PendingReviewOrderDtos.Query query) { + if (query == null || query.page() < 1 || query.size() < 1 || query.size() > 200 + || !query.isCreatedRangeValid()) { + throw new BizException(ResultCode.VALIDATION_FAILED, "待审核订单查询参数不正确"); + } + List channelCodes = normalizeChannelCodes(query.channelCodes()); + LocalDateTime createdFrom = query.createdStart() == null ? null + : query.createdStart().atStartOfDay(BUSINESS_ZONE).toLocalDateTime(); + LocalDateTime createdBefore = query.createdEnd() == null ? null + : query.createdEnd().plusDays(1).atStartOfDay(BUSINESS_ZONE).toLocalDateTime(); + var source = orders.pendingReviewPage(channelCodes, query.warehouseId(), query.customerId(), + normalize(query.inboundCode()), createdFrom, createdBefore, 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, + Collectors.summingInt(cargo -> cargo.getPackageCount() == null ? 0 : cargo.getPackageCount()))); + var rows = source.records().stream().map(order -> new PendingReviewOrderDtos.Row( + 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(); + return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages()); + } + + private static List normalizeChannelCodes(List values) { + if (values == null || values.isEmpty()) return List.of(); + LinkedHashSet normalized = new LinkedHashSet<>(); + for (String value : values) { + if (!StringUtils.hasText(value) || value.trim().length() > 64) { + throw new BizException(ResultCode.VALIDATION_FAILED, "渠道编码不能为空且不能超过64个字符"); + } + normalized.add(value.trim()); + } + return List.copyOf(normalized); + } + + private static String normalize(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } +} diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index 74bd4dfcb..86871d9d1 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -6,6 +6,34 @@ info: security: - bearerAuth: [] paths: + /internal/order/v1/pending-review-orders: + get: + security: [] + 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 投影。 + parameters: + - name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} + - {name: channelCodes, in: query, required: false, style: form, explode: true, schema: {type: array, items: {type: string, maxLength: 64}}} + - {name: warehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {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: 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}} + responses: + '200': + description: Order 权威待审核订单页 + content: + application/json: + schema: {$ref: '#/components/schemas/PendingReviewOrderPageEnvelope'} + '403': {description: 内部服务令牌无效} /internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches: post: security: [] @@ -1001,6 +1029,35 @@ components: description: 前端外层行标识;同一批次内必须唯一,用于幂等重试和结果关联 draft: $ref: '#/components/schemas/FirstLegOrderDraft' + PendingReviewOrderPageEnvelope: + type: object + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/PendingReviewOrderRow'}} + page: {type: integer, format: int64, minimum: 1} + size: {type: integer, format: int64, minimum: 1} + total: {type: integer, format: int64, minimum: 0} + pages: {type: integer, format: int64, minimum: 0} + PendingReviewOrderRow: + type: object + required: [orderId, orderNo, inboundCode, customerId, customerName, warehouseId, channelCode, channelName, totalPackageCount, createdAt, mainStatus] + properties: + orderId: {type: string, pattern: '^[1-9][0-9]*$'} + orderNo: {type: string} + inboundCode: {type: string} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} + customerName: {type: string} + warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} + channelCode: {type: string} + channelName: {type: string} + totalPackageCount: {type: integer, minimum: 0} + createdAt: {type: string, format: date-time} + mainStatus: {type: string, enum: [PENDING_REVIEW]} FirstLegBatchDraftResponse: type: object required: [total, succeeded, failed, items] diff --git a/order/src/test/java/com/baiyue/order/controller/InternalPendingReviewOrderControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalPendingReviewOrderControllerTest.java new file mode 100644 index 000000000..26f2fd718 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/InternalPendingReviewOrderControllerTest.java @@ -0,0 +1,37 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.PendingReviewOrderDtos; +import com.baiyue.order.service.PendingReviewOrderQueryService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(InternalPendingReviewOrderController.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef") +class InternalPendingReviewOrderControllerTest { + @MockBean private PendingReviewOrderQueryService service; + @Resource private InternalPendingReviewOrderController controller; + + @Test + void acceptsOnlyTheConfiguredInternalServiceToken() { + var query = new PendingReviewOrderDtos.Query(List.of("SEA-US"), null, null, null, null, null, 1, 20); + var page = new PageResult(List.of(), 1, 20, 0, 0); + when(service.page(query)).thenReturn(page); + + assertThatThrownBy(() -> controller.page(null, query)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.page("wrong", query)).isInstanceOf(BizException.class); + assertThat(controller.page("0123456789abcdef0123456789abcdef", query).data()).isSameAs(page); + verify(service).page(query); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/PendingReviewOrderOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/PendingReviewOrderOpenApiContractTest.java new file mode 100644 index 000000000..977176308 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/PendingReviewOrderOpenApiContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.order.controller; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class PendingReviewOrderOpenApiContractTest { + @Test + void documentsInternalPendingReviewPageAndStringIds() throws Exception { + String yaml = Files.readString(Path.of("src/main/resources/openapi/order-v1.yaml")); + assertThat(yaml) + .contains("/internal/order/v1/pending-review-orders:") + .contains("channelCodes") + .contains("createdStart") + .contains("createdEnd") + .contains("PendingReviewOrderRow:") + .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 new file mode 100644 index 000000000..90e4f246c --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/PendingReviewOrderRepositoryTest.java @@ -0,0 +1,59 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.mapper.*; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.time.LocalDateTime; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(OrderRepository.class) +class PendingReviewOrderRepositoryTest { + @MockBean private OrderMapper orderMapper; + @MockBean private OrderCargoMapper cargoMapper; + @MockBean private OrderPartyMapper partyMapper; + @MockBean private OrderInternalNoteMapper noteMapper; + @MockBean private OrderStatusHistoryMapper historyMapper; + @MockBean private OutboxEventMapper outboxMapper; + @MockBean private OrderChildPurgeMapper childPurgeMapper; + @MockBean private OrderIdempotencyRecordMapper idempotencyMapper; + @MockBean private InboundCodeSequenceMapper inboundCodeSequenceMapper; + @Resource private OrderRepository repository; + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void buildsSubmittedOnlyStablePageWithEverySupportedFilter() throws Exception { + 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), 3, 40); + + ArgumentCaptor> page = ArgumentCaptor.forClass(Page.class); + ArgumentCaptor> wrapper = ArgumentCaptor.forClass(Wrapper.class); + verify(orderMapper).selectPage(page.capture(), wrapper.capture()); + assertThat(page.getValue().getCurrent()).isEqualTo(3); + assertThat(page.getValue().getSize()).isEqualTo(40); + assertThat(wrapper.getValue().getExpression().getNormal()).hasSizeGreaterThanOrEqualTo(15); + assertThat(wrapper.getValue().getExpression().getOrderBy()).hasSize(2); + String source = Files.readString(Path.of("src/main/java/com/baiyue/order/repository/OrderRepository.java")); + assertThat(source) + .contains(".eq(OrderEntity::getDeleted, false)") + .contains(".eq(OrderEntity::getStatus, OrderStatus.SUBMITTED)") + .contains(".in(channelCodes != null && !channelCodes.isEmpty(), OrderEntity::getChannelCode, channelCodes)") + .contains(".lt(createdBefore != null, OrderEntity::getCreatedAt, createdBefore)") + .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 new file mode 100644 index 000000000..c3636222f --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/PendingReviewOrderQueryServiceTest.java @@ -0,0 +1,88 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.order.dto.PendingReviewOrderDtos; +import com.baiyue.order.entity.OrderCargoEntity; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.repository.OrderRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PendingReviewOrderQueryServiceTest { + private final OrderRepository repository = mock(OrderRepository.class); + private PendingReviewOrderQueryService service; + + @BeforeEach + void setUp() { + service = new PendingReviewOrderQueryService(); + ReflectionTestUtils.setField(service, "orders", repository); + } + + @Test + void convertsShanghaiNaturalDatesToClosedDayRangeAndNormalizesFilters() { + when(repository.pendingReviewPage(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), 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(2L), eq(20L)); + assertThat(channels.getValue()).containsExactly("SEA-US"); + } + + @Test + void mapsAuthoritativeOrderFieldsAndSumsPackageCounts() { + OrderEntity order = new OrderEntity(); + order.setId(101L); order.setOrderNo("FL101"); order.setInboundCode("RK101"); + order.setCustomerId(22L); order.setCustomerName("测试客户"); order.setReceivingWarehouseId(33L); + order.setChannelCode("SEA-US"); order.setChannelName("美线"); + order.setCreatedAt(LocalDateTime.of(2026, 9, 3, 10, 30)); + OrderCargoEntity first = cargo(101L, 2); + OrderCargoEntity second = cargo(101L, 3); + when(repository.pendingReviewPage(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, 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.mainStatus()).isEqualTo("PENDING_REVIEW"); + }); + } + + @Test + void rejectsReversedDatesAndBlankChannelValues() { + assertThatThrownBy(() -> service.page(new PendingReviewOrderDtos.Query(null, null, null, null, + LocalDate.of(2026, 9, 4), LocalDate.of(2026, 9, 3), 1, 20))).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.page(new PendingReviewOrderDtos.Query(List.of(" "), null, null, + null, null, null, 1, 20))).isInstanceOf(BizException.class); + } + + private static OrderCargoEntity cargo(long orderId, int packageCount) { + OrderCargoEntity cargo = new OrderCargoEntity(); + cargo.setOrderId(orderId); cargo.setPackageCount(packageCount); + return cargo; + } +}