优化
This commit is contained in:
@@ -85,6 +85,16 @@
|
||||
- 上表中的 `模块/.../` 从对应模块的 `src/main/java/com/baiyue/<domain>/` 起算;二期链路跨 `order`、`firstleg`、`warehouse`、`file`、`integration-service`,修改 DTO、内部令牌、状态或表结构时必须逐个检查调用方与被调用方。
|
||||
- 二期入库主状态归 `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/`。
|
||||
@@ -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`。
|
||||
- 平台管理与头程履约补齐的本地数据库只读核验、SQL 清单和回归结果见 `docs/2026-07-31平台管理与头程履约补齐回归报告.md`。
|
||||
- IAM 授角、订单主数据、File、Gateway CORS 与文件日志的本轮结果见 `docs/2026-07-31-P0-P2问题修复回归报告.md`。
|
||||
- 本轮测试环境数据库增量统一从 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql` 执行,执行边界与核验方式见 `deploy/sql/README.md`。
|
||||
- 二期入库新建或存量测试环境升级使用 `deploy/sql/2026-08-04-二期入库新增表字段-MySQL8一键执行.sql`;旧的 `2026-08-03-二期入库表结构-TEST环境一键执行.sql` 只保留为早期基线参考,不替代 8 月 4 日最终增量入口。
|
||||
- 测试环境数据库增量统一从 `deploy/sql/头程数据库统一增量-MySQL8.sql` 执行,执行边界与末尾核验方式见 `deploy/sql/README.md`;历史日期脚本只用于追溯,不能作为当前部署入口。
|
||||
- CI 总控入口是 `Jenkinsfile`,负责按变更或勾选项并行调度 11 个独立服务 Job;单服务构建、镜像与部署逻辑在 `Jenkinsfile.service`。Job 名必须以模块名结尾,公共模块或 `deploy/` 变化会触发全部服务。
|
||||
- 从本目录运行全量校验:`mvn -DskipTests validate`、`mvn test`。单模块修改优先运行 `mvn -pl <module> -am test`。
|
||||
- 公共导出中心测试环境真实任务验收使用 `scripts/export-center-test-env-acceptance.ps1`;脚本只调用 Gateway,不负责部署或停止 Broker/File/IAM,故障注入由有权限的运维配合。
|
||||
|
||||
@@ -30,3 +30,9 @@
|
||||
- 平台统一用户管理变更账号客户归属时由 IAM 调用 `PUT /internal/auth/accounts/{id}/scope` 同步 Auth 的客户隔离兼容字段、成员和应用端;修改该历史契约必须同时检查 `iam/client/AuthAccountClient.java` 与 `PlatformUserManagementService.java`。
|
||||
- 登录、刷新等业务失败仍是 HTTP 200 + 非 `"200"` body code;Security 入口产生的真实 401 保留给客户端 Token 刷新流程。
|
||||
- 从父目录运行 `mvn -pl auth -am test`,认证接口变更再运行相关 `scripts/` 回归。
|
||||
|
||||
## 分期快速索引
|
||||
|
||||
- 一期:负责登录、注册、账号状态和 Token;先读 `../../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_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3',
|
||||
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',
|
||||
machine_no VARCHAR(64) 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;
|
||||
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;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fl_inbound_weight_revision (
|
||||
|
||||
@@ -257,6 +257,13 @@ Nacos 服务发现允许部分服务交换顺序,但联调时按上述顺序
|
||||
缺少 `Idempotency-Key` 返回 `S0500`、跨客户资质文件读取返回 `B0001`。订单条件必填 `A0422`、
|
||||
已提交订单编辑 `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. 开发和拆分约束
|
||||
|
||||
- 每个业务服务只访问自己的数据库;禁止跨库 Join 和直接读取其他服务表。
|
||||
|
||||
@@ -50,3 +50,9 @@
|
||||
- 旧文件清理只接受 File 自身生成的 `/api/merchant/v1/files/qualifications/{uuid}.{ext}`,并且不得删除本次新上传的 `documentUrl`;不能仅从任意 URL 截取文件名后删除。
|
||||
- 平台上传要求平台身份以及 `platform:customer:create` 或 `platform:customer:edit`,平台下载属于客户档案只读能力并要求 `platform:customer:view`;不得复用审核权限,也不能只依赖 Gateway 路由或前端按钮隐藏。
|
||||
- `LocalQualificationStorage` 当前生成的 `documentUrl` 使用商户读取路径,但文件实际按 `storage-root/qualifications/{tenantId}/{uuid.ext}` 隔离;变更 URL 形态时同步检查 IAM 已存元数据、平台下载路由和迁移脚本。
|
||||
|
||||
## 分期快速索引
|
||||
|
||||
- 一期:资质文件入口为 `QualificationFileController`、`PlatformQualificationFileController`,先读 `../../docs/一期/README.md`、客户审核闭环文档;文件内容归 File,审核状态/元数据归 IAM,上传成功不等于客户已具备下单资格。
|
||||
- 二期:测量图片入口为 `PlatformMeasurementImageController` 与 `InternalMeasurementImageController`,先读 `../../docs/二期/README.md`、主流程和第三方测量联调说明;重量图片可触发 OCR,第三方尺寸证据不得触发重量 OCR,必须绑定客户、入库单和业务类型。
|
||||
- 三期:当前 NO-GO;暂不新增装柜、柜封、报关或出仓附件模型。待 `../../docs/三期/三期需求确认表.md` 确认后,再与 Firstleg/PDA 锁定文件证据生命周期和权限码。
|
||||
|
||||
+7
-1
@@ -35,7 +35,7 @@
|
||||
| 费用计算、确认、发布与快照 | `controller/PlatformFbaPricingController.java` → `service/FbaPricingService.java` | 自动计费和完成入库 `AutomaticInboundCompletionService.java`、`InboundCompletionOrchestrator.java`;测试 `service/FbaPricingServiceTest.java`、`InboundCompletionOrchestratorTest.java` |
|
||||
| 商户入库费用查询 | `controller/MerchantInboundBillingController.java` → `service/MerchantInboundBillingService.java` | 测试 `service/MerchantInboundBillingServiceTest.java`、`controller/MerchantInboundBillingControllerContractTest.java` |
|
||||
| 状态、幂等、历史和 Outbox | `domain/InboundProcessStatus.java`、`service/InboundIdempotencyService.java` | `repository/InboundWorkflowRepository.java`、`entity/Inbound*.java`、`mapper/Inbound*.java`;测试 `service/InboundIdempotencyServiceTest.java`、`repository/InboundWorkflowSqlContractTest.java` |
|
||||
| 二期表结构和增量 | `src/main/resources/db/phase2-inbound-schema.sql`、`phase2-inbound-workflow-migration.sql`、`phase2-inbound-measurement-maintenance.sql`、`phase2-measurement-openapi.sql`、`phase2-third-party-single-measurement-migration.sql`、`phase2-fba-pricing-migration.sql` | 测试/存量环境统一入口为 `../deploy/sql/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`。
|
||||
- 数据库执行顺序:新库执行 `firstleg.sql`;已有库在此后执行 `phase1-firstleg-task-list-index.sql`,为任务列表、按订单查询和分配历史分页补齐索引。
|
||||
- 二期数据库不得只执行单个建表脚本覆盖存量库;测试/存量环境按 `../deploy/sql/README.md` 使用 8 月 4 日统一脚本,并确认末尾核验全部为 `PASS`。
|
||||
|
||||
## 分期快速索引
|
||||
|
||||
- 一期:履约任务与分配入口为 `PlatformFulfillmentAssignmentController`,先读 `../../docs/一期/README.md`、`../../docs/一期/一期开发进度表.md` 和 `../../docs/一期/一期头程订单闭环设计.md`;订单交易状态仍归 `order/`,本模块只管理任务状态、分配历史和幂等。
|
||||
- 二期:入库主线从 `PlatformInboundOrderController`、`PlatformInboundMeasurementController`、`PlatformInboundMeasurementReviewController` 到 `PlatformFbaPricingController`;先读 `../../docs/二期/README.md`、主流程、需求确认清单及联调报告。验收终点为 `WAIT_SPLIT + APPROVED + CONFIRMED`,自动完成入库不得越过测量审核、计费确认和发布门禁。
|
||||
- 三期:当前 NO-GO;后续国内出仓、分柜、装柜、PDA 拣货与船期协作需先解除 P3 阻断并重新定义状态机,不能把二期 `WAIT_SPLIT` 直接当作三期实现。
|
||||
|
||||
+2
-2
@@ -10,8 +10,8 @@ import java.time.LocalDateTime;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@TableName("fl_loading_pick_task")
|
||||
public class LoadingPickTaskEntity {
|
||||
@TableName("fl_inbound_loading_pick_task")
|
||||
public class InboundLoadingPickTaskEntity {
|
||||
@TableId(type = IdType.ASSIGN_ID) private Long id;
|
||||
private Long tenantId;
|
||||
private Long inboundId;
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
package com.baiyue.firstleg.loading.mapper;
|
||||
|
||||
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.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
@Mapper
|
||||
public interface InboundLoadingPickTaskMapper extends BaseMapper<LoadingPickTaskEntity> {
|
||||
@Update("UPDATE fl_loading_pick_task SET status=#{toStatus}, updated_by=#{operatorId}, updated_by_name=#{operatorName}, "
|
||||
public interface InboundLoadingPickTaskMapper extends BaseMapper<InboundLoadingPickTaskEntity> {
|
||||
@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), "
|
||||
+ "picked_count=COALESCE(#{pickedCount}, picked_count), version=version+1 WHERE tenant_id=#{tenantId} AND inbound_id=#{inboundId} "
|
||||
+ "AND status=#{fromStatus} AND version=#{expectedVersion}")
|
||||
|
||||
+5
-5
@@ -8,7 +8,7 @@ import com.baiyue.firstleg.loading.mapper.LoadingEvidenceMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingInboundMapper;
|
||||
import com.baiyue.firstleg.loading.mapper.LoadingOperationMapper;
|
||||
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 org.springframework.stereotype.Repository;
|
||||
|
||||
@@ -75,11 +75,11 @@ public class LoadingRepository {
|
||||
operations.complete(tenantId, inboundId, operatorId, operation, key, resultJson);
|
||||
}
|
||||
|
||||
public LoadingPickTaskEntity findPickTask(long tenantId, long inboundId) {
|
||||
return picks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||
.eq(LoadingPickTaskEntity::getTenantId, tenantId).eq(LoadingPickTaskEntity::getInboundId, inboundId));
|
||||
public InboundLoadingPickTaskEntity findPickTask(long tenantId, long inboundId) {
|
||||
return picks.selectOne(new LambdaQueryWrapper<InboundLoadingPickTaskEntity>()
|
||||
.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,
|
||||
String operatorName, String remark, Integer expectedCount,
|
||||
Integer pickedCount, int version) {
|
||||
|
||||
+6
-6
@@ -10,7 +10,7 @@ import com.baiyue.firstleg.loading.dto.LoadingRequests.*;
|
||||
import com.baiyue.firstleg.loading.dto.LoadingResponses.*;
|
||||
import com.baiyue.firstleg.loading.entity.LoadingEvidenceEntity;
|
||||
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.dto.LoadingResponses.PickingResponse;
|
||||
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);
|
||||
if (replay != null) return pickResponse(tenantId, inboundId);
|
||||
requireLoadingStage(tenantId, inboundId);
|
||||
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
|
||||
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
|
||||
if (task == null) {
|
||||
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.setPickedCount(request.pickedCount());
|
||||
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);
|
||||
if (replay != null) return pickResponse(tenantId, inboundId);
|
||||
requireLoadingStage(tenantId, inboundId);
|
||||
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
|
||||
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
|
||||
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.setPickedCount(request.pickedCount());
|
||||
task.setRemark(request.remark()); task.setUpdatedBy(operatorId); task.setUpdatedByName(operatorName);
|
||||
@@ -164,7 +164,7 @@ public class LoadingDeliveryService {
|
||||
}
|
||||
|
||||
private PickingResponse pickResponse(long tenantId, long inboundId) {
|
||||
LoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
|
||||
InboundLoadingPickTaskEntity task = repository.findPickTask(tenantId, inboundId);
|
||||
if (task == null) return null;
|
||||
return new PickingResponse(task.getId(), task.getInboundId(), task.getStatus(), task.getVersion(),
|
||||
task.getExpectedCount(), task.getPickedCount(), task.getRemark());
|
||||
|
||||
+9
-9
@@ -2,7 +2,7 @@ package com.baiyue.firstleg.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
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.InboundLoadingPickTaskMapper;
|
||||
import com.baiyue.firstleg.split.entity.PreloadPlanEntity;
|
||||
@@ -34,10 +34,10 @@ public class UnifiedInboundReadRepository {
|
||||
.eq(PreloadPlanEntity::getInboundId, inboundId));
|
||||
}
|
||||
|
||||
public LoadingPickTaskEntity findPick(long tenantId, long inboundId) {
|
||||
return picks.selectOne(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||
.eq(LoadingPickTaskEntity::getTenantId, tenantId)
|
||||
.eq(LoadingPickTaskEntity::getInboundId, inboundId));
|
||||
public InboundLoadingPickTaskEntity findPick(long tenantId, long inboundId) {
|
||||
return picks.selectOne(new LambdaQueryWrapper<InboundLoadingPickTaskEntity>()
|
||||
.eq(InboundLoadingPickTaskEntity::getTenantId, tenantId)
|
||||
.eq(InboundLoadingPickTaskEntity::getInboundId, 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));
|
||||
}
|
||||
|
||||
public Map<Long, LoadingPickTaskEntity> findPicks(Map<Long, Long> tenantByInbound) {
|
||||
public Map<Long, InboundLoadingPickTaskEntity> findPicks(Map<Long, Long> tenantByInbound) {
|
||||
if (tenantByInbound.isEmpty()) return Map.of();
|
||||
return picks.selectList(new LambdaQueryWrapper<LoadingPickTaskEntity>()
|
||||
.in(LoadingPickTaskEntity::getInboundId, tenantByInbound.keySet()))
|
||||
return picks.selectList(new LambdaQueryWrapper<InboundLoadingPickTaskEntity>()
|
||||
.in(InboundLoadingPickTaskEntity::getInboundId, tenantByInbound.keySet()))
|
||||
.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) {
|
||||
|
||||
@@ -163,7 +163,8 @@ public class MeasurementBatchService {
|
||||
item.setVolumeWeightKg(totalVolume.multiply(VOLUME_WEIGHT_FACTOR).setScale(3, RoundingMode.CEILING));
|
||||
item.setCalculationRuleVersion(weighted.calculationRuleVersion());
|
||||
item.setSourceImageFileId(source.intelligentImageFileId());
|
||||
item.setOverLimitWarning(source.overLimitWarning());
|
||||
item.setOverLimitWarning(weighted.warning() == null
|
||||
? source.overLimitWarning() : weighted.warning());
|
||||
item.setOuterLengthCm(weighted.outerLengthCm());
|
||||
item.setOuterWidthCm(weighted.outerWidthCm());
|
||||
item.setOuterHeightCm(weighted.outerHeightCm());
|
||||
|
||||
@@ -84,7 +84,7 @@ public class UnifiedInboundQueryService {
|
||||
|
||||
private UnifiedInboundSummary summary(InboundOrderEntity inbound, InboundOrderDetail detail,
|
||||
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,
|
||||
InboundTransportSnapshotEntity transport) {
|
||||
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_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '本箱规总体积m3',
|
||||
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',
|
||||
machine_no VARCHAR(64) 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)
|
||||
) 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 (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT 'PDA拣货任务ID',
|
||||
loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID',
|
||||
|
||||
+13
@@ -103,6 +103,19 @@ class CustomerDimensionConfigSqlContractTest {
|
||||
.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
|
||||
void unifiedMigrationUpgradesOnlyRecognizedLegacyTableAndBackfillsIdempotently() throws Exception {
|
||||
String sql = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
|
||||
|
||||
+37
@@ -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);
|
||||
}
|
||||
|
||||
@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
|
||||
void submittedInboundRejectsDeviceBeforeUploadingImage() {
|
||||
when(repository.findInbound(10001L, 70001L)).thenReturn(inbound("MEASURE_PENDING"));
|
||||
|
||||
@@ -28,3 +28,9 @@
|
||||
- 普通业务响应由下游服务返回 HTTP 200 + body code;Gateway 自身的认证/会话失败仍保留真实 401/403,不能为了统一响应改成 HTTP 200。
|
||||
- CORS 生产来源必须为明确 HTTPS 域名,不能放宽为 `*`。
|
||||
- 从父目录运行 `mvn -pl gateway -am test`。
|
||||
|
||||
## 分期快速索引
|
||||
|
||||
- 一期:承载 Admin/Client 登录后的应用域、JWT 会话、客户隔离和 CORS;先读 `../../docs/一期/README.md` 与一期闭环设计,网关只做入口认证,不替代 `auth`、`iam`、`order` 的业务授权。
|
||||
- 二期:新增到仓、测量、计费和第三方测量联调路由时,先读 `../../docs/二期/README.md`、主流程和联调报告;保持 `tenant_id>0` 客户隔离、`tenant_id=0` 平台公共资源及内部服务令牌边界,不把内部测量接口暴露到外部 Gateway。
|
||||
- 三期:当前 NO-GO;不得提前开放分柜、订舱、PDA、装柜、报关或港务路由。待 `../../docs/三期/三期需求确认表.md` 的 P3 阻断项解除并锁定契约后,再同步公开路径、App Scope、权限码和 Swagger 聚合。
|
||||
|
||||
@@ -53,3 +53,9 @@
|
||||
- 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。
|
||||
- `deploy/scripts/initialize-test-databases.sh` 的 IAM 客户档案和 Warehouse 主数据脚本顺序必须与当前文件保持一致;测试环境正式增量仍以 `../deploy/sql/README.md` 指定的统一迁移为准。
|
||||
- 从父目录运行 `mvn -pl iam -am test`,再按影响执行一期认证/IAM API 回归。
|
||||
|
||||
## 分期快速索引
|
||||
|
||||
- 一期入口:`PlatformTenantController`(客户建档/审核)、`PlatformUserManagementController`/`RbacController`(平台用户与 RBAC)、`ProfileResourceController`(企业资料);先读 `../../docs/一期/README.md` → `../../docs/一期/一期开发进度表.md` → `../../docs/一期/一期头程订单闭环设计.md`。状态边界为客户审核与两类必填资质均 `APPROVED` 后才允许下单。
|
||||
- 二期协作:IAM 不承载入库或计费状态,仅为 `firstleg/` 提供客户资格/授权快照;先读 `../../docs/二期/README.md` 与 `../../docs/二期/二期V2需求确认清单.md`,确认 `tenant_id>0` 客户隔离和 `tenant_id=0` 平台公共资源边界不变。
|
||||
- 三期:当前 NO-GO;不提前新增出仓角色、租户层或 PDA 权限。待 `../../docs/三期/三期需求确认表.md` 的 P3 阻断项解除后,再同步权限资源、角色种子与 Auth Claim 消费者。
|
||||
|
||||
@@ -37,3 +37,9 @@
|
||||
- OCR 原始响应和第三方密钥不得写入应用日志,真实供应商调用必须保持默认关闭。
|
||||
- 单图接口只接收 File 传入的受控图片内容,不负责保存 `file_object`、入库单或重量确认记录;响应 DTO 或错误语义变化时同步检查 File 的 `WeightOcrClient`、`MeasurementImageService` 和契约测试。
|
||||
- 从父目录运行 `mvn -pl integration-service -am test`。
|
||||
|
||||
## 分期快速索引
|
||||
|
||||
- 一期:不承载一期订单主流程;仅提供可复用的第三方适配基础,不得把供应商 DTO 直接变成订单或客户领域模型。范围依据先读 `../../docs/一期/README.md`。
|
||||
- 二期:当前主要消费者是 `file/` 的重量 OCR 链路;先读 `../../docs/二期/README.md`、主流程和第三方测量联调说明。真实 OCR 凭证未配置时只能验证失败后人工修正的降级链路,不能伪造识别成功。
|
||||
- 三期:当前 NO-GO;不提前接入自动报关、船期、港务或其他外部业务接口。待 `../../docs/三期/三期需求确认表.md` 解除相关阻断项后,再为每个供应商单独确认契约、开关、超时、重试、幂等和审计边界。
|
||||
|
||||
@@ -48,3 +48,9 @@
|
||||
- `POST /api/platform/v1/orders/first-leg/{id}/submit?tenantId={tenantId}`:平台代客提交订单,校验 `platform:order:submit`,必须携带 `Idempotency-Key`。
|
||||
- `src/main/resources/openapi/order-v1.yaml` 是静态 OpenAPI 入口;`ord_idempotency_record` 以客户隔离兼容字段、操作人、操作和幂等键建立唯一约束。
|
||||
- 数据库执行顺序:新库先执行 `order.sql`,已有库执行 `phase1-order-extension.sql`;后者包含幂等表创建,必须在部署订单写接口前完成。
|
||||
|
||||
## 分期快速索引
|
||||
|
||||
- 一期:订单创建/提交/审核/接单/取消入口均在 `MerchantFirstLegOrderController`、`PlatformFirstLegOrderController`;先读 `../../docs/一期/README.md`、`../../docs/一期/一期开发进度表.md` 和 `../../docs/一期/一期头程订单闭环设计.md`。订单主状态由 `OrderStatus` 管理,提交资格由 IAM 审核与必填资质决定。
|
||||
- 二期:Order 仅提供 `InternalOrderCargoController` 供 Firstleg 校验订单货物和快照,不承载入库测量、计费或托盘状态;先读 `../../docs/二期/README.md` 与主流程文档。二期终点停在 `WAIT_SPLIT`,不得在此模块加入分柜/装柜状态。
|
||||
- 三期:当前 NO-GO;国内出仓状态机和排柜/订舱契约待 `../../docs/三期/三期需求确认表.md` 确认后再扩展,暂复用现有订单主数据和快照能力,不把原型动作当已确定 API。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -11,7 +11,7 @@ import process from 'node:process'
|
||||
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
|
||||
const DEFAULT_USERNAME = 'platform_admin'
|
||||
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 CUSTOMER_FIELDS = [
|
||||
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
| `019fdac1-ae04-7032-bac0-968497ea54ad` | 墨家测量 | `inbound-measurement.mjs` |
|
||||
| `019fd5af-2e85-74b0-94e5-98c1b9ea4d3e` | 船期价格表 | `pricing.mjs` |
|
||||
| `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()
|
||||
}
|
||||
}
|
||||
@@ -725,13 +725,12 @@ async function rbacAndMemberModule() {
|
||||
assertEqual(json.data?.status, 'DISABLED', 'role status')
|
||||
}, ({ json }) => ({ roleId: json.data.id, status: json.data.status }))
|
||||
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,
|
||||
}, ({ json, status }) => {
|
||||
assertEqual(status, 200, 'HTTP status')
|
||||
requireSuccess(json)
|
||||
assertEqual(arrayData(json.data).length, 0, 'effective backend permission count')
|
||||
}, ({ json }) => ({ backendPermissions: json?.data, visibleClientMenus: visibleClientMenus(arrayData(json?.data)) }))
|
||||
assertEqual(status, 401, 'HTTP status')
|
||||
assertEqual(String(json?.code), 'A0401', 'business code')
|
||||
}, ({ json }) => ({ rejectionCode: json?.code, message: json?.message }))
|
||||
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,
|
||||
}, ({ json, status }) => {
|
||||
|
||||
+7
-1
@@ -28,7 +28,7 @@
|
||||
| 请求与详情 DTO | `dto/WarehouseRequests.java`、`WarehouseDetailResponse.java`、`MasterDataQueries.java` |
|
||||
| 仓库、地址、能力、往来单位与快递公司持久化 | `entity/`、`mapper/`、`repository/`;往来单位多类型关联为 `PartnerTypeEntity.java` 与 `wh_partner_type` |
|
||||
| 表结构、伙伴、快递公司与种子数据 | `src/main/resources/db/warehouse.sql`、`warehouse-partner.sql`、`warehouse-express-company.sql`、`phase1-warehouse-schema-migration.sql`、`warehouse-seed.sql` |
|
||||
| 二期托盘库位表与迁移 | `src/main/resources/db/phase2-pallet-location-schema.sql`、`phase2-pallet-location-schema-migration.sql`、`phase2-location-overflow-migration.sql`;统一部署入口 `../deploy/sql/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/` |
|
||||
|
||||
## 修改与验证
|
||||
@@ -49,3 +49,9 @@
|
||||
- Warehouse 的数据库访问异常和 readiness/结构状态探针都保留真实 HTTP 503;其他由公共异常处理器捕获的业务失败才使用 HTTP 200 + body code。
|
||||
- 从父目录运行 `mvn -pl warehouse -am test`,再运行 `scripts/order-warehouse-real-api-regression.ps1`。
|
||||
- 二期直接验证至少运行 `PalletApplicationServiceTest`、`LocationApplicationServiceTest`、`LocationResourceServiceTest`、`PalletLocationAllocationServiceTest` 及两个平台 Controller 契约测试;存量数据库迁移后确认统一脚本末尾 Warehouse 核验全部为 `PASS`。
|
||||
|
||||
## 分期快速索引
|
||||
|
||||
- 一期:`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,
|
||||
@Schema(description = "状态", allowableValues = {"ACTIVE", "INACTIVE"})
|
||||
@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 {
|
||||
if (page == 0) {
|
||||
page = 1;
|
||||
|
||||
Reference in New Issue
Block a user