feat(order): formalize domestic order export contract
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
-- Order 导出权限初始化源;由统一增量合并后在 IAM 数据库执行。
|
||||
USE baiyue_iam;
|
||||
|
||||
INSERT INTO iam_permission
|
||||
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
|
||||
route_path, http_method, sort_no, status, deleted)
|
||||
VALUES
|
||||
(5102026, 0, 'PLATFORM', 'BUTTON', 5102010, 'platform:order:export', '导出订单列表',
|
||||
NULL, NULL, 120, 'ACTIVE', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
permission_name = VALUES(permission_name), parent_id = VALUES(parent_id),
|
||||
sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0;
|
||||
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT role_id, 5102026, 0
|
||||
FROM (SELECT 4000001 AS role_id UNION ALL SELECT 4000003 AS role_id) roles
|
||||
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
|
||||
@@ -6,6 +6,28 @@ info:
|
||||
security:
|
||||
- bearerAuth: []
|
||||
paths:
|
||||
/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches:
|
||||
post:
|
||||
security: []
|
||||
summary: File 异步导出中心分页读取国内仓订单
|
||||
description: >-
|
||||
仅接受内部服务令牌。selectedOrderNos 非空时忽略查询筛选并严格按所选业务订单号导出;
|
||||
selectedOrderNos 为空时按与平台统一订单列表一致的筛选条件导出全部匹配记录,page/size 不属于请求参数。
|
||||
Provider 只返回结构化数据,不生成文件;调用方创建 File 任务时 exportType 固定为
|
||||
DOMESTIC_WAREHOUSE_ORDER_LIST、format 固定为 XLSX,并需要 platform:order:export。
|
||||
parameters:
|
||||
- name: X-Internal-Service-Token
|
||||
in: header
|
||||
required: true
|
||||
schema: {type: string, minLength: 32}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/DomesticWarehouseOrderExportBatchRequest'}
|
||||
responses:
|
||||
'200': {description: 返回固定列定义、当前批数据、游标与首批总数}
|
||||
'403': {description: 内部服务令牌无效}
|
||||
/api/merchant/v1/orders/first-leg/addresses:
|
||||
get:
|
||||
summary: 查询当前客户下单地址簿
|
||||
@@ -486,6 +508,51 @@ components:
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
schemas:
|
||||
DomesticWarehouseOrderExportBatchRequest:
|
||||
type: object
|
||||
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
|
||||
properties:
|
||||
taskId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
tenantId: {type: string, enum: ['0'], description: 平台导出固定为0}
|
||||
appCode: {type: string, enum: [PLATFORM]}
|
||||
requestedBy: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
dispatchVersion: {type: integer, minimum: 1}
|
||||
parameters: {$ref: '#/components/schemas/DomesticWarehouseOrderExportParameters'}
|
||||
cursor: {type: string, nullable: true}
|
||||
snapshotToken: {type: string, nullable: true, maxLength: 512}
|
||||
limit: {type: integer, minimum: 1, maximum: 500}
|
||||
DomesticWarehouseOrderExportParameters:
|
||||
type: object
|
||||
description: >-
|
||||
selectedOrderNos 非空时仅导出所选订单;为空或省略时使用其余筛选字段导出全量匹配结果。
|
||||
筛选字段与 GET /api/platform/v1/orders 一致,但不得传 page、size。
|
||||
properties:
|
||||
selectedOrderNos:
|
||||
type: array
|
||||
maxItems: 5000
|
||||
uniqueItems: true
|
||||
items: {type: string, minLength: 1, maxLength: 64}
|
||||
tenantId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
keyword: {type: string, maxLength: 64}
|
||||
orderNo: {type: string, maxLength: 64}
|
||||
mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]}
|
||||
customerId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
|
||||
inboundCode: {type: string, maxLength: 64}
|
||||
internalOrderStatus: {type: string, maxLength: 32}
|
||||
expectedArrivalDate: {type: string, format: date}
|
||||
feeReviewStatus: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]}
|
||||
channelName: {type: string, maxLength: 128}
|
||||
containerNo: {type: string, maxLength: 64}
|
||||
cabinetNo: {type: string, maxLength: 64}
|
||||
sailingDate: {type: string, format: date}
|
||||
loaded: {type: boolean}
|
||||
fullContainer: {type: boolean}
|
||||
measurementReviewStatus: {type: string, enum: [UNRECORDED, PENDING, APPROVED, REJECTED]}
|
||||
fbaCode: {type: string, maxLength: 64}
|
||||
createdFrom: {type: string, format: date-time}
|
||||
createdTo: {type: string, format: date-time}
|
||||
TenantOrderStatisticsResponse:
|
||||
type: object
|
||||
required: [tenantId, totalSubmittedOrders, currentMonthSubmittedOrders, monthStart, nextMonthStart, zoneId, asOf]
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DomesticWarehouseOrderExportOpenApiContractTest {
|
||||
@Test
|
||||
void documentsAsyncProviderSelectionPrecedenceAndListFilters() throws Exception {
|
||||
String source = new ClassPathResource("openapi/order-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
Map<String, Object> document = map(new Yaml().load(source));
|
||||
Map<String, Object> paths = map(document.get("paths"));
|
||||
Map<String, Object> operation = map(map(paths.get(
|
||||
"/internal/exports/v1/DOMESTIC_WAREHOUSE_ORDER_LIST/batches")).get("post"));
|
||||
assertThat(operation.get("description").toString())
|
||||
.contains("selectedOrderNos 非空时忽略查询筛选", "不生成文件",
|
||||
"DOMESTIC_WAREHOUSE_ORDER_LIST", "platform:order:export");
|
||||
|
||||
Map<String, Object> schemas = map(map(document.get("components")).get("schemas"));
|
||||
Map<String, Object> parameters = map(map(
|
||||
schemas.get("DomesticWarehouseOrderExportParameters")).get("properties"));
|
||||
assertThat(parameters).containsKeys(
|
||||
"selectedOrderNos", "tenantId", "keyword", "orderNo", "mainStatus", "customerId",
|
||||
"receivingWarehouseId", "destinationWarehouseId", "inboundCode", "internalOrderStatus",
|
||||
"expectedArrivalDate", "feeReviewStatus", "channelName", "containerNo", "cabinetNo",
|
||||
"sailingDate", "loaded", "fullContainer", "measurementReviewStatus", "fbaCode",
|
||||
"createdFrom", "createdTo");
|
||||
assertThat(parameters).doesNotContainKeys("page", "size", "orderId", "customerInternalId");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> map(Object value) {
|
||||
return (Map<String, Object>) value;
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.order.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.order.service.DomesticWarehouseOrderExportProviderService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(DomesticWarehouseOrderExportProviderController.class)
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef")
|
||||
class DomesticWarehouseOrderExportProviderControllerTest {
|
||||
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
@MockBean private DomesticWarehouseOrderExportProviderService service;
|
||||
@Resource private DomesticWarehouseOrderExportProviderController controller;
|
||||
|
||||
@Test
|
||||
void acceptsOnlyConfiguredInternalServiceToken() {
|
||||
ExportBatchRequest request = new ExportBatchRequest(
|
||||
"1001", "0", "PLATFORM", "7001", 1, Map.of(), null, null, 100);
|
||||
ExportBatchResponse response = new ExportBatchResponse(
|
||||
1, "hash", "snapshot", List.of(), List.of(), null, false, 0L);
|
||||
when(service.exportBatch(request)).thenReturn(response);
|
||||
|
||||
assertThat(controller.batch(TOKEN, request).data()).isSameAs(response);
|
||||
verify(service).exportBatch(request);
|
||||
assertThatThrownBy(() -> controller.batch("wrong-token", request))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("Invalid internal service token");
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.baiyue.order.service;
|
||||
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.order.dto.UnifiedOrderQuery;
|
||||
import com.baiyue.order.dto.UnifiedOrderResponse;
|
||||
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.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@SpringJUnitConfig(DomesticWarehouseOrderExportProviderService.class)
|
||||
@TestPropertySource(properties = "baiyue.security.internal-service-token=0123456789abcdef0123456789abcdef")
|
||||
class DomesticWarehouseOrderExportProviderServiceTest {
|
||||
@MockBean private UnifiedOrderQueryService orders;
|
||||
@Resource private DomesticWarehouseOrderExportProviderService service;
|
||||
|
||||
@Test
|
||||
void nonEmptySelectionExportsOnlySelectedBusinessOrdersAndIgnoresFilters() {
|
||||
UnifiedOrderResponse.Row first = row("ORD-2", "RK-2", "客户乙");
|
||||
UnifiedOrderResponse.Row second = row("ORD-1", "RK-1", "客户甲");
|
||||
when(orders.platformRowsByOrderNos(List.of("ORD-2", "ORD-1")))
|
||||
.thenReturn(List.of(first, second));
|
||||
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("selectedOrderNos", List.of("ORD-2", "ORD-1"));
|
||||
parameters.put("mainStatus", "PENDING_REVIEW");
|
||||
ExportBatchResponse response = service.exportBatch(request("1001", parameters, null, null, 500));
|
||||
|
||||
assertThat(response.totalRows()).isEqualTo(2);
|
||||
assertThat(response.rows()).extracting(row -> row.get("orderNo"))
|
||||
.containsExactly("ORD-2", "ORD-1");
|
||||
assertThat(response.rows().get(0)).containsEntry("inboundCode", "RK-2")
|
||||
.containsEntry("customerName", "客户乙")
|
||||
.doesNotContainKeys("orderId", "tenantId", "customerId", "inboundId",
|
||||
"receivingWarehouseId", "destinationWarehouseId");
|
||||
assertThat(response.columns()).extracting(column -> column.key())
|
||||
.doesNotContain("orderId", "tenantId", "customerId", "inboundId");
|
||||
verify(orders, never()).platformPage(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptySelectionExportsAllMatchingFiltersWithProviderPaging() {
|
||||
UnifiedOrderResponse.Row row = row("ORD-3", "RK-3", "客户丙");
|
||||
when(orders.platformPage(eq(9001L), any()))
|
||||
.thenReturn(new PageResult<>(List.of(row), 1, 500, 501, 2));
|
||||
|
||||
Map<String, Object> parameters = new LinkedHashMap<>();
|
||||
parameters.put("selectedOrderNos", List.of());
|
||||
parameters.put("tenantId", "9001");
|
||||
parameters.put("keyword", "RK-3");
|
||||
parameters.put("loaded", true);
|
||||
parameters.put("createdFrom", "2026-08-01T00:00:00");
|
||||
ExportBatchResponse response = service.exportBatch(request("1002", parameters, null, null, 500));
|
||||
|
||||
ArgumentCaptor<UnifiedOrderQuery> query = ArgumentCaptor.forClass(UnifiedOrderQuery.class);
|
||||
verify(orders).platformPage(eq(9001L), query.capture());
|
||||
assertThat(query.getValue().keyword()).isEqualTo("RK-3");
|
||||
assertThat(query.getValue().loaded()).isTrue();
|
||||
assertThat(query.getValue().page()).isEqualTo(1);
|
||||
assertThat(query.getValue().size()).isEqualTo(500);
|
||||
assertThat(response.totalRows()).isEqualTo(501);
|
||||
assertThat(response.hasMore()).isTrue();
|
||||
assertThat(response.nextCursor()).isEqualTo("2");
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectionMustBeUniqueAndEntirelyVisible() {
|
||||
assertThatThrownBy(() -> service.exportBatch(request("1003",
|
||||
Map.of("selectedOrderNos", List.of("ORD-1", "ORD-1")), null, null, 100)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("不能重复");
|
||||
|
||||
UnifiedOrderResponse.Row visible = row("ORD-1", "RK-1", "客户甲");
|
||||
when(orders.platformRowsByOrderNos(List.of("ORD-1", "ORD-MISSING")))
|
||||
.thenReturn(List.of(visible));
|
||||
assertThatThrownBy(() -> service.exportBatch(request("1004",
|
||||
Map.of("selectedOrderNos", List.of("ORD-1", "ORD-MISSING")), null, null, 100)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("不存在或不可见");
|
||||
}
|
||||
|
||||
@Test
|
||||
void filteredExportRejectsEmptyAndOverLimitResults() {
|
||||
when(orders.platformPage(any(), any()))
|
||||
.thenReturn(new PageResult<>(List.of(), 1, 100, 0, 0));
|
||||
assertThatThrownBy(() -> service.exportBatch(request("1005", Map.of(), null, null, 100)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("没有符合");
|
||||
|
||||
when(orders.platformPage(any(), any()))
|
||||
.thenReturn(new PageResult<>(List.of(), 1, 100, 200_001, 2001));
|
||||
assertThatThrownBy(() -> service.exportBatch(request("1006", Map.of(), null, null, 100)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("超过最大限制");
|
||||
}
|
||||
|
||||
@Test
|
||||
void continuationSnapshotCannotBeReusedForDifferentTaskOrParameters() {
|
||||
UnifiedOrderResponse.Row row = row("ORD-1", "RK-1", "客户甲");
|
||||
when(orders.platformPage(any(), any()))
|
||||
.thenReturn(new PageResult<>(List.of(row), 1, 1, 2, 2));
|
||||
ExportBatchResponse first = service.exportBatch(request("1007", Map.of("keyword", "A"), null, null, 1));
|
||||
|
||||
assertThatThrownBy(() -> service.exportBatch(
|
||||
request("1008", Map.of("keyword", "A"), "2", first.snapshotToken(), 1)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("does not match");
|
||||
assertThatThrownBy(() -> service.exportBatch(
|
||||
request("1007", Map.of("keyword", "B"), "2", first.snapshotToken(), 1)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("does not match");
|
||||
}
|
||||
|
||||
private ExportBatchRequest request(String taskId, Map<String, Object> parameters,
|
||||
String cursor, String snapshot, int limit) {
|
||||
return new ExportBatchRequest(taskId, "0", "PLATFORM", "7001", 1,
|
||||
parameters, cursor, snapshot, limit);
|
||||
}
|
||||
|
||||
private UnifiedOrderResponse.Row row(String orderNo, String inboundCode, String customerName) {
|
||||
UnifiedOrderResponse.Row row = mock(UnifiedOrderResponse.Row.class);
|
||||
when(row.orderNo()).thenReturn(orderNo);
|
||||
when(row.inboundCode()).thenReturn(inboundCode);
|
||||
when(row.customerName()).thenReturn(customerName);
|
||||
return row;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user