This commit is contained in:
2026-08-08 11:33:18 +08:00
parent 2ee78aafc2
commit 92b3b9b1fb
37 changed files with 17226 additions and 39 deletions
+11 -2
View File
@@ -85,6 +85,16 @@
- 上表中的 `模块/.../` 从对应模块的 `src/main/java/com/baiyue/<domain>/` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。 - 上表中的 `模块/.../` 从对应模块的 `src/main/java/com/baiyue/<domain>/` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。
- 二期入库主状态归 `firstleg`,订单货物主数据仍归 `order`,托盘/库位资源归 `warehouse`,文件对象归 `file`,OCR 供应商适配归 `integration-service`;禁止通过跨库查询绕过内部 API。 - 二期入库主状态归 `firstleg`,订单货物主数据仍归 `order`,托盘/库位资源归 `warehouse`,文件对象归 `file`,OCR 供应商适配归 `integration-service`;禁止通过跨库查询绕过内部 API。
## 一期/二期/三期后端阶段索引
| 阶段 | 后端边界与入口 | 开始工作前阅读 | 状态边界 |
|---|---|---|---|
| 一期:头程订单闭环 | `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/`。 - 服务本地配置在各模块 `src/main/resources/application.yml`;Nacos 环境配置集中在 `config/nacos/local/` 和 `config/nacos/cn/`。
@@ -96,8 +106,7 @@
- 本地环境切换看 `docs/数据库环境切换说明.md`、`scripts/local-env.ps1` 与 `config/nacos/local/`;接口契约优先读目标模块 `src/main/resources/openapi/` 和 Controller,回归入口在 `scripts/`,历史资质文件迁移使用 `scripts/migrate-local-qualification-file.ps1`。 - 本地环境切换看 `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`。 - 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。
- IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。 - IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。
- 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。 - 测试环境数据库增量统一从 `deploy/sql/头程数据库统一增量-MySQL8.sql` 执行,执行边界与末尾核验方式见 `deploy/sql/README.md`;历史日期脚本只用于追溯,不能作为当前部署入口。
- 二期入库新建或存量测试环境升级使用 `deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql`;旧的 `2026-08-03-二期入库表结构-TEST环境一键执行.sql` 只保留为早期基线参考,不替代 8 月 4 日最终增量入口。
- CI 总控入口是 `Jenkinsfile`,负责按变更或勾选项并行调度 11 个独立服务 Job;单服务构建、镜像与部署逻辑在 `Jenkinsfile.service`。Job 名必须以模块名结尾,公共模块或 `deploy/` 变化会触发全部服务。 - CI 总控入口是 `Jenkinsfile`,负责按变更或勾选项并行调度 11 个独立服务 Job;单服务构建、镜像与部署逻辑在 `Jenkinsfile.service`。Job 名必须以模块名结尾,公共模块或 `deploy/` 变化会触发全部服务。
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。 - 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
- 公共导出中心测试环境真实任务验收使用 `scripts/export-center-test-env-acceptance.ps1`;脚本只调用 Gateway,不负责部署或停止 Broker/File/IAM,故障注入由有权限的运维配合。 - 公共导出中心测试环境真实任务验收使用 `scripts/export-center-test-env-acceptance.ps1`;脚本只调用 Gateway,不负责部署或停止 Broker/File/IAM,故障注入由有权限的运维配合。
+6
View File
@@ -30,3 +30,9 @@
- 平台统一用户管理变更账号客户归属时由 IAM 调用 `PUT /internal/auth/accounts/{id}/scope` 同步 Auth 的客户隔离兼容字段、成员和应用端;修改该历史契约必须同时检查 `iam/client/AuthAccountClient.java` 与 `PlatformUserManagementService.java`。 - 平台统一用户管理变更账号客户归属时由 IAM 调用 `PUT /internal/auth/accounts/{id}/scope` 同步 Auth 的客户隔离兼容字段、成员和应用端;修改该历史契约必须同时检查 `iam/client/AuthAccountClient.java` 与 `PlatformUserManagementService.java`。
- 登录、刷新等业务失败仍是 HTTP 200 + 非 `"200"` body code;Security 入口产生的真实 401 保留给客户端 Token 刷新流程。 - 登录、刷新等业务失败仍是 HTTP 200 + 非 `"200"` body code;Security 入口产生的真实 401 保留给客户端 Token 刷新流程。
- 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。 - 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。
## 分期快速索引
- 一期:负责登录、注册、账号状态和 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 身份协作。
@@ -2492,7 +2492,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg', total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg',
total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3', total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3',
volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg',
calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本', calculation_rule_version VARCHAR(64) NOT NULL COMMENT '体积计算和舍入规则版本',
source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID',
machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号', machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号',
device_measured_at DATETIME(3) NULL COMMENT '本次设备测量时间', device_measured_at DATETIME(3) NULL COMMENT '本次设备测量时间',
@@ -2986,6 +2986,9 @@ PREPARE phase2_measure_statement FROM @phase2_measure_sql;
EXECUTE phase2_measure_statement; EXECUTE phase2_measure_statement;
DEALLOCATE PREPARE phase2_measure_statement; DEALLOCATE PREPARE phase2_measure_statement;
ALTER TABLE fl_inbound_measurement_item
MODIFY COLUMN calculation_rule_version VARCHAR(64) NOT NULL COMMENT '体积计算和舍入规则版本';
DROP PROCEDURE IF EXISTS phase2_measure_add_column; DROP PROCEDURE IF EXISTS phase2_measure_add_column;
CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision ( CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
@@ -257,6 +257,13 @@ Nacos 服务发现允许部分服务交换顺序,但联调时按上述顺序
缺少 `Idempotency-Key` 返回 `S0500`、跨客户资质文件读取返回 `B0001`。订单条件必填 `A0422`、 缺少 `Idempotency-Key` 返回 `S0500`、跨客户资质文件读取返回 `B0001`。订单条件必填 `A0422`、
已提交订单编辑 `A0409`、密码恢复和报告脱敏均已通过。后续发布验证应以该报告为对照,确认上述 5 项全部关闭。 已提交订单编辑 `A0409`、密码恢复和报告脱敏均已通过。后续发布验证应以该报告为对照,确认上述 5 项全部关闭。
部署完成后的最终验收报告为 `reports/client-test-env-20260808032107-59bca758.json`:
163 项全部通过,0 失败、0 跳过,`success=true`。发布前 5 个失败项和 2 个依赖跳过项均已关闭,
包括角色停用、角色权限收缩和成员角色清空后的旧会话撤销,缺少 `Idempotency-Key` 返回 `A0400`,
以及跨客户资质文件读取返回 `A0404`。角色停用后,旧 Token 查询实时权限也必须返回
HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新会话查询停用角色的有效权限时才返回空集合。
账号原密码恢复和报告脱敏检查同时通过。本报告作为本轮 Client 全量 HTTP 验收的最终通过基线。
## 9. 开发和拆分约束 ## 9. 开发和拆分约束
- 每个业务服务只访问自己的数据库;禁止跨库 Join 和直接读取其他服务表。 - 每个业务服务只访问自己的数据库;禁止跨库 Join 和直接读取其他服务表。
+6
View File
@@ -50,3 +50,9 @@
- 旧文件清理只接受 File 自身生成的 `/api/merchant/v1/files/qualifications/{uuid}.{ext}`,并且不得删除本次新上传的 `documentUrl`;不能仅从任意 URL 截取文件名后删除。 - 旧文件清理只接受 File 自身生成的 `/api/merchant/v1/files/qualifications/{uuid}.{ext}`,并且不得删除本次新上传的 `documentUrl`;不能仅从任意 URL 截取文件名后删除。
- 平台上传要求平台身份以及 `platform:customer:create` 或 `platform:customer:edit`,平台下载属于客户档案只读能力并要求 `platform:customer:view`;不得复用审核权限,也不能只依赖 Gateway 路由或前端按钮隐藏。 - 平台上传要求平台身份以及 `platform:customer:create` 或 `platform:customer:edit`,平台下载属于客户档案只读能力并要求 `platform:customer:view`;不得复用审核权限,也不能只依赖 Gateway 路由或前端按钮隐藏。
- `LocalQualificationStorage` 当前生成的 `documentUrl` 使用商户读取路径,但文件实际按 `storage-root/qualifications/{tenantId}/{uuid.ext}` 隔离;变更 URL 形态时同步检查 IAM 已存元数据、平台下载路由和迁移脚本。 - `LocalQualificationStorage` 当前生成的 `documentUrl` 使用商户读取路径,但文件实际按 `storage-root/qualifications/{tenantId}/{uuid.ext}` 隔离;变更 URL 形态时同步检查 IAM 已存元数据、平台下载路由和迁移脚本。
## 分期快速索引
- 一期:资质文件入口为 `QualificationFileController`、`PlatformQualificationFileController`,先读 `../../docs/一期/README.md`、客户审核闭环文档;文件内容归 File,审核状态/元数据归 IAM,上传成功不等于客户已具备下单资格。
- 二期:测量图片入口为 `PlatformMeasurementImageController` 与 `InternalMeasurementImageController`,先读 `../../docs/二期/README.md`、主流程和第三方测量联调说明;重量图片可触发 OCR,第三方尺寸证据不得触发重量 OCR,必须绑定客户、入库单和业务类型。
- 三期:当前 NO-GO;暂不新增装柜、柜封、报关或出仓附件模型。待 `../../docs/三期/三期需求确认表.md` 确认后,再与 Firstleg/PDA 锁定文件证据生命周期和权限码。
+7 -1
View File
@@ -35,7 +35,7 @@
| 费用计算、确认、发布与快照 | `controller/PlatformFbaPricingController.java` → `service/FbaPricingService.java` | 自动计费和完成入库 `AutomaticInboundCompletionService.java`、`InboundCompletionOrchestrator.java`;测试 `service/FbaPricingServiceTest.java`、`InboundCompletionOrchestratorTest.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` | | 商户入库费用查询 | `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` | | 状态、幂等、历史和 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/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql`,执行说明见 `../deploy/sql/README.md` | | 二期表结构和增量 | `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`;历史日期脚本仅作追溯 |
## 修改与验证 ## 修改与验证
@@ -52,3 +52,9 @@
- 从父目录运行 `mvn -pl firstleg -am test`。 - 从父目录运行 `mvn -pl firstleg -am test`。
- 数据库执行顺序:新库执行 `firstleg.sql`;已有库在此后执行 `phase1-firstleg-task-list-index.sql`,为任务列表、按订单查询和分配历史分页补齐索引。 - 数据库执行顺序:新库执行 `firstleg.sql`;已有库在此后执行 `phase1-firstleg-task-list-index.sql`,为任务列表、按订单查询和分配历史分页补齐索引。
- 二期数据库不得只执行单个建表脚本覆盖存量库;测试/存量环境按 `../deploy/sql/README.md` 使用 8 月 4 日统一脚本,并确认末尾核验全部为 `PASS`。 - 二期数据库不得只执行单个建表脚本覆盖存量库;测试/存量环境按 `../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` 直接当作三期实现。
@@ -10,8 +10,8 @@ import java.time.LocalDateTime;
@Getter @Getter
@Setter @Setter
@TableName("fl_loading_pick_task") @TableName("fl_inbound_loading_pick_task")
public class LoadingPickTaskEntity { public class InboundLoadingPickTaskEntity {
@TableId(type = IdType.ASSIGN_ID) private Long id; @TableId(type = IdType.ASSIGN_ID) private Long id;
private Long tenantId; private Long tenantId;
private Long inboundId; private Long inboundId;
@@ -1,14 +1,14 @@
package com.baiyue.firstleg.loading.mapper; package com.baiyue.firstleg.loading.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update; import org.apache.ibatis.annotations.Update;
@Mapper @Mapper
public interface InboundLoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> { public interface InboundLoadingPickTaskMapper extends BaseMapper<InboundLoadingPickTaskEntity> {
@Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, " @Update("UPDATE fl_inbound_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, "
+ "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), " + "remark=COALESCE(#{remark}, remark), expected_count=COALESCE(#{expectedCount}, expected_count), "
+ "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} " + "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
+ "AND status=#{fromStatus} AND version=#{expectedVersion}") + "AND status=#{fromStatus} AND version=#{expectedVersion}")
@@ -8,7 +8,7 @@ import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper; import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper;
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
@@ -75,11 +75,11 @@ public class LoadingRepository {
operations.complete(tenantId, inboundId, operatorId, operation, key, resultJson); operations.complete(tenantId, inboundId, operatorId, operation, key, resultJson);
} }
public LoadingPickTaskEntity findPickTask(long tenantId, long inboundId) { public InboundLoadingPickTaskEntity findPickTask(long tenantId, long inboundId) {
return picks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>() return picks.selectOne(new LambdaQueryWrapper<InboundLoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getTenantId, tenantId).eq(LoadingPickTaskEntity::getInboundId, inboundId)); .eq(InboundLoadingPickTaskEntity::getTenantId, tenantId).eq(InboundLoadingPickTaskEntity::getInboundId, inboundId));
} }
public void createPickTask(LoadingPickTaskEntity task) { picks.insert(task); } public void createPickTask(InboundLoadingPickTaskEntity task) { picks.insert(task); }
public int changePickStatus(long tenantId, long inboundId, String from, String to, long operatorId, public int changePickStatus(long tenantId, long inboundId, String from, String to, long operatorId,
String operatorName, String remark, Integer expectedCount, String operatorName, String remark, Integer expectedCount,
Integer pickedCount, int version) { Integer pickedCount, int version) {
@@ -10,7 +10,7 @@ import com.baiyue.firstleg.loading.dto.LoadingRequests.*;
import com.baiyue.firstleg.loading.dto.LoadingResponses.*; import com.baiyue.firstleg.loading.dto.LoadingResponses.*;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingOperationEntity; import com.baiyue.firstleg.loading.entity.LoadingOperationEntity;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import com.baiyue.firstleg.loading.domain.PickingStatus; import com.baiyue.firstleg.loading.domain.PickingStatus;
import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse; import com.baiyue.firstleg.loading.dto.LoadingResponses.PickingResponse;
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot; import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.Snapshot;
@@ -120,10 +120,10 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "START_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId); if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId); requireLoadingStage(tenantId, inboundId);
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) { if (task == null) {
if (request.expectedVersion() != 0) throw concurrent(); if (request.expectedVersion() != 0) throw concurrent();
task = new LoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId); task = new InboundLoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId);
task.setStatus(PickingStatus.IN_PROGRESS); task.setVersion(0); task.setExpectedCount(request.expectedCount()); task.setStatus(PickingStatus.IN_PROGRESS); task.setVersion(0); task.setExpectedCount(request.expectedCount());
task.setPickedCount(request.pickedCount()); task.setPickedCount(request.pickedCount());
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName); task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
@@ -143,9 +143,9 @@ public class LoadingDeliveryService {
LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash); LoadingActionResponse replay = claimOrReplay(tenantId, inboundId, operatorId, "COMPLETE_PICKING", key, hash);
if (replay != null) return pickResponse(tenantId, inboundId); if (replay != null) return pickResponse(tenantId, inboundId);
requireLoadingStage(tenantId, inboundId); requireLoadingStage(tenantId, inboundId);
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) { if (task == null) {
task = new LoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId); task = new InboundLoadingPickTaskEntity(); task.setTenantId(tenantId); task.setInboundId(inboundId);
task.setStatus(PickingStatus.COMPLETED); task.setVersion(0); task.setExpectedCount(request.expectedCount()); task.setStatus(PickingStatus.COMPLETED); task.setVersion(0); task.setExpectedCount(request.expectedCount());
task.setPickedCount(request.pickedCount()); task.setPickedCount(request.pickedCount());
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName); task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
@@ -164,7 +164,7 @@ public class LoadingDeliveryService {
} }
private PickingResponse pickResponse(long tenantId, long inboundId) { private PickingResponse pickResponse(long tenantId, long inboundId) {
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId); InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
if (task == null) return null; if (task == null) return null;
return new PickingResponse(task.getId(), task.getInboundId(), task.getStatus(), task.getVersion(), return new PickingResponse(task.getId(), task.getInboundId(), task.getStatus(), task.getVersion(),
task.getExpectedCount(), task.getPickedCount(), task.getRemark()); task.getExpectedCount(), task.getPickedCount(), task.getRemark());
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity; import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
import com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity; import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper; import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper; import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import com.baiyue.firstleg.split.entity.PreloadPlanEntity; import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
@@ -34,10 +34,10 @@ public class UnifiedInboundReadRepository {
.eq(PreloadPlanEntity::getInboundId, inboundId)); .eq(PreloadPlanEntity::getInboundId, inboundId));
} }
public LoadingPickTaskEntity findPick(long tenantId, long inboundId) { public InboundLoadingPickTaskEntity findPick(long tenantId, long inboundId) {
return picks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>() return picks.selectOne(new LambdaQueryWrapper<InboundLoadingPickTaskEntity>()
.eq(LoadingPickTaskEntity::getTenantId, tenantId) .eq(InboundLoadingPickTaskEntity::getTenantId, tenantId)
.eq(LoadingPickTaskEntity::getInboundId, inboundId)); .eq(InboundLoadingPickTaskEntity::getInboundId, inboundId));
} }
public List<LoadingEvidenceEntity> findEvidence(long tenantId, long inboundId) { public List<LoadingEvidenceEntity> findEvidence(long tenantId, long inboundId) {
@@ -55,12 +55,12 @@ public class UnifiedInboundReadRepository {
.collect(Collectors.toMap(PreloadPlanEntity::getInboundId, Function.identity(), (left, right) -> left)); .collect(Collectors.toMap(PreloadPlanEntity::getInboundId, Function.identity(), (left, right) -> left));
} }
public Map<Long, LoadingPickTaskEntity> findPicks(Map<Long, Long> tenantByInbound) { public Map<Long, InboundLoadingPickTaskEntity> findPicks(Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) return Map.of(); if (tenantByInbound.isEmpty()) return Map.of();
return picks.selectList(new LambdaQueryWrapper<LoadingPickTaskEntity>() return picks.selectList(new LambdaQueryWrapper<InboundLoadingPickTaskEntity>()
.in(LoadingPickTaskEntity::getInboundId, tenantByInbound.keySet())) .in(InboundLoadingPickTaskEntity::getInboundId, tenantByInbound.keySet()))
.stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) .stream().filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.collect(Collectors.toMap(LoadingPickTaskEntity::getInboundId, Function.identity(), (left, right) -> left)); .collect(Collectors.toMap(InboundLoadingPickTaskEntity::getInboundId, Function.identity(), (left, right) -> left));
} }
public Map<Long, List<LoadingEvidenceEntity>> findEvidence(Map<Long, Long> tenantByInbound) { public Map<Long, List<LoadingEvidenceEntity>> findEvidence(Map<Long, Long> tenantByInbound) {
@@ -163,7 +163,8 @@ public class MeasurementBatchService {
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING)); item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
item.setCalculationRuleVersion(weighted.calculationRuleVersion()); item.setCalculationRuleVersion(weighted.calculationRuleVersion());
item.setSourceImageFileId(source.intelligentImageFileId()); item.setSourceImageFileId(source.intelligentImageFileId());
item.setOverLimitWarning(source.overLimitWarning()); item.setOverLimitWarning(weighted.warning() == null
? source.overLimitWarning() : weighted.warning());
item.setOuterLengthCm(weighted.outerLengthCm()); item.setOuterLengthCm(weighted.outerLengthCm());
item.setOuterWidthCm(weighted.outerWidthCm()); item.setOuterWidthCm(weighted.outerWidthCm());
item.setOuterHeightCm(weighted.outerHeightCm()); item.setOuterHeightCm(weighted.outerHeightCm());
@@ -84,7 +84,7 @@ public class UnifiedInboundQueryService {
private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail, private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail,
com.baiyue.firstleg.split.entity.PreloadPlanEntity preload, com.baiyue.firstleg.split.entity.PreloadPlanEntity preload,
com.baiyue.firstleg.loading.entity.LoadingPickTaskEntity pick, com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity pick,
List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt, List<LoadingEvidenceEntity> evidence, LocalDateTime deliveryAt,
InboundTransportSnapshotEntity transport) { InboundTransportSnapshotEntity transport) {
String mainStatus = mainStatus(inbound.getProcessStatus()); String mainStatus = mainStatus(inbound.getProcessStatus());
@@ -217,7 +217,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_measurement_item (
total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg', total_weight_kg DECIMAL(18,3) NULL COMMENT '本箱规总重量kg',
total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3', total_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3',
volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg', volume_weight_kg DECIMAL(18,3) NOT NULL COMMENT '本箱规材积重kg',
calculation_rule_version VARCHAR(32) NOT NULL COMMENT '体积计算和舍入规则版本', calculation_rule_version VARCHAR(64) NOT NULL COMMENT '体积计算和舍入规则版本',
source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID', source_image_file_id BIGINT UNSIGNED NULL COMMENT '测量设备图片文件ID',
machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号', machine_no VARCHAR(64) NULL COMMENT '本次测量设备编号',
device_measured_at DATETIME(3) NULL COMMENT '本次设备测量时间', device_measured_at DATETIME(3) NULL COMMENT '本次设备测量时间',
@@ -228,6 +228,92 @@ CREATE TABLE IF NOT EXISTS fl_loading_plan_history (
REFERENCES fl_loading_plan_item (id) REFERENCES fl_loading_plan_item (id)
) ENGINE=InnoDB COMMENT='智能装柜追加式不可变操作历史'; ) ENGINE=InnoDB COMMENT='智能装柜追加式不可变操作历史';
-- fl_loading_pick_task 曾被另一套入库汇总模型复用。先按字段签名识别并迁走,
-- 再创建智能装柜任务表;未知或混合结构必须人工处理,禁止静默套用错误表型。
DROP PROCEDURE IF EXISTS migrate_legacy_loading_pick_task;
DELIMITER $$
CREATE PROCEDURE migrate_legacy_loading_pick_task()
BEGIN
DECLARE source_exists 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_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_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');
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');
IF source_smart_columns = 3 AND source_inbound_columns = 0 THEN
SET source_exists = 1;
ELSEIF source_smart_columns = 0 AND source_inbound_columns = 5 THEN
SELECT COUNT(*) INTO target_exists
FROM information_schema.tables
WHERE table_schema = 'baiyue_firstleg'
AND table_name = 'fl_inbound_loading_pick_task';
IF target_exists = 0 THEN
RENAME TABLE fl_loading_pick_task TO fl_inbound_loading_pick_task;
ELSE
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
SIGNAL SQLSTATE '45000'
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,
remark, updated_by, updated_by_name, created_at, updated_at)
SELECT source.id, source.tenant_id, source.inbound_id, source.status, source.version,
source.expected_count, source.picked_count, source.remark, source.updated_by,
source.updated_by_name, source.created_at, source.updated_at
FROM fl_loading_pick_task source
LEFT JOIN fl_inbound_loading_pick_task target
ON target.id = source.id
WHERE target.id IS NULL;
IF EXISTS (
SELECT 1
FROM fl_loading_pick_task source
LEFT JOIN fl_inbound_loading_pick_task target ON target.id = source.id
WHERE target.id IS NULL
OR NOT (target.tenant_id <=> source.tenant_id)
OR NOT (target.inbound_id <=> source.inbound_id)
OR NOT (target.status <=> source.status)
OR NOT (target.version <=> source.version)
OR NOT (target.expected_count <=> source.expected_count)
OR NOT (target.picked_count <=> source.picked_count)
OR NOT (target.remark <=> source.remark)
OR NOT (target.updated_by <=> source.updated_by)
OR NOT (target.updated_by_name <=> source.updated_by_name)
OR NOT (target.created_at <=> source.created_at)
OR NOT (target.updated_at <=> source.updated_at)
) THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '拣货任务迁移失败:新旧入库汇总任务存在主键冲突';
END IF;
DROP TABLE fl_loading_pick_task;
END IF;
ELSE
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '拣货任务迁移失败:fl_loading_pick_task 字段签名未知或混合';
END IF;
END IF;
END$$
DELIMITER ;
CALL migrate_legacy_loading_pick_task();
DROP PROCEDURE migrate_legacy_loading_pick_task;
CREATE TABLE IF NOT EXISTS fl_loading_pick_task ( CREATE TABLE IF NOT EXISTS fl_loading_pick_task (
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID', id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID', loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
@@ -103,6 +103,19 @@ class CustomerDimensionConfigSqlContractTest {
.contains("客户尺寸计算配置权限码、固定ID或父节点不正确"); .contains("客户尺寸计算配置权限码、固定ID或父节点不正确");
} }
@Test
void measurementRuleVersionFitsAllBoxTypeNamesAndUpgradesExistingTables() throws Exception {
String schema = resource("db/phase2-inbound-schema.sql");
String unified = Files.readString(
Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(schema).contains("calculation_rule_version VARCHAR(64) NOT NULL");
assertThat(unified)
.contains("calculation_rule_version VARCHAR(64) NOT NULL")
.contains("MODIFY COLUMN calculation_rule_version VARCHAR(64) NOT NULL");
}
@Test @Test
void unifiedMigrationUpgradesOnlyRecognizedLegacyTableAndBackfillsIdempotently() throws Exception { void unifiedMigrationUpgradesOnlyRecognizedLegacyTableAndBackfillsIdempotently() throws Exception {
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"), String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
@@ -0,0 +1,37 @@
package com.baiyue.firstleg.repository;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baiyue.firstleg.entity.LoadingPickTaskEntity;
import com.baiyue.firstleg.loading.entity.InboundLoadingPickTaskEntity;
import com.baiyue.firstleg.loading.mapper.InboundLoadingPickTaskMapper;
import org.apache.ibatis.annotations.Update;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat;
class LoadingPickTaskMappingContractTest {
@Test
void detailedAndInboundPickEntitiesUseDistinctTables() {
assertThat(LoadingPickTaskEntity.class.getAnnotation(TableName.class).value())
.isEqualTo("fl_loading_pick_task");
assertThat(InboundLoadingPickTaskEntity.class.getAnnotation(TableName.class).value())
.isEqualTo("fl_inbound_loading_pick_task")
.isNotEqualTo(LoadingPickTaskEntity.class.getAnnotation(TableName.class).value());
}
@Test
void inboundMapperUsesInboundTaskTableAndFields() throws Exception {
Method method = InboundLoadingPickTaskMapper.class.getDeclaredMethod(
"changeStatus", long.class, long.class, String.class, String.class,
long.class, String.class, String.class, Integer.class, Integer.class, int.class);
String sql = method.getAnnotation(Update.class).value()[0];
assertThat(sql)
.contains("UPDATE fl_inbound_loading_pick_task")
.contains("tenant_id=#{tenantId}")
.contains("inbound_id=#{inboundId}")
.doesNotContain("fl_loading_pick_task");
}
}
@@ -112,6 +112,30 @@ class CartonMeasurementServiceTest {
verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L); verify(images).delete("internal-token", 90001L, 10001L, 70001L, 88001L);
} }
@Test
void oversizeScanKeepsImageAndActualDimensionsForManualReview() {
InboundMeasurementEntity measurement = measurement(30001L, "RECEIVED", 0);
when(repository.findItemByBoxCode(10001L, "RK-001#5-1")).thenReturn(null);
when(repository.findLatestEditableMeasurement(10001L, 70001L)).thenReturn(measurement);
when(repository.listItems(10001L, 30001L)).thenAnswer(invocation -> List.of(insertedItem.get()));
when(repository.updateMeasurement(measurement)).thenReturn(true);
CartonMeasurementCommand oversize = new CartonMeasurementCommand(
"RK-001#5-1", new BigDecimal("60.100"), new BigDecimal("50.100"),
new BigDecimal("50.100"), new BigDecimal("12.500"), "MJ-01",
LocalDateTime.parse("2026-08-07T10:30:00"),
new MockMultipartFile("image", "measure.jpg", "image/jpeg", "image".getBytes()));
CartonMeasurementResult result = service.upsert(10001L, 90001L, oversize);
assertThat(result.created()).isTrue();
assertThat(insertedItem.get().getSourceImageFileId()).isEqualTo(99001L);
assertThat(insertedItem.get().getLengthCm()).isEqualByComparingTo("60.100");
assertThat(insertedItem.get().getCalculationRuleVersion())
.isEqualTo("ACTUAL_DIMENSION_OVERSIZE_V1");
assertThat(insertedItem.get().getOverLimitWarning()).contains("人工复核");
verify(images, never()).delete(any(), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE), any(Long.TYPE));
}
@Test @Test
void submittedInboundRejectsDeviceBeforeUploadingImage() { void submittedInboundRejectsDeviceBeforeUploadingImage() {
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("MEASURE_PENDING")); when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("MEASURE_PENDING"));
+6
View File
@@ -28,3 +28,9 @@
- 普通业务响应由下游服务返回 HTTP 200 + body code;Gateway 自身的认证/会话失败仍保留真实 401/403,不能为了统一响应改成 HTTP 200。 - 普通业务响应由下游服务返回 HTTP 200 + body code;Gateway 自身的认证/会话失败仍保留真实 401/403,不能为了统一响应改成 HTTP 200。
- CORS 生产来源必须为明确 HTTPS 域名,不能放宽为 `*`。 - CORS 生产来源必须为明确 HTTPS 域名,不能放宽为 `*`。
- 从父目录运行 `mvn -pl gateway -am test`。 - 从父目录运行 `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 聚合。
+6
View File
@@ -53,3 +53,9 @@
- 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。 - 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。
- `deploy/scripts/initialize-test-databases.sh` 的 IAM 客户档案和 Warehouse 主数据脚本顺序必须与当前文件保持一致;测试环境正式增量仍以 `../deploy/sql/README.md` 指定的统一迁移为准。 - `deploy/scripts/initialize-test-databases.sh` 的 IAM 客户档案和 Warehouse 主数据脚本顺序必须与当前文件保持一致;测试环境正式增量仍以 `../deploy/sql/README.md` 指定的统一迁移为准。
- 从父目录运行 `mvn -pl iam -am test`,再按影响执行一期认证/IAM API 回归。 - 从父目录运行 `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 消费者。
+6
View File
@@ -37,3 +37,9 @@
- OCR 原始响应和第三方密钥不得写入应用日志,真实供应商调用必须保持默认关闭。 - OCR 原始响应和第三方密钥不得写入应用日志,真实供应商调用必须保持默认关闭。
- 单图接口只接收 File 传入的受控图片内容,不负责保存 `file_object`、入库单或重量确认记录;响应 DTO 或错误语义变化时同步检查 File 的 `WeightOcrClient`、`MeasurementImageService` 和契约测试。 - 单图接口只接收 File 传入的受控图片内容,不负责保存 `file_object`、入库单或重量确认记录;响应 DTO 或错误语义变化时同步检查 File 的 `WeightOcrClient`、`MeasurementImageService` 和契约测试。
- 从父目录运行 `mvn -pl integration-service -am test`。 - 从父目录运行 `mvn -pl integration-service -am test`。
## 分期快速索引
- 一期:不承载一期订单主流程;仅提供可复用的第三方适配基础,不得把供应商 DTO 直接变成订单或客户领域模型。范围依据先读 `../../docs/一期/README.md`。
- 二期:当前主要消费者是 `file/` 的重量 OCR 链路;先读 `../../docs/二期/README.md`、主流程和第三方测量联调说明。真实 OCR 凭证未配置时只能验证失败后人工修正的降级链路,不能伪造识别成功。
- 三期:当前 NO-GO;不提前接入自动报关、船期、港务或其他外部业务接口。待 `../../docs/三期/三期需求确认表.md` 解除相关阻断项后,再为每个供应商单独确认契约、开关、超时、重试、幂等和审计边界。
+6
View File
@@ -48,3 +48,9 @@
- `POST /api/platform/v1/orders/first-leg/{id}/submit?tenantId={tenantId}`:平台代客提交订单,校验 `platform:order:submit`,必须携带 `Idempotency-Key`。 - `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` 以客户隔离兼容字段、操作人、操作和幂等键建立唯一约束。 - `src/main/resources/openapi/order-v1.yaml` 是静态 OpenAPI 入口;`ord_idempotency_record` 以客户隔离兼容字段、操作人、操作和幂等键建立唯一约束。
- 数据库执行顺序:新库先执行 `order.sql`,已有库执行 `phase1-order-extension.sql`;后者包含幂等表创建,必须在部署订单写接口前完成。 - 数据库执行顺序:新库先执行 `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。
File diff suppressed because it is too large Load Diff
+257
View File
@@ -0,0 +1,257 @@
{
"reportName": "后台管理测试环境 HTTP API 验收",
"marker": "ADMIN-API-20260808030949923",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T03:09:50.497Z",
"startedAt": "2026-08-08T03:09:49.923Z",
"success": true,
"fatal": null,
"summary": {
"total": 12,
"passed": 12,
"failed": 0
},
"modules": [
{
"name": "supplemental",
"startedAt": "2026-08-08T03:09:50.302Z",
"finishedAt": "2026-08-08T03:09:50.497Z",
"success": true,
"passed": 11,
"failed": 0,
"fatal": null
}
],
"scenarios": [],
"deploymentBlockers": [],
"createdResources": {},
"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": "1f1e9c3b-11bb-4931-a6dd-fa576b7c95b5, abee7733-84b8-457d-b55f-91b7aa54f42d",
"traceId": "b0a64f960ec54ff89eb325abb5cc1c8d",
"evidence": "",
"checkedAt": "2026-08-08T03:09:50.301Z"
},
{
"module": "supplemental",
"scenario": null,
"name": "order-service 状态响应使用统一成功体",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/status"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "a5f466bf-ae64-4aa5-95ea-8d1968682163, dc93264c-bc52-42c6-a7e1-3dd7adff8559",
"traceId": "0c23c4de34694a139a2c895073ee0934",
"evidence": "HTTP=200, code=200",
"checkedAt": "2026-08-08T03:09:50.344Z"
},
{
"module": "supplemental",
"scenario": null,
"name": "order-service 状态字段完整且 readiness 语义明确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/status"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "a5f466bf-ae64-4aa5-95ea-8d1968682163, dc93264c-bc52-42c6-a7e1-3dd7adff8559",
"traceId": "0c23c4de34694a139a2c895073ee0934",
"evidence": "service=order-service, status=UP, readiness=UP",
"checkedAt": "2026-08-08T03:09:50.344Z"
},
{
"module": "supplemental",
"scenario": null,
"name": "firstleg-service 状态响应使用统一成功体",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/status"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "04765fab-c4bf-4c63-af3d-2a7d6a3f955e, 89a82465-cb1e-484d-b78f-5844bb9d8f81",
"traceId": "f6e4f3759e6841769a8e905154d5f5f8",
"evidence": "HTTP=200, code=200",
"checkedAt": "2026-08-08T03:09:50.371Z"
},
{
"module": "supplemental",
"scenario": null,
"name": "firstleg-service 状态字段完整且 readiness 语义明确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/first-leg/status"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "04765fab-c4bf-4c63-af3d-2a7d6a3f955e, 89a82465-cb1e-484d-b78f-5844bb9d8f81",
"traceId": "f6e4f3759e6841769a8e905154d5f5f8",
"evidence": "service=firstleg-service, status=UP, readiness=UP",
"checkedAt": "2026-08-08T03:09:50.371Z"
},
{
"module": "supplemental",
"scenario": null,
"name": "file-service 状态响应使用统一成功体",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/files/status"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "c14fe096-d5ae-447f-96ee-37802b84b58a, 439bfbc2-6441-4087-9962-53afff6c1f5f",
"traceId": "1fca0eae5e20493582ab64ce34c30181",
"evidence": "HTTP=200, code=200",
"checkedAt": "2026-08-08T03:09:50.400Z"
},
{
"module": "supplemental",
"scenario": null,
"name": "file-service 状态字段完整且 readiness 语义明确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/files/status"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "c14fe096-d5ae-447f-96ee-37802b84b58a, 439bfbc2-6441-4087-9962-53afff6c1f5f",
"traceId": "1fca0eae5e20493582ab64ce34c30181",
"evidence": "service=file-service, status=UP, readiness=UP",
"checkedAt": "2026-08-08T03:09:50.400Z"
},
{
"module": "supplemental",
"scenario": null,
"name": "warehouse-service 状态响应使用统一成功体",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/warehouses/status"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "b20712d9-2747-412b-9b98-f16e68e09b6a, 4206f98c-81e1-4bfc-b5ba-d679e825e211",
"traceId": "1900c4aea06346d49219bcabbe1647de",
"evidence": "HTTP=200, code=200",
"checkedAt": "2026-08-08T03:09:50.439Z"
},
{
"module": "supplemental",
"scenario": null,
"name": "warehouse-service 状态字段完整且 readiness 语义明确",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/warehouses/status"
},
"http": 200,
"code": "200",
"message": "success",
"requestId": "b20712d9-2747-412b-9b98-f16e68e09b6a, 4206f98c-81e1-4bfc-b5ba-d679e825e211",
"traceId": "1900c4aea06346d49219bcabbe1647de",
"evidence": "service=warehouse-service, status=UP, readiness=UP",
"checkedAt": "2026-08-08T03:09:50.439Z"
},
{
"module": "supplemental",
"scenario": null,
"name": "平台接口未携带令牌返回 401",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/status"
},
"http": 401,
"code": "A0401",
"message": "未登录或令牌已失效",
"requestId": "feab15f3-239",
"traceId": null,
"evidence": "HTTP=401, code=A0401",
"checkedAt": "2026-08-08T03:09:50.468Z"
},
{
"module": "supplemental",
"scenario": null,
"name": "平台接口伪造令牌返回 401",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/status"
},
"http": 401,
"code": "A0401",
"message": "未登录或令牌已失效",
"requestId": "5a78b8b2-240",
"traceId": null,
"evidence": "HTTP=401, code=A0401",
"checkedAt": "2026-08-08T03:09:50.481Z"
},
{
"module": "supplemental",
"scenario": null,
"name": "未认证请求不返回业务成功码",
"passed": true,
"deploymentBlocker": false,
"request": {
"method": "GET",
"path": "/api/platform/v1/orders/status"
},
"http": 401,
"code": "A0401",
"message": "未登录或令牌已失效",
"requestId": "feab15f3-241",
"traceId": null,
"evidence": "HTTP=401, code=A0401",
"checkedAt": "2026-08-08T03:09:50.497Z"
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,140 @@
{
"marker": "20260808033152-30c7cede",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T03:31:52.981Z",
"completedAt": "2026-08-08T03:31:53.854Z",
"success": false,
"fatal": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0400\""
},
"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": null,
"measurementImageFileIds": [],
"weightImageFileIds": []
},
"steps": [
{
"module": "phase2-session",
"name": "client login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:31:52.983Z",
"durationMs": 374,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-5ca244f4-1bed-4cce-a87f-9e53cd7eb893, client-phase2-5ca244f4-1bed-4cce-a87f-9e53cd7eb893",
"traceId": "b54f117ac5314d4c8154024ef181e6cd",
"evidence": {
"username": "merchant_admin"
}
},
{
"module": "phase2-session",
"name": "load current enterprise profile",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:31:53.357Z",
"durationMs": 41,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-30b4cae2-7ed3-4ae8-9265-77c442c0cf70, client-phase2-30b4cae2-7ed3-4ae8-9265-77c442c0cf70",
"traceId": "4094b52e45fb4365af92ebfa37ea55d7",
"evidence": {
"tenantId": "10001",
"tenantName": "一期演示企业",
"status": "ACTIVE"
}
},
{
"module": "phase2-session",
"name": "client has order view permission",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:31:53.398Z",
"durationMs": 25,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-b9da4226-9130-444a-8eda-5d810e5b2323, client-phase2-b9da4226-9130-444a-8eda-5d810e5b2323",
"traceId": "fb669cc4e4df4acbb1b576c1454d3da5",
"evidence": {
"permissionCount": 27
}
},
{
"module": "phase2-fixture",
"name": "platform fixture account login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:31:53.423Z",
"durationMs": 354,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-649afafa-3728-47af-a530-a318ceb664f7, client-phase2-649afafa-3728-47af-a530-a318ceb664f7",
"traceId": "b60c28e9cda9483cac29b08c032ec8a4",
"evidence": {
"username": "platform_admin"
}
},
{
"module": "phase2-fixture",
"name": "discover current enterprise inbound fixtures",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:31:53.778Z",
"durationMs": 47,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-45e96f9f-e7d9-461e-bb2b-682cc5ebb460, client-phase2-45e96f9f-e7d9-461e-bb2b-682cc5ebb460",
"traceId": "ae24d3d7603e4edd900dc4b6b6a693aa",
"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:31:53.825Z",
"durationMs": 29,
"http": 200,
"code": "A0400",
"message": "入库流程状态不合法",
"requestId": "client-phase2-ffdeb1e9-3493-42b4-928d-252d7d02fb75, client-phase2-ffdeb1e9-3493-42b4-928d-252d7d02fb75",
"traceId": "2f7c91ce5a284300ba40513c6e3c51e8",
"evidence": {
"inboundId": "2085909619958919170"
},
"error": {
"name": "Error",
"message": "business code: expected=\"200\" actual=\"A0400\""
}
}
]
}
@@ -0,0 +1,281 @@
{
"marker": "20260808033235-68aeee0f",
"gateway": "http://8.163.30.128:9000",
"generatedAt": "2026-08-08T03:32:35.635Z",
"completedAt": "2026-08-08T03:32:36.765Z",
"success": false,
"fatal": {
"name": "Error",
"message": "unified inbound process status: expected=\"WAIT_SPLIT\" 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:32:35.639Z",
"durationMs": 388,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-85e84081-79ba-406f-8015-138be32cb552, client-phase2-85e84081-79ba-406f-8015-138be32cb552",
"traceId": "f42a864524af465cba6a8c6200b4f234",
"evidence": {
"username": "merchant_admin"
}
},
{
"module": "phase2-session",
"name": "load current enterprise profile",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:32:36.027Z",
"durationMs": 36,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-1ca35db4-77a8-4dc7-aa6f-75aebdc4ef13, client-phase2-1ca35db4-77a8-4dc7-aa6f-75aebdc4ef13",
"traceId": "1e6ae512dcbf404b92af584481e67c9a",
"evidence": {
"tenantId": "10001",
"tenantName": "一期演示企业",
"status": "ACTIVE"
}
},
{
"module": "phase2-session",
"name": "client has order view permission",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:32:36.063Z",
"durationMs": 29,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-096d6bf0-8bc7-48c0-a765-6b0603fc0c72, client-phase2-096d6bf0-8bc7-48c0-a765-6b0603fc0c72",
"traceId": "74d55387cbbe48738e57c7c6869bede9",
"evidence": {
"permissionCount": 27
}
},
{
"module": "phase2-fixture",
"name": "platform fixture account login",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:32:36.092Z",
"durationMs": 346,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-4b57e735-10d1-4f18-a4c0-673acb627ffd, client-phase2-4b57e735-10d1-4f18-a4c0-673acb627ffd",
"traceId": "75ccb2e620574a2ea42b5bbc374f610e",
"evidence": {
"username": "platform_admin"
}
},
{
"module": "phase2-fixture",
"name": "discover current enterprise inbound fixtures",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:32:36.439Z",
"durationMs": 47,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-5fa80e12-1433-4a24-b0e7-6d98de4f03e4, client-phase2-5fa80e12-1433-4a24-b0e7-6d98de4f03e4",
"traceId": "b144c312406641c6830d0dcc9867826c",
"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:32:36.486Z",
"durationMs": 28,
"http": 200,
"code": "A0400",
"message": "入库流程状态不合法",
"requestId": "client-phase2-32c11a8c-89ac-43e8-b821-2a0c691e368e, client-phase2-32c11a8c-89ac-43e8-b821-2a0c691e368e",
"traceId": "165e30521b784f57b26ca872b67e851c",
"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:32:36.514Z",
"durationMs": 40,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-508a51db-5fa9-4230-ab6a-886a0800e1aa, client-phase2-508a51db-5fa9-4230-ab6a-886a0800e1aa",
"traceId": "3242b70df83b48f38ed2aaab148ad526",
"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:32:36.554Z",
"durationMs": 77,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-e9048af6-751a-4d20-907b-c6927551e605, client-phase2-e9048af6-751a-4d20-907b-c6927551e605",
"traceId": "7341d4ca976e406dafbaf99946f6ccce",
"evidence": {
"total": "263"
}
},
{
"module": "phase2-client-visibility",
"name": "unfinished inbound billing remains unavailable",
"passed": true,
"skipped": false,
"startedAt": "2026-08-08T03:32:36.631Z",
"durationMs": 26,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-ec7e1084-4771-4826-abf5-3a2dba46b35c, client-phase2-ec7e1084-4771-4826-abf5-3a2dba46b35c",
"traceId": "55e4cb1379f84194904afa01ca512f33",
"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:32:36.657Z",
"durationMs": 32,
"http": 200,
"code": "A0404",
"message": "订单尚未生成入库单",
"requestId": "client-phase2-0e5386b8-8e94-4b39-8985-72212f8abea6, client-phase2-0e5386b8-8e94-4b39-8985-72212f8abea6",
"traceId": "8d7a1a400fde487f9cf8cde02816833d",
"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:32:36.689Z",
"durationMs": 38,
"http": 200,
"code": "A0404",
"message": "入库费用尚未由客服审核并发布",
"requestId": "client-phase2-5e202092-4bcd-4b1b-80f7-ebdce6a082c0, client-phase2-5e202092-4bcd-4b1b-80f7-ebdce6a082c0",
"traceId": "0b5d5257ee3e48579fe20b2eaf1dd167",
"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:32:36.727Z",
"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:32:36.727Z",
"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:32:36.727Z",
"durationMs": 38,
"http": 200,
"code": "200",
"message": "success",
"requestId": "client-phase2-be5cd526-769a-4364-be2d-9e1a923a0272, client-phase2-be5cd526-769a-4364-be2d-9e1a923a0272",
"traceId": "cd41c009b91a4234861633b2eeb02641",
"evidence": {
"orderId": "2085909608158265345",
"mainStatus": "INBOUNDED"
},
"error": {
"name": "Error",
"message": "unified inbound process status: expected=\"WAIT_SPLIT\" actual=undefined"
}
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -11,7 +11,7 @@ import process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!' const DEFAULT_PASSWORD = 'ChangeMe123!'
const DEFAULT_MODULES = ['customers', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement'] const DEFAULT_MODULES = ['customers', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'supplemental']
const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
const CUSTOMER_FIELDS = [ const CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
@@ -10,6 +10,7 @@
| `019fdac1-ae04-7032-bac0-968497ea54ad` | 墨家测量 | `inbound-measurement.mjs` | | `019fdac1-ae04-7032-bac0-968497ea54ad` | 墨家测量 | `inbound-measurement.mjs` |
| `019fd5af-2e85-74b0-94e5-98c1b9ea4d3e` | 船期价格表 | `pricing.mjs` | | `019fd5af-2e85-74b0-94e5-98c1b9ea4d3e` | 船期价格表 | `pricing.mjs` |
| `019fdbb1-ca56-7263-8914-a29d96cc5177` | 客户端创建订单 | `orders.mjs`,负责创建隔离测试订单并补 Admin 状态流程 | | `019fdbb1-ca56-7263-8914-a29d96cc5177` | 客户端创建订单 | `orders.mjs`,负责创建隔离测试订单并补 Admin 状态流程 |
| `019fdf57-193c-7513-ac22-4ea62adaaf96` | Admin 补充边界验收 | `supplemental.mjs`,只读/负向/保护性契约,不创建或删除历史资源 |
## 统一交付要求 ## 统一交付要求
@@ -0,0 +1,235 @@
// Admin HTTP acceptance gaps that are intentionally independent from the CRUD
// modules. This module is read-only: it never creates or mutates business data.
const HEALTH_ENDPOINTS = [
{ path: '/api/platform/v1/orders/status', service: 'order-service' },
{ path: '/api/platform/v1/first-leg/status', service: 'firstleg-service' },
{ path: '/api/platform/v1/files/status', service: 'file-service' },
{ path: '/api/platform/v1/warehouses/status', service: 'warehouse-service', allows503: true },
]
function codeOf(response) {
return response?.body?.code == null ? 'missing' : String(response.body.code)
}
function isMissingRoute(response) {
return response?.http === 404 || response?.http === 405
|| /Not Found|No handler|No static resource/i.test(String(response?.raw || response?.body?.message || ''))
}
function isBusinessFailure(response) {
const code = codeOf(response)
return [400, 401, 403, 404, 409].includes(response?.http)
|| (response?.http === 200 && code !== '200')
}
function pageShape(data) {
return data && Array.isArray(data.records)
&& Number(data.current ?? data.page ?? 1) >= 1
&& Number(data.size ?? 0) >= 0
}
function xlsx(response) {
return response?.http === 200 && response.bytes?.length > 4
&& response.bytes[0] === 0x50 && response.bytes[1] === 0x4b
}
async function checkPage(context, module, name, path, options = {}) {
const response = await context.request('GET', path)
const data = context.apiSuccess(module, `${name}分页查询`, response, `path=${path}`, options)
context.assert(module, `${name}分页结构与记录数组正确`, response, pageShape(data),
`records=${data?.records?.length ?? 'n/a'}, current=${data?.current ?? data?.page ?? 'n/a'}, size=${data?.size ?? 'n/a'}`, options)
return { response, data }
}
export async function run(context) {
const module = 'supplemental'
const scenario = 'service-health-and-auth-boundary'
const blocker = { scenario, deploymentBlocker: true }
// Health endpoints are part of the deployed service contract. Warehouse is
// special: a schema/readiness failure is intentionally HTTP 503, not a fake
// success, so retain that signal in the report.
for (const endpoint of HEALTH_ENDPOINTS) {
const response = await context.request('GET', endpoint.path)
const notReadyEnvelope = endpoint.allows503 && response.http === 503 && codeOf(response) === '200'
const data = notReadyEnvelope
? response.body?.data
: context.apiSuccess(module, `${endpoint.service} 状态响应使用统一成功体`, response,
`HTTP=${response.http}, code=${codeOf(response)}`, isMissingRoute(response) ? blocker : {})
if (notReadyEnvelope) {
context.addStep(module, `${endpoint.service} readiness 失败仍返回统一响应体`, response, true,
`HTTP=503, code=${codeOf(response)}, status=${data?.status || 'missing'}`)
}
const shape = data && data.service === endpoint.service && typeof data.status === 'string'
&& (endpoint.service !== 'warehouse-service' || typeof data.message === 'string')
const ready = response.http === 200 && codeOf(response) === '200' && data?.status === 'UP'
const notReady = endpoint.allows503 && response.http === 503 && codeOf(response) === '200'
&& data && data.service === endpoint.service && data.status !== 'UP' && typeof data.message === 'string'
context.assert(module, `${endpoint.service} 状态字段完整且 readiness 语义明确`, response,
shape && (ready || notReady),
`service=${data?.service || 'missing'}, status=${data?.status || 'missing'}, readiness=${ready ? 'UP' : notReady ? 'NOT_READY' : 'invalid'}`,
isMissingRoute(response) ? blocker : {})
}
// Gateway must reject both absent and malformed bearer credentials before a
// platform handler is reached. This specifically guards against accidental
// route-level permitAll changes. No token or response body is persisted.
for (const [name, token] of [['未携带令牌', ''], ['伪造令牌', 'invalid-admin-token']]) {
const response = await context.request('GET', '/api/platform/v1/orders/status', undefined, token)
const rejected = response.http === 401 && ['A0401', '401'].includes(codeOf(response))
context.addStep(module, `平台接口${name}返回 401`, response, rejected,
`HTTP=${response.http}, code=${codeOf(response)}`)
}
// A route that exists but is not protected is an authorization regression;
// keep the check separate so a service outage does not look like a pass.
const protectedResponse = await context.request('GET', '/api/platform/v1/orders/status', undefined, '')
context.assert(module, '未认证请求不返回业务成功码', protectedResponse,
!(protectedResponse.http === 200 && codeOf(protectedResponse) === '200'),
`HTTP=${protectedResponse.http}, code=${codeOf(protectedResponse)}`)
// Warehouse/master-data read-only boundary checks. All resources are
// platform-public (tenantId=0); passing a customer scope must fail instead
// of silently widening or narrowing the result set.
const warehousePage = await checkPage(context, module, '仓库',
'/api/platform/v1/warehouse-admin?page=1&size=5&status=ACTIVE', { scenario: 'master-data-readonly' })
const warehouseId = String(warehousePage.data?.records?.[0]?.id || '')
if (warehouseId) {
await checkPage(context, module, '仓库筛选',
`/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=0&warehouseType=${encodeURIComponent(warehousePage.data.records[0].warehouseType || 'DOMESTIC')}`,
{ scenario: 'master-data-readonly' })
}
for (const [name, path] of [
['仓库非法状态', '/api/platform/v1/warehouse-admin?page=1&size=5&status=NOT_A_STATUS'],
['仓库客户作用域', '/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=10001'],
]) {
const response = await context.request('GET', path)
context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response),
`HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'master-data-readonly', deploymentBlocker: isMissingRoute(response) })
}
const locationBase = '/api/platform/v1/locations?page=1&size=5&tenantId=0'
const palletBase = '/api/platform/v1/pallets?page=1&size=5&tenantId=0'
const locations = await checkPage(context, module, '库位', locationBase, { scenario: 'warehouse-resources' })
const pallets = await checkPage(context, module, '托盘', palletBase, { scenario: 'warehouse-resources' })
if (warehouseId) {
await checkPage(context, module, '库位仓库筛选', `${locationBase}&warehouseId=${warehouseId}`, { scenario: 'warehouse-resources' })
await checkPage(context, module, '托盘仓库筛选', `${palletBase}&warehouseId=${warehouseId}`, { scenario: 'warehouse-resources' })
}
const resourceScopeChecks = [
['库位客户作用域', '/api/platform/v1/locations?page=1&size=5&tenantId=10001'],
['托盘客户作用域', '/api/platform/v1/pallets?page=1&size=5&tenantId=10001'],
['库位非法状态', `${locationBase}&status=ALLOCATED`],
]
for (const [name, path] of resourceScopeChecks) {
const response = await context.request('GET', path)
context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response),
`HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'warehouse-resources', deploymentBlocker: isMissingRoute(response) })
}
// A stale version is checked without identifying or mutating an existing
// resource; the sentinel ID must be rejected as not found/invalid.
for (const [name, path] of [
['库位过期版本/不存在资源', '/api/platform/v1/locations/999999999999?tenantId=0&warehouseId=999999999999'],
['托盘占用资源保护边界', '/api/platform/v1/pallets/999999999999?tenantId=0&warehouseId=999999999999'],
]) {
const response = await context.request('GET', path)
context.addStep(module, `${name}不泄露或误修改数据`, response, isBusinessFailure(response),
`HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'warehouse-resources', deploymentBlocker: isMissingRoute(response) })
}
// Shared express/partner master data: status/type filters and safe template
// downloads. We do not upload arbitrary or real customer workbooks.
for (const [label, base, invalid] of [
['快递公司', '/api/platform/v1/express-companies', 'UNKNOWN'],
['往来单位', '/api/platform/v1/partners', 'UNKNOWN_TYPE'],
]) {
await checkPage(context, module, label, `${base}?page=1&size=5&tenantId=0&status=ACTIVE`, { scenario: 'shared-master-data' })
const inactive = await checkPage(context, module, `${label}停用筛选`, `${base}?page=1&size=5&tenantId=0&status=INACTIVE`, { scenario: 'shared-master-data' })
context.assert(module, `${label}状态枚举过滤不混入其他状态`, inactive.response,
inactive.data?.records?.every?.((item) => item.status === 'INACTIVE'), `records=${inactive.data?.records?.length ?? 'n/a'}`, { scenario: 'shared-master-data' })
const invalidPath = label === '往来单位'
? `${base}?page=1&size=5&tenantId=0&types=${invalid}`
: `${base}?page=1&size=5&tenantId=0&status=${invalid}`
const invalidResponse = await context.request('GET', invalidPath)
context.addStep(module, `${label}无效筛选参数安全失败`, invalidResponse, isBusinessFailure(invalidResponse),
`HTTP=${invalidResponse.http}, code=${codeOf(invalidResponse)}`, { scenario: 'shared-master-data', deploymentBlocker: isMissingRoute(invalidResponse) })
}
for (const [label, path] of [
['仓库导入模板', '/api/platform/v1/warehouse-admin/import-template'],
['快递公司导入模板', '/api/platform/v1/express-companies/import-template'],
['往来单位导入模板', '/api/platform/v1/partners/import-template'],
]) {
const response = await context.requestBinary('GET', path)
context.addStep(module, `${label}为 XLSX 且不记录二进制`, response, xlsx(response),
`HTTP=${response.http}, bytes=${response.bytes?.length ?? 0}, contentType=${response.contentType || 'missing'}`,
{ scenario: 'shared-master-data', deploymentBlocker: isMissingRoute(response) })
}
// Price-book lifecycle is read-only here; pricing.mjs remains the sole owner
// of uploads/publication. Verify all supported status filters and source
// download evidence from an already existing active/history version.
const pricePath = '/api/platform/v1/fba-pricing/carrier-rates/price-books'
for (const status of ['PENDING_CONFIRMATION', 'ACTIVE', 'SUPERSEDED']) {
const response = await context.request('GET', `${pricePath}?status=${status}`)
const versions = context.apiSuccess(module, `船期价格表 ${status} 状态过滤`, response, `count=${Array.isArray(response.body?.data) ? response.body.data.length : 'n/a'}`, { scenario: 'price-book-readonly', deploymentBlocker: isMissingRoute(response) })
context.assert(module, `船期价格表 ${status} 结果状态一致`, response,
Array.isArray(versions) && versions.every((item) => item.status === status), `count=${versions?.length ?? 'n/a'}`, { scenario: 'price-book-readonly', deploymentBlocker: isMissingRoute(response) })
}
const activeVersionsResponse = await context.request('GET', `${pricePath}?status=ACTIVE`)
const activeVersions = context.apiSuccess(module, '读取现有生效价格版本用于下载安全检查', activeVersionsResponse, '', { scenario: 'price-book-readonly', deploymentBlocker: isMissingRoute(activeVersionsResponse) })
const activeVersionId = String(activeVersions?.[0]?.id || '')
if (activeVersionId) {
const sourceResponse = await context.requestBinary('GET', `${pricePath}/${encodeURIComponent(activeVersionId)}/source`)
context.addStep(module, '现有生效价格源文件下载为 XLSX', sourceResponse, xlsx(sourceResponse),
`versionId=${activeVersionId}, bytes=${sourceResponse.bytes?.length ?? 0}, contentType=${sourceResponse.contentType || 'missing'}`, { scenario: 'price-book-readonly' })
} else {
context.assert(module, '存在可供安全下载核验的生效价格版本', activeVersionsResponse, false, '当前无 ACTIVE 价格版本,无法安全执行源文件下载核验', { scenario: 'price-book-readonly', deploymentBlocker: true })
}
// Download center: list/status pagination and negative access checks only;
// downloads.mjs owns task creation and successful-file polling.
for (const status of ['PENDING', 'RUNNING', 'SUCCEEDED', 'FAILED', 'EXPIRED']) {
const response = await context.request('GET', `/api/platform/v1/files/export-tasks?page=1&size=5&status=${status}`)
const data = context.apiSuccess(module, `下载中心 ${status} 状态分页`, response, '', { scenario: 'download-readonly', deploymentBlocker: isMissingRoute(response) })
context.assert(module, `下载中心 ${status} 过滤结果状态一致`, response,
pageShape(data) && data.records.every((item) => item.status === status), `records=${data?.records?.length ?? 'n/a'}`, { scenario: 'download-readonly', deploymentBlocker: isMissingRoute(response) })
}
for (const [name, path] of [
['不存在下载任务详情', '/api/platform/v1/files/export-tasks/999999999999'],
['不存在下载任务文件', '/api/platform/v1/files/export-tasks/999999999999/download'],
]) {
const response = await context.request('GET', path, undefined, context.token)
context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response), `HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'download-readonly', deploymentBlocker: isMissingRoute(response) })
}
const unauthDownload = await context.request('GET', '/api/platform/v1/files/export-tasks/999999999999/download', undefined, '')
context.addStep(module, '未授权下载请求被拒绝', unauthDownload, unauthDownload.http === 401,
`HTTP=${unauthDownload.http}, code=${codeOf(unauthDownload)}`, { scenario: 'download-readonly' })
// IAM lists are intentionally read-only. The RBAC endpoints are list-shaped
// arrays (not paginated in the current contract); retain that fact instead of
// pretending pagination exists, and verify built-in markers are exposed.
for (const [label, path, builtInField] of [
['平台用户', '/api/platform/v1/iam/users?page=1&size=5', 'protectedAccount'],
['平台角色', '/api/platform/v1/iam/roles', 'roleType'],
['菜单权限', '/api/platform/v1/iam/permissions', 'builtIn'],
]) {
const response = await context.request('GET', path)
const data = context.apiSuccess(module, `${label}只读列表`, response, '', { scenario: 'iam-readonly', deploymentBlocker: isMissingRoute(response) })
const records = label === '平台用户' ? data?.records : data
context.assert(module, `${label}列表结构正确且包含内置保护标识`, response,
label === '平台用户' ? pageShape(data)
: Array.isArray(records) && records.some((item) => label === '平台角色'
? item.roleType === 'SYSTEM'
: Object.hasOwn(item, builtInField)),
`records=${records?.length ?? 'n/a'}, builtInField=${builtInField}`, { scenario: 'iam-readonly', deploymentBlocker: isMissingRoute(response) })
}
for (const [name, path] of [
['角色列表伪造令牌', '/api/platform/v1/iam/roles'],
['菜单权限未携带令牌', '/api/platform/v1/iam/permissions'],
]) {
const response = await context.request('GET', path, undefined, name.includes('伪造') ? 'invalid-admin-token' : '')
context.addStep(module, `${name}拒绝访问`, response, response.http === 401,
`HTTP=${response.http}, code=${codeOf(response)}`, { scenario: 'iam-readonly' })
}
}
@@ -0,0 +1,670 @@
#!/usr/bin/env node
/**
* Client phase-two test-environment acceptance flow.
*
* The platform account is used only to discover deterministic inbound fixtures.
* All phase-two product assertions are executed with the Client account.
*
* Run directly and enter credentials when prompted:
* node scripts/client-phase2-test-env-acceptance.mjs
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
const args = parseArgs(process.argv.slice(2))
const gateway = trimSlash(args.gateway ?? process.env.CLIENT_PHASE2_TEST_GATEWAY
?? 'http://8.163.30.128:9000')
const clientUsername = args['client-username'] ?? process.env.CLIENT_PHASE2_USERNAME
?? await promptText('二期客户端账号', 'merchant_admin')
const clientPassword = args['client-password'] ?? process.env.CLIENT_PHASE2_PASSWORD
?? await promptSecret('二期客户端密码')
const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME
?? await promptText('平台夹具查询账号', 'platform_admin')
const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD
?? await promptSecret('平台夹具查询密码')
const forcedOrderId = args['order-id'] ?? process.env.CLIENT_PHASE2_ORDER_ID ?? null
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_PHASE2_REPORT_DIR ?? 'reports')
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_PHASE2_TIMEOUT_MS ?? 30_000)
if (!clientPassword || !platformPassword) {
console.error('Client and platform passwords are required')
process.exit(2)
}
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
console.error('--timeout-ms must be at least 1000')
process.exit(2)
}
const now = new Date()
const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}`
const reportPath = resolve(reportDir, `client-phase2-test-env-${marker}.json`)
const report = {
marker,
gateway,
generatedAt: now.toISOString(),
completedAt: null,
success: false,
fatal: null,
testAccount: { username: clientUsername },
expectedTerminalState: {
processStatus: 'WAIT_SPLIT',
measureReviewStatus: 'APPROVED',
feeStatus: 'CONFIRMED',
},
resources: {
tenantId: null,
terminalInboundId: null,
terminalOrderId: null,
terminalInboundCode: null,
pendingOrderId: null,
foreignOrderId: null,
measurementImageFileIds: [],
weightImageFileIds: [],
},
steps: [],
}
let clientToken
let platformToken
let terminalFixture
let pendingFixture
let foreignFixture
let platformTerminalDetail
let merchantBilling
try {
await sessionModule()
await fixtureDiscoveryModule()
await unpublishedVisibilityModule()
await terminalBillingModule()
await unifiedOrderModule()
await isolationModule()
report.success = report.steps.every((step) => step.passed)
} catch (error) {
report.fatal = sanitizeError(error)
report.success = false
} finally {
report.completedAt = new Date().toISOString()
await mkdir(reportDir, { recursive: true })
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
printSummary()
}
process.exitCode = report.success ? 0 : 1
async function sessionModule() {
const clientLogin = await apiStep('phase2-session', 'client login', {
method: 'POST', path: '/api/auth/login',
body: { username: clientUsername, password: clientPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'client accessToken')
}, () => ({ username: clientUsername }))
clientToken = clientLogin.json.data.accessToken
const profile = await apiStep('phase2-session', 'load current enterprise profile', {
method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: clientToken,
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.id, 'enterprise id')
assertEqual(json.data?.status, 'ACTIVE', 'enterprise status')
}, ({ json }) => ({ tenantId: json.data.id, tenantName: json.data.tenantName, status: json.data.status }))
report.resources.tenantId = String(profile.json.data.id)
await apiStep('phase2-session', 'client has order view permission', {
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: clientToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(arrayData(json.data).includes('merchant:order:view'), 'merchant:order:view permission is required')
}, ({ json }) => ({ permissionCount: arrayData(json.data).length }))
const platformLogin = await apiStep('phase2-fixture', 'platform fixture account login', {
method: 'POST', path: '/api/auth/login',
body: { username: platformUsername, password: platformPassword },
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.accessToken, 'platform accessToken')
}, () => ({ username: platformUsername }))
platformToken = platformLogin.json.data.accessToken
}
async function fixtureDiscoveryModule() {
const ownInbounds = await apiStep('phase2-fixture', 'discover current enterprise inbound fixtures', {
method: 'GET',
path: `/api/platform/inbounds?tenantId=${report.resources.tenantId}&page=1&size=200`,
token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(Array.isArray(json.data?.records), 'inbound records must be an array')
}, ({ json }) => ({ total: json.data?.total, statusCounts: statusCounts(json.data?.records) }))
const ownRows = pageRecords(ownInbounds.json.data)
terminalFixture = forcedOrderId
? ownRows.find((row) => String(row.orderId) === String(forcedOrderId))
: ownRows.find(isTerminalFixture)
if (!terminalFixture) {
throw new Error(forcedOrderId
? `Configured orderId=${forcedOrderId} is not visible in tenant ${report.resources.tenantId}`
: `Tenant ${report.resources.tenantId} has no WAIT_SPLIT + APPROVED + CONFIRMED fixture`)
}
assertTerminalFixture(terminalFixture)
report.resources.terminalInboundId = String(terminalFixture.id)
report.resources.terminalOrderId = String(terminalFixture.orderId)
report.resources.terminalInboundCode = terminalFixture.inboundCode
pendingFixture = ownRows.find((row) => String(row.orderId) !== report.resources.terminalOrderId
&& !isTerminalFixture(row))
report.resources.pendingOrderId = pendingFixture == null ? null : String(pendingFixture.orderId)
await softApiStep('phase2-fixture', 'terminal state filters find selected inbound', {
method: 'GET',
path: `/api/platform/inbounds?tenantId=${report.resources.tenantId}`
+ '&processStatus=WAIT_SPLIT&measureReviewStatus=APPROVED&feeStatus=CONFIRMED&page=1&size=200',
token: platformToken,
}, ({ json }) => {
requireSuccess(json)
const row = pageRecords(json.data)
.find((item) => String(item.id) === report.resources.terminalInboundId)
assertPresent(row, 'terminal inbound filter row')
assertTerminalFixture(row)
}, ({ json }) => ({ total: json.data?.total, inboundId: report.resources.terminalInboundId }))
const detail = await apiStep('phase2-fixture', 'load platform terminal inbound reference detail', {
method: 'GET',
path: `/api/platform/inbounds/${report.resources.terminalInboundId}?tenantId=${report.resources.tenantId}`,
token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.summary?.id), report.resources.terminalInboundId, 'inbound id')
assertEqual(String(json.data?.summary?.orderId), report.resources.terminalOrderId, 'order id')
assertTerminalFixture(json.data?.summary)
}, ({ json }) => ({
inboundId: json.data.summary.id,
orderId: json.data.summary.orderId,
processStatus: json.data.summary.processStatus,
measureReviewStatus: json.data.summary.measureReviewStatus,
feeStatus: json.data.summary.feeStatus,
}))
platformTerminalDetail = detail.json.data
const allTerminal = await apiStep('phase2-fixture', 'discover foreign terminal fixture for isolation', {
method: 'GET',
path: '/api/platform/inbounds?page=1&size=200',
token: platformToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(Array.isArray(json.data?.records), 'terminal fixture records must be an array')
}, ({ json }) => ({ total: json.data?.total }))
foreignFixture = pageRecords(allTerminal.json.data)
.find((row) => isTerminalFixture(row) && String(row.tenantId) !== report.resources.tenantId)
report.resources.foreignOrderId = foreignFixture == null ? null : String(foreignFixture.orderId)
}
async function unpublishedVisibilityModule() {
if (!pendingFixture) {
recordSkipped('phase2-client-visibility', 'unfinished inbound billing remains unavailable',
'Current enterprise has no non-terminal inbound fixture')
return
}
await apiStep('phase2-client-visibility', 'unfinished inbound billing remains unavailable', {
method: 'GET',
path: `/api/merchant/v1/first-leg/orders/${pendingFixture.orderId}/inbound-billing`,
token: clientToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0404', 'business code')
}, ({ json }) => ({
orderId: pendingFixture.orderId,
processStatus: pendingFixture.processStatus,
feeStatus: pendingFixture.feeStatus,
rejectionCode: json?.code,
}))
await apiStep('phase2-client-visibility', 'unknown order inbound billing is rejected', {
method: 'GET', path: '/api/merchant/v1/first-leg/orders/1/inbound-billing', token: clientToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0404', 'business code')
}, ({ json }) => ({ orderId: 1, rejectionCode: json?.code }))
}
async function terminalBillingModule() {
const response = await softApiStep('phase2-client-billing',
'terminal inbound billing is visible to client', {
method: 'GET',
path: `/api/merchant/v1/first-leg/orders/${report.resources.terminalOrderId}/inbound-billing`,
token: clientToken,
}, ({ json }) => {
requireSuccess(json)
assertPresent(json.data?.inbound?.summary, 'merchant inbound summary')
assertPresent(json.data?.latestFee, 'merchant latest fee')
assertEqual(String(json.data.inbound.summary.id), report.resources.terminalInboundId, 'inbound id')
assertEqual(String(json.data.inbound.summary.orderId), report.resources.terminalOrderId, 'order id')
assertTerminalFixture(json.data.inbound.summary)
assertEqual(json.data.latestFee.feeStatus, 'CONFIRMED', 'latest fee status')
}, ({ json }) => ({
inboundId: json?.data?.inbound?.summary?.id,
orderId: json?.data?.inbound?.summary?.orderId,
processStatus: json?.data?.inbound?.summary?.processStatus,
measureReviewStatus: json?.data?.inbound?.summary?.measureReviewStatus,
feeStatus: json?.data?.latestFee?.feeStatus,
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')
return
}
merchantBilling = response.json.data
await localStep('phase2-client-billing', 'terminal billing aggregate contract', () => {
const summary = merchantBilling.inbound.summary
const fee = merchantBilling.latestFee
assertStringId(summary.id, 'inbound summary id')
assertStringId(summary.orderId, 'inbound order id')
assertStringId(fee.feeId, 'fee id')
assertStringId(fee.inboundId, 'fee inbound id')
assertEqual(String(fee.inboundId), String(summary.id), 'fee inbound relation')
assertEqual(fee.inboundCode, summary.inboundCode, 'fee inbound code')
assertEqual(fee.customerId == null ? null : String(fee.customerId), report.resources.tenantId,
'fee customer isolation')
assertTrue(Number(fee.actualWeightKg) > 0, 'actual weight must be positive')
assertTrue(Number(fee.actualVolumeCbm) > 0, 'actual volume must be positive')
assertTrue(Number(fee.chargeableQuantity) > 0, 'chargeable quantity must be positive')
assertTrue(Number(fee.unitPrice) >= 0, 'unit price must not be negative')
assertMoneyEqual(Number(fee.subtotalAmount) + Number(fee.additionalAmount),
Number(fee.totalAmount), 'fee amount composition')
assertEqual(fee.chargeUnit, fee.chargeMode, 'fee charge unit')
assertTrue(['KG', 'CBM'].includes(fee.chargeMode), 'charge mode must be KG or CBM')
}, {
inboundId: report.resources.terminalInboundId,
feeId: merchantBilling.latestFee.feeId,
feeNo: merchantBilling.latestFee.feeNo,
chargeMode: merchantBilling.latestFee.chargeMode,
chargeableQuantity: merchantBilling.latestFee.chargeableQuantity,
totalAmount: merchantBilling.latestFee.totalAmount,
currency: merchantBilling.latestFee.currency,
})
await localStep('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)) {
assertTrue(['PUBLIC', 'MERCHANT'].includes(file.visibility),
`merchant file visibility must be PUBLIC or MERCHANT, actual=${file.visibility}`)
}
assertEqual(merchantBilling.inbound.publicRemark ?? null,
platformTerminalDetail.publicRemark ?? null, 'public remark')
}, {
internalNoteCount: arrayData(merchantBilling.inbound.internalNotes).length,
statusHistoryCount: arrayData(merchantBilling.inbound.statusHistory).length,
visibleFileCount: arrayData(merchantBilling.inbound.files).length,
})
await localStep('phase2-client-billing', 'measurement and billing basis are internally consistent', () => {
const summary = merchantBilling.measurementSummary
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,
})
const measurementImages = arrayData(merchantBilling.measurementImages)
const weightImages = arrayData(merchantBilling.weightImages)
report.resources.measurementImageFileIds = measurementImages.map((item) => String(item.fileId))
report.resources.weightImageFileIds = weightImages.map((item) => String(item.fileId))
const images = [...measurementImages, ...weightImages]
if (images.length === 0) {
recordSkipped('phase2-client-images', 'published measurement and weight image access',
'Terminal billing aggregate contains no image references')
} else {
for (const image of images.slice(0, 4)) {
await apiStep('phase2-client-images', `download ${image.imageType} image ${image.fileId}`, {
method: 'GET', path: apiPath(image.previewUrl), token: clientToken, responseType: 'bytes',
}, ({ status, bytes, headers }) => {
assertEqual(status, 200, 'HTTP status')
assertTrue(bytes?.length > 0, 'downloaded image must not be empty')
assertTrue((headers.get('content-type') ?? '').startsWith('image/'),
'download content type must be an image')
}, ({ bytes, headers }) => ({
fileId: image.fileId,
imageType: image.imageType,
sizeBytes: bytes.length,
contentType: headers.get('content-type'),
}))
}
}
await apiStep('phase2-client-images', 'unreferenced inbound image is rejected', {
method: 'GET',
path: `/api/merchant/v1/first-leg/orders/${report.resources.terminalOrderId}/inbound-billing/images/1`,
token: clientToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0404', 'business code')
}, ({ json }) => ({ fileId: 1, rejectionCode: json?.code }))
}
async function unifiedOrderModule() {
const orderNo = terminalFixture.orderNo
await apiStep('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,
}, ({ json }) => {
requireSuccess(json)
const row = pageRecords(json.data)
.find((item) => String(item.orderId) === report.resources.terminalOrderId)
assertPresent(row, 'unified terminal order row')
assertEqual(row.mainStatus, 'INBOUNDED', 'unified main status')
assertEqual(String(row.inboundId), report.resources.terminalInboundId, 'unified inbound id')
assertEqual(row.measurement?.reviewStatus, 'APPROVED', 'unified measurement review status')
assertTrue(Number(row.actualGrossWeightKg) > 0, 'unified actual gross weight must be positive')
assertTrue(Number(row.actualVolumeCbm) > 0, 'unified actual volume must be positive')
}, ({ json }) => {
const row = pageRecords(json.data)
.find((item) => String(item.orderId) === report.resources.terminalOrderId)
return { orderId: row?.orderId, mainStatus: row?.mainStatus,
inboundId: row?.inboundId, measurementReviewStatus: row?.measurement?.reviewStatus,
actualGrossWeightKg: row?.actualGrossWeightKg, actualVolumeCbm: row?.actualVolumeCbm }
})
await apiStep('phase2-unified-orders', 'unified order detail includes phase-two timeline', {
method: 'GET', path: `/api/merchant/v1/orders/${report.resources.terminalOrderId}`, token: clientToken,
}, ({ json }) => {
requireSuccess(json)
assertEqual(String(json.data?.summary?.orderId), report.resources.terminalOrderId, 'unified order id')
assertEqual(json.data?.summary?.mainStatus, 'INBOUNDED', 'unified main status')
assertTrue(arrayData(json.data?.timeline).some((item) => item.status === 'INBOUNDED'),
'unified timeline must contain INBOUNDED')
}, ({ json }) => ({
orderId: json.data?.summary?.orderId,
mainStatus: json.data?.summary?.mainStatus,
timelineCount: arrayData(json.data?.timeline).length,
}))
}
async function isolationModule() {
if (!foreignFixture) {
recordSkipped('phase2-isolation', 'cross-enterprise terminal billing is rejected',
'No foreign WAIT_SPLIT + APPROVED + CONFIRMED fixture is visible to the platform account')
return
}
await apiStep('phase2-isolation', 'cross-enterprise terminal billing is rejected', {
method: 'GET',
path: `/api/merchant/v1/first-leg/orders/${foreignFixture.orderId}/inbound-billing`,
token: clientToken,
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertTrue(['A0403', 'A0404'].includes(String(json?.code)),
`expected A0403/A0404, actual=${json?.code}`)
}, ({ json }) => ({
orderId: foreignFixture.orderId,
tenantId: foreignFixture.tenantId,
rejectionCode: json?.code,
}))
}
async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
const startedAt = new Date().toISOString()
const started = Date.now()
let response
try {
response = await httpRequest(request)
validate(response)
report.steps.push(makeStep(module, name, true, startedAt, started, response, evidenceBuilder(response)))
console.log(`PASS [${module}] ${name}`)
return response
} catch (error) {
let evidence = null
if (response) {
try { evidence = evidenceBuilder(response) } catch { evidence = defaultEvidence(response) }
}
const step = makeStep(module, name, false, startedAt, started, response, evidence)
step.error = sanitizeError(error)
report.steps.push(step)
console.error(`FAIL [${module}] ${name}: ${error.message}`)
throw error
}
}
async function softApiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
try { return await apiStep(module, name, request, validate, evidenceBuilder) } catch { return null }
}
async function localStep(module, name, validate, evidence = null) {
const startedAt = new Date().toISOString()
const started = Date.now()
try {
validate()
report.steps.push(makeStep(module, name, true, startedAt, started, null, evidence))
console.log(`PASS [${module}] ${name}`)
} catch (error) {
const step = makeStep(module, name, false, startedAt, started, null, evidence)
step.error = sanitizeError(error)
report.steps.push(step)
console.error(`FAIL [${module}] ${name}: ${error.message}`)
throw error
}
}
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}`
let requestBody = body
if (body != null && !(body instanceof FormData)) {
requestHeaders['Content-Type'] = 'application/json'
requestBody = JSON.stringify(body)
}
const response = await fetch(`${gateway}${path}`, {
method, headers: requestHeaders, body: requestBody, signal: AbortSignal.timeout(timeoutMs),
})
const result = { status: response.status, headers: response.headers, json: null, bytes: null, text: null }
if (responseType === 'bytes') {
result.bytes = new Uint8Array(await response.arrayBuffer())
return result
}
const text = await response.text()
result.text = text
if (text) {
try { result.json = JSON.parse(text) }
catch { throw new Error(`Expected JSON but received ${text.slice(0, 200)}`) }
}
return result
}
function makeStep(module, name, passed, startedAt, started, response, evidence) {
return {
module, name, passed, skipped: false, startedAt, durationMs: Date.now() - started,
http: response?.status ?? null,
code: response?.json?.code == null ? null : String(response.json.code),
message: response?.json?.message ?? null,
requestId: headerOrBody(response, 'x-request-id', 'requestId'),
traceId: headerOrBody(response, 'x-trace-id', 'traceId'),
evidence,
}
}
function recordSkipped(module, name, reason) {
report.steps.push({
module, name, passed: true, skipped: true, startedAt: new Date().toISOString(), durationMs: 0,
http: null, code: null, message: reason, requestId: null, traceId: null, evidence: null,
})
console.log(`SKIP [${module}] ${name}: ${reason}`)
}
function requireSuccess(json) {
assertPresent(json, 'response body')
assertEqual(String(json.code), '200', 'business code')
}
function isTerminalFixture(row) {
return row?.processStatus === 'WAIT_SPLIT'
&& row?.measureReviewStatus === 'APPROVED'
&& row?.feeStatus === 'CONFIRMED'
}
function assertTerminalFixture(row) {
assertPresent(row, 'terminal inbound fixture')
assertEqual(row.processStatus, 'WAIT_SPLIT', 'process status')
assertEqual(row.measureReviewStatus, 'APPROVED', 'measurement review status')
assertEqual(row.feeStatus, 'CONFIRMED', 'fee status')
}
function statusCounts(values) {
const counts = {}
for (const row of arrayData(values)) {
const key = `${row.processStatus ?? 'UNKNOWN'}|${row.measureReviewStatus ?? 'NONE'}|${row.feeStatus ?? 'NONE'}`
counts[key] = (counts[key] ?? 0) + 1
}
return counts
}
function assertStringId(value, label) {
assertPresent(value, label)
assertEqual(typeof value, 'string', `${label} type`)
assertTrue(/^\d+$/.test(value), `${label} must contain only digits`)
}
function assertNumberEqual(actual, expected, label) {
assertEqual(Number(actual), Number(expected), label)
}
function assertMoneyEqual(actual, expected, label) {
assertEqual(Math.round(Number(actual) * 100), Math.round(Number(expected) * 100), label)
}
function defaultEvidence({ json }) {
if (!json || json.data == null) return null
if (typeof json.data !== 'object') return { data: json.data }
return null
}
function pageRecords(data) {
if (Array.isArray(data)) return data
return arrayData(data?.records ?? data?.items ?? data?.content)
}
function arrayData(value) { return Array.isArray(value) ? value : [] }
function headerOrBody(response, headerName, bodyName) {
return response?.headers?.get(headerName)
?? response?.json?.[bodyName]
?? response?.json?.data?.[bodyName]
?? null
}
function assertEqual(actual, expected, label) {
if (actual !== expected) throw new Error(`${label}: expected=${format(expected)} actual=${format(actual)}`)
}
function assertPresent(value, label) {
if (value == null || value === '') throw new Error(`${label} is required`)
}
function assertTrue(value, message) {
if (!value) throw new Error(message)
}
function apiPath(value) {
const url = new URL(String(value), `${gateway}/`)
return `${url.pathname}${url.search}`
}
function printSummary() {
const failed = report.steps.filter((step) => !step.passed)
const skipped = report.steps.filter((step) => step.skipped)
console.log(JSON.stringify({
success: report.success,
marker,
passed: report.steps.filter((step) => step.passed && !step.skipped).length,
failed: failed.length,
skipped: skipped.length,
reportPath,
}, null, 2))
}
function parseArgs(values) {
const parsed = {}
for (let index = 0; index < values.length; index += 1) {
const value = values[index]
if (!value.startsWith('--')) continue
const [rawKey, inlineValue] = value.slice(2).split('=', 2)
parsed[rawKey] = inlineValue ?? values[index + 1]
if (inlineValue == null) index += 1
}
return parsed
}
function compactTimestamp(value) {
return value.toISOString().replace(/[-:TZ.]/g, '').slice(0, 14)
}
function trimSlash(value) { return String(value).replace(/\/+$/, '') }
function format(value) { return typeof value === 'string' ? JSON.stringify(value) : String(value) }
function sanitizeError(error) {
return { name: error?.name ?? 'Error', message: error?.message ?? String(error) }
}
async function promptText(label, defaultValue = '') {
if (!process.stdin.isTTY || !process.stdout.isTTY) return defaultValue
const reader = createInterface({ input: process.stdin, output: process.stdout })
try {
const suffix = defaultValue ? ` [${defaultValue}]` : ''
const value = await reader.question(`${label}${suffix}: `)
return value.trim() || defaultValue
} finally {
reader.close()
}
}
async function promptSecret(label) {
if (!process.stdin.isTTY || !process.stdout.isTTY) return undefined
const input = process.stdin
const output = process.stdout
output.write(`${label}: `)
input.setRawMode(true)
input.resume()
input.setEncoding('utf8')
let value = ''
try {
for await (const chunk of input) {
for (const character of chunk) {
if (character === '\r' || character === '\n') {
output.write('\n')
return value
}
if (character === '\u0003') throw new Error('Input cancelled')
if (character === '\u007f' || character === '\b') {
if (value.length > 0) value = value.slice(0, -1)
continue
}
value += character
}
}
return value
} finally {
input.setRawMode(false)
input.pause()
}
}
+4 -5
View File
@@ -725,13 +725,12 @@ async function rbacAndMemberModule() {
assertEqual(json.data?.status, 'DISABLED', 'role status') assertEqual(json.data?.status, 'DISABLED', 'role status')
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status })) }, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
if (disabledRole) { if (disabledRole) {
await softApiStep('menu-permissions', 'disabled role is reflected by live permission query', { await softApiStep('menu-permissions', 'disabled role revokes stale token permission query', {
method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken, method: 'GET', path: '/api/merchant/v1/iam/authorization/permissions', token: memberToken,
}, ({ json, status }) => { }, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status') assertEqual(status, 401, 'HTTP status')
requireSuccess(json) assertEqual(String(json?.code), 'A0401', 'business code')
assertEqual(arrayData(json.data).length, 0, 'effective backend permission count') }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
}, ({ json }) => ({ backendPermissions: json?.data, visibleClientMenus: visibleClientMenus(arrayData(json?.data)) }))
await softApiStep('menu-permissions', 'disabled role blocks stale token order access', { await softApiStep('menu-permissions', 'disabled role blocks stale token order access', {
method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken, method: 'GET', path: '/api/merchant/v1/orders/first-leg?page=1&size=20', token: memberToken,
}, ({ json, status }) => { }, ({ json, status }) => {
+7 -1
View File
@@ -28,7 +28,7 @@
| 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java`、`MasterDataQueries.java` | | 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java`、`MasterDataQueries.java` |
| 仓库、地址、能力、往来单位与快递公司持久化 | `entity/`、`mapper/`、`repository/`;往来单位多类型关联为 `PartnerTypeEntity.java` 与 `wh_partner_type` | | 仓库、地址、能力、往来单位与快递公司持久化 | `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/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/2026-08-04-二期入库新增表字段-MySQL8一键执行.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/` | | 测试 | `src/test/java/com/baiyue/warehouse/` |
## 修改与验证 ## 修改与验证
@@ -49,3 +49,9 @@
- Warehouse 的数据库访问异常和 readiness/结构状态探针都保留真实 HTTP 503;其他由公共异常处理器捕获的业务失败才使用 HTTP 200 + body code。 - Warehouse 的数据库访问异常和 readiness/结构状态探针都保留真实 HTTP 503;其他由公共异常处理器捕获的业务失败才使用 HTTP 200 + body code。
- 从父目录运行 `mvn -pl warehouse -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1`。 - 从父目录运行 `mvn -pl warehouse -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1`。
- 二期直接验证至少运行 `PalletApplicationServiceTest`、`LocationApplicationServiceTest`、`LocationResourceServiceTest`、`PalletLocationAllocationServiceTest` 及两个平台 Controller 契约测试;存量数据库迁移后确认统一脚本末尾 Warehouse 核验全部为 `PASS`。 - 二期直接验证至少运行 `PalletApplicationServiceTest`、`LocationApplicationServiceTest`、`LocationResourceServiceTest`、`PalletLocationAllocationServiceTest` 及两个平台 Controller 契约测试;存量数据库迁移后确认统一脚本末尾 Warehouse 核验全部为 `PASS`。
## 分期快速索引
- 一期:`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。
@@ -28,7 +28,16 @@ public final class MasterDataQueries {
@Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String type, @Pattern(regexp = "^(CUSTOMER|SUPPLIER|BOTH|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String type,
@Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"}) @Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"})
@Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status @Pattern(regexp = "^(ACTIVE|INACTIVE)$") String status
,
@Schema(description = "往来单位类型集合(可重复传参)", allowableValues = {
"CUSTOMER", "SUPPLIER", "CARRIER", "CUSTOMS_BROKER", "SHIPPER", "CONSIGNEE"})
@Size(max = 6) List<@Pattern(regexp = "^(CUSTOMER|SUPPLIER|CARRIER|CUSTOMS_BROKER|SHIPPER|CONSIGNEE)$") String> types
) { ) {
/** 保持旧版五参数构造契约兼容。 */
public PartnerPageQuery(long page, long size, String keyword, String type, String status) {
this(page, size, keyword, type, status, null);
}
public PartnerPageQuery { public PartnerPageQuery {
if (page == 0) { if (page == 0) {
page = 1; page = 1;