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
+34 -116
View File
@@ -1,129 +1,47 @@
# 柏悦头程后端项目指引
# 国内头程后端项目指引
## 适用范围
## 适用范围与定位
- 本文件适用于 `baiyue-firstleg/` 整个 Maven 多模块仓库;进入子模块后同时遵守该模块的 `AGENTS.md`。
- 普通开发先读本文件、目标模块 `AGENTS.md` 和相邻代码;项目概览或聚合构建问题再读 `README.md`、`pom.xml`,跨服务架构、端口数据库、根目录结构、启动或部署问题再读 `docs/头程项目目录结构与服务说明.md`。
- 本仓只承载国内头程业务。尾程源码、数据库、Redis、Nacos 和内部接口不得并入本仓。
- 本文件适用于 `baiyue-firstleg/` Maven 多模块仓库;进入具体服务后继续读取该服务的 `AGENTS.md`。
- 本层只负责:服务边界、跨服务契约、数据库部署总入口、阶段路由和通用验证。Controller、Service、DTO、SQL 细节以模块级指引为准。
- 普通任务先读本文件和目标模块指引;聚合构建/架构问题再读 `README.md`、`pom.xml`、`docs/头程项目目录结构与服务说明.md`。
## 当前主体与隔离模型(重要,所有阶段必须遵守)
## 不可突破的产品与数据边界
- 完整目标模型原计划为“租户—平台—客户”三级,但为尽快上线,当前约一年内采用 **业务单租户、平台管理多客户、客户严格隔离** 的模式。当前没有可管理、可选择或可切换的上级租户,也没有真实的“租户—平台—客户”三级关系。
- “业务单租户”只是产品阶段约束,不表示代码中存在一个供所有数据共用的默认租户 ID。现有代码和数据库的 `tenant_id` 已经广泛承载 **客户 ID**,是当前客户隔离键;严禁把所有客户数据的 `tenant_id` 统一改成同一个值,否则会直接破坏客户隔离。
- 当前需求、一期、二期、三期及后续未明确启用租户能力的开发,统一按“平台—客户”两级实现。不得自行新增租户主表、租户管理、租户切换、租户级配置、客户所属租户选择、租户级权限或其他显式多租户能力。
- 当前产品只有“平台—客户”两级。`tenant_id=0` 是平台技术作用域;`tenant_id>0` 是客户隔离键,不得新增租户切换、租户选择或租户级 RBAC。
- 公共仓库、往来单位、快递、托盘、库位统一归属 `tenant_id=0`;订单、文件、费用等客户数据按客户 `tenant_id>0` 隔离。不得跨库 Join 或信任客户端客户 ID。
- 订单交易状态归 `order`,履约/入库状态归 `firstleg`,仓储资源归 `warehouse`,文件对象归 `file`,OCR 适配归 `integration-service`;通过内部客户端、DTO 和服务令牌协作。
- 普通业务成功/失败使用 HTTP 200 + body `code`(成功为字符串 `"200"`);认证、基础设施和网关拒绝可使用真实 401/403/503。
当前代码中的主体标识固定按下表理解:
## 服务路由(按关键词定位)
| 现有结构 | 当前业务含义 | 修改约束 |
| 任务关键词 | 首选服务 | 先看入口 |
|---|---|---|
| `iam_tenant.id = 0`、`tenant_type = PLATFORM` | 平台技术作用域 | 必须保留为平台账号、平台角色和平台权限范围,不是未来的默认租户 |
| `iam_tenant.id > 0`、`tenant_type = MERCHANT` | 平台直接服务的客户;`iam_tenant` 的这些记录当前直接作为客户主档 | 客户列表和客户业务只能使用 `MERCHANT` 记录,不得把平台记录当客户 |
| Auth/JWT/业务表中的 `tenant_id > 0` | 当前登录客户或数据所属客户的 ID | 商户端从认证上下文取得;平台代客操作显式携带目标客户;Repository/Mapper 必须按该值过滤 |
| RBAC 数据中的 `tenant_id = 0` | 平台权限或供客户复用的全局角色/权限模板 | 不代表真实上级租户,不能作为客户数据隔离条件 |
| Order/Firstleg 等业务中的 `customer_id` | 订单往来单位、发货客户或其他业务客户快照,具体以模块契约为准 | 不是 IAM 平台客户 ID,不能替代 `tenant_id` 做当前客户隔离 |
| `DEFAULT_TENANT_ID`、`public-tenant-id` 等配置 | 平台公共仓库和主数据的技术归属配置,当前固定为平台作用域 `0` | 不代表未来默认租户;公共资源不得再绑定普通客户 `10001`,变更时必须同步 Warehouse 种子、存量迁移及全部消费者 |
| 登录、Token、密码、注册 | `auth/` | `auth/AGENTS.md` |
| 客户档案、成员、审核、RBAC、授权快照 | `iam/` | `iam/AGENTS.md` |
| 订单、货物、快照、状态、幂等、Outbox | `order/` | `order/AGENTS.md` |
| 履约分配、入库、测量、审核、FBA 计费 | `firstleg/` | `firstleg/AGENTS.md` |
| 仓库、往来单位、快递、托盘、库位 | `warehouse/` | `warehouse/AGENTS.md` |
| 资质、导出、测量图片、文件下载 | `file/` | `file/AGENTS.md` |
| 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` |
| 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` |
| PDF 标签、审计、事件 | `print/`、`audit-service/`、`event-bridge-service/` | 各服务指引 |
- 当前真正的数据隔离边界是上述客户 ID。服务端必须校验客户归属和访问权限;商户请求不得信任前端传入其他客户 ID,平台跨客户查询或代客操作必须同时受平台应用域、权限码和目标客户条件约束。
- 平台维护并供所有客户使用的公共仓库、往来单位、快递公司、托盘、库位等公共资源统一归属平台技术作用域 `tenant_id = 0`;客户订单、文件、费用和其他客户私有数据继续使用客户 `tenant_id > 0`。往来单位不是客户私有数据,不能按客户复制或隔离。不得再使用演示客户 `10001` 充当公共资源所有者。
- 代码、数据库和接口中已有的 `tenant`、`Tenant*`、`tenant_id`、`merchant` 等名称属于历史命名和兼容契约,当前不做大规模重命名;但新增代码和文档必须按本节解释其真实含义,禁止看到名称后推断系统已经存在显式租户层。
- 预计约一年后再以独立需求引入真实租户。届时应优先新增独立租户主表或明确的上级 `root_tenant_id`,把所有存量 `MERCHANT` 客户统一归属到一个默认租户,再迁移 Auth、JWT、跨服务 DTO 和各服务数据库;不得原地把现有 `iam_tenant.id` 或业务 `tenant_id` 从客户 ID 改义为上级租户 ID。
- 用户在后续对话明确调整本模型之前,本节是所有新模块和分期任务的事实来源;原型、历史设计或代码中的“租户”文案不得自动扩大实现范围。
## 阶段路由
## 模块快速索引
- 一期:`auth`、`iam`、`warehouse`、`order`、`firstleg`、`file` 完成登录、客户/RBAC、公共仓、订单闭环;先读 `../docs/一期/README.md` 及闭环设计。
- 二期:`firstleg` 主导入库/测量/审核/计费,`warehouse` 管托盘库位,`file` 管测量图片,`integration-service` 管 OCR,`order` 仅提供货物内部查询;终点为 `WAIT_SPLIT + APPROVED + CONFIRMED`,不实现分柜、装柜、PDA 和完整财务。
- 三期:当前 NO-GO;待 `../docs/三期/三期需求确认表.md` 的 P3 阻断项解除并锁定契约后,才扩展国内出仓、分柜、装柜、报关和 PDA 协作。
| 模块 | 职责 | 首选入口 |
|---|---|---|
| `platform-bom/` | 公共制品版本清单 | `pom.xml` 的 `dependencyManagement` |
| `common-core/` | 返回模型、错误码、基础实体、MyBatis 配置 | `ApiResponse.java`、`ResultCode.java`、`exception/`、`model/`、`mybatis/`;成功码为字符串 `"200"` |
| `common-web/` | HTTP 200 业务响应、全局异常、请求链路标识、长整型 JSON 字符串序列化 | `CommonWebAutoConfiguration.java`、`GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` |
| `common-security/` | JWT、Redis 会话校验、认证上下文、401/403 | `JwtAuthenticationFilter.java`、`RedisAccessTokenSessionValidator.java` |
| `common-openapi/` | SpringDoc 自动配置与长整型字符串契约 | `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` |
| `common-audit/` | 审计注解、切面、脱敏与日志/HTTP 发送 | `BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`HttpAuditSink.java` |
| `common-event-contract/` | 跨区域事件信封、类型、标识、哈希与校验 | `EventEnvelope.java`、`EventTypes.java`、`EventEnvelopeValidator.java` |
| `gateway/` | 路由、CORS、入口安全、请求标识、访问域隔离 | `config/`、`filter/CorrelationIdWebFilter.java`、`src/main/resources/application*.yml` |
| `auth/` | 登录、令牌、注册、账号、密码与账号归属范围 | `InternalAccountController.java`、`AccountManagementService.java`、`AccountManagementDtos.java`、`src/main/resources/db/` |
| `iam/` | 单租户模式下的客户、客户成员、平台统一用户管理、企业资料/资质、RBAC 与授权快照;部分代码仍保留 `Tenant*` 历史命名 | `PlatformUserManagementController.java`、`PlatformTenantController.java`、`ProfileResourceController.java`、`RbacController.java` |
| `order/` | 订单主数据、客户运单数、持久化幂等、状态编排、快照、货物明细内部查询与 Outbox;客户之间按归属隔离,部分字段/接口仍保留 `tenant` 历史命名 | `PlatformFirstLegOrderController.java`、`OrderApplicationService.java`、`InternalOrderCargoController.java`、`README-ORDER.md` |
| `firstleg/` | 履约任务与分配;二期入库单、测量/OCR 结果、审核、FBA 计费和自动完成入库 | 履约:`PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`;入库:`controller/PlatformInbound*.java`、`service/Inbound*.java`、`service/Fba*.java` |
| `warehouse/` | 仓库、地址、服务能力、往来单位、快递公司、海外仓阶梯费用;二期托盘、库位、预占和占用 | 主数据:`controller/`、`PlatformPartnerQueryService.java`、`PlatformOverseasStorageFeeController.java`;二期:`PlatformPalletController.java`、`PlatformLocationController.java`、`PalletLocationAllocationService.java` |
| `file/` | 企业资质文件、公共导出/下载中心;入库测量图片与重量 OCR 协作 | 资质:`QualificationFileController.java`;导出:`PlatformExportTaskController.java`、`ExportStateService.java`;测量图片:`PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`MeasurementImageService.java` |
| `print/` | 无数据库的无状态 PDF 渲染;固定入仓标签模板、二维码、字体嵌入和资源保护 | `InternalPdfController.java`、`InboundMarkPdfService.java`、`InboundMarkV1Renderer.java`、`src/main/resources/openapi/print-internal-v1.yaml` |
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 |
| `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` |
| `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` |
## 契约、数据库与验证入口
## 高频业务检索入口
- 跨服务修改先检查调用方 `client/`、被调用方 Controller/DTO、`src/main/resources/openapi/` 和双方契约测试;禁止共享领域 Entity。
- 数据库只访问所属服务库。模块迁移位于 `<module>/src/main/resources/db/`;测试/存量环境唯一部署入口是 `deploy/sql/头程数据库统一增量-MySQL8.sql`,说明见 `deploy/sql/README.md`,历史日期脚本仅追溯。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- 全量验证:`mvn -DskipTests validate`、`mvn test`;单服务优先 `mvn -pl <module> -am test`。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。
| 搜索目标 | 先读 |
|---|---|
| 后台用户管理、平台/客户账号、跨客户调动 | `iam/.../controller/PlatformUserManagementController.java` → `service/PlatformUserManagementService.java` → `auth/.../controller/InternalAccountController.java` 的 scope 接口 |
| 企业客户档案、审核、必填资质 | `iam/.../controller/PlatformTenantController.java`、`service/TenantService.java`、`service/ProfileResourceService.java` |
| 客户列表真实运单数 | `order/.../controller/PlatformFirstLegOrderController.java` 的 `/tenant-counts`;不要在 IAM 查订单表 |
| 平台代客户上传或替换资质 | `file/.../controller/PlatformQualificationFileController.java` → `client/IamQualificationClient.java` → `iam/.../controller/InternalTenantController.java` 的 `/platform-qualifications` |
| 往来单位多类型 | `warehouse/.../service/PlatformPartnerQueryService.java`、`entity/PartnerTypeEntity.java`、`src/main/resources/db/warehouse-partner.sql`,消费者看 `order/.../client/PartnerClient.java` |
| 往来单位/快递公司批量导入 | `warehouse/.../service/PartnerImportService.java`、`ExpressCompanyImportService.java`、两个平台 Controller 的 `/import-template` 与 `/import` |
| 海外仓阶梯费用 | `warehouse/.../controller/PlatformOverseasStorageFeeController.java` → `service/OverseasStorageFeeService.java` → `repository/OverseasStorageFeeRepository.java`;表为 `wh_overseas_storage_fee_rule`、`wh_overseas_storage_fee_tier` |
| 入仓标签 PDF 生成 | 调用方先读 `print/README.md`;实现为 `print/.../controller/InternalPdfController.java` → `service/InboundMarkPdfService.java` → `renderer/InboundMarkV1Renderer.java`;机器契约见 `print/src/main/resources/openapi/print-internal-v1.yaml`,仅供携带内部令牌的服务调用,不加入 Gateway |
| HTTP 200、body code、成功码 `"200"` | `docs/统一响应约定改造方案-HTTP200.md`、`common-core/ResultCode.java`、`common-web/GlobalExceptionHandler.java` |
| 单服务 Jenkins 构建或部署 | 总控 `Jenkinsfile`、子 Job `Jenkinsfile.service`、模块/JAR 映射 `deploy/service-map.yml` |
## 阶段文档入口
## 二期入库快速索引
| 业务链路 | API 与编排入口 | 数据、跨服务与验证入口 |
|---|---|---|
| 平台入库单列表、页签与聚合详情 | `firstleg/.../controller/PlatformInboundOrderController.java` → `service/InboundOrderQueryService.java` | `repository/InboundOrderRepository.java`、`mapper/InboundAllocationQueryMapper.java`;测试 `service/InboundOrderQueryServiceTest.java`、`controller/InboundOrderControllerContractTest.java` |
| 平台测量草稿、提交及重量 OCR 结果 | `firstleg/.../controller/PlatformInboundMeasurementController.java` → `service/InboundMeasurementService.java`、`MeasurementSubmissionService.java` | 图片走 `client/MeasurementImageClient.java`,托盘库位走 `client/WarehousePalletLocationClient.java`;测试 `service/InboundMeasurementServiceTest.java`、`MeasurementSubmissionServiceTest.java` |
| 第三方尺寸测量回传 | `firstleg/.../controller/OpenApiInboundMeasurementController.java` → `security/MeasurementRequestAuthenticator.java` → `service/InboundMeasurementOpenApiService.java` | 订单货物校验走 `client/OrderCargoClient.java` → `order/.../controller/InternalOrderCargoController.java`;证据图片走 File 内部接口;配置 `config/MeasurementOpenApiProperties.java`,测试 `service/InboundMeasurementOpenApiServiceTest.java` |
| 测量审核、异常放行与自动完成入库 | `firstleg/.../controller/PlatformInboundMeasurementReviewController.java` → `service/MeasurementReviewService.java` → `AutomaticInboundCompletionService.java` | 费用与入库状态编排在 `FbaPricingService.java`、`InboundCompletionOrchestrator.java`;测试 `service/MeasurementReviewServiceTest.java`、`InboundCompletionOrchestratorTest.java` |
| FBA 客户规则、承运商价格与费用快照 | 主数据 `firstleg/.../controller/PlatformFbaMasterDataController.java` → `service/FbaMasterDataService.java`;计费 `PlatformFbaPricingController.java` → `service/FbaPricingService.java` | `repository/FbaPricingRepository.java`、`src/main/resources/openapi/fba-pricing-v1.yaml`、`src/main/resources/db/phase2-fba-pricing-migration.sql`;测试 `service/FbaMasterDataServiceTest.java`、`FbaPricingServiceTest.java` |
| 客户查看入库费用 | `firstleg/.../controller/MerchantInboundBillingController.java` → `service/MerchantInboundBillingService.java` | 只按已认证客户查询;`Merchant*` 及 `/api/merchant/` 为历史兼容命名,接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing` |
| 托盘、库位与预占/占用 | 平台 CRUD:`warehouse/.../controller/PlatformPalletController.java`、`PlatformLocationController.java`;内部事务接口:`InternalPalletLocationController.java` | `service/PalletApplicationService.java`、`LocationApplicationService.java`、`PalletLocationAllocationService.java`;表与迁移在 `warehouse/src/main/resources/db/phase2-*.sql` |
| 称重图片、OCR 重试与第三方测量证据 | 平台入口 `file/.../controller/PlatformMeasurementImageController.java`;Firstleg 内部入口 `InternalMeasurementImageController.java` | `service/MeasurementImageService.java`、`MeasurementImageStorage.java`、`client/WeightOcrClient.java`;重量识别服务入口 `integration-service/.../controller/WeightOcrController.java` |
| 入库状态、幂等、历史与 Outbox | `firstleg/.../domain/InboundProcessStatus.java`、`service/InboundIdempotencyService.java`、`repository/InboundWorkflowRepository.java` | 表结构与增量在 `firstleg/src/main/resources/db/phase2-*.sql`;SQL 契约测试 `repository/InboundWorkflowSqlContractTest.java` |
| 数据库部署 | 空库/存量环境唯一入口 `deploy/sql/头程数据库统一增量-MySQL8.sql` | 覆盖全部一期、二期及后续增量;可重复执行边界和末尾 `PASS` 核验见脚本头及 `deploy/sql/README.md`,历史日期脚本不得再单独执行 |
- 上表中的 `模块/.../` 从对应模块的 `src/main/java/com/baiyue/<domain>/` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。
- 二期入库主状态归 `firstleg`,订单货物主数据仍归 `order`,托盘/库位资源归 `warehouse`,文件对象归 `file`,OCR 供应商适配归 `integration-service`;禁止通过跨库查询绕过内部 API。
## 一期/二期/三期后端阶段索引
本表只固定后端职责和阶段边界;完成度、验收数量和数据库部署状态以仓库根目录对应阶段 `README.md`、最新验收报告及统一部署说明为准。
| 阶段 | 后端边界与入口 | 开始工作前阅读 | 状态边界 |
|---|---|---|---|
| 一期:头程订单闭环 | `auth/` 登录与账号、`iam/` 客户/RBAC/资质审核、`warehouse/` 公共仓与主数据、`order/` 订单主数据、`firstleg/` 履约任务、`file/` 资质文件 | `../docs/一期/README.md` → `../docs/一期/一期开发进度表.md` → `../docs/一期/一期头程订单闭环设计.md`;再读目标模块 `AGENTS.md`、Controller/DTO/OpenAPI 与迁移脚本 | 客户审核与必填资质通过后才具备下单资格;订单状态由 `order/` 主导,履约分配由 `firstleg/` 主导;不得把二期入库状态回写为一期订单状态 |
| 二期:到仓与头程计费 | `firstleg/` 入库、测量、审核、FBA 计费与完成编排;`warehouse/` 托盘/库位预占占用;`file/` 测量图片;`order/` 仅提供货物内部查询;`integration-service/` OCR | `../docs/二期/README.md` → `../docs/二期/二期需求优化版-v2-测量计费入库主流程.md` → `../docs/二期/二期V2需求确认清单.md`/`二期计费财务需求确认表.md` → 联调报告;旧 `二期开发进度表.md` 仅作历史参考 | 当前技术验收终点为 `WAIT_SPLIT + APPROVED + CONFIRMED`;`WAIT_SPLIT` 等待三期分柜,二期不得实现分柜、装柜、PDA 或完整财务应收应付 |
| 三期:国内出仓 | 当前 NO-GO;待契约确认后再进入 `order/`、`firstleg/`、`warehouse/`、`file/` 的出仓扩展,并与 PDA/外部接口协作 | `../docs/三期/README.md` → `../docs/三期/三期开发规划.md` → `../docs/三期/三期需求确认表.md` → `../docs/三期/三期开发进度表.md`、`../docs/三期/三期页面与原型清单.md`;先解除 P3-Q1/Q2/Q5/Q9/Q11/Q12/Q13 | 阶段终点为国内出仓、等待装船;确认项未解除前不得把复用接口或原型当作正式三期契约 |
跨阶段修改必须先锁定 `order`/`firstleg` 状态机归属,再同步检查 `warehouse`、`file`、`auth`、`iam` 的内部 DTO、权限码、令牌和数据库增量;不得跨库读取或跳过内部 API。
## 配置、数据库与验证入口
- 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。
- 每个服务的文件日志和滚动策略也在自身 `application.yml`,统一通过 `LOG_PATH` 或 `LOGGING_*` 环境变量覆盖;容器宿主机挂载见 `deploy/docker-compose.yml`。
- 子模块索引中 `controller/`、`service/`、`client/` 等包内简写均从该模块的 `src/main/java/com/baiyue/<domain>/` 起算;资源路径从模块根目录起算。
- 表结构和增量脚本优先在目标模块 `src/main/resources/db/` 查找;每个服务只能访问自己的数据库,禁止跨库 Join。
- `CREATE TABLE IF NOT EXISTS` 不会升级既有表;新增字段或索引时必须同时提供可重复执行的增量迁移,并在部署清单中写明执行顺序。
- 服务间调用先找调用方 `client/`;跨区域数据只使用 `common-event-contract/` 和事件桥,不共享领域 Entity。
- 本地环境切换看 `docs/数据库环境切换说明.md`、`scripts/local-env.ps1` 与 `config/nacos/local/`;接口契约优先读目标模块 `src/main/resources/openapi/` 和 Controller,回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。
- 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。
- IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。
- 测试环境数据库增量统一从 `deploy/sql/头程数据库统一增量-MySQL8.sql` 执行,执行边界与末尾核验方式见 `deploy/sql/README.md`;历史日期脚本只用于追溯,不能作为当前部署入口。
- CI 总控入口是 `Jenkinsfile`,负责按变更或勾选项并行调度 11 个独立服务 Job;单服务构建、镜像与部署逻辑在 `Jenkinsfile.service`。Job 名必须以模块名结尾,公共模块或 `deploy/` 变化会触发全部服务。
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
- 公共导出中心测试环境真实任务验收使用 `scripts/export-center-test-env-acceptance.ps1`;脚本只调用 Gateway,不负责部署或停止 Broker/File/IAM,故障注入由有权限的运维配合。
- `reports/`、`target/`、日志、上传文件和本地环境文件不属于源码,检索与提交时排除。
## 通用修改约束
- Controller 只做协议转换,Service 负责编排,Repository/Mapper 负责持久化;不要跨层堆放业务逻辑。
- 当前采用业务单租户、平台管理多客户模式。现有 `tenant_id` 是客户归属与隔离键,客户数据必须保留该维度,并由服务端执行平台/客户访问域、客户数据隔离、数据范围和按钮权限校验;不得把它统一替换为所谓默认租户 ID,也不得据此允许跨客户访问。
- 修改 SQL、DTO、事件或公共组件时同步检查所有生产者与消费者;优先保持兼容,不能兼容时写清迁移顺序。
- 对外 HTTP JSON 中的 Java `Long/long` 统一输出字符串,请求同时接受字符串 ID;业务模块禁止重复添加局部 ID 转换器,OpenAPI 必须同步声明字符串契约。
- 普通业务成功与失败统一使用 HTTP 200,并以响应体字符串 `code` 判断;`code == "200"` 才成功。网关/Servlet Security 的真实 401/403、协议与基础设施错误、Warehouse readiness 503、Audit 202 和 Event Bridge 内部鉴权边界除外;完整约定见 `docs/统一响应约定改造方案-HTTP200.md`。
- 密码、令牌、第三方密钥和真实客户数据只能通过环境变量或配置中心密文注入。
## 本地内部服务令牌
- `scripts/local-env.ps1` 是本地运行时的统一环境入口,Order、Warehouse、IAM 等相互调用的服务必须从同一套 `INTERNAL_SERVICE_TOKEN` 启动。
- `config/nacos/local/baiyue-common.yml` 的本地默认令牌必须与该脚本一致;变更后执行 `scripts/publish-local-nacos-config.ps1` 发布并重启相关服务。
- 一期:`../docs/一期/README.md`、`../docs/一期/一期开发进度表.md`、`../docs/一期/一期头程订单闭环设计.md`
- 二期:`../docs/二期/README.md`、`../docs/二期/二期需求优化版-v2-测量计费入库主流程.md`、需求确认清单及联调报告
- 三期:`../docs/三期/README.md`、`../docs/三期/三期开发规划.md`、`../docs/三期/三期需求确认表.md`、页面与原型清单
+22 -28
View File
@@ -1,38 +1,32 @@
# Auth Service 模块指引
## 职责边界
## 职责与边界
- 负责账号凭据、登录、Access/Refresh Token 生命周期、退出、密码变更及商户注册协作。
- 客户主档、成员资料、角色和权限属于 `iam/`;Auth 只通过 IAM 客户端加载授权快照和创建注册结果。现有账号/JWT 中的 `tenant_id > 0` 当前是客户 ID,`tenant_id=0` 是平台技术作用域;字段名保持兼容,不代表已经存在真实租户层。
- 负责登录、注册、账号凭据、Access/Refresh Token、退出、密码和账号归属范围。
- 客户主档、成员、角色权限归 `iam/`;Auth 通过 IAM 客户端获取授权快照,不跨库写 IAM。
- `tenant_id=0` 为平台作用域,`tenant_id>0` 为客户 ID;不得引入租户管理或切换。
## 快速索引
## 模块入口
| 功能 | 入口 |
| 目标 | 入口 |
|---|---|
| 应用与安全配置 | `src/main/java/com/baiyue/auth/BaiyueAuthApplication.java`、`config/AuthSecurityConfig.java` |
| 登录、注册、刷新、退出、改密 API | `controller/AuthController.java`(`/api/auth`) |
| 内部账号管理 API | `controller/InternalAccountController.java`(`/internal/auth/accounts`);含创建、资料/状态/密码、`PUT /{id}/scope` 账号归属迁移和补偿删除 |
| 认证与令牌编排 | `service/AuthService.java`、`TokenSessionStore.java` |
| 账号创建、显示名同步、状态、密码和归属范围 | `service/AccountManagementService.java`;平台账号使用 `default_app_code=PLATFORM`、`tenant_id=0`,范围变更会递增 token version 撤销旧会话 |
| 商户注册协作 | `service/MerchantRegistrationService.java` |
| IAM 调用与授权快照 | `client/IamAuthorizationClient.java`、`IamOnboardingClient.java`、`AuthorizationSnapshot.java` |
| 账号与 Refresh Token 持久化 | `entity/`、`mapper/`、`repository/` |
| 请求/响应模型 | `dto/AccountManagementDtos.java`;创建可指定初始状态,`AccountSummary` 返回最后登录时间、兼容字段 `tenantId`、成员与应用端 |
| 表结构与一期账号 | `src/main/resources/db/auth.sql`、`phase1-admin-accounts.sql` |
| 配置与测试 | `src/main/resources/application.yml`、`src/test/java/com/baiyue/auth/service/` |
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` |
| Token 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java` |
| 注册协作 | `service/MerchantRegistrationService.java` |
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
| DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` |
| SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/` |
| 测试/验证 | `src/test/java/com/baiyue/auth/`;`mvn -pl auth -am test` |
## 修改与验证
## 修改约束
- 密码必须安全散列;不得记录密码、Token、密钥或完整敏感 Claim。
- 修改 Token Claim、会话 Redis Key、过期或撤销逻辑时同步检查 `common-security/`、`gateway/` 和 IAM 授权快照。
- 注册流程跨 Auth/IAM,失败补偿和幂等必须成对检查;不要跨库写 IAM 表。
- 内部账号创建允许相同账号 ID 与相同归属幂等重试;不同身份占用同一 ID 必须冲突。内部接口只接收 IAM 的服务令牌,不直接暴露给前端。
- 平台统一用户管理变更账号客户归属时由 IAM 调用 `PUT /internal/auth/accounts/{id}/scope` 同步 Auth 的客户隔离兼容字段、成员和应用端;修改该历史契约必须同时检查 `iam/client/AuthAccountClient.java` 与 `PlatformUserManagementService.java`。
- 登录、刷新等业务失败仍是 HTTP 200 + 非 `"200"` body code;Security 入口产生的真实 401 保留给客户端 Token 刷新流程。
- 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。
- 密码、Token、敏感 Claim 不得写日志;修改 Claim、Redis Key 或过期策略时联查 `common-security/`、`gateway/`、`iam/`。
- 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
## 分期快速索引
## 分期导航
- 一期:负责登录、注册、账号状态和 Token;先读 `../../docs/一期/README.md`、`../../docs/一期/一期开发进度表.md`,状态边界是平台账号 `tenant_id=0`、客户账号 `tenant_id>0`,不得把 Auth 变成租户管理入口。
- 二期:无独立登录流程;仅需保证入库/计费接口沿用现有 JWT、应用域和客户隔离。涉及 `tenant_id`、Claim 或内部令牌时,先读 `../../docs/二期/README.md` 与主流程文档,再联查 `firstleg/`、`iam/`。
- 三期:当前 NO-GO,不新增出仓账号或角色;待 `../../docs/三期/三期需求确认表.md` 解除阻断后,才评估权限码和 PDA 身份协作。
- 一期:登录、注册、账号状态和 Token;先读 `../../docs/一期/README.md`。
- 二期:无独立登录流程,仅保障入库/计费沿用 JWT、应用域和客户隔离;先读 `../../docs/二期/README.md`。
- 三期:NO-GO,不新增出仓账号或角色;确认表解除阻断后再改权限。
+2 -1
View File
@@ -55,7 +55,8 @@
- 三期平台权限直接维护在统一初始化与发布入口中,避免额外增加独立权限脚本。
- 测试和生产环境仍只执行固定入口 `头程数据库统一增量-MySQL8.sql`,不得再串行执行上述模块脚本。
- 必须先执行统一增量并取得 `PASS`,再启动或重启 Firstleg Service;否则统一订单摘要查询会因缺少三期操作表而失败。
- 统一脚本会创建 `fl_preload_plan`、`fl_split_operation`、`fl_loading_evidence`、`fl_loading_operation`、`fl_loading_pick_task`,并补齐 9 项平台操作权限。新增权限默认只授予平台系统管理员,其他角色由平台权限管理按职责配置。
- 统一脚本会创建智能装柜任务 `fl_loading_pick_task`,并把历史入库汇总任务按字段签名无损迁移到 `fl_inbound_loading_pick_task`;未知混合结构会直接终止。`WAIT_SPLIT` 是二期有效终点,脚本不会改写主表或状态历史。
- 下载中心固定使用 `5103040-5103041`,库位权限迁移到 `5103060-5103064`;入库装柜权限迁移到 `5102121-5102129`,迁移会按 `permission_code` 保留角色授权,目标 ID 被其他语义占用时直接终止。
部署前可只读盘点:
+272 -151
View File
@@ -2510,6 +2510,30 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细';
CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt (
id BIGINT UNSIGNED NOT NULL COMMENT '墨家联调收件记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID',
box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码',
length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm',
width_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测宽度cm',
height_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测高度cm',
weight_kg DECIMAL(18,3) NULL COMMENT '设备实测重量kg',
machine_no VARCHAR(64) NOT NULL COMMENT '设备编号',
measured_at DATETIME(3) NOT NULL COMMENT '设备测量时间或服务接收时间',
image_file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务中的墨家联调图片ID',
status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '收件状态',
match_failure_code VARCHAR(16) NOT NULL COMMENT '正式业务匹配失败码',
match_failure_reason VARCHAR(500) NOT NULL COMMENT '脱敏后的匹配失败原因',
request_id VARCHAR(128) NULL COMMENT '请求链路ID',
trace_id VARCHAR(128) NULL COMMENT '调用链ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
KEY idx_mojia_receipt_status_time (tenant_id, status, created_at),
KEY idx_mojia_receipt_box_time (tenant_id, box_code, created_at)
) ENGINE=InnoDB COMMENT='墨家模拟或未匹配入库单的原始测量收件箱';
CREATE TABLE IF NOT EXISTS fl_inbound_carton_label (
id BIGINT UNSIGNED NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '当前客户隔离ID',
@@ -3429,6 +3453,66 @@ ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS repair_location_permission_ids;
DELIMITER $$
CREATE PROCEDURE repair_location_permission_ids()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE id IN (5103060, 5103061, 5103062, 5103063, 5103064)
AND permission_code <> CASE id
WHEN 5103060 THEN 'platform:location:list'
WHEN 5103061 THEN 'platform:location:create'
WHEN 5103062 THEN 'platform:location:edit'
WHEN 5103063 THEN 'platform:location:status'
WHEN 5103064 THEN 'platform:location:delete'
END
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '库位权限迁移失败:5103060-5103064 已被其他权限占用';
END IF;
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id = 5103040 AND permission_code <> 'platform:download:list')
OR (id = 5103041 AND permission_code <> 'platform:download:all')
OR (permission_code = 'platform:download:list' AND id <> 5103040)
OR (permission_code = 'platform:download:all' AND id <> 5103041)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '库位权限迁移失败:下载中心稳定 ID 被未知权限占用';
END IF;
DROP TEMPORARY TABLE IF EXISTS tmp_location_role_grant;
CREATE TEMPORARY TABLE tmp_location_role_grant (
role_id BIGINT UNSIGNED NOT NULL, tenant_id BIGINT UNSIGNED NOT NULL,
permission_code VARCHAR(128) NOT NULL,
PRIMARY KEY (role_id, tenant_id, permission_code)
) ENGINE=MEMORY;
INSERT IGNORE INTO tmp_location_role_grant (role_id, tenant_id, permission_code)
SELECT rp.role_id, rp.tenant_id, permission.permission_code
FROM iam_role_permission rp JOIN iam_permission permission ON permission.id = rp.permission_id
WHERE permission.permission_code IN ('platform:location:list', 'platform:location:create',
'platform:location:edit', 'platform:location:status', 'platform:location:delete');
DELETE rp FROM iam_role_permission rp
JOIN iam_permission permission ON permission.id = rp.permission_id
WHERE permission.permission_code IN ('platform:location:list', 'platform:location:create',
'platform:location:edit', 'platform:location:status', 'platform:location:delete');
DELETE FROM iam_permission WHERE permission_code IN ('platform:location:create',
'platform:location:edit', 'platform:location:status', 'platform:location:delete');
DELETE FROM iam_permission WHERE permission_code = 'platform:location:list';
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 1, 0),
(5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', NULL, NULL, 10, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
http_method = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE', built_in = 1, deleted = 0;
END$$
DELIMITER ;
CALL repair_location_permission_ids();
DROP PROCEDURE repair_location_permission_ids;
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)
@@ -3560,21 +3644,27 @@ INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:location:list', '库位管理',
(5103060, 0, 'PLATFORM', 'MENU', 5103000, 'platform:location:list', '库位管理',
'/master-data/locations', NULL, 40, 'ACTIVE', 1, 0),
(5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:create', '新增库位',
(5103061, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:create', '新增库位',
NULL, NULL, 10, 'ACTIVE', 1, 0),
(5103042, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:edit', '编辑库位',
(5103062, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:edit', '编辑库位',
NULL, NULL, 20, 'ACTIVE', 1, 0),
(5103043, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:status', '启停库位',
(5103063, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:status', '启停库位',
NULL, NULL, 30, 'ACTIVE', 1, 0),
(5103044, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:delete', '删除库位',
(5103064, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:delete', '删除库位',
NULL, NULL, 40, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
sort_no = VALUES(sort_no), status = 'ACTIVE', built_in = 1, deleted = 0;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT saved.role_id, permission.id, saved.tenant_id
FROM tmp_location_role_grant saved
JOIN iam_permission permission ON permission.permission_code = saved.permission_code
AND permission.tenant_id = 0 AND permission.app_code = 'PLATFORM';
-- 二期新增库位权限默认只授予平台系统管理员。
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0
@@ -3586,6 +3676,8 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
DROP TEMPORARY TABLE IF EXISTS tmp_location_role_grant;
-- ============================================================================
-- 来源:iam\src\main\resources\db\phase2-pallet-permissions.sql
-- ============================================================================
@@ -5026,120 +5118,9 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.check_constraints
WHERE constraint_schema = 'baiyue_firstleg'
AND constraint_name = 'chk_loading_plan_status'
AND check_clause LIKE '%''EDITING''%'
AND check_clause LIKE '%''LOADING''%'
AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_container_booking'
AND column_name IN ('booking_no', 'cabinet_no', 'warehouse_id', 'bill_of_lading_no',
'vessel_name', 'voyage_no', 'container_type', 'capacity_cbm',
'max_weight_kg', 'terminal_trucking_company')) <> 10 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:货柜原型字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
AND column_name IN ('account_id', 'customer_name', 'active_inbound_detail_id', 'frozen_at',
'destination_warehouse_name', 'product_name_zh', 'product_name_en',
'product_image_file_id', 'pallet_count', 'net_weight_kg',
'dimensional_weight_kg', 'product_quantity', 'units_per_carton',
'fba_tracking_no', 'shipment_no', 'shipping_mark', 'version')) <> 17 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:明细归属、清单快照或冻结字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_plan_item'
AND index_name = 'uk_loading_item_plan_detail' AND non_unique = 0) <> 2 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:计划内唯一活动明细锁缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_loading_pick_item'
AND column_name IN ('pick_task_id', 'loading_plan_item_id', 'inbound_id',
'inbound_detail_id', 'planned_package_count',
'picked_package_count', 'item_status', 'version')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA拣货明细字段缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND non_unique = 0
AND index_name IN ('uk_loading_pick_task_plan',
'uk_loading_pick_item_plan_item',
'uk_loading_pick_history_operation')) <> 3 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失';
END IF;
IF (SELECT COUNT(*) FROM information_schema.triggers
WHERE trigger_schema = 'baiyue_firstleg'
AND trigger_name IN ('trg_container_booking_before_update',
'trg_loading_plan_before_update', 'trg_loading_plan_after_update',
'trg_loading_item_before_insert', 'trg_loading_item_before_update',
'trg_loading_item_before_delete', 'trg_container_document_before_insert',
'trg_container_document_before_update', 'trg_container_document_before_delete',
'trg_loading_history_before_update', 'trg_loading_history_before_delete',
'trg_loading_pick_task_before_update',
'trg_loading_pick_item_before_update',
'trg_loading_pick_item_before_delete',
'trg_loading_pick_history_before_update',
'trg_loading_pick_history_before_delete')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:不可逆状态或冻结触发器缺失';
END IF;
IF (SELECT COUNT(DISTINCT permission_code) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND status = 'ACTIVE' AND deleted = 0
AND permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create', 'platform:firstleg:container:edit',
'platform:firstleg:smart-loading:list', 'platform:firstleg:smart-loading:create',
'platform:firstleg:smart-loading:assign', 'platform:firstleg:smart-loading:delete',
'platform:firstleg:smart-loading:push', 'platform:firstleg:preload:list',
'platform:firstleg:preload:view', 'platform:firstleg:preload:edit',
'platform:firstleg:preload:complete', 'platform:firstleg:preload:confirm',
'platform:firstleg:loading-list:list', 'platform:firstleg:loading-list:view')) <> 16 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台权限资源未完整创建';
END IF;
IF (SELECT COUNT(DISTINCT CONCAT(role.role_code, ':', permission.permission_code))
FROM baiyue_iam.iam_role role
JOIN baiyue_iam.iam_role_permission grant_row ON grant_row.role_id = role.id
JOIN baiyue_iam.iam_permission permission ON permission.id = grant_row.permission_id
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')
AND permission.tenant_id = 0
AND permission.permission_code IN (
'platform:firstleg:container:list', 'platform:firstleg:container:view',
'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push',
'platform:firstleg:preload:list', 'platform:firstleg:preload:view',
'platform:firstleg:preload:edit', 'platform:firstleg:preload:complete',
'platform:firstleg:preload:confirm', 'platform:firstleg:loading-list:list',
'platform:firstleg:loading-list:view')) <> 32 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:平台角色授权未完整写入';
END IF;
END$$
DELIMITER ;
CALL assert_phase3_smart_loading_schema();
DROP PROCEDURE IF EXISTS assert_phase3_smart_loading_schema;
DELIMITER $$
CREATE PROCEDURE assert_phase3_smart_loading_schema()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name IN ('fl_container_booking', 'fl_loading_plan', 'fl_loading_plan_item',
'fl_container_document', 'fl_loading_plan_history',
'fl_loading_pick_task', 'fl_loading_pick_item',
'fl_loading_pick_history')) <> 8 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:核心表未完整创建';
END IF;
IF EXISTS (SELECT 1 FROM fl_loading_plan
WHERE plan_status NOT IN ('EDITING', 'LOADING', 'COMPLETED')) THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:存在旧装柜状态';
END IF;
IF (SELECT COUNT(*) FROM information_schema.check_constraints
WHERE constraint_schema = 'baiyue_firstleg'
AND constraint_name = 'chk_loading_plan_status'
AND check_clause LIKE '%''EDITING''%'
AND check_clause LIKE '%''LOADING''%'
AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN
AND UPPER(check_clause) LIKE '%EDITING%'
AND UPPER(check_clause) LIKE '%LOADING%'
AND UPPER(check_clause) LIKE '%COMPLETED%') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
@@ -6118,28 +6099,106 @@ CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task (
USE baiyue_iam;
DROP PROCEDURE IF EXISTS unified_relocate_inbound_loading_permissions;
DELIMITER $$
CREATE PROCEDURE unified_relocate_inbound_loading_permissions()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE id BETWEEN 5102121 AND 5102129
AND permission_code <> CASE id
WHEN 5102121 THEN 'firstleg:inbound:split'
WHEN 5102122 THEN 'firstleg:inbound:preload'
WHEN 5102123 THEN 'firstleg:inbound:preload-view'
WHEN 5102124 THEN 'platform:firstleg:inbound:loading:edit'
WHEN 5102125 THEN 'platform:firstleg:inbound:loading:view'
WHEN 5102126 THEN 'platform:firstleg:inbound:loading:complete'
WHEN 5102127 THEN 'platform:firstleg:inbound:delivery:complete'
WHEN 5102128 THEN 'platform:firstleg:inbound:picking:edit'
WHEN 5102129 THEN 'platform:firstleg:inbound:picking:view'
END
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期权限迁移失败:5102121-5102129 已被其他权限占用';
END IF;
DROP TEMPORARY TABLE IF EXISTS tmp_phase3_inbound_role_grant;
CREATE TEMPORARY TABLE tmp_phase3_inbound_role_grant (
role_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
permission_code VARCHAR(128) NOT NULL,
PRIMARY KEY (role_id, tenant_id, permission_code)
) ENGINE=MEMORY;
INSERT IGNORE INTO tmp_phase3_inbound_role_grant (role_id, tenant_id, permission_code)
SELECT role_permission.role_id, role_permission.tenant_id, permission.permission_code
FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id = role_permission.permission_id
WHERE permission.permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view'
);
DELETE role_permission FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id = role_permission.permission_id
WHERE permission.permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view'
);
DELETE FROM iam_permission WHERE permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view'
);
END$$
DELIMITER ;
CALL unified_relocate_inbound_loading_permissions();
DROP PROCEDURE unified_relocate_inbound_loading_permissions;
-- 如果旧入库权限占用了 5102091-5102099,上面的语义迁移已释放这些 ID;
-- 此处重新写入智能装柜权限,修复此前 ON DUPLICATE 只改元数据、不改 code 的污染。
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102091, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:view', '查看货柜', NULL, 'GET', 10, 'ACTIVE', 1, 0),
(5102092, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:create', '建立货柜', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5102093, 0, 'PLATFORM', 'BUTTON', 5102090, 'platform:firstleg:container:edit', '编辑货柜', NULL, 'PUT', 30, 'ACTIVE', 1, 0),
(5102094, 0, 'PLATFORM', 'MENU', 5102000, 'platform:firstleg:smart-loading:list', '智能装柜', '/first-leg/smart-loading', 'GET', 50, 'ACTIVE', 1, 0),
(5102096, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:create', '创建智能装柜计划', NULL, 'POST', 20, 'ACTIVE', 1, 0),
(5102097, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:assign', '分配装柜明细', NULL, 'POST', 30, 'ACTIVE', 1, 0),
(5102098, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:delete', '删除装柜明细', NULL, 'DELETE', 40, 'ACTIVE', 1, 0),
(5102099, 0, 'PLATFORM', 'BUTTON', 5102094, 'platform:firstleg:smart-loading:push', '推送预装', NULL, 'POST', 50, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
http_method = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE',
built_in = 1, deleted = 0;
INSERT INTO iam_permission (
id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted
)
VALUES
(5102091, 0, 'PLATFORM', 'BUTTON', 5102030,
(5102121, 0, 'PLATFORM', 'BUTTON', 5102030,
'firstleg:inbound:split', '智能分柜', NULL, NULL, 140, 'ACTIVE', 1, 0),
(5102092, 0, 'PLATFORM', 'BUTTON', 5102030,
(5102122, 0, 'PLATFORM', 'BUTTON', 5102030,
'firstleg:inbound:preload', '编辑并确认预装清单', NULL, NULL, 150, 'ACTIVE', 1, 0),
(5102093, 0, 'PLATFORM', 'BUTTON', 5102030,
(5102123, 0, 'PLATFORM', 'BUTTON', 5102030,
'firstleg:inbound:preload-view', '查看预装清单', NULL, NULL, 160, 'ACTIVE', 1, 0),
(5102094, 0, 'PLATFORM', 'BUTTON', 5102030,
(5102124, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:loading:edit', '维护装柜证据', NULL, NULL, 170, 'ACTIVE', 1, 0),
(5102095, 0, 'PLATFORM', 'BUTTON', 5102030,
(5102125, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:loading:view', '查看装柜证据', NULL, NULL, 180, 'ACTIVE', 1, 0),
(5102096, 0, 'PLATFORM', 'BUTTON', 5102030,
(5102126, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:loading:complete', '完成装柜', NULL, NULL, 190, 'ACTIVE', 1, 0),
(5102097, 0, 'PLATFORM', 'BUTTON', 5102030,
(5102127, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:delivery:complete', '完成派送', NULL, NULL, 200, 'ACTIVE', 1, 0),
(5102098, 0, 'PLATFORM', 'BUTTON', 5102030,
(5102128, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:picking:edit', '执行拣货', NULL, NULL, 210, 'ACTIVE', 1, 0),
(5102099, 0, 'PLATFORM', 'BUTTON', 5102030,
(5102129, 0, 'PLATFORM', 'BUTTON', 5102030,
'platform:firstleg:inbound:picking:view', '查看拣货任务', NULL, NULL, 220, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
@@ -6164,6 +6223,25 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT grant_row.role_id, permission.id, grant_row.tenant_id
FROM tmp_phase3_inbound_role_grant grant_row
JOIN iam_permission permission ON permission.permission_code = grant_row.permission_code
AND permission.tenant_id = 0 AND permission.app_code = 'PLATFORM';
DROP TEMPORARY TABLE IF EXISTS tmp_phase3_inbound_role_grant;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT role.id, permission.id, role.tenant_id
FROM iam_role role
JOIN iam_permission permission ON permission.tenant_id = 0
AND permission.permission_code IN (
'platform:firstleg:container:view', 'platform:firstleg:container:create',
'platform:firstleg:container:edit', 'platform:firstleg:smart-loading:list',
'platform:firstleg:smart-loading:create', 'platform:firstleg:smart-loading:assign',
'platform:firstleg:smart-loading:delete', 'platform:firstleg:smart-loading:push'
)
WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR');
-- 兼容已经执行过旧单行配置模型的开发/测试库。仅精确识别旧结构时丢弃旧值并重建;
-- 已是四箱规结构时不做 DDL,避免破坏 Admin 已编辑的数据。
DROP PROCEDURE IF EXISTS unified_upgrade_legacy_customer_dimension_config;
@@ -6254,7 +6332,8 @@ SELECT UUID_SHORT(), tenant.tenant_id, defaults.box_type,
SELECT 'LARGE', 50.000, 40.000, 40.000, '标准大箱'
UNION ALL
SELECT 'EXTRA_LARGE', 60.000, 50.000, 50.000, '特大箱'
) defaults
) defaults
WHERE TRUE
ON DUPLICATE KEY UPDATE id = id;
-- ============================================================================
@@ -6808,9 +6887,9 @@ BEGIN
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_sequence',
AND index_name IN ('uk_inbound_master_order', 'uk_inbound_one_child',
'uk_inbound_split_operation', 'uk_inbound_split_idempotency',
'uk_inbound_split_child', 'uk_inbound_split_history_operation')) <> 6 THEN
'uk_inbound_split_history_operation')) <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.table_constraints
@@ -6865,6 +6944,23 @@ BEGIN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:稳定箱号或单箱测量唯一约束未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_mojia_measurement_receipt'
AND column_name IN ('id', 'tenant_id', 'subject_id', 'box_code', 'length_cm', 'width_cm',
'height_cm', 'weight_kg', 'machine_no', 'measured_at', 'image_file_id',
'status', 'match_failure_code', 'match_failure_reason', 'request_id',
'trace_id', 'created_at', 'updated_at')) <> 18 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:墨家未匹配测量收件箱字段未完整创建';
END IF;
IF (SELECT character_maximum_length FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_measurement_item'
AND column_name = 'calculation_rule_version') <> 64 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:测量计算规则版本字段长度不是64';
END IF;
IF (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_customer_dimension_config') <> 1 THEN
@@ -6969,30 +7065,55 @@ BEGIN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:订单运营角色的客户尺寸计算配置权限不是只读';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND column_name IN ('loading_plan_id', 'container_no', 'task_status')) <> 3
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count')) <> 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:智能装柜拣货任务表型不正确';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'
AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count')) <> 5
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'
AND column_name IN ('loading_plan_id', 'container_no', 'task_status')) <> 0 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:入库汇总拣货任务表型不正确';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND ((id = 5103040 AND permission_code = 'platform:download:list'
AND permission_name = '下载中心' AND route_path = '/downloads')
OR (id = 5103041 AND permission_code = 'platform:download:all' AND parent_id = 5103040))) <> 2
OR (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND ((id = 5103060 AND permission_code = 'platform:location:list' AND parent_id = 5103000)
OR (id = 5103061 AND permission_code = 'platform:location:create' AND parent_id = 5103060)
OR (id = 5103062 AND permission_code = 'platform:location:edit' AND parent_id = 5103060)
OR (id = 5103063 AND permission_code = 'platform:location:status' AND parent_id = 5103060)
OR (id = 5103064 AND permission_code = 'platform:location:delete' AND parent_id = 5103060))) <> 5 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:下载中心或库位权限语义未修复';
END IF;
IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND id BETWEEN 5102121 AND 5102129
AND permission_code IN ('firstleg:inbound:split', 'firstleg:inbound:preload',
'firstleg:inbound:preload-view', 'platform:firstleg:inbound:loading:edit',
'platform:firstleg:inbound:loading:view', 'platform:firstleg:inbound:loading:complete',
'platform:firstleg:inbound:delivery:complete', 'platform:firstleg:inbound:picking:edit',
'platform:firstleg:inbound:picking:view')) <> 9 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:三期入库装柜权限 ID 未完成迁移';
END IF;
END$$
DELIMITER ;
CALL phase3_assert_inbound_parent_child_schema();
DROP PROCEDURE IF EXISTS phase3_assert_inbound_parent_child_schema;
-- ============================================================================
-- 来源:firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql
-- ============================================================================
-- 将已废弃的 WAIT_SPLIT 统一为 INBOUNDED。
-- 执行前请确认所有服务已升级至本次状态契约;脚本可重复执行。
USE baiyue_firstleg;
UPDATE fl_inbound_order
SET process_status = 'INBOUNDED',
updated_at = CURRENT_TIMESTAMP(3),
version = version + 1
WHERE process_status = 'WAIT_SPLIT';
UPDATE fl_inbound_status_history
SET from_status = 'INBOUNDED'
WHERE from_status = 'WAIT_SPLIT';
UPDATE fl_inbound_status_history
SET to_status = 'INBOUNDED'
WHERE to_status = 'WAIT_SPLIT';
-- WAIT_SPLIT 是二期当前有效终点;统一迁移不得改写主表或历史状态。
SELECT 'PASS' AS migration_status,
'统一增量全部执行完成;箱号映射、单箱测量、三期出仓、费用、公共资源及可靠审计结构校验通过' AS message;
+20 -48
View File
@@ -1,58 +1,30 @@
# File Service 模块指引
## 职责边界
## 职责与边界
- File 服务是企业资质文件的唯一公开上传入口。必须先持久化并验证真实文件,再调用 IAM 内部接口登记元数据;禁止只生成或改写一个不存在文件的 URL。
- 负责企业资质文件、公共导出、二期入库测量图片的上传、存储、读取和鉴权。
- 资质审核元数据归 `iam/`,入库状态归 `firstleg/`,OCR 适配归 `integration-service/`;不跨库读取业务表。
- 负责企业资质文件、公共导出文件和二期入库测量图片的上传、存储、读取及访问鉴权;当前文件内容使用本地磁盘,不能进入 Git。
- `src/main/resources/db/file.sql` 中的 `file_object` 已由测量图片链路通过 `FileObjectEntity`、`FileObjectMapper` 接入,导出任务表也已接入;企业资质文件元数据仍归 IAM,不能把不同文件链路的持久化状态混为一谈。
- 企业资质审核状态和元数据归 `iam/`;本模块通过 IAM 客户端校验访问权限,不跨库读取 IAM 数据。
- 入库单和测量状态归 `firstleg/`,重量识别适配归 `integration-service/`;File 只保存图片对象、返回逻辑文件 ID 并代理受控 OCR 调用。
## 模块入口
## 快速索引
| 功能 | 入口 |
| 目标 | 入口 |
|---|---|
| 模块使用、RocketMQ 部署与导出排查 | `README.md`;完整架构与接口契约见 `../docs/公共导出与下载中心接入说明.md` |
| 商户资质上传与读取 | `src/main/java/com/baiyue/file/controller/QualificationFileController.java` |
| 平台资质上传、替换与查看 | `controller/PlatformQualificationFileController.java`;`POST /{tenantId}` 支持 `qualificationType`、`certificateNo`、`validTo`、`replace`,下载为 `GET /{tenantId}/{fileName}` |
| 服务状态 | `controller/FileHealthController.java` |
| 当前本地磁盘实现 | `service/LocalQualificationStorage.java` |
| 公共导出 API | `controller/PlatformExportTaskController.java`、`service/ExportTaskService.java` |
| 可靠调度 | `service/ExportStateService.java`、`mq/ExportOutboxPublisher.java`、`mq/ExportCommandConsumer.java`、`service/ExportReconciler.java` |
| 通用 Provider/XLSX | `client/ExportProviderClient.java`、`service/GenericXlsxExportRunner.java`、公共契约 `../common-core/.../export/ExportContracts.java` |
| 毒消息隔离 | `mq/ExportDlqHandler.java`;定位必须使用四个 User Properties,不解析损坏 body |
| 未被当前 Controller 使用的旧实现 | `service/QualificationFileStorage.java` |
| IAM 权限协作 | `client/IamQualificationClient.java`;商户登记 `/qualifications`,平台登记/替换 `/platform-qualifications` |
| 平台称重图片上传、OCR 重试、原图与缩略图查看 | `controller/PlatformMeasurementImageController.java`(`/api/platform/v1/files/measurements`)→ `service/MeasurementImageService.java` |
| Firstleg 第三方测量证据图片 | `controller/InternalMeasurementImageController.java`(`/internal/file/v1/measurement-images`)→ `MeasurementImageService.uploadMeasurementEvidence/deleteMeasurementEvidence`;必须校验内部令牌 |
| 测量图片磁盘与元数据 | `service/MeasurementImageStorage.java`、`entity/FileObjectEntity.java`、`mapper/FileObjectMapper.java`;表结构 `src/main/resources/db/file.sql` |
| 重量 OCR 下游 | `client/WeightOcrClient.java` → `integration-service` 的 `/internal/integrations/weight-ocr/single`;尺寸测量证据不触发重量 OCR |
| 测量图片测试 | `controller/PlatformMeasurementImageControllerContractTest.java`、`InternalMeasurementImageControllerTest.java`、`service/MeasurementImageServiceEvidenceTest.java` |
| 历史资质文件迁移 | `../scripts/migrate-local-qualification-file.ps1`、`../docs/企业资质文件迁移说明.md`;只适用于当前本地/共享目录存储实现 |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/file-service.yml` |
| 上传契约与存储测试 | `src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java`、`service/LocalQualificationStorageTest.java` |
| 商户/平台资质 | `controller/QualificationFileController.java`、`PlatformQualificationFileController.java` |
| 导出中心 | `controller/PlatformExportTaskController.java`、`service/ExportStateService.java`、`mq/` |
| 测量图片/证据 | `controller/PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`service/MeasurementImageService.java` |
| 存储与实体 | `service/LocalQualificationStorage.java`、`MeasurementImageStorage.java`、`entity/FileObjectEntity.java` |
| 下游 OCR | `client/WeightOcrClient.java` |
| SQL/配置 | `src/main/resources/db/file.sql`、`application.yml` |
| 测试/验证 | `src/test/java/com/baiyue/file/`;`mvn -pl file -am test` |
## 修改与验证
## 修改约束
- 严格校验客户归属、文件名/路径、大小、类型和读取权限,防止目录穿越、跨客户访问和任意文件上传;接口和存储路径中的 `tenantId`、`tenant_id` 当前是客户隔离兼容字段。
- 企业资质文件只接受 PDF、JPG 和 PNG,单个文件上限 5MB;Client、Admin 与 `LocalQualificationStorage` 的限制和提示必须保持一致。
- 生产存储应使用对象存储或共享文件存储;容器本地目录仅适合当前本地实现,不得硬编码环境路径。
- 企业资质元数据后续若迁入 File,需同时实现 Entity、Mapper、Repository、事务和迁移验证,并同步检查 IAM 资质流程及平台/商户 Controller;不得复用测量图片 `file_object` 的已接入状态宣称资质元数据已迁移。
- 测量图片必须同时按 `tenantId + inboundId + fileId + businessType` 校验归属。平台称重图片使用 `INBOUND_WEIGHT_IMAGE` 并可调用 OCR;第三方尺寸证据使用 `INBOUND_MEASUREMENT_IMAGE`,只允许内部上传/删除且不得触发重量 OCR。
- 平台测量图片权限与 Firstleg 权限码保持一致:上传和重试要求 `platform:firstleg:inbound:weight:edit`,查看要求 `platform:firstleg:inbound:measure:view`;内部证据接口只信任共享内部令牌和明确操作人。
- OCR 失败时保留已落盘文件和 `file_object` 元数据供重试或人工录入;入库称重图片和第三方尺寸证据均由其业务服务确认引用关系后通过内部接口逻辑删除,其他客户文件不得使用该接口删除。
- 从父目录运行 `mvn -pl file -am test`,并按接口影响验证商户/平台上传、替换、预览、无权限、签名伪造和磁盘故障场景。
- 非本地环境必须显式配置 `FILE_STORAGE_ROOT` 并挂载持久化可写目录;服务启动时会校验目录,存储不可用时直接启动失败,避免健康检查正常而首次上传才返回 HTTP 200 + `S0503`。
- 文件写入失败属于基础设施故障,统一返回 `S0503`,服务日志保留实际目录和底层异常;禁止将磁盘故障包装成 `B0001`。
- 迁移历史 `/phase1-seed/...` 路径时必须先写入并校验真实脱敏图片,再按 `id + tenant_id + 旧 URL` 更新 IAM 元数据;禁止只改 URL。对象存储环境必须改走 File 上传接口。
- 上传需先写新文件,再调用 IAM 登记;收到明确业务拒绝时删除新文件,传输异常、空响应或缺少 data 时远端结果未知,必须保留文件并记录兼容字段 tenant/documentUrl 供重试或对账,避免 IAM 已提交却被 File 删除。`replace=true` 成功时 IAM 返回 `replacedDocumentUrl`,File 只能在新元数据落库后按目标客户安全清理旧 UUID 文件。
- 旧文件清理只接受 File 自身生成的 `/api/merchant/v1/files/qualifications/{uuid}.{ext}`,并且不得删除本次新上传的 `documentUrl`;不能仅从任意 URL 截取文件名后删除。
- 平台上传要求平台身份以及 `platform:customer:create` 或 `platform:customer:edit`,平台下载属于客户档案只读能力并要求 `platform:customer:view`;不得复用审核权限,也不能只依赖 Gateway 路由或前端按钮隐藏。
- `LocalQualificationStorage` 当前生成的 `documentUrl` 使用商户读取路径,但文件实际按 `storage-root/qualifications/{tenantId}/{uuid.ext}` 隔离;变更 URL 形态时同步检查 IAM 已存元数据、平台下载路由和迁移脚本。
- 上传先写真实文件再登记 IAM 元数据;异常结果未知时保留文件供重试,禁止伪造 URL。
- 所有文件按客户、入库单、文件类型校验归属;重量图片可 OCR,第三方尺寸证据不得触发重量 OCR。
- 生产环境显式配置 `FILE_STORAGE_ROOT`;磁盘故障返回 `S0503`,不得包装成业务错误。
## 分期快速索引
## 分期导航
- 一期:资质文件入口为 `QualificationFileController`、`PlatformQualificationFileController`,先读 `../../docs/一期/README.md`、客户审核闭环文档;文件内容归 File,审核状态/元数据归 IAM,上传成功不等于客户已具备下单资格。
- 二期:测量图片入口为 `PlatformMeasurementImageController` 与 `InternalMeasurementImageController`,先读 `../../docs/二期/README.md`、主流程和第三方测量联调说明;重量图片可触发 OCR,第三方尺寸证据不得触发重量 OCR,必须绑定客户、入库单和业务类型。
- 三期:当前 NO-GO;暂不新增装柜、柜封、报关或出仓附件模型。待 `../../docs/三期/三期需求确认表.md` 确认后,再与 Firstleg/PDA 锁定文件证据生命周期和权限码。
- 一期:资质上传与读取;先读 `../../docs/一期/README.md`。
- 二期:测量图片和 OCR 重试;先读 `../../docs/二期/README.md` 与第三方联调说明。
- 三期:NO-GO,不新增装柜/报关附件模型;确认后再定义证据生命周期。
@@ -56,6 +56,21 @@ public class InternalMeasurementImageController {
images.uploadMeasurementEvidence(tenantId, inboundId, operatorId, file));
}
@PostMapping(value = "/receipts/{tenantId}/{receiptId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<MeasurementImageService.UploadResult> uploadReceipt(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long receiptId,
@RequestPart("file") MultipartFile file) {
requireToken(token);
if (tenantId <= 0 || receiptId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "客户、收件记录和操作主体ID必须大于0");
}
return ApiResponse.success(images.uploadMojiaReceiptEvidence(
tenantId, receiptId, operatorId, file));
}
@DeleteMapping("/{tenantId}/{inboundId}/{fileId}")
public ApiResponse<Void> delete(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -71,6 +86,21 @@ public class InternalMeasurementImageController {
return ApiResponse.success(null);
}
@DeleteMapping("/receipts/{tenantId}/{receiptId}/{fileId}")
public ApiResponse<Void> deleteReceipt(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long receiptId,
@PathVariable long fileId) {
requireToken(token);
if (tenantId <= 0 || receiptId <= 0 || fileId <= 0 || operatorId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "客户、收件记录、文件和操作主体ID必须大于0");
}
images.deleteMojiaReceiptEvidence(tenantId, receiptId, fileId, operatorId);
return ApiResponse.success(null);
}
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
public ResponseEntity<byte[]> download(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -51,6 +51,16 @@ public class MeasurementImageService {
object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null, null);
}
/** 保存尚未匹配正式入库单的墨家联调图片。 */
@Transactional
public UploadResult uploadMojiaReceiptEvidence(long tenantId, long receiptId, long operatorId,
MultipartFile file) {
FileObjectEntity object = storeObject(
tenantId, receiptId, operatorId, file, "MOJIA_MEASUREMENT_RECEIPT");
return new UploadResult(object.getId(), object.getOriginalName(), object.getContentType(),
object.getSizeBytes(), "NOT_REQUIRED", null, null, null, null, null);
}
private FileObjectEntity storeObject(long tenantId, long inboundId, long operatorId,
MultipartFile file, String businessType) {
var stored = storage.store(tenantId, inboundId, file);
@@ -124,11 +134,19 @@ public class MeasurementImageService {
@Transactional
public void deleteMeasurementEvidence(long tenantId, long inboundId, long fileId, long operatorId) {
FileObjectEntity object = requireObject(tenantId, inboundId, fileId);
if (!Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
.contains(object.getBusinessType())) {
throw new BizException(ResultCode.CONFLICT, "仅允许清理入库测量图片");
}
FileObjectEntity object = requireObject(tenantId, inboundId, fileId,
Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE"));
deleteObject(object, operatorId);
}
@Transactional
public void deleteMojiaReceiptEvidence(long tenantId, long receiptId, long fileId, long operatorId) {
FileObjectEntity object = requireObject(tenantId, receiptId, fileId,
Set.of("MOJIA_MEASUREMENT_RECEIPT"));
deleteObject(object, operatorId);
}
private void deleteObject(FileObjectEntity object, long operatorId) {
object.setStatus("DELETED");
object.setUpdatedBy(operatorId);
object.setUpdatedAt(LocalDateTime.now());
@@ -139,11 +157,17 @@ public class MeasurementImageService {
}
private FileObjectEntity requireObject(long tenantId, long inboundId, long fileId) {
return requireObject(tenantId, inboundId, fileId,
Set.of("INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE"));
}
private FileObjectEntity requireObject(long tenantId, long businessId, long fileId,
Set<String> businessTypes) {
FileObjectEntity object = files.selectOne(new LambdaQueryWrapper<FileObjectEntity>()
.eq(FileObjectEntity::getTenantId, tenantId)
.eq(FileObjectEntity::getId, fileId)
.in(FileObjectEntity::getBusinessType, "INBOUND_WEIGHT_IMAGE", "INBOUND_MEASUREMENT_IMAGE")
.eq(FileObjectEntity::getBusinessId, Long.toString(inboundId))
.in(FileObjectEntity::getBusinessType, businessTypes)
.eq(FileObjectEntity::getBusinessId, Long.toString(businessId))
.eq(FileObjectEntity::getStatus, "ACTIVE")
.eq(FileObjectEntity::getDeleted, false));
if (object == null) throw new BizException(ResultCode.NOT_FOUND, "称重图片不存在");
@@ -59,4 +59,18 @@ class InternalMeasurementImageControllerTest {
assertThat(response.getBody()).containsExactly(1, 2, 3);
verify(images).download(11L, 21L, 41L);
}
@Test
void storesAnUnmatchedMojiaReceiptImageWithoutAnInboundId() {
var file = new MockMultipartFile("file", "mojia.jpg", MediaType.IMAGE_JPEG_VALUE,
new byte[]{1, 2, 3});
var result = new MeasurementImageService.UploadResult(41L, "mojia.jpg",
MediaType.IMAGE_JPEG_VALUE, 3L, "NOT_REQUIRED", null, null, null, null, null);
when(images.uploadMojiaReceiptEvidence(11L, 51L, 31L, file)).thenReturn(result);
var response = controller.uploadReceipt("internal-token", 31L, 11L, 51L, file);
assertThat(response.data().fileId()).isEqualTo(41L);
verify(images).uploadMojiaReceiptEvidence(11L, 51L, 31L, file);
}
}
@@ -10,6 +10,7 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.mock.web.MockMultipartFile;
@@ -70,6 +71,29 @@ class MeasurementImageServiceEvidenceTest {
verifyNoInteractions(ocr);
}
@Test
void storesUnmatchedMojiaReceiptUnderItsOwnBusinessType() {
var image = new MockMultipartFile("file", "mojia.jpg", MediaType.IMAGE_JPEG_VALUE,
new byte[]{1, 2, 3});
when(storage.store(11L, 51L, image)).thenReturn(new MeasurementImageStorage.Stored(
"measurements/11/51/mojia.jpg", Path.of("mojia.jpg"), MediaType.IMAGE_JPEG_VALUE,
3L, "hash", "mojia.jpg"));
ArgumentCaptor<FileObjectEntity> object = ArgumentCaptor.forClass(FileObjectEntity.class);
doAnswer(invocation -> {
FileObjectEntity value = invocation.getArgument(0);
value.setId(41L);
return 1;
}).when(files).insert(any(FileObjectEntity.class));
var result = service.uploadMojiaReceiptEvidence(11L, 51L, 31L, image);
assertThat(result.fileId()).isEqualTo(41L);
verify(files).insert(object.capture());
assertThat(object.getValue().getBusinessType()).isEqualTo("MOJIA_MEASUREMENT_RECEIPT");
assertThat(object.getValue().getBusinessId()).isEqualTo("51");
verifyNoInteractions(ocr);
}
@Test
void shouldHandleCase02() {
FileObjectEntity object = new FileObjectEntity();
+23 -50
View File
@@ -1,60 +1,33 @@
# Firstleg Service 模块指引
## 职责边界
## 职责与边界
- 负责订单提交后的国内头程履约任务、操作分配和分配历史,以及二期入库单、测量、审核、FBA 计费、费用发布和完成入库状态编排。
- 订单主单、商户草稿和交易状态属于 `order/`;不要把订单模型复制进本服务。
- 订单货物明细归 `order/`,托盘和库位资源归 `warehouse/`,图片对象归 `file/`,OCR 供应商适配归 `integration-service/`;本服务只保存业务快照、逻辑引用和自身状态。
- 负责履约任务/分配,以及二期入库单、测量/OCR 结果、审核、FBA 计费、完成入库编排。
- 订单主数据归 `order/`,托盘/库位归 `warehouse/`,文件对象归 `file/`,OCR 供应商归 `integration-service/`;通过客户端 DTO 和内部令牌协作。
- 入库终点为 `WAIT_SPLIT + APPROVED + CONFIRMED`;二期不实现分柜、装柜、PDA 或完整财务。
## 快速索引
## 模块入口
| 功能 | 入口 |
| 目标 | 入口 |
|---|---|
| 应用入口 | `src/main/java/com/baiyue/firstleg/BaiyueFirstlegApplication.java` |
| 平台履约任务查询与分配 API | `controller/PlatformFulfillmentAssignmentController.java`(`/api/platform/v1/first-leg/tasks`) |
| 任务列表、详情、按订单查询、分配历史 | `FulfillmentTaskService.java`、`FulfillmentTaskRepository.java`;只读权限码见控制器 `@PreAuthorize` |
| 服务状态 | `controller/FirstlegHealthController.java` |
| 查询与分配请求 | `dto/FulfillmentTaskQuery.java`、`dto/AssignRequest.java` |
| 履约与分配编排 | `service/FulfillmentTaskService.java` |
| 任务与历史持久化 | `entity/`、`mapper/`、`repository/FulfillmentTaskRepository.java` |
| 表结构与查询索引迁移 | `src/main/resources/db/firstleg.sql`、`src/main/resources/db/phase1-firstleg-task-list-index.sql` |
| OpenAPI 与测试 | `src/main/resources/openapi/firstleg-v1.yaml`、`service/FulfillmentTaskServiceTest.java`、`controller/FulfillmentTaskControllerContractTest.java`(测试路径均从 `src/test/java/com/baiyue/firstleg/` 起算) |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/firstleg-service.yml` |
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 测量/OCR/第三方回传 | `controller/PlatformInboundMeasurementController.java`、`OpenApiInboundMeasurementController.java`、`service/InboundMeasurementService.java` |
| 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
| 客户费用查询 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java` |
| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` |
## 二期入库快速索引
## 修改约束
以下 Java 包路径从 `src/main/java/com/baiyue/firstleg/` 起算,测试路径从 `src/test/java/com/baiyue/firstleg/` 起算。
- 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。
- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。
- 测量审核→计费确认→费用发布→入库完成不得跳过门禁;状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。
| 功能 | API 与业务入口 | 持久化、协作与测试 |
|---|---|---|
| 入库单列表、状态页签和聚合详情 | `controller/PlatformInboundOrderController.java` → `service/InboundOrderQueryService.java` | `repository/InboundOrderRepository.java`、`mapper/InboundAllocationQueryMapper.java`;测试 `service/InboundOrderQueryServiceTest.java`、`controller/InboundOrderControllerContractTest.java` |
| 平台测量草稿、提交和重量结果 | `controller/PlatformInboundMeasurementController.java` → `service/InboundMeasurementService.java`、`MeasurementSubmissionService.java` | `repository/InboundMeasurementRepository.java`;图片 `client/MeasurementImageClient.java`,托盘库位 `client/WarehousePalletLocationClient.java`;测试 `service/InboundMeasurementServiceTest.java`、`MeasurementSubmissionServiceTest.java` |
| 第三方尺寸测量回传 | `controller/OpenApiInboundMeasurementController.java` → `security/MeasurementRequestAuthenticator.java` → `service/InboundMeasurementOpenApiService.java` | 货物明细 `client/OrderCargoClient.java`,证据图片 `client/MeasurementImageClient.java`;配置 `config/MeasurementOpenApiProperties.java`;测试 `service/InboundMeasurementOpenApiServiceTest.java`、`controller/OpenApiInboundMeasurementControllerContractTest.java` |
| 测量审核、驳回和异常放行 | `controller/PlatformInboundMeasurementReviewController.java` → `service/MeasurementReviewService.java` | 自动后续流程 `service/AutomaticInboundCompletionService.java`;测试 `service/MeasurementReviewServiceTest.java`、`controller/InboundMeasurementReviewContractTest.java` |
| 客户规则和承运商价格版本 | `controller/PlatformFbaMasterDataController.java` → `service/FbaMasterDataService.java` | `repository/FbaPricingRepository.java`、`src/main/resources/openapi/fba-pricing-v1.yaml`;测试 `service/FbaMasterDataServiceTest.java` |
| 费用计算、确认、发布与快照 | `controller/PlatformFbaPricingController.java` → `service/FbaPricingService.java` | 自动计费和完成入库 `AutomaticInboundCompletionService.java`、`InboundCompletionOrchestrator.java`;测试 `service/FbaPricingServiceTest.java`、`InboundCompletionOrchestratorTest.java` |
| 商户入库费用查询 | `controller/MerchantInboundBillingController.java` → `service/MerchantInboundBillingService.java` | 测试 `service/MerchantInboundBillingServiceTest.java`、`controller/MerchantInboundBillingControllerContractTest.java` |
| 状态、幂等、历史和 Outbox | `domain/InboundProcessStatus.java`、`service/InboundIdempotencyService.java` | `repository/InboundWorkflowRepository.java`、`entity/Inbound*.java`、`mapper/Inbound*.java`;测试 `service/InboundIdempotencyServiceTest.java`、`repository/InboundWorkflowSqlContractTest.java` |
| 二期表结构和增量 | `src/main/resources/db/phase2-inbound-schema.sql`、`phase2-inbound-workflow-migration.sql`、`phase2-inbound-measurement-maintenance.sql`、`phase2-measurement-openapi.sql`、`phase2-third-party-single-measurement-migration.sql`、`phase2-fba-pricing-migration.sql` | 测试/存量环境统一入口为 `../deploy/sql/头程数据库统一增量-MySQL8.sql`,执行说明见 `../deploy/sql/README.md`;历史日期脚本仅作追溯 |
## 分期导航
## 修改与验证
- 任务状态与订单状态保持领域分离;需要联动时使用稳定 DTO 或事件,不共享 Order Entity 或数据库。
- 分配操作要保留历史、操作者、时间和幂等语义;平台端接口必须执行权限校验。
- 所有客户归属查询必须带目标客户并按兼容字段 `tenant_id` 在仓储层过滤;列表和分配历史使用统一分页响应,详情不存在使用 HTTP 200 + body code `A0404`。
- 普通任务不存在、状态冲突和参数错误均遵循 HTTP 200 + body code,平台 Security 拒绝仍保留真实 403。
- 新建库由 `firstleg.sql` 创建查询索引;既有库必须再执行可重复部署的 `phase1-firstleg-task-list-index.sql`,不能只依赖 `CREATE TABLE IF NOT EXISTS`。
- 修改任务查询、状态或分配逻辑时同步扩展 Service 测试和 Controller 契约测试,不以模块构建代替行为验证。
- 二期入库所有平台查询和写入必须显式校验目标客户;接口中的 `tenantId` 当前是客户隔离兼容字段。商户费用查询只能使用登录态客户归属。状态推进、审核、计费和完成入库必须保留乐观锁、幂等键、历史和审计语义。
- 第三方测量入口必须执行签名、时间窗、防重放和请求哈希校验;一条回传绑定一条订单货物明细及一张 File 证据图片,不得信任第三方直接提交的客户隔离字段或订单快照。
- Firstleg 调用 Order、Warehouse、File 的内部接口统一使用共享 `INTERNAL_SERVICE_TOKEN`,只通过客户端 DTO 协作;任何内部接口、Feign DTO 或状态变化都要同步检查被调用模块和契约测试。
- 测量审核通过后的自动链路依次完成计费、确认、费用发布与入库完成;异常放行必须受配置开关、权限和审核历史共同约束,不能跳过测量门禁直接改状态。
- 从父目录运行 `mvn -pl firstleg -am test`。
- 数据库执行顺序:新库执行 `firstleg.sql`;已有库在此后执行 `phase1-firstleg-task-list-index.sql`,为任务列表、按订单查询和分配历史分页补齐索引。
- 二期数据库不得只执行单个建表脚本覆盖存量库;测试/存量环境按 `../deploy/sql/README.md` 使用 8 月 4 日统一脚本,并确认末尾核验全部为 `PASS`。
## 分期快速索引
- 一期:履约任务与分配入口为 `PlatformFulfillmentAssignmentController`,先读 `../../docs/一期/README.md`、`../../docs/一期/一期开发进度表.md` 和 `../../docs/一期/一期头程订单闭环设计.md`;订单交易状态仍归 `order/`,本模块只管理任务状态、分配历史和幂等。
- 二期:入库主线从 `PlatformInboundOrderController`、`PlatformInboundMeasurementController`、`PlatformInboundMeasurementReviewController` 到 `PlatformFbaPricingController`;先读 `../../docs/二期/README.md`、主流程、需求确认清单及联调报告。验收终点为 `WAIT_SPLIT + APPROVED + CONFIRMED`,自动完成入库不得越过测量审核、计费确认和发布门禁。
- 三期:当前 NO-GO;后续国内出仓、分柜、装柜、PDA 拣货与船期协作需先解除 P3 阻断并重新定义状态机,不能把二期 `WAIT_SPLIT` 直接当作三期实现。
- 一期:履约任务与分配;先读 `../../docs/一期/README.md`。
- 二期:入库、测量、审核、计费闭环;先读 `../../docs/二期/README.md`、主流程和联调报告。
- 三期:NO-GO;待 P3 阻断解除后重新定义出仓状态机。
@@ -22,6 +22,13 @@ public interface MeasurementImageClient {
@PathVariable long inboundId,
@RequestPart("file") MultipartFile file);
@PostMapping(value = "/receipts/{tenantId}/{receiptId}", consumes = "multipart/form-data")
ApiResponse<UploadResult> uploadReceipt(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long receiptId,
@RequestPart("file") MultipartFile file);
@DeleteMapping("/{tenantId}/{inboundId}/{fileId}")
ApiResponse<Void> delete(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@@ -29,6 +36,13 @@ public interface MeasurementImageClient {
@PathVariable long inboundId,
@PathVariable long fileId);
@DeleteMapping("/receipts/{tenantId}/{receiptId}/{fileId}")
ApiResponse<Void> deleteReceipt(@RequestHeader("X-Internal-Service-Token") String token,
@RequestHeader("X-Operator-Id") long operatorId,
@PathVariable long tenantId,
@PathVariable long receiptId,
@PathVariable long fileId);
@GetMapping("/{tenantId}/{inboundId}/{fileId}")
org.springframework.http.ResponseEntity<byte[]> download(
@RequestHeader("X-Internal-Service-Token") String token,
@@ -4,7 +4,7 @@ import jakarta.annotation.Resource;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.dto.OpenApiMeasurementDtos.EventResponse;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.dto.MojiaMeasurementDtos.UploadResponse;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
import com.baiyue.firstleg.service.InboundMeasurementOpenApiService;
import com.baiyue.firstleg.service.MojiaMeasurementOpenApiService;
@@ -45,7 +45,7 @@ public class OpenApiInboundMeasurementController {
@Operation(summary = "墨家设备按箱码上传单箱测量结果",
description = "首轮联调使用固定 X-App-Key;JSON 中 picture 为 JPEG Data URL,boxCode 原样取自柏悦箱唛二维码")
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ApiResponse<CartonMeasurementResult> receiveMojia(
public ApiResponse<UploadResponse> receiveMojia(
@RequestHeader("X-App-Key") String appKey,
@RequestHeader(value = "X-Timestamp", required = false) String timestamp,
@RequestHeader(value = "X-Nonce", required = false) String nonce,
@@ -25,4 +25,16 @@ public final class MojiaMeasurementDtos {
@NotBlank @Size(max = 7_100_000) String picture
) {
}
public record UploadResponse(
boolean accepted,
boolean matched,
Long receiptId,
Long measurementId,
Long itemId,
String boxCode,
Long imageFileId,
boolean created
) {
}
}
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.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;
@@ -13,6 +14,8 @@ public record UnifiedInboundQuery(
Long tenantId,
@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,
@@ -0,0 +1,36 @@
package com.baiyue.firstleg.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/** 墨家回传未匹配正式入库单时的联调收件记录。 */
@Getter
@Setter
@TableName("fl_mojia_measurement_receipt")
public class MojiaMeasurementReceiptEntity {
@TableId(type = IdType.INPUT)
private Long id;
private Long tenantId;
private Long subjectId;
private String boxCode;
private BigDecimal lengthCm;
private BigDecimal widthCm;
private BigDecimal heightCm;
private BigDecimal weightKg;
private String machineNo;
private LocalDateTime measuredAt;
private Long imageFileId;
private String status;
private String matchFailureCode;
private String matchFailureReason;
private String requestId;
private String traceId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,9 @@
package com.baiyue.firstleg.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface MojiaMeasurementReceiptMapper extends BaseMapper<MojiaMeasurementReceiptEntity> {
}
@@ -0,0 +1,20 @@
package com.baiyue.firstleg.repository;
import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity;
import com.baiyue.firstleg.mapper.MojiaMeasurementReceiptMapper;
import org.springframework.stereotype.Repository;
@Repository
public class MojiaMeasurementReceiptRepository {
private final MojiaMeasurementReceiptMapper mapper;
public MojiaMeasurementReceiptRepository(MojiaMeasurementReceiptMapper mapper) {
this.mapper = mapper;
}
public void insert(MojiaMeasurementReceiptEntity receipt) {
if (mapper.insert(receipt) != 1) {
throw new IllegalStateException("墨家联调收件记录保存失败");
}
}
}
@@ -18,8 +18,6 @@ import java.util.List;
public class CustomerDimensionWeightingService {
private static final BigDecimal TWO = new BigDecimal("2");
private static final BigDecimal CUBIC_CENTIMETERS_PER_CBM = new BigDecimal("1000000");
private static final String OVERSIZE_WARNING =
"实测尺寸超出客户最大箱规,已按实测尺寸记录,需人工复核";
private final CustomerDimensionConfigRepository repository;
@@ -64,16 +62,16 @@ public class CustomerDimensionWeightingService {
.filter(rule -> rule.contains(measured))
.findFirst()
.map(Rule::weighted)
.orElseGet(() -> actualOversize(lengthCm, widthCm, heightCm));
.orElseGet(() -> actualDimensions(lengthCm, widthCm, heightCm));
}
private WeightedDimensions actualOversize(BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
private WeightedDimensions actualDimensions(BigDecimal lengthCm, BigDecimal widthCm,
BigDecimal heightCm) {
BigDecimal rawVolume = lengthCm.multiply(widthCm).multiply(heightCm)
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
return new WeightedDimensions(null, null, lengthCm, widthCm, heightCm,
rawVolume, rawVolume.setScale(3, RoundingMode.HALF_UP),
"ACTUAL_DIMENSION_OVERSIZE_V1", OVERSIZE_WARNING);
"ACTUAL_DIMENSION_V1");
}
}
@@ -85,8 +83,7 @@ public class CustomerDimensionWeightingService {
BigDecimal outerHeightCm,
BigDecimal rawVolumeCbm,
BigDecimal unitVolumeCbm,
String calculationRuleVersion,
String warning
String calculationRuleVersion
) {
}
@@ -123,7 +120,7 @@ public class CustomerDimensionWeightingService {
.divide(CUBIC_CENTIMETERS_PER_CBM, 6, RoundingMode.HALF_UP);
BigDecimal unitVolume = rawVolume.setScale(3, RoundingMode.HALF_UP);
return new WeightedDimensions(boxType, version, outerLength, outerWidth, outerHeight,
rawVolume, unitVolume, "CUSTOMER_DIMENSION_" + boxType.name() + "_V" + version, null);
rawVolume, unitVolume, "CUSTOMER_DIMENSION_" + boxType.name() + "_V" + version);
}
}
@@ -163,8 +163,7 @@ public class MeasurementBatchService {
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(weighted.calculationRuleVersion());
item.setSourceImageFileId(source.intelligentImageFileId());
item.setOverLimitWarning(weighted.warning() == null
? source.overLimitWarning() : weighted.warning());
item.setOverLimitWarning(source.overLimitWarning());
item.setOuterLengthCm(weighted.outerLengthCm());
item.setOuterWidthCm(weighted.outerWidthCm());
item.setOuterHeightCm(weighted.outerHeightCm());
@@ -1,10 +1,16 @@
package com.baiyue.firstleg.service;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.dto.MojiaMeasurementDtos.UploadRequest;
import com.baiyue.firstleg.dto.MojiaMeasurementDtos.UploadResponse;
import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity;
import com.baiyue.firstleg.repository.MojiaMeasurementReceiptRepository;
import com.baiyue.firstleg.security.AuthenticatedMeasurementApp;
import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
@@ -16,6 +22,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
@@ -39,36 +46,54 @@ public class MojiaMeasurementOpenApiService {
private final CartonMeasurementService cartonMeasurements;
private final ObjectMapper objectMapper;
private final Validator validator;
private final MeasurementImageClient imageClient;
private final MojiaMeasurementReceiptRepository receipts;
@Value("${baiyue.security.internal-service-token}")
private String internalToken;
public MojiaMeasurementOpenApiService(MeasurementRequestAuthenticator authenticator,
CartonMeasurementService cartonMeasurements,
ObjectMapper objectMapper,
Validator validator) {
Validator validator,
MeasurementImageClient imageClient,
MojiaMeasurementReceiptRepository receipts) {
this.authenticator = authenticator;
this.cartonMeasurements = cartonMeasurements;
this.objectMapper = objectMapper;
this.validator = validator;
this.imageClient = imageClient;
this.receipts = receipts;
}
public CartonMeasurementResult receive(MeasurementRequestHeaders headers, String rawJson) {
public UploadResponse receive(MeasurementRequestHeaders headers, String rawJson) {
UploadRequest request = null;
try {
AuthenticatedMeasurementApp app = authenticator.authenticate(
"POST", CALLBACK_PATH, headers, rawJson, false);
request = parseAndValidate(rawJson);
MultipartFile image = decodeJpeg(request.picture());
CartonMeasurementResult result = cartonMeasurements.upsert(
app.tenantId(), app.subjectId(), new CartonMeasurementCommand(
request.boxCode(), request.length(), request.width(), request.height(), request.weight(),
request.machineNo(), request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt(),
image));
CartonMeasurementResult result;
try {
result = cartonMeasurements.upsert(
app.tenantId(), app.subjectId(), new CartonMeasurementCommand(
request.boxCode(), request.length(), request.width(), request.height(), request.weight(),
request.machineNo(), request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt(),
image));
} catch (BizException exception) {
if (exception.getResultCode() == ResultCode.NOT_FOUND) {
return saveUnmatched(app, request, image, exception);
}
throw exception;
}
log.info("MOJIA_MEASUREMENT accepted requestId={} traceId={} boxCode={} machineNo={} "
+ "lengthCm={} widthCm={} heightCm={} weightKg={} imageBytes={} measurementId={} "
+ "itemId={} created={}",
correlation("requestId"), correlation("traceId"), safe(request.boxCode()),
safe(request.machineNo()), request.length(), request.width(), request.height(), request.weight(),
image.getSize(), result.measurementId(), result.itemId(), result.created());
return result;
return new UploadResponse(true, true, null, result.measurementId(), result.itemId(),
result.boxCode(), result.imageFileId(), result.created());
} catch (RuntimeException exception) {
String code = exception instanceof BizException biz ? biz.getResultCode().code() : "S0500";
log.warn("MOJIA_MEASUREMENT rejected requestId={} traceId={} boxCode={} machineNo={} "
@@ -81,6 +106,69 @@ public class MojiaMeasurementOpenApiService {
}
}
private UploadResponse saveUnmatched(AuthenticatedMeasurementApp app, UploadRequest request,
MultipartFile image, BizException matchFailure) {
long receiptId = IdWorker.getId();
MeasurementImageClient.UploadResult uploaded = uploadReceiptImage(
app.tenantId(), app.subjectId(), receiptId, image);
try {
MojiaMeasurementReceiptEntity receipt = new MojiaMeasurementReceiptEntity();
receipt.setId(receiptId);
receipt.setTenantId(app.tenantId());
receipt.setSubjectId(app.subjectId());
receipt.setBoxCode(request.boxCode().trim());
receipt.setLengthCm(request.length());
receipt.setWidthCm(request.width());
receipt.setHeightCm(request.height());
receipt.setWeightKg(request.weight());
receipt.setMachineNo(request.machineNo().trim());
receipt.setMeasuredAt(request.measuredAt() == null ? LocalDateTime.now() : request.measuredAt());
receipt.setImageFileId(uploaded.fileId());
receipt.setStatus("UNMATCHED");
receipt.setMatchFailureCode(matchFailure.getResultCode().code());
receipt.setMatchFailureReason(safe(matchFailure.getMessage()));
receipt.setRequestId(correlation("requestId"));
receipt.setTraceId(correlation("traceId"));
receipt.setCreatedAt(LocalDateTime.now());
receipt.setUpdatedAt(LocalDateTime.now());
receipts.insert(receipt);
} catch (RuntimeException exception) {
cleanupReceiptImage(app.tenantId(), app.subjectId(), receiptId, uploaded.fileId());
throw exception;
}
log.info("MOJIA_MEASUREMENT accepted requestId={} traceId={} boxCode={} machineNo={} "
+ "matched=false receiptId={} imageBytes={} imageFileId={} matchCode={}",
correlation("requestId"), correlation("traceId"), safe(request.boxCode()),
safe(request.machineNo()), receiptId, image.getSize(), uploaded.fileId(),
matchFailure.getResultCode().code());
return new UploadResponse(true, false, receiptId, null, null, request.boxCode(),
uploaded.fileId(), true);
}
private MeasurementImageClient.UploadResult uploadReceiptImage(long tenantId, long operatorId,
long receiptId, MultipartFile image) {
try {
ApiResponse<MeasurementImageClient.UploadResult> response = imageClient.uploadReceipt(
internalToken, operatorId, tenantId, receiptId, image);
if (response == null || !response.successful() || response.data() == null
|| response.data().fileId() == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "墨家联调图片保存失败");
}
return response.data();
} catch (feign.FeignException exception) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "文件服务暂不可用,墨家回传未保存");
}
}
private void cleanupReceiptImage(long tenantId, long operatorId, long receiptId, long fileId) {
try {
imageClient.deleteReceipt(internalToken, operatorId, tenantId, receiptId, fileId);
} catch (RuntimeException exception) {
log.warn("墨家联调收件图片补偿删除失败 tenantId={} receiptId={} fileId={}",
tenantId, receiptId, fileId, exception);
}
}
private UploadRequest parseAndValidate(String rawJson) {
if (rawJson == null || rawJson.isBlank()) {
throw new BizException(ResultCode.VALIDATION_FAILED, "请求 JSON 不能为空");
@@ -28,7 +28,7 @@ import java.util.stream.Collectors;
@Service
public class UnifiedInboundQueryService {
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");
@Resource private InboundOrderRepository inbounds;
@@ -71,10 +71,14 @@ public class UnifiedInboundQueryService {
private List<UnifiedInboundSummary> summaries(List<InboundOrderEntity> records) {
Map<Long, Long> owners = records.stream().collect(Collectors.toMap(
InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left));
var preloads = operations.findPreloads(owners);
var picks = operations.findPicks(owners);
var evidences = operations.findEvidence(owners);
var deliveries = operations.findDeliveryHistory(owners);
Map<Long, Long> phaseThreeOwners = records.stream()
.filter(inbound -> requiresPhaseThreeSummary(mainStatus(inbound.getProcessStatus())))
.collect(Collectors.toMap(
InboundOrderEntity::getId, InboundOrderEntity::getTenantId, (left, right) -> left));
var preloads = operations.findPreloads(phaseThreeOwners);
var picks = operations.findPicks(phaseThreeOwners);
var evidences = operations.findEvidence(phaseThreeOwners);
var deliveries = operations.findDeliveryHistory(phaseThreeOwners);
var transports = operations.findTransports(owners);
return records.stream().map(inbound -> summary(inbound, null, preloads.get(inbound.getId()),
picks.get(inbound.getId()), evidences.getOrDefault(inbound.getId(), List.of()),
@@ -120,8 +124,8 @@ public class UnifiedInboundQueryService {
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;
};
@@ -132,9 +136,10 @@ public class UnifiedInboundQueryService {
return switch (mainStatus) {
case "PENDING_REVIEW" -> "APPROVE_ORDER";
case "WAIT_MEASURE" -> "MEASURE_PENDING".equals(processStatus) ? "CONFIRM_MEASUREMENT" : null;
case "INBOUNDED" -> !List.of("INBOUNDED", "WAIT_SPLIT").contains(processStatus) ? null
case "INBOUNDED" -> !"INBOUNDED".equals(processStatus) ? null
: "CONFIRMED".equals(preloadStatus) ? null
: preloadStatus == null ? "SMART_SPLIT" : "CONFIRM_PRELOAD";
case "WAIT_SPLIT" -> null;
case "LOADING" -> "COMPLETE_LOADING";
case "LOADED" -> "COMPLETE_DELIVERY";
case "DELIVERED" -> null;
@@ -142,11 +147,16 @@ public class UnifiedInboundQueryService {
};
}
static boolean requiresPhaseThreeSummary(String mainStatus) {
return mainStatus != null && Set.of("LOADING", "LOADED", "DELIVERED").contains(mainStatus);
}
private List<String> internalStatuses(String mainStatus) {
if (!StringUtils.hasText(mainStatus)) return List.of();
return switch (mainStatus) {
case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED");
case "INBOUNDED" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "WAIT_SPLIT", "INBOUNDED");
case "INBOUNDED" -> List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED");
case "WAIT_SPLIT" -> List.of("WAIT_SPLIT");
case "LOADING", "LOADED", "DELIVERED" -> List.of(mainStatus);
default -> List.of("__NO_MATCH__");
};
@@ -235,6 +235,30 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
CONSTRAINT fk_measure_item_batch FOREIGN KEY (measurement_id) REFERENCES fl_inbound_measurement (id)
) ENGINE=InnoDB COMMENT='入库长宽高和箱数测量明细';
CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt (
id BIGINT UNSIGNED NOT NULL COMMENT '墨家联调收件记录ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离ID',
subject_id BIGINT UNSIGNED NOT NULL COMMENT '设备应用操作主体ID',
box_code VARCHAR(128) NOT NULL COMMENT '设备扫描的原始箱码',
length_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测长度cm',
width_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测宽度cm',
height_cm DECIMAL(12,3) NOT NULL COMMENT '设备实测高度cm',
weight_kg DECIMAL(18,3) NULL COMMENT '设备实测重量kg',
machine_no VARCHAR(64) NOT NULL COMMENT '设备编号',
measured_at DATETIME(3) NOT NULL COMMENT '设备测量时间或服务接收时间',
image_file_id BIGINT UNSIGNED NOT NULL COMMENT 'File服务中的墨家联调图片ID',
status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '收件状态',
match_failure_code VARCHAR(16) NOT NULL COMMENT '正式业务匹配失败码',
match_failure_reason VARCHAR(500) NOT NULL COMMENT '脱敏后的匹配失败原因',
request_id VARCHAR(128) NULL COMMENT '请求链路ID',
trace_id VARCHAR(128) NULL COMMENT '调用链ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (id),
KEY idx_mojia_receipt_status_time (tenant_id, status, created_at),
KEY idx_mojia_receipt_box_time (tenant_id, box_code, created_at)
) ENGINE=InnoDB COMMENT='墨家模拟或未匹配入库单的原始测量收件箱';
CREATE TABLE IF NOT EXISTS fl_inbound_weight_result (
id BIGINT UNSIGNED NOT NULL COMMENT 'OCR重量结果ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
@@ -546,9 +546,9 @@ BEGIN
END IF;
IF (SELECT COUNT(DISTINCT index_name) FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND index_name IN ('uk_inbound_master_order', 'uk_inbound_parent_sequence',
AND index_name IN ('uk_inbound_master_order', 'uk_inbound_one_child',
'uk_inbound_split_operation', 'uk_inbound_split_idempotency',
'uk_inbound_split_child', 'uk_inbound_split_history_operation')) <> 6 THEN
'uk_inbound_split_history_operation')) <> 5 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '主子入仓单唯一约束未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.table_constraints
@@ -1,17 +1,20 @@
-- 将已废弃的 WAIT_SPLIT 统一为 INBOUNDED。
-- 执行前请确认所有服务已升级至本次状态契约;脚本可重复执行。
-- WAIT_SPLIT 是二期当前有效终点。本脚本仅校验兼容结构,禁止改写主表或历史状态。
USE baiyue_firstleg;
UPDATE fl_inbound_order
SET process_status = 'INBOUNDED',
updated_at = CURRENT_TIMESTAMP(3),
version = version + 1
WHERE process_status = 'WAIT_SPLIT';
UPDATE fl_inbound_status_history
SET from_status = 'INBOUNDED'
WHERE from_status = 'WAIT_SPLIT';
UPDATE fl_inbound_status_history
SET to_status = 'INBOUNDED'
WHERE to_status = 'WAIT_SPLIT';
DROP PROCEDURE IF EXISTS assert_wait_split_compatibility;
DELIMITER $$
CREATE PROCEDURE assert_wait_split_compatibility()
BEGIN
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_order'
AND column_name = 'process_status') <> 1
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_status_history'
AND column_name IN ('from_status', 'to_status')) <> 2 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = 'WAIT_SPLIT 兼容校验失败:入库状态字段不完整';
END IF;
END$$
DELIMITER ;
CALL assert_wait_split_compatibility();
DROP PROCEDURE assert_wait_split_compatibility;
@@ -235,27 +235,84 @@ DELIMITER $$
CREATE PROCEDURE migrate_legacy_loading_pick_task()
BEGIN
DECLARE source_exists INT DEFAULT 0;
DECLARE source_total_columns INT DEFAULT 0;
DECLARE source_smart_columns INT DEFAULT 0;
DECLARE source_inbound_columns INT DEFAULT 0;
DECLARE target_exists INT DEFAULT 0;
DECLARE target_total_columns INT DEFAULT 0;
DECLARE target_inbound_columns INT DEFAULT 0;
SELECT COUNT(*) INTO source_exists
FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task';
IF source_exists = 1 THEN
SELECT COUNT(*) INTO source_total_columns
FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task';
SELECT COUNT(*) INTO source_smart_columns
FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND column_name IN ('loading_plan_id', 'container_no', 'task_status');
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'loading_plan_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'container_no' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'NO')
OR (column_name = 'task_status' AND LOWER(column_type) = 'varchar(16)' AND is_nullable = 'NO' AND column_default = 'LOADING')
OR (column_name = 'started_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'started_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
OR (column_name = 'started_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
OR (column_name = 'completed_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'YES')
OR (column_name = 'completed_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
OR (column_name = 'completed_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'YES')
OR (column_name = 'version' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
);
SELECT COUNT(*) INTO source_inbound_columns
FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count');
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task' AND (
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING')
OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES')
OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')
);
IF source_smart_columns = 3 AND source_inbound_columns = 0 THEN
IF source_total_columns = 11 AND source_smart_columns = 11 AND source_inbound_columns < 12
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'uk_loading_pick_task_plan' AND non_unique = 0
AND seq_in_index = 1 AND column_name = 'loading_plan_id') = 1
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'idx_loading_pick_container'
AND ((seq_in_index = 1 AND column_name = 'container_no')
OR (seq_in_index = 2 AND column_name = 'task_status')
OR (seq_in_index = 3 AND column_name = 'started_at'))) = 3
AND (SELECT COUNT(*) FROM information_schema.key_column_usage
WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND column_name = 'loading_plan_id' AND referenced_table_name = 'fl_loading_plan'
AND referenced_column_name = 'id') = 1 THEN
SET source_exists = 1;
ELSEIF source_smart_columns = 0 AND source_inbound_columns = 5 THEN
ELSEIF source_total_columns = 12 AND source_inbound_columns = 12 AND source_smart_columns < 11
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0
AND ((seq_in_index = 1 AND column_name = 'tenant_id')
OR (seq_in_index = 2 AND column_name = 'inbound_id'))) = 2
AND (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND index_name = 'idx_loading_pick_status'
AND ((seq_in_index = 1 AND column_name = 'tenant_id')
OR (seq_in_index = 2 AND column_name = 'status')
OR (seq_in_index = 3 AND column_name = 'updated_at'))) = 3
AND (SELECT COUNT(*) FROM information_schema.key_column_usage
WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_loading_pick_task'
AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order'
AND referenced_column_name = 'id') = 1 THEN
SELECT COUNT(*) INTO target_exists
FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
@@ -263,14 +320,44 @@ BEGIN
IF target_exists = 0 THEN
RENAME TABLE fl_loading_pick_task TO fl_inbound_loading_pick_task;
ELSE
SELECT COUNT(*) INTO target_total_columns
FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task';
SELECT COUNT(*) INTO target_inbound_columns
FROM information_schema.columns
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_loading_pick_task'
AND column_name IN ('tenant_id', 'inbound_id', 'status', 'expected_count', 'picked_count');
IF target_inbound_columns <> 5 THEN
AND table_name = 'fl_inbound_loading_pick_task' AND (
(column_name = 'id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'tenant_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'inbound_id' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'status' AND LOWER(column_type) = 'varchar(24)' AND is_nullable = 'NO' AND column_default = 'PENDING')
OR (column_name = 'version' AND LOWER(column_type) = 'int' AND is_nullable = 'NO' AND CAST(column_default AS CHAR) = '0')
OR (column_name = 'expected_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
OR (column_name = 'picked_count' AND LOWER(column_type) = 'int unsigned' AND is_nullable = 'YES')
OR (column_name = 'remark' AND LOWER(column_type) = 'varchar(1000)' AND is_nullable = 'YES')
OR (column_name = 'updated_by' AND LOWER(column_type) = 'bigint unsigned' AND is_nullable = 'NO')
OR (column_name = 'updated_by_name' AND LOWER(column_type) = 'varchar(64)' AND is_nullable = 'YES')
OR (column_name = 'created_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%')
OR (column_name = 'updated_at' AND data_type = 'datetime' AND datetime_precision = 3 AND is_nullable = 'NO' AND LOWER(column_default) LIKE 'current_timestamp%' AND LOWER(extra) LIKE '%on update current_timestamp%')
);
IF target_total_columns <> 12 OR target_inbound_columns <> 12
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'
AND index_name = 'uk_loading_pick_tenant_inbound' AND non_unique = 0
AND ((seq_in_index = 1 AND column_name = 'tenant_id')
OR (seq_in_index = 2 AND column_name = 'inbound_id'))) <> 2
OR (SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'
AND index_name = 'idx_loading_pick_status'
AND ((seq_in_index = 1 AND column_name = 'tenant_id')
OR (seq_in_index = 2 AND column_name = 'status')
OR (seq_in_index = 3 AND column_name = 'updated_at'))) <> 3
OR (SELECT COUNT(*) FROM information_schema.key_column_usage
WHERE constraint_schema = 'baiyue_firstleg' AND table_name = 'fl_inbound_loading_pick_task'
AND column_name = 'inbound_id' AND referenced_table_name = 'fl_inbound_order'
AND referenced_column_name = 'id') <> 1 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是入库汇总结构';
SET MESSAGE_TEXT = '拣货任务迁移失败:fl_inbound_loading_pick_task 不是完整入库汇总结构';
END IF;
INSERT INTO fl_inbound_loading_pick_task
(id, tenant_id, inbound_id, status, version, expected_count, picked_count,
@@ -776,6 +863,95 @@ DELIMITER ;
-- 三期智能装柜、预装清单和装柜清单平台权限;与一期平台根菜单幂等合并。
USE baiyue_iam;
DROP PROCEDURE IF EXISTS relocate_legacy_inbound_loading_permissions;
DELIMITER $$
CREATE PROCEDURE relocate_legacy_inbound_loading_permissions()
BEGIN
DECLARE legacy_count INT DEFAULT 0;
SELECT COUNT(*) INTO legacy_count
FROM iam_permission
WHERE tenant_id = 0 AND app_code = 'PLATFORM'
AND permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view'
);
IF legacy_count > 0 THEN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE id BETWEEN 5102121 AND 5102129
AND permission_code <> CASE id
WHEN 5102121 THEN 'firstleg:inbound:split'
WHEN 5102122 THEN 'firstleg:inbound:preload'
WHEN 5102123 THEN 'firstleg:inbound:preload-view'
WHEN 5102124 THEN 'platform:firstleg:inbound:loading:edit'
WHEN 5102125 THEN 'platform:firstleg:inbound:loading:view'
WHEN 5102126 THEN 'platform:firstleg:inbound:loading:complete'
WHEN 5102127 THEN 'platform:firstleg:inbound:delivery:complete'
WHEN 5102128 THEN 'platform:firstleg:inbound:picking:edit'
WHEN 5102129 THEN 'platform:firstleg:inbound:picking:view'
END
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '三期权限迁移失败:5102121-5102129 已被其他权限占用';
END IF;
DROP TEMPORARY TABLE IF EXISTS tmp_phase3_inbound_role_grant;
CREATE TEMPORARY TABLE tmp_phase3_inbound_role_grant (
role_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
permission_code VARCHAR(128) NOT NULL,
PRIMARY KEY (role_id, tenant_id, permission_code)
) ENGINE=MEMORY;
INSERT IGNORE INTO tmp_phase3_inbound_role_grant (role_id, tenant_id, permission_code)
SELECT role_permission.role_id, role_permission.tenant_id, permission.permission_code
FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id = role_permission.permission_id
WHERE permission.permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view'
);
DELETE role_permission FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id = role_permission.permission_id
WHERE permission.permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view'
);
DELETE FROM iam_permission WHERE permission_code IN (
'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view',
'platform:firstleg:inbound:loading:edit', 'platform:firstleg:inbound:loading:view',
'platform:firstleg:inbound:loading:complete', 'platform:firstleg:inbound:delivery:complete',
'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view'
);
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5102121, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:split', '智能分柜', NULL, NULL, 140, 'ACTIVE', 1, 0),
(5102122, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:preload', '编辑并确认预装清单', NULL, NULL, 150, 'ACTIVE', 1, 0),
(5102123, 0, 'PLATFORM', 'BUTTON', 5102030, 'firstleg:inbound:preload-view', '查看预装清单', NULL, NULL, 160, 'ACTIVE', 1, 0),
(5102124, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:loading:edit', '维护装柜证据', NULL, NULL, 170, 'ACTIVE', 1, 0),
(5102125, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:loading:view', '查看装柜证据', NULL, NULL, 180, 'ACTIVE', 1, 0),
(5102126, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:loading:complete', '完成装柜', NULL, NULL, 190, 'ACTIVE', 1, 0),
(5102127, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:delivery:complete', '完成派送', NULL, NULL, 200, 'ACTIVE', 1, 0),
(5102128, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:picking:edit', '执行拣货', NULL, NULL, 210, 'ACTIVE', 1, 0),
(5102129, 0, 'PLATFORM', 'BUTTON', 5102030, 'platform:firstleg:inbound:picking:view', '查看拣货任务', NULL, NULL, 220, 'ACTIVE', 1, 0);
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT grant_row.role_id, permission.id, grant_row.tenant_id
FROM tmp_phase3_inbound_role_grant grant_row
JOIN iam_permission permission ON permission.permission_code = grant_row.permission_code
AND permission.tenant_id = 0 AND permission.app_code = 'PLATFORM';
DROP TEMPORARY TABLE tmp_phase3_inbound_role_grant;
END IF;
END$$
DELIMITER ;
CALL relocate_legacy_inbound_loading_permissions();
DROP PROCEDURE relocate_legacy_inbound_loading_permissions;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code,
permission_name, route_path, http_method, sort_no, status, built_in, deleted)
@@ -857,9 +1033,9 @@ BEGIN
IF (SELECT COUNT(*) FROM information_schema.check_constraints
WHERE constraint_schema = 'baiyue_firstleg'
AND constraint_name = 'chk_loading_plan_status'
AND check_clause LIKE '%''EDITING''%'
AND check_clause LIKE '%''LOADING''%'
AND check_clause LIKE '%''COMPLETED''%') <> 1 THEN
AND UPPER(check_clause) LIKE '%EDITING%'
AND UPPER(check_clause) LIKE '%LOADING%'
AND UPPER(check_clause) LIKE '%COMPLETED%') <> 1 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:装柜状态约束不是最终三状态';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
@@ -262,7 +262,11 @@ paths:
format: binary
description: 当前测量明细唯一对应图片,JPG/PNG/BMP且不超过5MB
responses:
'200': {description: 成功或业务失败,按响应体字符串 code 判断;非法签名返回 A0401}
'200':
description: JSON 墨家协议在正式箱码匹配或未匹配联调收件成功时均返回 code=200;data.matched 区分结果。其他业务失败按 code 判断。
content:
application/json:
schema: {$ref: '#/components/schemas/MojiaMeasurementUploadEnvelope'}
'413': {description: 请求体超过网关限制}
'415': {description: Content-Type 不支持}
/openapi/v1/inbound-measurements/events/{eventNo}:
@@ -523,6 +527,24 @@ components:
required: true
schema: {type: string, pattern: '^[1-9][0-9]*$'}
schemas:
MojiaMeasurementUploadEnvelope:
type: object
properties:
code: {type: string, example: '200'}
message: {type: string, example: success}
data: {$ref: '#/components/schemas/MojiaMeasurementUploadResponse'}
MojiaMeasurementUploadResponse:
type: object
required: [accepted, matched, boxCode, created]
properties:
accepted: {type: boolean, example: true}
matched: {type: boolean, description: true 表示已写入正式箱级测量;false 表示已写入联调收件箱等待排查}
receiptId: {type: string, nullable: true, description: matched=false 时返回的联调收件记录ID}
measurementId: {type: string, nullable: true}
itemId: {type: string, nullable: true}
boxCode: {type: string}
imageFileId: {type: string, nullable: true}
created: {type: boolean}
CustomerDimensionConfigUpdateRequest:
type: object
required: [rules]
@@ -46,21 +46,6 @@ paths:
schema: {$ref: '#/components/schemas/TransportUpdateRequest'}
responses:
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
/api/platform/v1/first-leg/loading-plans/{planId}/complete:
post:
summary: 完成预装
description: 仅允许将 LOADING 单向推进为 COMPLETED;完成前必须完成全部 PDA 拣货。
x-permission: platform:firstleg:preload:complete
parameters:
- {$ref: '#/components/parameters/PlanId'}
- {$ref: '#/components/parameters/IdempotencyKey'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/AdvanceRequest'}
responses:
'200': {$ref: '#/components/responses/PreloadDetailResponse'}
/api/platform/v1/first-leg/loading-lists:
get:
summary: 分页查询装柜清单
@@ -326,10 +311,3 @@ components:
voyageNo: {type: string, maxLength: 64, nullable: true, description: 航次}
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
AdvanceRequest:
type: object
required: [expectedVersion]
additionalProperties: false
properties:
expectedVersion: {type: integer, format: int32, minimum: 0, description: 当前乐观锁版本}
operationReason: {type: string, maxLength: 500, nullable: true, description: 操作原因}
@@ -76,13 +76,21 @@ class LoadingListControllerContractTest {
.getContentAsString(StandardCharsets.UTF_8);
Map<String, Object> document = stringMap(new Yaml().load(yaml));
assertThat(document.get("openapi")).isEqualTo("3.0.3");
assertThat((Map<?, ?>) document.get("paths")).hasSize(6);
Map<String, Object> paths = stringMap(document.get("paths"));
assertThat(paths).containsOnlyKeys(
"/api/platform/v1/first-leg/loading-plans",
"/api/platform/v1/first-leg/loading-plans/{planId}",
"/api/platform/v1/first-leg/loading-plans/{planId}/transport",
"/api/platform/v1/first-leg/loading-lists",
"/api/platform/v1/first-leg/loading-lists/{planId}");
Map<String, Object> components = stringMap(document.get("components"));
Map<String, Object> schemas = stringMap(components.get("schemas"));
assertThat(schemas).containsKeys(
"LongId", "PreloadPage", "ConfirmedPage", "Customer", "PreloadSummary",
"ConfirmedSummary", "LoadingItem", "OperationHistory", "PreloadDetail",
"ConfirmedDetail", "TransportUpdateRequest", "AdvanceRequest");
"ConfirmedDetail", "TransportUpdateRequest");
assertThat(paths)
.doesNotContainKey("/api/platform/v1/first-leg/loading-plans/{planId}/complete");
assertThat(stringMap(schemas.get("LongId"))).containsEntry("type", "string");
assertThat(properties(schemas, "PreloadPage")).containsKeys("records", "page", "size", "total", "pages");
assertThat(properties(schemas, "PreloadSummary")).containsKeys("planId", "customers", "skuCount");
@@ -0,0 +1,64 @@
package com.baiyue.firstleg.loading.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteDeliveryRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.CompleteLoadingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.PickingRequest;
import com.baiyue.firstleg.loading.dto.LoadingRequests.UpsertEvidenceRequest;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformLoadingDeliveryControllerContractTest {
@Test
void keepsInboundScopedPathsAndPermissions() throws Exception {
assertThat(PlatformLoadingDeliveryController.class.getAnnotation(RequestMapping.class).value())
.containsExactly("/api/platform/v1/inbounds");
post("evidence", "/{inboundId}/loading/evidence",
"platform:firstleg:inbound:loading:edit", UpsertEvidenceRequest.class);
get("evidence", "/{inboundId}/loading/evidence",
"platform:firstleg:inbound:loading:view");
post("completeLoading", "/{inboundId}/loading/complete",
"platform:firstleg:inbound:loading:complete", CompleteLoadingRequest.class);
post("completeDelivery", "/{inboundId}/delivery/complete",
"platform:firstleg:inbound:delivery:complete", CompleteDeliveryRequest.class);
post("startPicking", "/{inboundId}/loading/picking/start",
"platform:firstleg:inbound:picking:edit", PickingRequest.class);
post("completePicking", "/{inboundId}/loading/picking/complete",
"platform:firstleg:inbound:picking:edit", PickingRequest.class);
get("picking", "/{inboundId}/loading/picking",
"platform:firstleg:inbound:picking:view");
}
private static void post(String name, String path, String authority, Class<?> requestType) throws Exception {
Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod(name,
AuthenticatedUser.class, long.class, long.class, String.class, requestType);
assertThat(method.getAnnotation(PostMapping.class).value()).containsExactly(path);
assertAuthority(method, authority);
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class)).isNotNull();
assertThat(method.getParameters()[3].getAnnotation(RequestHeader.class).value())
.isEqualTo("Idempotency-Key");
}
private static void get(String name, String path, String authority) throws Exception {
Method method = PlatformLoadingDeliveryController.class.getDeclaredMethod(name,
AuthenticatedUser.class, long.class, long.class);
assertThat(method.getAnnotation(GetMapping.class).value()).containsExactly(path);
assertAuthority(method, authority);
assertThat(method.getParameters()[1].getAnnotation(RequestParam.class)).isNotNull();
}
private static void assertAuthority(Method method, String authority) {
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + authority + "')");
}
}
@@ -11,14 +11,21 @@ import static org.assertj.core.api.Assertions.assertThat;
class InboundStatusUnificationSqlContractTest {
@Test
void syncsStatusUnificationMigrationToUnifiedDeploymentScript() throws Exception {
void preservesWaitSplitWithoutDataRewrite() throws Exception {
String module = normalize(new ClassPathResource("db/phase3-inbounded-status-unification.sql")
.getContentAsString(StandardCharsets.UTF_8)).trim();
String unified = normalize(Files.readString(unifiedPath(), StandardCharsets.UTF_8));
assertThat(module)
.contains("CREATE PROCEDURE assert_wait_split_compatibility()")
.contains("WAIT_SPLIT 是二期当前有效终点")
.doesNotContain("UPDATE fl_inbound_order")
.doesNotContain("UPDATE fl_inbound_status_history");
assertThat(unified)
.contains("-- 来源:firstleg/src/main/resources/db/phase3-inbounded-status-unification.sql")
.contains(module);
.contains("WAIT_SPLIT 是二期当前有效终点;统一迁移不得改写主表或历史状态")
.doesNotContain("SET process_status = 'INBOUNDED'")
.doesNotContain("SET from_status = 'INBOUNDED'")
.doesNotContain("SET to_status = 'INBOUNDED'");
}
private static Path unifiedPath() {
@@ -0,0 +1,34 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class MojiaMeasurementReceiptSqlContractTest {
@Test
void schemaAndUnifiedMigrationKeepUnmatchedMeasurementsWithImageReferences() throws Exception {
String schema = new ClassPathResource("db/phase2-inbound-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
String unified = Files.readString(
Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
for (String sql : new String[]{schema, unified}) {
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_mojia_measurement_receipt")
.contains("box_code VARCHAR(128) NOT NULL")
.contains("image_file_id BIGINT UNSIGNED NOT NULL")
.contains("status VARCHAR(24) NOT NULL DEFAULT 'UNMATCHED'")
.contains("idx_mojia_receipt_status_time")
.doesNotContain("picture_base64");
}
assertThat(unified)
.contains("墨家未匹配测量收件箱字段未完整创建")
.contains("测量计算规则版本字段长度不是64");
}
}
@@ -91,7 +91,11 @@ class SmartLoadingSqlContractTest {
void shouldAssertCompletePermissionGrants() throws Exception {
assertPermissionContract(migration());
assertPermissionContract(unifiedMigration());
assertThat(unifiedMigration()).contains(migration().trim());
assertThat(unifiedMigration())
.contains("CALL migrate_legacy_loading_pick_task()")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task")
.contains("5102121, 0, 'PLATFORM'")
.contains("5102129, 0, 'PLATFORM'");
}
@Test
@@ -126,6 +130,8 @@ class SmartLoadingSqlContractTest {
void shouldProvidePdaContract() throws Exception {
assertThat(migration())
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task")
.contains("CALL migrate_legacy_loading_pick_task()")
.contains("RENAME TABLE fl_loading_pick_task TO fl_inbound_loading_pick_task")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_history")
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
@@ -142,6 +148,38 @@ class SmartLoadingSqlContractTest {
.contains(")) <> 16 THEN");
}
@Test
@DisplayName("统一脚本只定义并调用一次智能装柜断言")
void shouldDefineSmartLoadingAssertionOnce() throws Exception {
String unified = unifiedMigration();
assertThat(count(unified, "CREATE PROCEDURE assert_phase3_smart_loading_schema()"))
.isEqualTo(1);
assertThat(count(unified, "CALL assert_phase3_smart_loading_schema();"))
.isEqualTo(1);
assertThat(count(unified, "SELECT 'PASS' AS migration_status"))
.isEqualTo(1);
}
@Test
@DisplayName("详细拣货和入库汇总拣货使用独立完整表型")
void shouldKeepDetailedAndInboundPickTaskShapesSeparate() throws Exception {
String unified = unifiedMigration();
assertThat(unified)
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_task (")
.contains("loading_plan_id BIGINT UNSIGNED NOT NULL")
.contains("container_no VARCHAR(64) NOT NULL")
.contains("task_status VARCHAR(16) NOT NULL DEFAULT 'LOADING'")
.contains("UNIQUE KEY uk_loading_pick_task_plan (loading_plan_id)")
.contains("KEY idx_loading_pick_container (container_no, task_status, started_at)")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_loading_pick_task (")
.contains("tenant_id BIGINT UNSIGNED NOT NULL")
.contains("inbound_id BIGINT UNSIGNED NOT NULL")
.contains("expected_count INT UNSIGNED NULL")
.contains("picked_count INT UNSIGNED NULL")
.contains("UNIQUE KEY uk_loading_pick_tenant_inbound (tenant_id, inbound_id)")
.contains("KEY idx_loading_pick_status (tenant_id, status, updated_at)");
}
private static String migration() throws Exception {
return new ClassPathResource("db/phase3-smart-loading-schema.sql")
.getContentAsString(StandardCharsets.UTF_8);
@@ -174,4 +212,8 @@ class SmartLoadingSqlContractTest {
Matcher matcher = PERMISSION_PATTERN.matcher(sql);
return matcher.results().map(result -> result.group(1)).collect(Collectors.toSet());
}
private static int count(String value, String token) {
return (value.length() - value.replace(token, "").length()) / token.length();
}
}
@@ -56,7 +56,7 @@ class CustomerDimensionWeightingServiceTest {
}
@Test
void dimensionsBeyondTheLargestRuleAreRecordedUsingActualDimensions() {
void dimensionsOutsideConfiguredWeightingRulesUseActualDimensions() {
var weighted = service.load(10001L).resolve(
new BigDecimal("60.001"), new BigDecimal("50"), new BigDecimal("50"));
@@ -64,8 +64,7 @@ class CustomerDimensionWeightingServiceTest {
assertThat(weighted.outerLengthCm()).isEqualByComparingTo("60.001");
assertThat(weighted.outerWidthCm()).isEqualByComparingTo("50");
assertThat(weighted.outerHeightCm()).isEqualByComparingTo("50");
assertThat(weighted.calculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_OVERSIZE_V1");
assertThat(weighted.warning()).contains("已按实测尺寸记录").contains("人工复核");
assertThat(weighted.calculationRuleVersion()).isEqualTo("ACTUAL_DIMENSION_V1");
}
@Test
@@ -1,7 +1,14 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.client.MeasurementImageClient;
import com.baiyue.firstleg.dto.CartonMeasurementCommand;
import com.baiyue.firstleg.dto.CartonMeasurementResult;
import com.baiyue.firstleg.dto.MojiaMeasurementDtos.UploadResponse;
import com.baiyue.firstleg.entity.MojiaMeasurementReceiptEntity;
import com.baiyue.firstleg.repository.MojiaMeasurementReceiptRepository;
import com.baiyue.firstleg.security.AuthenticatedMeasurementApp;
import com.baiyue.firstleg.security.MeasurementRequestAuthenticator;
import com.baiyue.firstleg.security.MeasurementRequestHeaders;
@@ -9,6 +16,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@@ -26,8 +34,11 @@ class MojiaMeasurementOpenApiServiceTest {
void fixedAppKeyJsonIsConvertedToTheExistingCartonMeasurementFlow() throws Exception {
MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class);
CartonMeasurementService cartons = mock(CartonMeasurementService.class);
MeasurementImageClient images = mock(MeasurementImageClient.class);
MojiaMeasurementReceiptRepository receipts = mock(MojiaMeasurementReceiptRepository.class);
var service = new MojiaMeasurementOpenApiService(authenticator, cartons,
new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator());
new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(),
images, receipts);
MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null);
when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH,
headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp("mojia", 10001L, 90001L, Map.of()));
@@ -35,8 +46,9 @@ class MojiaMeasurementOpenApiServiceTest {
1L, 1, 2L, "RK-001#4-1", 1, new BigDecimal("60.1"), new BigDecimal("40.2"),
new BigDecimal("30.3"), new BigDecimal("12.5"), 3L, "MJ-01", LocalDateTime.now(), true));
CartonMeasurementResult result = service.receive(headers, json());
UploadResponse result = service.receive(headers, json());
assertThat(result.matched()).isTrue();
assertThat(result.boxCode()).isEqualTo("RK-001#4-1");
ArgumentCaptor<CartonMeasurementCommand> command = ArgumentCaptor.forClass(CartonMeasurementCommand.class);
verify(cartons).upsert(eq(10001L), eq(90001L), command.capture());
@@ -46,6 +58,40 @@ class MojiaMeasurementOpenApiServiceTest {
assertThat(command.getValue().image().getBytes()).containsExactly(0xff, 0xd8, 0xff, 0xd9);
}
@Test
void unmatchedSimulationIsAcceptedWithFieldsAndImageSavedForInvestigation() {
MeasurementRequestAuthenticator authenticator = mock(MeasurementRequestAuthenticator.class);
CartonMeasurementService cartons = mock(CartonMeasurementService.class);
MeasurementImageClient images = mock(MeasurementImageClient.class);
MojiaMeasurementReceiptRepository receipts = mock(MojiaMeasurementReceiptRepository.class);
var service = new MojiaMeasurementOpenApiService(authenticator, cartons,
new ObjectMapper().findAndRegisterModules(), Validation.buildDefaultValidatorFactory().getValidator(),
images, receipts);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
MeasurementRequestHeaders headers = new MeasurementRequestHeaders("mojia", null, null, null, null);
when(authenticator.authenticate("POST", MojiaMeasurementOpenApiService.CALLBACK_PATH,
headers, json(), false)).thenReturn(new AuthenticatedMeasurementApp("mojia", 10001L, 90001L, Map.of()));
when(cartons.upsert(eq(10001L), eq(90001L), any()))
.thenThrow(new BizException(ResultCode.NOT_FOUND, "箱号不存在或不属于当前客户"));
when(images.uploadReceipt(eq("internal-token"), eq(90001L), eq(10001L), any(Long.TYPE), any()))
.thenReturn(ApiResponse.success(new MeasurementImageClient.UploadResult(
3L, "mojia.jpg", "image/jpeg", 4L, "NOT_REQUIRED", null, null, null, null)));
UploadResponse result = service.receive(headers, json());
assertThat(result.accepted()).isTrue();
assertThat(result.matched()).isFalse();
assertThat(result.receiptId()).isNotNull();
assertThat(result.imageFileId()).isEqualTo(3L);
ArgumentCaptor<MojiaMeasurementReceiptEntity> receipt =
ArgumentCaptor.forClass(MojiaMeasurementReceiptEntity.class);
verify(receipts).insert(receipt.capture());
assertThat(receipt.getValue().getBoxCode()).isEqualTo("RK-001#4-1");
assertThat(receipt.getValue().getLengthCm()).isEqualByComparingTo("60.1");
assertThat(receipt.getValue().getImageFileId()).isEqualTo(3L);
assertThat(receipt.getValue().getStatus()).isEqualTo("UNMATCHED");
}
private String json() {
return """
{"boxCode":"RK-001#4-1","length":60.1,"width":40.2,"height":30.3,
@@ -17,7 +17,7 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_PRICING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("FEE_CONFIRMING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDING")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("WAIT_SPLIT")).isEqualTo("WAIT_SPLIT");
assertThat(UnifiedInboundQueryService.mainStatus("INBOUNDED")).isEqualTo("INBOUNDED");
assertThat(UnifiedInboundQueryService.mainStatus("LOADING")).isEqualTo("LOADING");
assertThat(UnifiedInboundQueryService.mainStatus("LOADED")).isEqualTo("LOADED");
@@ -37,12 +37,34 @@ class UnifiedInboundQueryServiceTest {
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDING", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "INBOUNDED", "CONFIRMED")).isNull();
assertThat(UnifiedInboundQueryService.nextAction("INBOUNDED", "WAIT_SPLIT", null))
.isEqualTo("SMART_SPLIT");
.isNull();
assertThat(UnifiedInboundQueryService.nextAction("WAIT_SPLIT", "WAIT_SPLIT", null)).isNull();
assertThat(UnifiedInboundQueryService.nextAction("LOADED", "LOADED", null))
.isEqualTo("COMPLETE_DELIVERY");
assertThat(UnifiedInboundQueryService.nextAction("DELIVERED", "DELIVERED", null)).isNull();
}
@Test
void waitSplitFilterRemainsSeparateFromInbounded() throws Exception {
var method = UnifiedInboundQueryService.class.getDeclaredMethod("internalStatuses", String.class);
method.setAccessible(true);
assertThat(method.invoke(new UnifiedInboundQueryService(), "WAIT_SPLIT"))
.isEqualTo(java.util.List.of("WAIT_SPLIT"));
assertThat(method.invoke(new UnifiedInboundQueryService(), "INBOUNDED"))
.isEqualTo(java.util.List.of("WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING", "INBOUNDED"));
}
@Test
void phaseTwoMeasurementStatusesDoNotRequireUndeployedPhaseThreeTables() {
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("WAIT_MEASURE")).isFalse();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("INBOUNDED")).isFalse();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("WAIT_SPLIT")).isFalse();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("LOADING")).isTrue();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("LOADED")).isTrue();
assertThat(UnifiedInboundQueryService.requiresPhaseThreeSummary("DELIVERED")).isTrue();
}
@Test
void rejectsUnknownReviewStatusBeforeRepositoryQuery() {
UnifiedInboundQuery query = new UnifiedInboundQuery(
+13 -29
View File
@@ -1,36 +1,20 @@
# Gateway 模块指引
## 职责边界
## 职责与边界
- 统一承接前端流量,负责服务路由、CORS、JWT/Redis 会话校验、商户端与平台端访问域隔离及 Swagger 聚合。
- 网关完成入口认证,但业务服务仍必须执行客户归属、数据范围和权限校验;业务编排不得写入网关。Claim 中的 `tenant_id > 0` 当前是客户隔离兼容字段,`tenant_id=0` 是平台技术作用域,不得据此新增租户切换或真实租户访问域。
- 负责路由、CORS、JWT/Redis 会话校验、平台/客户访问域隔离、请求标识和 Swagger 聚合。
- 只做入口认证,不做业务编排;下游服务仍必须校验客户归属、数据范围和权限。
## 快速索引
## 模块入口与验证
| 功能 | 入口 |
|---|---|
| 应用入口 | `src/main/java/com/baiyue/gateway/BaiyueGatewayApplication.java` |
| Security WebFilterChain | `config/GatewaySecurityConfig.java` |
| 商户/平台访问域 | `config/AppScopeAuthorizationManager.java` |
| JWT 与安全配置项 | `config/GatewaySecurityProperties.java` |
| Redis 会话过滤 | `config/RedisTokenSessionWebFilter.java` |
| CORS | `config/GatewayCorsConfiguration.java`、`GatewayCorsProperties.java` |
| 请求标识 | `filter/CorrelationIdWebFilter.java`;包括预检和安全失败响应的 `X-Request-Id` |
| 可信身份头清理/注入 | `filter/TrustedIdentityHeaderFilter.java` |
| 本地路由 | `src/main/resources/application.yml` |
| 国内环境与 Swagger 聚合 | `src/main/resources/application-cn.yml`、`../config/nacos/{local,cn}/gateway.yml` |
| 访问域与 CORS 测试 | `src/test/java/com/baiyue/gateway/config/` |
- 应用:`BaiyueGatewayApplication.java`
- Security/访问域:`config/GatewaySecurityConfig.java`、`AppScopeAuthorizationManager.java`
- 会话/CORS/请求头:`config/RedisTokenSessionWebFilter.java`、`GatewayCorsConfiguration.java`、`filter/TrustedIdentityHeaderFilter.java`、`CorrelationIdWebFilter.java`
- 路由配置:`src/main/resources/application*.yml`、`../config/nacos/{local,cn}/gateway.yml`
- 测试:`src/test/java/com/baiyue/gateway/`;`mvn -pl gateway -am test`
## 修改与验证
## 修改约束与分期
- 新增服务路由时同步检查本地与 Nacos 配置、访问域、公开路径和 Swagger 聚合;前端只访问网关。
- 禁止信任客户端自行传入的身份头;Token Claim、Redis Key 变化要与 `auth/`、`common-security/` 同步。
- 普通业务响应由下游服务返回 HTTP 200 + body code;Gateway 自身的认证/会话失败仍保留真实 401/403,不能为了统一响应改成 HTTP 200。
- CORS 生产来源必须为明确 HTTPS 域名,不能放宽为 `*`。
- 从父目录运行 `mvn -pl gateway -am test`。
## 分期快速索引
- 一期:承载 Admin/Client 登录后的应用域、JWT 会话、客户隔离和 CORS;先读 `../../docs/一期/README.md` 与一期闭环设计,网关只做入口认证,不替代 `auth`、`iam`、`order` 的业务授权。
- 二期:新增到仓、测量、计费和第三方测量联调路由时,先读 `../../docs/二期/README.md`、主流程和联调报告;保持 `tenant_id>0` 客户隔离、`tenant_id=0` 平台公共资源及内部服务令牌边界,不把内部测量接口暴露到外部 Gateway。
- 三期:当前 NO-GO;不得提前开放分柜、订舱、PDA、装柜、报关或港务路由。待 `../../docs/三期/三期需求确认表.md` 的 P3 阻断项解除并锁定契约后,再同步公开路径、App Scope、权限码和 Swagger 聚合。
- 新增路由同步检查本地/Nacos、App Scope、CORS、公开路径和 Swagger;不得暴露内部令牌接口。
- 一期承载登录后 Admin/Client 入口;二期接入到仓/测量/计费公开路由但保持内部测量接口隔离;三期当前 NO-GO,不开放分柜、订舱、PDA、装柜、报关或港务路由。
- 三期先读 `../../docs/三期/三期需求确认表.md`,解除阻断并冻结契约后再改网关。
+24 -52
View File
@@ -1,61 +1,33 @@
# IAM Service 模块指引
## 职责边界
## 职责与边界
- 商户侧资质公开接口在 IAM 中只提供查询;真实文件上传统一由 File 服务的 multipart 接口完成,IAM 仅接受 File 服务携带内部令牌提交的资质元数据。
- 负责客户主档、成员、企业资料/审核、平台用户、角色权限、数据范围和授权快照。
- 账号凭据/Token 属于 `auth/`,文件内容属于 `file/`;资质登记只接收 File 的内部元数据调用。
- 当前仅“平台—客户”两级:`iam_tenant.id=0` 为平台作用域,`id>0` 为客户主档;不得新增真实租户层。
- 负责客户主档、用户、企业成员关系、企业资料与审核、角色、权限、数据范围、个人资料和授权快照;`iam_tenant`、`Tenant*` 及 `/tenants` 是历史兼容命名。
- 账号凭据与 Token 属于 `auth/`;资质文件内容属于 `file/`。IAM 只维护业务元数据和跨服务协作。
- 当前只实现“平台—客户”两级:`iam_tenant.id > 0` 的 `MERCHANT` 记录直接作为客户主档,相关 `tenant_id` 是客户隔离兼容字段;`iam_tenant.id = 0` 是平台账号、平台 RBAC 和平台公共资源技术作用域。不得由本模块提前引入真实租户、租户选择或租户级 RBAC。
## 模块入口
## 功能快速索引
| 目标 | 入口 |
|---|---|
| 客户资料/审核 | `controller/PlatformTenantController.java`、`service/TenantService.java` |
| 商户成员与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java` |
| 平台用户 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java` |
| 角色/权限/RBAC | `controller/RbacController.java`、`service/RbacService.java` |
| 授权快照/内部资格 | `controller/AuthorizationController.java`、`InternalAuthorizationController.java`、`service/AuthorizationSnapshotService.java` |
| File 资质元数据协作 | `controller/InternalTenantController.java`、`service/ProfileResourceService.java`、`client/AuthAccountClient.java` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
| SQL/OpenAPI | `src/main/resources/db/`、`src/main/resources/openapi/iam-v1.yaml` |
| 测试/验证 | `src/test/java/com/baiyue/iam/`;`mvn -pl iam -am test` |
| 功能 | Controller | Service / 数据入口 |
|---|---|---|
| 商户个人与企业资料 | `controller/MerchantTenantController.java` | `PersonalProfileService.java`、`TenantService.java` |
| 商户成员增改、启停、重置密码 | `MerchantTenantController.java` | `MemberService.java`,账号协作看 `client/AuthAccountClient.java` |
| 平台创建企业与入驻审核 | `controller/PlatformTenantController.java` | `MerchantOnboardingService.java`、`TenantService.java` |
| 平台企业客户分页 | `controller/PlatformTenantController.java` | `TenantService.pageMerchants`、`TenantRepository.pageMerchants`;仅返回 `tenant_type=MERCHANT` |
| 平台企业客户档案 | `controller/PlatformTenantController.java` | 一期维护销售负责人、账期、企业登记资料、联系微信、注册地址和内部备注;响应返回入驻与审批时间,不承载运单、财务或套餐统计 |
| 平台内部用户管理 | `controller/PlatformUserManagementController.java`(`/api/platform/v1/iam/users`) | `PlatformUserManagementService.java`、`PlatformUserManagementDtos.java`;服务端分页且强制 `tenant_id=0`,选项仅返回平台技术作用域和 PLATFORM 角色 |
| 平台内部用户 | `controller/ProfileResourceController.java` | `PlatformInternalUserService.java`;账号创建、显示名、启停和重置密码经 `AuthAccountClient` 编排 |
| 企业地址与资质元数据 | `controller/ProfileResourceController.java` | `ProfileResourceService.java`、`ProfileResourceRepository.java`;平台上传登记由 `InternalTenantController /platform-qualifications` 接收 File 内部调用 |
| 角色、权限、成员角色、角色授权 | `controller/RbacController.java` | `RbacService.java`、`RbacRepository.java` |
| Auth/Order 内部授权与资格查询 | `InternalAuthorizationController.java`、`InternalTenantController.java` | `AuthorizationSnapshotService.java`、`TenantGuard.java` |
| 当前登录成员有效权限 | `controller/AuthorizationController.java` | `AuthorizationSnapshotService.currentPermissions`;实时按有效成员、角色和权限资源计算,不依赖角色管理权限 |
| DTO | `src/main/java/com/baiyue/iam/dto/` | 按 Authorization、Member、Onboarding、Profile、Rbac、Tenant 分类 |
| 持久化 | `entity/`、`mapper/`、`repository/` | 客户主档、成员、角色、权限、地址、资质与审核模型;表字段保留 `tenant` 兼容命名 |
| 数据库 | `src/main/resources/db/` | 主结构 `iam.sql`,一期与 RBAC 增量脚本同目录 |
| OpenAPI 与说明 | `src/main/resources/openapi/iam-v1.yaml`、`README-IAM.md` | 修改接口契约时同步更新 |
| 测试 | `src/test/java/com/baiyue/iam/` | Controller 路由、授权快照、成员、资料、内部用户、平台资质和 RBAC 测试 |
## 修改约束
## 修改与验证
- `/api/platform/v1/iam/users` 是 Admin 后台内部用户入口,列表、创建、编辑、启停和重置密码都只能作用于 `tenant_id=0` 的平台账号;企业客户成员必须走商户成员边界。
- 内部用户列表使用后端分页,分页查询必须在仓储层显式携带 `tenant_id=0`;创建要求 `platform:user:create` 与 `platform:user:grant-role`,角色只能属于平台技术作用域且 `appCode=PLATFORM`。受保护的平台管理员不得被降权、停用或自停用。
- `/users` 不提供跨客户移动能力;企业成员的维护由商户成员接口负责,不得通过内部用户接口修改 Auth scope。
- 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。
- 平台代客户上传资质的公开 multipart 入口在 File 服务;IAM 的 `/internal/iam/platform-qualifications` 只登记元数据。当前仅 `PROFILE_REQUIRED/REJECTED` 可上传,同类型重复需显式 `replace=true`;替换时旧记录标记 REJECTED 并把旧 `documentUrl` 返回 File 清理。
- 平台资质登记在事务内通过 `ProfileResourceRepository.findQualificationsForUpdate` 锁定目标客户现有资质,避免并发上传或替换产生多个有效同类型记录;方法名和查询字段保留历史兼容,该查询不得改回普通无锁列表。
- 企业送审和审核通过只要求 `BUSINESS_LICENSE`、`LEGAL_PERSON_ID` 两类必填资质均为 `PENDING`;`FREIGHT_FORWARDER_CERTIFICATE` 与 `BANK_ACCOUNT_LICENSE` 为选填,已上传时仍随审核统一处理。审核由 `TenantService.review()` 同事务更新审核单、租户和所有 PENDING 资质,行锁负责并发防重。
- 平台客户列表使用 `platform:customer:list` 并脱敏电话、邮箱;完整详情、平台地址和平台资质只读查询统一使用独立的 `platform:customer:view`,不得复用 `platform:customer:review`。
- 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。
- 所有客户查询按 `tenant_id` 过滤;平台用户只能 `tenant_id=0`。必填资质审核通过才具备下单资格。
- 成员/账号跨 IAM/Auth 保持幂等补偿;资质跨 IAM/File 不保存文件内容。
- 权限资源 code/type 不可变;按钮隐藏不能替代后端权限校验。
- `tenant_id > 0` 是当前客户数据隔离核心;平台端与商户端访问域、角色和权限不可混用,`tenant_id=0` 只表示平台技术作用域。
- 按钮隐藏不能代替后端 permission code / permissionIds 校验;权限快照变化需同步检查 Auth 与 Gateway Claim 消费。
- `/api/{app}/v1/iam/authorization/permissions` 是登录初始化接口,只校验已认证应用域和有效成员身份;禁止增加 `role:list` 等业务权限前置条件。
- 平台企业客户分页仅查询商户租户;分页参数类型错误使用 body code `A0400`,Bean Validation 范围或状态约束错误使用 `A0422`,两者普通业务响应均为 HTTP 200。
- 成员账号操作跨 IAM/Auth,保持幂等与补偿;资质上传跨 IAM/File,IAM 不保存文件内容。
- 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。
- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台技术作用域下已启用的平台角色;空 `roleIds` 表示清空。
- 商户成员新增、编辑采用字段级授权:基础资料分别校验 `merchant:member:create`、`merchant:member:edit`;创建时非空角色、编辑时角色集合发生任何变化(含空数组清空)还必须校验 `merchant:member:grant-role`。详情和列表实时回显当前 `roleIds`,比较和写入均限定当前客户。
- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须营业执照和法人身份证两类必填资质均为 `APPROVED`。任一必填资质缺失、驳回、已替换或过期都必须由服务端阻止提交订单;选填资质不影响下单资格。
- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。
- 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。
- `deploy/scripts/initialize-test-databases.sh` 的 IAM 客户档案和 Warehouse 主数据脚本顺序必须与当前文件保持一致;测试环境正式增量仍以 `../deploy/sql/README.md` 指定的统一迁移为准。
- 从父目录运行 `mvn -pl iam -am test`,再按影响执行一期认证/IAM API 回归。
## 分期导航
## 分期快速索引
- 一期入口:`PlatformTenantController`(客户建档/审核)、`PlatformUserManagementController`/`RbacController`(平台用户与 RBAC)、`ProfileResourceController`(企业资料);先读 `../../docs/一期/README.md` → `../../docs/一期/一期开发进度表.md` → `../../docs/一期/一期头程订单闭环设计.md`。状态边界为客户审核与两类必填资质均 `APPROVED` 后才允许下单。
- 二期协作:IAM 不承载入库或计费状态,仅为 `firstleg/` 提供客户资格/授权快照;先读 `../../docs/二期/README.md` 与 `../../docs/二期/二期V2需求确认清单.md`,确认 `tenant_id>0` 客户隔离和 `tenant_id=0` 平台公共资源边界不变。
- 三期:当前 NO-GO;不提前新增出仓角色、租户层或 PDA 权限。待 `../../docs/三期/三期需求确认表.md` 的 P3 阻断项解除后,再同步权限资源、角色种子与 Auth Claim 消费者。
- 一期:客户建档审核、平台用户与 RBAC、企业资料;先读 `../../docs/一期/README.md`。
- 二期:仅提供客户资格/授权快照,不承载入库或计费状态;先读 `../../docs/二期/README.md`。
- 三期:NO-GO,不提前增加出仓角色/PDA 权限;先读 `../../docs/三期/三期需求确认表.md`。
@@ -3,25 +3,107 @@
USE baiyue_iam;
SET NAMES utf8mb4;
DROP PROCEDURE IF EXISTS repair_location_permission_ids;
DELIMITER $$
CREATE PROCEDURE repair_location_permission_ids()
BEGIN
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE id IN (5103060, 5103061, 5103062, 5103063, 5103064)
AND permission_code <> CASE id
WHEN 5103060 THEN 'platform:location:list'
WHEN 5103061 THEN 'platform:location:create'
WHEN 5103062 THEN 'platform:location:edit'
WHEN 5103063 THEN 'platform:location:status'
WHEN 5103064 THEN 'platform:location:delete'
END
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '库位权限迁移失败:5103060-5103064 已被其他权限占用';
END IF;
IF EXISTS (
SELECT 1 FROM iam_permission
WHERE (id = 5103040 AND permission_code <> 'platform:download:list')
OR (id = 5103041 AND permission_code <> 'platform:download:all')
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '库位权限迁移失败:下载中心稳定 ID 被未知权限占用';
END IF;
DROP TEMPORARY TABLE IF EXISTS tmp_location_role_grant;
CREATE TEMPORARY TABLE tmp_location_role_grant (
role_id BIGINT UNSIGNED NOT NULL,
tenant_id BIGINT UNSIGNED NOT NULL,
permission_code VARCHAR(128) NOT NULL,
PRIMARY KEY (role_id, tenant_id, permission_code)
) ENGINE=MEMORY;
INSERT IGNORE INTO tmp_location_role_grant (role_id, tenant_id, permission_code)
SELECT role_permission.role_id, role_permission.tenant_id, permission.permission_code
FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id = role_permission.permission_id
WHERE permission.permission_code IN (
'platform:location:list', 'platform:location:create', 'platform:location:edit',
'platform:location:status', 'platform:location:delete'
);
DELETE role_permission
FROM iam_role_permission role_permission
JOIN iam_permission permission ON permission.id = role_permission.permission_id
WHERE permission.permission_code IN (
'platform:location:list', 'platform:location:create', 'platform:location:edit',
'platform:location:status', 'platform:location:delete'
);
DELETE FROM iam_permission
WHERE permission_code IN (
'platform:location:create', 'platform:location:edit',
'platform:location:status', 'platform:location:delete'
);
DELETE FROM iam_permission WHERE permission_code = 'platform:location:list';
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心',
'/downloads', NULL, 40, 'ACTIVE', 1, 0),
(5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务',
NULL, NULL, 10, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
http_method = VALUES(http_method), sort_no = VALUES(sort_no), status = 'ACTIVE',
built_in = 1, deleted = 0;
END$$
DELIMITER ;
CALL repair_location_permission_ids();
DROP PROCEDURE repair_location_permission_ids;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, http_method, sort_no, status, built_in, deleted)
VALUES
(5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:location:list', '库位管理',
(5103060, 0, 'PLATFORM', 'MENU', 5103000, 'platform:location:list', '库位管理',
'/master-data/locations', NULL, 40, 'ACTIVE', 1, 0),
(5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:create', '新增库位',
(5103061, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:create', '新增库位',
NULL, NULL, 10, 'ACTIVE', 1, 0),
(5103042, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:edit', '编辑库位',
(5103062, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:edit', '编辑库位',
NULL, NULL, 20, 'ACTIVE', 1, 0),
(5103043, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:status', '启停库位',
(5103063, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:status', '启停库位',
NULL, NULL, 30, 'ACTIVE', 1, 0),
(5103044, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:location:delete', '删除库位',
(5103064, 0, 'PLATFORM', 'BUTTON', 5103060, 'platform:location:delete', '删除库位',
NULL, NULL, 40, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_type = VALUES(permission_type), parent_id = VALUES(parent_id),
permission_name = VALUES(permission_name), route_path = VALUES(route_path),
sort_no = VALUES(sort_no), status = 'ACTIVE', built_in = 1, deleted = 0;
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT grant_row.role_id, permission.id, grant_row.tenant_id
FROM tmp_location_role_grant grant_row
JOIN iam_permission permission
ON permission.tenant_id = 0 AND permission.app_code = 'PLATFORM'
AND permission.permission_code = grant_row.permission_code;
-- 二期新增库位权限默认只授予平台系统管理员。
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000001, id, 0
@@ -32,3 +114,5 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM'
'platform:location:status', 'platform:location:delete'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
DROP TEMPORARY TABLE IF EXISTS tmp_location_role_grant;
+14 -38
View File
@@ -1,45 +1,21 @@
# Integration Service 模块指引
## 职责边界
## 职责与边界
- 集中承载百度 OCR、Amazon Logistics/SP-API 等第三方适配器和能力启停状态。
- 不保存业务主数据,不把第三方 DTO 泄漏为内部领域模型;真实外部调用默认关闭,密钥仅从安全配置注入。
- 二期入库重量 OCR 由 `file/` 调用本服务;Firstleg 只消费 File 返回的识别结果,不直接依赖供应商协议。
- 负责百度 OCR、Amazon Logistics/SP-API 等第三方适配和能力开关;不保存业务主数据。
- 二期重量 OCR 由 `file/` 调用;本服务只返回识别结果,不保存 `file_object`、入库单或重量确认记录。
## 快速索引
## 模块入口与验证
| 功能 | 入口 |
|---|---|
| 应用与配置扫描 | `src/main/java/com/baiyue/integration/IntegrationServiceApplication.java` |
| `baiyue.integration` 配置 | `config/IntegrationProperties.java` |
| 内部供应商状态 API | `controller/IntegrationProviderController.java` |
| 重量 OCR 内部 API | `controller/WeightOcrController.java`;目录批处理 `POST /internal/integrations/weight-ocr/batches`,单图识别 `POST /internal/integrations/weight-ocr/single` |
| 能力状态模型与服务 | `dto/ProviderStatus.java`、`service/ProviderStatusService.java` |
| 适配器抽象 | `provider/IntegrationProviderAdapter.java` |
| 百度 OCR 状态与客户端 | `provider/BaiduOcrAdapter.java`、`provider/ocr/BaiduMeterClient.java` |
| 重量 OCR 协议模型 | `dto/ocr/` |
| 重量 OCR 批处理 | `service/ocr/WeightOcrBatchService.java` |
| 图片裁剪与重量筛选 | `service/ocr/ImageCropper.java`、`service/ocr/WeightCandidateSelector.java` |
| OCR 文件访问边界 | `service/ocr/WeightOcrStoragePolicy.java` |
| 二期调用方 | `../file/src/main/java/com/baiyue/file/client/WeightOcrClient.java`、`../file/src/main/java/com/baiyue/file/service/MeasurementImageService.java`;File 负责图片持久化和业务归属,本服务只返回识别结果 |
| Amazon | `provider/AmazonLogisticsAdapter.java` |
| 内部服务令牌 | `security/InternalServiceTokenGuard.java` |
| 配置与 OCR 测试 | `src/test/java/com/baiyue/integration/config/IntegrationPropertiesTest.java`、`src/test/java/com/baiyue/integration/service/ocr/` |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/integration-service.yml` |
- 应用/配置:`IntegrationServiceApplication.java`、`config/IntegrationProperties.java`
- 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java`
- OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java`
- 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java`
- 安全:`security/InternalServiceTokenGuard.java`;配置 `application.yml`、`../config/nacos/{local,cn}/integration-service.yml`
- 测试:`src/test/java/com/baiyue/integration/`;`mvn -pl integration-service -am test`
## 修改与验证
## 修改约束与分期
- 第三方密钥、Token、请求原文和客户敏感数据不得写入源码或日志;配置应支持按供应商独立关闭。
- 适配器负责外部协议转换、超时、错误归一化和安全降级;业务编排留在调用方服务。
- 启用真实调用前补齐超时、重试边界、限流、幂等和可观测性,并避免在测试中访问真实供应商。
- OCR 目录批处理只能访问 `WEIGHT_OCR_INPUT_ROOT` 和 `WEIGHT_OCR_OUTPUT_ROOT` 内的路径,不得恢复任意服务器路径读取。
- OCR 接口只允许内部服务令牌调用;图片大小、解码像素数、单批数量和并发批次限制不得绕过。
- OCR 原始响应和第三方密钥不得写入应用日志,真实供应商调用必须保持默认关闭。
- 单图接口只接收 File 传入的受控图片内容,不负责保存 `file_object`、入库单或重量确认记录;响应 DTO 或错误语义变化时同步检查 File 的 `WeightOcrClient`、`MeasurementImageService` 和契约测试。
- 从父目录运行 `mvn -pl integration-service -am test`。
## 分期快速索引
- 一期:不承载一期订单主流程;仅提供可复用的第三方适配基础,不得把供应商 DTO 直接变成订单或客户领域模型。范围依据先读 `../../docs/一期/README.md`。
- 二期:当前主要消费者是 `file/` 的重量 OCR 链路;先读 `../../docs/二期/README.md`、主流程和第三方测量联调说明。真实 OCR 凭证未配置时只能验证失败后人工修正的降级链路,不能伪造识别成功。
- 三期:当前 NO-GO;不提前接入自动报关、船期、港务或其他外部业务接口。待 `../../docs/三期/三期需求确认表.md` 解除相关阻断项后,再为每个供应商单独确认契约、开关、超时、重试、幂等和审计边界。
- 密钥、原始响应和客户敏感数据不得写入日志;真实调用默认关闭,适配器负责超时、重试、限流、错误归一化。
- OCR 只能访问受控输入/输出目录,接口仅接受内部服务令牌;契约变更同步检查 File 的 `WeightOcrClient` 和测试。
- 一期仅保留通用适配基础;二期服务 File 重量 OCR,凭证缺失时走人工修正降级;三期当前 NO-GO,不提前接入报关、船期或港务接口。阶段文档:`../../docs/二期/README.md`、`../../docs/三期/三期需求确认表.md`。
+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(
@@ -0,0 +1,789 @@
{
"reportName": "后台管理测试环境 HTTP API 验收",
"marker": "ADMIN-API-20260808035005218",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T03:50:28.098Z",
"startedAt": "2026-08-08T03:50:05.218Z",
"success": false,
"fatal": null,
"summary": {
"total": 39,
"passed": 36,
"failed": 3
},
"modules": [
{
"name": "orders",
"startedAt": "2026-08-08T03:50:05.612Z",
"finishedAt": "2026-08-08T03:50:28.096Z",
"success": false,
"passed": 35,
"failed": 2,
"fatal": "订单审核后的入库单契约未部署,无法向 OCR/墨家模块交接"
},
{
"name": "inbound-measurement",
"startedAt": "2026-08-08T03:50:28.097Z",
"finishedAt": "2026-08-08T03:50:28.098Z",
"success": false,
"passed": 0,
"failed": 1,
"fatal": null
}
],
"scenarios": [
{
"name": "mojia-carton-measurement",
"module": "inbound-measurement",
"success": false,
"deploymentBlocked": true,
"passed": 0,
"failed": 1
}
],
"deploymentBlockers": [
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "审核后生成隔离入库单",
"evidence": "20秒内未返回 inboundId",
"code": "S0503",
"requestId": "9c42b42f-b2a6-47c8-ac8e-94f0c10b57fd",
"traceId": "4fca2c8c698548088d3a733bebdc14d2"
},
{
"module": "inbound-measurement",
"scenario": "mojia-carton-measurement",
"name": "取得隔离的待测量入库单",
"evidence": "orders 模块未提供 marker=ADMIN-API-20260808035005218 的 inboundId/tenantId;为避免修改随机历史订单,本模块停止写入",
"code": null,
"requestId": null,
"traceId": null
}
],
"createdResources": {
"orders": {
"marker": "ADMIN-API-20260808035005218",
"tenantId": "10001",
"orderId": "2085936562680954881",
"productImageFileId": "2085936561020010498",
"boxTypes": [
"SMALL",
"MEDIUM",
"LARGE",
"EXTRA_LARGE"
],
"inboundId": null,
"orderNo": "FL2085936562680954881"
}
},
"excluded": [
{
"scope": "dashboard",
"reason": "首页为静态演示数据,不调用后端 API"
},
{
"scope": "hidden-prototypes",
"reason": "隐藏原型页面尚无已确认后端契约"
}
],
"securityNote": "报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。",
"steps": [
{
"module": "authentication",
"scenario": null,
"name": "平台管理员登录",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/auth/login"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d90d50a8-a2cf-4e01-9828-73a73e1dd88c, fbdfbf4b-c834-4a58-ace2-2ae2d2566435",
"traceId": "f1123e15f2e1493da43e07233d5fef72",
"evidence": "",
"checkedAt": "2026-08-08T03:50:05.611Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 测试账号登录",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/auth/login"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "07196faa-210b-44c5-9f02-beaa7448d364, 9a17a6c1-cbcd-477e-a6a7-1caa911610ee",
"traceId": "2e2384afa34546a9a821f948e8d2af97",
"evidence": "username=merchant_admin",
"checkedAt": "2026-08-08T03:50:05.968Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询当前企业资料",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/iam/tenant/profile"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "27249d70-e97a-4266-98e7-926bb26792c0, c780bd14-921b-4d99-a9d6-a43e11b5d333",
"traceId": "597f8cfacf3d48bf9ee25f26cb82396a",
"evidence": "",
"checkedAt": "2026-08-08T03:50:06.013Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 测试企业已审核且客户 ID 有效",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/iam/tenant/profile"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "27249d70-e97a-4266-98e7-926bb26792c0, c780bd14-921b-4d99-a9d6-a43e11b5d333",
"traceId": "597f8cfacf3d48bf9ee25f26cb82396a",
"evidence": "tenantId=10001, status=ACTIVE",
"checkedAt": "2026-08-08T03:50:06.014Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Admin 查询客户固定箱型",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "537fbceb-3e93-446e-aa34-c27e5229f494, 222ed70a-a2d5-439c-9436-43843e3ea23a",
"traceId": "0f5d39f8007e42e7911f814c8ce2f8b4",
"evidence": "tenantId=10001",
"checkedAt": "2026-08-08T03:50:06.062Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "固定四箱型顺序、内径及每边加2cm正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/customer-dimension-config?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "537fbceb-3e93-446e-aa34-c27e5229f494, 222ed70a-a2d5-439c-9436-43843e3ea23a",
"traceId": "0f5d39f8007e42e7911f814c8ce2f8b4",
"evidence": "types=SMALL,MEDIUM,LARGE,EXTRA_LARGE",
"checkedAt": "2026-08-08T03:50:06.062Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询国内收货仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "0db894da-96c5-4c47-a723-dee5ddc0142c, 0be3bb58-41b4-49e6-af70-304db93ff26b",
"traceId": "1304337dc55e4cacacbf0d07cd69fbeb",
"evidence": "",
"checkedAt": "2026-08-08T03:50:06.107Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在可用国内收货仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "0db894da-96c5-4c47-a723-dee5ddc0142c, 0be3bb58-41b4-49e6-af70-304db93ff26b",
"traceId": "1304337dc55e4cacacbf0d07cd69fbeb",
"evidence": "count=2",
"checkedAt": "2026-08-08T03:50:06.107Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询 FBA 目的仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "46ea09fe-e12f-4a41-901d-ee8b004a665f, cfd6337a-d352-4ab0-80fa-6a1df1465f58",
"traceId": "baa18a3ff0964fb68fac27f559981e4f",
"evidence": "",
"checkedAt": "2026-08-08T03:50:06.182Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在可用 FBA 目的仓",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "46ea09fe-e12f-4a41-901d-ee8b004a665f, cfd6337a-d352-4ab0-80fa-6a1df1465f58",
"traceId": "baa18a3ff0964fb68fac27f559981e4f",
"evidence": "count=200",
"checkedAt": "2026-08-08T03:50:06.182Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询可用计费渠道",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=193000000000200001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "843b4aff-d936-4c0a-b7f3-49b30fe05660, f5bf7aa2-0ac5-4552-8139-971624e6863c",
"traceId": "b239dd1d611849998980c4f439ec46b9",
"evidence": "",
"checkedAt": "2026-08-08T03:50:06.249Z"
},
{
"module": "orders",
"scenario": null,
"name": "存在带计价税制的可用渠道",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=193000000000200001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "843b4aff-d936-4c0a-b7f3-49b30fe05660, f5bf7aa2-0ac5-4552-8139-971624e6863c",
"traceId": "b239dd1d611849998980c4f439ec46b9",
"evidence": "count=15",
"checkedAt": "2026-08-08T03:50:06.249Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询发货客户候选",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/partners/candidates?type=SHIPPER"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "fa587f92-f5a2-4e95-a8ee-57a71f4afde4, e6096757-3e3a-4d9f-94ff-e0e033bc68c6",
"traceId": "b990276e9bdb47b1bc03a853b23e3554",
"evidence": "",
"checkedAt": "2026-08-08T03:50:06.294Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 上传产品图片",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/files/product-images"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "221a2a80-bc37-4805-a2c7-5a6068712e80, 327911c4-606c-4c2d-a3b1-02388624b4da",
"traceId": "c114a50cff3343b7968c473df7e8980d",
"evidence": "contentType=image/png",
"checkedAt": "2026-08-08T03:50:06.400Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "产品图片上传返回字符串文件 ID",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/files/product-images"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "221a2a80-bc37-4805-a2c7-5a6068712e80, 327911c4-606c-4c2d-a3b1-02388624b4da",
"traceId": "c114a50cff3343b7968c473df7e8980d",
"evidence": "fileIdType=string, contentType=image/png",
"checkedAt": "2026-08-08T03:50:06.400Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 可下载本轮产品图片",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/files/product-images/2085936561020010498"
},
"http": 200,
"code": null,
"message": null,
"requestId": "e6516ed1-b216-4739-9eec-ea6ffd1d1d0d, b1e7edf4-d203-4b5e-aa7e-d647d6f92717",
"traceId": "0a04414be98842a5826876004a70be72",
"evidence": "bytes=68, contentType=image/png",
"checkedAt": "2026-08-08T03:50:06.445Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "Client 创建四箱型隔离订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "912f711b-9451-4c0e-ad27-be7e4c67c512, 9a0a1558-91de-4c9a-818e-1645ac72573e",
"traceId": "fd77a1151aac44e4955caa8f4c5f3a8e",
"evidence": "marker=ADMIN-API-20260808035005218",
"checkedAt": "2026-08-08T03:50:06.842Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 创建订单幂等重放",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "058facb9-5179-4843-8c34-e1ea3cb1d5c6, ff4ebce7-1aa3-4656-b2ff-17de9fa5ee7d",
"traceId": "bc30f877e3b8453583b0750538eca947",
"evidence": "",
"checkedAt": "2026-08-08T03:50:07.041Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 创建幂等结果一致",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "058facb9-5179-4843-8c34-e1ea3cb1d5c6, ff4ebce7-1aa3-4656-b2ff-17de9fa5ee7d",
"traceId": "bc30f877e3b8453583b0750538eca947",
"evidence": "orderId=2085936562680954881, replayedId=2085936562680954881",
"checkedAt": "2026-08-08T03:50:07.041Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 查询隔离订单草稿",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085936562680954881"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "1480d6a8-1619-483a-abd6-acc42c37166f, 5bc84fc9-7764-4152-91e8-bae9d96adb3c",
"traceId": "d448c7f1d9ee420cbda4c4308fd2d7da",
"evidence": "",
"checkedAt": "2026-08-08T03:50:07.100Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 草稿身份、marker 和状态正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085936562680954881"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "1480d6a8-1619-483a-abd6-acc42c37166f, 5bc84fc9-7764-4152-91e8-bae9d96adb3c",
"traceId": "d448c7f1d9ee420cbda4c4308fd2d7da",
"evidence": "orderNo=FL2085936562680954881, status=DRAFT",
"checkedAt": "2026-08-08T03:50:07.100Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "四箱型产品字段及图片契约完整回显",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085936562680954881"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "1480d6a8-1619-483a-abd6-acc42c37166f, 5bc84fc9-7764-4152-91e8-bae9d96adb3c",
"traceId": "d448c7f1d9ee420cbda4c4308fd2d7da",
"evidence": "cargoes=4, imageFileId=2085936561020010498",
"checkedAt": "2026-08-08T03:50:07.100Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 提交隔离订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/merchant/v1/orders/first-leg/2085936562680954881/submit"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "d8f74e7e-6632-49e3-947d-959c0e45e2bd, 8f2a5a96-e3d7-4ff7-8847-d0fb62ed6f07",
"traceId": "d76d60c97d344c1794256fb56f00d309",
"evidence": "orderId=2085936562680954881",
"checkedAt": "2026-08-08T03:50:07.261Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 回查已提交订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085936562680954881"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "7a6c86b6-0262-467a-8781-b93c8a229838, 76b3610c-af75-46a0-99c2-e42b17931c12",
"traceId": "d9d0b1c8aaff466cb5f71bcc7ae8d09e",
"evidence": "",
"checkedAt": "2026-08-08T03:50:07.305Z"
},
{
"module": "orders",
"scenario": null,
"name": "Client 订单已进入待审核状态",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/merchant/v1/orders/first-leg/2085936562680954881"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "7a6c86b6-0262-467a-8781-b93c8a229838, 76b3610c-af75-46a0-99c2-e42b17931c12",
"traceId": "d9d0b1c8aaff466cb5f71bcc7ae8d09e",
"evidence": "status=SUBMITTED",
"checkedAt": "2026-08-08T03:50:07.305Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询统一订单列表",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders?page=1&size=100&tenantId=10001&orderNo=FL2085936562680954881"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "56b3a002-9c16-493b-8b42-9fc2af179612, 50a0f405-787b-4e78-8b6b-fc5cfa0f3710",
"traceId": "95dfa6470dd14d93b9beea6dbeb87335",
"evidence": "",
"checkedAt": "2026-08-08T03:50:07.367Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 统一订单列表只核对本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders?page=1&size=100&tenantId=10001&orderNo=FL2085936562680954881"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "56b3a002-9c16-493b-8b42-9fc2af179612, 50a0f405-787b-4e78-8b6b-fc5cfa0f3710",
"traceId": "95dfa6470dd14d93b9beea6dbeb87335",
"evidence": "orderId=2085936562680954881, found=true",
"checkedAt": "2026-08-08T03:50:07.367Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询统一订单详情",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085936562680954881?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "02defc6f-b79d-47cc-9ad0-da1a1a70ec09, e9c70d04-734f-45e4-8820-cfb6a18f3df7",
"traceId": "2ac7d41470a94142a5116f6ef1a007ca",
"evidence": "",
"checkedAt": "2026-08-08T03:50:07.420Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 统一订单详情身份与待审核状态正确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085936562680954881?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "02defc6f-b79d-47cc-9ad0-da1a1a70ec09, e9c70d04-734f-45e4-8820-cfb6a18f3df7",
"traceId": "2ac7d41470a94142a5116f6ef1a007ca",
"evidence": "mainStatus=PENDING_REVIEW, timeline=2",
"checkedAt": "2026-08-08T03:50:07.420Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询头程订单列表",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg?tenantId=10001&keyword=FL2085936562680954881&page=1&size=100"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "89c6c932-d1a7-4b71-b31d-3b152b3a568d, fbc6cafc-ba71-4e7d-a31b-d56379811587",
"traceId": "288219750d8a4ef6b36c1bbed94b30a6",
"evidence": "",
"checkedAt": "2026-08-08T03:50:07.465Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 头程订单列表只核对本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg?tenantId=10001&keyword=FL2085936562680954881&page=1&size=100"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "89c6c932-d1a7-4b71-b31d-3b152b3a568d, fbc6cafc-ba71-4e7d-a31b-d56379811587",
"traceId": "288219750d8a4ef6b36c1bbed94b30a6",
"evidence": "orderId=2085936562680954881, found=true",
"checkedAt": "2026-08-08T03:50:07.465Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 查询头程订单详情",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085936562680954881?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "14ba6f5a-d181-4670-95fb-81345d1b670e, eb3e1619-b79b-491a-9039-de7c4d650edf",
"traceId": "d1edd0c156c24624bff1da6e850c00a7",
"evidence": "",
"checkedAt": "2026-08-08T03:50:07.506Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 头程订单详情聚合结构完整",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085936562680954881?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "14ba6f5a-d181-4670-95fb-81345d1b670e, eb3e1619-b79b-491a-9039-de7c4d650edf",
"traceId": "d1edd0c156c24624bff1da6e850c00a7",
"evidence": "cargoes=4, history=2",
"checkedAt": "2026-08-08T03:50:07.506Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "四箱型产品字段及图片契约完整回显",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/first-leg/2085936562680954881?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "14ba6f5a-d181-4670-95fb-81345d1b670e, eb3e1619-b79b-491a-9039-de7c4d650edf",
"traceId": "d1edd0c156c24624bff1da6e850c00a7",
"evidence": "cargoes=4, imageFileId=2085936561020010498",
"checkedAt": "2026-08-08T03:50:07.506Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 为本轮订单添加内部备注",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/orders/first-leg/2085936562680954881/internal-notes?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "72083869-15b2-4f4c-9ba2-be418120f49a, 7b28af25-39bd-41fa-8afd-25d63b9a6167",
"traceId": "72e3c848780748119c9cbda2550f9c8f",
"evidence": "",
"checkedAt": "2026-08-08T03:50:07.562Z"
},
{
"module": "orders",
"scenario": null,
"name": "Admin 审核通过本轮订单",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "POST",
"path": "/api/platform/v1/orders/first-leg/2085936562680954881/approve?tenantId=10001"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "8880f156-7f2d-409e-8bce-cd7dd2b98508, 8bb22a3b-b025-442c-be35-a5b7b1cbbd6b",
"traceId": "ed96056d34ea4faebef85e7ba5ed71fa",
"evidence": "orderId=2085936562680954881",
"checkedAt": "2026-08-08T03:50:07.624Z"
},
{
"module": "orders",
"scenario": "isolated-order-chain",
"name": "审核后生成隔离入库单",
"passed": false,
"deploymentBlocker": true,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/2085936562680954881?tenantId=10001"
},
"http": 200,
"code": "S0503",
"message": "统一订单履约详情响应无效",
"requestId": "9c42b42f-b2a6-47c8-ac8e-94f0c10b57fd",
"traceId": "4fca2c8c698548088d3a733bebdc14d2",
"evidence": "20秒内未返回 inboundId",
"checkedAt": "2026-08-08T03:50:28.096Z"
},
{
"module": "orders",
"scenario": null,
"name": "模块执行",
"passed": false,
"deploymentBlocker": false,
"request": null,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": "订单审核后的入库单契约未部署,无法向 OCR/墨家模块交接",
"checkedAt": "2026-08-08T03:50:28.096Z"
},
{
"module": "inbound-measurement",
"scenario": "mojia-carton-measurement",
"name": "取得隔离的待测量入库单",
"passed": false,
"deploymentBlocker": true,
"request": null,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": "orders 模块未提供 marker=ADMIN-API-20260808035005218 的 inboundId/tenantId;为避免修改随机历史订单,本模块停止写入",
"checkedAt": "2026-08-08T03:50:28.097Z"
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,314 @@
{
"marker": "20260808033417-f5c3f23a",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T03:34:17.235Z",
"completedAt": "2026-08-08T03:34:18.437Z",
"success": false,
"fatal": null,
"testAccount": {
"username": "merchant_admin"
},
"expectedTerminalState": {
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
},
"resources": {
"tenantId": "10001",
"terminalInboundId": "2085909619958919170",
"terminalOrderId": "2085909608158265345",
"terminalInboundCode": "RK-2085909608158265345",
"pendingOrderId": "2085931050820239362",
"foreignOrderId": "2085665774056833025",
"measurementImageFileIds": [],
"weightImageFileIds": []
},
"steps": [
{
"module": "phase2-session",
"name": "client login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:17.238Z",
"durationMs": 375,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-245d0333-30bd-4254-b116-afbc6643da5e, client-phase2-245d0333-30bd-4254-b116-afbc6643da5e",
"traceId": "ddc9cbba28f74097b5441b7951653647",
"evidence": {
"username": "merchant_admin"
}
},
{
"module": "phase2-session",
"name": "load current enterprise profile",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:17.614Z",
"durationMs": 35,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-744f52c2-3b84-4cef-918d-06885b7ff68c, client-phase2-744f52c2-3b84-4cef-918d-06885b7ff68c",
"traceId": "52e830aca6f146eab0e21e2e24fcf796",
"evidence": {
"tenantId": "10001",
"tenantName": "一期演示企业",
"status": "ACTIVE"
}
},
{
"module": "phase2-session",
"name": "client has order view permission",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:17.649Z",
"durationMs": 30,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-20c1259a-50a9-47ec-ade6-b4d230d20dd1, client-phase2-20c1259a-50a9-47ec-ade6-b4d230d20dd1",
"traceId": "24ba6612aa8049a1aa969c97242fb48a",
"evidence": {
"permissionCount": 27
}
},
{
"module": "phase2-fixture",
"name": "platform fixture account login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:17.679Z",
"durationMs": 360,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-89e2bb91-a51e-46af-a404-8b4182d14152, client-phase2-89e2bb91-a51e-46af-a404-8b4182d14152",
"traceId": "d33157a789db4cc0adc9725129bb7c3a",
"evidence": {
"username": "platform_admin"
}
},
{
"module": "phase2-fixture",
"name": "discover current enterprise inbound fixtures",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:18.040Z",
"durationMs": 53,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-a1536a77-68d2-4990-855b-78bff33c644b, client-phase2-a1536a77-68d2-4990-855b-78bff33c644b",
"traceId": "d6f1ab9754dc425896d34f70cf27bf0d",
"evidence": {
"total": "23",
"statusCounts": {
"WAIT_MEASURE|NOT_SUBMITTED|UNPRICED": 10,
"MEASURING|NOT_SUBMITTED|UNPRICED": 9,
"WAIT_SPLIT|APPROVED|UNPRICED": 1,
"WAIT_SPLIT|APPROVED|CONFIRMED": 2,
"WAIT_SPLIT|APPROVED|CALCULATED": 1
}
}
},
{
"module": "phase2-fixture",
"name": "terminal state filters find selected inbound",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:34:18.093Z",
"durationMs": 39,
"http": 200,
"code": "A0400",
"message": "入库流程状态不合法",
"requestId": "client-phase2-6102ea3e-d5c4-407e-aaa1-afe3feead391, client-phase2-6102ea3e-d5c4-407e-aaa1-afe3feead391",
"traceId": "825eb752d0754840a4616d3d3a103d42",
"evidence": {
"inboundId": "2085909619958919170"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0400\""
}
},
{
"module": "phase2-fixture",
"name": "load platform terminal inbound reference detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:18.132Z",
"durationMs": 31,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-62fa2d6d-6e6f-4982-a70d-aa43b5b1da18, client-phase2-62fa2d6d-6e6f-4982-a70d-aa43b5b1da18",
"traceId": "40d8259bc4b242f18cb762a92a9f2e9e",
"evidence": {
"inboundId": "2085909619958919170",
"orderId": "2085909608158265345",
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
}
},
{
"module": "phase2-fixture",
"name": "discover foreign terminal fixture for isolation",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:18.163Z",
"durationMs": 75,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-3a656c52-bad7-4b3a-af9b-3b8a5db94fe4, client-phase2-3a656c52-bad7-4b3a-af9b-3b8a5db94fe4",
"traceId": "3037bad5954349f48d6eefeeb48f7b96",
"evidence": {
"total": "263"
}
},
{
"module": "phase2-client-visibility",
"name": "unfinished inbound billing remains unavailable",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:18.238Z",
"durationMs": 33,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-ffaa2157-1048-4b0e-b11e-5fd09e831d57, client-phase2-ffaa2157-1048-4b0e-b11e-5fd09e831d57",
"traceId": "a5c214bfa0124760892739d44fec9a11",
"evidence": {
"orderId": "2085931050820239362",
"processStatus": "WAIT_MEASURE",
"feeStatus": "UNPRICED",
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-visibility",
"name": "unknown order inbound billing is rejected",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:18.271Z",
"durationMs": 28,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-bb5cdb61-7ccc-4a7e-b343-1423b999dd53, client-phase2-bb5cdb61-7ccc-4a7e-b343-1423b999dd53",
"traceId": "9830428eb7a741e0a39bc95e198ad690",
"evidence": {
"orderId": 1,
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-billing",
"name": "terminal inbound billing is visible to client",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:34:18.299Z",
"durationMs": 23,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-13e0c5f5-200f-4cb2-a3b8-dd60ad889a4c, client-phase2-13e0c5f5-200f-4cb2-a3b8-dd60ad889a4c",
"traceId": "cf7c4553a83c46c88282917676f37e4a",
"evidence": {
"rejectionCode": "A0404"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0404\""
}
},
{
"module": "phase2-client-billing",
"name": "terminal billing aggregate contract",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:34:18.322Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-images",
"name": "published measurement and weight image access",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:34:18.322Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return image references",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-unified-orders",
"name": "unified order list exposes phase-two terminal state",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:18.322Z",
"durationMs": 40,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-c30d219b-a2c1-470b-be37-8e775961243c, client-phase2-c30d219b-a2c1-470b-be37-8e775961243c",
"traceId": "16601c532a2e401aacd62211f9cb1009",
"evidence": {
"orderId": "2085909608158265345",
"mainStatus": "INBOUNDED",
"inboundId": "2085909619958919170",
"measurementReviewStatus": "APPROVED",
"actualGrossWeightKg": 1383,
"actualVolumeCbm": 0.1895
}
},
{
"module": "phase2-unified-orders",
"name": "unified order detail exposes phase-two summary and timeline",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:18.362Z",
"durationMs": 44,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-4389dfd4-ee1d-4d50-938e-cfab27dca8dc, client-phase2-4389dfd4-ee1d-4d50-938e-cfab27dca8dc",
"traceId": "d4801cd67ee3401e9340b8aec08411ad",
"evidence": {
"orderId": "2085909608158265345",
"mainStatus": "INBOUNDED",
"timelineCount": 5
}
},
{
"module": "phase2-isolation",
"name": "cross-enterprise terminal billing is rejected",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:34:18.406Z",
"durationMs": 31,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-c3fe5422-7576-4137-b932-4cc0fc15dc35, client-phase2-c3fe5422-7576-4137-b932-4cc0fc15dc35",
"traceId": "d00036079efd405ba0a27c5edb92cb10",
"evidence": {
"orderId": "2085665774056833025",
"tenantId": "2085308606829346818",
"rejectionCode": "A0404"
}
}
]
}
@@ -0,0 +1,278 @@
{
"marker": "20260808033753-4c2c604e",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T03:37:53.736Z",
"completedAt": "2026-08-08T03:37:58.175Z",
"success": false,
"fatal": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"undefined\""
},
"testAccount": {
"username": "merchant_admin"
},
"expectedTerminalState": {
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
},
"resources": {
"tenantId": "10001",
"terminalInboundId": "2085909619958919170",
"terminalOrderId": "2085909608158265345",
"terminalInboundCode": "RK-2085909608158265345",
"pendingOrderId": "2085931050820239362",
"foreignOrderId": "2085665774056833025",
"measurementImageFileIds": [],
"weightImageFileIds": []
},
"steps": [
{
"module": "phase2-session",
"name": "client login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:37:53.739Z",
"durationMs": 1936,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-ead0bc8b-a25e-4772-8d01-3201a4d79421, client-phase2-ead0bc8b-a25e-4772-8d01-3201a4d79421",
"traceId": "17acc285d527403b87cfe697d19f5866",
"evidence": {
"username": "merchant_admin"
}
},
{
"module": "phase2-session",
"name": "load current enterprise profile",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:37:55.675Z",
"durationMs": 551,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-aff88882-3334-4ec0-b9d9-87593bbfef26, client-phase2-aff88882-3334-4ec0-b9d9-87593bbfef26",
"traceId": "3b45386aaf104c3fb72e71fd4be10a79",
"evidence": {
"tenantId": "10001",
"tenantName": "一期演示企业",
"status": "ACTIVE"
}
},
{
"module": "phase2-session",
"name": "client has order view permission",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:37:56.226Z",
"durationMs": 72,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-42dc4e17-71be-4bc4-8480-b941a6d51f3d, client-phase2-42dc4e17-71be-4bc4-8480-b941a6d51f3d",
"traceId": "4e41c8b35f114390b5e6cd49a25d9282",
"evidence": {
"permissionCount": 27
}
},
{
"module": "phase2-fixture",
"name": "platform fixture account login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:37:56.298Z",
"durationMs": 402,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-806fe614-4724-4f2e-8953-2cc67902216a, client-phase2-806fe614-4724-4f2e-8953-2cc67902216a",
"traceId": "6127f43d3d104a8998b4e8eab39f0b8f",
"evidence": {
"username": "platform_admin"
}
},
{
"module": "phase2-fixture",
"name": "discover current enterprise inbound fixtures",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:37:56.700Z",
"durationMs": 821,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-b8a6edf3-2b47-4e38-b417-523faaf00337, client-phase2-b8a6edf3-2b47-4e38-b417-523faaf00337",
"traceId": "d81142a6caaa415b983a517c872e6983",
"evidence": {
"total": "23",
"statusCounts": {
"WAIT_MEASURE|NOT_SUBMITTED|UNPRICED": 10,
"MEASURING|NOT_SUBMITTED|UNPRICED": 9,
"WAIT_SPLIT|APPROVED|UNPRICED": 1,
"WAIT_SPLIT|APPROVED|CONFIRMED": 2,
"WAIT_SPLIT|APPROVED|CALCULATED": 1
}
}
},
{
"module": "phase2-fixture",
"name": "terminal state filters find selected inbound",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:37:57.522Z",
"durationMs": 80,
"http": 200,
"code": "A0400",
"message": "入库流程状态不合法",
"requestId": "client-phase2-d5a2bf0c-dc1d-4a03-a8f6-c87cfa65fb1f, client-phase2-d5a2bf0c-dc1d-4a03-a8f6-c87cfa65fb1f",
"traceId": "7a865fee161546aeb2468e1e0d709031",
"evidence": {
"inboundId": "2085909619958919170"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0400\""
}
},
{
"module": "phase2-fixture",
"name": "load platform terminal inbound reference detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:37:57.602Z",
"durationMs": 99,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-94dc8387-1ddb-410d-bac9-59ec6442c5c3, client-phase2-94dc8387-1ddb-410d-bac9-59ec6442c5c3",
"traceId": "3277d55a79c344f28d66f6cefe3effe7",
"evidence": {
"inboundId": "2085909619958919170",
"orderId": "2085909608158265345",
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
}
},
{
"module": "phase2-fixture",
"name": "discover foreign terminal fixture for isolation",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:37:57.701Z",
"durationMs": 196,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-47c2feb0-8f59-485e-8825-ae7e4544276b, client-phase2-47c2feb0-8f59-485e-8825-ae7e4544276b",
"traceId": "1090649425ad4193bd279655fd51e411",
"evidence": {
"total": "263"
}
},
{
"module": "phase2-client-visibility",
"name": "unfinished inbound billing remains unavailable",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:37:57.897Z",
"durationMs": 56,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-d59134fc-9cc0-49ce-bed7-719cda77d291, client-phase2-d59134fc-9cc0-49ce-bed7-719cda77d291",
"traceId": "88c27c271d6b44ee83afb4aa5696172c",
"evidence": {
"orderId": "2085931050820239362",
"processStatus": "WAIT_MEASURE",
"feeStatus": "UNPRICED",
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-visibility",
"name": "unknown order inbound billing is rejected",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:37:57.953Z",
"durationMs": 37,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-0eed8aa8-d0f0-49f2-9c6c-b801514df9fa, client-phase2-0eed8aa8-d0f0-49f2-9c6c-b801514df9fa",
"traceId": "49dddb72e1414a29a7176c283843224b",
"evidence": {
"orderId": 1,
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-billing",
"name": "terminal inbound billing is visible to client",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:37:57.991Z",
"durationMs": 43,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-83ba9d6d-5eed-41eb-a6fa-da2c0af20687, client-phase2-83ba9d6d-5eed-41eb-a6fa-da2c0af20687",
"traceId": "0030dfc8dd9a48dd873fa852e80031dd",
"evidence": {
"rejectionCode": "A0404"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0404\""
}
},
{
"module": "phase2-client-billing",
"name": "terminal billing aggregate contract",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:37:58.035Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-images",
"name": "published measurement and weight image access",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:37:58.035Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return image references",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-unified-orders",
"name": "unified order list exposes phase-two terminal state",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:37:58.035Z",
"durationMs": 140,
"http": 503,
"code": null,
"message": null,
"requestId": "client-phase2-804cd78c-2820-44d2-b771-4a660eec6b94",
"traceId": null,
"evidence": {},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"undefined\""
}
}
]
}
@@ -0,0 +1,278 @@
{
"marker": "20260808033825-6adc1a41",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T03:38:25.859Z",
"completedAt": "2026-08-08T03:38:27.940Z",
"success": false,
"fatal": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"S0503\""
},
"testAccount": {
"username": "merchant_admin"
},
"expectedTerminalState": {
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
},
"resources": {
"tenantId": "10001",
"terminalInboundId": "2085909619958919170",
"terminalOrderId": "2085909608158265345",
"terminalInboundCode": "RK-2085909608158265345",
"pendingOrderId": "2085931050820239362",
"foreignOrderId": "2085665774056833025",
"measurementImageFileIds": [],
"weightImageFileIds": []
},
"steps": [
{
"module": "phase2-session",
"name": "client login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:38:25.865Z",
"durationMs": 432,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-d3334363-d5d9-4907-919c-28307248307e, client-phase2-d3334363-d5d9-4907-919c-28307248307e",
"traceId": "3c78ca4b6c0e43aeb41d3d0389f6e5f6",
"evidence": {
"username": "merchant_admin"
}
},
{
"module": "phase2-session",
"name": "load current enterprise profile",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:38:26.297Z",
"durationMs": 47,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-ac9deddb-9f6c-49ae-be61-c9936a4f1070, client-phase2-ac9deddb-9f6c-49ae-be61-c9936a4f1070",
"traceId": "0d120da189e94971874c68074cdef7fb",
"evidence": {
"tenantId": "10001",
"tenantName": "一期演示企业",
"status": "ACTIVE"
}
},
{
"module": "phase2-session",
"name": "client has order view permission",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:38:26.344Z",
"durationMs": 56,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-700ca15b-73b6-4ac4-8ca1-89e0e7afe218, client-phase2-700ca15b-73b6-4ac4-8ca1-89e0e7afe218",
"traceId": "fe890d9d115f4393afc1e1f6b4ee594c",
"evidence": {
"permissionCount": 27
}
},
{
"module": "phase2-fixture",
"name": "platform fixture account login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:38:26.400Z",
"durationMs": 344,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-172bd473-1583-4ec9-8647-e1e5ddc56d96, client-phase2-172bd473-1583-4ec9-8647-e1e5ddc56d96",
"traceId": "575d1818c003430ca3f39ef987e981f8",
"evidence": {
"username": "platform_admin"
}
},
{
"module": "phase2-fixture",
"name": "discover current enterprise inbound fixtures",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:38:26.745Z",
"durationMs": 64,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-9219ca39-e737-4b55-8700-e122b057de2b, client-phase2-9219ca39-e737-4b55-8700-e122b057de2b",
"traceId": "2479ec898fb94ca2b511c52144a168ab",
"evidence": {
"total": "23",
"statusCounts": {
"WAIT_MEASURE|NOT_SUBMITTED|UNPRICED": 10,
"MEASURING|NOT_SUBMITTED|UNPRICED": 9,
"WAIT_SPLIT|APPROVED|UNPRICED": 1,
"WAIT_SPLIT|APPROVED|CONFIRMED": 2,
"WAIT_SPLIT|APPROVED|CALCULATED": 1
}
}
},
{
"module": "phase2-fixture",
"name": "terminal state filters find selected inbound",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:38:26.810Z",
"durationMs": 47,
"http": 200,
"code": "A0400",
"message": "入库流程状态不合法",
"requestId": "client-phase2-63f2fd09-eaed-43ef-87ac-29dabf683fb7, client-phase2-63f2fd09-eaed-43ef-87ac-29dabf683fb7",
"traceId": "faae41bec48b4c1383e379fdfd018f89",
"evidence": {
"inboundId": "2085909619958919170"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0400\""
}
},
{
"module": "phase2-fixture",
"name": "load platform terminal inbound reference detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:38:26.857Z",
"durationMs": 56,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-73a863ed-cc18-4b33-8748-25be155fc77e, client-phase2-73a863ed-cc18-4b33-8748-25be155fc77e",
"traceId": "3fb0d503d9364e32a4f0bbdfcbcd4744",
"evidence": {
"inboundId": "2085909619958919170",
"orderId": "2085909608158265345",
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
}
},
{
"module": "phase2-fixture",
"name": "discover foreign terminal fixture for isolation",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:38:26.913Z",
"durationMs": 110,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-045fb847-f9b4-42f9-985e-7e6d921bf4ac, client-phase2-045fb847-f9b4-42f9-985e-7e6d921bf4ac",
"traceId": "1fb2301e723f4899b21a4a5beedfe65f",
"evidence": {
"total": "263"
}
},
{
"module": "phase2-client-visibility",
"name": "unfinished inbound billing remains unavailable",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:38:27.023Z",
"durationMs": 37,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-254ead3c-40c5-40e8-a174-07cb95c87933, client-phase2-254ead3c-40c5-40e8-a174-07cb95c87933",
"traceId": "cb33df99765649e39ffe9ad7f4351382",
"evidence": {
"orderId": "2085931050820239362",
"processStatus": "WAIT_MEASURE",
"feeStatus": "UNPRICED",
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-visibility",
"name": "unknown order inbound billing is rejected",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:38:27.060Z",
"durationMs": 35,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-65a26abf-32d8-420e-8505-ccc618ac6b04, client-phase2-65a26abf-32d8-420e-8505-ccc618ac6b04",
"traceId": "b3d5bd870afc4ec19a579050cfe968d3",
"evidence": {
"orderId": 1,
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-billing",
"name": "terminal inbound billing is visible to client",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:38:27.095Z",
"durationMs": 48,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-19565704-f571-4807-a207-571824b158fd, client-phase2-19565704-f571-4807-a207-571824b158fd",
"traceId": "e6a3e225a7e74bbf978f6c521d711701",
"evidence": {
"rejectionCode": "A0404"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0404\""
}
},
{
"module": "phase2-client-billing",
"name": "terminal billing aggregate contract",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:38:27.143Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-images",
"name": "published measurement and weight image access",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:38:27.144Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return image references",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-unified-orders",
"name": "unified order list exposes phase-two terminal state",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:38:27.144Z",
"durationMs": 796,
"http": 200,
"code": "S0503",
"message": "统一订单履约摘要响应无效",
"requestId": "client-phase2-23c9d9df-4172-44c8-aff3-9de4137fecaa, client-phase2-23c9d9df-4172-44c8-aff3-9de4137fecaa",
"traceId": "023aa81784974b8e9d26f2c908e0ba36",
"evidence": {},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"S0503\""
}
}
]
}
@@ -0,0 +1,247 @@
{
"marker": "20260808035731-14b65b47",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T03:57:31.213Z",
"completedAt": "2026-08-08T03:57:32.148Z",
"success": false,
"fatal": {
"name": "Error",
"message": "SMALL configured volume: expected=0.019584 actual=0.02"
},
"testAccount": {
"username": "merchant_admin"
},
"expectedTerminalState": {
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
},
"resources": {
"tenantId": "10001",
"terminalInboundId": "2085909619958919170",
"terminalOrderId": "2085909608158265345",
"terminalInboundCode": "RK-2085909608158265345",
"terminalMeasurementId": null,
"pendingOrderId": "2085936562680954881",
"foreignOrderId": null,
"measurementImageFileIds": [],
"weightImageFileIds": []
},
"steps": [
{
"module": "phase2-session",
"name": "client login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:57:31.215Z",
"durationMs": 373,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-c028cbfe-f6c5-4953-be50-7f4930a95811, client-phase2-c028cbfe-f6c5-4953-be50-7f4930a95811",
"traceId": "3e7f6f7a3be448799bc6ae364e223cd5",
"evidence": {
"username": "merchant_admin"
}
},
{
"module": "phase2-session",
"name": "load current enterprise profile",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:57:31.589Z",
"durationMs": 36,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-8aae9084-6bc7-4554-bf67-d2020660d24c, client-phase2-8aae9084-6bc7-4554-bf67-d2020660d24c",
"traceId": "654ec508bc3641b88e23a7d75dc0fab7",
"evidence": {
"tenantId": "10001",
"tenantName": "一期演示企业",
"status": "ACTIVE"
}
},
{
"module": "phase2-session",
"name": "client has order view permission",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:57:31.625Z",
"durationMs": 28,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-6b401f8b-393e-4c88-b33e-4d0526dc345e, client-phase2-6b401f8b-393e-4c88-b33e-4d0526dc345e",
"traceId": "1f0c9e5000e84ca7ae21ca2c1c715299",
"evidence": {
"permissionCount": 27
}
},
{
"module": "phase2-fixture",
"name": "platform fixture account login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:57:31.653Z",
"durationMs": 342,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-8af55827-356f-44a6-8d6d-dcde12c3955f, client-phase2-8af55827-356f-44a6-8d6d-dcde12c3955f",
"traceId": "60212bed77e04aaa9d08cb30dbc8270f",
"evidence": {
"username": "platform_admin"
}
},
{
"module": "phase2-fixture",
"name": "discover current enterprise inbound fixtures",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:57:31.995Z",
"durationMs": 50,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-fc76ac49-ecf8-41f7-8068-e105bbfc5e7c, client-phase2-fc76ac49-ecf8-41f7-8068-e105bbfc5e7c",
"traceId": "d6101af65e6c4903a06dc38e7d8a9410",
"evidence": {
"total": "24",
"statusCounts": {
"WAIT_MEASURE|NOT_SUBMITTED|UNPRICED": 11,
"MEASURING|NOT_SUBMITTED|UNPRICED": 9,
"WAIT_SPLIT|APPROVED|UNPRICED": 1,
"WAIT_SPLIT|APPROVED|CONFIRMED": 2,
"WAIT_SPLIT|APPROVED|CALCULATED": 1
}
}
},
{
"module": "phase2-fixture",
"name": "terminal state filters find selected inbound",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:57:32.046Z",
"durationMs": 43,
"http": 200,
"code": "A0400",
"message": "入库流程状态不合法",
"requestId": "client-phase2-428e24a8-0884-4659-a0ad-18ba4901d18f, client-phase2-428e24a8-0884-4659-a0ad-18ba4901d18f",
"traceId": "35b069ca37e34f668707240be13284ba",
"evidence": {
"inboundId": "2085909619958919170"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0400\""
}
},
{
"module": "phase2-fixture",
"name": "load platform terminal inbound reference detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:57:32.089Z",
"durationMs": 31,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-2e651582-b2d9-4e2b-8c8c-4cda78e2749d, client-phase2-2e651582-b2d9-4e2b-8c8c-4cda78e2749d",
"traceId": "7da77db5695340449247f870edca8258",
"evidence": {
"inboundId": "2085909619958919170",
"orderId": "2085909608158265345",
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
}
},
{
"module": "phase2-measurement-fixture",
"name": "load four customer dimension weighting rules",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:57:32.120Z",
"durationMs": 28,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-3db586cc-2c39-4ed1-812a-dbad9673870a, client-phase2-3db586cc-2c39-4ed1-812a-dbad9673870a",
"traceId": "fa2b984c91f5434aafdd19e4026f42e4",
"evidence": {
"tenantId": "10001",
"rules": [
{
"boxType": "SMALL",
"inner": [
30,
20,
20
],
"edgeAdditionCm": 2,
"outer": [
34,
24,
24
],
"volumeCbm": 0.02,
"version": 0
},
{
"boxType": "MEDIUM",
"inner": [
40,
30,
30
],
"edgeAdditionCm": 2,
"outer": [
44,
34,
34
],
"volumeCbm": 0.051,
"version": 0
},
{
"boxType": "LARGE",
"inner": [
50,
40,
40
],
"edgeAdditionCm": 2,
"outer": [
54,
44,
44
],
"volumeCbm": 0.105,
"version": 0
},
{
"boxType": "EXTRA_LARGE",
"inner": [
60,
50,
50
],
"edgeAdditionCm": 2,
"outer": [
64,
54,
54
],
"volumeCbm": 0.187,
"version": 0
}
]
},
"error": {
"name": "Error",
"message": "SMALL configured volume: expected=0.019584 actual=0.02"
}
}
]
}
@@ -0,0 +1,652 @@
{
"marker": "20260808035804-95e5d27d",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T03:58:04.734Z",
"completedAt": "2026-08-08T03:58:05.935Z",
"success": false,
"fatal": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"S0503\""
},
"testAccount": {
"username": "merchant_admin"
},
"expectedTerminalState": {
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
},
"resources": {
"tenantId": "10001",
"terminalInboundId": "2085909619958919170",
"terminalOrderId": "2085909608158265345",
"terminalInboundCode": "RK-2085909608158265345",
"terminalMeasurementId": "2085909625084358657",
"pendingOrderId": "2085936562680954881",
"foreignOrderId": "2085665774056833025",
"measurementImageFileIds": [],
"weightImageFileIds": []
},
"steps": [
{
"module": "phase2-session",
"name": "client login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:04.736Z",
"durationMs": 382,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-a3667269-4147-419a-b7e9-d95950f1b358, client-phase2-a3667269-4147-419a-b7e9-d95950f1b358",
"traceId": "8d7e8199765e434ba32a1897cca68bb2",
"evidence": {
"username": "merchant_admin"
}
},
{
"module": "phase2-session",
"name": "load current enterprise profile",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.119Z",
"durationMs": 38,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-0b135874-ef10-4513-bd6a-c1b641b3cc20, client-phase2-0b135874-ef10-4513-bd6a-c1b641b3cc20",
"traceId": "eed3af0f8d544769ade7586a02138d2c",
"evidence": {
"tenantId": "10001",
"tenantName": "一期演示企业",
"status": "ACTIVE"
}
},
{
"module": "phase2-session",
"name": "client has order view permission",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.157Z",
"durationMs": 47,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-e79856c7-9a9d-41b2-bb6a-57ec182caced, client-phase2-e79856c7-9a9d-41b2-bb6a-57ec182caced",
"traceId": "e71371a37a944321bdf1ac46745163c5",
"evidence": {
"permissionCount": 27
}
},
{
"module": "phase2-fixture",
"name": "platform fixture account login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.204Z",
"durationMs": 340,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-a40f327b-c4bd-4e25-8877-9522c7a00938, client-phase2-a40f327b-c4bd-4e25-8877-9522c7a00938",
"traceId": "ad2dab9195e54af59ffa97e936d78253",
"evidence": {
"username": "platform_admin"
}
},
{
"module": "phase2-fixture",
"name": "discover current enterprise inbound fixtures",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.544Z",
"durationMs": 50,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-20ca695f-a261-4c8b-8c18-385c0366a01c, client-phase2-20ca695f-a261-4c8b-8c18-385c0366a01c",
"traceId": "486e78abb8024a52ae60c882214ebe7e",
"evidence": {
"total": "24",
"statusCounts": {
"WAIT_MEASURE|NOT_SUBMITTED|UNPRICED": 11,
"MEASURING|NOT_SUBMITTED|UNPRICED": 9,
"WAIT_SPLIT|APPROVED|UNPRICED": 1,
"WAIT_SPLIT|APPROVED|CONFIRMED": 2,
"WAIT_SPLIT|APPROVED|CALCULATED": 1
}
}
},
{
"module": "phase2-fixture",
"name": "terminal state filters find selected inbound",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.595Z",
"durationMs": 25,
"http": 200,
"code": "A0400",
"message": "入库流程状态不合法",
"requestId": "client-phase2-0f0d89d7-74ba-4f4e-a924-8a40c39fbfb7, client-phase2-0f0d89d7-74ba-4f4e-a924-8a40c39fbfb7",
"traceId": "0e6a817b965349359983c059f3c2f098",
"evidence": {
"inboundId": "2085909619958919170"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0400\""
}
},
{
"module": "phase2-fixture",
"name": "load platform terminal inbound reference detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.620Z",
"durationMs": 36,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-0486566e-44ed-4768-976b-d09da7b2d7ae, client-phase2-0486566e-44ed-4768-976b-d09da7b2d7ae",
"traceId": "e4c7ff793a3844faa33ed992edf02918",
"evidence": {
"inboundId": "2085909619958919170",
"orderId": "2085909608158265345",
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
}
},
{
"module": "phase2-measurement-fixture",
"name": "load four customer dimension weighting rules",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.656Z",
"durationMs": 31,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-b316f755-913d-4bdd-852a-70713da8dd72, client-phase2-b316f755-913d-4bdd-852a-70713da8dd72",
"traceId": "f45c818adb9e48c59815be010c667a48",
"evidence": {
"tenantId": "10001",
"rules": [
{
"boxType": "SMALL",
"inner": [
30,
20,
20
],
"edgeAdditionCm": 2,
"outer": [
34,
24,
24
],
"volumeCbm": 0.02,
"version": 0
},
{
"boxType": "MEDIUM",
"inner": [
40,
30,
30
],
"edgeAdditionCm": 2,
"outer": [
44,
34,
34
],
"volumeCbm": 0.051,
"version": 0
},
{
"boxType": "LARGE",
"inner": [
50,
40,
40
],
"edgeAdditionCm": 2,
"outer": [
54,
44,
44
],
"volumeCbm": 0.105,
"version": 0
},
{
"boxType": "EXTRA_LARGE",
"inner": [
60,
50,
50
],
"edgeAdditionCm": 2,
"outer": [
64,
54,
54
],
"volumeCbm": 0.187,
"version": 0
}
]
}
},
{
"module": "phase2-measurement-fixture",
"name": "load approved terminal measurement detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.687Z",
"durationMs": 28,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-f7baf80b-d5c7-4bb9-b69e-903bb0982ed5, client-phase2-f7baf80b-d5c7-4bb9-b69e-903bb0982ed5",
"traceId": "460574b41d574c809f8d31e8173779e1",
"evidence": {
"inboundId": "2085909619958919170",
"measurementId": "2085909625084358657",
"measurementStatus": "APPROVED",
"actualPackageCount": 4,
"actualGrossWeightKg": 1383,
"actualVolumeCbm": 0.1895,
"volumeWeightKg": 31.647,
"itemCount": 4,
"weightResultCount": 3
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton measurements contain complete traceable data",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.715Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"itemCount": 4
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton volume and package aggregates are consistent",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.715Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"actualPackageCount": 4,
"actualVolumeCbm": 0.1895,
"volumeWeightKg": 31.647
}
},
{
"module": "phase2-measurement-fixture",
"name": "confirmed weight results form an independent weight line",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.715Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"actualGrossWeightKg": 1383,
"weightResultCount": 3
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton billing volume uses current customer dimension weighting",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.715Z",
"durationMs": 1,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"measurementId": "2085909625084358657",
"items": [
{
"itemNo": "B1",
"measured": [
10,
10,
10
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.001,
"expected": {
"boxType": "SMALL",
"ruleVersion": 0,
"outer": [
34,
24,
24
],
"unitVolumeCbm": 0.02
}
},
{
"itemNo": "B2",
"measured": [
35,
25,
25
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.0219,
"expected": {
"boxType": "MEDIUM",
"ruleVersion": 0,
"outer": [
44,
34,
34
],
"unitVolumeCbm": 0.051
}
},
{
"itemNo": "B3",
"measured": [
45,
35,
35
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.0552,
"expected": {
"boxType": "LARGE",
"ruleVersion": 0,
"outer": [
54,
44,
44
],
"unitVolumeCbm": 0.105
}
},
{
"itemNo": "B4",
"measured": [
55,
45,
45
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.1114,
"expected": {
"boxType": "EXTRA_LARGE",
"ruleVersion": 0,
"outer": [
64,
54,
54
],
"unitVolumeCbm": 0.187
}
}
]
},
"error": {
"name": "Error",
"message": "B1 weighted unit volume: expected=0.02 actual=0.001"
}
},
{
"module": "phase2-fixture",
"name": "discover foreign terminal fixture for isolation",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.716Z",
"durationMs": 79,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-303dd014-7935-4078-a43b-592887f6bc1e, client-phase2-303dd014-7935-4078-a43b-592887f6bc1e",
"traceId": "b9655eab283f412a9ccf54b78b010b31",
"evidence": {
"total": "264"
}
},
{
"module": "phase2-client-visibility",
"name": "unfinished inbound billing remains unavailable",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.795Z",
"durationMs": 25,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-eb66c998-56dc-4ba5-8ecb-561fb483effb, client-phase2-eb66c998-56dc-4ba5-8ecb-561fb483effb",
"traceId": "e4f7ccca38bf46029f7e4fb1803bc3d6",
"evidence": {
"orderId": "2085936562680954881",
"processStatus": "WAIT_MEASURE",
"feeStatus": "UNPRICED",
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-visibility",
"name": "unknown order inbound billing is rejected",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.820Z",
"durationMs": 30,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-b67ac37c-c216-4c24-a32c-d8b844d7f73a, client-phase2-b67ac37c-c216-4c24-a32c-d8b844d7f73a",
"traceId": "0c4b5d41648b4ae4931998511d79bfb7",
"evidence": {
"orderId": 1,
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-billing",
"name": "terminal inbound billing is visible to client",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.851Z",
"durationMs": 31,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-ad77eb81-7f5f-4440-a55b-be433d8f4f78, client-phase2-ad77eb81-7f5f-4440-a55b-be433d8f4f78",
"traceId": "a02933f3e5f44f7e9a5f648eee134a5c",
"evidence": {
"rejectionCode": "A0404"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0404\""
}
},
{
"module": "phase2-client-billing",
"name": "terminal billing aggregate contract",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:05.882Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published measurement summary matches approved platform snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:05.882Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published carton measurements match platform snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:05.882Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published carton volumes follow current weighting rules",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:05.882Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published weight results match confirmed platform weights",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:05.882Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "weight and volume billing lines are independently consistent",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:05.882Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published fee detail matches confirmed fee snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:05.882Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "client aggregate excludes platform internal collections",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:05.882Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-images",
"name": "published measurement and weight image access",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:05.882Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-unified-orders",
"name": "unified order list exposes phase-two terminal state",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:58:05.882Z",
"durationMs": 53,
"http": 200,
"code": "S0503",
"message": "统一订单履约摘要响应无效",
"requestId": "client-phase2-c96c99d9-545f-4cb3-bed0-490a6dbbfc37, client-phase2-c96c99d9-545f-4cb3-bed0-490a6dbbfc37",
"traceId": "b871d193543b4e9aa35b0f4828feb259",
"evidence": {},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"S0503\""
}
}
]
}
@@ -0,0 +1,687 @@
{
"marker": "20260808035850-0d408736",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T03:58:50.046Z",
"completedAt": "2026-08-08T03:58:51.370Z",
"success": false,
"fatal": null,
"testAccount": {
"username": "merchant_admin"
},
"expectedTerminalState": {
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
},
"resources": {
"tenantId": "10001",
"terminalInboundId": "2085909619958919170",
"terminalOrderId": "2085909608158265345",
"terminalInboundCode": "RK-2085909608158265345",
"terminalMeasurementId": "2085909625084358657",
"pendingOrderId": "2085936562680954881",
"foreignOrderId": "2085665774056833025",
"measurementImageFileIds": [],
"weightImageFileIds": []
},
"steps": [
{
"module": "phase2-session",
"name": "client login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:50.047Z",
"durationMs": 375,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-7dc375d8-6f45-485c-bfca-fa3429d6fafa, client-phase2-7dc375d8-6f45-485c-bfca-fa3429d6fafa",
"traceId": "c46e94b9733847d68c279fa80dba4b5a",
"evidence": {
"username": "merchant_admin"
}
},
{
"module": "phase2-session",
"name": "load current enterprise profile",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:50.422Z",
"durationMs": 72,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-f1e8f33e-7191-4b82-a89c-b30c36793b0e, client-phase2-f1e8f33e-7191-4b82-a89c-b30c36793b0e",
"traceId": "fbb3fcb49c154297868ad78e096f9a06",
"evidence": {
"tenantId": "10001",
"tenantName": "一期演示企业",
"status": "ACTIVE"
}
},
{
"module": "phase2-session",
"name": "client has order view permission",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:50.494Z",
"durationMs": 47,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-21e5aee3-f3e8-478a-8b42-090fdca1045b, client-phase2-21e5aee3-f3e8-478a-8b42-090fdca1045b",
"traceId": "91e0b8607b0543ceb398925db0014974",
"evidence": {
"permissionCount": 27
}
},
{
"module": "phase2-fixture",
"name": "platform fixture account login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:50.541Z",
"durationMs": 332,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-1cd08967-d48a-4c56-b677-5eae43ae41b4, client-phase2-1cd08967-d48a-4c56-b677-5eae43ae41b4",
"traceId": "d636678c16044fb481dc3ee6f91eac85",
"evidence": {
"username": "platform_admin"
}
},
{
"module": "phase2-fixture",
"name": "discover current enterprise inbound fixtures",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:50.873Z",
"durationMs": 56,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-2b6fab6e-3822-48b0-a2d0-c3c04462e1d9, client-phase2-2b6fab6e-3822-48b0-a2d0-c3c04462e1d9",
"traceId": "66237af136a64590956bd80a14efdbc3",
"evidence": {
"total": "24",
"statusCounts": {
"WAIT_MEASURE|NOT_SUBMITTED|UNPRICED": 11,
"MEASURING|NOT_SUBMITTED|UNPRICED": 9,
"WAIT_SPLIT|APPROVED|UNPRICED": 1,
"WAIT_SPLIT|APPROVED|CONFIRMED": 2,
"WAIT_SPLIT|APPROVED|CALCULATED": 1
}
}
},
{
"module": "phase2-fixture",
"name": "terminal state filters find selected inbound",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:58:50.930Z",
"durationMs": 41,
"http": 200,
"code": "A0400",
"message": "入库流程状态不合法",
"requestId": "client-phase2-e5286602-c8da-4400-ab2d-81d10f4e3133, client-phase2-e5286602-c8da-4400-ab2d-81d10f4e3133",
"traceId": "1dd4924d8f7d460db2710e4587a4fb5b",
"evidence": {
"inboundId": "2085909619958919170"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0400\""
}
},
{
"module": "phase2-fixture",
"name": "load platform terminal inbound reference detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:50.971Z",
"durationMs": 34,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-af43fb3f-192d-4bc1-af34-f9b97951ac66, client-phase2-af43fb3f-192d-4bc1-af34-f9b97951ac66",
"traceId": "fba775ba7f4b41fc97c1bce6e9887a48",
"evidence": {
"inboundId": "2085909619958919170",
"orderId": "2085909608158265345",
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
}
},
{
"module": "phase2-measurement-fixture",
"name": "load four customer dimension weighting rules",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.005Z",
"durationMs": 27,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-246b9ee9-0c01-40d2-bf1a-420ef94e8807, client-phase2-246b9ee9-0c01-40d2-bf1a-420ef94e8807",
"traceId": "3828d98b3d804984bf734dee33c12e78",
"evidence": {
"tenantId": "10001",
"rules": [
{
"boxType": "SMALL",
"inner": [
30,
20,
20
],
"edgeAdditionCm": 2,
"outer": [
34,
24,
24
],
"volumeCbm": 0.02,
"version": 0
},
{
"boxType": "MEDIUM",
"inner": [
40,
30,
30
],
"edgeAdditionCm": 2,
"outer": [
44,
34,
34
],
"volumeCbm": 0.051,
"version": 0
},
{
"boxType": "LARGE",
"inner": [
50,
40,
40
],
"edgeAdditionCm": 2,
"outer": [
54,
44,
44
],
"volumeCbm": 0.105,
"version": 0
},
{
"boxType": "EXTRA_LARGE",
"inner": [
60,
50,
50
],
"edgeAdditionCm": 2,
"outer": [
64,
54,
54
],
"volumeCbm": 0.187,
"version": 0
}
]
}
},
{
"module": "phase2-measurement-fixture",
"name": "load approved terminal measurement detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.032Z",
"durationMs": 34,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-09c6b6f8-a318-4dbc-bf46-e4089d74380b, client-phase2-09c6b6f8-a318-4dbc-bf46-e4089d74380b",
"traceId": "571edcfd38a14099a7b4fae58ba20827",
"evidence": {
"inboundId": "2085909619958919170",
"measurementId": "2085909625084358657",
"measurementStatus": "APPROVED",
"actualPackageCount": 4,
"actualGrossWeightKg": 1383,
"actualVolumeCbm": 0.1895,
"volumeWeightKg": 31.647,
"itemCount": 4,
"weightResultCount": 3
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton measurements contain complete traceable data",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.066Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"itemCount": 4
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton volume and package aggregates are consistent",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.066Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"actualPackageCount": 4,
"actualVolumeCbm": 0.1895,
"volumeWeightKg": 31.647
}
},
{
"module": "phase2-measurement-fixture",
"name": "confirmed weight results form an independent weight line",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.067Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"actualGrossWeightKg": 1383,
"weightResultCount": 3
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton billing volume uses current customer dimension weighting",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.067Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"measurementId": "2085909625084358657",
"items": [
{
"itemNo": "B1",
"measured": [
10,
10,
10
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.001,
"expected": {
"boxType": "SMALL",
"ruleVersion": 0,
"outer": [
34,
24,
24
],
"unitVolumeCbm": 0.02
}
},
{
"itemNo": "B2",
"measured": [
35,
25,
25
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.0219,
"expected": {
"boxType": "MEDIUM",
"ruleVersion": 0,
"outer": [
44,
34,
34
],
"unitVolumeCbm": 0.051
}
},
{
"itemNo": "B3",
"measured": [
45,
35,
35
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.0552,
"expected": {
"boxType": "LARGE",
"ruleVersion": 0,
"outer": [
54,
44,
44
],
"unitVolumeCbm": 0.105
}
},
{
"itemNo": "B4",
"measured": [
55,
45,
45
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.1114,
"expected": {
"boxType": "EXTRA_LARGE",
"ruleVersion": 0,
"outer": [
64,
54,
54
],
"unitVolumeCbm": 0.187
}
}
]
},
"error": {
"name": "Error",
"message": "B1 weighted unit volume: expected=0.02 actual=0.001"
}
},
{
"module": "phase2-fixture",
"name": "discover foreign terminal fixture for isolation",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.067Z",
"durationMs": 73,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-b21ec988-cb8b-4ae4-b39e-dadbd2ccd053, client-phase2-b21ec988-cb8b-4ae4-b39e-dadbd2ccd053",
"traceId": "7f4a471d30ec4e999996221cfe2132bf",
"evidence": {
"total": "264"
}
},
{
"module": "phase2-client-visibility",
"name": "unfinished inbound billing remains unavailable",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.140Z",
"durationMs": 21,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-6e172b49-5cc9-43a8-adb4-db3eb8a8249e, client-phase2-6e172b49-5cc9-43a8-adb4-db3eb8a8249e",
"traceId": "811d8d7584024746810d8a102ac802c7",
"evidence": {
"orderId": "2085936562680954881",
"processStatus": "WAIT_MEASURE",
"feeStatus": "UNPRICED",
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-visibility",
"name": "unknown order inbound billing is rejected",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.161Z",
"durationMs": 23,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-65f794b5-0d0a-47c6-a2bd-5e60bb089f1a, client-phase2-65f794b5-0d0a-47c6-a2bd-5e60bb089f1a",
"traceId": "460f16f1da514fd1908ef799b99c5b59",
"evidence": {
"orderId": 1,
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-billing",
"name": "terminal inbound billing is visible to client",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.185Z",
"durationMs": 31,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-d53a96f2-03ab-4cae-85b7-e1c9281a65cd, client-phase2-d53a96f2-03ab-4cae-85b7-e1c9281a65cd",
"traceId": "64b66f21a54d487c87870e21cae84542",
"evidence": {
"rejectionCode": "A0404"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0404\""
}
},
{
"module": "phase2-client-billing",
"name": "terminal billing aggregate contract",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:51.216Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published measurement summary matches approved platform snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:51.216Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published carton measurements match platform snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:51.216Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published carton volumes follow current weighting rules",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:51.216Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published weight results match confirmed platform weights",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:51.216Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "weight and volume billing lines are independently consistent",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:51.216Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published fee detail matches confirmed fee snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:51.216Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "client aggregate excludes platform internal collections",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:51.216Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-images",
"name": "published measurement and weight image access",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T03:58:51.216Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-unified-orders",
"name": "unified order list exposes phase-two terminal state",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.217Z",
"durationMs": 54,
"http": 200,
"code": "S0503",
"message": "统一订单履约摘要响应无效",
"requestId": "client-phase2-d058f587-20be-4da0-be23-acd980d8c321, client-phase2-d058f587-20be-4da0-be23-acd980d8c321",
"traceId": "170831ea21c4498ebfc7dd89299253de",
"evidence": {},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"S0503\""
}
},
{
"module": "phase2-unified-orders",
"name": "unified order detail exposes phase-two summary and timeline",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.271Z",
"durationMs": 68,
"http": 200,
"code": "S0503",
"message": "统一订单履约详情响应无效",
"requestId": "client-phase2-e171f03a-d5c6-4c2d-aa07-034bf5af39b2, client-phase2-e171f03a-d5c6-4c2d-aa07-034bf5af39b2",
"traceId": "b9f3af4512fe48568565428bc3eb894d",
"evidence": {
"timelineCount": 0
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"S0503\""
}
},
{
"module": "phase2-isolation",
"name": "cross-enterprise terminal billing is rejected",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:58:51.339Z",
"durationMs": 31,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-f71f6d5b-056c-438c-9901-7d3f1260ffaa, client-phase2-f71f6d5b-056c-438c-9901-7d3f1260ffaa",
"traceId": "592bd936d6ac467786e8516e2dc04aca",
"evidence": {
"orderId": "2085665774056833025",
"tenantId": "2085308606829346818",
"rejectionCode": "A0404"
}
}
]
}
@@ -0,0 +1,720 @@
{
"marker": "20260808040123-ac558482",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T04:01:23.697Z",
"completedAt": "2026-08-08T04:01:25.173Z",
"success": false,
"fatal": null,
"testAccount": {
"username": "merchant_admin"
},
"expectedTerminalState": {
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
},
"resources": {
"tenantId": "10001",
"terminalInboundId": "2085909619958919170",
"terminalOrderId": "2085909608158265345",
"terminalInboundCode": "RK-2085909608158265345",
"terminalMeasurementId": "2085909625084358657",
"pendingOrderId": "2085936562680954881",
"foreignOrderId": "2085665774056833025",
"measurementImageFileIds": [],
"weightImageFileIds": []
},
"steps": [
{
"module": "phase2-session",
"name": "client login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:23.698Z",
"durationMs": 374,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-c2bf562d-ba94-492c-9c5a-6dcc85c59691, client-phase2-c2bf562d-ba94-492c-9c5a-6dcc85c59691",
"traceId": "aa69c83dfc38421f85d35f13b762184d",
"evidence": {
"username": "merchant_admin"
}
},
{
"module": "phase2-session",
"name": "load current enterprise profile",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.072Z",
"durationMs": 44,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-71cbe220-8427-4a9c-9ad0-26c86a04f282, client-phase2-71cbe220-8427-4a9c-9ad0-26c86a04f282",
"traceId": "fe85faa91a884d7da91cc3ecd3e02488",
"evidence": {
"tenantId": "10001",
"tenantName": "一期演示企业",
"status": "ACTIVE"
}
},
{
"module": "phase2-session",
"name": "client has order view permission",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.116Z",
"durationMs": 39,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-9851092b-1d4e-404b-a87e-05d760b8ff55, client-phase2-9851092b-1d4e-404b-a87e-05d760b8ff55",
"traceId": "cb524098d39e4210a907cb4ad1bb6c7d",
"evidence": {
"permissionCount": 27
}
},
{
"module": "phase2-fixture",
"name": "platform fixture account login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.155Z",
"durationMs": 337,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-ee8a1641-2caa-43fd-ba94-7d9f5acbd1f4, client-phase2-ee8a1641-2caa-43fd-ba94-7d9f5acbd1f4",
"traceId": "ff1506b27236453e993de6b0ca8d0fe0",
"evidence": {
"username": "platform_admin"
}
},
{
"module": "phase2-fixture",
"name": "discover current enterprise inbound fixtures",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.492Z",
"durationMs": 49,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-428e6f75-d11d-4870-b9b1-cbd1992691c7, client-phase2-428e6f75-d11d-4870-b9b1-cbd1992691c7",
"traceId": "94100d651dcc4003a9a9dffcb4062085",
"evidence": {
"total": "24",
"statusCounts": {
"WAIT_MEASURE|NOT_SUBMITTED|UNPRICED": 11,
"MEASURING|NOT_SUBMITTED|UNPRICED": 9,
"WAIT_SPLIT|APPROVED|UNPRICED": 1,
"WAIT_SPLIT|APPROVED|CONFIRMED": 2,
"WAIT_SPLIT|APPROVED|CALCULATED": 1
}
}
},
{
"module": "phase2-fixture",
"name": "terminal state filters find selected inbound",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.542Z",
"durationMs": 32,
"http": 200,
"code": "A0400",
"message": "入库流程状态不合法",
"requestId": "client-phase2-bc301e41-9e94-4db0-aa3f-11db9d8546bf, client-phase2-bc301e41-9e94-4db0-aa3f-11db9d8546bf",
"traceId": "227598eb9cf1498a811234fc0bd70cba",
"evidence": {
"inboundId": "2085909619958919170"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0400\""
}
},
{
"module": "phase2-fixture",
"name": "load platform terminal inbound reference detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.574Z",
"durationMs": 38,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-6f4b3484-1ad4-4883-b54c-5b045841d276, client-phase2-6f4b3484-1ad4-4883-b54c-5b045841d276",
"traceId": "036791794aed496e91f592927264d5d6",
"evidence": {
"inboundId": "2085909619958919170",
"orderId": "2085909608158265345",
"processStatus": "WAIT_SPLIT",
"measureReviewStatus": "APPROVED",
"feeStatus": "CONFIRMED"
}
},
{
"module": "phase2-measurement-fixture",
"name": "load four customer dimension weighting rules",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.613Z",
"durationMs": 40,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-948bee77-2d4a-421f-9b91-abfa8c1604cc, client-phase2-948bee77-2d4a-421f-9b91-abfa8c1604cc",
"traceId": "b548c55de64f40928fd877bf93e9db91",
"evidence": {
"tenantId": "10001",
"rules": [
{
"boxType": "SMALL",
"inner": [
30,
20,
20
],
"edgeAdditionCm": 2,
"outer": [
34,
24,
24
],
"volumeCbm": 0.02,
"version": 0
},
{
"boxType": "MEDIUM",
"inner": [
40,
30,
30
],
"edgeAdditionCm": 2,
"outer": [
44,
34,
34
],
"volumeCbm": 0.051,
"version": 0
},
{
"boxType": "LARGE",
"inner": [
50,
40,
40
],
"edgeAdditionCm": 2,
"outer": [
54,
44,
44
],
"volumeCbm": 0.105,
"version": 0
},
{
"boxType": "EXTRA_LARGE",
"inner": [
60,
50,
50
],
"edgeAdditionCm": 2,
"outer": [
64,
54,
54
],
"volumeCbm": 0.187,
"version": 0
}
]
}
},
{
"module": "phase2-measurement-fixture",
"name": "load approved terminal measurement detail",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.653Z",
"durationMs": 33,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-04797e36-5bc6-4579-ab1c-78b29aafbec5, client-phase2-04797e36-5bc6-4579-ab1c-78b29aafbec5",
"traceId": "94d316356db04659a3fb5a7f368825e2",
"evidence": {
"inboundId": "2085909619958919170",
"measurementId": "2085909625084358657",
"measurementStatus": "APPROVED",
"actualPackageCount": 4,
"actualGrossWeightKg": 1383,
"actualVolumeCbm": 0.1895,
"volumeWeightKg": 31.647,
"itemCount": 4,
"weightResultCount": 3
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton measurements contain complete traceable data",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.687Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"itemCount": 4
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton volume and package aggregates are consistent",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.687Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"actualPackageCount": 4,
"actualVolumeCbm": 0.1895,
"volumeWeightKg": 31.647
}
},
{
"module": "phase2-measurement-fixture",
"name": "confirmed weight results form an independent weight line",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.687Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"actualGrossWeightKg": 1383,
"weightResultCount": 3
}
},
{
"module": "phase2-measurement-fixture",
"name": "carton billing volume uses current customer dimension weighting",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.687Z",
"durationMs": 0,
"http": null,
"code": null,
"message": null,
"requestId": null,
"traceId": null,
"evidence": {
"measurementId": "2085909625084358657",
"items": [
{
"itemNo": "B1",
"measured": [
10,
10,
10
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.001,
"expected": {
"boxType": "SMALL",
"ruleVersion": 0,
"outer": [
34,
24,
24
],
"unitVolumeCbm": 0.02
}
},
{
"itemNo": "B2",
"measured": [
35,
25,
25
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.0219,
"expected": {
"boxType": "MEDIUM",
"ruleVersion": 0,
"outer": [
44,
34,
34
],
"unitVolumeCbm": 0.051
}
},
{
"itemNo": "B3",
"measured": [
45,
35,
35
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.0552,
"expected": {
"boxType": "LARGE",
"ruleVersion": 0,
"outer": [
54,
44,
44
],
"unitVolumeCbm": 0.105
}
},
{
"itemNo": "B4",
"measured": [
55,
45,
45
],
"storedOuter": [
null,
null,
null
],
"unitVolumeCbm": 0.1114,
"expected": {
"boxType": "EXTRA_LARGE",
"ruleVersion": 0,
"outer": [
64,
54,
54
],
"unitVolumeCbm": 0.187
}
}
]
},
"error": {
"name": "Error",
"message": "B1 weighted unit volume: expected=0.02 actual=0.001"
}
},
{
"module": "phase2-client-security",
"name": "client cannot read platform measurement workspace directly",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.687Z",
"durationMs": 19,
"http": 403,
"code": "A0403",
"message": "无权访问该资源",
"requestId": "client-phase2-2c40c728-2f36-45bc-a5f2-3d9d3e78e115",
"traceId": null,
"evidence": {
"rejectionCode": "A0403"
}
},
{
"module": "phase2-client-security",
"name": "client cannot bypass billing publication to read platform image",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.706Z",
"durationMs": 28,
"http": 403,
"code": "A0403",
"message": "无权访问该资源",
"requestId": "client-phase2-5cb81397-a261-4175-a4c2-03928f896d06",
"traceId": null,
"evidence": {
"fileId": "2085909625048727553",
"rejectionCode": "A0403"
}
},
{
"module": "phase2-fixture",
"name": "discover foreign terminal fixture for isolation",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.734Z",
"durationMs": 183,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-df15b1c3-fa79-49c7-a6bb-85f9813dc7a8, client-phase2-df15b1c3-fa79-49c7-a6bb-85f9813dc7a8",
"traceId": "686399932ca6466980a6261d0fd96c6b",
"evidence": {
"total": "264"
}
},
{
"module": "phase2-client-visibility",
"name": "unfinished inbound billing remains unavailable",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.917Z",
"durationMs": 33,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-97d45ca9-8c49-4805-bea7-bf3f95118039, client-phase2-97d45ca9-8c49-4805-bea7-bf3f95118039",
"traceId": "4571d819f0f448cca655959f83f89591",
"evidence": {
"orderId": "2085936562680954881",
"processStatus": "WAIT_MEASURE",
"feeStatus": "UNPRICED",
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-visibility",
"name": "unknown order inbound billing is rejected",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.950Z",
"durationMs": 31,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-cef1ee54-53c9-47e3-983f-40ca571be3ae, client-phase2-cef1ee54-53c9-47e3-983f-40ca571be3ae",
"traceId": "44ab0a79494144d6b7dc773ae32c863a",
"evidence": {
"orderId": 1,
"rejectionCode": "A0404"
}
},
{
"module": "phase2-client-billing",
"name": "terminal inbound billing is visible to client",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T04:01:24.981Z",
"durationMs": 28,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-59da9648-3227-43e1-b3a9-b07e9d5cd0ed, client-phase2-59da9648-3227-43e1-b3a9-b07e9d5cd0ed",
"traceId": "1535b144e3324ac7baa1ccda98a86760",
"evidence": {
"rejectionCode": "A0404"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0404\""
}
},
{
"module": "phase2-client-billing",
"name": "terminal billing aggregate contract",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T04:01:25.009Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published measurement summary matches approved platform snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T04:01:25.009Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published carton measurements match platform snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T04:01:25.009Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published carton volumes follow current weighting rules",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T04:01:25.009Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published weight results match confirmed platform weights",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T04:01:25.009Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "weight and volume billing lines are independently consistent",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T04:01:25.009Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "published fee detail matches confirmed fee snapshot",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T04:01:25.009Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-billing",
"name": "client aggregate excludes platform internal collections",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T04:01:25.009Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-client-images",
"name": "published measurement and weight image access",
"passed": true,
"skipped": true,
"startedAt": "2026-08-08T04:01:25.009Z",
"durationMs": 0,
"http": null,
"code": null,
"message": "Terminal billing endpoint did not return a visible aggregate",
"requestId": null,
"traceId": null,
"evidence": null
},
{
"module": "phase2-unified-orders",
"name": "unified order list exposes phase-two terminal state",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T04:01:25.009Z",
"durationMs": 55,
"http": 200,
"code": "S0503",
"message": "统一订单履约摘要响应无效",
"requestId": "client-phase2-df76ddb8-885c-4045-ad02-f79ecb50472a, client-phase2-df76ddb8-885c-4045-ad02-f79ecb50472a",
"traceId": "6c43dc53e7424317865f9b854d46bea1",
"evidence": {},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"S0503\""
}
},
{
"module": "phase2-unified-orders",
"name": "unified order detail exposes phase-two summary and timeline",
"passed": false,
"skipped": false,
"startedAt": "2026-08-08T04:01:25.064Z",
"durationMs": 75,
"http": 200,
"code": "S0503",
"message": "统一订单履约详情响应无效",
"requestId": "client-phase2-8fb3c470-b24d-4583-9cf8-142c3b52a0b8, client-phase2-8fb3c470-b24d-4583-9cf8-142c3b52a0b8",
"traceId": "20784521f0004e3cad30f95926b4e00c",
"evidence": {
"timelineCount": 0
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"S0503\""
}
},
{
"module": "phase2-isolation",
"name": "cross-enterprise terminal billing is rejected",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T04:01:25.139Z",
"durationMs": 34,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-56a5479b-88ed-488f-acb0-fa857b95b93c, client-phase2-56a5479b-88ed-488f-acb0-fa857b95b93c",
"traceId": "9eeec0b7a79448c5a24bdb54530682d6",
"evidence": {
"orderId": "2085665774056833025",
"tenantId": "2085308606829346818",
"rejectionCode": "A0404"
}
}
]
}
+344 -16
View File
@@ -60,6 +60,7 @@ const report = {
terminalInboundId: null,
terminalOrderId: null,
terminalInboundCode: null,
terminalMeasurementId: null,
pendingOrderId: null,
foreignOrderId: null,
measurementImageFileIds: [],
@@ -74,6 +75,8 @@ let terminalFixture
let pendingFixture
let foreignFixture
let platformTerminalDetail
let platformMeasurement
let dimensionRules = []
let merchantBilling
try {
@@ -190,6 +193,148 @@ async function fixtureDiscoveryModule() {
}))
platformTerminalDetail = detail.json.data
const dimensionConfig = await apiStep('phase2-measurement-fixture',
'load four customer dimension weighting rules', {
method: 'GET',
path: `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${report.resources.tenantId}`,
token: platformToken,
}, ({ json }) => {
requireSuccess(json)
const rules = arrayData(json.data?.rules)
assertEqual(rules.length, 4, 'dimension rule count')
assertEqual(new Set(rules.map((rule) => rule.boxType)).size, 4, 'dimension box type count')
for (const rule of rules) assertDimensionRule(rule)
}, ({ json }) => ({
tenantId: json.data?.tenantId,
rules: arrayData(json.data?.rules).map((rule) => ({
boxType: rule.boxType,
inner: dimensions(rule, 'inner'),
edgeAdditionCm: rule.edgeAdditionCm,
outer: dimensions(rule, 'outer'),
volumeCbm: rule.volumeCbm,
version: rule.version,
})),
}))
dimensionRules = dimensionConfig.json.data.rules
const measurement = await apiStep('phase2-measurement-fixture',
'load approved terminal measurement detail', {
method: 'GET',
path: `/api/platform/v1/first-leg/inbounds/${report.resources.terminalInboundId}`
+ `/measurement?tenantId=${report.resources.tenantId}`,
token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.inboundId), report.resources.terminalInboundId, 'measurement inbound id')
assertEqual(json.data?.processStatus, 'WAIT_SPLIT', 'measurement process status')
assertEqual(json.data?.measureReviewStatus, 'APPROVED', 'measurement review status')
assertEqual(json.data?.measurementStatus, 'APPROVED', 'measurement batch status')
assertPresent(json.data?.measurementId, 'measurement id')
assertTrue(arrayData(json.data?.items).length > 0, 'measurement items must not be empty')
assertTrue(arrayData(json.data?.weightResults).length > 0, 'weight results must not be empty')
}, ({ json }) => ({
inboundId: json.data?.inboundId,
measurementId: json.data?.measurementId,
measurementStatus: json.data?.measurementStatus,
actualPackageCount: json.data?.actualPackageCount,
actualGrossWeightKg: json.data?.actualGrossWeightKg,
actualVolumeCbm: json.data?.actualVolumeCbm,
volumeWeightKg: json.data?.volumeWeightKg,
itemCount: arrayData(json.data?.items).length,
weightResultCount: arrayData(json.data?.weightResults).length,
}))
platformMeasurement = measurement.json.data
report.resources.terminalMeasurementId = String(platformMeasurement.measurementId)
await localStep('phase2-measurement-fixture', 'carton measurements contain complete traceable data', () => {
for (const item of platformMeasurement.items) {
assertStringId(item.id, 'measurement item id')
assertStringId(item.inboundDetailId, 'measurement inbound detail id')
assertPresent(item.itemNo, 'measurement item number')
assertPresent(item.boxCode, 'measurement box code')
assertPresent(item.machineNo, 'measurement machine number')
assertPresent(item.deviceMeasuredAt, 'device measured time')
assertStringId(item.intelligentImageFileId, 'measurement image file id')
assertTrue(Number(item.packageCount) > 0, 'measurement package count must be positive')
assertEqual(Number(item.confirmedPackageCount), Number(item.packageCount),
'confirmed package count')
for (const field of ['lengthCm', 'widthCm', 'heightCm', 'unitVolumeCbm', 'totalVolumeCbm']) {
assertTrue(Number(item[field]) > 0, `${field} must be positive`)
}
}
}, { itemCount: platformMeasurement.items.length })
await localStep('phase2-measurement-fixture', 'carton volume and package aggregates are consistent', () => {
const packageCount = sum(platformMeasurement.items, 'packageCount')
const volumeCbm = sum(platformMeasurement.items, 'totalVolumeCbm')
const volumeWeightKg = sum(platformMeasurement.items, 'volumeWeightKg')
assertNumberEqual(packageCount, platformMeasurement.actualPackageCount, 'measurement package aggregate')
assertApproxEqual(volumeCbm, platformMeasurement.actualVolumeCbm, 0.000001,
'measurement volume aggregate')
assertApproxEqual(volumeWeightKg, platformMeasurement.volumeWeightKg, 0.001,
'measurement volume weight aggregate')
}, {
actualPackageCount: platformMeasurement.actualPackageCount,
actualVolumeCbm: platformMeasurement.actualVolumeCbm,
volumeWeightKg: platformMeasurement.volumeWeightKg,
})
await localStep('phase2-measurement-fixture', 'confirmed weight results form an independent weight line', () => {
for (const weight of platformMeasurement.weightResults) {
assertStringId(weight.id, 'weight result id')
assertStringId(weight.fileId, 'weight image file id')
assertEqual(weight.recognitionStatus, 'CONFIRMED', 'weight recognition status')
assertPresent(weight.confirmedAt, 'weight confirmation time')
assertTrue(Number(weight.finalWeightKg) > 0, 'final weight must be positive')
}
assertApproxEqual(sum(platformMeasurement.weightResults, 'finalWeightKg'),
platformMeasurement.actualGrossWeightKg, 0.001, 'measurement gross weight aggregate')
}, {
actualGrossWeightKg: platformMeasurement.actualGrossWeightKg,
weightResultCount: platformMeasurement.weightResults.length,
})
await softLocalStep('phase2-measurement-fixture',
'carton billing volume uses current customer dimension weighting', () => {
for (const item of platformMeasurement.items) {
const expected = resolveWeightedDimensions(item, dimensionRules)
assertApproxEqual(item.unitVolumeCbm, expected.unitVolumeCbm, 0.000001,
`${item.itemNo} weighted unit volume`)
}
}, {
measurementId: platformMeasurement.measurementId,
items: platformMeasurement.items.map((item) => ({
itemNo: item.itemNo,
measured: dimensions(item),
storedOuter: dimensions(item, 'outer'),
unitVolumeCbm: item.unitVolumeCbm,
expected: resolveWeightedDimensions(item, dimensionRules),
})),
})
await apiStep('phase2-client-security', 'client cannot read platform measurement workspace directly', {
method: 'GET',
path: `/api/platform/v1/first-leg/inbounds/${report.resources.terminalInboundId}`
+ `/measurement?tenantId=${report.resources.tenantId}`,
token: clientToken,
}, ({ status, json }) => {
assertEqual(status, 403, 'HTTP status')
assertTrue(json?.code == null || String(json.code) === 'A0403',
`expected absent/A0403 business code, actual=${json?.code}`)
}, ({ json }) => ({ rejectionCode: json?.code }))
const platformImageFileId = platformMeasurement.items[0]?.intelligentImageFileId
await apiStep('phase2-client-security', 'client cannot bypass billing publication to read platform image', {
method: 'GET',
path: `/api/platform/v1/files/measurements/${report.resources.tenantId}`
+ `/${report.resources.terminalInboundId}/${platformImageFileId}`,
token: clientToken,
}, ({ status, json }) => {
assertEqual(status, 403, 'HTTP status')
assertTrue(json?.code == null || String(json.code) === 'A0403',
`expected absent/A0403 business code, actual=${json?.code}`)
}, ({ json }) => ({ fileId: platformImageFileId, rejectionCode: json?.code }))
const allTerminal = await apiStep('phase2-fixture', 'discover foreign terminal fixture for isolation', {
method: 'GET',
path: '/api/platform/inbounds?page=1&size=200',
@@ -254,15 +399,23 @@ async function terminalBillingModule() {
rejectionCode: json?.code,
}))
if (!response) {
recordSkipped('phase2-client-billing', 'terminal billing aggregate contract',
'Terminal billing endpoint did not return a visible aggregate')
recordSkipped('phase2-client-images', 'published measurement and weight image access',
'Terminal billing endpoint did not return image references')
const reason = 'Terminal billing endpoint did not return a visible aggregate'
for (const name of [
'terminal billing aggregate contract',
'published measurement summary matches approved platform snapshot',
'published carton measurements match platform snapshot',
'published carton volumes follow current weighting rules',
'published weight results match confirmed platform weights',
'weight and volume billing lines are independently consistent',
'published fee detail matches confirmed fee snapshot',
'client aggregate excludes platform internal collections',
]) recordSkipped('phase2-client-billing', name, reason)
recordSkipped('phase2-client-images', 'published measurement and weight image access', reason)
return
}
merchantBilling = response.json.data
await localStep('phase2-client-billing', 'terminal billing aggregate contract', () => {
await softLocalStep('phase2-client-billing', 'terminal billing aggregate contract', () => {
const summary = merchantBilling.inbound.summary
const fee = merchantBilling.latestFee
assertStringId(summary.id, 'inbound summary id')
@@ -291,7 +444,7 @@ async function terminalBillingModule() {
currency: merchantBilling.latestFee.currency,
})
await localStep('phase2-client-billing', 'client aggregate excludes platform internal collections', () => {
await softLocalStep('phase2-client-billing', 'client aggregate excludes platform internal collections', () => {
assertEqual(arrayData(merchantBilling.inbound.internalNotes).length, 0, 'merchant internal note count')
assertEqual(arrayData(merchantBilling.inbound.statusHistory).length, 0, 'merchant status history count')
for (const file of arrayData(merchantBilling.inbound.files)) {
@@ -306,22 +459,123 @@ async function terminalBillingModule() {
visibleFileCount: arrayData(merchantBilling.inbound.files).length,
})
await localStep('phase2-client-billing', 'measurement and billing basis are internally consistent', () => {
await softLocalStep('phase2-client-billing', 'published measurement summary matches approved platform snapshot', () => {
const summary = merchantBilling.measurementSummary
assertPresent(summary?.measurementId, 'measurement id')
assertEqual(String(summary.measurementId), String(platformMeasurement.measurementId), 'measurement id')
assertEqual(summary.measurementStatus, platformMeasurement.measurementStatus, 'measurement status')
assertNumberEqual(summary.actualPackageCount, platformMeasurement.actualPackageCount,
'measurement package count')
assertApproxEqual(summary.actualGrossWeightKg, platformMeasurement.actualGrossWeightKg, 0.001,
'measurement gross weight')
assertApproxEqual(summary.actualVolumeCbm, platformMeasurement.actualVolumeCbm, 0.000001,
'measurement volume')
assertApproxEqual(summary.volumeWeightKg, platformMeasurement.volumeWeightKg, 0.001,
'measurement volume weight')
}, {
measurementId: merchantBilling.measurementSummary?.measurementId,
measurementStatus: merchantBilling.measurementSummary?.measurementStatus,
actualPackageCount: merchantBilling.measurementSummary?.actualPackageCount,
actualGrossWeightKg: merchantBilling.measurementSummary?.actualGrossWeightKg,
actualVolumeCbm: merchantBilling.measurementSummary?.actualVolumeCbm,
})
await softLocalStep('phase2-client-billing', 'published carton measurements match platform snapshot', () => {
const publishedItems = arrayData(merchantBilling.measurementItems)
assertEqual(publishedItems.length, platformMeasurement.items.length, 'published measurement item count')
const platformById = new Map(platformMeasurement.items.map((item) => [String(item.id), item]))
for (const item of publishedItems) {
assertStringId(item.id, 'published measurement item id')
const expected = platformById.get(String(item.id))
assertPresent(expected, `platform measurement item ${item.id}`)
for (const field of ['packageCount', 'lengthCm', 'widthCm', 'heightCm', 'unitWeightKg',
'totalWeightKg', 'unitVolumeCbm', 'totalVolumeCbm', 'volumeWeightKg']) {
assertApproxEqual(item[field], expected[field], 0.000001, `${item.itemNo} ${field}`)
}
}
}, { measurementItemCount: arrayData(merchantBilling.measurementItems).length })
await softLocalStep('phase2-client-billing', 'published carton volumes follow current weighting rules', () => {
for (const item of arrayData(merchantBilling.measurementItems)) {
const expected = resolveWeightedDimensions(item, dimensionRules)
assertApproxEqual(item.unitVolumeCbm, expected.unitVolumeCbm, 0.000001,
`${item.itemNo} published weighted unit volume`)
}
}, {
items: arrayData(merchantBilling.measurementItems).map((item) => ({
itemNo: item.itemNo,
measured: dimensions(item),
unitVolumeCbm: item.unitVolumeCbm,
expected: resolveWeightedDimensions(item, dimensionRules),
})),
})
await softLocalStep('phase2-client-billing', 'published weight results match confirmed platform weights', () => {
const publishedWeights = arrayData(merchantBilling.weightResults)
assertEqual(publishedWeights.length, platformMeasurement.weightResults.length,
'published weight result count')
const platformById = new Map(platformMeasurement.weightResults.map((item) => [String(item.id), item]))
for (const weight of publishedWeights) {
assertStringId(weight.id, 'published weight result id')
const expected = platformById.get(String(weight.id))
assertPresent(expected, `platform weight result ${weight.id}`)
assertEqual(String(weight.fileId), String(expected.fileId), 'weight image file id')
assertEqual(weight.recognitionStatus, 'CONFIRMED', 'published weight status')
assertApproxEqual(weight.finalWeightKg, expected.finalWeightKg, 0.001, 'published final weight')
}
assertApproxEqual(sum(publishedWeights, 'finalWeightKg'),
merchantBilling.measurementSummary.actualGrossWeightKg, 0.001, 'published weight aggregate')
}, { weightResultCount: arrayData(merchantBilling.weightResults).length })
await softLocalStep('phase2-client-billing',
'weight and volume billing lines are independently consistent', () => {
const summary = merchantBilling.measurementSummary
const basis = merchantBilling.billingBasis
const fee = merchantBilling.latestFee
assertNumberEqual(basis.actualWeightKg, summary.actualGrossWeightKg, 'billing actual weight')
assertNumberEqual(basis.actualVolumeCbm, summary.actualVolumeCbm, 'billing actual volume')
assertNumberEqual(basis.actualWeightKg, fee.actualWeightKg, 'fee actual weight')
assertNumberEqual(basis.actualVolumeCbm, fee.actualVolumeCbm, 'fee actual volume')
assertNumberEqual(basis.convertedQuantity, fee.convertedQuantity, 'billing converted quantity')
assertNumberEqual(basis.chargeableQuantity, fee.chargeableQuantity, 'billing chargeable quantity')
assertTrue(['ACTUAL_WEIGHT', 'VOLUME_WEIGHT', 'EQUAL', 'ACTUAL_VOLUME',
'WEIGHT_CONVERTED_VOLUME'].includes(basis.chargeableBasis), 'chargeable basis must be supported')
}, {
actualWeightKg: merchantBilling.billingBasis?.actualWeightKg,
actualVolumeCbm: merchantBilling.billingBasis?.actualVolumeCbm,
convertedQuantity: merchantBilling.billingBasis?.convertedQuantity,
chargeableQuantity: merchantBilling.billingBasis?.chargeableQuantity,
chargeableBasis: merchantBilling.billingBasis?.chargeableBasis,
})
await softLocalStep('phase2-client-billing', 'published fee detail matches confirmed fee snapshot', () => {
const detail = merchantBilling.publishedFeeDetail
const fee = merchantBilling.latestFee
assertPresent(detail?.id, 'published fee detail id')
assertEqual(String(detail.inboundId), report.resources.terminalInboundId, 'published fee inbound id')
assertEqual(String(detail.sourceFeeId), String(fee.feeId), 'published source fee id')
assertEqual(Number(detail.sourceFeeVersion), Number(fee.feeVersion), 'published source fee version')
assertEqual(String(detail.customerId), report.resources.tenantId, 'published fee customer id')
assertEqual(detail.publicationStatus, 'PUBLISHED', 'published fee publication status')
assertPresent(detail.publishedAt, 'published fee time')
assertMoneyEqual(detail.amount, fee.totalAmount, 'published fee amount')
assertNumberEqual(detail.chargeableQuantity, fee.chargeableQuantity, 'published chargeable quantity')
assertEqual(detail.chargeUnit, fee.chargeUnit, 'published charge unit')
}, {
publishedFeeDetailId: merchantBilling.publishedFeeDetail?.id,
sourceFeeId: merchantBilling.publishedFeeDetail?.sourceFeeId,
publicationStatus: merchantBilling.publishedFeeDetail?.publicationStatus,
amount: merchantBilling.publishedFeeDetail?.amount,
})
await softLocalStep('phase2-client-billing', 'measurement and billing basis are internally consistent', () => {
const basis = merchantBilling.billingBasis
const fee = merchantBilling.latestFee
assertPresent(summary?.measurementId, 'measurement id')
assertEqual(summary.measurementStatus, 'SUBMITTED', 'measurement status')
assertTrue(Number(summary.actualPackageCount) > 0, 'actual package count must be positive')
assertTrue(Number(summary.actualGrossWeightKg) > 0, 'measurement gross weight must be positive')
assertTrue(Number(summary.actualVolumeCbm) > 0, 'measurement volume must be positive')
assertNumberEqual(basis.actualWeightKg, fee.actualWeightKg, 'billing actual weight')
assertNumberEqual(basis.actualVolumeCbm, fee.actualVolumeCbm, 'billing actual volume')
assertNumberEqual(basis.convertedQuantity, fee.convertedQuantity, 'billing converted quantity')
assertNumberEqual(basis.chargeableQuantity, fee.chargeableQuantity, 'billing chargeable quantity')
}, {
measurementId: merchantBilling.measurementSummary?.measurementId,
measurementStatus: merchantBilling.measurementSummary?.measurementStatus,
measurementItemCount: arrayData(merchantBilling.measurementItems).length,
weightResultCount: arrayData(merchantBilling.weightResults).length,
chargeableBasis: merchantBilling.billingBasis?.chargeableBasis,
@@ -365,7 +619,7 @@ async function terminalBillingModule() {
async function unifiedOrderModule() {
const orderNo = terminalFixture.orderNo
await apiStep('phase2-unified-orders', 'unified order list exposes phase-two terminal state', {
await softApiStep('phase2-unified-orders', 'unified order list exposes phase-two terminal state', {
method: 'GET',
path: `/api/merchant/v1/orders?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`,
token: clientToken,
@@ -387,7 +641,7 @@ async function unifiedOrderModule() {
actualGrossWeightKg: row?.actualGrossWeightKg, actualVolumeCbm: row?.actualVolumeCbm }
})
await apiStep('phase2-unified-orders', 'unified order detail exposes phase-two summary and timeline', {
await softApiStep('phase2-unified-orders', 'unified order detail exposes phase-two summary and timeline', {
method: 'GET', path: `/api/merchant/v1/orders/${report.resources.terminalOrderId}`, token: clientToken,
}, ({ json }) => {
requireSuccess(json)
@@ -469,6 +723,10 @@ async function localStep(module, name, validate, evidence = null) {
}
}
async function softLocalStep(module, name, validate, evidence = null) {
try { await localStep(module, name, validate, evidence) } catch { }
}
async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) {
const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-phase2-${randomUUID()}`, ...headers }
if (token) requestHeaders.Authorization = `Bearer ${token}`
@@ -532,6 +790,67 @@ function assertTerminalFixture(row) {
assertEqual(row.feeStatus, 'CONFIRMED', 'fee status')
}
function assertDimensionRule(rule) {
assertPresent(rule?.boxType, 'dimension box type')
assertTrue(Number(rule.version) >= 0, `${rule.boxType} version must not be negative`)
for (const field of ['innerLengthCm', 'innerWidthCm', 'innerHeightCm', 'edgeAdditionCm',
'outerLengthCm', 'outerWidthCm', 'outerHeightCm', 'volumeCbm']) {
assertTrue(Number(rule[field]) > 0, `${rule.boxType} ${field} must be positive`)
}
const edgePair = Number(rule.edgeAdditionCm) * 2
assertApproxEqual(rule.outerLengthCm, Number(rule.innerLengthCm) + edgePair, 0.000001,
`${rule.boxType} outer length`)
assertApproxEqual(rule.outerWidthCm, Number(rule.innerWidthCm) + edgePair, 0.000001,
`${rule.boxType} outer width`)
assertApproxEqual(rule.outerHeightCm, Number(rule.innerHeightCm) + edgePair, 0.000001,
`${rule.boxType} outer height`)
assertApproxEqual(rule.volumeCbm,
Number(rule.outerLengthCm) * Number(rule.outerWidthCm) * Number(rule.outerHeightCm) / 1_000_000,
0.0005, `${rule.boxType} configured volume`)
}
function resolveWeightedDimensions(item, rules) {
const measured = [Number(item.lengthCm), Number(item.widthCm), Number(item.heightCm)]
.sort((left, right) => right - left)
const candidates = [...arrayData(rules)].sort((left, right) =>
Number(left.innerLengthCm) * Number(left.innerWidthCm) * Number(left.innerHeightCm)
- Number(right.innerLengthCm) * Number(right.innerWidthCm) * Number(right.innerHeightCm))
const rule = candidates.find((candidate) => {
const inner = [Number(candidate.innerLengthCm), Number(candidate.innerWidthCm),
Number(candidate.innerHeightCm)].sort((left, right) => right - left)
return measured.every((value, index) => value <= inner[index])
})
if (!rule) {
const rawVolume = measured.reduce((total, value) => total * value, 1) / 1_000_000
return { boxType: null, ruleVersion: null, outer: dimensions(item),
unitVolumeCbm: round(rawVolume, 3) }
}
const edgePair = Number(rule.edgeAdditionCm) * 2
const outer = [Number(rule.innerLengthCm) + edgePair, Number(rule.innerWidthCm) + edgePair,
Number(rule.innerHeightCm) + edgePair]
return {
boxType: rule.boxType,
ruleVersion: rule.version,
outer,
unitVolumeCbm: round(outer.reduce((total, value) => total * value, 1) / 1_000_000, 3),
}
}
function dimensions(value, prefix = '') {
const field = (name) => `${prefix}${prefix ? name[0].toUpperCase() + name.slice(1) : name}`
return [value?.[field('lengthCm')] ?? null, value?.[field('widthCm')] ?? null,
value?.[field('heightCm')] ?? null]
}
function sum(values, field) {
return arrayData(values).reduce((total, value) => total + Number(value?.[field] ?? 0), 0)
}
function round(value, scale) {
const factor = 10 ** scale
return Math.round((Number(value) + Number.EPSILON) * factor) / factor
}
function statusCounts(values) {
const counts = {}
for (const row of arrayData(values)) {
@@ -551,6 +870,15 @@ function assertNumberEqual(actual, expected, label) {
assertEqual(Number(actual), Number(expected), label)
}
function assertApproxEqual(actual, expected, tolerance, label) {
const actualNumber = Number(actual)
const expectedNumber = Number(expected)
if (!Number.isFinite(actualNumber) || !Number.isFinite(expectedNumber)
|| Math.abs(actualNumber - expectedNumber) > tolerance) {
throw new Error(`${label}: expected=${format(expected)} actual=${format(actual)}`)
}
}
function assertMoneyEqual(actual, expected, label) {
assertEqual(Math.round(Number(actual) * 100), Math.round(Number(expected) * 100), label)
}
+21 -47
View File
@@ -1,57 +1,31 @@
# Warehouse Service 模块指引
## 职责边界
## 职责与边界
- 负责国内收货仓、仓库地址、服务能力、启停校验、往来单位主数据,以及二期托盘、库位、预占和占用资源。
- 往来单位保存在 `baiyue_warehouse.wh_partner`,不是独立服务;订单只通过内部接口校验并保存快照。
- 入库流程状态和测量数据属于 `firstleg/`;本模块只维护仓储资源及其容量、预占和占用状态,不复制入库单领域模型。
- 负责国内收货仓、地址/能力、往来单位、快递公司,以及二期托盘、库位、预占和占用。
- 入库单、测量和入库状态归 `firstleg/`;订单只通过内部接口校验并保存快照。
- 公共资源统一 `tenant_id=0`,不得绑定客户或要求客户筛选。
## 功能快速索引
## 模块入口
| 功能 | 入口 |
| 目标 | 入口 |
|---|---|
| 商户可用仓库列表与详情 | `controller/MerchantWarehouseController.java` |
| 平台仓库增改查与启停 | `controller/PlatformWarehouseController.java` |
| 内部仓库有效性校验 | `controller/InternalWarehouseController.java` |
| 商户往来单位候选与详情 | `controller/MerchantPartnerController.java` |
| 内部往来单位有效性校验 | `controller/InternalPartnerController.java` |
| 平台往来单位增改查、启停与 XLSX 导入 | `controller/PlatformPartnerController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java`;模板 `/import-template`,导入 `/import` |
| 平台快递公司增改查、启停与 XLSX 导入 | `controller/PlatformExpressCompanyController.java`、`service/ExpressCompanyQueryService.java`、`ExpressCompanyImportService.java`;模板 `/import-template`,导入 `/import` |
| 平台托盘增改查 | `controller/PlatformPalletController.java` → `service/PalletApplicationService.java` → `repository/PalletRepository.java`;体积分托盘规则在 `service/PalletVolumeSplitter.java` |
| 平台库位增改查、可用库位与容量 | `controller/PlatformLocationController.java` → `service/LocationApplicationService.java`、`LocationResourceService.java` → `repository/LocationRepository.java` |
| 海外仓阶梯费用 CRUD | `controller/PlatformOverseasStorageFeeController.java` → `service/OverseasStorageFeeService.java` → `repository/OverseasStorageFeeRepository.java`;客户主记录与阶梯明细分别落 `wh_overseas_storage_fee_rule`、`wh_overseas_storage_fee_tier` |
| Firstleg 托盘库位预占、确认和释放 | `controller/InternalPalletLocationController.java`(`/reservations`、`/confirm`、`/release`)→ `service/PalletLocationAllocationService.java`;只允许内部令牌调用 |
| 服务与数据库结构状态 | `controller/WarehouseHealthController.java`、`service/WarehouseSchemaReadinessService.java` |
| 数据访问异常映射 | `controller/WarehouseDataAccessExceptionHandler.java`;数据库不可用或结构漂移统一返回 `S0503/503` |
| 仓库业务编排 | `service/WarehouseApplicationService.java` |
| 往来单位查询与停用过滤 | `service/PartnerQueryService.java` |
| 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java`、`MasterDataQueries.java` |
| 仓库、地址、能力、往来单位与快递公司持久化 | `entity/`、`mapper/`、`repository/`;往来单位多类型关联为 `PartnerTypeEntity.java` 与 `wh_partner_type` |
| 表结构、伙伴、快递公司与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` |
| 二期托盘库位表与迁移 | `src/main/resources/db/phase2-pallet-location-schema.sql`、`phase2-pallet-location-schema-migration.sql`、`phase2-location-overflow-migration.sql`;统一部署入口 `../deploy/sql/头程数据库统一增量-MySQL8.sql`,历史日期脚本仅作追溯 |
| 测试 | `src/test/java/com/baiyue/warehouse/` |
| 仓库/地址/能力 | `controller/PlatformWarehouseController.java`、`MerchantWarehouseController.java`、`service/WarehouseApplicationService.java` |
| 往来单位/快递 | `controller/PlatformPartnerController.java`、`PlatformExpressCompanyController.java`、`service/PlatformPartnerQueryService.java`、`PartnerImportService.java` |
| 托盘 | `controller/PlatformPalletController.java`、`service/PalletApplicationService.java` |
| 库位与分配 | `controller/PlatformLocationController.java`、`InternalPalletLocationController.java`、`service/PalletLocationAllocationService.java` |
| 海外仓阶梯费用 | `controller/PlatformOverseasStorageFeeController.java`、`service/OverseasStorageFeeService.java` |
| DTO/持久化 | `dto/`、`entity/`、`mapper/`、`repository/` |
| SQL | `src/main/resources/db/`;存量统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 测试/验证 | `src/test/java/com/baiyue/warehouse/`;`mvn -pl warehouse -am test` |
## 修改与验证
## 修改约束
- 往来单位、仓库、快递公司、托盘、库位等均是平台公共主数据,统一归属平台技术作用域 `tenant_id=0`,不得绑定任一客户;客户只读取平台共享往来单位。平台启停必须检查业务约束,不能只改状态字段。列表接口默认查询全部公共资源,不得要求 Admin 先选择客户,也不得让客户筛选控制仓库、往来单位、快递、托盘或库位候选范围。历史接口或字段仍出现 `tenantId` 时只保留兼容,只允许传平台公共作用域 `0`,不得传客户 ID 作为资源归属。接口同时校验平台访问域和按钮权限:列表/详情使用 `platform:warehouse:list`,新增使用 `platform:warehouse:create`,编辑使用 `platform:warehouse:edit`,启停使用 `platform:warehouse:status`;OpenAPI 的 403 响应说明必须与权限码同步。
- 往来单位与仓库查询供 Order 使用,DTO 或有效性语义变化需同步检查 `order/client/` 和订单快照逻辑。
- 往来单位类型以 `types` 多值集合和 `wh_partner_type` 为准,允许同一单位同时作为客户、供应商等;`wh_partner.partner_type` 与 DTO 的 `partnerType` 仅保留旧数据兼容。写入时 Entity、Mapper、Repository、平台保存/导入和 Order 客户端必须一起核对。
- 往来单位与快递公司导入只接收标准 XLSX 模板,文件上限 5MB、最多 1000 行;先完成整表头、字段、文件内重复和数据库重复校验,再原子写入,任一行失败整批不落库。权限分别是 `platform:counterparty:import` 与 `platform:express:import`。
- 模板与导入 DTO 在 `dto/MasterDataQueries.java`,Apache POI 依赖在 `pom.xml`;调整模板版本、列顺序或枚举时同步修改生成、解析、错误行号和两组 ImportService 测试。
- Warehouse 内部仓库/往来单位接口使用 `X-Internal-Service-Token`:公共仓库和平台共享往来单位均按 `tenant_id=0` 校验;往来单位不存在使用 HTTP 200 + body code `A0404`,资源已停用使用 HTTP 200 + `A0409`。`WarehouseDataAccessExceptionHandler` 捕获的数据库或结构故障保留真实 HTTP 503 + `S0503`。内部路径不加入 Gateway 公网路由。
- 托盘和库位平台接口必须执行平台访问域、平台技术作用域 `tenant_id=0` 和对应权限校验,不得要求目标客户;预占、确认、释放只通过 `InternalPalletLocationController` 提供给 Firstleg,不加入 Gateway 公网路由。Firstleg 的入库单仍按客户 `tenant_id>0` 隔离,调用公共仓储资源时必须把业务客户归属与公共资源归属分开校验。
- 公共资源与客户数据必须分开:公共仓库、往来单位、快递公司、托盘、库位使用 `tenant_id=0`;客户订单、文件和费用使用所属客户 `tenant_id>0`。`10001` 是客户 ID,不得再用于平台公共资源归属。
- 库位容量、共享溢出、预占和占用必须在数据库事务及条件更新中保持一致;修改分配算法时同步覆盖并发冲突、容量不足、重复确认、释放和共享溢出测试。
- Firstleg 只保存托盘/库位逻辑引用和入库快照。内部 DTO、预占状态或容量口径变化时同步检查 `firstleg/client/WarehousePalletLocationClient.java`、`MeasurementLocationOccupationService.java` 和双方测试。
- 数据表、Entity、Mapper、Repository 与种子数据必须同步;历史订单不随主数据更新。
- 既有数据库发布必须按 `warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` 的顺序执行;服务不在启动时自动跑 SQL。
- `/api/platform/v1/warehouses/status` 必须执行真实字段探针;缺表或缺列时返回 `DEGRADED/503`,不得只根据进程存活伪报 `UP`。
- Warehouse 的数据库访问异常和 readiness/结构状态探针都保留真实 HTTP 503;其他由公共异常处理器捕获的业务失败才使用 HTTP 200 + body code。
- 从父目录运行 `mvn -pl warehouse -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1`。
- 二期直接验证至少运行 `PalletApplicationServiceTest`、`LocationApplicationServiceTest`、`LocationResourceServiceTest`、`PalletLocationAllocationServiceTest` 及两个平台 Controller 契约测试;存量数据库迁移后确认统一脚本末尾 Warehouse 核验全部为 `PASS`。
- 内部仓储接口使用 `X-Internal-Service-Token`,不加入 Gateway 公网路由;资源停用/不存在遵循统一 body code。
- 导入先全量校验再原子写入;调整 DTO、类型或容量规则时同步 Order/Firstleg 客户端和契约测试。
## 分期快速索引
## 分期导航
- 一期:`PlatformWarehouseController`、`InternalWarehouseController`、Partner/Express Controller 提供平台公共仓与主数据;先读 `../../docs/一期/README.md`、`../../docs/一期/一期头程订单闭环设计.md`。资源归属固定 `tenant_id=0`,不得绑定客户或由客户筛选候选范围。
- 二期:`PlatformPalletController`、`PlatformLocationController`、`InternalPalletLocationController` 支撑托盘/库位预占、确认、释放;先读 `../../docs/二期/README.md`、主流程文档和 8 月 4 日迁移说明。入库状态仍归 `firstleg/`,Warehouse 只维护资源容量与占用。
- 三期:当前 NO-GO;暂不实现分柜、装柜、出仓库存扣减或 PDA 专用资源。解除 `../../docs/三期/三期需求确认表.md` 阻断项后,再锁定资源状态机与 Firstleg 内部 DTO。
- 一期:公共仓与主数据;先读 `../../docs/一期/README.md`。
- 二期:托盘/库位预占占用和海外仓费用;先读 `../../docs/二期/README.md` 与主流程。
- 三期:NO-GO;待确认后再扩展出仓资源。