This commit is contained in:
2026-10-20 11:30:34 +08:00
parent c13175bf77
commit 16ac817524
92 changed files with 1916 additions and 190 deletions
@@ -24,6 +24,18 @@ public interface FirstlegUnifiedOrderClient {
record WorkbenchFilters(List<Long> matchingStatusOrderIds, List<Long> feeOrderIds,
List<Long> customsOrderIds, List<com.baiyue.order.dto.MerchantOrderProgress> progress) {}
/** 当前页候选订单按原筛选批量读取摘要。 */
@PostMapping("/status-summaries")
ApiResponse<List<Summary>> statusSummaries(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody Query request);
/** 批量读取符合筛选的去重订单状态,不返回履约明细。 */
@PostMapping("/status-rows")
ApiResponse<List<StatusRow>> statusRows(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody Query request);
record StatusRow(Long tenantId, Long orderId, String mainStatus) {}
@PostMapping("/query")
ApiResponse<PageResult<Summary>> page(@RequestHeader("X-Internal-Service-Token") String token,
@RequestBody Query request);
@@ -5,6 +5,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.UnifiedOrderQuery;
import com.baiyue.order.dto.UnifiedOrderStatusCountQuery;
import com.baiyue.order.dto.UnifiedOrderResponse;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.dto.OrderDeclarationDtos;
@@ -52,6 +53,19 @@ public class PlatformUnifiedOrderController {
return ApiResponse.success(service.platformPage(tenantId, query));
}
/** 与订单列表共用平台权限及客户范围,不提供全部汇总数量。 */
@GetMapping("/status-counts")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "统一订单页签数量", description = "忽略mainStatus和分页,按其余列表筛选全量去重统计;不返回全部数量。")
public ApiResponse<UnifiedOrderQueryService.StatusCounts> statusCounts(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) Long tenantId,
@Valid UnifiedOrderStatusCountQuery query) {
OrderScopeGuard.platform(user);
if (tenantId != null) OrderScopeGuard.platform(user, tenantId);
return ApiResponse.success(service.statusCounts(tenantId, query.toOrderQuery()));
}
@GetMapping("/{orderId}")
@PreAuthorize("hasAuthority('platform:order:list')")
@Operation(summary = "统一订单详情", description = "合并订单和履约时间线;平台响应包含内部备注。")
@@ -13,7 +13,7 @@ public record UnifiedOrderQuery(
@Size(max = 64) String keyword,
@Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED",
"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "COMPLETED"})
"WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "COMPLETED", "WAIT_WEIGHT_CONFIRM", "WEIGHT_CONFIRMED", "WAIT_CUSTOMS_DOCS", "EXPORT_CUSTOMS", "SAILING_CLEARANCE", "WAIT_UNLOAD", "WAIT_DISPATCH", "OUTBOUND_DOCUMENT", "DELIVERING", "SIGNED", "MEASUREMENT_REVIEW"})
@Size(max = 32) String mainStatus,
Long customerId,
Long receivingWarehouseId,
@@ -297,9 +297,28 @@ 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>()
/** 统一订单列表按共享筛选分页。 */
public PageResult<OrderEntity> unifiedPage(Long tenantId, UnifiedOrderQuery query, List<OrderStatus> statuses) {
return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), unifiedFilter(tenantId, query, statuses)));
}
/** 全量轻量投影只读取订单身份和权威交易状态。 */
public List<OrderEntity> unifiedStatusOrders(Long tenantId, UnifiedOrderQuery query, List<OrderStatus> statuses) {
return orderMapper.selectList(unifiedFilter(tenantId, query, statuses)
.select(OrderEntity::getId, OrderEntity::getTenantId, OrderEntity::getStatus));
}
/** 状态覆盖只接受本客户范围内未删除订单的权威状态。 */
public List<OrderEntity> statusAuthorities(Long tenantId, List<Long> orderIds) {
if (orderIds.isEmpty()) return List.of();
return orderMapper.selectList(new LambdaQueryWrapper<OrderEntity>()
.select(OrderEntity::getId, OrderEntity::getTenantId, OrderEntity::getStatus)
.eq(tenantId != null, OrderEntity::getTenantId, tenantId).in(OrderEntity::getId, orderIds));
}
/** 列表和统计共用非分页筛选及客户隔离。 */
private LambdaQueryWrapper<OrderEntity> unifiedFilter(Long tenantId, UnifiedOrderQuery query, List<OrderStatus> statuses) {
return new LambdaQueryWrapper<OrderEntity>()
.eq(tenantId != null, OrderEntity::getTenantId, tenantId)
.eq(query.taxMode() != null, OrderEntity::getTaxMode, query.taxMode())
.in(OrderEntity::getStatus, statuses)
@@ -325,7 +344,6 @@ public class OrderRepository {
.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<Long> findOrderIdsBySkuNo(Long tenantId, String fbaCode) {
@@ -42,6 +42,7 @@ public class UnifiedOrderQueryService {
private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED", "COMPLETED");
private static final List<String> WORKFLOW_STATUSES = List.of("WAIT_WEIGHT_CONFIRM", "WEIGHT_CONFIRMED", "WAIT_CUSTOMS_DOCS", "EXPORT_CUSTOMS", "SAILING_CLEARANCE", "WAIT_UNLOAD", "WAIT_DISPATCH", "OUTBOUND_DOCUMENT", "DELIVERING", "SIGNED", "MEASUREMENT_REVIEW");
private static final Set<String> REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED");
private static final List<OrderStatus> ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of(
OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED,
@@ -60,6 +61,102 @@ public class UnifiedOrderQueryService {
return page(tenantId, query, true);
}
/** 页签统计忽略主状态和分页,返回筛选全量订单的准确去重数量。 */
public StatusCounts statusCounts(Long tenantId, UnifiedOrderQuery query) {
query = withoutMainStatus(query);
validate(query);
Map<String, Long> counts = new java.util.LinkedHashMap<>();
for (String key : List.of("PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED", "COMPLETED")) counts.put(key, 0L);
WORKFLOW_STATUSES.forEach(key -> counts.put(key, 0L));
boolean operational = requiresFirstlegQuery(query);
if (!operational) {
// 审核与完成由 Order 权威状态决定,不依赖 Firstleg 是否存在投影。
for (OrderEntity order : orders.unifiedStatusOrders(tenantId, query,
List.of(OrderStatus.SUBMITTED, OrderStatus.COMPLETED))) {
counts.merge(orderMainStatus(order.getStatus()), 1L, Long::sum);
}
}
for (FirstlegUnifiedOrderClient.StatusRow row : matchingStatusRows(tenantId, query)) {
String status = row.mainStatus();
if (!operational && Set.of("PENDING_REVIEW", "COMPLETED").contains(status)) continue;
if (counts.containsKey(status)) counts.merge(status, 1L, Long::sum);
}
return new StatusCounts(java.util.Collections.unmodifiableMap(counts));
}
public record StatusCounts(
@com.fasterxml.jackson.databind.annotation.JsonSerialize(contentUsing = CountNumberSerializer.class)
Map<String, Long> counts) {}
/** 数量保持 JSON number;不能被全局雪花 ID 的 Long 字符串序列化规则改写。 */
public static final class CountNumberSerializer extends com.fasterxml.jackson.databind.JsonSerializer<Long> {
/** 按数值输出数量,不影响系统其他 Long ID 的安全传输。 */
@Override
public void serialize(Long value, com.fasterxml.jackson.core.JsonGenerator generator,
com.fasterxml.jackson.databind.SerializerProvider provider) throws java.io.IOException {
generator.writeNumber(value);
}
}
/** 统计不受当前页签及分页字段影响;其他条件原样保留。 */
private UnifiedOrderQuery withoutMainStatus(UnifiedOrderQuery query) {
return new UnifiedOrderQuery(query.keyword(), query.orderNo(), null, query.customerId(),
query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(),
query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(),
query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(),
query.measurementReviewStatus(), query.fbaCode(), query.createdFrom(), query.createdTo(), 1, 200, query.taxMode());
}
/** 在分页前按订单去重并覆盖审核/完成状态,不能用履约快照冒充订单权威状态。 */
private List<FirstlegUnifiedOrderClient.StatusRow> matchingStatusRows(Long tenantId, UnifiedOrderQuery query) {
List<Long> candidates = StringUtils.hasText(query.fbaCode())
? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null;
if (candidates != null && candidates.isEmpty()) return List.of();
var request = statusRequest(tenantId, withoutMainStatus(query), candidates);
List<FirstlegUnifiedOrderClient.StatusRow> source = require(
call(() -> firstleg.statusRows(internalToken, request)), "统一订单状态查询响应无效");
Map<String, FirstlegUnifiedOrderClient.StatusRow> distinct = new java.util.LinkedHashMap<>();
for (var row : source) {
if (row == null || row.orderId() == null || row.tenantId() == null
|| tenantId != null && !tenantId.equals(row.tenantId())) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "统一订单状态查询响应无效");
}
distinct.putIfAbsent(row.orderId() + ":" + row.mainStatus(), row);
}
Map<Long, OrderEntity> authority = new HashMap<>();
List<Long> ids = distinct.values().stream().map(FirstlegUnifiedOrderClient.StatusRow::orderId).distinct().toList();
// 有界批量读取权威状态,避免逐票远程调用及超长 IN 条件。
for (int offset = 0; offset < ids.size(); offset += 500) {
orders.statusAuthorities(tenantId, ids.subList(offset, Math.min(offset + 500, ids.size())))
.forEach(order -> authority.put(order.getId(), order));
}
List<FirstlegUnifiedOrderClient.StatusRow> result = new ArrayList<>();
for (var row : distinct.values()) {
OrderEntity order = authority.get(row.orderId());
if (order == null || !Objects.equals(order.getTenantId(), row.tenantId())
|| !ACTIVE_ORDER_STATUSES.contains(order.getStatus())) continue;
String status = order.getStatus() == OrderStatus.SUBMITTED ? "PENDING_REVIEW"
: order.getStatus() == OrderStatus.COMPLETED && !WORKFLOW_STATUSES.contains(row.mainStatus()) ? "COMPLETED" : row.mainStatus();
if (status != null) result.add(new FirstlegUnifiedOrderClient.StatusRow(row.tenantId(), row.orderId(), status));
}
// 同一订单的多个分片覆盖为待审核/已完成后仍只计一次。
Map<String, FirstlegUnifiedOrderClient.StatusRow> uniqueStatuses = new java.util.LinkedHashMap<>();
result.forEach(row -> uniqueStatuses.putIfAbsent(row.orderId() + ":" + row.mainStatus(), row));
return List.copyOf(uniqueStatuses.values());
}
/** 状态投影和当前页摘要共用原筛选,候选订单用于服务内批量收敛范围。 */
private FirstlegUnifiedOrderClient.Query statusRequest(Long tenantId, UnifiedOrderQuery query, List<Long> candidates) {
String status = Set.of("PENDING_REVIEW", "COMPLETED").contains(
query.mainStatus() == null ? "" : query.mainStatus()) ? null : query.mainStatus();
return new FirstlegUnifiedOrderClient.Query(tenantId, query.keyword(), query.orderNo(), status,
query.customerId(), query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(),
query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(),
query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(),
query.measurementReviewStatus(), candidates, query.createdFrom(), query.createdTo(), 1, 200, query.taxMode());
}
/** 平台导出选择项按业务订单号读取;不暴露内部 ID 作为选择契约。 */
public List<UnifiedOrderResponse.Row> platformRowsByOrderNos(List<String> orderNos) {
if (orderNos == null || orderNos.isEmpty()) return List.of();
@@ -83,13 +180,11 @@ public class UnifiedOrderQueryService {
return selected.stream().map(order->row(order,summaries.get(order.getId()),null,children,cargo.get(order.getId()))).toList();
}
/** 依条件选择订单查询或履约筛选,保留订单审核优先规则。 */
private PageResult<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query,
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, includeCoLoadGroups);
}
List<OrderStatus> statuses = "PENDING_REVIEW".equals(query.mainStatus())
@@ -162,25 +257,33 @@ public class UnifiedOrderQueryService {
return new UnifiedOrderResponse.Detail(summary, orderDetail, timeline, fragments);
}
/** 先校正权威状态并去重,再对匹配订单分页和批量补齐当前页摘要。 */
private PageResult<UnifiedOrderResponse.Row> pageFromFirstleg(Long tenantId, UnifiedOrderQuery query,
boolean includeCoLoadGroups) {
List<Long> candidateOrderIds = StringUtils.hasText(query.fbaCode())
? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null;
if (candidateOrderIds != null && candidateOrderIds.isEmpty()) {
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
}
FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query(
tenantId, query.keyword(), query.orderNo(), query.mainStatus(), query.customerId(),
query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(),
query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(),
query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(),
query.measurementReviewStatus(), candidateOrderIds, query.createdFrom(), query.createdTo(),
query.page(), query.size(), query.taxMode());
PageResult<FirstlegUnifiedOrderClient.Summary> source = require(
call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效");
List<FirstlegUnifiedOrderClient.Summary> summaries = validateSummaries(
source.records(), "统一订单履约查询响应无效");
List<Long> orderIds = summaries.stream().map(item -> item.inbound().orderId()).toList();
List<FirstlegUnifiedOrderClient.StatusRow> matching = matchingStatusRows(tenantId, query).stream()
.filter(row -> !StringUtils.hasText(query.mainStatus()) || query.mainStatus().equals(row.mainStatus()))
.collect(Collectors.collectingAndThen(Collectors.toMap(FirstlegUnifiedOrderClient.StatusRow::orderId,
Function.identity(), (left, right) -> left, java.util.LinkedHashMap::new),
values -> List.copyOf(values.values())));
long total = matching.size();
long start = query.page() - 1 > Long.MAX_VALUE / query.size()
? total : Math.min((query.page() - 1) * query.size(), total);
List<Long> orderIds = matching.subList((int) start, (int) Math.min(start + query.size(), total))
.stream().map(FirstlegUnifiedOrderClient.StatusRow::orderId).toList();
Map<Long, FirstlegUnifiedOrderClient.Summary> byOrder = orderIds.isEmpty() ? Map.of()
: validateSummaries(require(call(() -> firstleg.statusSummaries(internalToken,
statusRequest(tenantId, query, orderIds))), "统一订单履约摘要响应无效"),
"统一订单履约摘要响应无效").stream().collect(Collectors.toMap(
item -> item.inbound().orderId(), Function.identity(), (left, right) -> left));
Map<Long, Long> expectedTenants = matching.stream().collect(Collectors.toMap(
FirstlegUnifiedOrderClient.StatusRow::orderId, FirstlegUnifiedOrderClient.StatusRow::tenantId));
List<FirstlegUnifiedOrderClient.Summary> summaries = orderIds.stream().map(id -> {
var summary = byOrder.get(id);
if (summary == null || !Objects.equals(summary.inbound().tenantId(), expectedTenants.get(id))) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "统一订单履约摘要缺失或客户不匹配");
}
return summary;
}).toList();
Map<Long, OrderEntity> orderById = orders.findByIds(tenantId, orderIds).stream()
.collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left));
Map<Long, CargoSummary> cargoSummaries = cargoSummaries(tenantId, List.copyOf(orderById.values()));
@@ -193,7 +296,8 @@ public class UnifiedOrderQueryService {
cargoSummaries.get(summary.inbound().orderId()),
coLoadByOrder.get(summary.inbound().orderId())))
.toList();
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
return new PageResult<>(records, query.page(), query.size(), total,
total / query.size() + (total % query.size() == 0 ? 0 : 1));
}
private Map<Long, FirstlegUnifiedOrderClient.Summary> summaries(Long tenantId, List<Long> orderIds) {
@@ -391,14 +495,17 @@ public class UnifiedOrderQueryService {
order.getCreatedAt(), order.getUpdatedAt(), inbound.requestedPickupAt());
}
/** 将当前页匹配履约记录映射为订单行,审核与完成优先使用 Order 权威状态。 */
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order,
String latestInternalNote, Map<Long, Integer> childCounts,
CargoSummary cargo,
UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup) {
var inbound = summary.inbound();
boolean completed = order != null && order.getStatus() == OrderStatus.COMPLETED;
boolean pending = order != null && order.getStatus() == OrderStatus.SUBMITTED;
return new UnifiedOrderResponse.Row(inbound.orderId(), order == null ? null : order.getVersion(), inbound.tenantId(), inbound.orderNo(),
completed ? "COMPLETED" : summary.mainStatus(), completed ? null : summary.nextAction(), Boolean.TRUE.equals(summary.customerFeeConfirmationRequired()), relation(order, childCounts),
pending ? "PENDING_REVIEW" : completed ? "COMPLETED" : summary.mainStatus(),
pending ? "APPROVE_ORDER" : completed ? null : summary.nextAction(), Boolean.TRUE.equals(summary.customerFeeConfirmationRequired()), relation(order, childCounts),
parseOrderStatus(inbound.orderStatusSnapshot()),
inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(),
order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(),
@@ -577,6 +684,7 @@ public class UnifiedOrderQueryService {
}
if (StringUtils.hasText(query.mainStatus())
&& !MAIN_STATUSES.contains(query.mainStatus())
&& !WORKFLOW_STATUSES.contains(query.mainStatus())
) {
throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法");
}
+53 -4
View File
@@ -378,7 +378,7 @@ paths:
inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。
指定履约主状态时先按 Order 权威状态校正并去重,再分页读取 Firstleg 摘要。旧 /orders/first-leg 接口保持兼容。
列表行同时返回 customerRemark(客户下单备注)、latestInternalNote(最新一条平台内部备注)和
relation(仅有关联时返回;PARENT 含 childCount,CHILD 含 rootOrderId、parentOrderId 与 splitSequence)。
coLoadGroup 仅平台响应返回,由 Order 经 Firstleg 内部批量只读契约聚合;成员业务标识由 Order 权威订单片段补齐。
@@ -414,6 +414,55 @@ paths:
application/json:
schema: {$ref: '#/components/schemas/UnifiedOrderPageEnvelope'}
'403': {description: 需要平台身份和 platform:order:list 权限}
/api/platform/v1/orders/status-counts:
get:
summary: 平台统一订单页签数量
description: >-
需要平台身份和 platform:order:list;tenantId 省略时按授权平台范围查询全部客户。
按统一列表非分页、非 mainStatus 筛选全量去重;忽略主状态和分页,不返回全部数量。
订单审核与完成状态优先于履约投影,依赖异常返回业务错误,不能以零代替失败。
MEASUREMENT_REVIEW 为独立任务,暂不返回该 key。
parameters:
- $ref: '#/components/parameters/UnifiedTaxMode'
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
- $ref: '#/components/parameters/UnifiedKeyword'
- $ref: '#/components/parameters/UnifiedOrderNo'
- {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/UnifiedInboundCode'
- $ref: '#/components/parameters/UnifiedInternalOrderStatus'
- $ref: '#/components/parameters/UnifiedExpectedArrivalDate'
- $ref: '#/components/parameters/UnifiedFeeReviewStatus'
- $ref: '#/components/parameters/UnifiedChannelName'
- $ref: '#/components/parameters/UnifiedContainerNo'
- $ref: '#/components/parameters/UnifiedCabinetNo'
- $ref: '#/components/parameters/UnifiedSailingDate'
- $ref: '#/components/parameters/UnifiedLoaded'
- $ref: '#/components/parameters/UnifiedFullContainer'
- $ref: '#/components/parameters/UnifiedMeasurementReviewStatus'
- $ref: '#/components/parameters/UnifiedFbaCode'
- $ref: '#/components/parameters/UnifiedCreatedFrom'
- $ref: '#/components/parameters/UnifiedCreatedTo'
responses:
'200':
description: 标准 ApiResponse;仅 code 为字符串 200 时可使用数量
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
type: object
required: [counts]
properties:
counts:
type: object
description: PENDING_REVIEW、WAIT_MEASURE、WAIT_PALLETIZE、WAIT_SPLIT、LOADING、LOADED、DELIVERED、COMPLETED
additionalProperties: {type: integer, format: int64, minimum: 0}
'403': {description: 需要平台身份和 platform:order:list 权限}
/api/platform/v1/orders/{orderId}:
get:
summary: 平台统一订单详情
@@ -1038,7 +1087,7 @@ components:
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
keyword: {type: string, maxLength: 64}
orderNo: {type: string, maxLength: 64}
mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED]}
mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]}
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
@@ -1694,10 +1743,10 @@ components:
name: mainStatus
in: query
required: false
description: 统一订单八态主状态筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询
description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 为实际开船后待上传清关资料;清关资料上传后进入 WAIT_UNLOAD,持续到实际卸柜完成。实际开船统一读取手动或拉取的实际离港节点。
schema:
type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED]
enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]
UnifiedInboundCode:
{name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}}
UnifiedInternalOrderStatus:
@@ -19,6 +19,38 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class UnifiedOrderApiContractTest {
/** 全局Long ID配置启用时,数量必须仍为JSON数字。 */
@Test
void statusCountValuesRemainNumbersWithLongIdSerializationEnabled() throws Exception {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
var module = new com.fasterxml.jackson.databind.module.SimpleModule();
module.addSerializer(Long.class, com.fasterxml.jackson.databind.ser.std.ToStringSerializer.instance);
mapper.registerModule(module);
var json = mapper.readTree(mapper.writeValueAsString(new com.baiyue.order.service.UnifiedOrderQueryService.StatusCounts(
Map.of("LOADED", 1205L))));
assertThat(json.path("counts").path("LOADED").isIntegralNumber()).isTrue();
assertThat(json.path("counts").path("LOADED").longValue()).isEqualTo(1205L);
}
/** 页签数量沿用正式列表权限,不额外开放查询域。 */
@Test
void statusCountsKeepListPermissionAndFilterContract() throws Exception {
Method method = PlatformUnifiedOrderController.class.getMethod("statusCounts",
com.baiyue.common.security.AuthenticatedUser.class, Long.class, com.baiyue.order.dto.UnifiedOrderStatusCountQuery.class);
assertThat(method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value())
.containsExactly("/status-counts");
assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:order:list')");
try (var input = new ClassPathResource("openapi/order-v1.yaml").getInputStream()) {
Map<String, Object> document = new Yaml().load(input);
var paths = stringMap(document.get("paths"));
var counts = stringMap(stringMap(paths.get("/api/platform/v1/orders/status-counts")).get("get"));
String parameters = counts.get("parameters").toString();
assertThat(parameters).contains("UnifiedTaxMode", "UnifiedFbaCode", "UnifiedCreatedFrom", "UnifiedMeasurementReviewStatus")
.doesNotContain("UnifiedMainStatus", "UnifiedPage", "UnifiedSize");
}
}
@Test
void keepsPlatformAndMerchantBoundaries() {
assertThat(PlatformUnifiedOrderController.class.getAnnotation(RequestMapping.class).value())
@@ -43,7 +75,9 @@ class UnifiedOrderApiContractTest {
Map<String, Object> schema = stringMap(mainStatus.get("schema"));
assertThat(stringList(schema.get("enum"))).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED", "COMPLETED");
"LOADING", "LOADED", "DELIVERED", "COMPLETED",
"WAIT_WEIGHT_CONFIRM", "WEIGHT_CONFIRMED", "WAIT_CUSTOMS_DOCS", "EXPORT_CUSTOMS",
"SAILING_CLEARANCE", "WAIT_UNLOAD", "WAIT_DISPATCH", "OUTBOUND_DOCUMENT", "DELIVERING", "SIGNED", "MEASUREMENT_REVIEW");
assertThat(mainStatus.get("description").toString())
.contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED");
@@ -59,7 +93,9 @@ class UnifiedOrderApiContractTest {
assertThat(queryAnnotation).isNotNull();
assertThat(queryAnnotation.allowableValues()).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED", "COMPLETED");
"LOADING", "LOADED", "DELIVERED", "COMPLETED",
"WAIT_WEIGHT_CONFIRM", "WEIGHT_CONFIRMED", "WAIT_CUSTOMS_DOCS", "EXPORT_CUSTOMS",
"SAILING_CLEARANCE", "WAIT_UNLOAD", "WAIT_DISPATCH", "OUTBOUND_DOCUMENT", "DELIVERING", "SIGNED", "MEASUREMENT_REVIEW");
Map<String, Object> row = stringMap(schemas.get("UnifiedOrderRow"));
assertThat(stringMap(row.get("properties"))).containsKeys(
@@ -75,6 +75,17 @@ class UnifiedOrderTaxModePaginationTest {
assertPage(null, 2, 4, 4);
}
/** 轻量统计使用完整匹配集,并保留税种、客户及软删除条件。 */
@Test
void statusProjectionIgnoresPagingWithoutLeakingDeletedOrForeignOrders() {
var query = new UnifiedOrderQuery(null, null, null, null, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, 2, 1, "TAX_REFUND");
assertThat(repository.unifiedStatusOrders(11L, query, List.of(com.baiyue.order.entity.OrderStatus.APPROVED)))
.extracting(OrderEntity::getId).containsExactly(6L, 2L);
assertThat(repository.statusAuthorities(11L, List.of(2L, 3L, 7L)))
.extracting(OrderEntity::getId).containsExactly(2L);
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings = {"TAX_REFUND", "BUY_DECLARATION"})
void realSplitChildIsPersistedAndIncludedInTaxFilteredParentChildPages(String taxMode) {
@@ -586,8 +586,7 @@ class UnifiedOrderQueryServiceTest {
void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() {
UnifiedOrderQuery query = query("INBOUNDED");
var summary = summary("INBOUNDED", "CONFIRM_PRELOAD");
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
stubStatusProjection(List.of(summary), 1);
var result = service.page(null, query);
@@ -617,8 +616,7 @@ class UnifiedOrderQueryServiceTest {
UnifiedOrderQuery query = query("INBOUNDED");
var summary = summary("INBOUNDED", null);
when(summary.inbound().actualNetWeightKg()).thenReturn(null);
when(firstleg.page(eq("internal-token"), any())).thenReturn(
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
stubStatusProjection(List.of(summary), 1);
var row = service.page(null, query).records().get(0);
@@ -631,8 +629,7 @@ class UnifiedOrderQueryServiceTest {
UnifiedOrderQuery query = query("INBOUNDED");
var summary = summary("INBOUNDED", null);
when(summary.inbound().locationCodes()).thenReturn("A0、 A0, A0,A0");
when(firstleg.page(eq("internal-token"), any())).thenReturn(
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
stubStatusProjection(List.of(summary), 1);
var row = service.page(null, query).records().get(0);
@@ -643,8 +640,7 @@ class UnifiedOrderQueryServiceTest {
void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() {
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)));
stubStatusProjection(List.of(summary), 1);
when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING)));
var result = service.page(10001L, query);
@@ -653,17 +649,16 @@ class UnifiedOrderQueryServiceTest {
.extracting(row -> row.mainStatus()).isEqualTo("WAIT_SPLIT");
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captor.capture());
verify(firstleg).statusRows(eq("internal-token"), captor.capture());
assertThat(captor.getValue().tenantId()).isEqualTo(10001L);
assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT");
assertThat(captor.getValue().mainStatus()).isNull();
}
@Test
void waitPalletizeFilterReturnsDropshipProjectionAndForwardsToFirstleg() {
UnifiedOrderQuery query = query("WAIT_PALLETIZE");
var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP");
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
stubStatusProjection(List.of(summary), 1);
when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING)));
var result = service.page(10001L, query);
@@ -676,8 +671,8 @@ class UnifiedOrderQueryServiceTest {
});
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captor.capture());
assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_PALLETIZE");
verify(firstleg).statusRows(eq("internal-token"), captor.capture());
assertThat(captor.getValue().mainStatus()).isNull();
}
@Test
@@ -800,8 +795,7 @@ class UnifiedOrderQueryServiceTest {
var summary = summary("INBOUNDED", null);
when(summary.inbound().holdFlag()).thenReturn(false);
when(summary.inbound().currentHoldReason()).thenReturn(null);
when(firstleg.page(eq("internal-token"), any())).thenReturn(
ApiResponse.success(new PageResult<>(List.of(summary), 4, 10, 31, 4)));
stubStatusProjection(List.of(summary), 31);
var result = service.page(10001L, query);
@@ -815,10 +809,10 @@ class UnifiedOrderQueryServiceTest {
});
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captor.capture());
assertThat(captor.getValue().mainStatus()).isEqualTo("INBOUNDED");
assertThat(captor.getValue().page()).isEqualTo(4);
assertThat(captor.getValue().size()).isEqualTo(10);
verify(firstleg).statusRows(eq("internal-token"), captor.capture());
assertThat(captor.getValue().mainStatus()).isNull();
assertThat(captor.getValue().page()).isEqualTo(1);
assertThat(captor.getValue().size()).isEqualTo(200);
}
@Test
@@ -830,8 +824,7 @@ class UnifiedOrderQueryServiceTest {
when(summary.inbound().orderId()).thenReturn(orderId);
when(summary.inbound().id()).thenReturn(inboundId);
when(summary.inbound().tenantId()).thenReturn(tenantId);
when(firstleg.page(eq("internal-token"), any())).thenReturn(
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
stubStatusProjection(List.of(summary), 1);
assertThat(service.page(null, query("INBOUNDED")).records()).singleElement().satisfies(row -> {
assertThat(row.orderId()).isEqualTo(orderId);
@@ -862,8 +855,7 @@ class UnifiedOrderQueryServiceTest {
void missingHoldFlagFailsClosed() {
var summary = summary("INBOUNDED", null);
when(summary.inbound().holdFlag()).thenReturn(null);
when(firstleg.page(eq("internal-token"), any())).thenReturn(
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
stubStatusProjection(List.of(summary), 1);
assertServiceUnavailable(() -> service.page(null, query("INBOUNDED")));
}
@@ -872,8 +864,7 @@ class UnifiedOrderQueryServiceTest {
void heldInboundWithoutReasonFailsClosed() {
var summary = summary("INBOUNDED", null);
when(summary.inbound().currentHoldReason()).thenReturn(" ");
when(firstleg.page(eq("internal-token"), any())).thenReturn(
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
stubStatusProjection(List.of(summary), 1);
assertServiceUnavailable(() -> service.page(null, query("INBOUNDED")));
}
@@ -883,8 +874,7 @@ class UnifiedOrderQueryServiceTest {
var summary = summary("INBOUNDED", null);
when(summary.inbound().holdFlag()).thenReturn(false);
when(summary.inbound().currentHoldReason()).thenReturn("旧扣货原因");
when(firstleg.page(eq("internal-token"), any())).thenReturn(
ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
stubStatusProjection(List.of(summary), 1);
assertServiceUnavailable(() -> service.page(null, query("INBOUNDED")));
}
@@ -905,7 +895,7 @@ class UnifiedOrderQueryServiceTest {
@Test
void firstlegCallExceptionFailsClosed() {
when(firstleg.page(eq("internal-token"), any())).thenThrow(new IllegalStateException("boom"));
when(firstleg.statusRows(eq("internal-token"), any())).thenThrow(new IllegalStateException("boom"));
assertServiceUnavailable(() -> service.page(null, query("INBOUNDED")));
}
@@ -914,8 +904,7 @@ class UnifiedOrderQueryServiceTest {
void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() {
List<String> statuses = List.of(
"WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 3, 20, 0, 0)));
stubStatusProjection(List.of(), 0);
statuses.forEach(status -> service.page(null, new UnifiedOrderQuery(
"CC-2026", "FL-77", status, null, null, null, null, null,
@@ -925,14 +914,14 @@ class UnifiedOrderQueryServiceTest {
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg, org.mockito.Mockito.times(statuses.size()))
.page(eq("internal-token"), captor.capture());
.statusRows(eq("internal-token"), captor.capture());
IntStream.range(0, statuses.size()).forEach(index ->
assertThat(captor.getAllValues().get(index)).satisfies(request -> {
assertThat(request.mainStatus()).isEqualTo(statuses.get(index));
assertThat(request.mainStatus()).isNull();
assertThat(request.keyword()).isEqualTo("CC-2026");
assertThat(request.orderNo()).isEqualTo("FL-77");
assertThat(request.page()).isEqualTo(3);
assertThat(request.size()).isEqualTo(20);
assertThat(request.page()).isEqualTo(1);
assertThat(request.size()).isEqualTo(200);
}));
}
@@ -944,14 +933,13 @@ class UnifiedOrderQueryServiceTest {
LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", "FBA-1",
null, null, 1, 20, null);
when(orders.findOrderIdsBySkuNo(10001L, "FBA-1")).thenReturn(List.of(77L));
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 20, 0, 0)));
stubStatusProjection(List.of(), 0);
service.page(10001L, query);
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captor.capture());
verify(firstleg).statusRows(eq("internal-token"), captor.capture());
assertThat(captor.getValue()).satisfies(request -> {
assertThat(request.inboundCode()).isEqualTo("RK-77");
assertThat(request.internalOrderStatus()).isEqualTo("APPROVED");
@@ -978,6 +966,101 @@ class UnifiedOrderQueryServiceTest {
verify(firstleg, never()).page(any(), any());
}
/** 全量重复投影和审核/完成优先不能污染其他页签,也不能受当前页限制。 */
@Test
void statusCountsDeduplicateAndHonorAuthoritativeStatesBeyondCurrentPage() {
var pending = order(OrderStatus.SUBMITTED);
var completed = order(OrderStatus.COMPLETED); completed.setId(78L);
var active = order(OrderStatus.PROCESSING); active.setId(79L);
when(orders.unifiedStatusOrders(eq(10001L), any(), any())).thenReturn(List.of(pending, completed));
when(orders.statusAuthorities(eq(10001L), any())).thenReturn(List.of(pending, completed, active));
var rows = new java.util.ArrayList<FirstlegUnifiedOrderClient.StatusRow>();
rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "WAIT_MEASURE"));
rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "LOADING"));
rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 78L, "LOADED"));
rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 79L, "WAIT_SPLIT"));
rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 79L, "LOADING"));
rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 80L, "LOADED"));
when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(rows));
var result = service.statusCounts(10001L, taxQuery("LOADED", "TAX_REFUND"));
assertThat(result.counts()).containsEntry("PENDING_REVIEW", 1L).containsEntry("COMPLETED", 1L)
.containsEntry("WAIT_SPLIT", 1L).containsEntry("LOADING", 1L).containsEntry("LOADED", 0L)
.containsEntry("WAIT_MEASURE", 0L).containsEntry("MEASUREMENT_REVIEW", 0L).doesNotContainKeys("ALL");
verify(firstleg, never()).summaries(any(), any());
verify(firstleg, never()).page(any(), any());
}
/** 履约条件下的待审核订单保留在审核页签,先校正再分页。 */
@Test
void operationalFiltersKeepPendingReviewAndMatchCountsToListTotal() {
var query = new UnifiedOrderQuery(null, null, "PENDING_REVIEW", null, null, null, null, null,
null, null, null, null, null, null, null, null, "PENDING", null, null, null, 1, 1, null);
var pending = order(OrderStatus.SUBMITTED);
when(orders.statusAuthorities(eq(10001L), any())).thenReturn(List.of(pending));
when(orders.findByIds(eq(10001L), any())).thenReturn(List.of(pending));
when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of(
new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "WAIT_MEASURE"))));
var projected = summary("WAIT_MEASURE", null);
when(firstleg.statusSummaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of(projected)));
var counts = service.statusCounts(10001L, query);
var page = service.page(10001L, query);
assertThat(counts.counts()).containsEntry("PENDING_REVIEW", 1L).containsEntry("WAIT_MEASURE", 0L);
assertThat(page.total()).isEqualTo(counts.counts().get("PENDING_REVIEW"));
assertThat(page.records()).singleElement().satisfies(row -> {
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER");
});
}
/** 错误响应和跨客户投影均失败关闭,不能返回看似可信的零。 */
@Test
void statusCountsRejectDependencyFailuresAndForeignCustomerRows() {
when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE, "offline", null, null));
assertServiceUnavailable(() -> service.statusCounts(10001L, query(null)));
when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of(
new FirstlegUnifiedOrderClient.StatusRow(20002L, 77L, "LOADED"))));
assertServiceUnavailable(() -> service.statusCounts(10001L, query(null)));
}
/** 超过分页上限的匹配订单仍全量计数,权威状态校验采用有界批次。 */
@Test
void countsAll1205OrdersUsingThreeAuthorityBatches() {
var rows = IntStream.range(0, 1205).mapToObj(index ->
new FirstlegUnifiedOrderClient.StatusRow(10001L, (long) index, "LOADED")).toList();
when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(rows));
when(orders.statusAuthorities(eq(10001L), any())).thenAnswer(invocation -> {
List<Long> ids = invocation.getArgument(1);
return ids.stream().map(id -> {
var item = order(OrderStatus.PROCESSING); item.setId(id); return item;
}).toList();
});
assertThat(service.statusCounts(10001L, taxQuery("WAIT_SPLIT", null)).counts())
.containsEntry("LOADED", 1205L).containsEntry("WAIT_SPLIT", 0L);
verify(orders, org.mockito.Mockito.times(3)).statusAuthorities(eq(10001L), any());
}
/** 使用轻量投影准备全量匹配集,摘要仍只读取当前页。 */
private void stubStatusProjection(List<FirstlegUnifiedOrderClient.Summary> summaries, long total) {
var rows = new java.util.ArrayList<FirstlegUnifiedOrderClient.StatusRow>();
for (int i = summaries.size(); i < total; i++) {
var sample = summaries.get(0);
rows.add(new FirstlegUnifiedOrderClient.StatusRow(sample.inbound().tenantId(), 1000L + i, sample.mainStatus()));
}
summaries.forEach(summary -> rows.add(new FirstlegUnifiedOrderClient.StatusRow(
summary.inbound().tenantId(), summary.inbound().orderId(), summary.mainStatus())));
when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(rows));
when(firstleg.statusSummaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(summaries));
when(orders.statusAuthorities(any(), any())).thenAnswer(invocation -> {
List<Long> ids = invocation.getArgument(1);
return rows.stream().filter(row -> ids.contains(row.orderId())).map(row -> {
OrderEntity authority = order(OrderStatus.PROCESSING);
authority.setId(row.orderId());
authority.setTenantId(row.tenantId());
return authority;
}).toList();
});
}
private UnifiedOrderQuery query(String status) {
return new UnifiedOrderQuery(null, null, status, null, null, null,
null, null, null, null, null, null, null, null, null, null, null, null,
@@ -1000,15 +1083,14 @@ class UnifiedOrderQueryServiceTest {
service.page(10001L, direct);
verify(orders).unifiedPage(eq(10001L), eq(direct), any());
org.mockito.Mockito.verify(firstleg, org.mockito.Mockito.never()).page(any(), any());
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 2, 1, 0, 0)));
stubStatusProjection(List.of(), 0);
service.page(10001L, taxQuery("LOADED", taxMode));
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captured = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captured.capture());
verify(firstleg).statusRows(eq("internal-token"), captured.capture());
assertThat(captured.getValue().taxMode()).isEqualTo(taxMode);
assertThat(captured.getValue().tenantId()).isEqualTo(10001L);
assertThat(captured.getValue().page()).isEqualTo(2);
assertThat(captured.getValue().size()).isEqualTo(1);
assertThat(captured.getValue().page()).isEqualTo(1);
assertThat(captured.getValue().size()).isEqualTo(200);
}
@org.junit.jupiter.params.ParameterizedTest