From dca3a48284b33acda1ef70f26c7e568a1049bb99 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 6 Aug 2026 13:56:20 +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 --- .../InternalInboundOrderController.java | 33 +++ .../firstleg/domain/InboundProcessStatus.java | 3 +- .../firstleg/dto/UnifiedInboundDetail.java | 14 ++ .../firstleg/dto/UnifiedInboundQuery.java | 27 +++ .../firstleg/dto/UnifiedInboundRequest.java | 17 ++ .../firstleg/dto/UnifiedInboundSummary.java | 23 ++ .../repository/InboundOrderRepository.java | 29 +++ .../UnifiedInboundReadRepository.java | 105 +++++++++ .../service/UnifiedInboundQueryService.java | 174 +++++++++++++++ ...alInboundOrderReferenceControllerTest.java | 17 ++ .../UnifiedInboundQueryServiceTest.java | 40 ++++ .../client/FirstlegUnifiedOrderClient.java | 57 +++++ .../MerchantUnifiedOrderController.java | 45 ++++ .../PlatformUnifiedOrderController.java | 50 +++++ .../baiyue/order/dto/UnifiedOrderQuery.java | 29 +++ .../order/dto/UnifiedOrderResponse.java | 45 ++++ .../order/repository/OrderRepository.java | 21 ++ .../service/UnifiedOrderQueryService.java | 206 ++++++++++++++++++ .../src/main/resources/openapi/order-v1.yaml | 115 +++++++++- .../OrderControllerPermissionTest.java | 13 ++ .../service/UnifiedOrderQueryServiceTest.java | 93 ++++++++ 21 files changed, 1154 insertions(+), 2 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundRequest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundSummary.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java create mode 100644 order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java create mode 100644 order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java create mode 100644 order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java create mode 100644 order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java create mode 100644 order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java create mode 100644 order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java create mode 100644 order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index dd550b6ac..71a1caa22 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -9,6 +9,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.OrderApprovedInboundCommand; import com.baiyue.firstleg.dto.InboundOrderReference; import com.baiyue.firstleg.service.InboundOrderCreationService; +import com.baiyue.firstleg.service.UnifiedInboundQueryService; +import com.baiyue.firstleg.dto.UnifiedInboundDetail; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; +import com.baiyue.firstleg.dto.UnifiedInboundRequest; +import com.baiyue.firstleg.dto.UnifiedInboundSummary; +import com.baiyue.common.core.api.PageResult; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -28,6 +34,8 @@ import java.security.MessageDigest; public class InternalInboundOrderController { @Resource private InboundOrderCreationService service; + @Resource + private UnifiedInboundQueryService unifiedQueryService; @Value("${baiyue.security.internal-service-token}") private String internalToken; private byte[] expectedToken; @@ -60,6 +68,31 @@ public class InternalInboundOrderController { request.tenantId(), request.orderIds().stream().distinct().toList())); } + @PostMapping("/unified-orders/query") + public ApiResponse> unifiedPage( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody UnifiedInboundQuery request) { + requireToken(token); + return ApiResponse.success(unifiedQueryService.page(request)); + } + + @PostMapping("/unified-orders/summaries") + public ApiResponse> unifiedSummaries( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody UnifiedInboundRequest.BatchRequest request) { + requireToken(token); + return ApiResponse.success(unifiedQueryService.summaries( + request.tenantId(), request.orderIds().stream().distinct().toList())); + } + + @PostMapping("/unified-orders/detail") + public ApiResponse unifiedDetail( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody UnifiedInboundRequest.DetailRequest request) { + requireToken(token); + return ApiResponse.success(unifiedQueryService.detail(request.tenantId(), request.orderId())); + } + private void requireToken(String token) { if (token == null || !MessageDigest.isEqual(expectedToken, token.getBytes(StandardCharsets.UTF_8))) { throw new BizException(ResultCode.FORBIDDEN, "内部服务凭证无效"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java index 9087c4414..7339ccc18 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/domain/InboundProcessStatus.java @@ -6,7 +6,8 @@ import java.util.Set; public final class InboundProcessStatus { public static final Set VALUES = Set.of( "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", - "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", "VOIDED" + "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED", + "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "VOIDED" ); private InboundProcessStatus() { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java new file mode 100644 index 000000000..e50938cac --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundDetail.java @@ -0,0 +1,14 @@ +package com.baiyue.firstleg.dto; + +import java.time.LocalDateTime; +import java.util.List; + +/** Detail payload for the product-level order detail. */ +public record UnifiedInboundDetail( + UnifiedInboundSummary summary, + List timeline +) { + public record TimelineEvent(String source, String eventType, String fromStatus, String toStatus, + String action, String remark, Long operatorId, String operatorName, + LocalDateTime occurredAt) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java new file mode 100644 index 000000000..ef8f7fcda --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java @@ -0,0 +1,27 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +/** Order service internal query for the product-level order line. */ +public record UnifiedInboundQuery( + Long tenantId, + @Size(max = 64) String keyword, + @Size(max = 64) String orderNo, + @Size(max = 32) String mainStatus, + Long customerId, + Long receivingWarehouseId, + Long destinationWarehouseId, + LocalDateTime createdFrom, + LocalDateTime createdTo, + @Min(1) long page, + @Min(1) @Max(200) long size +) { + public UnifiedInboundQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundRequest.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundRequest.java new file mode 100644 index 000000000..0eb3c0653 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundRequest.java @@ -0,0 +1,17 @@ +package com.baiyue.firstleg.dto; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** Internal request contracts used by Order service. */ +public final class UnifiedInboundRequest { + private UnifiedInboundRequest() {} + + public record BatchRequest(Long tenantId, + @NotEmpty @Size(max = 200) List<@Positive Long> orderIds) {} + + public record DetailRequest(@Positive long tenantId, @Positive long orderId) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundSummary.java new file mode 100644 index 000000000..1f3d3efee --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundSummary.java @@ -0,0 +1,23 @@ +package com.baiyue.firstleg.dto; + +import java.time.LocalDateTime; + +/** Product-level status and operational summaries returned to Order service. */ +public record UnifiedInboundSummary( + InboundOrderSummary inbound, + String mainStatus, + String nextAction, + PreloadSummary preload, + LoadingSummary loading, + DeliverySummary delivery +) { + public record PreloadSummary(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {} + /** + * loadingVersion is currently a compatibility alias of pickingVersion because no separate + * loading aggregate row exists. C04 loading complete must use inbound.version instead. + */ + public record LoadingSummary(String status, Integer loadingVersion, Integer pickingVersion, + Integer expectedCount, Integer pickedCount, Integer evidenceCount, + String sealNo, LocalDateTime updatedAt) {} + public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index f0aba181c..4582af4a1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; /** 入库单只读仓储;平台分页可跨企业查询,详情继续强制租户隔离。 */ @Repository @@ -103,6 +104,34 @@ public class InboundOrderRepository { .last("LIMIT 1")); } + /** Internal unified-query accessors. They always retain the tenant predicate when one is supplied. */ + public Page pageUnifiedEntities(UnifiedInboundQuery query, List processStatuses) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(query.tenantId() != null, InboundOrderEntity::getTenantId, query.tenantId()) + .in(processStatuses != null && !processStatuses.isEmpty(), InboundOrderEntity::getProcessStatus, processStatuses) + .like(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo()) + .and(StringUtils.hasText(query.keyword()), nested -> nested + .like(InboundOrderEntity::getOrderNo, query.keyword()) + .or().like(InboundOrderEntity::getInboundCode, query.keyword()) + .or().like(InboundOrderEntity::getCustomerName, query.keyword())) + .eq(query.customerId() != null, InboundOrderEntity::getCustomerId, query.customerId()) + .eq(query.receivingWarehouseId() != null, InboundOrderEntity::getWarehouseId, query.receivingWarehouseId()) + .eq(query.destinationWarehouseId() != null, InboundOrderEntity::getDeliveryWarehouseId, query.destinationWarehouseId()) + .ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo()) + .orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId); + return orders.selectPage(new Page<>(query.page(), query.size()), wrapper); + } + + public List findByOrderIds(Long tenantId, List orderIds) { + if (orderIds == null || orderIds.isEmpty()) return List.of(); + return orders.selectList(new LambdaQueryWrapper() + .eq(tenantId != null, InboundOrderEntity::getTenantId, tenantId) + .in(InboundOrderEntity::getOrderId, orderIds) + .orderByDesc(InboundOrderEntity::getCreatedAt)); + } + + public InboundOrderDetail detail(long tenantId, InboundOrderEntity order) { long inboundId = order.getId(); InboundTransportSnapshotEntity transport = transports.selectOne( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java new file mode 100644 index 000000000..798ab618a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/UnifiedInboundReadRepository.java @@ -0,0 +1,105 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; +import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; +import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper; +import com.baiyue.firstleg.loading.mapper.LoadingPickTaskMapper; +import com.baiyue.firstleg.split.entity.PreloadPlanEntity; +import com.baiyue.firstleg.split.mapper.PreloadPlanMapper; +import com.baiyue.firstleg.entity.InboundStatusHistoryEntity; +import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; +import com.baiyue.firstleg.mapper.InboundStatusHistoryMapper; +import com.baiyue.firstleg.mapper.InboundTransportSnapshotMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** Read-only access to phase-three operational summaries for unified order queries. */ +@Repository +public class UnifiedInboundReadRepository { + @Resource private PreloadPlanMapper preloads; + @Resource private LoadingPickTaskMapper picks; + @Resource private LoadingEvidenceMapper evidences; + @Resource private InboundStatusHistoryMapper histories; + @Resource private InboundTransportSnapshotMapper transports; + + public PreloadPlanEntity findPreload(long tenantId, long inboundId) { + return preloads.selectOne(new LambdaQueryWrapper() + .eq(PreloadPlanEntity::getTenantId, tenantId) + .eq(PreloadPlanEntity::getInboundId, inboundId)); + } + + public LoadingPickTaskEntity findPick(long tenantId, long inboundId) { + return picks.selectOne(new LambdaQueryWrapper() + .eq(LoadingPickTaskEntity::getTenantId, tenantId) + .eq(LoadingPickTaskEntity::getInboundId, inboundId)); + } + + public List findEvidence(long tenantId, long inboundId) { + return evidences.selectList(new LambdaQueryWrapper() + .eq(LoadingEvidenceEntity::getTenantId, tenantId) + .eq(LoadingEvidenceEntity::getInboundId, inboundId) + .orderByAsc(LoadingEvidenceEntity::getEvidenceType)); + } + + public Map findPreloads(Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return preloads.selectList(new LambdaQueryWrapper() + .in(PreloadPlanEntity::getInboundId, tenantByInbound.keySet())) + .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.toMap(PreloadPlanEntity::getInboundId, Function.identity(), (left, right) -> left)); + } + + public Map findPicks(Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return picks.selectList(new LambdaQueryWrapper() + .in(LoadingPickTaskEntity::getInboundId, tenantByInbound.keySet())) + .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.toMap(LoadingPickTaskEntity::getInboundId, Function.identity(), (left, right) -> left)); + } + + public Map> findEvidence(Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return evidences.selectList(new LambdaQueryWrapper() + .in(LoadingEvidenceEntity::getInboundId, tenantByInbound.keySet()) + .orderByAsc(LoadingEvidenceEntity::getEvidenceType)) + .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.groupingBy(LoadingEvidenceEntity::getInboundId)); + } + + public Map findDeliveryHistory(Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return histories.selectList(new LambdaQueryWrapper() + .in(InboundStatusHistoryEntity::getInboundId, tenantByInbound.keySet()) + .and(nested -> nested.eq(InboundStatusHistoryEntity::getAction, "COMPLETE_DELIVERY") + .or().eq(InboundStatusHistoryEntity::getToStatus, "DELIVERED")) + .orderByDesc(InboundStatusHistoryEntity::getOccurredAt)) + .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.toMap(InboundStatusHistoryEntity::getInboundId, + Function.identity(), (latest, ignored) -> latest)); + } + + public InboundTransportSnapshotEntity findTransport(long tenantId, long inboundId) { + return transports.selectOne(new LambdaQueryWrapper() + .eq(InboundTransportSnapshotEntity::getTenantId, tenantId) + .eq(InboundTransportSnapshotEntity::getInboundId, inboundId)); + } + + public Map findTransports(Map tenantByInbound) { + if (tenantByInbound.isEmpty()) return Map.of(); + return transports.selectList(new LambdaQueryWrapper() + .in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet())) + .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId, + Function.identity(), (left, right) -> left)); + } + + private static boolean owns(Map tenantByInbound, Long inboundId, Long tenantId) { + return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java new file mode 100644 index 000000000..fff815a8d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -0,0 +1,174 @@ +package com.baiyue.firstleg.service; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.firstleg.dto.InboundOrderDetail; +import com.baiyue.firstleg.dto.UnifiedInboundDetail; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; +import com.baiyue.firstleg.dto.UnifiedInboundSummary; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; +import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; +import com.baiyue.firstleg.repository.InboundOrderRepository; +import com.baiyue.firstleg.repository.UnifiedInboundReadRepository; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** Read-only adapter from internal inbound states to the seven product main statuses. */ +@Service +public class UnifiedInboundQueryService { + private static final Set MAIN_STATUSES = Set.of( + "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + + @Resource private InboundOrderRepository inbounds; + @Resource private UnifiedInboundReadRepository operations; + @Resource private ObjectMapper json; + + public PageResult page(UnifiedInboundQuery query) { + validate(query); + if ("PENDING_REVIEW".equals(query.mainStatus())) { + return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); + } + Page source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus())); + List records = summaries(source.getRecords()); + return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages()); + } + + public List summaries(Long tenantId, List orderIds) { + Map byOrder = new LinkedHashMap<>(); + for (UnifiedInboundSummary item : summaries(inbounds.findByOrderIds(tenantId, orderIds))) + byOrder.putIfAbsent(item.inbound().orderId(), item); + return orderIds.stream().distinct().map(byOrder::get).filter(java.util.Objects::nonNull).toList(); + } + + public UnifiedInboundDetail detail(long tenantId, long orderId) { + InboundOrderEntity inbound = inbounds.findByOrderId(tenantId, orderId); + if (inbound == null) return null; + InboundOrderDetail detail = inbounds.detail(tenantId, inbound); + List timeline = detail.statusHistory().stream() + .map(item -> new UnifiedInboundDetail.TimelineEvent( + "FIRSTLEG", "STATUS", item.fromStatus(), item.toStatus(), item.action(), item.reason(), + item.operatorId(), item.operatorName(), item.occurredAt())) + .toList(); + return new UnifiedInboundDetail(summary(inbound, detail, + operations.findPreload(tenantId, inbound.getId()), operations.findPick(tenantId, inbound.getId()), + operations.findEvidence(tenantId, inbound.getId()), deliveryAt(detail.statusHistory()), + operations.findTransport(tenantId, inbound.getId())), timeline); + } + + private List summaries(List records) { + Map owners = records.stream().collect(Collectors.toMap( + InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left)); + var preloads = operations.findPreloads(owners); + var picks = operations.findPicks(owners); + var evidences = operations.findEvidence(owners); + var deliveries = operations.findDeliveryHistory(owners); + var transports = operations.findTransports(owners); + return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()), + picks.get(inbound.getId()), evidences.getOrDefault(inbound.getId(), List.of()), + deliveries.get(inbound.getId()) == null ? null : deliveries.get(inbound.getId()).getOccurredAt(), + transports.get(inbound.getId()))).toList(); + } + + private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail, + com.baiyue.firstleg.split.entity.PreloadPlanEntity preload, + com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity pick, + List evidence, LocalDateTime deliveryAt, + InboundTransportSnapshotEntity transport) { + String mainStatus = mainStatus(inbound.getProcessStatus()); + String sealNo = evidence.stream().filter(item -> "SEAL".equals(item.getEvidenceType())) + .map(LoadingEvidenceEntity::getSealNo).filter(StringUtils::hasText).findFirst().orElse(null); + var preloadSummary = preload == null ? null : new UnifiedInboundSummary.PreloadSummary( + preload.getStatus(), preload.getVersion(), itemCount(preload.getItemsJson()), preload.getUpdatedAt()); + var loadingSummary = pick == null && evidence.isEmpty() ? null : new UnifiedInboundSummary.LoadingSummary( + pick == null ? null : pick.getStatus(), pick == null ? null : pick.getVersion(), + pick == null ? null : pick.getVersion(), pick == null ? null : pick.getExpectedCount(), + pick == null ? null : pick.getPickedCount(), evidence.size(), sealNo, + pick == null ? evidence.stream().map(LoadingEvidenceEntity::getUpdatedAt) + .filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null) : pick.getUpdatedAt()); + var deliverySummary = "LOADED".equals(mainStatus) || "DELIVERED".equals(mainStatus) + ? new UnifiedInboundSummary.DeliverySummary( + "DELIVERED".equals(mainStatus) ? "COMPLETED" : "PENDING", deliveryAt, + inbound.getExpressTrackingNo()) : null; + var inboundSummary = detail == null + ? com.baiyue.firstleg.dto.InboundOrderSummary.from(inbound, transport, null) : detail.summary(); + return new UnifiedInboundSummary(inboundSummary, mainStatus, + nextAction(mainStatus, inbound.getProcessStatus(), preload == null ? null : preload.getStatus()), + preloadSummary, loadingSummary, deliverySummary); + } + + private LocalDateTime deliveryAt(List history) { + return history.stream() + .filter(item -> "COMPLETE_DELIVERY".equals(item.action()) || "DELIVERED".equals(item.toStatus())) + .map(InboundOrderDetail.StatusHistorySummary::occurredAt) + .filter(java.util.Objects::nonNull).max(LocalDateTime::compareTo).orElse(null); + } + + static String mainStatus(String status) { + if (status == null) return null; + return switch (status) { + case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE"; + case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT"; + case "INBOUNDED" -> "INBOUNDED"; + case "LOADING", "LOADED", "DELIVERED" -> status; + default -> null; + }; + } + + static String nextAction(String mainStatus, String processStatus, String preloadStatus) { + if (mainStatus == null) return null; + return switch (mainStatus) { + case "PENDING_REVIEW" -> "APPROVE_ORDER"; + case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null; + case "WAIT_SPLIT" -> !"WAIT_SPLIT".equals(processStatus) ? null + : "CONFIRMED".equals(preloadStatus) ? null + : preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD"; + case "LOADING" -> "COMPLETE_LOADING"; + case "LOADED" -> "COMPLETE_DELIVERY"; + case "DELIVERED" -> null; + default -> null; + }; + } + + private List internalStatuses(String mainStatus) { + if (!StringUtils.hasText(mainStatus)) return List.of(); + return switch (mainStatus) { + case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED"); + case "WAIT_SPLIT" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT"); + case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus); + default -> List.of("__NO_MATCH__"); + }; + } + + private int itemCount(String itemsJson) { + if (!StringUtils.hasText(itemsJson)) return 0; + try { + JsonNode node = json.readTree(itemsJson); + return node.isArray() ? node.size() : 0; + } catch (Exception ignored) { + return 0; + } + } + + private void validate(UnifiedInboundQuery query) { + if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法"); + } + if (query.createdFrom() != null && query.createdTo() != null + && query.createdFrom().isAfter(query.createdTo())) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "创建时间起点不能晚于终点"); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index 6413d93f1..020bb429e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -3,6 +3,9 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.dto.InboundOrderReference; import com.baiyue.firstleg.service.InboundOrderCreationService; +import com.baiyue.firstleg.service.UnifiedInboundQueryService; +import com.baiyue.firstleg.dto.UnifiedInboundQuery; +import com.baiyue.common.core.api.PageResult; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; @@ -22,6 +25,8 @@ import static org.mockito.Mockito.when; class InternalInboundOrderReferenceControllerTest { @MockBean private InboundOrderCreationService service; + @MockBean + private UnifiedInboundQueryService unifiedQueryService; @Resource private InternalInboundOrderController controller; @@ -44,4 +49,16 @@ class InternalInboundOrderReferenceControllerTest { assertThatThrownBy(() -> controller.references("bad-token", request)) .isInstanceOf(BizException.class); } + + @Test + void unifiedQueryRequiresInternalTokenAndForwardsBatchRequest() { + UnifiedInboundQuery request = new UnifiedInboundQuery( + 10001L, null, null, "WAIT_SPLIT", null, null, null, null, null, 1, 20); + when(unifiedQueryService.page(request)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); + + assertThat(controller.unifiedPage("internal-token", request).data().records()).isEmpty(); + assertThatThrownBy(() -> controller.unifiedPage("bad-token", request)) + .isInstanceOf(BizException.class); + verify(unifiedQueryService).page(request); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java new file mode 100644 index 000000000..983912356 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -0,0 +1,40 @@ +package com.baiyue.firstleg.service; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class UnifiedInboundQueryServiceTest { + @Test + void mapsAllCompatibleInternalStatesToProductMainStatuses() { + assertThat(UnifiedInboundQueryService.mainStatus("WAIT_MEASURE")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("MEASURING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_PENDING")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("MEASURE_REJECTED")).isEqualTo("WAIT_MEASURE"); + assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("WAIT_SPLIT"); + assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("WAIT_SPLIT"); + assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("WAIT_SPLIT"); + assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED"); + assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING"); + assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED"); + assertThat(UnifiedInboundQueryService.mainStatus("DELIVERED")).isEqualTo("DELIVERED"); + assertThat(UnifiedInboundQueryService.mainStatus("VOIDED")).isNull(); + } + + @Test + void nextActionReflectsExecutableMeasurementAndPreloadSubstates() { + assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURING", null)).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_MEASURE", "MEASURE_PENDING", null)) + .isEqualTo("CONFIRM_MEASUREMENT"); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null)) + .isEqualTo("SMART_SPLIT"); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "PENDING")) + .isEqualTo("CONFIRM_PRELOAD"); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "INBOUNDING", null)).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", "CONFIRMED")).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", null)).isNull(); + assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null)) + .isEqualTo("COMPLETE_DELIVERY"); + assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull(); + } +} diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java new file mode 100644 index 000000000..8a4846002 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -0,0 +1,57 @@ +package com.baiyue.order.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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** Typed internal read contract from Order service to Firstleg service. */ +@FeignClient(name = "firstleg-service", contextId = "orderFirstlegUnifiedOrderClient", + url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/inbounds/unified-orders") +public interface FirstlegUnifiedOrderClient { + @PostMapping("/query") + ApiResponse> page(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody Query request); + + @PostMapping("/summaries") + ApiResponse> summaries(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody BatchRequest request); + + @PostMapping("/detail") + ApiResponse detail(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody DetailRequest request); + + record Query(Long tenantId, String keyword, String orderNo, String mainStatus, Long customerId, + Long receivingWarehouseId, Long destinationWarehouseId, + LocalDateTime createdFrom, LocalDateTime createdTo, long page, long size) {} + record BatchRequest(Long tenantId, List orderIds) {} + record DetailRequest(long tenantId, long orderId) {} + + record Summary(Inbound inbound, String mainStatus, String nextAction, + Preload preload, Loading loading, Delivery delivery) {} + record Detail(Summary summary, List timeline) {} + record Inbound(Long id, Long tenantId, Long orderId, String orderNo, String inboundCode, + String processStatus, String orderStatusSnapshot, String measureReviewStatus, + Integer version, + Long customerId, String customerName, Long warehouseId, String warehouseName, + Long deliveryWarehouseId, String deliveryWarehouseName, String channelName, + Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, + BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt, + String loadingStatus, String containerNo, String cabinetNo, String vesselName, String voyageNo, + LocalDateTime sailingAt, LocalDateTime portArrivalAt, + String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {} + record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {} + record Loading(String status, Integer loadingVersion, Integer pickingVersion, + Integer expectedCount, Integer pickedCount, Integer evidenceCount, + String sealNo, LocalDateTime updatedAt) {} + record Delivery(String status, LocalDateTime completedAt, String trackingNo) {} + record Timeline(String source, String eventType, String fromStatus, String toStatus, + String action, String remark, Long operatorId, String operatorName, + LocalDateTime occurredAt) {} +} diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java new file mode 100644 index 000000000..6ec107ab9 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/MerchantUnifiedOrderController.java @@ -0,0 +1,45 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderResponse; +import com.baiyue.order.service.OrderScopeGuard; +import com.baiyue.order.service.UnifiedOrderQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Merchant product-level order query restricted to the authenticated customer. */ +@Tag(name = "Merchant unified orders / 客户统一订单") +@RestController +@RequestMapping("/api/merchant/v1/orders") +public class MerchantUnifiedOrderController { + @Resource private UnifiedOrderQueryService service; + + @GetMapping + @PreAuthorize("hasAuthority('merchant:order:list')") + @Operation(summary = "我的统一订单列表") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @Valid UnifiedOrderQuery query) { + return ApiResponse.success(service.page(OrderScopeGuard.merchant(user), query)); + } + + @GetMapping("/{orderId}") + @PreAuthorize("hasAuthority('merchant:order:view')") + @Operation(summary = "我的统一订单详情", description = "仅返回当前客户订单,不包含平台内部备注。") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long orderId) { + return ApiResponse.success(service.detail(OrderScopeGuard.merchant(user), orderId, false)); + } +} diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java new file mode 100644 index 000000000..30a503f59 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -0,0 +1,50 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderResponse; +import com.baiyue.order.service.OrderScopeGuard; +import com.baiyue.order.service.UnifiedOrderQueryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** Platform product-level order query. Legacy first-leg paths remain available. */ +@Tag(name = "Platform unified orders / 平台统一订单") +@RestController +@RequestMapping("/api/platform/v1/orders") +public class PlatformUnifiedOrderController { + @Resource private UnifiedOrderQueryService service; + + @GetMapping + @PreAuthorize("hasAuthority('platform:order:list')") + @Operation(summary = "统一订单列表", description = "返回七态产品主状态及测量、预装、装柜、派送摘要。") + public ApiResponse> page( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) Long tenantId, + @Valid UnifiedOrderQuery query) { + OrderScopeGuard.platform(user); + if (tenantId != null) OrderScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.page(tenantId, query)); + } + + @GetMapping("/{orderId}") + @PreAuthorize("hasAuthority('platform:order:list')") + @Operation(summary = "统一订单详情", description = "合并订单和履约时间线;平台响应包含内部备注。") + public ApiResponse detail( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam long tenantId, + @PathVariable long orderId) { + return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), orderId, true)); + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java new file mode 100644 index 000000000..1141db65a --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java @@ -0,0 +1,29 @@ +package com.baiyue.order.dto; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +/** Unified product order list filters shared by platform and merchant entry points. */ +public record UnifiedOrderQuery( + @Size(max = 64) String keyword, + @Size(max = 64) String orderNo, + @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", + "LOADING", "LOADED", "DELIVERED"}) + @Size(max = 32) String mainStatus, + Long customerId, + Long receivingWarehouseId, + Long destinationWarehouseId, + LocalDateTime createdFrom, + LocalDateTime createdTo, + @Min(1) long page, + @Min(1) @Max(200) long size +) { + public UnifiedOrderQuery { + if (page == 0) page = 1; + if (size == 0) size = 20; + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java new file mode 100644 index 000000000..46544709c --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderResponse.java @@ -0,0 +1,45 @@ +package com.baiyue.order.dto; + +import com.baiyue.order.entity.OrderStatus; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** Stable product-level order list/detail responses. */ +public final class UnifiedOrderResponse { + private UnifiedOrderResponse() {} + + public record Row( + Long orderId, Long tenantId, String orderNo, String mainStatus, String nextAction, + OrderStatus internalOrderStatus, + Long customerId, String customerName, String channelName, + Long receivingWarehouseId, String receivingWarehouseName, + Long destinationWarehouseId, String destinationWarehouseName, + Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate, + Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm, + MeasurementSummary measurement, PreloadSummary preload, + LoadingSummary loading, DeliverySummary delivery, TransportSummary transport, + LocalDateTime createdAt, LocalDateTime updatedAt + ) {} + + public record Detail(Row summary, OrderDetailResponse order, + List timeline) {} + + public record MeasurementSummary(String reviewStatus, BigDecimal actualGrossWeightKg, + BigDecimal actualVolumeCbm, BigDecimal chargeableWeightKg, + LocalDateTime completedAt) {} + public record PreloadSummary(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {} + /** loadingVersion currently aliases pickingVersion; inboundVersion is the C04 loading expectedVersion. */ + public record LoadingSummary(String status, Integer loadingVersion, Integer pickingVersion, + Integer expectedCount, Integer pickedCount, Integer evidenceCount, + String sealNo, LocalDateTime updatedAt) {} + public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {} + public record TransportSummary(String loadingStatus, String containerNo, String cabinetNo, + String vesselName, String voyageNo, LocalDateTime sailingAt, + LocalDateTime portArrivalAt) {} + public record TimelineEvent(String source, String eventType, String mainStatus, String action, + String remark, Long operatorId, String operatorName, + LocalDateTime occurredAt) {} +} 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 f018fdd4c..14a70363f 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -5,6 +5,7 @@ import jakarta.annotation.Resource; import com.baiyue.common.core.api.PageResult; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.MerchantInboundOrderQuery; +import com.baiyue.order.dto.UnifiedOrderQuery; import com.baiyue.order.entity.*; import com.baiyue.order.mapper.*; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -154,6 +155,26 @@ public class OrderRepository { return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); } + public PageResult unifiedPage(Long tenantId, UnifiedOrderQuery query, + List statuses) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(tenantId != null, OrderEntity::getTenantId, tenantId) + .in(OrderEntity::getStatus, statuses) + .like(StringUtils.hasText(query.orderNo()), OrderEntity::getOrderNo, query.orderNo()) + .eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId()) + .eq(query.receivingWarehouseId() != null, OrderEntity::getReceivingWarehouseId, + query.receivingWarehouseId()) + .eq(query.destinationWarehouseId() != null, OrderEntity::getDestinationWarehouseId, + query.destinationWarehouseId()) + .ge(query.createdFrom() != null, OrderEntity::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, OrderEntity::getCreatedAt, query.createdTo()) + .and(StringUtils.hasText(query.keyword()), nested -> nested + .like(OrderEntity::getOrderNo, query.keyword()) + .or().like(OrderEntity::getCustomerName, query.keyword())) + .orderByDesc(OrderEntity::getCreatedAt).orderByDesc(OrderEntity::getId); + return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); + } + public List findCargoByOrderIds(long tenantId, List orderIds) { if (orderIds.isEmpty()) return List.of(); return cargoMapper.selectList(new LambdaQueryWrapper() diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java new file mode 100644 index 000000000..7f94d077e --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -0,0 +1,206 @@ +package com.baiyue.order.service; + +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.client.FirstlegUnifiedOrderClient; +import com.baiyue.order.dto.OrderDetailResponse; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderResponse; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.repository.OrderRepository; +import feign.FeignException; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** Product-level read façade. Existing Order and Firstleg state machines stay unchanged. */ +@Service +public class UnifiedOrderQueryService { + private static final Set MAIN_STATUSES = Set.of( + "PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); + private static final List ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of( + OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, + OrderStatus.PROCESSING, OrderStatus.COMPLETED)); + + @Resource private OrderRepository orders; + @Resource private FirstlegUnifiedOrderClient firstleg; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public PageResult page(Long tenantId, UnifiedOrderQuery query) { + validate(query); + if (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus())) { + return pageFromFirstleg(tenantId, query); + } + List statuses = "PENDING_REVIEW".equals(query.mainStatus()) + ? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES; + PageResult source = orders.unifiedPage(tenantId, query, statuses); + Map inboundByOrder = summaries( + tenantId, source.records().stream().map(OrderEntity::getId).toList()); + List rows = source.records().stream() + .map(order -> row(order, inboundByOrder.get(order.getId()))).toList(); + return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages()); + } + + public UnifiedOrderResponse.Detail detail(long tenantId, long orderId, boolean includeInternalNotes) { + OrderEntity order = orders.findOwned(tenantId, orderId); + if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在"); + OrderDetailResponse orderDetail = new OrderDetailResponse(order, orders.findCargo(tenantId, orderId), + orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(), + orders.findHistory(tenantId, orderId)); + FirstlegUnifiedOrderClient.Detail inbound = firstlegDetail(tenantId, orderId); + UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary()); + List timeline = new ArrayList<>(); + orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( + "ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(), + item.getOperatorId(), item.getOperatorName(), item.getOccurredAt()))); + if (inbound != null && inbound.timeline() != null) { + inbound.timeline().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent( + item.source(), item.eventType(), inboundMainStatus(item.toStatus()), item.action(), item.remark(), + item.operatorId(), item.operatorName(), item.occurredAt()))); + } + timeline.sort(Comparator.comparing(UnifiedOrderResponse.TimelineEvent::occurredAt, + Comparator.nullsLast(Comparator.naturalOrder()))); + return new UnifiedOrderResponse.Detail(summary, orderDetail, timeline); + } + + private PageResult pageFromFirstleg(Long tenantId, UnifiedOrderQuery query) { + FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query( + tenantId, query.keyword(), query.orderNo(), query.mainStatus(), query.customerId(), + query.receivingWarehouseId(), query.destinationWarehouseId(), query.createdFrom(), query.createdTo(), + query.page(), query.size()); + PageResult source = require( + call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效"); + List records = source.records().stream().map(this::row).toList(); + return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); + } + + private Map summaries(Long tenantId, List orderIds) { + if (orderIds.isEmpty()) return Map.of(); + List data = require( + call(() -> firstleg.summaries(internalToken, + new FirstlegUnifiedOrderClient.BatchRequest(tenantId, orderIds))), + "统一订单履约摘要响应无效"); + return data.stream().collect(Collectors.toMap(item -> item.inbound().orderId(), + Function.identity(), (left, right) -> left)); + } + + private FirstlegUnifiedOrderClient.Detail firstlegDetail(long tenantId, long orderId) { + return requireNullable(call(() -> firstleg.detail(internalToken, + new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效"); + } + + private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary) { + if (summary == null) { + String mainStatus = orderMainStatus(order.getStatus()); + return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus, + order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null, + order.getStatus(), order.getCustomerId(), order.getCustomerName(), + order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), + order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, null, + order.getExpectedShipDate(), null, null, null, null, null, null, null, null, + order.getCreatedAt(), order.getUpdatedAt()); + } + FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound(); + return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), + summary.mainStatus(), summary.nextAction(), order.getStatus(), order.getCustomerId(), order.getCustomerName(), + order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(), + order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(), + order.getExpectedShipDate(), inbound.totalPackageCount(), inbound.actualGrossWeightKg(), + inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), + delivery(summary.delivery()), transport(inbound), order.getCreatedAt(), order.getUpdatedAt()); + } + + private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary) { + var inbound = summary.inbound(); + return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(), + summary.mainStatus(), summary.nextAction(), parseOrderStatus(inbound.orderStatusSnapshot()), + inbound.customerId(), inbound.customerName(), inbound.channelName(), inbound.warehouseId(), + inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(), + inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), inbound.actualGrossWeightKg(), + inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()), + delivery(summary.delivery()), transport(inbound), inbound.createdAt(), inbound.updatedAt()); + } + + private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) { + return new UnifiedOrderResponse.MeasurementSummary(inbound.measureReviewStatus(), + inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.chargeableWeightKg(), + inbound.measureCompletedAt()); + } + private UnifiedOrderResponse.PreloadSummary preload(FirstlegUnifiedOrderClient.Preload value) { + return value == null ? null : new UnifiedOrderResponse.PreloadSummary( + value.status(), value.version(), value.itemCount(), value.updatedAt()); + } + private UnifiedOrderResponse.LoadingSummary loading(FirstlegUnifiedOrderClient.Loading value) { + return value == null ? null : new UnifiedOrderResponse.LoadingSummary(value.status(), value.loadingVersion(), + value.pickingVersion(), value.expectedCount(), value.pickedCount(), value.evidenceCount(), + value.sealNo(), value.updatedAt()); + } + private UnifiedOrderResponse.DeliverySummary delivery(FirstlegUnifiedOrderClient.Delivery value) { + return value == null ? null : new UnifiedOrderResponse.DeliverySummary( + value.status(), value.completedAt(), value.trackingNo()); + } + private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) { + return new UnifiedOrderResponse.TransportSummary(inbound.loadingStatus(), inbound.containerNo(), + inbound.cabinetNo(), inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt()); + } + + private String orderMainStatus(OrderStatus status) { + if (status == null) return null; + return status == OrderStatus.SUBMITTED ? "PENDING_REVIEW" + : ACTIVE_ORDER_STATUSES.contains(status) ? "WAIT_MEASURE" : null; + } + private String inboundMainStatus(String status) { + if (status == null) return null; + return switch (status) { + case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE"; + case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT"; + case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> status; + default -> null; + }; + } + private OrderStatus parseOrderStatus(String value) { + try { return value == null ? null : OrderStatus.valueOf(value); } + catch (IllegalArgumentException ignored) { return null; } + } + + private void validate(UnifiedOrderQuery query) { + if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())) { + throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法"); + } + if (query.createdFrom() != null && query.createdTo() != null + && query.createdFrom().isAfter(query.createdTo())) { + throw new BizException(ResultCode.BAD_REQUEST, "创建时间起点不能晚于终点"); + } + } + + private ApiResponse call(java.util.concurrent.Callable> invocation) { + try { return invocation.call(); } + catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); } + catch (Exception exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败"); } + } + private T require(ApiResponse response, String message) { + if (response == null || !response.successful() || response.data() == null) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message); + } + return response.data(); + } + private T requireNullable(ApiResponse response, String message) { + if (response == null || !response.successful()) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message); + } + return response.data(); + } +} diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index a7ae00244..e550ba556 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -2,10 +2,74 @@ openapi: 3.0.3 info: title: 柏悦头程订单 API description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。 - version: 1.1.0 + version: 1.2.0 security: - bearerAuth: [] paths: + /api/platform/v1/orders: + get: + summary: 平台统一订单列表 + description: >- + 统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。 + 有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion + 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 + tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, + 指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。 + parameters: + - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedKeyword' + - $ref: '#/components/parameters/UnifiedOrderNo' + - $ref: '#/components/parameters/UnifiedMainStatus' + - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedCreatedFrom' + - $ref: '#/components/parameters/UnifiedCreatedTo' + - $ref: '#/components/parameters/UnifiedPage' + - $ref: '#/components/parameters/UnifiedSize' + responses: + '200': {description: 成功或业务失败,响应体 code 为字符串 200 时成功} + '403': {description: 需要平台身份和 platform:order:list 权限} + /api/platform/v1/orders/{orderId}: + get: + summary: 平台统一订单详情 + description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;平台可见内部备注。 + parameters: + - $ref: '#/components/parameters/UnifiedOrderId' + - $ref: '#/components/parameters/UnifiedTenantId' + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 需要平台身份和 platform:order:list 权限} + /api/merchant/v1/orders: + get: + summary: 客户统一订单列表 + description: >- + 只按登录态客户隔离查询,需要 merchant:order:list;不接受目标客户参数。 + 有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion + 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 + parameters: + - $ref: '#/components/parameters/UnifiedKeyword' + - $ref: '#/components/parameters/UnifiedOrderNo' + - $ref: '#/components/parameters/UnifiedMainStatus' + - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedCreatedFrom' + - $ref: '#/components/parameters/UnifiedCreatedTo' + - $ref: '#/components/parameters/UnifiedPage' + - $ref: '#/components/parameters/UnifiedSize' + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 需要 merchant:order:list 权限} + /api/merchant/v1/orders/{orderId}: + get: + summary: 客户统一订单详情 + description: 仅返回登录客户所属订单及客户可见时间线,不返回平台内部备注。 + parameters: + - $ref: '#/components/parameters/UnifiedOrderId' + responses: + '200': {description: 成功或业务失败,按响应体 code 判断} + '403': {description: 需要 merchant:order:view 权限} /api/merchant/v1/orders/first-leg: get: summary: 客户端入库订单聚合列表 @@ -119,6 +183,55 @@ components: scheme: bearer bearerFormat: JWT parameters: + UnifiedKeyword: + name: keyword + in: query + required: false + schema: {type: string, maxLength: 64} + UnifiedOrderNo: + name: orderNo + in: query + required: false + schema: {type: string, maxLength: 64} + UnifiedMainStatus: + name: mainStatus + in: query + required: false + schema: + type: string + enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + UnifiedCreatedFrom: + name: createdFrom + in: query + required: false + description: 无主状态或待审核筛选按订单创建时间;其他主状态按履约快照创建时间 + schema: {type: string, format: date-time} + UnifiedCreatedTo: + name: createdTo + in: query + required: false + description: 无主状态或待审核筛选按订单创建时间;其他主状态按履约快照创建时间 + schema: {type: string, format: date-time} + UnifiedPage: + name: page + in: query + required: false + schema: {type: integer, minimum: 1, default: 1} + UnifiedSize: + name: size + in: query + required: false + schema: {type: integer, minimum: 1, maximum: 200, default: 20} + UnifiedOrderId: + name: orderId + in: path + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} + UnifiedTenantId: + name: tenantId + in: query + required: true + schema: {type: string, pattern: '^[1-9][0-9]*$'} TenantId: name: tenantId in: query diff --git a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java index 5b6addeff..03870fd24 100644 --- a/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java +++ b/order/src/test/java/com/baiyue/order/controller/OrderControllerPermissionTest.java @@ -3,6 +3,7 @@ package com.baiyue.order.controller; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.MerchantInboundOrderQuery; +import com.baiyue.order.dto.UnifiedOrderQuery; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; @@ -42,6 +43,18 @@ class OrderControllerPermissionTest { AuthenticatedUser.class, long.class, long.class, String.class); } + @Test + void unifiedOrderQueriesReuseExistingReadPermissions() throws Exception { + assertAuthority(PlatformUnifiedOrderController.class, "page", "hasAuthority('platform:order:list')", + AuthenticatedUser.class, Long.class, UnifiedOrderQuery.class); + assertAuthority(PlatformUnifiedOrderController.class, "detail", "hasAuthority('platform:order:list')", + AuthenticatedUser.class, long.class, long.class); + assertAuthority(MerchantUnifiedOrderController.class, "page", "hasAuthority('merchant:order:list')", + AuthenticatedUser.class, UnifiedOrderQuery.class); + assertAuthority(MerchantUnifiedOrderController.class, "detail", "hasAuthority('merchant:order:view')", + AuthenticatedUser.class, long.class); + } + private void assertAuthority(Class controller, String methodName, String expected, Class... parameterTypes) throws NoSuchMethodException { Method method = controller.getDeclaredMethod(methodName, parameterTypes); diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java new file mode 100644 index 000000000..b42e01194 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -0,0 +1,93 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.order.client.FirstlegUnifiedOrderClient; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.entity.OrderEntity; +import com.baiyue.order.entity.OrderStatus; +import com.baiyue.order.repository.OrderRepository; +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.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SpringJUnitConfig(UnifiedOrderQueryService.class) +@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token") +class UnifiedOrderQueryServiceTest { + @MockBean private OrderRepository orders; + @MockBean private FirstlegUnifiedOrderClient firstleg; + @Resource private UnifiedOrderQueryService service; + + @Test + void pendingReviewIsPagedFromOrderAndMappedToApproveAction() { + UnifiedOrderQuery query = query("PENDING_REVIEW"); + OrderEntity order = order(OrderStatus.SUBMITTED); + when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED)))) + .thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1)); + when(firstleg.summaries(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(List.of())); + + var result = service.page(10001L, query); + + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW"); + assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER"); + }); + } + + @Test + void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() { + UnifiedOrderQuery query = query("WAIT_SPLIT"); + var summary = summary("WAIT_SPLIT", "CONFIRM_PRELOAD"); + when(firstleg.page(eq("internal-token"), any())) + .thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + + var result = service.page(null, query); + + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(row.nextAction()).isEqualTo("CONFIRM_PRELOAD"); + assertThat(row.inboundId()).isEqualTo(88L); + assertThat(row.inboundVersion()).isEqualTo(7); + assertThat(row.loading().loadingVersion()).isEqualTo(3); + assertThat(row.loading().pickingVersion()).isEqualTo(3); + assertThat(row.inboundCode()).isEqualTo("RK-77"); + }); + verify(orders, never()).unifiedPage(any(), any(), any()); + } + + private UnifiedOrderQuery query(String status) { + return new UnifiedOrderQuery(null, null, status, null, null, null, null, null, 1, 20); + } + + private OrderEntity order(OrderStatus status) { + OrderEntity order = new OrderEntity(); + order.setId(77L); order.setTenantId(10001L); order.setOrderNo("FL-77"); + order.setStatus(status); order.setCustomerId(30001L); order.setCustomerName("测试客户"); + order.setCreatedAt(LocalDateTime.of(2026, 8, 6, 10, 0)); + return order; + } + + private FirstlegUnifiedOrderClient.Summary summary(String status, String action) { + var inbound = new FirstlegUnifiedOrderClient.Inbound( + 88L, 10001L, 77L, "FL-77", "RK-77", "WAIT_SPLIT", "APPROVED", "APPROVED", 7, + 30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, null, null, null, + null, null, null, null, null, null, null, null, null, + LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0)); + return new FirstlegUnifiedOrderClient.Summary(inbound, status, action, + new FirstlegUnifiedOrderClient.Preload("PENDING", 1, 2, null), + new FirstlegUnifiedOrderClient.Loading("IN_PROGRESS", 3, 3, 10, 5, 1, "SEAL-1", null), null); + } +}