优化
This commit is contained in:
+33
@@ -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<PageResult<UnifiedInboundSummary>> 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<List<UnifiedInboundSummary>> 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<UnifiedInboundDetail> 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, "内部服务凭证无效");
|
||||
|
||||
@@ -6,7 +6,8 @@ import java.util.Set;
|
||||
public final class InboundProcessStatus {
|
||||
public static final Set<String> 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() {
|
||||
|
||||
@@ -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<TimelineEvent> timeline
|
||||
) {
|
||||
public record TimelineEvent(String source, String eventType, String fromStatus, String toStatus,
|
||||
String action, String remark, Long operatorId, String operatorName,
|
||||
LocalDateTime occurredAt) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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<InboundOrderEntity> pageUnifiedEntities(UnifiedInboundQuery query, List<String> processStatuses) {
|
||||
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.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<InboundOrderEntity> findByOrderIds(Long tenantId, List<Long> orderIds) {
|
||||
if (orderIds == null || orderIds.isEmpty()) return List.of();
|
||||
return orders.selectList(new LambdaQueryWrapper<InboundOrderEntity>()
|
||||
.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(
|
||||
|
||||
+105
@@ -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<PreloadPlanEntity>()
|
||||
.eq(PreloadPlanEntity::getTenantId, tenantId)
|
||||
.eq(PreloadPlanEntity::getInboundId, inboundId));
|
||||
}
|
||||
|
||||
public LoadingPickTaskEntity findPick(long tenantId, long inboundId) {
|
||||
return picks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||
.eq(LoadingPickTaskEntity::getTenantId, tenantId)
|
||||
.eq(LoadingPickTaskEntity::getInboundId, inboundId));
|
||||
}
|
||||
|
||||
public List<LoadingEvidenceEntity> findEvidence(long tenantId, long inboundId) {
|
||||
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
|
||||
.eq(LoadingEvidenceEntity::getTenantId, tenantId)
|
||||
.eq(LoadingEvidenceEntity::getInboundId, inboundId)
|
||||
.orderByAsc(LoadingEvidenceEntity::getEvidenceType));
|
||||
}
|
||||
|
||||
public Map<Long, PreloadPlanEntity> findPreloads(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return preloads.selectList(new LambdaQueryWrapper<PreloadPlanEntity>()
|
||||
.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<Long, LoadingPickTaskEntity> findPicks(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return picks.selectList(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||
.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<Long, List<LoadingEvidenceEntity>> findEvidence(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return evidences.selectList(new LambdaQueryWrapper<LoadingEvidenceEntity>()
|
||||
.in(LoadingEvidenceEntity::getInboundId, tenantByInbound.keySet())
|
||||
.orderByAsc(LoadingEvidenceEntity::getEvidenceType))
|
||||
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
|
||||
.collect(Collectors.groupingBy(LoadingEvidenceEntity::getInboundId));
|
||||
}
|
||||
|
||||
public Map<Long, InboundStatusHistoryEntity> findDeliveryHistory(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return histories.selectList(new LambdaQueryWrapper<InboundStatusHistoryEntity>()
|
||||
.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<InboundTransportSnapshotEntity>()
|
||||
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId)
|
||||
.eq(InboundTransportSnapshotEntity::getInboundId, inboundId));
|
||||
}
|
||||
|
||||
public Map<Long, InboundTransportSnapshotEntity> findTransports(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
|
||||
.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<Long, Long> tenantByInbound, Long inboundId, Long tenantId) {
|
||||
return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId));
|
||||
}
|
||||
}
|
||||
@@ -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<String> 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<UnifiedInboundSummary> page(UnifiedInboundQuery query) {
|
||||
validate(query);
|
||||
if ("PENDING_REVIEW".equals(query.mainStatus())) {
|
||||
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
||||
}
|
||||
Page<InboundOrderEntity> source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus()));
|
||||
List<UnifiedInboundSummary> records = summaries(source.getRecords());
|
||||
return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages());
|
||||
}
|
||||
|
||||
public List<UnifiedInboundSummary> summaries(Long tenantId, List<Long> orderIds) {
|
||||
Map<Long, UnifiedInboundSummary> 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<UnifiedInboundDetail.TimelineEvent> 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<UnifiedInboundSummary> summaries(List<InboundOrderEntity> records) {
|
||||
Map<Long, Long> 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<LoadingEvidenceEntity> 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<InboundOrderDetail.StatusHistorySummary> 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<String> 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, "创建时间起点不能晚于终点");
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<PageResult<Summary>> page(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody Query request);
|
||||
|
||||
@PostMapping("/summaries")
|
||||
ApiResponse<List<Summary>> summaries(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody BatchRequest request);
|
||||
|
||||
@PostMapping("/detail")
|
||||
ApiResponse<Detail> 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<Long> 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> 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) {}
|
||||
}
|
||||
@@ -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<PageResult<UnifiedOrderResponse.Row>> 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<UnifiedOrderResponse.Detail> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long orderId) {
|
||||
return ApiResponse.success(service.detail(OrderScopeGuard.merchant(user), orderId, false));
|
||||
}
|
||||
}
|
||||
@@ -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<PageResult<UnifiedOrderResponse.Row>> 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<UnifiedOrderResponse.Detail> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long orderId) {
|
||||
return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), orderId, true));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<TimelineEvent> 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) {}
|
||||
}
|
||||
@@ -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<OrderEntity> unifiedPage(Long tenantId, UnifiedOrderQuery query,
|
||||
List<OrderStatus> statuses) {
|
||||
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
|
||||
.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<OrderCargoEntity> findCargoByOrderIds(long tenantId, List<Long> orderIds) {
|
||||
if (orderIds.isEmpty()) return List.of();
|
||||
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
|
||||
|
||||
@@ -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<String> MAIN_STATUSES = Set.of(
|
||||
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
|
||||
private static final List<OrderStatus> 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<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query) {
|
||||
validate(query);
|
||||
if (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus())) {
|
||||
return pageFromFirstleg(tenantId, query);
|
||||
}
|
||||
List<OrderStatus> statuses = "PENDING_REVIEW".equals(query.mainStatus())
|
||||
? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES;
|
||||
PageResult<OrderEntity> source = orders.unifiedPage(tenantId, query, statuses);
|
||||
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = summaries(
|
||||
tenantId, source.records().stream().map(OrderEntity::getId).toList());
|
||||
List<UnifiedOrderResponse.Row> 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<UnifiedOrderResponse.TimelineEvent> 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<UnifiedOrderResponse.Row> 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<FirstlegUnifiedOrderClient.Summary> source = require(
|
||||
call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效");
|
||||
List<UnifiedOrderResponse.Row> records = source.records().stream().map(this::row).toList();
|
||||
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
private Map<Long, FirstlegUnifiedOrderClient.Summary> summaries(Long tenantId, List<Long> orderIds) {
|
||||
if (orderIds.isEmpty()) return Map.of();
|
||||
List<FirstlegUnifiedOrderClient.Summary> 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 <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> 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> T require(ApiResponse<T> response, String message) {
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
|
||||
}
|
||||
return response.data();
|
||||
}
|
||||
private <T> T requireNullable(ApiResponse<T> response, String message) {
|
||||
if (response == null || !response.successful()) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
|
||||
}
|
||||
return response.data();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user