优化
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.order.client;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import org.springframework.cloud.openfeign.FeignClient;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Typed internal read contract from Order service to Firstleg service. */
|
||||
@FeignClient(name = "firstleg-service", contextId = "orderFirstlegUnifiedOrderClient",
|
||||
url = "${baiyue.clients.firstleg.url:}", path = "/internal/firstleg/v1/inbounds/unified-orders")
|
||||
public interface FirstlegUnifiedOrderClient {
|
||||
@PostMapping("/query")
|
||||
ApiResponse<PageResult<Summary>> page(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody Query request);
|
||||
|
||||
@PostMapping("/summaries")
|
||||
ApiResponse<List<Summary>> summaries(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody BatchRequest request);
|
||||
|
||||
@PostMapping("/detail")
|
||||
ApiResponse<Detail> detail(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody DetailRequest request);
|
||||
|
||||
record Query(Long tenantId, String keyword, String orderNo, String mainStatus, Long customerId,
|
||||
Long receivingWarehouseId, Long destinationWarehouseId,
|
||||
LocalDateTime createdFrom, LocalDateTime createdTo, long page, long size) {}
|
||||
record BatchRequest(Long tenantId, List<Long> orderIds) {}
|
||||
record DetailRequest(long tenantId, long orderId) {}
|
||||
|
||||
record Summary(Inbound inbound, String mainStatus, String nextAction,
|
||||
Preload preload, Loading loading, Delivery delivery) {}
|
||||
record Detail(Summary summary, List<Timeline> timeline) {}
|
||||
record Inbound(Long id, Long tenantId, Long orderId, String orderNo, String inboundCode,
|
||||
String processStatus, String orderStatusSnapshot, String measureReviewStatus,
|
||||
Integer version,
|
||||
Long customerId, String customerName, Long warehouseId, String warehouseName,
|
||||
Long deliveryWarehouseId, String deliveryWarehouseName, String channelName,
|
||||
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
|
||||
BigDecimal chargeableWeightKg, LocalDateTime measureCompletedAt,
|
||||
String loadingStatus, String containerNo, String cabinetNo, String vesselName, String voyageNo,
|
||||
LocalDateTime sailingAt, LocalDateTime portArrivalAt,
|
||||
String expressTrackingNo, LocalDateTime createdAt, LocalDateTime updatedAt) {}
|
||||
record Preload(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {}
|
||||
record Loading(String status, Integer loadingVersion, Integer pickingVersion,
|
||||
Integer expectedCount, Integer pickedCount, Integer evidenceCount,
|
||||
String sealNo, LocalDateTime updatedAt) {}
|
||||
record Delivery(String status, LocalDateTime completedAt, String trackingNo) {}
|
||||
record Timeline(String source, String eventType, String fromStatus, String toStatus,
|
||||
String action, String remark, Long operatorId, String operatorName,
|
||||
LocalDateTime occurredAt) {}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import com.baiyue.order.dto.UnifiedOrderResponse;
|
||||
import com.baiyue.order.service.OrderScopeGuard;
|
||||
import com.baiyue.order.service.UnifiedOrderQueryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Merchant product-level order query restricted to the authenticated customer. */
|
||||
@Tag(name = "Merchant unified orders / 客户统一订单")
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/orders")
|
||||
public class MerchantUnifiedOrderController {
|
||||
@Resource private UnifiedOrderQueryService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('merchant:order:list')")
|
||||
@Operation(summary = "我的统一订单列表")
|
||||
public ApiResponse<PageResult<UnifiedOrderResponse.Row>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid UnifiedOrderQuery query) {
|
||||
return ApiResponse.success(service.page(OrderScopeGuard.merchant(user), query));
|
||||
}
|
||||
|
||||
@GetMapping("/{orderId}")
|
||||
@PreAuthorize("hasAuthority('merchant:order:view')")
|
||||
@Operation(summary = "我的统一订单详情", description = "仅返回当前客户订单,不包含平台内部备注。")
|
||||
public ApiResponse<UnifiedOrderResponse.Detail> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable long orderId) {
|
||||
return ApiResponse.success(service.detail(OrderScopeGuard.merchant(user), orderId, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import com.baiyue.order.dto.UnifiedOrderResponse;
|
||||
import com.baiyue.order.service.OrderScopeGuard;
|
||||
import com.baiyue.order.service.UnifiedOrderQueryService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Platform product-level order query. Legacy first-leg paths remain available. */
|
||||
@Tag(name = "Platform unified orders / 平台统一订单")
|
||||
@RestController
|
||||
@RequestMapping("/api/platform/v1/orders")
|
||||
public class PlatformUnifiedOrderController {
|
||||
@Resource private UnifiedOrderQueryService service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasAuthority('platform:order:list')")
|
||||
@Operation(summary = "统一订单列表", description = "返回七态产品主状态及测量、预装、装柜、派送摘要。")
|
||||
public ApiResponse<PageResult<UnifiedOrderResponse.Row>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam(required = false) Long tenantId,
|
||||
@Valid UnifiedOrderQuery query) {
|
||||
OrderScopeGuard.platform(user);
|
||||
if (tenantId != null) OrderScopeGuard.platform(user, tenantId);
|
||||
return ApiResponse.success(service.page(tenantId, query));
|
||||
}
|
||||
|
||||
@GetMapping("/{orderId}")
|
||||
@PreAuthorize("hasAuthority('platform:order:list')")
|
||||
@Operation(summary = "统一订单详情", description = "合并订单和履约时间线;平台响应包含内部备注。")
|
||||
public ApiResponse<UnifiedOrderResponse.Detail> detail(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@RequestParam long tenantId,
|
||||
@PathVariable long orderId) {
|
||||
return ApiResponse.success(service.detail(OrderScopeGuard.platform(user, tenantId), orderId, true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.order.dto;
|
||||
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** Unified product order list filters shared by platform and merchant entry points. */
|
||||
public record UnifiedOrderQuery(
|
||||
@Size(max = 64) String keyword,
|
||||
@Size(max = 64) String orderNo,
|
||||
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT",
|
||||
"LOADING", "LOADED", "DELIVERED"})
|
||||
@Size(max = 32) String mainStatus,
|
||||
Long customerId,
|
||||
Long receivingWarehouseId,
|
||||
Long destinationWarehouseId,
|
||||
LocalDateTime createdFrom,
|
||||
LocalDateTime createdTo,
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size
|
||||
) {
|
||||
public UnifiedOrderQuery {
|
||||
if (page == 0) page = 1;
|
||||
if (size == 0) size = 20;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.baiyue.order.dto;
|
||||
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Stable product-level order list/detail responses. */
|
||||
public final class UnifiedOrderResponse {
|
||||
private UnifiedOrderResponse() {}
|
||||
|
||||
public record Row(
|
||||
Long orderId, Long tenantId, String orderNo, String mainStatus, String nextAction,
|
||||
OrderStatus internalOrderStatus,
|
||||
Long customerId, String customerName, String channelName,
|
||||
Long receivingWarehouseId, String receivingWarehouseName,
|
||||
Long destinationWarehouseId, String destinationWarehouseName,
|
||||
Long inboundId, Integer inboundVersion, String inboundCode, LocalDate expectedShipDate,
|
||||
Integer totalPackageCount, BigDecimal actualGrossWeightKg, BigDecimal actualVolumeCbm,
|
||||
MeasurementSummary measurement, PreloadSummary preload,
|
||||
LoadingSummary loading, DeliverySummary delivery, TransportSummary transport,
|
||||
LocalDateTime createdAt, LocalDateTime updatedAt
|
||||
) {}
|
||||
|
||||
public record Detail(Row summary, OrderDetailResponse order,
|
||||
List<TimelineEvent> timeline) {}
|
||||
|
||||
public record MeasurementSummary(String reviewStatus, BigDecimal actualGrossWeightKg,
|
||||
BigDecimal actualVolumeCbm, BigDecimal chargeableWeightKg,
|
||||
LocalDateTime completedAt) {}
|
||||
public record PreloadSummary(String status, Integer version, Integer itemCount, LocalDateTime updatedAt) {}
|
||||
/** loadingVersion currently aliases pickingVersion; inboundVersion is the C04 loading expectedVersion. */
|
||||
public record LoadingSummary(String status, Integer loadingVersion, Integer pickingVersion,
|
||||
Integer expectedCount, Integer pickedCount, Integer evidenceCount,
|
||||
String sealNo, LocalDateTime updatedAt) {}
|
||||
public record DeliverySummary(String status, LocalDateTime completedAt, String trackingNo) {}
|
||||
public record TransportSummary(String loadingStatus, String containerNo, String cabinetNo,
|
||||
String vesselName, String voyageNo, LocalDateTime sailingAt,
|
||||
LocalDateTime portArrivalAt) {}
|
||||
public record TimelineEvent(String source, String eventType, String mainStatus, String action,
|
||||
String remark, Long operatorId, String operatorName,
|
||||
LocalDateTime occurredAt) {}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import jakarta.annotation.Resource;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.dto.MerchantInboundOrderQuery;
|
||||
import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import com.baiyue.order.entity.*;
|
||||
import com.baiyue.order.mapper.*;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
@@ -154,6 +155,26 @@ public class OrderRepository {
|
||||
return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
|
||||
}
|
||||
|
||||
public PageResult<OrderEntity> unifiedPage(Long tenantId, UnifiedOrderQuery query,
|
||||
List<OrderStatus> statuses) {
|
||||
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<OrderEntity>()
|
||||
.eq(tenantId != null, OrderEntity::getTenantId, tenantId)
|
||||
.in(OrderEntity::getStatus, statuses)
|
||||
.like(StringUtils.hasText(query.orderNo()), OrderEntity::getOrderNo, query.orderNo())
|
||||
.eq(query.customerId() != null, OrderEntity::getCustomerId, query.customerId())
|
||||
.eq(query.receivingWarehouseId() != null, OrderEntity::getReceivingWarehouseId,
|
||||
query.receivingWarehouseId())
|
||||
.eq(query.destinationWarehouseId() != null, OrderEntity::getDestinationWarehouseId,
|
||||
query.destinationWarehouseId())
|
||||
.ge(query.createdFrom() != null, OrderEntity::getCreatedAt, query.createdFrom())
|
||||
.le(query.createdTo() != null, OrderEntity::getCreatedAt, query.createdTo())
|
||||
.and(StringUtils.hasText(query.keyword()), nested -> nested
|
||||
.like(OrderEntity::getOrderNo, query.keyword())
|
||||
.or().like(OrderEntity::getCustomerName, query.keyword()))
|
||||
.orderByDesc(OrderEntity::getCreatedAt).orderByDesc(OrderEntity::getId);
|
||||
return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper));
|
||||
}
|
||||
|
||||
public List<OrderCargoEntity> findCargoByOrderIds(long tenantId, List<Long> orderIds) {
|
||||
if (orderIds.isEmpty()) return List.of();
|
||||
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
|
||||
import com.baiyue.order.dto.OrderDetailResponse;
|
||||
import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import com.baiyue.order.dto.UnifiedOrderResponse;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import feign.FeignException;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Product-level read façade. Existing Order and Firstleg state machines stay unchanged. */
|
||||
@Service
|
||||
public class UnifiedOrderQueryService {
|
||||
private static final Set<String> MAIN_STATUSES = Set.of(
|
||||
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
|
||||
private static final List<OrderStatus> ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of(
|
||||
OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED,
|
||||
OrderStatus.PROCESSING, OrderStatus.COMPLETED));
|
||||
|
||||
@Resource private OrderRepository orders;
|
||||
@Resource private FirstlegUnifiedOrderClient firstleg;
|
||||
@Value("${baiyue.security.internal-service-token}") private String internalToken;
|
||||
|
||||
public PageResult<UnifiedOrderResponse.Row> page(Long tenantId, UnifiedOrderQuery query) {
|
||||
validate(query);
|
||||
if (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus())) {
|
||||
return pageFromFirstleg(tenantId, query);
|
||||
}
|
||||
List<OrderStatus> statuses = "PENDING_REVIEW".equals(query.mainStatus())
|
||||
? List.of(OrderStatus.SUBMITTED) : ACTIVE_ORDER_STATUSES;
|
||||
PageResult<OrderEntity> source = orders.unifiedPage(tenantId, query, statuses);
|
||||
Map<Long, FirstlegUnifiedOrderClient.Summary> inboundByOrder = summaries(
|
||||
tenantId, source.records().stream().map(OrderEntity::getId).toList());
|
||||
List<UnifiedOrderResponse.Row> rows = source.records().stream()
|
||||
.map(order -> row(order, inboundByOrder.get(order.getId()))).toList();
|
||||
return new PageResult<>(rows, source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
public UnifiedOrderResponse.Detail detail(long tenantId, long orderId, boolean includeInternalNotes) {
|
||||
OrderEntity order = orders.findOwned(tenantId, orderId);
|
||||
if (order == null) throw new BizException(ResultCode.NOT_FOUND, "订单不存在");
|
||||
OrderDetailResponse orderDetail = new OrderDetailResponse(order, orders.findCargo(tenantId, orderId),
|
||||
orders.findParties(tenantId, orderId), includeInternalNotes ? orders.findNotes(tenantId, orderId) : List.of(),
|
||||
orders.findHistory(tenantId, orderId));
|
||||
FirstlegUnifiedOrderClient.Detail inbound = firstlegDetail(tenantId, orderId);
|
||||
UnifiedOrderResponse.Row summary = row(order, inbound == null ? null : inbound.summary());
|
||||
List<UnifiedOrderResponse.TimelineEvent> timeline = new ArrayList<>();
|
||||
orderDetail.statusHistory().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
|
||||
"ORDER", "STATUS", orderMainStatus(item.getToStatus()), item.getAction(), item.getReason(),
|
||||
item.getOperatorId(), item.getOperatorName(), item.getOccurredAt())));
|
||||
if (inbound != null && inbound.timeline() != null) {
|
||||
inbound.timeline().forEach(item -> timeline.add(new UnifiedOrderResponse.TimelineEvent(
|
||||
item.source(), item.eventType(), inboundMainStatus(item.toStatus()), item.action(), item.remark(),
|
||||
item.operatorId(), item.operatorName(), item.occurredAt())));
|
||||
}
|
||||
timeline.sort(Comparator.comparing(UnifiedOrderResponse.TimelineEvent::occurredAt,
|
||||
Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
return new UnifiedOrderResponse.Detail(summary, orderDetail, timeline);
|
||||
}
|
||||
|
||||
private PageResult<UnifiedOrderResponse.Row> pageFromFirstleg(Long tenantId, UnifiedOrderQuery query) {
|
||||
FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query(
|
||||
tenantId, query.keyword(), query.orderNo(), query.mainStatus(), query.customerId(),
|
||||
query.receivingWarehouseId(), query.destinationWarehouseId(), query.createdFrom(), query.createdTo(),
|
||||
query.page(), query.size());
|
||||
PageResult<FirstlegUnifiedOrderClient.Summary> source = require(
|
||||
call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效");
|
||||
List<UnifiedOrderResponse.Row> records = source.records().stream().map(this::row).toList();
|
||||
return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages());
|
||||
}
|
||||
|
||||
private Map<Long, FirstlegUnifiedOrderClient.Summary> summaries(Long tenantId, List<Long> orderIds) {
|
||||
if (orderIds.isEmpty()) return Map.of();
|
||||
List<FirstlegUnifiedOrderClient.Summary> data = require(
|
||||
call(() -> firstleg.summaries(internalToken,
|
||||
new FirstlegUnifiedOrderClient.BatchRequest(tenantId, orderIds))),
|
||||
"统一订单履约摘要响应无效");
|
||||
return data.stream().collect(Collectors.toMap(item -> item.inbound().orderId(),
|
||||
Function.identity(), (left, right) -> left));
|
||||
}
|
||||
|
||||
private FirstlegUnifiedOrderClient.Detail firstlegDetail(long tenantId, long orderId) {
|
||||
return requireNullable(call(() -> firstleg.detail(internalToken,
|
||||
new FirstlegUnifiedOrderClient.DetailRequest(tenantId, orderId))), "统一订单履约详情响应无效");
|
||||
}
|
||||
|
||||
private UnifiedOrderResponse.Row row(OrderEntity order, FirstlegUnifiedOrderClient.Summary summary) {
|
||||
if (summary == null) {
|
||||
String mainStatus = orderMainStatus(order.getStatus());
|
||||
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(), mainStatus,
|
||||
order.getStatus() == OrderStatus.SUBMITTED ? "APPROVE_ORDER" : null,
|
||||
order.getStatus(), order.getCustomerId(), order.getCustomerName(),
|
||||
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
|
||||
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), null, null, null,
|
||||
order.getExpectedShipDate(), null, null, null, null, null, null, null, null,
|
||||
order.getCreatedAt(), order.getUpdatedAt());
|
||||
}
|
||||
FirstlegUnifiedOrderClient.Inbound inbound = summary.inbound();
|
||||
return new UnifiedOrderResponse.Row(order.getId(), order.getTenantId(), order.getOrderNo(),
|
||||
summary.mainStatus(), summary.nextAction(), order.getStatus(), order.getCustomerId(), order.getCustomerName(),
|
||||
order.getChannelName(), order.getReceivingWarehouseId(), order.getReceivingWarehouseName(),
|
||||
order.getDestinationWarehouseId(), order.getDestinationWarehouseName(), inbound.id(), inbound.version(), inbound.inboundCode(),
|
||||
order.getExpectedShipDate(), inbound.totalPackageCount(), inbound.actualGrossWeightKg(),
|
||||
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
|
||||
delivery(summary.delivery()), transport(inbound), order.getCreatedAt(), order.getUpdatedAt());
|
||||
}
|
||||
|
||||
private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary) {
|
||||
var inbound = summary.inbound();
|
||||
return new UnifiedOrderResponse.Row(inbound.orderId(), inbound.tenantId(), inbound.orderNo(),
|
||||
summary.mainStatus(), summary.nextAction(), parseOrderStatus(inbound.orderStatusSnapshot()),
|
||||
inbound.customerId(), inbound.customerName(), inbound.channelName(), inbound.warehouseId(),
|
||||
inbound.warehouseName(), inbound.deliveryWarehouseId(), inbound.deliveryWarehouseName(),
|
||||
inbound.id(), inbound.version(), inbound.inboundCode(), null, inbound.totalPackageCount(), inbound.actualGrossWeightKg(),
|
||||
inbound.actualVolumeCbm(), measurement(inbound), preload(summary.preload()), loading(summary.loading()),
|
||||
delivery(summary.delivery()), transport(inbound), inbound.createdAt(), inbound.updatedAt());
|
||||
}
|
||||
|
||||
private UnifiedOrderResponse.MeasurementSummary measurement(FirstlegUnifiedOrderClient.Inbound inbound) {
|
||||
return new UnifiedOrderResponse.MeasurementSummary(inbound.measureReviewStatus(),
|
||||
inbound.actualGrossWeightKg(), inbound.actualVolumeCbm(), inbound.chargeableWeightKg(),
|
||||
inbound.measureCompletedAt());
|
||||
}
|
||||
private UnifiedOrderResponse.PreloadSummary preload(FirstlegUnifiedOrderClient.Preload value) {
|
||||
return value == null ? null : new UnifiedOrderResponse.PreloadSummary(
|
||||
value.status(), value.version(), value.itemCount(), value.updatedAt());
|
||||
}
|
||||
private UnifiedOrderResponse.LoadingSummary loading(FirstlegUnifiedOrderClient.Loading value) {
|
||||
return value == null ? null : new UnifiedOrderResponse.LoadingSummary(value.status(), value.loadingVersion(),
|
||||
value.pickingVersion(), value.expectedCount(), value.pickedCount(), value.evidenceCount(),
|
||||
value.sealNo(), value.updatedAt());
|
||||
}
|
||||
private UnifiedOrderResponse.DeliverySummary delivery(FirstlegUnifiedOrderClient.Delivery value) {
|
||||
return value == null ? null : new UnifiedOrderResponse.DeliverySummary(
|
||||
value.status(), value.completedAt(), value.trackingNo());
|
||||
}
|
||||
private UnifiedOrderResponse.TransportSummary transport(FirstlegUnifiedOrderClient.Inbound inbound) {
|
||||
return new UnifiedOrderResponse.TransportSummary(inbound.loadingStatus(), inbound.containerNo(),
|
||||
inbound.cabinetNo(), inbound.vesselName(), inbound.voyageNo(), inbound.sailingAt(), inbound.portArrivalAt());
|
||||
}
|
||||
|
||||
private String orderMainStatus(OrderStatus status) {
|
||||
if (status == null) return null;
|
||||
return status == OrderStatus.SUBMITTED ? "PENDING_REVIEW"
|
||||
: ACTIVE_ORDER_STATUSES.contains(status) ? "WAIT_MEASURE" : null;
|
||||
}
|
||||
private String inboundMainStatus(String status) {
|
||||
if (status == null) return null;
|
||||
return switch (status) {
|
||||
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE";
|
||||
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT" -> "WAIT_SPLIT";
|
||||
case "INBOUNDED", "LOADING", "LOADED", "DELIVERED" -> status;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
private OrderStatus parseOrderStatus(String value) {
|
||||
try { return value == null ? null : OrderStatus.valueOf(value); }
|
||||
catch (IllegalArgumentException ignored) { return null; }
|
||||
}
|
||||
|
||||
private void validate(UnifiedOrderQuery query) {
|
||||
if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法");
|
||||
}
|
||||
if (query.createdFrom() != null && query.createdTo() != null
|
||||
&& query.createdFrom().isAfter(query.createdTo())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "创建时间起点不能晚于终点");
|
||||
}
|
||||
}
|
||||
|
||||
private <T> ApiResponse<T> call(java.util.concurrent.Callable<ApiResponse<T>> invocation) {
|
||||
try { return invocation.call(); }
|
||||
catch (FeignException exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务暂时不可用"); }
|
||||
catch (Exception exception) { throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "头程服务查询失败"); }
|
||||
}
|
||||
private <T> T require(ApiResponse<T> response, String message) {
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
|
||||
}
|
||||
return response.data();
|
||||
}
|
||||
private <T> T requireNullable(ApiResponse<T> response, String message) {
|
||||
if (response == null || !response.successful()) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
|
||||
}
|
||||
return response.data();
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,74 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: 柏悦头程订单 API
|
||||
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
|
||||
version: 1.1.0
|
||||
version: 1.2.0
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/api/platform/v1/orders:
|
||||
get:
|
||||
summary: 平台统一订单列表
|
||||
description: >-
|
||||
统一返回七态产品主状态、下一步动作及测量/预装/装柜/派送摘要,需要 platform:order:list。
|
||||
有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
|
||||
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
|
||||
tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询,
|
||||
指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。
|
||||
parameters:
|
||||
- {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- $ref: '#/components/parameters/UnifiedKeyword'
|
||||
- $ref: '#/components/parameters/UnifiedOrderNo'
|
||||
- $ref: '#/components/parameters/UnifiedMainStatus'
|
||||
- {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- $ref: '#/components/parameters/UnifiedCreatedFrom'
|
||||
- $ref: '#/components/parameters/UnifiedCreatedTo'
|
||||
- $ref: '#/components/parameters/UnifiedPage'
|
||||
- $ref: '#/components/parameters/UnifiedSize'
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,响应体 code 为字符串 200 时成功}
|
||||
'403': {description: 需要平台身份和 platform:order:list 权限}
|
||||
/api/platform/v1/orders/{orderId}:
|
||||
get:
|
||||
summary: 平台统一订单详情
|
||||
description: 合并 Order 与 Firstleg 时间线并按发生时间升序返回;平台可见内部备注。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UnifiedOrderId'
|
||||
- $ref: '#/components/parameters/UnifiedTenantId'
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 需要平台身份和 platform:order:list 权限}
|
||||
/api/merchant/v1/orders:
|
||||
get:
|
||||
summary: 客户统一订单列表
|
||||
description: >-
|
||||
只按登录态客户隔离查询,需要 merchant:order:list;不接受目标客户参数。
|
||||
有入库单时同时返回 inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion
|
||||
才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UnifiedKeyword'
|
||||
- $ref: '#/components/parameters/UnifiedOrderNo'
|
||||
- $ref: '#/components/parameters/UnifiedMainStatus'
|
||||
- {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
|
||||
- $ref: '#/components/parameters/UnifiedCreatedFrom'
|
||||
- $ref: '#/components/parameters/UnifiedCreatedTo'
|
||||
- $ref: '#/components/parameters/UnifiedPage'
|
||||
- $ref: '#/components/parameters/UnifiedSize'
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 需要 merchant:order:list 权限}
|
||||
/api/merchant/v1/orders/{orderId}:
|
||||
get:
|
||||
summary: 客户统一订单详情
|
||||
description: 仅返回登录客户所属订单及客户可见时间线,不返回平台内部备注。
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/UnifiedOrderId'
|
||||
responses:
|
||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||
'403': {description: 需要 merchant:order:view 权限}
|
||||
/api/merchant/v1/orders/first-leg:
|
||||
get:
|
||||
summary: 客户端入库订单聚合列表
|
||||
@@ -119,6 +183,55 @@ components:
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
parameters:
|
||||
UnifiedKeyword:
|
||||
name: keyword
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string, maxLength: 64}
|
||||
UnifiedOrderNo:
|
||||
name: orderNo
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: string, maxLength: 64}
|
||||
UnifiedMainStatus:
|
||||
name: mainStatus
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
|
||||
UnifiedCreatedFrom:
|
||||
name: createdFrom
|
||||
in: query
|
||||
required: false
|
||||
description: 无主状态或待审核筛选按订单创建时间;其他主状态按履约快照创建时间
|
||||
schema: {type: string, format: date-time}
|
||||
UnifiedCreatedTo:
|
||||
name: createdTo
|
||||
in: query
|
||||
required: false
|
||||
description: 无主状态或待审核筛选按订单创建时间;其他主状态按履约快照创建时间
|
||||
schema: {type: string, format: date-time}
|
||||
UnifiedPage:
|
||||
name: page
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: integer, minimum: 1, default: 1}
|
||||
UnifiedSize:
|
||||
name: size
|
||||
in: query
|
||||
required: false
|
||||
schema: {type: integer, minimum: 1, maximum: 200, default: 20}
|
||||
UnifiedOrderId:
|
||||
name: orderId
|
||||
in: path
|
||||
required: true
|
||||
schema: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
UnifiedTenantId:
|
||||
name: tenantId
|
||||
in: query
|
||||
required: true
|
||||
schema: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
TenantId:
|
||||
name: tenantId
|
||||
in: query
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.baiyue.order.controller;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.order.dto.OrderRequests;
|
||||
import com.baiyue.order.dto.MerchantInboundOrderQuery;
|
||||
import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
@@ -42,6 +43,18 @@ class OrderControllerPermissionTest {
|
||||
AuthenticatedUser.class, long.class, long.class, String.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void unifiedOrderQueriesReuseExistingReadPermissions() throws Exception {
|
||||
assertAuthority(PlatformUnifiedOrderController.class, "page", "hasAuthority('platform:order:list')",
|
||||
AuthenticatedUser.class, Long.class, UnifiedOrderQuery.class);
|
||||
assertAuthority(PlatformUnifiedOrderController.class, "detail", "hasAuthority('platform:order:list')",
|
||||
AuthenticatedUser.class, long.class, long.class);
|
||||
assertAuthority(MerchantUnifiedOrderController.class, "page", "hasAuthority('merchant:order:list')",
|
||||
AuthenticatedUser.class, UnifiedOrderQuery.class);
|
||||
assertAuthority(MerchantUnifiedOrderController.class, "detail", "hasAuthority('merchant:order:view')",
|
||||
AuthenticatedUser.class, long.class);
|
||||
}
|
||||
|
||||
private void assertAuthority(Class<?> controller, String methodName, String expected, Class<?>... parameterTypes)
|
||||
throws NoSuchMethodException {
|
||||
Method method = controller.getDeclaredMethod(methodName, parameterTypes);
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
|
||||
import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import com.baiyue.order.entity.OrderEntity;
|
||||
import com.baiyue.order.entity.OrderStatus;
|
||||
import com.baiyue.order.repository.OrderRepository;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(UnifiedOrderQueryService.class)
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
|
||||
class UnifiedOrderQueryServiceTest {
|
||||
@MockBean private OrderRepository orders;
|
||||
@MockBean private FirstlegUnifiedOrderClient firstleg;
|
||||
@Resource private UnifiedOrderQueryService service;
|
||||
|
||||
@Test
|
||||
void pendingReviewIsPagedFromOrderAndMappedToApproveAction() {
|
||||
UnifiedOrderQuery query = query("PENDING_REVIEW");
|
||||
OrderEntity order = order(OrderStatus.SUBMITTED);
|
||||
when(orders.unifiedPage(eq(10001L), eq(query), eq(List.of(OrderStatus.SUBMITTED))))
|
||||
.thenReturn(new PageResult<>(List.of(order), 1, 20, 1, 1));
|
||||
when(firstleg.summaries(eq("internal-token"), any()))
|
||||
.thenReturn(ApiResponse.success(List.of()));
|
||||
|
||||
var result = service.page(10001L, query);
|
||||
|
||||
assertThat(result.records()).singleElement().satisfies(row -> {
|
||||
assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW");
|
||||
assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() {
|
||||
UnifiedOrderQuery query = query("WAIT_SPLIT");
|
||||
var summary = summary("WAIT_SPLIT", "CONFIRM_PRELOAD");
|
||||
when(firstleg.page(eq("internal-token"), any()))
|
||||
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
|
||||
|
||||
var result = service.page(null, query);
|
||||
|
||||
assertThat(result.records()).singleElement().satisfies(row -> {
|
||||
assertThat(row.mainStatus()).isEqualTo("WAIT_SPLIT");
|
||||
assertThat(row.nextAction()).isEqualTo("CONFIRM_PRELOAD");
|
||||
assertThat(row.inboundId()).isEqualTo(88L);
|
||||
assertThat(row.inboundVersion()).isEqualTo(7);
|
||||
assertThat(row.loading().loadingVersion()).isEqualTo(3);
|
||||
assertThat(row.loading().pickingVersion()).isEqualTo(3);
|
||||
assertThat(row.inboundCode()).isEqualTo("RK-77");
|
||||
});
|
||||
verify(orders, never()).unifiedPage(any(), any(), any());
|
||||
}
|
||||
|
||||
private UnifiedOrderQuery query(String status) {
|
||||
return new UnifiedOrderQuery(null, null, status, null, null, null, null, null, 1, 20);
|
||||
}
|
||||
|
||||
private OrderEntity order(OrderStatus status) {
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(77L); order.setTenantId(10001L); order.setOrderNo("FL-77");
|
||||
order.setStatus(status); order.setCustomerId(30001L); order.setCustomerName("测试客户");
|
||||
order.setCreatedAt(LocalDateTime.of(2026, 8, 6, 10, 0));
|
||||
return order;
|
||||
}
|
||||
|
||||
private FirstlegUnifiedOrderClient.Summary summary(String status, String action) {
|
||||
var inbound = new FirstlegUnifiedOrderClient.Inbound(
|
||||
88L, 10001L, 77L, "FL-77", "RK-77", "WAIT_SPLIT", "APPROVED", "APPROVED", 7,
|
||||
30001L, "测试客户", 11L, "深圳仓", 22L, "ONT8", "美森", 10, null, null, null,
|
||||
null, null, null, null, null, null, null, null, null,
|
||||
LocalDateTime.of(2026, 8, 6, 10, 0), LocalDateTime.of(2026, 8, 6, 11, 0));
|
||||
return new FirstlegUnifiedOrderClient.Summary(inbound, status, action,
|
||||
new FirstlegUnifiedOrderClient.Preload("PENDING", 1, 2, null),
|
||||
new FirstlegUnifiedOrderClient.Loading("IN_PROGRESS", 3, 3, 10, 5, 1, "SEAL-1", null), null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user