feat(order): add batch review preview
This commit is contained in:
+37
-3
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
|
|||||||
import com.baiyue.common.core.exception.BizException;
|
import com.baiyue.common.core.exception.BizException;
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.order.dto.BatchOrderReviewResponse;
|
import com.baiyue.order.dto.BatchOrderReviewResponse;
|
||||||
|
import com.baiyue.order.dto.BatchOrderReviewPreviewResponse;
|
||||||
import com.baiyue.order.dto.OrderDetailResponse;
|
import com.baiyue.order.dto.OrderDetailResponse;
|
||||||
import com.baiyue.order.dto.OrderRequests;
|
import com.baiyue.order.dto.OrderRequests;
|
||||||
import com.baiyue.order.dto.InboundCodePolicyResponse;
|
import com.baiyue.order.dto.InboundCodePolicyResponse;
|
||||||
@@ -17,6 +18,7 @@ import com.baiyue.order.entity.OrderStatus;
|
|||||||
import com.baiyue.order.service.OrderApplicationService;
|
import com.baiyue.order.service.OrderApplicationService;
|
||||||
import com.baiyue.order.service.OrderScopeGuard;
|
import com.baiyue.order.service.OrderScopeGuard;
|
||||||
import com.baiyue.order.service.InboundCodeAssociationService;
|
import com.baiyue.order.service.InboundCodeAssociationService;
|
||||||
|
import com.baiyue.order.service.BatchOrderReviewPreviewService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
@@ -51,6 +53,8 @@ public class PlatformFirstLegOrderController {
|
|||||||
private OrderApplicationService service;
|
private OrderApplicationService service;
|
||||||
@Resource
|
@Resource
|
||||||
private InboundCodeAssociationService inboundCodeAssociations;
|
private InboundCodeAssociationService inboundCodeAssociations;
|
||||||
|
@Resource
|
||||||
|
private BatchOrderReviewPreviewService batchReviewPreview;
|
||||||
|
|
||||||
@GetMapping("/inbound-code-policy")
|
@GetMapping("/inbound-code-policy")
|
||||||
@PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')")
|
@PreAuthorize("hasAnyAuthority('platform:order:list', 'platform:order:create', 'platform:order:edit', 'platform:order:submit')")
|
||||||
@@ -192,15 +196,26 @@ public class PlatformFirstLegOrderController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@BusinessAudit(event = "ORDER_BATCH_APPROVE", resourceType = "FIRST_LEG_ORDER_BATCH",
|
@BusinessAudit(event = "ORDER_BATCH_APPROVE", resourceType = "FIRST_LEG_ORDER_BATCH",
|
||||||
resourceId = "#request.orderIds", tenantId = "#tenantId", description = "批量审核通过订单")
|
resourceId = "#request.items", tenantId = "#tenantId", description = "批量审核通过订单")
|
||||||
@Operation(summary = "批量审核通过订单", description = "单租户模式下逐单独立审核,返回成功和失败明细。")
|
@Operation(summary = "批量审核通过订单", description = "单租户模式下逐票校验权限、状态及预览版本并独立提交;允许部分成功,返回成功和失败明细。")
|
||||||
@PostMapping("/batch-approve")
|
@PostMapping("/batch-approve")
|
||||||
@PreAuthorize("hasAuthority('platform:order:approve')")
|
@PreAuthorize("hasAuthority('platform:order:approve')")
|
||||||
public ApiResponse<BatchOrderReviewResponse> batchApprove(
|
public ApiResponse<BatchOrderReviewResponse> batchApprove(
|
||||||
@AuthenticationPrincipal AuthenticatedUser user,
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
@RequestParam long tenantId,
|
@RequestParam long tenantId,
|
||||||
@Valid @RequestBody OrderRequests.BatchReviewRequest request) {
|
@Valid @RequestBody OrderRequests.BatchReviewRequest request) {
|
||||||
return batchTransit(user, tenantId, request.orderIds(), OrderStatus.APPROVED, null);
|
return batchApproveTransit(user, tenantId, request.items());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "批量审核预览", description = "一次批量查询返回审核弹窗所需订单与中文货物摘要,固定两次数据库查询,不逐票读取详情。")
|
||||||
|
@PostMapping("/batch-review-preview")
|
||||||
|
@PreAuthorize("hasAuthority('platform:order:approve')")
|
||||||
|
public ApiResponse<BatchOrderReviewPreviewResponse> batchReviewPreview(
|
||||||
|
@AuthenticationPrincipal AuthenticatedUser user,
|
||||||
|
@RequestParam long tenantId,
|
||||||
|
@Valid @RequestBody OrderRequests.BatchReviewRequest request) {
|
||||||
|
long scopedTenantId = OrderScopeGuard.platform(user, tenantId);
|
||||||
|
return ApiResponse.success(batchReviewPreview.preview(scopedTenantId, request.items()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单")
|
@BusinessAudit(event = "ORDER_REJECT", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Reject order / 驳回订单")
|
||||||
@@ -320,4 +335,23 @@ public class PlatformFirstLegOrderController {
|
|||||||
return ApiResponse.success(new BatchOrderReviewResponse(orderIds.size(), successOrderIds.size(),
|
return ApiResponse.success(new BatchOrderReviewResponse(orderIds.size(), successOrderIds.size(),
|
||||||
failedOrders.size(), List.copyOf(successOrderIds), List.copyOf(failedOrders)));
|
failedOrders.size(), List.copyOf(successOrderIds), List.copyOf(failedOrders)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ApiResponse<BatchOrderReviewResponse> batchApproveTransit(
|
||||||
|
AuthenticatedUser user, long tenantId, List<OrderRequests.BatchReviewItem> items) {
|
||||||
|
long scopedTenantId = OrderScopeGuard.platform(user, tenantId);
|
||||||
|
List<Long> successOrderIds = new ArrayList<>();
|
||||||
|
List<BatchOrderReviewResponse.FailedOrder> failedOrders = new ArrayList<>();
|
||||||
|
for (OrderRequests.BatchReviewItem item : items) {
|
||||||
|
try {
|
||||||
|
service.transit(scopedTenantId, user.userId(), user.username(), item.orderId(),
|
||||||
|
OrderStatus.APPROVED, null, item.orderVersion());
|
||||||
|
successOrderIds.add(item.orderId());
|
||||||
|
} catch (BizException exception) {
|
||||||
|
failedOrders.add(new BatchOrderReviewResponse.FailedOrder(
|
||||||
|
item.orderId(), exception.getResultCode().code(), exception.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ApiResponse.success(new BatchOrderReviewResponse(items.size(), successOrderIds.size(),
|
||||||
|
failedOrders.size(), List.copyOf(successOrderIds), List.copyOf(failedOrders)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.baiyue.order.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** 平台批量审核前的一次性稳定展示快照。 */
|
||||||
|
@Schema(description = "订单批量审核预览")
|
||||||
|
public record BatchOrderReviewPreviewResponse(
|
||||||
|
int total,
|
||||||
|
int approvableCount,
|
||||||
|
List<Item> items
|
||||||
|
) {
|
||||||
|
public record Item(
|
||||||
|
Long orderId,
|
||||||
|
Integer orderVersion,
|
||||||
|
String inboundCode,
|
||||||
|
Long customerId,
|
||||||
|
String customerName,
|
||||||
|
List<String> chineseGoodsNames,
|
||||||
|
String channelCode,
|
||||||
|
String channelName,
|
||||||
|
String destinationWarehouseCode,
|
||||||
|
String destinationWarehouseName,
|
||||||
|
boolean canApprove,
|
||||||
|
String blockReason
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -268,15 +268,22 @@ public final class OrderRequests {
|
|||||||
|
|
||||||
@Schema(name = "FirstLegOrderBatchReviewRequest", description = "单租户订单批量审核请求")
|
@Schema(name = "FirstLegOrderBatchReviewRequest", description = "单租户订单批量审核请求")
|
||||||
public record BatchReviewRequest(
|
public record BatchReviewRequest(
|
||||||
@Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "订单及列表快照版本,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
@NotEmpty @Size(max = 100) List<@NotNull @Min(1) Long> orderIds
|
@NotEmpty @Size(max = 100) List<@Valid @NotNull BatchReviewItem> items
|
||||||
) {
|
) {
|
||||||
@AssertTrue(message = "订单 ID 不能重复")
|
@AssertTrue(message = "订单 ID 不能重复")
|
||||||
public boolean hasDistinctOrderIds() {
|
public boolean hasDistinctOrderIds() {
|
||||||
return orderIds == null || orderIds.size() == orderIds.stream().distinct().count();
|
return items == null || items.size() == items.stream()
|
||||||
|
.map(item -> item == null ? null : item.orderId()).distinct().count();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Schema(name = "FirstLegOrderBatchReviewItem", description = "批量审核订单及其列表快照版本")
|
||||||
|
public record BatchReviewItem(
|
||||||
|
@NotNull @Min(1) Long orderId,
|
||||||
|
@NotNull @Min(0) Integer orderVersion
|
||||||
|
) {}
|
||||||
|
|
||||||
@Schema(name = "FirstLegOrderBatchRejectRequest", description = "单租户订单批量驳回请求")
|
@Schema(name = "FirstLegOrderBatchRejectRequest", description = "单租户订单批量驳回请求")
|
||||||
public record BatchRejectRequest(
|
public record BatchRejectRequest(
|
||||||
@Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.baiyue.order.service;
|
||||||
|
|
||||||
|
import com.baiyue.order.dto.BatchOrderReviewPreviewResponse;
|
||||||
|
import com.baiyue.order.dto.OrderRequests;
|
||||||
|
import com.baiyue.order.entity.OrderCargoEntity;
|
||||||
|
import com.baiyue.order.entity.OrderEntity;
|
||||||
|
import com.baiyue.order.entity.OrderStatus;
|
||||||
|
import com.baiyue.order.repository.OrderRepository;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/** 用固定两次批量查询生成审核弹窗快照,禁止前端逐票查询详情。 */
|
||||||
|
@Service
|
||||||
|
public class BatchOrderReviewPreviewService {
|
||||||
|
@Resource
|
||||||
|
private OrderRepository orders;
|
||||||
|
|
||||||
|
public BatchOrderReviewPreviewResponse preview(long tenantId, List<OrderRequests.BatchReviewItem> requested) {
|
||||||
|
List<Long> orderIds = requested.stream().map(OrderRequests.BatchReviewItem::orderId).toList();
|
||||||
|
Map<Long, OrderEntity> byId = new LinkedHashMap<>();
|
||||||
|
orders.findByIds(tenantId, orderIds).forEach(order -> byId.put(order.getId(), order));
|
||||||
|
|
||||||
|
Map<Long, List<String>> goodsNames = new LinkedHashMap<>();
|
||||||
|
for (OrderCargoEntity cargo : orders.findCargoByOrderIds(tenantId, orderIds)) {
|
||||||
|
String name = cargo.getCargoName() == null ? null : cargo.getCargoName().trim();
|
||||||
|
if (name == null || name.isEmpty()) continue;
|
||||||
|
List<String> names = goodsNames.computeIfAbsent(cargo.getOrderId(), ignored -> new ArrayList<>());
|
||||||
|
if (!names.contains(name)) names.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BatchOrderReviewPreviewResponse.Item> items = requested.stream()
|
||||||
|
.map(item -> toItem(item, byId.get(item.orderId()), goodsNames.getOrDefault(item.orderId(), List.of())))
|
||||||
|
.toList();
|
||||||
|
int approvableCount = (int) items.stream().filter(BatchOrderReviewPreviewResponse.Item::canApprove).count();
|
||||||
|
return new BatchOrderReviewPreviewResponse(items.size(), approvableCount, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BatchOrderReviewPreviewResponse.Item toItem(OrderRequests.BatchReviewItem requested,
|
||||||
|
OrderEntity order,
|
||||||
|
List<String> goodsNames) {
|
||||||
|
if (order == null) {
|
||||||
|
return new BatchOrderReviewPreviewResponse.Item(requested.orderId(), requested.orderVersion(),
|
||||||
|
null, null, null, List.of(), null, null, null, null, false,
|
||||||
|
"订单不存在或不属于当前客户");
|
||||||
|
}
|
||||||
|
String blockReason = null;
|
||||||
|
if (order.getStatus() != OrderStatus.SUBMITTED) {
|
||||||
|
blockReason = "当前订单状态不可审核";
|
||||||
|
} else if (!Objects.equals(order.getVersion(), requested.orderVersion())) {
|
||||||
|
blockReason = "订单版本已变化,请刷新后重试";
|
||||||
|
}
|
||||||
|
return new BatchOrderReviewPreviewResponse.Item(order.getId(), order.getVersion(), order.getInboundCode(),
|
||||||
|
order.getCustomerId(), order.getCustomerName(), List.copyOf(goodsNames), order.getChannelCode(),
|
||||||
|
order.getChannelName(), order.getAmazonWarehouseCode(), order.getDestinationWarehouseName(),
|
||||||
|
blockReason == null, blockReason);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -148,7 +148,16 @@ public class OrderApplicationService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason) {
|
public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason) {
|
||||||
|
transit(tenantId, userId, username, id, target, reason, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void transit(long tenantId, long userId, String username, long id, OrderStatus target, String reason,
|
||||||
|
Integer expectedVersion) {
|
||||||
OrderEntity order = require(tenantId, id);
|
OrderEntity order = require(tenantId, id);
|
||||||
|
if (expectedVersion != null && !expectedVersion.equals(order.getVersion())) {
|
||||||
|
throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新后重试");
|
||||||
|
}
|
||||||
if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId);
|
if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId);
|
||||||
OrderStatus from = order.getStatus();
|
OrderStatus from = order.getStatus();
|
||||||
if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||||
|
|||||||
@@ -491,6 +491,45 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200': {description: 成功或业务失败,按响应体 code 判断}
|
'200': {description: 成功或业务失败,按响应体 code 判断}
|
||||||
'403': {description: 需要 platform:order:create 权限}
|
'403': {description: 需要 platform:order:create 权限}
|
||||||
|
/api/platform/v1/orders/first-leg/batch-review-preview:
|
||||||
|
post:
|
||||||
|
tags: [Platform first-leg orders]
|
||||||
|
summary: 批量审核预览
|
||||||
|
description: >-
|
||||||
|
在一个 HTTP 请求中按目标客户批量返回审核弹窗所需稳定快照。服务端固定使用订单与货物两次批量查询,
|
||||||
|
中文品名按货物 itemNo 顺序去重;不得由前端逐票请求详情。orderId 仅供路由,主要展示使用 inboundCode;
|
||||||
|
canApprove=false 时必须展示 blockReason。请求中的 orderVersion 用于识别列表快照是否过期。
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BatchReviewRequest'}
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 返回请求顺序一致的批量审核预览
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BatchReviewPreviewEnvelope'}
|
||||||
|
'403': {description: 需要平台身份和 platform:order:approve 权限}
|
||||||
|
/api/platform/v1/orders/first-leg/batch-approve:
|
||||||
|
post:
|
||||||
|
tags: [Platform first-leg orders]
|
||||||
|
summary: 批量审核通过订单
|
||||||
|
description: >-
|
||||||
|
单客户范围内逐票重新校验权限、状态和 orderVersion,每票独立事务;允许部分成功,
|
||||||
|
failedOrders 返回未成功订单及业务原因,成功订单不会因其他订单失败而回滚。
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/TenantId'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: {$ref: '#/components/schemas/BatchReviewRequest'}
|
||||||
|
responses:
|
||||||
|
'200': {description: 返回部分成功语义的逐票处理汇总}
|
||||||
|
'403': {description: 需要平台身份和 platform:order:approve 权限}
|
||||||
/api/platform/v1/orders/first-leg/tenant-statistics:
|
/api/platform/v1/orders/first-leg/tenant-statistics:
|
||||||
get:
|
get:
|
||||||
tags: [Platform first-leg orders]
|
tags: [Platform first-leg orders]
|
||||||
@@ -635,6 +674,54 @@ components:
|
|||||||
widthCm: {type: number, nullable: true}
|
widthCm: {type: number, nullable: true}
|
||||||
heightCm: {type: number, nullable: true}
|
heightCm: {type: number, nullable: true}
|
||||||
shipmentId: {type: string, nullable: true, description: 当前货物行的 FBA 货件号快照,不以订单级字段回退}
|
shipmentId: {type: string, nullable: true, description: 当前货物行的 FBA 货件号快照,不以订单级字段回退}
|
||||||
|
BatchReviewRequest:
|
||||||
|
type: object
|
||||||
|
required: [items]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
maxItems: 100
|
||||||
|
uniqueItems: true
|
||||||
|
items: {$ref: '#/components/schemas/BatchReviewRequestItem'}
|
||||||
|
BatchReviewRequestItem:
|
||||||
|
type: object
|
||||||
|
required: [orderId, orderVersion]
|
||||||
|
properties:
|
||||||
|
orderId: {type: string, pattern: '^[1-9][0-9]*$', description: 仅供路由关联,不作为主要展示键}
|
||||||
|
orderVersion: {type: integer, minimum: 0, description: 统一订单列表返回的 Order 乐观锁版本}
|
||||||
|
BatchReviewPreviewEnvelope:
|
||||||
|
type: object
|
||||||
|
required: [code, data]
|
||||||
|
properties:
|
||||||
|
code: {type: string, example: "200"}
|
||||||
|
message: {type: string, nullable: true}
|
||||||
|
data: {$ref: '#/components/schemas/BatchReviewPreview'}
|
||||||
|
BatchReviewPreview:
|
||||||
|
type: object
|
||||||
|
required: [total, approvableCount, items]
|
||||||
|
properties:
|
||||||
|
total: {type: integer, minimum: 1, maximum: 100}
|
||||||
|
approvableCount: {type: integer, minimum: 0, maximum: 100}
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items: {$ref: '#/components/schemas/BatchReviewPreviewItem'}
|
||||||
|
BatchReviewPreviewItem:
|
||||||
|
type: object
|
||||||
|
required: [orderId, orderVersion, chineseGoodsNames, canApprove]
|
||||||
|
properties:
|
||||||
|
orderId: {type: string, pattern: '^[1-9][0-9]*$', description: 仅供路由关联,不作为主要展示键}
|
||||||
|
orderVersion: {type: integer, minimum: 0}
|
||||||
|
inboundCode: {type: string, nullable: true, description: 权威入仓码;缺失时为 null,不以内部 ID 回退}
|
||||||
|
customerId: {type: string, nullable: true, pattern: '^[1-9][0-9]*$'}
|
||||||
|
customerName: {type: string, nullable: true}
|
||||||
|
chineseGoodsNames: {type: array, items: {type: string}, description: 按订单货物顺序去重的中文品名}
|
||||||
|
channelCode: {type: string, nullable: true}
|
||||||
|
channelName: {type: string, nullable: true}
|
||||||
|
destinationWarehouseCode: {type: string, nullable: true, description: Order 保存的目的仓业务编码;缺失时不以内部 ID 回退}
|
||||||
|
destinationWarehouseName: {type: string, nullable: true}
|
||||||
|
canApprove: {type: boolean}
|
||||||
|
blockReason: {type: string, nullable: true}
|
||||||
DomesticWarehouseOrderExportBatchRequest:
|
DomesticWarehouseOrderExportBatchRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
|
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.baiyue.order.controller;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class BatchReviewOpenApiContractTest {
|
||||||
|
@Test
|
||||||
|
void documentsSingleRequestPreviewFieldsAndVersionedPartialApproval() throws Exception {
|
||||||
|
String yaml = Files.readString(Path.of("src/main/resources/openapi/order-v1.yaml"));
|
||||||
|
|
||||||
|
assertThat(yaml)
|
||||||
|
.contains("/api/platform/v1/orders/first-leg/batch-review-preview:")
|
||||||
|
.contains("/api/platform/v1/orders/first-leg/batch-approve:")
|
||||||
|
.contains("inboundCode:")
|
||||||
|
.contains("customerName:")
|
||||||
|
.contains("chineseGoodsNames:")
|
||||||
|
.contains("channelCode:")
|
||||||
|
.contains("channelName:")
|
||||||
|
.contains("destinationWarehouseCode:")
|
||||||
|
.contains("destinationWarehouseName:")
|
||||||
|
.contains("orderVersion:")
|
||||||
|
.contains("canApprove:")
|
||||||
|
.contains("blockReason:")
|
||||||
|
.contains("允许部分成功");
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-4
@@ -4,10 +4,12 @@ import com.baiyue.common.core.exception.BizException;
|
|||||||
import com.baiyue.common.core.api.ResultCode;
|
import com.baiyue.common.core.api.ResultCode;
|
||||||
import com.baiyue.common.security.AuthenticatedUser;
|
import com.baiyue.common.security.AuthenticatedUser;
|
||||||
import com.baiyue.order.dto.BatchOrderReviewResponse;
|
import com.baiyue.order.dto.BatchOrderReviewResponse;
|
||||||
|
import com.baiyue.order.dto.BatchOrderReviewPreviewResponse;
|
||||||
import com.baiyue.order.dto.OrderRequests;
|
import com.baiyue.order.dto.OrderRequests;
|
||||||
import com.baiyue.order.entity.OrderStatus;
|
import com.baiyue.order.entity.OrderStatus;
|
||||||
import com.baiyue.order.service.OrderApplicationService;
|
import com.baiyue.order.service.OrderApplicationService;
|
||||||
import com.baiyue.order.service.InboundCodeAssociationService;
|
import com.baiyue.order.service.InboundCodeAssociationService;
|
||||||
|
import com.baiyue.order.service.BatchOrderReviewPreviewService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||||
@@ -23,11 +25,13 @@ import static org.mockito.Mockito.doThrow;
|
|||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.verifyNoInteractions;
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@SpringJUnitConfig(PlatformFirstLegOrderController.class)
|
@SpringJUnitConfig(PlatformFirstLegOrderController.class)
|
||||||
class PlatformOrderPageControllerTest {
|
class PlatformOrderPageControllerTest {
|
||||||
@MockBean private OrderApplicationService service;
|
@MockBean private OrderApplicationService service;
|
||||||
@MockBean private InboundCodeAssociationService inboundCodeAssociations;
|
@MockBean private InboundCodeAssociationService inboundCodeAssociations;
|
||||||
|
@MockBean private BatchOrderReviewPreviewService batchReviewPreview;
|
||||||
@Resource private PlatformFirstLegOrderController controller;
|
@Resource private PlatformFirstLegOrderController controller;
|
||||||
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
|
private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest(
|
||||||
null, null, null, null, null, null, 1, 20);
|
null, null, null, null, null, null, 1, 20);
|
||||||
@@ -77,10 +81,13 @@ class PlatformOrderPageControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void batchApproveReturnsPartialFailureAndContinuesRemainingOrders() {
|
void batchApproveReturnsPartialFailureAndContinuesRemainingOrders() {
|
||||||
doThrow(new BizException(ResultCode.CONFLICT, "订单已审核"))
|
doThrow(new BizException(ResultCode.CONFLICT, "订单已审核"))
|
||||||
.when(service).transit(10001L, 1L, "operator", 102L, OrderStatus.APPROVED, null);
|
.when(service).transit(10001L, 1L, "operator", 102L, OrderStatus.APPROVED, null, 4);
|
||||||
|
|
||||||
var response = controller.batchApprove(user("PLATFORM", 0L), 10001L,
|
var response = controller.batchApprove(user("PLATFORM", 0L), 10001L,
|
||||||
new OrderRequests.BatchReviewRequest(List.of(101L, 102L, 103L)));
|
new OrderRequests.BatchReviewRequest(List.of(
|
||||||
|
new OrderRequests.BatchReviewItem(101L, 3),
|
||||||
|
new OrderRequests.BatchReviewItem(102L, 4),
|
||||||
|
new OrderRequests.BatchReviewItem(103L, 5))));
|
||||||
|
|
||||||
BatchOrderReviewResponse result = response.data();
|
BatchOrderReviewResponse result = response.data();
|
||||||
assertThat(result.total()).isEqualTo(3);
|
assertThat(result.total()).isEqualTo(3);
|
||||||
@@ -90,8 +97,8 @@ class PlatformOrderPageControllerTest {
|
|||||||
assertThat(failure.code()).isEqualTo(ResultCode.CONFLICT.code());
|
assertThat(failure.code()).isEqualTo(ResultCode.CONFLICT.code());
|
||||||
assertThat(failure.message()).isEqualTo("订单已审核");
|
assertThat(failure.message()).isEqualTo("订单已审核");
|
||||||
});
|
});
|
||||||
verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.APPROVED, null);
|
verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.APPROVED, null, 3);
|
||||||
verify(service).transit(10001L, 1L, "operator", 103L, OrderStatus.APPROVED, null);
|
verify(service).transit(10001L, 1L, "operator", 103L, OrderStatus.APPROVED, null, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -103,6 +110,20 @@ class PlatformOrderPageControllerTest {
|
|||||||
verify(service).transit(10001L, 1L, "operator", 102L, OrderStatus.REJECTED, "资料不完整");
|
verify(service).transit(10001L, 1L, "operator", 102L, OrderStatus.REJECTED, "资料不完整");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchReviewPreviewUsesOneControllerCallAndExactCustomerScope() {
|
||||||
|
var request = new OrderRequests.BatchReviewRequest(List.of(
|
||||||
|
new OrderRequests.BatchReviewItem(101L, 3),
|
||||||
|
new OrderRequests.BatchReviewItem(102L, 4)));
|
||||||
|
var preview = new BatchOrderReviewPreviewResponse(2, 1, List.of());
|
||||||
|
when(batchReviewPreview.preview(10001L, request.items())).thenReturn(preview);
|
||||||
|
|
||||||
|
var response = controller.batchReviewPreview(user("PLATFORM", 0L), 10001L, request);
|
||||||
|
|
||||||
|
assertThat(response.data()).isSameAs(preview);
|
||||||
|
verify(batchReviewPreview).preview(10001L, request.items());
|
||||||
|
}
|
||||||
|
|
||||||
private AuthenticatedUser user(String appCode, Long tenantId) {
|
private AuthenticatedUser user(String appCode, Long tenantId) {
|
||||||
return new AuthenticatedUser(1L, tenantId, null, null, "operator", appCode, "WEB", Set.of());
|
return new AuthenticatedUser(1L, tenantId, null, null, "operator", appCode, "WEB", Set.of());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,20 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
class OrderRequestsValidationTest {
|
class OrderRequestsValidationTest {
|
||||||
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
|
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchReviewRequiresDistinctOrderIdsAndNonNegativeVersions() {
|
||||||
|
var duplicate = new OrderRequests.BatchReviewRequest(List.of(
|
||||||
|
new OrderRequests.BatchReviewItem(101L, 1),
|
||||||
|
new OrderRequests.BatchReviewItem(101L, 2)));
|
||||||
|
var invalidVersion = new OrderRequests.BatchReviewRequest(List.of(
|
||||||
|
new OrderRequests.BatchReviewItem(102L, -1)));
|
||||||
|
|
||||||
|
assertThat(validator.validate(duplicate))
|
||||||
|
.anyMatch(violation -> violation.getMessage().contains("订单 ID 不能重复"));
|
||||||
|
assertThat(validator.validate(invalidVersion))
|
||||||
|
.anyMatch(violation -> violation.getPropertyPath().toString().contains("orderVersion"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void carrierSnapshotIsOptionalWhenCustomerCreatesAnOrderDraft() {
|
void carrierSnapshotIsOptionalWhenCustomerCreatesAnOrderDraft() {
|
||||||
assertThat(validator.validate(request(null, null, "TAX_INCLUDED"))).isEmpty();
|
assertThat(validator.validate(request(null, null, "TAX_INCLUDED"))).isEmpty();
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.baiyue.order.service;
|
||||||
|
|
||||||
|
import com.baiyue.order.dto.OrderRequests;
|
||||||
|
import com.baiyue.order.entity.OrderCargoEntity;
|
||||||
|
import com.baiyue.order.entity.OrderEntity;
|
||||||
|
import com.baiyue.order.entity.OrderStatus;
|
||||||
|
import com.baiyue.order.repository.OrderRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoMoreInteractions;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class BatchOrderReviewPreviewServiceTest {
|
||||||
|
private final OrderRepository orders = mock(OrderRepository.class);
|
||||||
|
private final BatchOrderReviewPreviewService service = new BatchOrderReviewPreviewService();
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
org.springframework.test.util.ReflectionTestUtils.setField(service, "orders", orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsStableRowsWithOrderedDistinctChineseGoodsUsingTwoBatchQueries() {
|
||||||
|
OrderEntity eligible = order(101L, 3, OrderStatus.SUBMITTED, "RK-101", "客户甲");
|
||||||
|
OrderEntity stale = order(102L, 8, OrderStatus.SUBMITTED, "RK-102", "客户乙");
|
||||||
|
when(orders.findByIds(11L, List.of(101L, 102L))).thenReturn(List.of(stale, eligible));
|
||||||
|
when(orders.findCargoByOrderIds(11L, List.of(101L, 102L))).thenReturn(List.of(
|
||||||
|
cargo(101L, " 蓝牙音箱 "), cargo(101L, "蓝牙音箱"), cargo(101L, "充电器"),
|
||||||
|
cargo(102L, "数据线")));
|
||||||
|
|
||||||
|
var result = service.preview(11L, List.of(
|
||||||
|
new OrderRequests.BatchReviewItem(101L, 3),
|
||||||
|
new OrderRequests.BatchReviewItem(102L, 7)));
|
||||||
|
|
||||||
|
assertThat(result.total()).isEqualTo(2);
|
||||||
|
assertThat(result.approvableCount()).isEqualTo(1);
|
||||||
|
assertThat(result.items()).extracting(item -> item.orderId()).containsExactly(101L, 102L);
|
||||||
|
assertThat(result.items().get(0).chineseGoodsNames()).containsExactly("蓝牙音箱", "充电器");
|
||||||
|
assertThat(result.items().get(0).destinationWarehouseCode()).isEqualTo("ONT8");
|
||||||
|
assertThat(result.items().get(0).canApprove()).isTrue();
|
||||||
|
assertThat(result.items().get(1).orderVersion()).isEqualTo(8);
|
||||||
|
assertThat(result.items().get(1).blockReason()).contains("版本已变化");
|
||||||
|
verify(orders).findByIds(11L, List.of(101L, 102L));
|
||||||
|
verify(orders).findCargoByOrderIds(11L, List.of(101L, 102L));
|
||||||
|
verifyNoMoreInteractions(orders);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keepsUnknownAndWrongStatusRowsWithoutLeakingAnotherCustomer() {
|
||||||
|
OrderEntity approved = order(101L, 3, OrderStatus.APPROVED, "RK-101", "客户甲");
|
||||||
|
when(orders.findByIds(11L, List.of(999L, 101L))).thenReturn(List.of(approved));
|
||||||
|
when(orders.findCargoByOrderIds(11L, List.of(999L, 101L))).thenReturn(List.of());
|
||||||
|
|
||||||
|
var result = service.preview(11L, List.of(
|
||||||
|
new OrderRequests.BatchReviewItem(999L, 1),
|
||||||
|
new OrderRequests.BatchReviewItem(101L, 3)));
|
||||||
|
|
||||||
|
assertThat(result.items().get(0).inboundCode()).isNull();
|
||||||
|
assertThat(result.items().get(0).blockReason()).contains("不存在或不属于当前客户");
|
||||||
|
assertThat(result.items().get(1).blockReason()).contains("状态不可审核");
|
||||||
|
assertThat(result.approvableCount()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrderEntity order(long id, int version, OrderStatus status,
|
||||||
|
String inboundCode, String customerName) {
|
||||||
|
OrderEntity order = new OrderEntity();
|
||||||
|
order.setId(id);
|
||||||
|
order.setTenantId(11L);
|
||||||
|
order.setVersion(version);
|
||||||
|
order.setStatus(status);
|
||||||
|
order.setInboundCode(inboundCode);
|
||||||
|
order.setCustomerId(11L);
|
||||||
|
order.setCustomerName(customerName);
|
||||||
|
order.setChannelCode("FBA-SEA");
|
||||||
|
order.setChannelName("美森海运");
|
||||||
|
order.setAmazonWarehouseCode("ONT8");
|
||||||
|
order.setDestinationWarehouseName("ONT8 亚马逊仓");
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static OrderCargoEntity cargo(long orderId, String name) {
|
||||||
|
OrderCargoEntity cargo = new OrderCargoEntity();
|
||||||
|
cargo.setOrderId(orderId);
|
||||||
|
cargo.setCargoName(name);
|
||||||
|
return cargo;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -235,6 +235,24 @@ class OrderApplicationServiceStateTest {
|
|||||||
&& event.getPayloadJson().contains("\"totalPackageCount\":4")));
|
&& event.getPayloadJson().contains("\"totalPackageCount\":4")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void batchApprovalRejectsStalePreviewVersionBeforeChangingOrder() {
|
||||||
|
OrderRepository repository = mock(OrderRepository.class);
|
||||||
|
OrderEntity order = persistedOrder(OrderStatus.SUBMITTED);
|
||||||
|
order.setVersion(6);
|
||||||
|
when(repository.findOwned(10001L, 1L)).thenReturn(order);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class),
|
||||||
|
mock(OrderMasterDataGuard.class)).transit(
|
||||||
|
10001L, 2L, "u", 1L, OrderStatus.APPROVED, null, 5))
|
||||||
|
.isInstanceOf(BizException.class)
|
||||||
|
.hasMessage("订单版本已变化,请刷新后重试");
|
||||||
|
|
||||||
|
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
|
||||||
|
verify(repository, never()).saveOrder(anyLong(), any());
|
||||||
|
verify(repository, never()).insertOutbox(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void submittingDraftGeneratesAndPersistsInboundCode() {
|
void submittingDraftGeneratesAndPersistsInboundCode() {
|
||||||
OrderRepository repository = mock(OrderRepository.class);
|
OrderRepository repository = mock(OrderRepository.class);
|
||||||
|
|||||||
Reference in New Issue
Block a user