From eead11e99daab1f1347aa8a2cda2f9b9c6f79cfe Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 8 Sep 2026 17:32:56 +0800 Subject: [PATCH] feat(order): add batch review preview --- .../PlatformFirstLegOrderController.java | 40 +++++++- .../dto/BatchOrderReviewPreviewResponse.java | 28 ++++++ .../com/baiyue/order/dto/OrderRequests.java | 13 ++- .../BatchOrderReviewPreviewService.java | 63 +++++++++++++ .../service/OrderApplicationService.java | 9 ++ .../src/main/resources/openapi/order-v1.yaml | 87 +++++++++++++++++ .../BatchReviewOpenApiContractTest.java | 30 ++++++ .../PlatformOrderPageControllerTest.java | 29 +++++- .../dto/OrderRequestsValidationTest.java | 14 +++ .../BatchOrderReviewPreviewServiceTest.java | 93 +++++++++++++++++++ .../OrderApplicationServiceStateTest.java | 18 ++++ 11 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 order/src/main/java/com/baiyue/order/dto/BatchOrderReviewPreviewResponse.java create mode 100644 order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java create mode 100644 order/src/test/java/com/baiyue/order/controller/BatchReviewOpenApiContractTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/BatchOrderReviewPreviewServiceTest.java diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java index 56b740cfc..3eab42d11 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformFirstLegOrderController.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.BatchOrderReviewResponse; +import com.baiyue.order.dto.BatchOrderReviewPreviewResponse; import com.baiyue.order.dto.OrderDetailResponse; import com.baiyue.order.dto.OrderRequests; 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.OrderScopeGuard; 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.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -51,6 +53,8 @@ public class PlatformFirstLegOrderController { private OrderApplicationService service; @Resource private InboundCodeAssociationService inboundCodeAssociations; + @Resource + private BatchOrderReviewPreviewService batchReviewPreview; @GetMapping("/inbound-code-policy") @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", - resourceId = "#request.orderIds", tenantId = "#tenantId", description = "批量审核通过订单") - @Operation(summary = "批量审核通过订单", description = "单租户模式下逐单独立审核,返回成功和失败明细。") + resourceId = "#request.items", tenantId = "#tenantId", description = "批量审核通过订单") + @Operation(summary = "批量审核通过订单", description = "单租户模式下逐票校验权限、状态及预览版本并独立提交;允许部分成功,返回成功和失败明细。") @PostMapping("/batch-approve") @PreAuthorize("hasAuthority('platform:order:approve')") public ApiResponse batchApprove( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @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 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 / 驳回订单") @@ -320,4 +335,23 @@ public class PlatformFirstLegOrderController { return ApiResponse.success(new BatchOrderReviewResponse(orderIds.size(), successOrderIds.size(), failedOrders.size(), List.copyOf(successOrderIds), List.copyOf(failedOrders))); } + + private ApiResponse batchApproveTransit( + AuthenticatedUser user, long tenantId, List items) { + long scopedTenantId = OrderScopeGuard.platform(user, tenantId); + List successOrderIds = new ArrayList<>(); + List 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))); + } } diff --git a/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewPreviewResponse.java b/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewPreviewResponse.java new file mode 100644 index 000000000..f2737c4f5 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/BatchOrderReviewPreviewResponse.java @@ -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 items +) { + public record Item( + Long orderId, + Integer orderVersion, + String inboundCode, + Long customerId, + String customerName, + List chineseGoodsNames, + String channelCode, + String channelName, + String destinationWarehouseCode, + String destinationWarehouseName, + boolean canApprove, + String blockReason + ) {} +} diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 670a89c7d..70ef87ce4 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -268,15 +268,22 @@ public final class OrderRequests { @Schema(name = "FirstLegOrderBatchReviewRequest", description = "单租户订单批量审核请求") public record BatchReviewRequest( - @Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED) - @NotEmpty @Size(max = 100) List<@NotNull @Min(1) Long> orderIds + @Schema(description = "订单及列表快照版本,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty @Size(max = 100) List<@Valid @NotNull BatchReviewItem> items ) { @AssertTrue(message = "订单 ID 不能重复") 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 = "单租户订单批量驳回请求") public record BatchRejectRequest( @Schema(description = "订单 ID 列表,单次最多 100 条", requiredMode = Schema.RequiredMode.REQUIRED) diff --git a/order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java b/order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java new file mode 100644 index 000000000..07b3fc6e8 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/BatchOrderReviewPreviewService.java @@ -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 requested) { + List orderIds = requested.stream().map(OrderRequests.BatchReviewItem::orderId).toList(); + Map byId = new LinkedHashMap<>(); + orders.findByIds(tenantId, orderIds).forEach(order -> byId.put(order.getId(), order)); + + Map> 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 names = goodsNames.computeIfAbsent(cargo.getOrderId(), ignored -> new ArrayList<>()); + if (!names.contains(name)) names.add(name); + } + + List 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 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); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index f7654985b..bbc2390d9 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -148,7 +148,16 @@ public class OrderApplicationService { @Transactional 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); + if (expectedVersion != null && !expectedVersion.equals(order.getVersion())) { + throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新后重试"); + } if (target == OrderStatus.SUBMITTED) eligibilityGuard.requireApproved(tenantId); OrderStatus from = order.getStatus(); if (!allowed(from, target)) throw new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT, diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index b1ea0db3c..b69987464 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -491,6 +491,45 @@ paths: responses: '200': {description: 成功或业务失败,按响应体 code 判断} '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: get: tags: [Platform first-leg orders] @@ -635,6 +674,54 @@ components: widthCm: {type: number, nullable: true} heightCm: {type: number, nullable: true} 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: type: object required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit] diff --git a/order/src/test/java/com/baiyue/order/controller/BatchReviewOpenApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/BatchReviewOpenApiContractTest.java new file mode 100644 index 000000000..e054e5759 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/BatchReviewOpenApiContractTest.java @@ -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("允许部分成功"); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java index 320a1c5bc..28d51a583 100644 --- a/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/PlatformOrderPageControllerTest.java @@ -4,10 +4,12 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.BatchOrderReviewResponse; +import com.baiyue.order.dto.BatchOrderReviewPreviewResponse; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.entity.OrderStatus; import com.baiyue.order.service.OrderApplicationService; import com.baiyue.order.service.InboundCodeAssociationService; +import com.baiyue.order.service.BatchOrderReviewPreviewService; import org.junit.jupiter.api.Test; import jakarta.annotation.Resource; 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.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; @SpringJUnitConfig(PlatformFirstLegOrderController.class) class PlatformOrderPageControllerTest { @MockBean private OrderApplicationService service; @MockBean private InboundCodeAssociationService inboundCodeAssociations; + @MockBean private BatchOrderReviewPreviewService batchReviewPreview; @Resource private PlatformFirstLegOrderController controller; private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( null, null, null, null, null, null, 1, 20); @@ -77,10 +81,13 @@ class PlatformOrderPageControllerTest { @Test void batchApproveReturnsPartialFailureAndContinuesRemainingOrders() { 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, - 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(); assertThat(result.total()).isEqualTo(3); @@ -90,8 +97,8 @@ class PlatformOrderPageControllerTest { assertThat(failure.code()).isEqualTo(ResultCode.CONFLICT.code()); assertThat(failure.message()).isEqualTo("订单已审核"); }); - verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.APPROVED, null); - verify(service).transit(10001L, 1L, "operator", 103L, OrderStatus.APPROVED, null); + verify(service).transit(10001L, 1L, "operator", 101L, OrderStatus.APPROVED, null, 3); + verify(service).transit(10001L, 1L, "operator", 103L, OrderStatus.APPROVED, null, 5); } @Test @@ -103,6 +110,20 @@ class PlatformOrderPageControllerTest { 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) { return new AuthenticatedUser(1L, tenantId, null, null, "operator", appCode, "WEB", Set.of()); } diff --git a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java index f58d48fe2..37f938c0b 100644 --- a/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java +++ b/order/src/test/java/com/baiyue/order/dto/OrderRequestsValidationTest.java @@ -12,6 +12,20 @@ import static org.assertj.core.api.Assertions.assertThat; class OrderRequestsValidationTest { 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 void carrierSnapshotIsOptionalWhenCustomerCreatesAnOrderDraft() { assertThat(validator.validate(request(null, null, "TAX_INCLUDED"))).isEmpty(); diff --git a/order/src/test/java/com/baiyue/order/service/BatchOrderReviewPreviewServiceTest.java b/order/src/test/java/com/baiyue/order/service/BatchOrderReviewPreviewServiceTest.java new file mode 100644 index 000000000..5980456e3 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/BatchOrderReviewPreviewServiceTest.java @@ -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; + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 7675f999a..c959de48a 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -235,6 +235,24 @@ class OrderApplicationServiceStateTest { && 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 void submittingDraftGeneratesAndPersistsInboundCode() { OrderRepository repository = mock(OrderRepository.class);