This commit is contained in:
2026-08-06 13:56:20 +08:00
parent e6604ed680
commit dca3a48284
21 changed files with 1154 additions and 2 deletions
@@ -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(
@@ -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, "创建时间起点不能晚于终点");
}
}
}
@@ -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);
}
}
@@ -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();
}
}
+114 -1
View File
@@ -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);
}
}