This commit is contained in:
2026-08-26 11:10:42 +08:00
parent 15421db421
commit cf5d603a21
35 changed files with 664 additions and 435 deletions
+2 -2
View File
@@ -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 @@
- 数据库只访问所属服务库。模块表结构位于 `<module>/src/main/resources/db/`;当前开发阶段直接更新最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql`,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作现有结构来源和开发追溯,部署说明见 `deploy/sql/README.md`。
- 通用配置在 `config/nacos/{local,cn}/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。
- Java 签名变更的最低编译门禁是 `mvn -pl <module> -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl <module> -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 验收脚本路由
@@ -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),
@@ -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("一期演示企业管理员");
}
+2 -2
View File
@@ -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`。
+43 -143
View File
@@ -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 ;
+2 -1
View File
@@ -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 复制财务状态机。
@@ -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;
@@ -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;
@@ -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
@@ -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;
@@ -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'
@@ -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()")));
}
@@ -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");
@@ -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
+1 -1
View File
@@ -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`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 |
+182 -3
View File
@@ -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 <name> 必填,管理员账号
--password <value> 必填,管理员密码
--modules <names> 逗号分隔,默认 ${DEFAULT_MODULES.join(',')}
--customer-name <name> 指定本轮隔离客户名称,默认自动生成
--merchant-username <name> 复用既有已审核客户账号,不再创建客户
--merchant-password <value> 复用客户账号密码,不写入报告
--origin-warehouse-name <name> 固定国内起运仓名称
--channel-name <name> 固定物流渠道名称
--channel-code <code> 固定物流渠道编码
--order-profile-mode <mode> 订单档案模式:fixed 或 randomized,默认fixed
--wait-split-count <n> 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1
--output <path> 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,
+2 -2
View File
@@ -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 : [] }
@@ -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,应付供应商长整型保持字符串精度', () => {
@@ -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 }
@@ -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\)/)
})
@@ -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)
@@ -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 提供人工验收所需公共仓储主数据', () => {
@@ -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 <name>/)
assert.match(acceptance, /--merchant-username <name>/)
assert.match(acceptance, /--origin-warehouse-name <name>/)
assert.match(acceptance, /--channel-name <name>/)
assert.match(acceptance, /--wait-split-count <n>/)
assert.match(acceptance, /--order-profile-mode <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/)
})
@@ -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,
@@ -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', []],
+121 -41
View File
@@ -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,
@@ -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) }
@@ -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) }
@@ -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'
@@ -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) {
+2 -2
View File
@@ -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 }
+63 -1
View File
@@ -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
@@ -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) {
+3 -3
View File
@@ -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) {
@@ -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 () => {