This commit is contained in:
2026-08-31 10:17:03 +08:00
parent aa583efa4a
commit bf1dc1f209
17 changed files with 354 additions and 142 deletions
@@ -8,16 +8,16 @@ import org.springframework.web.bind.annotation.RequestHeader;
import java.util.List;
/** Platform-scoped read contract for Firstleg co-load group aggregation. */
/** Internal read contract for Firstleg co-load group aggregation. */
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegColoadGroupClient",
url = "${baiyue.clients.firstleg.url:}", path = "/api/platform/v1/first-leg/coload-groups")
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/coload-groups")
public interface FirstlegColoadGroupClient {
@PostMapping("/batch-query")
ApiResponse<List<OrderGroupInfo>> batchQuery(@RequestHeader("Authorization") String authorization,
ApiResponse<List<OrderGroupInfo>> batchQuery(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody BatchQueryRequest request);
record BatchQueryRequest(List<Long> orderIds) {}
record OrderGroupInfo(Long orderId, GroupSnapshot group) {}
record GroupSnapshot(Long groupId, String groupNo, String status, List<Member> members) {}
record Member(Long tenantId, Long orderId, Long inboundId, Integer fullPackageCount) {}
record GroupSnapshot(String status, List<Member> members) {}
record Member(Long orderId) {}
}
@@ -24,7 +24,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@@ -42,12 +41,11 @@ public class PlatformUnifiedOrderController {
@Operation(summary = "统一订单列表", description = "返回七态产品主状态及测量、预装、装柜、派送摘要。")
public ApiResponse<PageResult<UnifiedOrderResponse.Row>> page(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Authorization") String authorization,
@RequestParam(required = false) Long tenantId,
@Valid UnifiedOrderQuery query) {
OrderScopeGuard.platform(user);
if (tenantId != null) OrderScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.platformPage(tenantId, query, authorization));
return ApiResponse.success(service.platformPage(tenantId, query));
}
@GetMapping("/{orderId}")
@@ -55,11 +53,9 @@ public class PlatformUnifiedOrderController {
@Operation(summary = "统一订单详情", description = "合并订单和履约时间线;平台响应包含内部备注。")
public ApiResponse<UnifiedOrderResponse.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestHeader("Authorization") String authorization,
@RequestParam long tenantId,
@PathVariable long orderId) {
return ApiResponse.success(service.platformDetail(
OrderScopeGuard.platform(user, tenantId), orderId, authorization));
return ApiResponse.success(service.platformDetail(OrderScopeGuard.platform(user, tenantId), orderId));
}
@GetMapping("/{orderId}/fulfillment-tree")
@@ -40,10 +40,9 @@ public final class UnifiedOrderResponse {
public record OrderRelationSummary(String role, Long rootOrderId, Long parentOrderId,
Integer splitSequence, Integer childCount) {}
/** Platform-only Firstleg co-load group view; identifiers stay strings for routing only. */
public record CoLoadGroupSummary(String groupId, String groupNo, String status,
Integer memberCount, List<CoLoadGroupMemberSummary> members) {}
public record CoLoadGroupMemberSummary(String orderId, String inboundCode) {}
/** Platform-only Firstleg co-load business view; no group or member internal IDs are exposed. */
public record CoLoadGroupSummary(String status, Integer memberCount,
List<String> memberInboundCodes) {}
public record Detail(Row summary, OrderDetailResponse order,
List<TimelineEvent> timeline) {}
@@ -25,6 +25,7 @@ import java.util.ArrayList;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -49,22 +50,21 @@ public class UnifiedOrderQueryService {
@Value("${baiyue.security.internal-service-token}") private String internalToken;
public PageResult<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query) {
return page(tenantId, query, null);
return page(tenantId, query, false);
}
public PageResult<UnifiedOrderResponse.Row> platformPage(Long tenantId, UnifiedOrderQuery query,
String authorization) {
return page(tenantId, query, authorization);
public PageResult<UnifiedOrderResponse.Row> platformPage(Long tenantId, UnifiedOrderQuery query) {
return page(tenantId, query, true);
}
private PageResult<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query,
String platformAuthorization) {
boolean includeCoLoadGroups) {
validate(query);
if (requiresFirstlegQuery(query)) {
if ("PENDING_REVIEW".equals(query.mainStatus())) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
return pageFromFirstleg(tenantId, query, platformAuthorization);
return pageFromFirstleg(tenantId, query, includeCoLoadGroups);
}
List<OrderStatus> statuses = "PENDING_REVIEW".equals(query.mainStatus())
? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES;
@@ -77,7 +77,7 @@ public class UnifiedOrderQueryService {
source.records().stream().map(OrderEntity::getId).toList());
Map<Long, Integer> childCounts = childCounts(source.records());
Map<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadByOrder = coLoadGroups(
source.records().stream().map(OrderEntity::getId).toList(), platformAuthorization);
source.records().stream().map(OrderEntity::getId).toList(), includeCoLoadGroups);
List<UnifiedOrderResponse.Row> rows = source.records().stream()
.map(order -> row(order, inboundByOrder.get(order.getId()), latestNotes.get(order.getId()),
childCounts, cargoSummaries.get(order.getId()), coLoadByOrder.get(order.getId()))).toList();
@@ -85,15 +85,15 @@ public class UnifiedOrderQueryService {
}
public UnifiedOrderResponse.Detail detail(long tenantId, long orderId, boolean includeInternalNotes) {
return detail(tenantId, orderId, includeInternalNotes, null);
return detail(tenantId, orderId, includeInternalNotes, false);
}
public UnifiedOrderResponse.Detail platformDetail(long tenantId, long orderId, String authorization) {
return detail(tenantId, orderId, true, authorization);
public UnifiedOrderResponse.Detail platformDetail(long tenantId, long orderId) {
return detail(tenantId, orderId, true, true);
}
private UnifiedOrderResponse.Detail detail(long tenantId, long orderId, boolean includeInternalNotes,
String platformAuthorization) {
boolean includeCoLoadGroups) {
OrderEntity order = orders.findOwned(tenantId, orderId);
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
OrderDetailResponse orderDetail = new OrderDetailResponse(order, orders.findCargo(tenantId, orderId),
@@ -114,7 +114,7 @@ public class UnifiedOrderQueryService {
.orElse(null));
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary(), latestNote,
childCounts(List.of(order)), CargoSummary.from(orderDetail.cargoes()), creatorName,
coLoadGroups(List.of(orderId), platformAuthorization).get(orderId));
coLoadGroups(List.of(orderId), includeCoLoadGroups).get(orderId));
List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
"ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(),
@@ -130,7 +130,7 @@ public class UnifiedOrderQueryService {
}
private PageResult<UnifiedOrderResponse.Row> pageFromFirstleg(Long tenantId, UnifiedOrderQuery query,
String platformAuthorization) {
boolean includeCoLoadGroups) {
List<Long> candidateOrderIds = StringUtils.hasText(query.fbaCode())
? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null;
if (candidateOrderIds != null && candidateOrderIds.isEmpty()) {
@@ -151,8 +151,8 @@ public class UnifiedOrderQueryService {
Map<Long, String> latestNotes = latestNotes(tenantId, orderIds);
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId, orderIds);
Map<Long, Integer> childCounts = childCounts(List.copyOf(orderById.values()));
Map<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadByOrder =
coLoadGroups(orderIds, platformAuthorization);
Map<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadByOrder = coLoadGroups(
orderIds, includeCoLoadGroups);
List<UnifiedOrderResponse.Row> records = source.records().stream()
.map(summary -> row(summary, orderById.get(summary.inbound().orderId()),
latestNotes.get(summary.inbound().orderId()), childCounts,
@@ -197,37 +197,38 @@ public class UnifiedOrderQueryService {
}
private Map<Long, UnifiedOrderResponse.CoLoadGroupSummary> coLoadGroups(
List<Long> orderIds, String platformAuthorization) {
if (!StringUtils.hasText(platformAuthorization) || orderIds.isEmpty()) return Map.of();
List<Long> orderIds, boolean includeCoLoadGroups) {
if (!includeCoLoadGroups || orderIds.isEmpty()) return Map.of();
List<FirstlegColoadGroupClient.OrderGroupInfo> data = require(
call(() -> coloadGroups.batchQuery(platformAuthorization,
call(() -> coloadGroups.batchQuery(internalToken,
new FirstlegColoadGroupClient.BatchQueryRequest(orderIds.stream().distinct().toList()))),
"同柜组摘要响应无效");
List<Long> memberOrderIds = data.stream()
.filter(Objects::nonNull).map(FirstlegColoadGroupClient.OrderGroupInfo::group)
.filter(Objects::nonNull).flatMap(group -> safeMembers(group).stream())
List<Long> memberOrderIds = data.stream().filter(Objects::nonNull)
.map(FirstlegColoadGroupClient.OrderGroupInfo::group).filter(Objects::nonNull)
.flatMap(group -> safeMembers(group).stream())
.map(FirstlegColoadGroupClient.Member::orderId).filter(Objects::nonNull).distinct().toList();
Map<Long, String> inboundCodeByOrder = orders.findByIds(null, memberOrderIds).stream()
.collect(Collectors.toMap(OrderEntity::getId, OrderEntity::getInboundCode, (left, right) -> left));
Map<Long, String> inboundCodeByOrder = new HashMap<>();
if (!memberOrderIds.isEmpty()) {
orders.findByIds(null, memberOrderIds).forEach(order ->
inboundCodeByOrder.putIfAbsent(order.getId(), order.getInboundCode()));
}
return data.stream().filter(Objects::nonNull)
.filter(info -> info.orderId() != null && info.group() != null)
.collect(Collectors.toMap(FirstlegColoadGroupClient.OrderGroupInfo::orderId,
info -> coLoadGroup(info.group(), inboundCodeByOrder), (left, right) -> left));
}
private List<FirstlegColoadGroupClient.Member> safeMembers(FirstlegColoadGroupClient.GroupSnapshot group) {
return group.members() == null ? List.of() : group.members();
}
private UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup(
FirstlegColoadGroupClient.GroupSnapshot group, Map<Long, String> inboundCodeByOrder) {
List<UnifiedOrderResponse.CoLoadGroupMemberSummary> members = safeMembers(group).stream()
.filter(member -> member.orderId() != null)
.map(member -> new UnifiedOrderResponse.CoLoadGroupMemberSummary(
string(member.orderId()), inboundCodeByOrder.get(member.orderId())))
.toList();
return new UnifiedOrderResponse.CoLoadGroupSummary(
string(group.groupId()), group.groupNo(), group.status(), members.size(), members);
List<FirstlegColoadGroupClient.Member> members = safeMembers(group);
List<String> inboundCodes = members.stream().map(FirstlegColoadGroupClient.Member::orderId)
.map(inboundCodeByOrder::get).toList();
return new UnifiedOrderResponse.CoLoadGroupSummary(group.status(), members.size(), inboundCodes);
}
private List<FirstlegColoadGroupClient.Member> safeMembers(
FirstlegColoadGroupClient.GroupSnapshot group) {
return group.members() == null ? List.of() : group.members();
}
private UnifiedOrderResponse.OrderRelationSummary relation(OrderEntity order,
+14 -2
View File
@@ -49,6 +49,7 @@ paths:
指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。
列表行同时返回 customerRemark(客户下单备注)、latestInternalNote(最新一条平台内部备注)和
relation(仅有关联时返回;PARENT 含 childCount,CHILD 含 rootOrderId、parentOrderId 与 splitSequence)。
coLoadGroup 仅平台响应返回,由 Order 经 Firstleg 内部批量只读契约聚合;成员业务标识由 Order 权威订单片段补齐。
parameters:
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- $ref: '#/components/parameters/UnifiedKeyword'
@@ -83,7 +84,7 @@ paths:
/api/platform/v1/orders/{orderId}:
get:
summary: 平台统一订单详情
description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;所有状态直接返回 Order 创建时生成的 inboundCode;平台可见内部备注。
description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;返回当前订单片段的 inboundCode 与同柜组摘要;平台可见内部备注。
parameters:
- $ref: '#/components/parameters/UnifiedOrderId'
- $ref: '#/components/parameters/UnifiedTenantId'
@@ -483,7 +484,7 @@ components:
destinationWarehouseName: {type: string, nullable: true}
inboundId: {type: integer, format: int64, nullable: true}
inboundVersion: {type: integer, nullable: true, description: C04 loading/complete 的 expectedVersion}
inboundCode: {type: string, nullable: true}
inboundCode: {type: string, nullable: true, description: 当前订单片段的权威入仓码;缺失时为 null,不得回退为 orderId 或 orderNo}
expectedShipDate: {type: string, format: date, nullable: true}
totalPackageCount: {type: integer, nullable: true, description: 无入仓事实时为当前订单有效货物箱数合计;存在入仓业务单元后为该单元履约总箱数,拆分后不以父单原始箱数覆盖}
recommendedPalletType: {type: string, nullable: true, description: 当前无列表级权威事实时返回 null,不得返回固定托盘类型}
@@ -520,6 +521,7 @@ components:
loading: {$ref: '#/components/schemas/UnifiedLoadingSummary'}
delivery: {$ref: '#/components/schemas/UnifiedDeliverySummary'}
transport: {$ref: '#/components/schemas/UnifiedTransportSummary'}
coLoadGroup: {$ref: '#/components/schemas/UnifiedCoLoadGroupSummary'}
customerRemark: {type: string, nullable: true}
latestInternalNote: {type: string, nullable: true, description: 客户详情不返回平台内部备注}
createdAt: {type: string, format: date-time, nullable: true}
@@ -533,6 +535,16 @@ components:
parentOrderId: {type: integer, format: int64, nullable: true}
splitSequence: {type: integer, nullable: true}
childCount: {type: integer, nullable: true}
UnifiedCoLoadGroupSummary:
type: object
nullable: true
description: 仅平台统一订单列表与详情返回;未加入同柜组时为 null
properties:
status: {type: string}
memberCount: {type: integer, minimum: 0}
memberInboundCodes:
type: array
items: {type: string, nullable: true, description: 同柜组成员订单片段的权威入仓码;缺失时保留 null,不回退内部ID}
UnifiedMeasurementSummary:
type: object
nullable: true
@@ -1,6 +1,7 @@
package com.baiyue.order.controller;
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
import com.baiyue.order.client.FirstlegColoadGroupClient;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.cloud.openfeign.FeignClient;
@@ -57,7 +58,13 @@ class UnifiedOrderApiContractTest {
"taxMode", "containerMode", "customsInspection", "destinationType", "destinationCode",
"totalPalletCount", "chargeableWeightKg", "chargeableVolumeCbm", "volumetricWeightKg", "totalNetWeightKg",
"measurementCompletedAt", "inboundCompletedAt", "expressCompany", "expressTrackingNo",
"createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId");
"createdBy", "skuNos", "totalQuantity", "shipmentId", "referenceId", "coLoadGroup");
Map<String, Object> inboundCode = stringMap(stringMap(row.get("properties")).get("inboundCode"));
assertThat(inboundCode).containsEntry("nullable", true);
assertThat(inboundCode.get("description").toString()).contains("不得回退");
Map<String, Object> coLoadGroup = stringMap(schemas.get("UnifiedCoLoadGroupSummary"));
assertThat(stringMap(coLoadGroup.get("properties")))
.containsOnlyKeys("status", "memberCount", "memberInboundCodes");
assertThat(responseSchema(paths, "/api/platform/v1/orders", "get"))
.containsEntry("$ref", "#/components/schemas/UnifiedOrderPageEnvelope");
assertThat(responseSchema(paths, "/api/merchant/v1/orders/{orderId}", "get"))
@@ -74,6 +81,16 @@ class UnifiedOrderApiContractTest {
assertThat(page.getAnnotation(PostMapping.class).value()).containsExactly("/query");
}
@Test
void keepsFirstlegInternalColoadBatchQueryPath() throws Exception {
FeignClient client = FirstlegColoadGroupClient.class.getAnnotation(FeignClient.class);
assertThat(client.path()).isEqualTo("/internal/firstleg/v1/coload-groups");
Method method = FirstlegColoadGroupClient.class.getMethod(
"batchQuery", String.class, FirstlegColoadGroupClient.BatchQueryRequest.class);
assertThat(method.getAnnotation(PostMapping.class).value()).containsExactly("/batch-query");
}
@SuppressWarnings("unchecked")
private static Map<String, Object> stringMap(Object value) {
return (Map<String, Object>) value;
@@ -4,6 +4,7 @@ import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
import com.baiyue.order.client.FirstlegColoadGroupClient;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.entity.OrderCargoEntity;
@@ -40,6 +41,7 @@ import static org.mockito.Mockito.when;
class UnifiedOrderQueryServiceTest {
@MockBean private OrderRepository orders;
@MockBean private FirstlegUnifiedOrderClient firstleg;
@MockBean private FirstlegColoadGroupClient coloadGroups;
@Resource private UnifiedOrderQueryService service;
@BeforeEach
@@ -216,6 +218,89 @@ class UnifiedOrderQueryServiceTest {
}));
}
@Test
void splitChildKeepsItsOwnEffectiveInboundCode() {
UnifiedOrderQuery query = query("PENDING_REVIEW");
OrderEntity child = order(OrderStatus.SUBMITTED);
child.setId(78L);
child.setOrderNo("FL-77-1");
child.setInboundCode("RK-77-1");
child.setParentOrderId(77L);
child.setRootOrderId(77L);
child.setSplitSequence(1);
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
.thenReturn(new PageResult<>(List.of(child), 1, 20, 1, 1));
assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> {
assertThat(row.orderId()).isEqualTo(78L);
assertThat(row.orderNo()).isEqualTo("FL-77-1");
assertThat(row.inboundCode()).isEqualTo("RK-77-1");
});
}
@Test
void missingInboundCodeRemainsNullWithoutIdentifierFallback() {
UnifiedOrderQuery query = query("PENDING_REVIEW");
OrderEntity order = order(OrderStatus.SUBMITTED);
order.setInboundCode(null);
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
assertThat(service.page(10001L, query).records()).singleElement().satisfies(row -> {
assertThat(row.orderId()).isEqualTo(77L);
assertThat(row.orderNo()).isEqualTo("FL-77");
assertThat(row.inboundCode()).isNull();
});
}
@Test
void platformPageAggregatesColoadStatusAndMemberInboundCodes() {
UnifiedOrderQuery query = query("PENDING_REVIEW");
OrderEntity current = order(OrderStatus.SUBMITTED);
current.setInboundCode("RK-77");
OrderEntity otherCustomerMember = order(OrderStatus.APPROVED);
otherCustomerMember.setId(78L);
otherCustomerMember.setTenantId(20002L);
otherCustomerMember.setInboundCode("RK-OTHER-CUSTOMER");
var group = new FirstlegColoadGroupClient.GroupSnapshot("ACTIVE", List.of(
new FirstlegColoadGroupClient.Member(77L), new FirstlegColoadGroupClient.Member(78L)));
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
.thenReturn(new PageResult<>(List.of(current), 1, 20, 1, 1));
when(coloadGroups.batchQuery(eq("internal-token"), any())).thenReturn(ApiResponse.success(
List.of(new FirstlegColoadGroupClient.OrderGroupInfo(77L, group))));
when(orders.findByIds(null, List.of(77L, 78L))).thenReturn(List.of(current, otherCustomerMember));
assertThat(service.platformPage(10001L, query).records())
.singleElement().satisfies(row -> {
assertThat(row.coLoadGroup()).isNotNull();
assertThat(row.coLoadGroup().status()).isEqualTo("ACTIVE");
assertThat(row.coLoadGroup().memberCount()).isEqualTo(2);
assertThat(row.coLoadGroup().memberInboundCodes())
.containsExactly("RK-77", "RK-OTHER-CUSTOMER");
});
verify(coloadGroups).batchQuery(eq("internal-token"), any());
verify(orders).findByIds(null, List.of(77L, 78L));
}
@Test
void platformPageKeepsMissingInboundCodeNullDuringColoadAggregation() {
UnifiedOrderQuery query = query("PENDING_REVIEW");
OrderEntity order = order(OrderStatus.SUBMITTED);
order.setInboundCode(null);
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
when(coloadGroups.batchQuery(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(List.of()));
assertThat(service.platformPage(10001L, query).records())
.singleElement().satisfies(row -> {
assertThat(row.inboundCode()).isNull();
assertThat(row.coLoadGroup()).isNull();
});
verify(coloadGroups).batchQuery(eq("internal-token"), any());
verify(orders, never()).findByIds(eq(null), any());
}
@Test
void allStatusInboundCodeFilterIncludesPendingOrdersFromOrderQuery() {
UnifiedOrderQuery query = new UnifiedOrderQuery(