Merge branch 'test' into test-02huogui
# Conflicts: # deploy/sql/头程数据库统一增量-MySQL8.sql
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
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.LocalDate;
|
||||
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,
|
||||
String inboundCode, String internalOrderStatus, LocalDate expectedArrivalDate,
|
||||
String feeReviewStatus, String channelName, String containerNo, String cabinetNo,
|
||||
LocalDate sailingDate, Boolean loaded, Boolean fullContainer,
|
||||
String measurementReviewStatus, List<Long> candidateOrderIds,
|
||||
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));
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -61,8 +61,8 @@ public class PlatformFirstLegOrderController {
|
||||
return ApiResponse.success(service.createDraft(scopedTenant, user.userId(), request, idempotencyKey));
|
||||
}
|
||||
|
||||
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑草稿")
|
||||
@Operation(summary = "平台编辑草稿", description = "在明确指定的目标租户内编辑草稿或驳回订单。")
|
||||
@BusinessAudit(event = "ORDER_UPDATE", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "平台编辑订单资料")
|
||||
@Operation(summary = "平台编辑订单", description = "在待审核阶段编辑订单资料;编辑不会将订单退回草稿状态。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "编辑成功"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "无平台编辑权限"),
|
||||
@@ -75,7 +75,7 @@ public class PlatformFirstLegOrderController {
|
||||
@Parameter(description = "订单 ID", required = true) @PathVariable long id,
|
||||
@Valid @RequestBody OrderRequests.SaveRequest request) {
|
||||
long scopedTenant = OrderScopeGuard.platform(user, tenantId);
|
||||
service.updateDraft(scopedTenant, user.userId(), id, request);
|
||||
service.updateSubmittedOrder(scopedTenant, user.userId(), id, request);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,42 @@
|
||||
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.LocalDate;
|
||||
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,
|
||||
@Size(max = 64) String inboundCode,
|
||||
@Size(max = 32) String internalOrderStatus,
|
||||
LocalDate expectedArrivalDate,
|
||||
@Size(max = 32) String feeReviewStatus,
|
||||
@Size(max = 128) String channelName,
|
||||
@Size(max = 64) String containerNo,
|
||||
@Size(max = 64) String cabinetNo,
|
||||
LocalDate sailingDate,
|
||||
Boolean loaded,
|
||||
Boolean fullContainer,
|
||||
@Size(max = 32) String measurementReviewStatus,
|
||||
@Size(max = 64) String fbaCode,
|
||||
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,45 @@ 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())
|
||||
.eq(StringUtils.hasText(query.internalOrderStatus()), OrderEntity::getStatus,
|
||||
StringUtils.hasText(query.internalOrderStatus()) ? OrderStatus.valueOf(query.internalOrderStatus()) : null)
|
||||
.eq(StringUtils.hasText(query.channelName()), OrderEntity::getChannelName, query.channelName())
|
||||
.eq(query.fullContainer() != null, OrderEntity::getContainerMode,
|
||||
query.fullContainer() == null ? null : query.fullContainer() ? "FULL_CONTAINER" : "LCL")
|
||||
.apply(StringUtils.hasText(query.fbaCode()),
|
||||
"EXISTS (SELECT 1 FROM ord_order_cargo c WHERE c.tenant_id = ord_order.tenant_id "
|
||||
+ "AND c.order_id = ord_order.id AND c.deleted = 0 AND c.sku_no LIKE CONCAT('%', {0}, '%'))",
|
||||
query.fbaCode())
|
||||
.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<Long> findOrderIdsBySkuNo(Long tenantId, String fbaCode) {
|
||||
if (!StringUtils.hasText(fbaCode)) return List.of();
|
||||
QueryWrapper<OrderCargoEntity> wrapper = new QueryWrapper<OrderCargoEntity>()
|
||||
.select("DISTINCT order_id")
|
||||
.eq(tenantId != null, "tenant_id", tenantId)
|
||||
.eq("deleted", 0)
|
||||
.like("sku_no", fbaCode);
|
||||
return cargoMapper.selectObjs(wrapper).stream().map(value -> ((Number) value).longValue()).toList();
|
||||
}
|
||||
|
||||
public List<OrderCargoEntity> findCargoByOrderIds(long tenantId, List<Long> orderIds) {
|
||||
if (orderIds.isEmpty()) return List.of();
|
||||
return cargoMapper.selectList(new LambdaQueryWrapper<OrderCargoEntity>()
|
||||
|
||||
@@ -60,16 +60,37 @@ public class OrderApplicationService {
|
||||
|
||||
@Transactional
|
||||
public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
|
||||
updateOrderData(tenantId, userId, id, request, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台审核队列中的订单仍允许编辑。编辑只更新订单资料,不回退已提交订单的主状态。
|
||||
* 商户端继续使用 {@link #updateDraft(long, long, long, OrderRequests.SaveRequest)},
|
||||
* 因此不会因为该兼容能力意外获得已提交订单的编辑权限。
|
||||
*/
|
||||
@Transactional
|
||||
public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
|
||||
updateOrderData(tenantId, userId, id, request, true);
|
||||
}
|
||||
|
||||
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
|
||||
boolean allowSubmitted) {
|
||||
OrderEntity order = require(tenantId, id);
|
||||
validatePrototypeFields(request);
|
||||
var snapshots = masterDataGuard.validate(tenantId, request);
|
||||
if (order.getStatus() != OrderStatus.DRAFT && order.getStatus() != OrderStatus.REJECTED) {
|
||||
throw new BizException("Only draft or rejected orders can be edited");
|
||||
OrderStatus before = order.getStatus();
|
||||
if (before != OrderStatus.DRAFT && before != OrderStatus.REJECTED
|
||||
&& !(allowSubmitted && before == OrderStatus.SUBMITTED)) {
|
||||
throw new BizException("当前订单状态不允许编辑订单资料");
|
||||
}
|
||||
apply(order, request, snapshots); order.setStatus(OrderStatus.DRAFT); order.setUpdatedBy(userId);
|
||||
if (before == OrderStatus.SUBMITTED) {
|
||||
order.setStatus(OrderStatus.SUBMITTED);
|
||||
}
|
||||
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
|
||||
repository.replaceDraftChildren(tenantId, id);
|
||||
saveChildren(tenantId, userId, id, request);
|
||||
record(tenantId, id, before, order.getStatus(), "UPDATE", null, userId, null);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
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 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,
|
||||
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 (requiresFirstlegQuery(query)) {
|
||||
if ("PENDING_REVIEW".equals(query.mainStatus())) {
|
||||
return new PageResult<>(List.of(), query.page(), query.size(), 0, 0);
|
||||
}
|
||||
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) {
|
||||
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());
|
||||
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, "创建时间起点不能晚于终点");
|
||||
}
|
||||
if (StringUtils.hasText(query.internalOrderStatus())) {
|
||||
try { OrderStatus.valueOf(query.internalOrderStatus()); }
|
||||
catch (IllegalArgumentException exception) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "订单内部状态不合法");
|
||||
}
|
||||
}
|
||||
if (StringUtils.hasText(query.feeReviewStatus()) && !REVIEW_STATUSES.contains(query.feeReviewStatus())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "费用审核状态不合法");
|
||||
}
|
||||
if (StringUtils.hasText(query.measurementReviewStatus())
|
||||
&& !REVIEW_STATUSES.contains(query.measurementReviewStatus())) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST, "测量审核状态不合法");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean requiresFirstlegQuery(UnifiedOrderQuery query) {
|
||||
return (StringUtils.hasText(query.mainStatus()) && !"PENDING_REVIEW".equals(query.mainStatus()))
|
||||
|| StringUtils.hasText(query.inboundCode()) || query.expectedArrivalDate() != null
|
||||
|| StringUtils.hasText(query.feeReviewStatus()) || StringUtils.hasText(query.containerNo())
|
||||
|| StringUtils.hasText(query.cabinetNo()) || query.sailingDate() != null || query.loaded() != null
|
||||
|| StringUtils.hasText(query.measurementReviewStatus());
|
||||
}
|
||||
|
||||
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,98 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: 柏悦头程订单 API
|
||||
description: 业务成功和失败均使用 HTTP 200;仅响应体 code 为字符串 "200" 时表示成功,其他 code 表示失败。
|
||||
version: 1.1.0
|
||||
version: 1.3.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/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'
|
||||
- $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/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'
|
||||
- $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 +207,103 @@ 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]
|
||||
UnifiedInboundCode:
|
||||
{name: inboundCode, in: query, required: false, schema: {type: string, maxLength: 64}}
|
||||
UnifiedInternalOrderStatus:
|
||||
name: internalOrderStatus
|
||||
in: query
|
||||
required: false
|
||||
description: Order 内部生命周期状态;不是七态产品主状态
|
||||
schema: {type: string, enum: [DRAFT, SUBMITTED, APPROVED, ACCEPTED, REJECTED, CANCELLED, PROCESSING, COMPLETED]}
|
||||
UnifiedExpectedArrivalDate:
|
||||
{name: expectedArrivalDate, in: query, required: false, schema: {type: string, format: date}}
|
||||
UnifiedFeeReviewStatus:
|
||||
name: feeReviewStatus
|
||||
in: query
|
||||
required: false
|
||||
description: 当前最高版本入库费用审核状态;UNRECORDED 表示尚无费用记录
|
||||
schema: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]}
|
||||
UnifiedChannelName:
|
||||
{name: channelName, in: query, required: false, schema: {type: string, maxLength: 128}}
|
||||
UnifiedContainerNo:
|
||||
{name: containerNo, in: query, required: false, schema: {type: string, maxLength: 64}}
|
||||
UnifiedCabinetNo:
|
||||
{name: cabinetNo, in: query, required: false, schema: {type: string, maxLength: 64}}
|
||||
UnifiedSailingDate:
|
||||
{name: sailingDate, in: query, required: false, schema: {type: string, format: date}}
|
||||
UnifiedLoaded:
|
||||
name: loaded
|
||||
in: query
|
||||
required: false
|
||||
description: true 表示运输装载状态存在且不是 UNLOADED/PENDING;false 表示无该状态
|
||||
schema: {type: boolean}
|
||||
UnifiedFullContainer:
|
||||
name: fullContainer
|
||||
in: query
|
||||
required: false
|
||||
description: true 对应 FULL_CONTAINER,false 对应 LCL
|
||||
schema: {type: boolean}
|
||||
UnifiedMeasurementReviewStatus:
|
||||
name: measurementReviewStatus
|
||||
in: query
|
||||
required: false
|
||||
description: 测量审核状态;UNRECORDED 表示字段为空
|
||||
schema: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]}
|
||||
UnifiedFbaCode:
|
||||
name: fbaCode
|
||||
in: query
|
||||
required: false
|
||||
description: 兼容匹配订单货物 sku_no
|
||||
schema: {type: string, maxLength: 64}
|
||||
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);
|
||||
|
||||
@@ -78,6 +78,48 @@ class OrderApplicationServiceStateTest {
|
||||
verify(repository).saveOrder(eq(10001L), argThat(saved -> saved.getStatus() == OrderStatus.DRAFT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCanEditSubmittedOrderWithoutRollingBackReviewStatus() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
|
||||
OrderRequests.SaveRequest request = request();
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(1L);
|
||||
order.setTenantId(10001L);
|
||||
order.setStatus(OrderStatus.SUBMITTED);
|
||||
when(repository.findOwned(10001L, 1L)).thenReturn(order);
|
||||
when(masterData.validate(10001L, request)).thenReturn(snapshots());
|
||||
when(repository.saveOrder(10001L, order)).thenReturn(1);
|
||||
|
||||
service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.updateSubmittedOrder(10001L, 2L, 1L, request);
|
||||
|
||||
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
|
||||
verify(repository).replaceDraftChildren(10001L, 1L);
|
||||
verify(repository).insertHistory(argThat(history -> history.getFromStatus() == OrderStatus.SUBMITTED
|
||||
&& history.getToStatus() == OrderStatus.SUBMITTED
|
||||
&& "UPDATE".equals(history.getAction())));
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantDraftEditorStillCannotEditSubmittedOrder() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
OrderEntity order = new OrderEntity();
|
||||
order.setId(1L);
|
||||
order.setTenantId(10001L);
|
||||
order.setStatus(OrderStatus.SUBMITTED);
|
||||
when(repository.findOwned(10001L, 1L)).thenReturn(order);
|
||||
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
|
||||
when(masterData.validate(eq(10001L), any(OrderRequests.SaveRequest.class))).thenReturn(snapshots());
|
||||
|
||||
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
|
||||
.updateDraft(10001L, 2L, 1L, request()))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("不允许编辑");
|
||||
|
||||
verify(repository, never()).saveOrder(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unapprovedTenantStillCannotSubmitDraft() {
|
||||
OrderRepository repository = mock(OrderRepository.class);
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
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.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.mockito.ArgumentCaptor;
|
||||
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.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
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());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expandedFulfillmentFiltersAreForwardedBeforeDatabasePaging() {
|
||||
UnifiedOrderQuery query = new UnifiedOrderQuery(
|
||||
null, null, "LOADING", 30001L, 11L, 22L, "RK-77", "APPROVED",
|
||||
LocalDate.of(2026, 8, 6), "APPROVED", "美森", "MSCU", "CAB-1",
|
||||
LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", "FBA-1",
|
||||
null, null, 1, 20);
|
||||
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)));
|
||||
|
||||
service.page(10001L, query);
|
||||
|
||||
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
|
||||
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
|
||||
verify(firstleg).page(eq("internal-token"), captor.capture());
|
||||
assertThat(captor.getValue()).satisfies(request -> {
|
||||
assertThat(request.inboundCode()).isEqualTo("RK-77");
|
||||
assertThat(request.internalOrderStatus()).isEqualTo("APPROVED");
|
||||
assertThat(request.expectedArrivalDate()).isEqualTo(LocalDate.of(2026, 8, 6));
|
||||
assertThat(request.containerNo()).isEqualTo("MSCU");
|
||||
assertThat(request.sailingDate()).isEqualTo(LocalDate.of(2026, 8, 8));
|
||||
assertThat(request.loaded()).isTrue();
|
||||
assertThat(request.fullContainer()).isTrue();
|
||||
assertThat(request.measurementReviewStatus()).isEqualTo("UNRECORDED");
|
||||
assertThat(request.candidateOrderIds()).containsExactly(77L);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownReviewStatusesBeforeQueryingEitherService() {
|
||||
UnifiedOrderQuery query = new UnifiedOrderQuery(
|
||||
null, null, null, null, null, null, null, null,
|
||||
null, "UNKNOWN", null, null, null, null, null, null, "INVALID", null,
|
||||
null, null, 1, 20);
|
||||
|
||||
assertThatThrownBy(() -> service.page(10001L, query))
|
||||
.isInstanceOf(BizException.class);
|
||||
verify(orders, never()).unifiedPage(any(), any(), any());
|
||||
verify(firstleg, never()).page(any(), any());
|
||||
}
|
||||
|
||||
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,
|
||||
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