diff --git a/AGENTS.md b/AGENTS.md index 8fd132bd1..c35577029 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ |---|---| | 公共依赖版本 | `platform-bom/pom.xml`;统一管理公共组件版本,不放业务实现 | | 通用响应、分页、异常、基础实体与导出契约 | `common-core/`;从 `ApiResponse.java`、`PageResult.java`、`BizException.java`、`ExportContracts.java` 定位 | -| Web 异常、请求关联 ID、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`LongIdJacksonConfiguration.java` 定位 | +| Web 异常、请求关联/Trace、同步出站传播、Long ID 序列化 | `common-web/`;从 `GlobalExceptionHandler.java`、`CorrelationIdFilter.java`、`TraceParent.java`、`FeignTraceRequestInterceptor.java`、`RestClientTraceInterceptor.java`、`LongIdJacksonConfiguration.java` 定位 | | JWT、JWKS、会话校验与认证用户 | `common-security/`;从 `JwtAuthenticationFilter.java`、`JwtClaimsMapper.java`、`AccessTokenSessionValidator.java` 定位 | | Springdoc/OpenAPI 自动配置 | `common-openapi/`;从 `BaiyueOpenApiAutoConfiguration.java`、`OpenApiProperties.java` 定位 | | 业务审计注解、脱敏、Outbox 与投递 | `common-audit/`;从 `BusinessAudit.java`、`BusinessAuditAspect.java`、`SensitiveDataSanitizer.java`、`AuditOutboxPublisher.java` 定位 | @@ -83,7 +83,7 @@ - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。 - 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 -- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、海外 Gateway 覆盖、Lastleg 海外安全投影映射、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 +- `AGENTS.md` 索引维护完成后运行 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,验证根快速表唯一性、部署单元、Client active 路由、四期范围漂移、订舱关联候选与柜量语义、海外 Gateway 覆盖、PDA 运行配置、Lastleg 海外安全投影、同步 Trace、模块覆盖、关键入口与排除目录,不访问 HTTP、数据库或部署环境。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 ## 全量 HTTP 验收脚本路由 diff --git a/auth/src/main/resources/db/phase1-admin-accounts.sql b/auth/src/main/resources/db/phase1-admin-accounts.sql index cfa329c3d..130763a9f 100644 --- a/auth/src/main/resources/db/phase1-admin-accounts.sql +++ b/auth/src/main/resources/db/phase1-admin-accounts.sql @@ -1,4 +1,4 @@ --- Phase-one local administrator accounts. +-- Phase-one local platform administrator account. Merchant accounts are created through onboarding. -- Development-only initial password: ChangeMe123! -- The BCrypt hash is intentionally stored instead of plaintext. USE baiyue_auth; @@ -10,9 +10,7 @@ INSERT INTO auth_account ( operator_tenant_id, default_membership_id, default_app_code, deleted ) VALUES (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0), - (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) + '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0) ON DUPLICATE KEY UPDATE password_hash = VALUES(password_hash), display_name = VALUES(display_name), diff --git a/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java index 025bf6ec7..82620e90c 100644 --- a/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java +++ b/auth/src/test/java/com/baiyue/auth/repository/InitialAccountSeedSqlContractTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialAccountSeedSqlContractTest { @Test - void initializesOnlyThePlatformAndItTestAdministratorAccounts() throws Exception { + void initializesOnlyThePlatformAdministratorAccount() throws Exception { assertAccountSeed(new ClassPathResource("db/phase1-admin-accounts.sql") .getContentAsString(StandardCharsets.UTF_8)); assertAccountSeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); @@ -21,9 +21,10 @@ class InitialAccountSeedSqlContractTest { assertThat(sql) .contains("(1000001, 'platform_admin'") .contains("'平台系统管理员', 'ACTIVE'") - .contains("(1000002, 'merchant_admin'") - .contains("'IT 测试客户管理员', 'ACTIVE'") .contains("password_change_required") + .doesNotContain("merchant_admin") + .doesNotContain("IT_TEST") + .doesNotContain("(1000002, 'merchant_admin'") .doesNotContain("一期演示企业管理员"); } diff --git a/deploy/sql/README.md b/deploy/sql/README.md index f857cf41f..aabc7dd7d 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,14 +3,14 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口仅升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口会创建并校验独立的 `baiyue_event_bridge_overseas`,再升级 `baiyue_lastleg`与 `baiyue_lastlag_warehouse`;IAM 安全事件 Inbox 和可恢复投影已并入 `baiyue_lastleg.ll_security_*`,Redis 只作可重建镜像。 - Lastleg 基线和海外固定增量仅确定性初始化国内固定 `platform_admin`(`authUserId=1000001`、`tenantId=0`)的 USER/AUTHORIZATION version=0 安全投影;其他既有开发账号不建立兼容表或长期补投入口。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 - 当前测试环境尚未上线,船期价格表相关表、字段和索引只维护在现有初始化建表定义及本固定入口中,不向 `phase1-order-extension.sql`、`phase2-fba-pricing-migration.sql` 或新的增量脚本追加同一结构。 - 模块 `src/main/resources/db/` 下的 SQL 用于开发溯源;`deploy/sql/` 下已有日期文件全部冻结为历史记录,不再单独部署,也不得与固定入口重复执行。 -- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建最小登录账号,重复执行不覆盖现有密码。脚本不写入演示订单;正式平台公共仓库主数据已内联到固定入口。 +- 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`;仅在开发/测试基线账号缺失时创建平台管理员,重复执行不覆盖现有密码。脚本不创建固定客户、客户账号或演示订单;客户必须通过正式建档/注册流程创建。正式平台公共仓库主数据已内联到固定入口。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 - IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 22b4dabb6..3943ecee3 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -278,7 +278,7 @@ PREPARE auth_password_change_required_stmt FROM @auth_password_change_required_s EXECUTE auth_password_change_required_stmt; DEALLOCATE PREPARE auth_password_change_required_stmt; --- 开发/测试环境最小登录基线:一个平台系统管理员和一个 IT 测试客户管理员。 +-- 开发/测试环境最小登录基线只保留平台系统管理员;客户必须走正式建档/注册流程创建。 -- 缺失账号首次创建时写入开发基线 BCrypt 哈希;重复执行统一增量绝不覆盖现有密码。 INSERT INTO auth_account ( id, username, password_hash, display_name, account_status, @@ -286,9 +286,7 @@ INSERT INTO auth_account ( operator_tenant_id, default_membership_id, default_app_code, deleted ) VALUES (1000001, 'platform_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0), - (1000002, 'merchant_admin', '$2a$12$AMZJjd88leYPLI9uKvjZiew2CTQXI32BXoHFX.FrCi28/IeZ7mZ96', - 'IT 测试客户管理员', 'ACTIVE', 0, 1, 0, 10001, 10001, 3000002, 'MERCHANT', 0) + '平台系统管理员', 'ACTIVE', 0, 1, 0, 0, 0, 3000001, 'PLATFORM', 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), account_status = 'ACTIVE', @@ -1194,10 +1192,7 @@ INSERT INTO iam_tenant ( approved_at, approved_by, created_by, deleted, version ) VALUES (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', - NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), - (10001, 'IT_TEST', 'IT 测试客户', 'MERCHANT', 'ACTIVE', 'IT 测试客户', - '91310000ITTEST0001', 'IT 测试客户管理员', '13800000000', 'it-test@example.com', - NOW(3), 1000001, 1000001, 0, 0) + NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), @@ -1206,28 +1201,10 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; --- The built-in IT customer must be immediately usable for order acceptance tests. --- These approved metadata rows intentionally have no document URL: initialization must not --- point at qualification files that do not exist in File service storage. -INSERT INTO iam_tenant_qualification ( - id, tenant_id, qualification_type, certificate_no, document_url, - valid_from, valid_to, status, review_comment, deleted -) VALUES - (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), - (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) -ON DUPLICATE KEY UPDATE - tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), - certificate_no = VALUES(certificate_no), status = 'APPROVED', - review_comment = VALUES(review_comment), deleted = 0; - INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES - (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0), - (2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员', '13800000000', - 'it-test@example.com', 'ACTIVE', 0, 0) + (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), status = 'ACTIVE', deleted = 0; @@ -1235,8 +1212,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_membership ( id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version ) VALUES - (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), - (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0) ON DUPLICATE KEY UPDATE member_type = VALUES(member_type), member_no = VALUES(member_no), status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; @@ -1246,9 +1222,7 @@ INSERT INTO iam_role ( ) VALUES (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), - (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), - (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), - (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -1335,39 +1309,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE id IN (5102019, 5102024, 5102025) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- Merchant: level-one menus, level-two menus and buttons. -INSERT INTO iam_permission - (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) -VALUES - (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), - (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), - (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), - (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), - (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), - (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), - (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), - (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), - (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), - (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), - (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), - (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), - (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), - (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), - (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), - (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), - (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), - (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), - (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), - (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), - (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES - (3000001, 4000001, 0), - (3000002, 4000101, 10001) + (3000001, 4000001, 0) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- Full administrator roles receive all phase-one permissions in their application scope. @@ -1376,11 +1319,6 @@ SELECT 4000001, id, 0 FROM iam_permission WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - -- Limited platform and merchant operational roles. INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000002, id, 0 FROM iam_permission @@ -1396,17 +1334,10 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000102, id, 10001 FROM iam_permission -WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES (6000001, 4000001, 0, 'TENANT', NULL), (6000002, 4000002, 0, 'TENANT', NULL), - (6000003, 4000003, 0, 'TENANT', NULL), - (6000101, 4000101, 10001, 'TENANT', NULL), - (6000102, 4000102, 10001, 'TENANT', NULL) + (6000003, 4000003, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; -- ============================================================================ @@ -1455,17 +1386,34 @@ INSERT INTO iam_role ( ) ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; --- Clone the一期 merchant menu/button template into the global scope. +-- Global merchant permission template used by every formally onboarded customer. INSERT INTO iam_permission ( id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted ) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') +VALUES + (5300000, 0, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5301000, 0, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5302000, 0, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5300010, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5300020, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5300030, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5301010, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5301020, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5302010, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5302020, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5300011, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5300012, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5300021, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5300022, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:grant-role', '分配成员角色', NULL, NULL, 20, 'ACTIVE', 0), + (5300031, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5300032, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5301011, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5301012, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5301021, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5301022, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5301023, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; @@ -1507,20 +1455,11 @@ DEALLOCATE PREPARE member_no_index_stmt; INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) VALUES - (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), - (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), - (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND permission_code IN - ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND permission_code IN @@ -1548,9 +1487,6 @@ VALUES (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), - (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), - (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), - (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) @@ -1569,12 +1505,6 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'MERCHANT' @@ -4286,23 +4216,9 @@ SET @unified_ddl = IF( PREPARE unified_stmt FROM @unified_ddl; EXECUTE unified_stmt; DEALLOCATE PREPARE unified_stmt; -- ============================================================================ --- 重新同步一期商户管理员全局模板,包含一期后续追加的菜单和按钮 +-- 重新授权一期商户管理员全局模板,包含一期后续追加的菜单和按钮 -- ============================================================================ USE baiyue_iam; -INSERT INTO iam_permission ( - id, tenant_id, app_code, permission_type, parent_id, permission_code, - permission_name, route_path, http_method, sort_no, status, deleted -) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - parent_id = VALUES(parent_id), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT owner_role.id, permission.id, 0 FROM iam_role owner_role @@ -15350,10 +15266,9 @@ VALUES (5105019, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:cost:create', '登记成本', NULL, 'POST', 100, 'ACTIVE', 0), (5105020, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:view', '查看客户账期', NULL, 'GET', 110, 'ACTIVE', 0), (5105021, 0, 'PLATFORM', 'BUTTON', 5105000, 'platform:finance:customer-terms:update', '修改客户账期', NULL, 'PUT', 120, 'ACTIVE', 0), - (5205000, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), - (5205010, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), - (5205011, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), - (5205012, 10001, 'MERCHANT', 'BUTTON', 5205000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0), + (5305000, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:finance:statement', '财务账单', '/finance/statements', NULL, 40, 'ACTIVE', 0), + (5305010, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:list', '查看账单', NULL, 'GET', 10, 'ACTIVE', 0), + (5305011, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:view', '查看账单详情及收款', NULL, 'GET', 20, 'ACTIVE', 0), (5305012, 0, 'MERCHANT', 'BUTTON', 5305000, 'merchant:finance:statement:confirm', '确认客户对账单', NULL, 'POST', 30, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), parent_id=VALUES(parent_id), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; @@ -15486,11 +15401,6 @@ WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code IN ( 'platform:finance:settlement-ap:create', 'platform:finance:payment:create', 'platform:finance:settlement:void') ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); -INSERT INTO baiyue_iam.iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM baiyue_iam.iam_permission - WHERE tenant_id=10001 AND app_code='MERCHANT' AND (permission_code='merchant:finance:statement' OR permission_code LIKE 'merchant:finance:%') -ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id); - -- 开发期 list-only 收口:先移除角色关联并停用旧只读资源,再执行最终 Finance 断言。 DELETE rp FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id @@ -15956,22 +15866,18 @@ SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p WHERE r.tenant_id=0 AND r.app_code='PLATFORM' AND r.role_code='PLATFORM_ADMIN' AND p.permission_code IN ('platform:finance:customer-account:list','platform:finance:customer-arap:list','platform:finance:receivable-statistics:list','platform:finance:customer-statement:list','platform:finance:settlement-ar:list','platform:finance:settlement-ap:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export','platform:download:list'); UPDATE iam_permission p JOIN (SELECT 'platform:finance:fee-source:review' code,5105041 parent_id UNION ALL SELECT 'platform:finance:fee-source:edit',5105041 UNION ALL SELECT 'platform:finance:reconciliation:create',5105042 UNION ALL SELECT 'platform:finance:reconciliation:review',5105042 UNION ALL SELECT 'platform:finance:reconciliation:issue',5105042 UNION ALL SELECT 'platform:finance:reconciliation:adjust',5105042 UNION ALL SELECT 'platform:finance:reconciliation:void',5105042 UNION ALL SELECT 'platform:finance:settlement-ar:create',5105043 UNION ALL SELECT 'platform:finance:settlement-ap:create',5105044 UNION ALL SELECT 'platform:finance:payment:create',5105044) mapping ON mapping.code=p.permission_code SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; -INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) -SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' -WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') - AND p.permission_code='merchant:finance:statement:confirm'; - INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT' WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 - AND p.permission_code='merchant:finance:statement:confirm' + AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list', + 'merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0; INSERT INTO baiyue_iam.iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) VALUES - (5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; @@ -16187,11 +16093,8 @@ BEGIN IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='PLATFORM' AND permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台 Finance 权限不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=10001 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 3 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户 Finance 查询与确认权限不完整'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code='merchant:finance:statement:confirm' AND status='ACTIVE' AND deleted=0) <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局对账单确认权限不完整'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND app_code='MERCHANT' AND permission_code IN ('merchant:finance:statement','merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND status='ACTIVE' AND deleted=0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局 Finance 权限模板不完整'; END IF; IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND (p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 授权不完整'; @@ -16199,11 +16102,8 @@ BEGIN IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000001 AND rp.tenant_id=0 AND p.status='ACTIVE' AND p.deleted=0 AND p.permission_code IN ('platform:download:list','platform:finance:customer-account:export','platform:finance:customer-arap:export','platform:finance:receivable-statistics:export','platform:finance:customer-statement:export','platform:finance:settlement-ar:export','platform:finance:settlement-ap:export')) <> 7 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员 Finance 导出和下载中心授权不完整'; END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE rp.role_id=4000101 AND rp.tenant_id=10001 AND (p.permission_code='merchant:finance:statement' OR p.permission_code LIKE 'merchant:finance:%')) <> 4 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户管理员 Finance 查询与确认授权不完整'; - END IF; - IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code='merchant:finance:statement:confirm' AND p.status='ACTIVE' AND p.deleted=0) <> 1 THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员缺少对账单确认权限'; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id WHERE r.id=4000100 AND r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list','merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0) <> 4 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:客户全局管理员 Finance 模板授权不完整'; END IF; END$$ DELIMITER ; diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 041535f39..36b45e079 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -32,7 +32,7 @@ | PDA 拣货与装柜 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java` | | 装柜完成跨区事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`client/EventBridgeClient.java`;从 `fl_inbound_order.destination_type` 冻结 V2 `orders[].destinationType=FBA|OVERSEAS`,OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | | 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | -| 三期海运订舱与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`portschedule/controller/PlatformPortScheduleController.java`;订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 三期海运订舱、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;关联候选入口 `/api/platform/v1/ocean-bookings/link-candidates`,订舱契约见 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | | 四期国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/四期/船节点标准化契约.md` | | 船节点跨区交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,复用 `LoadingCompletedOutboxRepository`/Relay 投 Event Bridge;海外消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java` | | Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | @@ -56,6 +56,7 @@ - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 四期船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/四期/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在三期另建查询任务。 - 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜照片上传时间加两天计算,实际报空时间可为空。 - Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 diff --git a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql index 4508de070..097bed02a 100644 --- a/iam/src/main/resources/db/admin-prototype-menu-permissions.sql +++ b/iam/src/main/resources/db/admin-prototype-menu-permissions.sql @@ -29,7 +29,9 @@ VALUES (5105053,0,'PLATFORM','BUTTON',5105043,'platform:finance:settlement-ar:export','导出应收结算单',NULL,'POST',90,'ACTIVE',1,0), (5105054,0,'PLATFORM','BUTTON',5105044,'platform:finance:settlement-ap:export','导出应付结算单',NULL,'POST',90,'ACTIVE',1,0), (5105055,0,'PLATFORM','BUTTON',5105045,'platform:finance:receivable-statistics:export','导出应收款统计表',NULL,'POST',90,'ACTIVE',1,0), - (5205012,10001,'MERCHANT','BUTTON',5205000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0), + (5305000,0,'MERCHANT','MENU',5300000,'merchant:finance:statement','财务账单','/finance/statements',NULL,40,'ACTIVE',1,0), + (5305010,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:list','查看账单',NULL,'GET',10,'ACTIVE',1,0), + (5305011,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:view','查看账单详情及收款',NULL,'GET',20,'ACTIVE',1,0), (5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm','确认客户对账单',NULL,'POST',30,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), @@ -101,18 +103,12 @@ UPDATE iam_permission p SET p.parent_id=mapping.parent_id WHERE p.tenant_id=0 AND p.app_code='PLATFORM' AND p.permission_type='BUTTON'; -INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) -SELECT 10001, r.id, p.id, CURRENT_TIMESTAMP(3) - FROM iam_role r - JOIN iam_permission p ON p.tenant_id=10001 AND p.app_code='MERCHANT' - WHERE r.tenant_id=10001 AND r.app_code='MERCHANT' AND r.role_code IN ('MERCHANT_OWNER','MERCHANT_ADMIN') - AND p.permission_code='merchant:finance:statement:confirm'; - INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) FROM iam_role r JOIN iam_permission p ON p.tenant_id=0 AND p.app_code='MERCHANT' WHERE r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER' AND r.status='ACTIVE' AND r.deleted=0 - AND p.permission_code='merchant:finance:statement:confirm' + AND p.permission_code IN ('merchant:finance:statement','merchant:finance:statement:list', + 'merchant:finance:statement:view','merchant:finance:statement:confirm') AND p.status='ACTIVE' AND p.deleted=0; diff --git a/iam/src/main/resources/db/phase1-admin-rbac.sql b/iam/src/main/resources/db/phase1-admin-rbac.sql index fcc5d4bde..0f00deb81 100644 --- a/iam/src/main/resources/db/phase1-admin-rbac.sql +++ b/iam/src/main/resources/db/phase1-admin-rbac.sql @@ -26,10 +26,7 @@ INSERT INTO iam_tenant ( approved_at, approved_by, created_by, deleted, version ) VALUES (0, 'PLATFORM', '虫虫出海平台', 'PLATFORM', 'ACTIVE', '虫虫出海平台', - NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0), - (10001, 'IT_TEST', 'IT 测试客户', 'MERCHANT', 'ACTIVE', 'IT 测试客户', - '91310000ITTEST0001', 'IT 测试客户管理员', '13800000000', 'it-test@example.com', - NOW(3), 1000001, 1000001, 0, 0) + NULL, '平台系统管理员', NULL, NULL, NOW(3), 1000001, 1000001, 0, 0) ON DUPLICATE KEY UPDATE tenant_code = VALUES(tenant_code), tenant_name = VALUES(tenant_name), tenant_type = VALUES(tenant_type), status = 'ACTIVE', legal_name = VALUES(legal_name), @@ -38,28 +35,10 @@ ON DUPLICATE KEY UPDATE contact_email = VALUES(contact_email), approved_at = COALESCE(approved_at, NOW(3)), approved_by = VALUES(approved_by), deleted = 0; --- The built-in IT customer must be immediately usable for order acceptance tests. --- These approved metadata rows intentionally have no document URL: initialization must not --- point at qualification files that do not exist in File service storage. -INSERT INTO iam_tenant_qualification ( - id, tenant_id, qualification_type, certificate_no, document_url, - valid_from, valid_to, status, review_comment, deleted -) VALUES - (2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0), - (2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL, - NULL, NULL, 'APPROVED', '开发测试初始化资质,无文件附件', 0) -ON DUPLICATE KEY UPDATE - tenant_id = VALUES(tenant_id), qualification_type = VALUES(qualification_type), - certificate_no = VALUES(certificate_no), status = 'APPROVED', - review_comment = VALUES(review_comment), deleted = 0; - INSERT INTO iam_user ( id, auth_user_id, user_type, display_name, phone, email, status, deleted, version ) VALUES - (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0), - (2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员', '13800000000', - 'it-test@example.com', 'ACTIVE', 0, 0) + (2000001, 1000001, 'INTERNAL', '平台系统管理员', NULL, NULL, 'ACTIVE', 0, 0) ON DUPLICATE KEY UPDATE display_name = VALUES(display_name), phone = VALUES(phone), email = VALUES(email), status = 'ACTIVE', deleted = 0; @@ -67,8 +46,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO iam_membership ( id, tenant_id, user_id, member_type, member_no, status, joined_at, deleted, version ) VALUES - (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0), - (3000002, 10001, 2000002, 'OWNER', 'MERCHANT-OWNER-001', 'ACTIVE', NOW(3), 0, 0) + (3000001, 0, 2000001, 'EMPLOYEE', 'PLATFORM-ADMIN-001', 'ACTIVE', NOW(3), 0, 0) ON DUPLICATE KEY UPDATE member_type = VALUES(member_type), member_no = VALUES(member_no), status = 'ACTIVE', joined_at = COALESCE(joined_at, NOW(3)), left_at = NULL, deleted = 0; @@ -78,9 +56,7 @@ INSERT INTO iam_role ( ) VALUES (4000001, 0, 'PLATFORM', 'PLATFORM_ADMIN', '平台超级管理员', 'SYSTEM', 'ACTIVE', '一期平台全部菜单和按钮', 0, 0), (4000002, 0, 'PLATFORM', 'CUSTOMER_REVIEWER', '企业审核员', 'SYSTEM', 'ACTIVE', '企业资料与入驻审核', 0, 0), - (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0), - (4000101, 10001, 'MERCHANT', 'MERCHANT_OWNER', '企业管理员', 'SYSTEM', 'ACTIVE', '本企业一期全部菜单和按钮', 0, 0), - (4000102, 10001, 'MERCHANT', 'MERCHANT_ORDER_MANAGER', '企业订单管理员', 'SYSTEM', 'ACTIVE', '本企业头程订单操作', 0, 0) + (4000003, 0, 'PLATFORM', 'ORDER_OPERATOR', '平台订单运营', 'SYSTEM', 'ACTIVE', '头程订单审核与履约处理', 0, 0) ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), role_type = VALUES(role_type), status = 'ACTIVE', description = VALUES(description), deleted = 0; @@ -167,40 +143,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE id IN (5102019, 5102024, 5102025) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); --- Merchant: level-one menus, level-two menus and buttons. -INSERT INTO iam_permission - (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) -VALUES - (5200000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), - (5201000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), - (5202000, 10001, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), - (5200010, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), - (5200020, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), - (5200030, 10001, 'MERCHANT', 'MENU', 5200000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), - (5201010, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), - (5201020, 10001, 'MERCHANT', 'MENU', 5201000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), - (5202010, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), - (5202020, 10001, 'MERCHANT', 'MENU', 5202000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), - (5200011, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), - (5200012, 10001, 'MERCHANT', 'BUTTON', 5200010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), - (5200021, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), - (5200022, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:grant-role', '分配角色', NULL, NULL, 20, 'ACTIVE', 0), - (5200031, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), - (5200032, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), - (5201011, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), - (5201012, 10001, 'MERCHANT', 'BUTTON', 5201010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), - (5201021, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), - (5201022, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), - (5201023, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), - (5201024, 10001, 'MERCHANT', 'BUTTON', 5201020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) -ON DUPLICATE KEY UPDATE - permission_type = VALUES(permission_type), parent_id = VALUES(parent_id), - permission_name = VALUES(permission_name), route_path = VALUES(route_path), - http_method = NULL, sort_no = VALUES(sort_no), status = 'ACTIVE', deleted = 0; - INSERT INTO iam_membership_role (membership_id, role_id, tenant_id) VALUES - (3000001, 4000001, 0), - (3000002, 4000101, 10001) + (3000001, 4000001, 0) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -- Full administrator roles receive all phase-one permissions in their application scope. @@ -209,11 +153,6 @@ SELECT 4000001, id, 0 FROM iam_permission WHERE app_code = 'PLATFORM' AND tenant_id = 0 AND permission_type IN ('MENU', 'BUTTON') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE app_code = 'MERCHANT' AND tenant_id = 10001 AND permission_type IN ('MENU', 'BUTTON') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - -- Limited platform and merchant operational roles. INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000002, id, 0 FROM iam_permission @@ -229,15 +168,8 @@ SELECT 4000003, id, 0 FROM iam_permission WHERE permission_code LIKE 'platform:order%' OR permission_code LIKE 'platform:firstleg%' ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000102, id, 10001 FROM iam_permission -WHERE permission_code LIKE 'merchant:order%' OR permission_code IN ('merchant:master-data', 'merchant:warehouse:list') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_data_scope (id, role_id, tenant_id, scope_type, scope_expression) VALUES (6000001, 4000001, 0, 'TENANT', NULL), (6000002, 4000002, 0, 'TENANT', NULL), - (6000003, 4000003, 0, 'TENANT', NULL), - (6000101, 4000101, 10001, 'TENANT', NULL), - (6000102, 4000102, 10001, 'TENANT', NULL) + (6000003, 4000003, 0, 'TENANT', NULL) ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id), scope_type = VALUES(scope_type), scope_expression = NULL; diff --git a/iam/src/main/resources/db/phase1-member-permissions.sql b/iam/src/main/resources/db/phase1-member-permissions.sql index 8a88a7896..bddb3b44d 100644 --- a/iam/src/main/resources/db/phase1-member-permissions.sql +++ b/iam/src/main/resources/db/phase1-member-permissions.sql @@ -17,20 +17,11 @@ DEALLOCATE PREPARE member_no_index_stmt; INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, sort_no, status, deleted) VALUES - (5200023, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), - (5200024, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), - (5200025, 10001, 'MERCHANT', 'BUTTON', 5200020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0), (5300023, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:edit', '编辑成员', 30, 'ACTIVE', 0), (5300024, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:status', '启停成员', 40, 'ACTIVE', 0), (5300025, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:reset-password', '重置成员密码', 50, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), status = VALUES(status), deleted = 0; -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND permission_code IN - ('merchant:member:edit', 'merchant:member:status', 'merchant:member:reset-password') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND permission_code IN diff --git a/iam/src/main/resources/db/phase1-onboarding.sql b/iam/src/main/resources/db/phase1-onboarding.sql index a31f31127..27a7da36e 100644 --- a/iam/src/main/resources/db/phase1-onboarding.sql +++ b/iam/src/main/resources/db/phase1-onboarding.sql @@ -41,17 +41,34 @@ INSERT INTO iam_role ( ) ON DUPLICATE KEY UPDATE status='ACTIVE', deleted=0; --- Clone the一期 merchant menu/button template into the global scope. +-- Global merchant permission template used by every formally onboarded customer. INSERT INTO iam_permission ( id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted ) -SELECT id + 100000, 0, app_code, permission_type, - CASE WHEN parent_id = 0 THEN 0 ELSE parent_id + 100000 END, - permission_code, permission_name, route_path, NULL, sort_no, 'ACTIVE', 0 -FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_type IN ('MENU', 'BUTTON') +VALUES + (5300000, 0, 'MERCHANT', 'MENU', 0, 'merchant:enterprise', '企业管理', '/enterprise', NULL, 10, 'ACTIVE', 0), + (5301000, 0, 'MERCHANT', 'MENU', 0, 'merchant:order', '头程订单', '/first-leg-orders', NULL, 20, 'ACTIVE', 0), + (5302000, 0, 'MERCHANT', 'MENU', 0, 'merchant:master-data', '基础资料', '/master-data', NULL, 30, 'ACTIVE', 0), + (5300010, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:profile:view', '企业资料', '/enterprise/profile', NULL, 10, 'ACTIVE', 0), + (5300020, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:member:list', '企业成员', '/enterprise/members', NULL, 20, 'ACTIVE', 0), + (5300030, 0, 'MERCHANT', 'MENU', 5300000, 'merchant:role:list', '角色管理', '/enterprise/roles', NULL, 30, 'ACTIVE', 0), + (5301010, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:list', '我的订单', '/first-leg-orders/list', NULL, 10, 'ACTIVE', 0), + (5301020, 0, 'MERCHANT', 'MENU', 5301000, 'merchant:order:create-page', '新建订单', '/first-leg-orders/create', NULL, 20, 'ACTIVE', 0), + (5302010, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:warehouse:list', '收货仓选择', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0), + (5302020, 0, 'MERCHANT', 'MENU', 5302000, 'merchant:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 20, 'ACTIVE', 0), + (5300011, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:profile:edit', '编辑企业资料', NULL, NULL, 10, 'ACTIVE', 0), + (5300012, 0, 'MERCHANT', 'BUTTON', 5300010, 'merchant:qualification:upload', '上传企业资质', NULL, NULL, 20, 'ACTIVE', 0), + (5300021, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:create', '新增成员', NULL, NULL, 10, 'ACTIVE', 0), + (5300022, 0, 'MERCHANT', 'BUTTON', 5300020, 'merchant:member:grant-role', '分配成员角色', NULL, NULL, 20, 'ACTIVE', 0), + (5300031, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:create', '新增角色', NULL, NULL, 10, 'ACTIVE', 0), + (5300032, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:grant', '角色授权', NULL, NULL, 20, 'ACTIVE', 0), + (5301011, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:view', '查看订单', NULL, NULL, 10, 'ACTIVE', 0), + (5301012, 0, 'MERCHANT', 'BUTTON', 5301010, 'merchant:order:cancel', '取消订单', NULL, NULL, 20, 'ACTIVE', 0), + (5301021, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:create', '创建草稿', NULL, NULL, 10, 'ACTIVE', 0), + (5301022, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:edit', '编辑草稿', NULL, NULL, 20, 'ACTIVE', 0), + (5301023, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:submit', '提交订单', NULL, NULL, 30, 'ACTIVE', 0), + (5301024, 0, 'MERCHANT', 'BUTTON', 5301020, 'merchant:order:quick-create', '快速下单', NULL, NULL, 40, 'ACTIVE', 0) ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0; diff --git a/iam/src/main/resources/db/rbac-role-management-v2.sql b/iam/src/main/resources/db/rbac-role-management-v2.sql index 5228b9b71..87f2ea5de 100644 --- a/iam/src/main/resources/db/rbac-role-management-v2.sql +++ b/iam/src/main/resources/db/rbac-role-management-v2.sql @@ -19,9 +19,6 @@ VALUES (5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', 30, 'ACTIVE', 0), (5101014, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:reset-password', '重置密码', 40, 'ACTIVE', 0), (5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 0), - (5200033, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), - (5200034, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), - (5200035, 10001, 'MERCHANT', 'BUTTON', 5200030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0), (5300033, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:edit', '编辑角色', 30, 'ACTIVE', 0), (5300034, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:status', '启停角色', 40, 'ACTIVE', 0), (5300035, 0, 'MERCHANT', 'BUTTON', 5300030, 'merchant:role:delete', '删除角色', 50, 'ACTIVE', 0) @@ -40,12 +37,6 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:user:grant-role') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000101, id, 10001 FROM iam_permission -WHERE tenant_id = 10001 AND app_code = 'MERCHANT' - AND permission_code IN ('merchant:role:edit', 'merchant:role:status', 'merchant:role:delete') -ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); - INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) SELECT 4000100, id, 0 FROM iam_permission WHERE tenant_id = 0 AND app_code = 'MERCHANT' diff --git a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java index 2190fbe70..f8cd21e76 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/AdminPrototypeMenuPermissionSqlContractTest.java @@ -31,6 +31,9 @@ class AdminPrototypeMenuPermissionSqlContractTest { assertThat(sql).contains("(" + (5105050 + index) + ",0,'PLATFORM','BUTTON'," + (5105040 + index) + ",'" + FINANCE_EXPORTS[index] + "'"); } assertThat(sql) + .contains("(5305000,0,'MERCHANT','MENU',5300000,'merchant:finance:statement'") + .contains("(5305010,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:list'") + .contains("(5305011,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:view'") .contains("(5305012,0,'MERCHANT','BUTTON',5305000,'merchant:finance:statement:confirm'") .contains("r.tenant_id=0 AND r.app_code='MERCHANT' AND r.role_code='MERCHANT_OWNER'") .contains("'platform:finance:fee-source:review' code, 5105041 parent_id") @@ -60,8 +63,8 @@ class AdminPrototypeMenuPermissionSqlContractTest { .contains("permission_code LIKE 'platform:finance:%' AND status='ACTIVE' AND deleted=0) <> 32") .contains("(p.permission_code='platform:finance' OR p.permission_code LIKE 'platform:finance:%')) <> 33") .contains("Finance 导出和下载中心授权不完整") - .contains("merchant:finance:statement:confirm") - .contains("客户全局管理员缺少对账单确认权限") + .contains("客户全局 Finance 权限模板不完整") + .contains("客户全局管理员 Finance 模板授权不完整") .satisfies(content -> assertThat(content.indexOf("(5105050,0,'PLATFORM','BUTTON'")) .isLessThan(content.indexOf("CREATE PROCEDURE assert_finance_permissions()"))); } diff --git a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java index 86f26ec04..64008a223 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/InitialIdentitySeedSqlContractTest.java @@ -11,25 +11,42 @@ import static org.assertj.core.api.Assertions.assertThat; class InitialIdentitySeedSqlContractTest { @Test - void initializesPlatformAndItTestCustomerIdentitiesWithStableIds() throws Exception { - assertIdentitySeed(new ClassPathResource("db/phase1-admin-rbac.sql") - .getContentAsString(StandardCharsets.UTF_8)); - assertIdentitySeed(Files.readString(unifiedSql(), StandardCharsets.UTF_8)); + void initializesOnlyPlatformIdentityAndGlobalMerchantTemplates() throws Exception { + String identitySeed = new ClassPathResource("db/phase1-admin-rbac.sql") + .getContentAsString(StandardCharsets.UTF_8); + String templateSeed = new ClassPathResource("db/phase1-onboarding.sql") + .getContentAsString(StandardCharsets.UTF_8); + String unified = Files.readString(unifiedSql(), StandardCharsets.UTF_8); + assertPlatformIdentitySeed(identitySeed); + assertPlatformIdentitySeed(unified); + assertGlobalMerchantTemplate(templateSeed); + assertGlobalMerchantTemplate(unified); } - private static void assertIdentitySeed(String sql) { + private static void assertPlatformIdentitySeed(String sql) { assertThat(sql) .contains("(0, 'PLATFORM', '虫虫出海平台'") - .contains("(10001, 'IT_TEST', 'IT 测试客户'") .contains("(2000001, 1000001, 'INTERNAL', '平台系统管理员'") - .contains("(2000002, 1000002, 'ENTERPRISE', 'IT 测试客户管理员'") .contains("(3000001, 0, 2000001, 'EMPLOYEE'") - .contains("(3000002, 10001, 2000002, 'OWNER'") .contains("tenant_code = VALUES(tenant_code)") + .doesNotContain("merchant_admin") + .doesNotContain("IT_TEST") + .doesNotContain("(10001,") + .doesNotContain("(2000002,") + .doesNotContain("(3000002,") + .doesNotContain("4000101, 10001") .doesNotContain("PHASE1_DEMO") .doesNotContain("一期演示企业"); } + private static void assertGlobalMerchantTemplate(String sql) { + assertThat(sql) + .contains("4000100, 0, 'MERCHANT', 'MERCHANT_OWNER'") + .contains("5300000, 0, 'MERCHANT', 'MENU'") + .contains("5301024, 0, 'MERCHANT', 'BUTTON'") + .doesNotContain("WHERE tenant_id = 10001 AND app_code = 'MERCHANT'"); + } + private static Path unifiedSql() { Path path = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql"); return Files.exists(path) ? path : Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"); diff --git a/iam/src/test/resources/db/verify-phase1-admin-rbac.sql b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql index e2942472f..24dce2749 100644 --- a/iam/src/test/resources/db/verify-phase1-admin-rbac.sql +++ b/iam/src/test/resources/db/verify-phase1-admin-rbac.sql @@ -1,13 +1,17 @@ -- Any returned row represents a failed phase-one seed assertion. SELECT 'missing_auth_accounts' AS failure WHERE (SELECT COUNT(*) FROM baiyue_auth.auth_account - WHERE username IN ('platform_admin', 'merchant_admin') AND account_status = 'ACTIVE') <> 2 + WHERE username = 'platform_admin' AND account_status = 'ACTIVE') <> 1 UNION ALL SELECT 'missing_active_identities' WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_user u JOIN baiyue_iam.iam_membership m ON m.user_id = u.id - WHERE u.auth_user_id IN (1000001, 1000002) - AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 2 + WHERE u.auth_user_id = 1000001 + AND u.status = 'ACTIVE' AND m.status = 'ACTIVE') <> 1 +UNION ALL +SELECT 'unexpected_fixed_customer_seed' +WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_tenant WHERE tenant_code = 'IT_TEST') + OR EXISTS (SELECT 1 FROM baiyue_auth.auth_account WHERE username = 'merchant_admin') UNION ALL SELECT 'unexpected_permission_type' WHERE EXISTS (SELECT 1 FROM baiyue_iam.iam_permission @@ -34,10 +38,6 @@ SELECT 'platform_admin_not_fully_granted' WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000001) <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 0 AND app_code = 'PLATFORM' AND permission_type IN ('MENU', 'BUTTON')) UNION ALL -SELECT 'merchant_owner_not_fully_granted' -WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000101) - <> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 10001 AND app_code = 'MERCHANT' AND permission_type IN ('MENU', 'BUTTON')) -UNION ALL SELECT 'missing_global_merchant_owner_template' WHERE NOT EXISTS ( SELECT 1 FROM baiyue_iam.iam_role diff --git a/scripts/README.md b/scripts/README.md index fac0cd8b6..360871a62 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -31,7 +31,7 @@ node scripts/create-pending-review-orders.mjs | Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` | | PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | -| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、Lastleg 海外安全投影映射、关键入口与排除目录 | +| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、四期范围漂移、订舱候选与柜量语义、海外 Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口与排除目录 | | Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 | | 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 | diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 1b6f37546..89bfa0a4c 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -8,6 +8,8 @@ import process from 'node:process' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] const MODULE_DEPENDENCIES = new Map([ + ['suppliers', ['merchant-qualification-fixture']], + ['supplemental', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']], ['inbound-measurement', ['orders']], ['phase3-container-loading', ['inbound-measurement']], @@ -232,11 +234,20 @@ function usage() { --username 必填,管理员账号 --password 必填,管理员密码 --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} + --customer-name 指定本轮隔离客户名称,默认自动生成 + --merchant-username 复用既有已审核客户账号,不再创建客户 + --merchant-password 复用客户账号密码,不写入报告 + --origin-warehouse-name 固定国内起运仓名称 + --channel-name 固定物流渠道名称 + --channel-code 固定物流渠道编码 + --order-profile-mode 订单档案模式:fixed 或 randomized,默认fixed + --wait-split-count 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1 --output JSON 报告路径,默认 reports/ --help 显示帮助 也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, -ADMIN_TEST_WEIGHT_IMAGE_PATH`) +ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME, +MERCHANT_TEST_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`) } export function parseArguments(argv) { @@ -255,12 +266,27 @@ export function parseArguments(argv) { values.set(name, next); index += 1 } } + const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || '' + const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' + const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)) + if (merchantUsername && merchantPassword) { + const fixtureIndex = modules.indexOf('merchant-qualification-fixture') + if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) + } return { gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''), overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''), username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '', password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '', - modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)), + modules, + customerName: values.get('customer-name') || process.env.ADMIN_TEST_CUSTOMER_NAME || '', + merchantUsername, + merchantPassword, + originWarehouseName: values.get('origin-warehouse-name') || '', + channelName: values.get('channel-name') || '', + channelCode: values.get('channel-code') || '', + orderProfileMode: values.get('order-profile-mode') || 'fixed', + waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT || 1), output: values.get('output') || '', } } @@ -273,6 +299,32 @@ export function validateExecutionOptions(options) { && (!options.overseasGateway || options.overseasGateway.trim() === '')) { throw new Error('四期海外模块缺少必填执行参数: overseas-gateway') } + if (!Number.isInteger(options.waitSplitCount) || options.waitSplitCount < 1 || options.waitSplitCount > 100) { + throw new Error('wait-split-count必须是1-100的整数') + } + if (options.customerName && options.customerName.trim().length > 128) { + throw new Error('customer-name不能超过128个字符') + } + if (Boolean(options.merchantUsername) !== Boolean(options.merchantPassword)) { + throw new Error('merchant-username和merchant-password必须同时提供') + } + for (const [name, value] of [['origin-warehouse-name', options.originWarehouseName], + ['channel-name', options.channelName], ['channel-code', options.channelCode]]) { + if (value && value.trim().length > 128) throw new Error(`${name}不能超过128个字符`) + } + if (!['fixed', 'randomized'].includes(options.orderProfileMode)) { + throw new Error('order-profile-mode必须为fixed或randomized') + } + if (options.orderProfileMode === 'randomized' + && (!options.originWarehouseName || !options.channelName || !options.channelCode)) { + throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码') + } + if (options.waitSplitCount > 1) { + const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement']) + if (options.modules.some((module) => !fixtureModules.has(module))) { + throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement') + } + } return options } @@ -555,6 +607,124 @@ export class AcceptanceRun { this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal }) } + captureWaitSplitFixture(sequence) { + const order = this.createdResources.orders || {} + const measurement = this.createdResources.inboundMeasurement || {} + const ready = Boolean(order.orderId && measurement.inboundId + && String(order.tenantId) === String(measurement.tenantId) + && measurement.processStatus === 'WAIT_SPLIT' && measurement.finalStatus === 'APPROVED') + this.assert('wait-split-fixtures', `固定客户第 ${sequence} 单停在待分柜`, null, ready, + `tenantId=${measurement.tenantId || 'missing'}, orderId=${order.orderId || 'missing'}, inboundId=${measurement.inboundId || 'missing'}, processStatus=${measurement.processStatus || 'missing'}, reviewStatus=${measurement.finalStatus || 'missing'}`, + { scenario: 'fixed-customer-wait-split', deploymentBlocker: true }) + if (!ready) return false + this.createdResources.waitSplitFixtures ??= [] + this.createdResources.waitSplitFixtures.push({ + sequence, tenantId: String(measurement.tenantId), orderId: String(order.orderId), + orderNo: order.orderNo || null, inboundId: String(measurement.inboundId), + inboundCode: order.inboundCode || null, processStatus: measurement.processStatus, + reviewStatus: measurement.finalStatus, skuCount: order.skuLines?.length || null, + totalPackageCount: (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0), + palletCount: measurement.palletCount || null, + actualGrossWeightKg: measurement.actualGrossWeightKg || null, + actualNetWeightKg: measurement.actualNetWeightKg || null, + measurementProfile: this.context.waitSplitVariant?.measurementProfile || null, + receivingWarehouseId: order.receivingWarehouseId || null, + receivingWarehouseName: order.receivingWarehouseName || null, + destinationWarehouseId: order.destinationWarehouseId || null, + destinationWarehouseCode: order.destinationWarehouseCode || null, + destinationWarehouseName: order.destinationWarehouseName || null, + channelCode: order.channelCode || null, + channelName: order.channelName || null, + pricingTaxMode: order.pricingTaxMode || null, + destinationType: order.destinationType || null, + fulfillmentType: order.fulfillmentType || null, + pickupMethod: order.pickupMethod || null, + taxMode: order.taxMode || null, + containerMode: order.containerMode || null, + customsInspection: order.customsInspection ?? null, + }) + return true + } + + waitSplitVariant(sequence) { + const dimensionProfiles = [ + { code: 'SMALL', first: { length: 29.1, width: 19.1, height: 18.1 }, replacement: { length: 29.5, width: 19.5, height: 18.5 }, outer: { length: 34, width: 24, height: 24 } }, + { code: 'MEDIUM', first: { length: 39.1, width: 29.1, height: 28.1 }, replacement: { length: 39.5, width: 29.5, height: 28.5 }, outer: { length: 44, width: 34, height: 34 } }, + { code: 'LARGE', first: { length: 49.1, width: 39.1, height: 38.1 }, replacement: { length: 49.5, width: 39.5, height: 38.5 }, outer: { length: 54, width: 44, height: 44 } }, + { code: 'EXTRA_LARGE', first: { length: 59.1, width: 49.1, height: 48.1 }, replacement: { length: 59.5, width: 49.5, height: 48.5 }, outer: { length: 64, width: 54, height: 54 } }, + ] + const skuCount = 1 + ((sequence - 1) % 4) + const packageCountPerSku = 1 + (Math.floor((sequence - 1) / 4) % 5) + const palletCount = 1 + ((sequence - 1) % 3) + const grossWeightPerPalletKg = 45 + ((sequence - 1) % 6) * 15 + return { + sequence, skuCount, packageCountPerSku, palletCount, grossWeightPerPalletKg, + measurementProfile: dimensionProfiles[(sequence - 1) % dimensionProfiles.length], + } + } + + randomizedOrderProfile(sequence) { + const combinations = [ + ['FBA', 'STANDARD'], + ['OVERSEAS', 'STANDARD'], + ['PRIVATE', 'STANDARD'], + ['FBA', 'ONE_CLICK_DROPSHIP'], + ['OVERSEAS', 'ONE_CLICK_DROPSHIP'], + ['PRIVATE', 'ONE_CLICK_DROPSHIP'], + ] + const [destinationType, fulfillmentType] = combinations[(sequence * 5 + 1) % combinations.length] + const pickupMethods = ['PICKUP', 'SELF_EXPRESS', 'SELF_TRUCK'] + return { + destinationType, + fulfillmentType, + pickupMethod: pickupMethods[(sequence * 7 + 2) % pickupMethods.length], + taxMode: sequence % 2 === 0 ? 'TAX_REFUND' : 'BUY_DECLARATION', + containerMode: sequence % 2 === 0 ? 'FULL_CONTAINER' : 'LCL', + customsInspection: sequence % 4 === 0, + } + } + + applyWaitSplitVariant(sequence) { + const variant = this.waitSplitVariant(sequence) + this.context.waitSplitVariant = variant + this.context.phase3LoadingIncrementalDetailCount = variant.skuCount + this.context.phase3LoadingIncrementalPackageCount = variant.packageCountPerSku + const profile = this.options.orderProfileMode === 'randomized' + ? this.randomizedOrderProfile(sequence) + : { destinationType: 'FBA', fulfillmentType: 'STANDARD', pickupMethod: 'PICKUP', + taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false } + this.context.orderProfile = profile + this.context.orderDestinationType = profile.destinationType + this.context.orderFulfillmentType = profile.fulfillmentType + this.context.waitSplitRoute = { + originWarehouseName: this.options.originWarehouseName || '', + channelName: this.options.channelName || '', + channelCode: this.options.channelCode || '', + destinationIndex: (sequence * 17 + 11), + taxModeIndex: sequence - 1, + } + } + + async createAdditionalWaitSplitFixtures() { + const total = this.options.waitSplitCount + if (total <= 1) return + const baseMarker = this.marker + const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) + const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) + try { + for (let sequence = 2; sequence <= total; sequence += 1) { + this.marker = `${baseMarker}-WS${String(sequence).padStart(2, '0')}` + this.applyWaitSplitVariant(sequence) + await this.executeModule('orders', () => orders.run(this)) + if (this.modules.at(-1)?.success !== true) break + await this.executeModule('inbound-measurement', () => measurement.run(this)) + if (this.modules.at(-1)?.success !== true || !this.captureWaitSplitFixture(sequence)) break + } + } finally { + this.marker = baseMarker + } + } + async refreshSession(reason) { const response = await this.request('POST', '/api/auth/refresh', { refreshToken: this.refreshToken }, '') @@ -1184,6 +1354,11 @@ export class AcceptanceRun { } async run() { + if (this.options.merchantUsername && this.options.merchantPassword) { + process.env.MERCHANT_TEST_USERNAME = this.options.merchantUsername + process.env.MERCHANT_TEST_PASSWORD = this.options.merchantPassword + } + if (this.options.waitSplitCount > 1) this.applyWaitSplitVariant(1) const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '') const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse) if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' @@ -1270,6 +1445,9 @@ export class AcceptanceRun { } finally { if (restoreFixture) restoreFixture() } + if (module === 'inbound-measurement' && this.captureWaitSplitFixture(1)) { + await this.createAdditionalWaitSplitFixtures() + } const laterOverseasModule = this.options.modules.slice(moduleIndex + 1) .some((name) => name.startsWith('phase4-overseas-')) if (module === 'system' && laterOverseasModule) { @@ -1290,7 +1468,8 @@ export class AcceptanceRun { deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, }) - const forbiddenValues = [this.options.password, this.token, this.refreshToken, this.overseasToken] + const forbiddenValues = [this.options.password, this.options.merchantPassword, + this.token, this.refreshToken, this.overseasToken] .filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ /"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i, diff --git a/scripts/admin-test-env-modules/fee-items.mjs b/scripts/admin-test-env-modules/fee-items.mjs index dd3472bdd..f943c9e96 100644 --- a/scripts/admin-test-env-modules/fee-items.mjs +++ b/scripts/admin-test-env-modules/fee-items.mjs @@ -5,8 +5,8 @@ const BASE = '/api/platform/v1/fee-items' const BASELINE_COUNT = 111 const DOMESTIC_COUNT = 84 const INTERNATIONAL_COUNT = 27 -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' function id(value) { return value == null ? '' : String(value) } function records(value) { return Array.isArray(value?.records) ? value.records : [] } diff --git a/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs index 6482476d2..a67d138ee 100644 --- a/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-demo-fixtures-contract.test.mjs @@ -7,10 +7,12 @@ import { fileURLToPath } from 'node:url' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-demo-fixtures.mjs'), 'utf8') -test('演示数据只消费本轮固定 marker 和客户 10001', () => { +test('演示数据只消费本轮固定 marker 和动态正式客户 fixture', () => { assert.match(source, /const MARKER = 'FIN-DEMO-20260812'/) assert.match(source, /fixture\.marker !== runMarker/) - assert.match(source, /fixture\.customerId !== '10001'/) + assert.match(source, /customerId !== id\(qualificationFixture\?\.tenantId\)/) + assert.match(source, /context\.context\.merchantQualificationFixture/) + assert.doesNotMatch(source, /customerId:\s*['"]?10001|accountId=10001/) assert.match(source, /const runMarker = `\$\{MARKER\}-/) assert.match(source, /禁止借历史来源/) assert.doesNotMatch(source, /DELETE|cleanup|remove/i) @@ -41,8 +43,8 @@ test('所有财务写操作使用 marker 幂等键并上传真实凭证', () => }) test('额外应付审核路径先追加 feeId 再追加 accountId 查询参数', () => { - assert.match(source, /`\$\{basePath\}\/\$\{created\.id\}\/approve\?accountId=10001`/) - assert.doesNotMatch(source, /fee-details\?accountId=10001\/\$\{created\.id\}/) + assert.match(source, /`\$\{basePath\}\/\$\{created\.id\}\/approve\?accountId=\$\{encodeURIComponent\(customerId\)\}`/) + assert.doesNotMatch(source, /fee-details\?accountId=.*\/\$\{created\.id\}/) }) test('费用来源使用 operationVersion,应付供应商长整型保持字符串精度', () => { diff --git a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs index 4ce0c5dc7..255375182 100644 --- a/scripts/admin-test-env-modules/finance-demo-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-demo-fixtures.mjs @@ -30,10 +30,10 @@ function blocker(context, name, response, evidence, category) { }) } -async function waitForSources(context, sourceFeeIds) { +async function waitForSources(context, customerId, sourceFeeIds) { let pool = [] for (let attempt = 0; attempt < 30; attempt += 1) { - const response = await context.request('GET', `${BASE}/fee-pool?customerId=10001&direction=ALL`) + const response = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`) pool = rows(data(response)).concat(rows(data(response)?.items)) const visible = new Set(pool.map((item) => id(item.sourceId))) if (sourceFeeIds.every((sourceId) => visible.has(sourceId))) return pool @@ -80,10 +80,11 @@ async function createExtraPayable(context, fixture, index) { unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', supplierId, remark: `${MARKER}-AP-${index}` } const basePath = `/api/platform/v1/inbounds/${inboundId}/fee-details` - const createdResponse = await write(context, 'POST', `${basePath}?accountId=10001`, body, `extra-payable-${index}`) + const customerId = id(fixture.customerId) + const createdResponse = await write(context, 'POST', `${basePath}?accountId=${encodeURIComponent(customerId)}`, body, `extra-payable-${index}`) const created = data(createdResponse) if (!ok(createdResponse) || !created?.id) return null - const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=10001`, undefined, `extra-payable-${index}-approve`) + const approveResponse = await write(context, 'POST', `${basePath}/${created.id}/approve?accountId=${encodeURIComponent(customerId)}`, undefined, `extra-payable-${index}-approve`) record(context, `创建第 ${index} 条额外应付来源`, approveResponse, ok(approveResponse), `sourceFeeId=${created.id}, inboundId=${inboundId}, supplierId=${supplierId}`) return { sourceFeeId: id(created.id), direction: 'PAYABLE', businessDate: body.businessDate, @@ -91,7 +92,7 @@ async function createExtraPayable(context, fixture, index) { } async function createStatement(context, source, role, targetStatus) { - const body = { customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', + const body = { customerId: id(source.customerId), currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [source.feeSourceId], periodStart: source.businessDate, periodEnd: source.businessDate, remark: `${MARKER}-${role}` } const response = await write(context, 'POST', `${BASE}/statements`, body, `statement-${role}-create`) @@ -120,7 +121,7 @@ async function createAr(context, statement, role) { async function receive(context, fixture, settlement, amount, role) { const response = await write(context, 'POST', `${BASE}/receipts`, { - customerId: 10001, receivedDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY', + customerId: id(fixture.customerId), receivedDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [fixture.voucherFileId], remark: `${MARKER}-${role}`, allocations: [{ billId: Number(settlement.reconciliationStatementId), amount }], }, `receipt-${role}`) @@ -130,7 +131,7 @@ async function receive(context, fixture, settlement, amount, role) { async function createAp(context, fixture, source, role) { const response = await write(context, 'POST', `${BASE}/payable-settlements`, { - customerId: 10001, supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)], + customerId: id(fixture.customerId), supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)], dueDate: new Date().toISOString().slice(0, 10), currency: 'CNY', }, `ap-${role}-${source.feeSourceId}-${source.supplierId}-create`) const value = data(response) @@ -165,8 +166,9 @@ async function recoverExistingDemo(context, reportPath) { const report = JSON.parse(await readFile(reportPath, 'utf8')) const fixture = report?.createdResources?.financeFullFixtures const matrix = structuredClone(report?.createdResources?.financeDemo ?? {}) - if (fixture?.marker !== matrix.runMarker || matrix.marker !== MARKER || matrix.customerId !== '10001') { - throw new Error('恢复报告不是 FIN-DEMO-20260812 / 客户 10001 的同一轮 fixture') + if (fixture?.marker !== matrix.runMarker || matrix.marker !== MARKER + || !id(matrix.customerId) || id(matrix.customerId) !== id(fixture.customerId)) { + throw new Error('恢复报告不是 FIN-DEMO-20260812 同一轮动态客户 fixture') } context.marker = matrix.runMarker await verifyUnfilteredLists(context) @@ -189,7 +191,7 @@ async function recoverExistingDemo(context, reportPath) { const role = roles[index] const detail = await sourceDetail(context, payableIds[index]) const source = detail.value - if (!ok(detail.response) || source?.direction !== 'PAYABLE' || source?.customerId !== '10001' + if (!ok(detail.response) || source?.direction !== 'PAYABLE' || id(source?.customerId) !== id(matrix.customerId) || !id(source?.sourceFeeId)) { blocker(context, `${role} 恢复来源属于本轮 fixture`, detail.response, `feeSourceId=${payableIds[index]}, sourceFeeId=${source?.sourceFeeId ?? 'missing'}`, @@ -276,9 +278,12 @@ export async function run(context) { const producer = await import('./finance-full-fixtures.mjs') await producer.run(context) const fixture = context.createdResources.financeFullFixtures - if (!fixture || fixture.marker !== runMarker || fixture.customerId !== '10001') { + const qualificationFixture = context.context.merchantQualificationFixture + const customerId = id(fixture?.customerId) + if (!fixture || fixture.marker !== runMarker || !customerId + || customerId !== id(qualificationFixture?.tenantId)) { blocker(context, '取得演示企业真实 Finance fixture', null, - 'finance-full-fixtures 未返回 marker=FIN-DEMO-20260812、customerId=10001 的独立资源,禁止借历史来源', + 'finance-full-fixtures 未返回与 merchant-qualification-fixture 同客户的本轮独立资源,禁止借历史来源', 'missing-finance-demo-fixture') return } @@ -289,10 +294,10 @@ export async function run(context) { if (value) extraPayables.push(value) } const produced = [...fixture.feeSources.map((item) => ({ ...item, sourceFeeId: id(item.feeSourceId) })), ...extraPayables] - const pool = await waitForSources(context, produced.map((item) => item.sourceFeeId).filter(Boolean)) + const pool = await waitForSources(context, customerId, produced.map((item) => item.sourceFeeId).filter(Boolean)) const poolBySource = new Map(pool.map((item) => [id(item.sourceId), item])) const sources = produced.map((source) => ({ ...source, - feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker, + feeSourceId: financeId(poolBySource.get(source.sourceFeeId)), marker: runMarker, customerId, supplierId: id(source.supplierId ?? fixture.supplier?.supplierId) })) .filter((source) => source.feeSourceId) const receivables = sources.filter((source) => source.direction === 'RECEIVABLE') @@ -304,7 +309,8 @@ export async function run(context) { return } - const matrix = { marker: MARKER, runMarker, customerId: '10001', customerLabel: '一期演示企业 10001', + const matrix = { marker: MARKER, runMarker, customerId, + customerLabel: qualificationFixture?.tenantName || `动态验收客户 ${customerId}`, feeSources: {}, statements: {}, receivableSettlements: {}, payableSettlements: {}, voucherFileId: id(fixture.voucherFileId), container: fixture.container, allocations: fixture.allocations } diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 4704d790d..0fed93283 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -22,7 +22,9 @@ test('measurement submission confirms weight evidence before submit', () => { }) test('measurement fixture derives pallet resources from confirmed image count', () => { - assert.match(source, /palletCount: WEIGHING_IMAGE_COUNT/) + assert.match(source, /waitSplitVariant\?\.palletCount \|\| 1/) + assert.match(source, /palletIds: warehouseResourcesReady\.palletIds/) + assert.match(source, /confirmedRows\.length === palletIds\.length/) assert.doesNotMatch(source, /Math\.ceil\(Number\(totalVolumeCbm\)/) assert.doesNotMatch(source, /Math\.ceil\(totalVolume \/ DEFAULT_PALLET_CAPACITY_CBM\)/) }) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 6c2f7e14d..f68f7d3d0 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -1,7 +1,6 @@ const MODULE = 'inbound-measurement' const SCENARIO = 'mojia-carton-measurement' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } -const WEIGHING_IMAGE_COUNT = 1 const FIXTURE_PALLET_CAPACITY_CBM = 1.2 const WEIGHT_IMAGE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, @@ -44,16 +43,17 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa if (!warehouseId || !Number.isInteger(palletCount) || palletCount < 1) return null const suffix = `${String(context.marker).replace(/\D/g, '').slice(-8)}${String(inboundId).slice(-6)}` - const palletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', { - warehouseId, palletType: 'STANDARD', capacityCbm: FIXTURE_PALLET_CAPACITY_CBM, - weightKg: 8, color: '蓝色', remark: `${context.marker}-INBOUND-${inboundId}`, - }) - const palletId = asId(context.apiSuccess(MODULE, '创建称重托盘主数据', palletResponse, - `warehouseId=${warehouseId}`, OPTIONS)) - if (!palletId) return null - const resources = [] + const palletIds = [] for (let index = 1; index <= palletCount; index += 1) { + const palletResponse = await context.request('POST', '/api/platform/v1/pallets?tenantId=0', { + warehouseId, palletType: 'STANDARD', capacityCbm: FIXTURE_PALLET_CAPACITY_CBM, + weightKg: 8, color: '蓝色', remark: `${context.marker}-INBOUND-${inboundId}-PALLET-${index}`, + }) + const palletId = asId(context.apiSuccess(MODULE, `创建称重托盘主数据 ${index}/${palletCount}`, + palletResponse, `warehouseId=${warehouseId}`, OPTIONS)) + if (!palletId) return null + palletIds.push(palletId) const resourceSuffix = `${suffix}${String(index).padStart(2, '0')}` let locationId = null if (context.context?.phase3LoadingRequireSharedOverflow !== true) { @@ -75,54 +75,65 @@ async function provisionWarehouseResources(context, { inboundId, warehouseId, pa marker: context.marker, tenantId: '0', warehouseId: asId(warehouseId), palletCount, palletCapacityCbm: FIXTURE_PALLET_CAPACITY_CBM, resources, } - return { palletId, palletCount, resources } + return { palletId: palletIds[0], palletIds, palletCount, resources } } -async function createConfirmedWeightEvidence(context, { inboundId, tenantId, palletId }) { - const uploadResponse = await context.requestMultipart('POST', - `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, { - content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}.png`, - }) - const upload = context.apiSuccess(MODULE, '上传称重图片并执行 OCR', uploadResponse, - `inboundId=${inboundId}`, OPTIONS) - const fileId = asId(upload?.fileId) - if (!fileId) return null +async function createConfirmedWeightEvidence(context, { + inboundId, tenantId, palletIds, grossWeightPerPalletKg, +}) { + if (!Array.isArray(palletIds) || palletIds.length < 1) return null + const confirmedResults = [] + for (let index = 0; index < palletIds.length; index += 1) { + const palletId = palletIds[index] + const uploadResponse = await context.requestMultipart('POST', + `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, { + content: WEIGHT_IMAGE, type: 'image/png', name: `weight-${inboundId}-${index + 1}.png`, + }) + const upload = context.apiSuccess(MODULE, `上传称重图片并执行 OCR ${index + 1}/${palletIds.length}`, + uploadResponse, `inboundId=${inboundId}`, OPTIONS) + const fileId = asId(upload?.fileId) + if (!fileId) return null - const createResponse = await context.request('POST', - `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, { - fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, - ocrWeightValue: null, ocrWeightUnit: null, confidence: null, - }) - const created = context.apiSuccess(MODULE, '登记称重图片与托盘快照', createResponse, - `fileId=${fileId}, palletId=${palletId}`, OPTIONS) - if (!created?.id) return null + const createResponse = await context.request('POST', + `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results?${query(tenantId)}`, { + fileId, palletId, ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, + ocrWeightValue: null, ocrWeightUnit: null, confidence: null, + }) + const created = context.apiSuccess(MODULE, `登记称重图片与托盘快照 ${index + 1}/${palletIds.length}`, + createResponse, `fileId=${fileId}, palletId=${palletId}`, OPTIONS) + if (!created?.id) return null - const confirmResponse = await context.request('POST', - `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, { - version: Number(created.version), adjustedWeightValue: 100, - adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 自动化人工确认称重`, - }) - const confirmed = context.apiSuccess(MODULE, '人工确认称重图片净重', confirmResponse, - `resultId=${created.id}, palletId=${palletId}`, OPTIONS) - const netWeightKg = Number(confirmed?.netWeightKg) - const grossWeightKg = Number(confirmed?.grossWeightKg) - if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) { - return null + const grossWeight = Number(grossWeightPerPalletKg) + index * 5 + const confirmResponse = await context.request('POST', + `/api/platform/v1/first-leg/inbounds/${inboundId}/weight-results/${created.id}/confirm?${query(tenantId)}`, { + version: Number(created.version), adjustedWeightValue: grossWeight, + adjustedWeightUnit: 'KG', adjustmentReason: `${context.marker} 自动化人工确认称重`, + }) + const confirmed = context.apiSuccess(MODULE, `人工确认称重图片净重 ${index + 1}/${palletIds.length}`, + confirmResponse, `resultId=${created.id}, palletId=${palletId}`, OPTIONS) + const netWeightKg = Number(confirmed?.netWeightKg) + const grossWeightKg = Number(confirmed?.grossWeightKg) + if (confirmed?.recognitionStatus !== 'CONFIRMED' || !Number.isFinite(netWeightKg) || netWeightKg <= 0) { + return null + } + confirmedResults.push({ id: asId(created.id), palletId, netWeightKg, grossWeightKg }) } const workspaceResponse = await context.request('GET', `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement?${query(tenantId)}`) const workspace = context.apiSuccess(MODULE, '回查已确认称重图片和货物总重量', workspaceResponse, - `resultId=${created.id}`, OPTIONS) + `resultIds=${confirmedResults.map((result) => result.id).join(',')}`, OPTIONS) const confirmedRows = (workspace?.weightResults || []) .filter((result) => result?.recognitionStatus === 'CONFIRMED') - const persisted = confirmedRows.length === WEIGHING_IMAGE_COUNT - && asId(confirmedRows[0]?.id) === asId(created.id) - && asId(confirmedRows[0]?.palletId) === palletId + const grossWeightKg = confirmedResults.reduce((sum, result) => sum + result.grossWeightKg, 0) + const netWeightKg = confirmedResults.reduce((sum, result) => sum + result.netWeightKg, 0) + const persisted = confirmedRows.length === palletIds.length + && confirmedResults.every((expected) => confirmedRows.some((row) => + asId(row?.id) === expected.id && asId(row?.palletId) === expected.palletId)) && closeTo(workspace?.actualGrossWeightKg, grossWeightKg, 0.001) && closeTo(workspace?.actualNetWeightKg, netWeightKg, 0.001) context.assert(MODULE, '图片张数决定托数且净重汇总已落地', workspaceResponse, persisted, - `confirmedImages=${confirmedRows.length}, expectedPallets=${WEIGHING_IMAGE_COUNT}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`, + `confirmedImages=${confirmedRows.length}, expectedPallets=${palletIds.length}, grossWeight=${grossWeightKg}, netWeight=${netWeightKg}, actualGross=${workspace?.actualGrossWeightKg ?? 'missing'}, actualNet=${workspace?.actualNetWeightKg ?? 'missing'}`, OPTIONS) return persisted ? { count: confirmedRows.length, grossWeightKg, netWeightKg } : null } @@ -280,8 +291,8 @@ export async function run(context) { } const scanPath = `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/carton-scans?${query(tenantId)}` - // Keep the isolated sample inside the customer's largest configured inner box (60x50x50cm). - const firstDimensions = { length: 59.1, width: 40.2, height: 30.3 } + const measurementProfile = context.context?.waitSplitVariant?.measurementProfile + const firstDimensions = measurementProfile?.first || { length: 59.1, width: 40.2, height: 30.3 } const firstShipmentId = `FBA-${context.marker.slice(-12)}-01` const firstScanResponse = await multipartRequest(context, scanPath, { boxCode, ...firstDimensions, scannedShipmentId: firstShipmentId, @@ -325,7 +336,7 @@ export async function run(context) { && Number(countResult?.measurementVersion) === Number(firstScan.measurementVersion) + 1, `version=${countResult?.measurementVersion ?? 'missing'}`, OPTIONS) - const replacement = { length: 59.5, width: 41.2, height: 31.3 } + const replacement = measurementProfile?.replacement || { length: 59.5, width: 41.2, height: 31.3 } const replacementShipmentId = `FBA-${context.marker.slice(-12)}-02` const rescanResponse = await multipartRequest(context, scanPath, { boxCode, ...replacement, scannedShipmentId: replacementShipmentId, @@ -342,8 +353,7 @@ export async function run(context) { let measuredResponse = await context.request('GET', measurementPath) let measured = context.apiSuccess(MODULE, '回查覆盖后的尺寸与体积结果', measuredResponse, '', OPTIONS) let measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) - // The verified customer rule maps this scan to EXTRA_LARGE 60x50x50cm and adds 2cm per edge. - const weightedOuter = { length: 64, width: 54, height: 54 } + const weightedOuter = measurementProfile?.outer || { length: 64, width: 54, height: 54 } const unitVolume = halfUp( weightedOuter.length * weightedOuter.width * weightedOuter.height / 1_000_000, 3) const totalVolume = unitVolume * originalPackageCount @@ -374,7 +384,7 @@ export async function run(context) { `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${firstScan.itemId}/confirmed-package-count?${query(tenantId)}`, { measurementVersion: Number(measured?.version), confirmedPackageCount: packagesPerDetail }) const resetFirstCount = context.apiSuccess(MODULE, - '增量 fixture 将首条货物确认箱数恢复为自身两箱', resetFirstCountResponse, + '增量 fixture 将首条货物确认箱数恢复为自身箱数', resetFirstCountResponse, `itemId=${asId(firstScan.itemId)}, confirmedPackageCount=${packagesPerDetail}`, OPTIONS) context.assert(MODULE, '增量首条货物箱数和版本更新正确', resetFirstCountResponse, Number(resetFirstCount?.confirmedPackageCount) === packagesPerDetail @@ -409,10 +419,10 @@ export async function run(context) { `measurementVersion=${countDetail?.measurementVersion ?? 'missing'}`, OPTIONS) } measuredResponse = await context.request('GET', measurementPath) - measured = context.apiSuccess(MODULE, '回查增量 fixture 四条测量明细', measuredResponse, '', OPTIONS) + measured = context.apiSuccess(MODULE, '回查增量 fixture 动态测量明细', measuredResponse, '', OPTIONS) measuredItem = measured?.items?.find((item) => asId(item.id) === asId(firstScan.itemId)) const detailIds = new Set((measured?.items || []).map((item) => asId(item?.inboundDetailId)).filter(Boolean)) - context.assert(MODULE, '增量 fixture 形成四条真实独立货物测量明细', measuredResponse, + context.assert(MODULE, '增量 fixture 形成动态真实独立货物测量明细', measuredResponse, detailIds.size >= incrementalDetailCount && measured.items.filter((item) => detailIds.has(asId(item?.inboundDetailId))).length >= incrementalDetailCount, `measurementItems=${measured?.items?.length ?? 'missing'}, distinctInboundDetails=${detailIds.size}`, OPTIONS) @@ -482,12 +492,13 @@ export async function run(context) { return } + const palletCount = Math.max(1, Number(context.context?.waitSplitVariant?.palletCount || 1)) const warehouseResourcesReady = await provisionWarehouseResources(context, { - inboundId, warehouseId: asId(summary?.warehouseId), palletCount: WEIGHING_IMAGE_COUNT, + inboundId, warehouseId: asId(summary?.warehouseId), palletCount, }) context.assert(MODULE, '测量提交前按称重图片数准备真实库位', draftResponse, Boolean(warehouseResourcesReady), - `warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${WEIGHING_IMAGE_COUNT}`, + `warehouseId=${asId(summary?.warehouseId) || 'missing'}, palletCount=${palletCount}`, OPTIONS) if (!warehouseResourcesReady) { finishScenario(context, start) @@ -495,11 +506,12 @@ export async function run(context) { } const weightEvidence = await createConfirmedWeightEvidence(context, { - inboundId, tenantId, palletId: warehouseResourcesReady.palletId, + inboundId, tenantId, palletIds: warehouseResourcesReady.palletIds, + grossWeightPerPalletKg: Number(context.context?.waitSplitVariant?.grossWeightPerPalletKg || 100), }) context.assert(MODULE, '测量提交前已通过正式接口确认称重图片', draftResponse, Boolean(weightEvidence), - `confirmedImages=${weightEvidence?.count ?? 0}, expected=${WEIGHING_IMAGE_COUNT}`, + `confirmedImages=${weightEvidence?.count ?? 0}, expected=${palletCount}`, OPTIONS) if (!weightEvidence) { finishScenario(context, start) diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 746730c2a..f462e5104 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -7,23 +7,23 @@ const iamSeed = readFileSync(new URL('../../iam/src/main/resources/db/phase1-adm const oceanBookingSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql', import.meta.url), 'utf8') const truckAppointmentSchema = readFileSync(new URL('../../firstleg/src/main/resources/db/phase3-truck-appointment-schema.sql', import.meta.url), 'utf8') -test('统一初始化 SQL 保留最小登录与客户主体', () => { - assert.match(sql, /一个平台系统管理员和一个 IT 测试客户管理员/) +test('统一初始化 SQL 只保留平台管理员且不创建固定客户', () => { + assert.match(sql, /最小登录基线只保留平台系统管理员/) assert.equal((sql.match(/'platform_admin'/g) || []).length, 1) - assert.equal((sql.match(/'merchant_admin'/g) || []).length, 1) - assert.match(sql, /\(10001, 'IT_TEST', 'IT 测试客户'/) + assert.doesNotMatch(sql, /'merchant_admin'|'IT_TEST'|'IT 测试客户'/) + assert.doesNotMatch(iamSeed, /'IT_TEST'|'IT 测试客户'|\(10001,/) const accountSeed = sql.match(/INSERT INTO auth_account \([\s\S]*?deleted = 0;/)?.[0] assert.ok(accountSeed, 'missing auth account seed') assert.doesNotMatch(accountSeed, /ON DUPLICATE KEY UPDATE[\s\S]*?password_hash\s*=/, 're-running the unified SQL must not reset an existing account password') }) -test('IT 测试客户初始化后具备已审核的必填资质和下单资格', () => { - for (const source of [sql, iamSeed]) { - assert.match(source, /\(2100001, 10001, 'BUSINESS_LICENSE', '91310000ITTEST0001', NULL,[\s\S]*?'APPROVED'/) - assert.match(source, /\(2100002, 10001, 'LEGAL_PERSON_ID', NULL, NULL,[\s\S]*?'APPROVED'/) - assert.match(source, /status = 'APPROVED'/) - } +test('统一初始化直接提供tenant0商户角色与权限模板', () => { + assert.match(sql, /4000100, 0, 'MERCHANT', 'MERCHANT_OWNER'/) + assert.match(sql, /5300000, 0, 'MERCHANT', 'MENU',[\s\S]*?'merchant:enterprise'/) + assert.match(sql, /5301024, 0, 'MERCHANT', 'BUTTON',[\s\S]*?'merchant:order:quick-create'/) + assert.match(sql, /5305000, 0, 'MERCHANT', 'MENU',[\s\S]*?'merchant:finance:statement'/) + assert.match(sql, /r\.id=4000100[\s\S]*?客户全局管理员 Finance 模板授权不完整/) }) test('统一初始化 SQL 提供人工验收所需公共仓储主数据', () => { diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 6560cab8f..ac4d215a1 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test' const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8') +const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') test('默认全量验收先准备已审核商户再运行订单链路', () => { const modules = acceptance.match(/const CONFIGURED_MODULES = \[([^\n]+)\]/)?.[1] || '' @@ -22,5 +23,38 @@ test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () assert.match(fixture, /item\.status === 'APPROVED'/) assert.match(fixture, /MERCHANT_TEST_USERNAME/) assert.match(fixture, /MERCHANT_TEST_PASSWORD/) + assert.match(fixture, /context\.options\?\.customerName/) assert.doesNotMatch(fixture, /internal\/iam|UPDATE\s+iam_|INSERT\s+INTO\s+iam_/i) }) + +test('批量待分柜参数固定同一客户并限制为订单测量链', () => { + assert.match(acceptance, /--customer-name /) + assert.match(acceptance, /--merchant-username /) + assert.match(acceptance, /--origin-warehouse-name /) + assert.match(acceptance, /--channel-name /) + assert.match(acceptance, /--wait-split-count /) + assert.match(acceptance, /--order-profile-mode /) + assert.match(acceptance, /merchant-username和merchant-password必须同时提供/) + assert.match(acceptance, /modules\.splice\(fixtureIndex, 1\)/) + assert.match(acceptance, /wait-split-count必须是1-100的整数/) + assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/) + assert.match(acceptance, /createAdditionalWaitSplitFixtures/) + assert.match(acceptance, /measurement\.processStatus === 'WAIT_SPLIT'/) + assert.match(acceptance, /measurement\.finalStatus === 'APPROVED'/) + assert.match(acceptance, /waitSplitVariant\(sequence\)/) + assert.match(acceptance, /packageCountPerSku/) + assert.match(acceptance, /grossWeightPerPalletKg/) + assert.match(acceptance, /randomizedOrderProfile\(sequence\)/) + assert.match(acceptance, /\['PRIVATE', 'STANDARD'\]/) + assert.match(acceptance, /ONE_CLICK_DROPSHIP/) + assert.match(acceptance, /destinationWarehouseCode: order\.destinationWarehouseCode/) + assert.match(acceptance, /channelName: order\.channelName/) + assert.match(acceptance, /pricingTaxMode: order\.pricingTaxMode/) + assert.match(orders, /customerId: profile\.id/) + assert.match(orders, /customerName: companyName/) + assert.match(orders, /\['FBA', 'OVERSEAS', 'PRIVATE'\]\.includes/) + assert.match(orders, /destinationType === 'PRIVATE'/) + assert.match(orders, /PRIVATE_ADDRESS/) + assert.match(orders, /eligible\.length < 1/) + assert.doesNotMatch(orders, /customerName: master\.partner\?\.partnerName/) +}) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index 61c541603..a21a9053c 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -28,10 +28,11 @@ async function waitForDimensionInitialization(context, tenantId) { export async function run(context) { const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0') + const customerName = String(context.options?.customerName || '').trim() const phone = await context.findAvailablePhone(MODULE) const createResponse = await context.request('POST', '/api/platform/v1/iam/tenants', { - tenantName: `全量验收下单客户-${suffix}`, - legalName: `全量验收下单有限公司-${suffix}`, + tenantName: customerName || `全量验收下单客户-${suffix}`, + legalName: customerName ? `${customerName}有限公司` : `全量验收下单有限公司-${suffix}`, legalRepresentative: '全量验收法人', contactName: '全量验收管理员', contactPhone: phone, diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index fdfe9b1b9..246386f9e 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -2,8 +2,8 @@ import { findPricedOrderRouteAcrossOrigins } from './orders.mjs' const MODULE = 'order-inbound-status-fixtures' const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const ORDER_TARGETS = [ ['DRAFT', []], diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 3e7b83c41..d3d3c5954 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -1,7 +1,7 @@ const MODULE = 'orders' const CONTRACT_OPTIONS = { scenario: 'isolated-order-chain', deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const BOXES = [ { boxType: 'SMALL', lengthCm: 30, widthCm: 20, heightCm: 20 }, { boxType: 'MEDIUM', lengthCm: 40, widthCm: 30, heightCm: 30 }, @@ -21,8 +21,8 @@ function success(response) { return Boolean(response) && response.http === 200 & function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } export function requestedOrderDestinationType(context) { - return String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() === 'OVERSEAS' - ? 'OVERSEAS' : 'FBA' + const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() + return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA' } export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) { const summary = orderDetail?.summary @@ -202,35 +202,88 @@ async function assertProductImageDownload(context, merchantToken, fileId) { async function loadMasterData(context, merchantToken) { const destinationType = requestedOrderDestinationType(context) + const fixedRoute = context.context?.waitSplitRoute || {} const domesticResponse = await request(context, 'GET', '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', undefined, merchantToken) const domesticPage = context.apiSuccess(MODULE, 'Client 查询国内收货仓', domesticResponse) context.assert(MODULE, '存在可用国内收货仓', domesticResponse, records(domesticPage).length > 0, `count=${records(domesticPage).length}`) - const destinationResponse = await request(context, 'GET', - `/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, - undefined, merchantToken) - const destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse) - context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse, - records(destinationPage).length > 0, `count=${records(destinationPage).length}`) + let destinationResponse = null + let destinationPage = null + if (destinationType !== 'PRIVATE') { + destinationResponse = await request(context, 'GET', + `/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, + undefined, merchantToken) + destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse) + context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse, + records(destinationPage).length > 0, `count=${records(destinationPage).length}`) + } const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE') - const destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE') - if (receivings.length === 0 || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓') + let destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE') + if (Number(destinationPage?.total) > destinations.length) { + const nextResponse = await request(context, 'GET', + `/api/merchant/v1/warehouses?page=2&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, + undefined, merchantToken) + const nextPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓第二页`, nextResponse) + destinations = [...destinations, ...records(nextPage).filter((warehouse) => warehouse?.status === 'ACTIVE')] + } + if (receivings.length === 0 || (destinationType !== 'PRIVATE' && destinations.length === 0)) { + throw new Error('缺少创建测试订单所需的收货仓或目的仓') + } - const pricingResult = await findPricedOrderRouteAcrossOrigins({ - receivings, destinations, - loadChannels: (receiving) => request(context, 'GET', + let pricingResult + if (fixedRoute.originWarehouseName && (fixedRoute.channelCode || fixedRoute.channelName)) { + const receiving = receivings.find((warehouse) => warehouse.warehouseName === fixedRoute.originWarehouseName) + if (!receiving) throw new Error(`未找到固定起运仓: ${fixedRoute.originWarehouseName}`) + const channelsResponse = await request(context, 'GET', `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, - undefined, merchantToken), - estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), - destinationType, - }) + undefined, merchantToken) + const channels = context.apiSuccess(MODULE, 'Client 查询固定起运仓物流渠道', channelsResponse) + const channel = (Array.isArray(channels) ? channels : []).find((item) => + (!fixedRoute.channelCode || item.channelCode === fixedRoute.channelCode) + && (!fixedRoute.channelName || item.channelName === fixedRoute.channelName)) + if (!channel) throw new Error(`未找到固定物流渠道: ${fixedRoute.channelCode || fixedRoute.channelName}`) + const modes = Array.isArray(channel.pricingTaxModes) ? [...channel.pricingTaxModes] : [] + if (modes.length === 0) throw new Error('固定物流渠道没有可用报价税制') + const shift = modes.length ? Number(fixedRoute.taxModeIndex || 0) % modes.length : 0 + const selectedChannel = { ...channel, pricingTaxModes: [...modes.slice(shift), ...modes.slice(0, shift)] } + if (destinationType === 'PRIVATE') { + pricingResult = { receiving, priced: { channel: selectedChannel, + pricingTaxMode: selectedChannel.pricingTaxModes[0], destination: null }, + response: channelsResponse, attempts: 0, failures: [], originFailures: [] } + } else { + const detailResponse = await context.request('GET', + `/api/platform/v1/fba-pricing/carrier-rates/price-books/${encodeURIComponent(channel.priceBookVersionId)}`) + const detail = context.apiSuccess(MODULE, 'Admin 查询固定渠道当前价格明细', detailResponse) + const pricedCodes = new Set((detail?.rates || []) + .filter((rate) => rate.channelCode === channel.channelCode && rate.destinationType === destinationType) + .map((rate) => String(rate.destinationCode || '').trim()).filter(Boolean)) + const eligible = destinations.filter((destination) => pricedCodes.has(String(destination.warehouseCode))) + if (eligible.length < 1) throw new Error(`固定渠道没有可计价目的仓: ${destinationType}`) + const destination = eligible[Number(fixedRoute.destinationIndex || 0) % eligible.length] + const result = await findPricedOrderRoute({ + receiving, destinations: [destination], channels: [selectedChannel], + estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), + destinationType, + }) + pricingResult = { receiving, ...result, originFailures: [] } + } + } else { + pricingResult = await findPricedOrderRouteAcrossOrigins({ + receivings, destinations, + loadChannels: (receiving) => request(context, 'GET', + `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, + undefined, merchantToken), + estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), + destinationType, + }) + } const priced = pricingResult.priced const receiving = pricingResult.receiving context.addStep(MODULE, 'Client 从 ACTIVE 国内仓选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced), priced - ? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination.warehouseCode}, attempts=${pricingResult.attempts}` + ? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination?.warehouseCode || 'PRIVATE_ADDRESS'}, attempts=${pricingResult.attempts}` : originPricingFailureEvidence(pricingResult), CONTRACT_OPTIONS) if (!priced) throw new Error(`当前 ACTIVE 国内收货仓、渠道、报价税制和 ${destinationType} 目的仓没有可计价组合`) @@ -249,22 +302,39 @@ function buildPayload(context, profile, master, productImageFileId) { const shipmentId = `SHIP-${context.marker}`.slice(0, 64) const referenceId = `REF-${context.marker}`.slice(0, 64) const companyName = profile.tenantName || `订单验收客户-${context.marker}` + const requestedSkuCount = Number(context.context?.waitSplitVariant?.skuCount || BOXES.length) + const skuCount = Math.max(1, Math.min(BOXES.length, requestedSkuCount)) + const packageCountPerSku = Math.max(1, + Number(context.context?.waitSplitVariant?.packageCountPerSku + || context.context?.phase3LoadingIncrementalPackageCount || 1)) + const selectedBoxes = BOXES.slice(0, skuCount) + const orderProfile = context.context?.orderProfile || {} + const isPrivate = master.destinationType === 'PRIVATE' + const pickupMethod = orderProfile.pickupMethod || 'PICKUP' + const receiver = isPrivate ? { + companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64), + phone: '12025550188', email: 'private-receiver@example.com', countryCode: 'US', + province: 'New York', city: 'New York', district: 'Queens', + addressLine: `${context.marker} 88 Randomized Test Ave`, postalCode: '11354', + } : null return { fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(), - customerId: master.partner?.id ?? null, - customerName: master.partner?.partnerName ?? companyName, + customerId: profile.id, + customerName: companyName, transportMode: 'SEA', tradeTerm: 'FOB', - destinationCountryCode: master.destination.countryCode || 'US', - destinationWarehouseId: master.destination.id, - destinationWarehouseName: master.destination.warehouseName, + destinationCountryCode: master.destination?.countryCode || receiver?.countryCode || 'US', + destinationWarehouseId: master.destination?.id || null, + destinationWarehouseName: master.destination?.warehouseName || (isPrivate ? '私人地址' : null), expectedShipDate: tomorrow(), customerRemark: `${context.marker} Client 创建隔离订单`, - pickupMethod: 'PICKUP', receivingWarehouseId: master.receiving.id, + pickupMethod, requestedPickupAt: pickupMethod === 'PICKUP' + ? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null, + receivingWarehouseId: master.receiving.id, receivingWarehouseName: master.receiving.warehouseName, channelCode: master.channel.channelCode, channelName: master.channel.channelName, - carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION', - pricingTaxMode: master.pricingTaxMode, containerMode: 'LCL', - customsInspection: false, destinationType: master.destinationType, - amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination.warehouseCode : null, + carrierId: null, carrierName: null, taxMode: orderProfile.taxMode || 'BUY_DECLARATION', + pricingTaxMode: master.pricingTaxMode, containerMode: orderProfile.containerMode || 'LCL', + customsInspection: orderProfile.customsInspection ?? false, destinationType: master.destinationType, + amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination?.warehouseCode : null, shipmentId: master.destinationType === 'FBA' ? shipmentId : null, referenceId: master.destinationType === 'FBA' ? referenceId : null, sender: { @@ -272,11 +342,11 @@ function buildPayload(context, profile, master, productImageFileId) { email: 'order-acceptance@example.com', countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', addressLine: `${context.marker} 测试地址`, postalCode: '518000', }, - receiver: null, - cargoes: BOXES.map((box, index) => { + receiver, + cargoes: selectedBoxes.map((box, index) => { const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000 const unitsPerPackage = 10 + index - const packageCount = Number(context.context?.phase3LoadingIncrementalPackageCount) === 2 ? 2 : 1 + const packageCount = packageCountPerSku return { skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64), cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`, @@ -300,11 +370,12 @@ function buildPayload(context, profile, master, productImageFileId) { } function assertCargoContract(context, response, cargoes, payload) { - const complete = Array.isArray(cargoes) && cargoes.length === BOXES.length - && BOXES.every((box, index) => { - const actual = cargoes[index]; const expected = payload.cargoes[index] - return Number(actual?.lengthCm) === box.lengthCm && Number(actual?.widthCm) === box.widthCm - && Number(actual?.heightCm) === box.heightCm && id(actual?.productImageFileId) === id(expected.productImageFileId) + const expectedCargoes = payload.cargoes || [] + const complete = Array.isArray(cargoes) && cargoes.length === expectedCargoes.length + && expectedCargoes.every((expected, index) => { + const actual = cargoes[index] + return Number(actual?.lengthCm) === expected.lengthCm && Number(actual?.widthCm) === expected.widthCm + && Number(actual?.heightCm) === expected.heightCm && id(actual?.productImageFileId) === id(expected.productImageFileId) && actual?.skuNo === expected.skuNo && actual?.cargoName === expected.cargoName && actual?.cargoNameEn === expected.cargoNameEn && actual?.customsHsCode === expected.customsHsCode && Number(actual?.unitsPerPackage) === expected.unitsPerPackage @@ -320,7 +391,7 @@ function assertCargoContract(context, response, cargoes, payload) { && (actual?.referenceId ?? null) === (expected.referenceId ?? null) && actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker) }) - context.assert(MODULE, '四箱型产品字段及图片契约完整回显', response, complete, + context.assert(MODULE, '动态SKU产品字段及图片契约完整回显', response, complete, `cargoes=${Array.isArray(cargoes) ? cargoes.length : 'n/a'}, imageFileId=${payload.cargoes[0].productImageFileId}`, CONTRACT_OPTIONS) } @@ -405,9 +476,18 @@ export async function run(context) { })), boxTypes: BOXES.map((box) => box.boxType), inboundId: null, receivingWarehouseId: id(master.receiving.id), + receivingWarehouseName: master.receiving.warehouseName, destinationType: master.destinationType, - destinationWarehouseId: id(master.destination.id), - destinationWarehouseCode: master.destination.warehouseCode, + destinationWarehouseId: id(master.destination?.id), + destinationWarehouseCode: master.destination?.warehouseCode || null, + destinationWarehouseName: master.destination?.warehouseName || (master.destinationType === 'PRIVATE' ? '私人地址' : null), + channelCode: master.channel.channelCode, + channelName: master.channel.channelName, + pricingTaxMode: master.pricingTaxMode, + pickupMethod: payload.pickupMethod, + taxMode: payload.taxMode, + containerMode: payload.containerMode, + customsInspection: payload.customsInspection, } const replayResponse = await request(context, 'POST', createPath, payload, merchantToken, diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index de3230435..18a4d7be2 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -4,8 +4,8 @@ const MODULE = 'phase3-customs' const OPTIONS = { scenario: 'phase3-customs-collaboration', deploymentBlocker: true } const PLATFORM = '/api/platform/v1/customs' const MERCHANT = '/api/merchant/v1/customs' -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 9059f6a91..060f643d0 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -3,8 +3,8 @@ import { createHash } from 'node:crypto' const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' function id(value) { return value == null ? '' : String(value) } diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index d42339d61..eaddf6079 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -1,8 +1,8 @@ const MODULE = 'phase3-order-splitting' const SCENARIO = 'source-loaded-single-remainder-chain' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' const CONTAINERS = '/api/platform/v1/containers' const SMART = '/api/platform/v1/first-leg/smart-loading' const PDA = '/api/platform/v1/first-leg/pda-loading' diff --git a/scripts/admin-test-env-modules/supplemental.mjs b/scripts/admin-test-env-modules/supplemental.mjs index 24a95aa72..28bef19f7 100644 --- a/scripts/admin-test-env-modules/supplemental.mjs +++ b/scripts/admin-test-env-modules/supplemental.mjs @@ -46,6 +46,10 @@ export async function run(context) { const module = 'supplemental' const scenario = 'service-health-and-auth-boundary' const blocker = { scenario, deploymentBlocker: true } + const customerId = String(context.context.merchantQualificationFixture?.tenantId || '') + context.assert(module, '客户作用域负向检查使用本轮正式客户', null, Boolean(customerId), + `customerId=${customerId || 'missing'}`, blocker) + if (!customerId) return // Health endpoints are part of the deployed service contract. Warehouse is // special: a schema/readiness failure is intentionally HTTP 503, not a fake @@ -102,7 +106,7 @@ export async function run(context) { } 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'], + ['仓库客户作用域', `/api/platform/v1/warehouse-admin?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], ]) { const response = await context.request('GET', path) context.addStep(module, `${name}安全失败`, response, isBusinessFailure(response), @@ -118,8 +122,8 @@ export async function run(context) { 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'], + ['库位客户作用域', `/api/platform/v1/locations?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], + ['托盘客户作用域', `/api/platform/v1/pallets?page=1&size=5&tenantId=${encodeURIComponent(customerId)}`], ['库位非法状态', `${locationBase}&status=ALLOCATED`], ] for (const [name, path] of resourceScopeChecks) { diff --git a/scripts/admin-test-env-modules/suppliers.mjs b/scripts/admin-test-env-modules/suppliers.mjs index abb38b91a..ae2b96255 100644 --- a/scripts/admin-test-env-modules/suppliers.mjs +++ b/scripts/admin-test-env-modules/suppliers.mjs @@ -1,8 +1,8 @@ const MODULE = 'suppliers' const OPTIONS = { scenario: 'platform-public-suppliers', deploymentBlocker: true } const BASE = '/api/platform/v1/suppliers' -const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' -const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' +const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' +const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function code(response, expected) { return response?.http === 200 && String(response?.body?.code) === expected } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index bc11e7d09..fe61a180d 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -172,6 +172,48 @@ Assert-Contains $overseasCompose '/internal/lastleg/security/v1/events' 'Lastleg Assert-Contains $overseasCompose 'LASTLEG_SECURITY_SERVICE_URL' 'Lastleg 安全校验地址' Assert-NotContains $serviceJenkinsfile "'overseas-security-service'" '独立海外安全服务构建入口' +# 高变化语义必须在最近指引与服务契约中保持一致。 +Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 四期范围边界' +Assert-Contains $pdaAgents 'VITE_H5_API_BASE_URL' '国内 PDA H5 运行配置' +Assert-Contains $overseasPdaAgents 'tests/app-icons.test.mjs' '海外 PDA 图标合同' +Assert-Contains $backendAgents 'RestClientTraceInterceptor.java' '后端同步 Trace 入口' +$oceanBookingOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml') +Assert-Contains $oceanBookingOpenApi 'never represents linked or ACTIVE containers' '订舱柜量语义' +Assert-Contains $oceanBookingOpenApi '/api/platform/v1/ocean-bookings/link-candidates:' '订舱关联候选契约' +Assert-Contains $oceanBookingOpenApi 'exact warehouseId, channelCode and containerType' '订舱关联候选筛选语义' + +$adminOceanBookingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts') +$adminOceanBookingView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue') +$adminOceanBookingViewTest = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.test.ts') +Assert-Contains $adminOceanBookingApi '/link-candidates' 'Admin 订舱关联候选调用' +Assert-Contains $adminOceanBookingView "label: '订舱柜量'" 'Admin 订舱柜量文案' +Assert-Contains $adminOceanBookingViewTest 'without detail N+1' 'Admin 订舱列表无详情扇出合同' + +# 已注册但超出当前四期边界的海外出库只能作为范围漂移事实存在,不得进入根正式业务路由。 +$adminOverseasRoutes = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/routes.ts') +Assert-Contains $adminOverseasRoutes "path: 'overseas-warehouse/outbounds'" 'Admin 海外出库现存路由' +Assert-Contains $adminAgents '海外出库(范围外现存代码)' 'Admin 海外出库范围警示' +Assert-NotContains $rootAgents 'OutboundsView.vue' '根正式四期业务路由' + +# Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。 +$navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts') +Assert-Contains $navigationRecovery 'const RECOVERY_WINDOW_MS = 30_000' 'Admin 路由恢复窗口' +Assert-Contains $navigationRecovery 'if (dependencies.hasUnsavedWork() && !await dependencies.confirmDiscard()) return true' 'Admin 未保存编辑保护' +Assert-Contains $navigationRecovery 'if (!isStaleRouteAssetError(error))' 'Admin 普通路由错误边界' + +# 国内 PDA 的 H5 地址必须来自当前构建和同源代理,不能被历史登录缓存覆盖。 +$domesticPdaRuntime = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/config/runtime.js') +$domesticPdaVite = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/vite.config.js') +$domesticPdaSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/stores/session.js') +Assert-Contains $domesticPdaRuntime "VITE_H5_API_BASE_URL || '/api'" '国内 PDA H5 同源 API' +Assert-Contains $domesticPdaVite 'API_PROXY_TARGET' '国内 PDA H5 代理上游' +Assert-Contains $domesticPdaSession 'session.apiBase = PLATFORM_API_ROOT' '国内 PDA 当前构建 API' +Assert-NotContains $domesticPdaSession 'apiBase: session.apiBase' '国内 PDA 会话持久化' + +# 同步 RestClient Trace 必须注册为公共自动配置,不能只有孤立拦截器源码。 +$commonWebAutoConfigurations = Read-RequiredText (Join-Path $backendRoot 'common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports') +Assert-Contains $commonWebAutoConfigurations 'com.baiyue.common.web.RestClientTracePropagationAutoConfiguration' 'RestClient Trace 自动配置注册' + # Maven 聚合声明的全部历史/当前模块必须在后端根指引中可定位。 [xml]$pom = Get-Content -LiteralPath (Join-Path $backendRoot 'pom.xml') -Raw foreach ($module in $pom.project.modules.module) { @@ -189,7 +231,15 @@ $criticalPaths = @( 'apps/admin-web/src/modules/overseas-warehouse/pages/DispatchDetailView.vue', 'apps/admin-web/src/modules/overseas-warehouse/components/SmartDispatchDialog.vue', 'apps/admin-web/src/modules/container-loading/pages/SmartLoadingWorkbenchView.vue', + 'apps/admin-web/src/modules/container-loading/pages/ContainerDetailView.vue', + 'apps/admin-web/src/modules/container-loading/components/ContainerOverviewPanel.vue', 'apps/admin-web/src/modules/container-loading/api/platformContainerLoading.ts', + 'apps/admin-web/src/modules/ocean-bookings/api/platformOceanBookings.ts', + 'apps/admin-web/src/modules/ocean-bookings/pages/OceanBookingsView.vue', + 'apps/admin-web/src/core/routing/navigationRecovery.ts', + 'apps/admin-web/src/core/routing/navigationRecovery.test.ts', + 'apps/admin-web/src/modules/overseas-warehouse/api/outbounds.ts', + 'apps/admin-web/src/modules/overseas-warehouse/pages/OutboundsView.vue', 'apps/admin-web/src/modules/iam/pages/InternalUsersView.vue', 'apps/admin-web/src/modules/iam/api/platformIam.ts', 'apps/client-web/src/api/overseasHttp.ts', @@ -199,6 +249,9 @@ $criticalPaths = @( 'apps/admin-web/src/modules/overseas-business/api/platformOverseasBusiness.ts', 'apps/admin-web/src/modules/overseas-master-data/api/overseasMasterData.ts', 'apps/pda-uniapp/pages/tasks/tasks.vue', + 'apps/pda-uniapp/config/runtime.js', + 'apps/pda-uniapp/.env.example', + 'apps/pda-uniapp/vite.config.js', 'apps/pda-uniapp/utils/evidence-upload-queue.js', 'apps/pda-overseas-uniapp/package.json', 'apps/pda-overseas-uniapp/src/pages.json', @@ -210,6 +263,8 @@ $criticalPaths = @( 'apps/pda-overseas-uniapp/src/api/receiving-adapter.js', 'apps/pda-overseas-uniapp/src/api/overseas.js', 'apps/pda-overseas-uniapp/src/utils/scan-broadcast.js', + 'apps/pda-overseas-uniapp/src/manifest.json', + 'apps/pda-overseas-uniapp/tests/app-icons.test.mjs', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/FeePaymentSnapshotValidator.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java', @@ -221,6 +276,9 @@ $criticalPaths = @( 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java', + 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java', + 'baiyue-firstleg/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/in/PriceBookQueryUseCase.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/PriceBookQueryService.java', 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricebookquery/application/port/out/PriceBookQueryReader.java', @@ -243,6 +301,9 @@ $criticalPaths = @( 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/AuthenticatedUser.java', 'baiyue-firstleg/common-security/src/main/java/com/baiyue/common/security/JwtClaimsMapper.java', 'baiyue-firstleg/common-openapi/src/test/java/com/baiyue/common/openapi/PdaOpenApiGroupContractTest.java', + 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java', + 'baiyue-firstleg/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java', + 'baiyue-firstleg/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/PdaSessionValidationFilter.java', 'baiyue-firstleg/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/security/controller/LastlegSecurityController.java', @@ -312,6 +373,7 @@ $indexedAgentFiles = @( (Join-Path $backendRoot 'lastleg-service/AGENTS.md'), (Join-Path $backendRoot 'iam/AGENTS.md'), (Join-Path $backendRoot 'common-security/AGENTS.md'), + (Join-Path $backendRoot 'common-web/AGENTS.md'), (Join-Path $backendRoot 'common-openapi/AGENTS.md'), (Join-Path $backendRoot 'warehouse-overseas-service/AGENTS.md'), (Join-Path $backendRoot 'event-bridge-service/AGENTS.md'), @@ -353,5 +415,5 @@ if ($failures.Count -gt 0) { exit 1 } -Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、Gateway 覆盖、PDA 注册页面与能力开关、Maven 模块、Lastleg 海外安全投影映射、关键入口和排除目录。" -ForegroundColor Green +Write-Host "AGENTS 索引合同通过:$checked 项检查,覆盖快速表唯一性、部署单元、前端模块、Client active 路由、四期范围漂移、订舱候选与柜量语义、Gateway 覆盖、PDA 页面/运行配置/能力开关、Maven 模块、Lastleg 海外安全投影、同步 Trace、关键入口和排除目录。" -ForegroundColor Green exit 0 diff --git a/scripts/client-phase2-test-env-acceptance.mjs b/scripts/client-phase2-test-env-acceptance.mjs index b3981a78a..1815ee4d5 100644 --- a/scripts/client-phase2-test-env-acceptance.mjs +++ b/scripts/client-phase2-test-env-acceptance.mjs @@ -19,7 +19,7 @@ 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') + ?? await promptText('二期客户端账号(必须由正式注册/建档产生)', '') const clientPassword = args['client-password'] ?? process.env.CLIENT_PHASE2_PASSWORD ?? await promptSecret('二期客户端密码') const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERNAME @@ -30,8 +30,8 @@ const forcedOrderId = args['order-id'] ?? process.env.CLIENT_PHASE2_ORDER_ID ?? 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') +if (!clientUsername || !clientPassword || !platformPassword) { + console.error('An onboarded Client account and platform credentials are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 034057e97..b7afa8553 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -24,14 +24,14 @@ const platformUsername = args['platform-username'] ?? process.env.PLATFORM_USERN const platformPassword = args['platform-password'] ?? process.env.PLATFORM_PASSWORD ?? await promptSecret('平台审核密码') const existingClientUsername = args['existing-client-username'] ?? process.env.EXISTING_CLIENT_USERNAME - ?? await promptText('跨客户隔离测试账号', 'merchant_admin') + ?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '') const existingClientPassword = args['existing-client-password'] ?? process.env.EXISTING_CLIENT_PASSWORD ?? await promptSecret('跨客户隔离测试密码') const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) -if (!platformPassword) { - console.error('Platform password is required') +if (!platformPassword || !existingClientUsername || !existingClientPassword) { + console.error('Platform password and an independently onboarded client account are required') process.exit(2) } if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index 03fdfdd11..11b3dfb67 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -56,7 +56,7 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.match(source, /async requestOverseas\(/) assert.match(source, /async requestOverseasBinary\(/) assert.match(source, /async requestOverseasMultipart\(/) - assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.refreshToken, this\.overseasToken\]/) + assert.match(source, /forbiddenValues = \[this\.options\.password, this\.options\.merchantPassword,[\s\S]*this\.token, this\.refreshToken, this\.overseasToken\]/) }) test('dual-token refresh replaces the whole session only after a complete response', async () => {