This commit is contained in:
2026-08-08 12:01:45 +08:00
parent 1a2f598c9a
commit a35cd868b9
56 changed files with 7178 additions and 735 deletions
+20 -46
View File
@@ -1,56 +1,30 @@
# Order Service 模块指引
## 职责边界
## 职责与边界
- 负责头程订单主数据、草稿、提交、平台处理状态、货物、参与方快照、状态历史、内部备注和 Outbox。
- 履约作业与分配属于 `firstleg/`;仓库和往来单位主数据属于 `warehouse/`;订单只保存必要业务快照。
- 负责头程订单主数据、货物明细、快照、状态编排、幂等、Outbox 和内部货物查询。
- 订单交易状态归本模块;履约/入库状态归 `firstleg/`,仓储资源归 `warehouse/`,不得跨库读取。
- `tenant_id` 是客户隔离键;`customer_id` 是业务客户/往来单位快照,不能替代前者。
## 功能快速索引
## 模块入口
| 功能 | 入口 |
| 目标 | 入口 |
|---|---|
| 商户订单创建、编辑、列表、详情、提交、取消 | `controller/MerchantFirstLegOrderController.java`;列表校验 `merchant:order:list`,详情校验 `merchant:order:view` |
| 平台订单列表、详情、审核、接单、开始、完成、取消 | `controller/PlatformFirstLegOrderController.java`;列表和详情校验 `platform:order:list` |
| 平台批量客户运单数 | `PlatformFirstLegOrderController.java` 的 `GET /tenant-counts`、`service/OrderApplicationService.countByTenantIds`、`repository/OrderRepository.countByTenantIds`、`dto/TenantOrderCountResponse.java` |
| Firstleg 查询订单货物明细 | `controller/InternalOrderCargoController.java`(`GET /internal/order/v1/cargoes/{tenantId}/{orderId}/{cargoId}`)→ `repository/OrderRepository.findCargo`;契约测试 `controller/InternalOrderCargoControllerTest.java` |
| 内部备注与快递信息 | `PlatformFirstLegOrderController.java` |
| 核心编排与状态机 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` |
| 企业下单资格 | `service/OrderEligibilityGuard.java`、`client/TenantEligibilityClient.java` |
| 仓库/往来单位有效性 | `service/OrderMasterDataGuard.java`、`client/WarehouseClient.java`、`PartnerClient.java`;往来单位响应同时包含多值 `types` 与兼容字段 `partnerType` |
| 客户归属与访问范围 | `service/OrderScopeGuard.java`;类内 `tenant` 命名为历史兼容 |
| 请求与详情响应 | `dto/OrderRequests.java`、`OrderDetailResponse.java` |
| 主单、货物、参与方、历史、备注、Outbox、幂等记录 | `entity/`、`mapper/`、`repository/OrderRepository.java` |
| 表结构与一期扩展 | `src/main/resources/db/order.sql`、`phase1-masterdata-extension.sql`、`phase1-order-extension.sql` |
| 模块说明 | `README-ORDER.md` |
| 状态、主数据与接口权限测试 | `src/test/java/com/baiyue/order/service/`、`src/test/java/com/baiyue/order/controller/` |
| 平台订单 API | `controller/PlatformFirstLegOrderController.java` |
| 订单编排/状态/幂等 | `service/OrderApplicationService.java`、`entity/OrderStatus.java` |
| 货物内部查询 | `controller/InternalOrderCargoController.java` |
| 快照/Outbox | `service/`、`repository/`、`event/` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
| SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/` |
| 测试/验证 | `src/test/java/com/baiyue/order/`;`mvn -pl order -am test` |
## 修改与验证
## 修改约束
- 所有商户查询和写入必须执行客户归属/访问范围校验;业务表 `tenant_id` 当前承载客户 ID。平台操作必须验证平台技术作用域、目标客户、权限及合法状态迁移。
- 查询接口同样必须校验权限码;沿用 IAM 已初始化的权限资源,新增权限码时同步补充 IAM 初始化脚本与角色授权。
- 已提交订单中的仓库、往来单位等快照不能随主数据修改而变化。
- 客户列表中的真实运单数归 Order,不得写回 IAM。历史接口 `GET /api/platform/v1/orders/first-leg/tenant-counts?tenantIds=...` 仅允许平台 `platform:order:list`,一次接收 1 至 200 个有效客户 ID,并对没有订单的客户返回 0;路径和参数名保持兼容。
- Warehouse 往来单位已支持多类型;下单校验优先使用 `PartnerClient.Partner.types`,`partnerType` 只作为旧数据兼容。类型语义变化需同步 Warehouse 的 `wh_partner_type`、Order 客户端契约与快照测试。
- 往来单位是平台统一维护、供所有客户共享的公共主数据,Order 调用 Warehouse 内部接口时固定使用公共作用域 `tenant_id=0`;订单自己的 `tenant_id` 仍是客户隔离键,不能拿来查询往来单位。
- 跨服务只通过 `client/` 或事件演进,不跨库查询 IAM/Warehouse;跨域通知优先使用 Outbox。
- 二期第三方测量绑定订单货物时,Firstleg 只能通过 `InternalOrderCargoController` 校验 `tenantId + orderId + cargoId` 并读取必要快照;该接口必须保留内部令牌校验,不能暴露到 Gateway,也不能返回 Order Entity。
- Order 到 Warehouse 的内部调用使用 Nacos 服务名 `warehouse-service`,不经过 Gateway;只有隔离端口回归显式设置 `WAREHOUSE_SERVICE_URL`。内部接口返回 403 时应将外部响应规范化为 `S0503`,并提示核对内部服务凭证,禁止输出真实令牌。
- 新增状态或动作时同步修改 Controller、Service 状态机、历史记录、DTO、SQL、前端消费者和测试。
- 创建草稿和提交订单必须接收 `Idempotency-Key`;幂等范围按客户、操作者和操作隔离,请求指纹包含资源,并由数据库唯一键保证并发安全。
- 订单创建会落库收货仓地址、收货时间和主数据名称快照;既有库必须执行 `phase1-order-extension.sql`,否则实体字段与表结构漂移会在草稿落库时产生 HTTP 200 + `S0500`,真实原因只记录在服务日志。
- 从父目录运行 `mvn -pl order -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1` 或对应一期回归。
- 普通业务失败使用 HTTP 200 + body code;Feign 收到 HTTP 非 200 代表鉴权、协议或基础设施异常,应归一为 `S0503`,不能用 Feign 状态码推断 404/409 业务语义。
- 所有客户查询在 Repository/Mapper 显式带 `tenant_id`;状态变更保留幂等、历史、审计和并发控制。
- Warehouse 往来单位、Firstleg 入库协作只通过客户端 DTO/内部令牌;修改契约需同步消费者。
## 平台代客接口补充
## 分期导航
- `POST /api/platform/v1/orders/first-leg?tenantId={tenantId}`:平台代客创建草稿,校验 `platform:order:create`,必须携带 `Idempotency-Key`。
- `PUT /api/platform/v1/orders/first-leg/{id}?tenantId={tenantId}`:平台编辑草稿,校验 `platform:order:edit`。
- `POST /api/platform/v1/orders/first-leg/{id}/submit?tenantId={tenantId}`:平台代客提交订单,校验 `platform:order:submit`,必须携带 `Idempotency-Key`。
- `src/main/resources/openapi/order-v1.yaml` 是静态 OpenAPI 入口;`ord_idempotency_record` 以客户隔离兼容字段、操作人、操作和幂等键建立唯一约束。
- 数据库执行顺序:新库先执行 `order.sql`,已有库执行 `phase1-order-extension.sql`;后者包含幂等表创建,必须在部署订单写接口前完成。
## 分期快速索引
- 一期:订单创建/提交/审核/接单/取消入口均在 `MerchantFirstLegOrderController`、`PlatformFirstLegOrderController`;先读 `../../docs/一期/README.md`、`../../docs/一期/一期开发进度表.md` 和 `../../docs/一期/一期头程订单闭环设计.md`。订单主状态由 `OrderStatus` 管理,提交资格由 IAM 审核与必填资质决定。
- 二期:Order 仅提供 `InternalOrderCargoController` 供 Firstleg 校验订单货物和快照,不承载入库测量、计费或托盘状态;先读 `../../docs/二期/README.md` 与主流程文档。二期终点停在 `WAIT_SPLIT`,不得在此模块加入分柜/装柜状态。
- 三期:当前 NO-GO;国内出仓状态机和排柜/订舱契约待 `../../docs/三期/三期需求确认表.md` 确认后再扩展,暂复用现有订单主数据和快照能力,不把原型动作当已确定 API。
- 一期:订单下单与交易状态闭环;先读 `../../docs/一期/README.md` 与闭环设计。
- 二期:仅提供货物内部查询,不承载入库状态;先读 `../../docs/二期/README.md`。
- 三期:NO-GO;待确认后再设计分柜/出仓状态扩展。
@@ -12,7 +12,7 @@ import java.time.LocalDateTime;
public record UnifiedOrderQuery(
@Size(max = 64) String keyword,
@Size(max = 64) String orderNo,
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED",
@Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED"})
@Size(max = 32) String mainStatus,
Long customerId,
@@ -31,7 +31,7 @@ import java.util.stream.Collectors;
@Service
public class UnifiedOrderQueryService {
private static final Set<String> MAIN_STATUSES = Set.of(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "LOADING", "LOADED", "DELIVERED");
"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,
@@ -199,8 +199,8 @@ public class UnifiedOrderQueryService {
if (status == null) return null;
return switch (status) {
case "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED" -> "WAIT_MEASURE";
// WAIT_SPLIT is retained only to normalize records created before status unification.
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED" -> "INBOUNDED";
case "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED" -> "INBOUNDED";
case "WAIT_SPLIT" -> "WAIT_SPLIT";
case "LOADING", "LOADED", "DELIVERED" -> status;
default -> null;
};
@@ -311,7 +311,7 @@ components:
required: false
schema:
type: string
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, LOADING, LOADED, DELIVERED]
enum: [PENDING_REVIEW, WAIT_MEASURE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED]
UnifiedInboundCode:
{name: inboundCode, in: query, required: false, description: 客户提交后生成的入仓码,待审核订单同样可查询, schema: {type: string, maxLength: 40}}
UnifiedInternalOrderStatus:
@@ -0,0 +1,65 @@
package com.baiyue.order.controller;
import com.baiyue.order.client.FirstlegUnifiedOrderClient;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.yaml.snakeyaml.Yaml;
import java.nio.charset.StandardCharsets;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class UnifiedOrderApiContractTest {
@Test
void keepsPlatformAndMerchantBoundaries() {
assertThat(PlatformUnifiedOrderController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/orders");
assertThat(MerchantUnifiedOrderController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/merchant/v1/orders");
}
@Test
void documentsPhaseTwoWaitSplitStatus() throws Exception {
String yaml = new ClassPathResource("openapi/order-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml));
Map<String, Object> paths = stringMap(document.get("paths"));
assertThat(paths).containsKeys(
"/api/platform/v1/orders", "/api/platform/v1/orders/{orderId}",
"/api/merchant/v1/orders", "/api/merchant/v1/orders/{orderId}");
Map<String, Object> components = stringMap(document.get("components"));
Map<String, Object> parameters = stringMap(components.get("parameters"));
Map<String, Object> mainStatus = stringMap(parameters.get("UnifiedMainStatus"));
Map<String, Object> schema = stringMap(mainStatus.get("schema"));
assertThat(stringList(schema.get("enum"))).containsExactly(
"PENDING_REVIEW", "WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT",
"LOADING", "LOADED", "DELIVERED");
}
@Test
void keepsFirstlegUnifiedOrderQueryPath() throws Exception {
FeignClient client = FirstlegUnifiedOrderClient.class.getAnnotation(FeignClient.class);
assertThat(client.path()).isEqualTo("/internal/firstleg/v1/inbounds/unified-orders");
Method page = FirstlegUnifiedOrderClient.class.getMethod(
"page", String.class, FirstlegUnifiedOrderClient.Query.class);
assertThat(page.getAnnotation(PostMapping.class).value()).containsExactly("/query");
}
@SuppressWarnings("unchecked")
private static Map<String, Object> stringMap(Object value) {
return (Map<String, Object>) value;
}
@SuppressWarnings("unchecked")
private static List<String> stringList(Object value) {
return (List<String>) value;
}
}
@@ -20,6 +20,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -139,6 +140,51 @@ class UnifiedOrderQueryServiceTest {
verify(orders, never()).unifiedPage(any(), any(), any());
}
@Test
void waitSplitFilterForwardsExactStatusAndPreservesSummaryForMerchantScope() {
UnifiedOrderQuery query = query("WAIT_SPLIT");
var summary = summary("WAIT_SPLIT", null);
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1)));
when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING)));
var result = service.page(10001L, query);
assertThat(result.records()).singleElement()
.extracting(row -> row.mainStatus()).isEqualTo("WAIT_SPLIT");
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg).page(eq("internal-token"), captor.capture());
assertThat(captor.getValue().tenantId()).isEqualTo(10001L);
assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() {
List<String> statuses = List.of(
"WAIT_MEASURE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED");
when(firstleg.page(eq("internal-token"), any()))
.thenReturn(ApiResponse.success(new PageResult<>(List.of(), 3, 20, 0, 0)));
statuses.forEach(status -> service.page(null, new UnifiedOrderQuery(
"CC-2026", "FL-77", status, null, null, null, null, null,
null, null, null, null, null, null, null, null, null, null,
null, null, 3, 20)));
ArgumentCaptor<FirstlegUnifiedOrderClient.Query> captor =
ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class);
verify(firstleg, org.mockito.Mockito.times(statuses.size()))
.page(eq("internal-token"), captor.capture());
IntStream.range(0, statuses.size()).forEach(index ->
assertThat(captor.getAllValues().get(index)).satisfies(request -> {
assertThat(request.mainStatus()).isEqualTo(statuses.get(index));
assertThat(request.keyword()).isEqualTo("CC-2026");
assertThat(request.orderNo()).isEqualTo("FL-77");
assertThat(request.page()).isEqualTo(3);
assertThat(request.size()).isEqualTo(20);
}));
}
@Test
void expandedFulfillmentFiltersAreForwardedBeforeDatabasePaging() {
UnifiedOrderQuery query = new UnifiedOrderQuery(