From ad5e0812bfffe927dcc613f54f58d82605ba9879 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 21:55:27 +0800 Subject: [PATCH] fix: restore phase five development acceptance chain --- deploy/sql/头程数据库统一增量-MySQL8.sql | 32 ++ .../mapper/LoadingCompletedEventMapper.java | 2 +- .../Phase3FinalUnifiedSqlContractTest.java | 2 +- .../LoadingCompletedOutboxServiceTest.java | 6 + order/src/main/resources/db/order.sql | 12 +- scripts/README.md | 4 +- scripts/admin-test-env-acceptance.mjs | 10 +- scripts/admin-test-env-modules/README.md | 7 + .../domestic-container.mjs | 2 +- scripts/admin-test-env-modules/fee-items.mjs | 2 +- .../finance-downloads.mjs | 2 +- .../finance-reconciliation-full.mjs | 2 +- .../admin-test-env-modules/ocean-bookings.mjs | 2 +- .../order-inbound-status-fixtures.mjs | 2 +- scripts/admin-test-env-modules/orders.mjs | 42 ++- .../phase3-container-customs-fixture.mjs | 2 +- .../phase3-container-loading.mjs | 120 ++------ .../admin-test-env-modules/phase3-customs.mjs | 2 +- .../admin-test-env-modules/phase3-loading.mjs | 127 ++------ .../phase3-order-splitting.mjs | 4 +- .../phase3-producer-chain-contract.test.mjs | 82 +++-- .../phase4-overseas-business.mjs | 2 +- .../phase4-overseas-client-services.mjs | 2 +- .../phase4-overseas-master-data.mjs | 2 +- .../phase4-overseas-pda.mjs | 10 +- ...se5-overseas-fulfillment-contract.test.mjs | 64 ++++ .../phase5-overseas-fulfillment.mjs | 282 ++++++++++++++++++ scripts/admin-test-env-modules/suppliers.mjs | 2 +- scripts/admin-test-env-modules/system.mjs | 6 +- .../truck-appointments.mjs | 10 +- scripts/auth-login-payload-contract.test.mjs | 42 +++ scripts/client-phase2-test-env-acceptance.mjs | 4 +- scripts/client-test-env-acceptance.mjs | 38 +-- scripts/create-pending-review-orders.mjs | 2 +- scripts/pda-final-complete-diagnostic.mjs | 4 +- 35 files changed, 623 insertions(+), 313 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs create mode 100644 scripts/auth-login-payload-contract.test.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 3a951cdd9..495cf4b1f 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -685,6 +685,33 @@ CREATE TABLE IF NOT EXISTS ord_order_party ( CONSTRAINT fk_party_order FOREIGN KEY (order_id) REFERENCES ord_order (id) ) ENGINE=InnoDB COMMENT='订单收发货方快照表:固化下单时发货人和收货人信息,避免主数据变化影响历史订单'; +CREATE TABLE IF NOT EXISTS ord_customer_address ( + id BIGINT UNSIGNED NOT NULL COMMENT '客户地址ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', + address_type VARCHAR(32) NOT NULL COMMENT 'PICKUP取货地址或PRIVATE_DESTINATION私人目的仓', + address_name VARCHAR(64) NOT NULL COMMENT '客户自定义地址名称', + company_name VARCHAR(128) NOT NULL COMMENT '公司名称', + contact_name VARCHAR(64) NOT NULL COMMENT '联系人', + phone VARCHAR(32) NOT NULL COMMENT '联系电话', + email VARCHAR(128) NULL COMMENT '联系邮箱', + country_code CHAR(2) NOT NULL COMMENT '国家二字码', + province VARCHAR(64) NULL COMMENT '省州', + city VARCHAR(64) NOT NULL COMMENT '城市', + district VARCHAR(64) NULL COMMENT '区县', + address_line VARCHAR(255) NOT NULL COMMENT '详细地址', + postal_code VARCHAR(20) NULL COMMENT '邮政编码', + default_address TINYINT(1) NOT NULL DEFAULT 0 COMMENT '同类型默认地址', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可选', + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + PRIMARY KEY (id), + KEY idx_customer_address_scope (tenant_id, address_type, active, updated_at) +) ENGINE=InnoDB COMMENT='客户自有下单地址簿;不承载平台仓库主档'; + CREATE TABLE IF NOT EXISTS ord_order_cargo ( id BIGINT UNSIGNED NOT NULL COMMENT '货物明细ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', @@ -721,6 +748,7 @@ CREATE TABLE IF NOT EXISTS ord_order_cargo ( shipment_id VARCHAR(64) NULL COMMENT '本货物明细对应的Amazon Shipment ID', reference_id VARCHAR(64) NULL COMMENT '本货物明细对应的Amazon Reference ID', product_image_file_id BIGINT UNSIGNED NULL COMMENT 'File服务中的产品图片逻辑文件ID', + wooden_product TINYINT(1) NOT NULL DEFAULT 0 COMMENT '木制品权威判定快照;1时订单必须商检', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', @@ -1733,6 +1761,7 @@ CALL phase1_add_column('ord_order_cargo', 'product_height_cm', "DECIMAL(12,2) NU CALL phase1_add_column('ord_order_cargo', 'shipment_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Shipment ID'"); CALL phase1_add_column('ord_order_cargo', 'reference_id', "VARCHAR(64) NULL COMMENT 'Cargo Amazon Reference ID'"); CALL phase1_add_column('ord_order_cargo', 'product_image_file_id', "BIGINT UNSIGNED NULL COMMENT 'Product image logical file ID'"); +CALL phase1_add_column('ord_order_cargo', 'wooden_product', "TINYINT(1) NOT NULL DEFAULT 0 COMMENT '木制品权威判定快照;1时订单必须商检'"); UPDATE ord_order_cargo cargo JOIN ord_order orders ON orders.id = cargo.order_id AND orders.tenant_id = cargo.tenant_id @@ -4275,6 +4304,7 @@ DELIMITER ; CALL unified_assert_table('baiyue_auth', 'auth_account'); CALL unified_assert_table('baiyue_iam', 'iam_permission'); CALL unified_assert_table('baiyue_order', 'ord_order'); +CALL unified_assert_table('baiyue_order', 'ord_customer_address'); CALL unified_assert_table('baiyue_warehouse', 'wh_warehouse'); CALL unified_assert_table('baiyue_warehouse', 'wh_pallet'); CALL unified_assert_table('baiyue_warehouse', 'wh_location'); @@ -4290,6 +4320,7 @@ CALL unified_assert_column('baiyue_order', 'ord_order', 'carrier_name'); CALL unified_assert_column('baiyue_order', 'ord_order', 'pricing_tax_mode'); CALL unified_assert_column('baiyue_order', 'ord_order', 'inbound_code'); CALL unified_assert_column('baiyue_order', 'ord_order', 'fulfillment_type'); +CALL unified_assert_column('baiyue_order', 'ord_order_cargo', 'wooden_product'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_pallet_allocation', 'warehouse_resource_tenant_id'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'pricing_tax_mode'); CALL unified_assert_column('baiyue_firstleg', 'fl_inbound_order', 'fulfillment_type'); @@ -12383,6 +12414,7 @@ CALL queue_table_comment('baiyue_iam', 'iam_user', 'IAM用户资料表:连接 CALL queue_table_comment('baiyue_iam', 'sys_audit_outbox', '可靠审计事件本地发送箱'); CALL queue_table_comment('baiyue_order', 'ord_idempotency_record', '订单写接口持久化幂等记录'); CALL queue_table_comment('baiyue_order', 'ord_order', '头程订单主表:保存商户委托、目的仓、运输方式和订单当前状态'); +CALL queue_table_comment('baiyue_order', 'ord_customer_address', '客户自有下单地址簿;不承载平台仓库主档'); CALL queue_table_comment('baiyue_order', 'ord_order_cargo', '订单货物明细表:记录件数、重量、体积、申报价值和危险品属性'); CALL queue_table_comment('baiyue_order', 'ord_order_fulfillment_split', '父订单→子订单→柜次数量台账'); CALL queue_table_comment('baiyue_order', 'ord_order_internal_note', '订单内部备注表:保存平台运营或履约人员的内部协作记录,不向商户客户展示'); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java index fb737b58d..f849fa865 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletedEventMapper.java @@ -84,7 +84,7 @@ public interface LoadingCompletedEventMapper { @Select(""" SELECT carton.tenant_id AS account_id,carton.inbound_id AS source_inbound_id, pallet.id AS pallet_id,1 AS actual_binding, - pallet.pallet_code,NULL AS allocation_no, + pallet.pallet_no AS pallet_code,NULL AS allocation_no, COUNT(*) OVER (PARTITION BY pallet.id,carton.tenant_id,carton.inbound_id) AS allocated_package_count, carton.inbound_detail_id AS source_inbound_detail_id,carton.sku_code, item.product_name_zh,item.package_count,item.product_quantity,carton.box_code diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index c66c9f126..d8c5a5675 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -183,7 +183,7 @@ class Phase3FinalUnifiedSqlContractTest { .contains("存在缺失中文注释的业务表") .contains("存在缺失中文注释的业务字段") .doesNotContain("CREATE PROCEDURE upgrade_column_comment"); - assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144); + assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(145); assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2374); assertThat(sql) .contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java index d6bc53f47..e76c3cd2c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletedOutboxServiceTest.java @@ -239,6 +239,9 @@ class LoadingCompletedOutboxServiceTest { .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); String palletsSql = String.join(" ", LoadingCompletedEventMapper.class.getMethod("palletRows", long.class) .getAnnotation(Select.class).value()).replaceAll("\\s+", " "); + String actualPalletsSql = String.join(" ", LoadingCompletedEventMapper.class + .getMethod("actualPalletRows", long.class).getAnnotation(Select.class).value()) + .replaceAll("\\s+", " "); assertThat(rowsSql).contains("p.plan_status = 'COMPLETED'") .contains("o.process_status = 'LOADED'") @@ -265,6 +268,9 @@ class LoadingCompletedOutboxServiceTest { .contains("label.inbound_detail_id = i.inbound_detail_id") .contains("label.batch_status = 'ACTIVE'") .doesNotContain("label.inbound_id = i.inbound_id"); + assertThat(actualPalletsSql) + .contains("pallet.pallet_no AS pallet_code") + .doesNotContain("pallet.pallet_code"); } @Test void publishesDropshipPalletSkuAndCartonSnapshots() throws Exception { diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 91651e923..24700f584 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -255,12 +255,12 @@ CREATE TABLE IF NOT EXISTS ord_customer_address ( postal_code VARCHAR(20) NULL COMMENT '邮政编码', default_address TINYINT(1) NOT NULL DEFAULT 0 COMMENT '同类型默认地址', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可选', - created_by BIGINT UNSIGNED NOT NULL, - created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), - updated_by BIGINT UNSIGNED NOT NULL, - updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), - deleted TINYINT(1) NOT NULL DEFAULT 0, - version INT NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', + version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', PRIMARY KEY (id), KEY idx_customer_address_scope (tenant_id, address_type, active, updated_at) ) ENGINE=InnoDB COMMENT='客户自有下单地址簿;不承载平台仓库主档'; diff --git a/scripts/README.md b/scripts/README.md index 2c5a2dd2f..7c5386255 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -87,6 +87,8 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套 | OpenAPI | `openapi-validation/validate-openapi.mjs` | 发现并解析各服务 OpenAPI,检查跨文件引用;只读源码 | | 本地环境 | `local-env.ps1` | 本地开发环境公共参数/辅助函数,由其他 PowerShell 脚本加载 | | 本地环境 | `publish-local-nacos-config.ps1` | 将 `config/nacos/local/*.yml` 发布到指定本地 Nacos,会修改 Nacos 配置 | +| Nacos 环境选择 | `use-nacos-environment.ps1` | 按显式环境与区域设置当前 PowerShell 进程的 Nacos、Spring Profile 和运行区域变量;开发/测试密码必须由参数或环境变量提供,不发布配置 | +| Nacos 配置发布 | `publish-nacos-config.ps1` | 将 `config/nacos/{local|cn|overseas}/*.yml` 发布到显式指定的 Nacos;非本地环境强制要求非 public namespace,会修改目标 Nacos 配置,执行前须取得对应环境授权 | | 密钥配置 | `generate-overseas-jwk.mjs` | 生成 3072 位海外 JWT RSA JWK 并写入指定 `deploy/env/.env.*`;已有 JWK 时拒绝覆盖,密钥文件不得提交 | | 迁移辅助 | `migrate-local-qualification-file.ps1` | 本地资质文件迁移辅助,会修改本地文件/数据;运行前核对脚本参数和备份策略 | | Jenkins 管理 | `provision-development-service-jobs.groovy` | Jenkins Script Console 批量准备开发服务 Job;默认 `applyChanges=false` 仅预览,改为 true 会创建 Job | @@ -99,7 +101,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套 | 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 | |---|---|---|---| -| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 | +| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、海外排车/出库/派送/异常/回访、财务、通知、统计等模块 | | 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 | | 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 | | Client 二期专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 | diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 3739f0d36..6985e2985 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' 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-container-documents', 'phase3-customs', 'phase3-container-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 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-container-documents', 'phase3-customs', 'phase3-container-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', 'phase5-overseas-fulfillment', '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 OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', @@ -75,6 +75,7 @@ const MODULE_DEPENDENCIES = new Map([ ['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']], ['phase4-overseas-client-services', ['phase4-overseas-pda-operations']], ['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']], + ['phase5-overseas-fulfillment', ['phase4-overseas-dispatch']], ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']], ['phase4-carrier-nodes', ['phase4-overseas-pda-operations']], ]) @@ -1565,7 +1566,7 @@ export class AcceptanceRun { || this.options.orderProfileMode === 'randomized' || this.options.routeMode === 'random') { this.applyWaitSplitVariant(this.options.waitSplitSequence || 1) } - const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '') + const loginResponse = await this.request('POST', '/api/auth/login', { loginId: this.options.username, password: this.options.password }, '') const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse) if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' else { @@ -1574,7 +1575,8 @@ export class AcceptanceRun { this.overseasToken = login.overseasAccessToken || '' this.overseasExpiresAt = Number(login.overseasExpiresIn) > 0 ? Date.now() + Number(login.overseasExpiresIn) * 1000 : 0 - const requiresOverseas = this.options.modules.some((module) => module.startsWith('phase4-overseas-')) + const requiresOverseas = this.options.modules.some((module) => + module.startsWith('phase4-overseas-') || module.startsWith('phase5-overseas-')) if (requiresOverseas) { this.assert('authentication', '登录响应包含有效海外访问令牌', loginResponse, Boolean(this.overseasToken) && Number(login.overseasExpiresIn) > 0, @@ -1584,7 +1586,7 @@ export class AcceptanceRun { const handlers = { customers: () => this.runCustomers() } for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) { const module = this.options.modules[moduleIndex] - if (module.startsWith('phase4-overseas-') + if ((module.startsWith('phase4-overseas-') || module.startsWith('phase5-overseas-')) && this.overseasExpiresAt > 0 && Date.now() + 120_000 >= this.overseasExpiresAt) { await this.refreshSession(`海外模块 ${module} 执行前Token临近到期`) } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 1770ed349..0aa1a47e7 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -84,6 +84,7 @@ export async function run(context) { | 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | | 四期船节点安全冒烟 | `phase4-carrier-nodes.mjs`(standalone 对历史任务只读;仅复用当前 marker 的 `phase3CarrierScheduleQuery` 时补录预计事实) | `phase4-carrier-nodes-contract.test.mjs` | +| 五期海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮四期已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、转出库/派送、PDA 全量拣货/发车、派送完成、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 @@ -102,6 +103,12 @@ node scripts/admin-test-env-acceptance.mjs --gateway --ove 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 +五期履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮三期装柜完成、四期跨区接柜和海外 PDA 实收入库生成真实库存,再由四期智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。五期模块只消费该本轮排车,先补齐车辆与司机,原子转为 `WAIT_OUTBOUND + WAIT_DELIVERY`,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后覆盖派送完成、回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。 + +```powershell +node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" scripts/admin-test-env-acceptance.mjs --modules phase5-overseas-fulfillment +``` + ## WAIT_SPLIT 到 LOADED 冻结链 默认 Admin 全量入口已注册并按同一 `context.marker` 串行执行货柜主链。冻结时序为: diff --git a/scripts/admin-test-env-modules/domestic-container.mjs b/scripts/admin-test-env-modules/domestic-container.mjs index 15bfbaacf..10d2b9917 100644 --- a/scripts/admin-test-env-modules/domestic-container.mjs +++ b/scripts/admin-test-env-modules/domestic-container.mjs @@ -145,7 +145,7 @@ async function verifyPermissionDenial(context, marker, warehouseId, containerNo) const membershipId = id(user?.membershipId) if (!membershipId) throw new Error('无法创建权限拒绝验收用户') try { - const loginResponse = await context.request('POST', '/api/auth/login', { username, password }, '') + const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password }, '') const token = context.apiSuccess(MODULE, '无整柜权限用户登录', loginResponse)?.accessToken if (!token) throw new Error('权限拒绝验收用户登录失败') const denied = await context.request('GET', `${BASE}/inventories?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`, undefined, token) diff --git a/scripts/admin-test-env-modules/fee-items.mjs b/scripts/admin-test-env-modules/fee-items.mjs index f943c9e96..b15d2d4bd 100644 --- a/scripts/admin-test-env-modules/fee-items.mjs +++ b/scripts/admin-test-env-modules/fee-items.mjs @@ -146,7 +146,7 @@ export async function run(context) { `total=${activePage?.total ?? 'n/a'}`, OPTIONS) const merchantLoginResponse = await context.request('POST', '/api/auth/login', { - username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, + loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, }, '') const merchantLogin = success(merchantLoginResponse) ? merchantLoginResponse.body?.data : null const merchantToken = merchantLogin?.accessToken diff --git a/scripts/admin-test-env-modules/finance-downloads.mjs b/scripts/admin-test-env-modules/finance-downloads.mjs index 93ea11a74..7af56c111 100644 --- a/scripts/admin-test-env-modules/finance-downloads.mjs +++ b/scripts/admin-test-env-modules/finance-downloads.mjs @@ -173,7 +173,7 @@ async function noPermission(context) { context.addCoverageGap(MODULE, 'finance-download-center', '无导出权限拒绝', '本轮商户验收账号前置未生成', 'prerequisite') return } - const login = await context.request('POST', '/api/auth/login', { username, password }, '') + const login = await context.request('POST', '/api/auth/login', { loginId: username, password }, '') const token = login.http === 200 && String(login.body?.code) === '200' ? login.body.data?.accessToken : '' if (!token) { context.addStep(MODULE, '无权限账号登录', login, false, '商户 Token 未生成', OPTIONS); return diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs index 709324f85..9e31e04c3 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs @@ -130,7 +130,7 @@ async function confirmIssuedStatementAsClient(context, fixture, statement) { `username=${username ? 'present' : 'missing'}, password=${password ? 'present' : 'missing'}, statementId=${targetId || 'missing'}`, OPTIONS) return null } - const loginResponse = await context.request('POST', '/api/auth/login', { username, password }, '') + const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password }, '') const login = context.apiSuccess(MODULE, 'Client 客户登录用于确认对账单', loginResponse, `username=${username}`, OPTIONS) const clientToken = login?.accessToken diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 09b6ea7c4..caa8e2f43 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -42,7 +42,7 @@ export async function run(context) { const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return const suffix = context.marker.replace(/\D/g, '').slice(-10) || 'BOOKING' - const agentResponse = await request(context, 'GET', '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') + const agentResponse = await request(context, 'GET', '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=20') const agentPage = payload(agentResponse) const agent = agentPage?.records?.find?.((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE') step(context, '使用 ACTIVE BOOKING 订舱代理事实源', agentResponse, Boolean(agent?.id && agent?.name), diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index 246386f9e..61ed5c50d 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -225,7 +225,7 @@ export async function run(context, matrix = context.createdResources.manualState } matrix.pages ||= {} const loginResponse = await context.request('POST', '/api/auth/login', - { username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD }, '') + { loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD }, '') const token = context.apiSuccess(MODULE, 'IT 测试客户管理员登录', loginResponse)?.accessToken if (!token) throw new Error('客户管理员登录未返回 accessToken') const fixture = await loadFixtureInputs(context, token) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index 6f6404e3d..7b9f53a71 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -339,7 +339,34 @@ async function loadMasterData(context, merchantToken) { pricingTaxMode: priced.pricingTaxMode, destinationType, partner } } -function buildPayload(context, profile, master, productImageFileId) { +async function ensureCustomerAddress(context, merchantToken, profile, addressType) { + const path = `/api/merchant/v1/orders/first-leg/addresses?addressType=${encodeURIComponent(addressType)}` + const listResponse = await request(context, 'GET', path, undefined, merchantToken) + const existing = context.apiSuccess(MODULE, `Client 查询${addressType}地址簿`, listResponse) + const existingId = id(Array.isArray(existing) ? existing[0]?.id : null) + if (existingId) return existingId + + const isPickup = addressType === 'PICKUP' + const createResponse = await request(context, 'POST', '/api/merchant/v1/orders/first-leg/addresses', { + addressType, + addressName: isPickup ? '开发联调取货地址' : '开发联调私人目的仓', + companyName: profile.tenantName || `订单验收客户-${context.marker}`, + contactName: isPickup ? '订单验收发货人' : '订单验收收货人', + phone: isPickup ? '13800138000' : '12025550188', + email: isPickup ? 'order-acceptance@example.com' : 'private-receiver@example.com', + countryCode: isPickup ? 'CN' : 'US', + province: isPickup ? '广东省' : 'New York', + city: isPickup ? '深圳市' : 'New York', + district: isPickup ? '南山区' : 'Queens', + addressLine: `${context.marker} ${isPickup ? '开发联调取货地址' : '88 Randomized Test Ave'}`, + postalCode: isPickup ? '518000' : '11354', + defaultAddress: true, + }, merchantToken) + return id(context.apiSuccess(MODULE, `Client 创建${addressType}地址`, createResponse, + `addressType=${addressType}`, CONTRACT_OPTIONS)) +} + +function buildPayload(context, profile, master, productImageFileId, customerAddresses) { const shipmentId = `SHIP-${context.marker}`.slice(0, 64) const referenceId = `REF-${context.marker}`.slice(0, 64) const companyName = profile.tenantName || `订单验收客户-${context.marker}` @@ -371,6 +398,8 @@ function buildPayload(context, profile, master, productImageFileId) { expectedShipDate: tomorrow(), customerRemark: `${context.marker} Client 创建隔离订单`, pickupMethod, requestedPickupAt: pickupMethod === 'PICKUP' ? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null, + senderAddressId: pickupMethod === 'PICKUP' ? customerAddresses.pickupAddressId : null, + privateDestinationAddressId: isPrivate ? customerAddresses.privateDestinationAddressId : null, receivingWarehouseId: master.receiving.id, receivingWarehouseName: master.receiving.warehouseName, channelCode: master.channel.channelCode, channelName: master.channel.channelName, @@ -405,7 +434,7 @@ function buildPayload(context, profile, master, productImageFileId) { shipmentId: master.destinationType === 'FBA' ? shipmentId : null, referenceId: master.destinationType === 'FBA' ? referenceId : null, upcNo: `${Date.now()}${index}`.slice(-12), - productImageFileId, dangerousGoods: false, + productImageFileId, dangerousGoods: false, woodenProduct: false, cargoRemark: `${context.marker} boxType=${box.boxType}`, } }), @@ -482,7 +511,7 @@ async function waitForInbound(context, tenantId, orderId) { export async function run(context) { const loginResponse = await context.request('POST', '/api/auth/login', { - username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, + loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, }, '') const login = context.apiSuccess(MODULE, 'Client 测试账号登录', loginResponse, `username=${MERCHANT_USERNAME}`) @@ -509,7 +538,12 @@ export async function run(context) { if (!productImageFileId) throw new Error('产品图片新契约未部署,无法创建带图片的隔离订单') await assertProductImageDownload(context, merchantToken, productImageFileId) - const payload = buildPayload(context, profile, master, productImageFileId) + const customerAddresses = { + pickupAddressId: await ensureCustomerAddress(context, merchantToken, profile, 'PICKUP'), + privateDestinationAddressId: master.destinationType === 'PRIVATE' + ? await ensureCustomerAddress(context, merchantToken, profile, 'PRIVATE_DESTINATION') : null, + } + const payload = buildPayload(context, profile, master, productImageFileId, customerAddresses) const createPath = '/api/merchant/v1/orders/first-leg' const createResponse = await request(context, 'POST', createPath, payload, merchantToken, { 'Idempotency-Key': key(context, 'client-create') }) diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 3e0784a70..4f7bd0fa9 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -41,7 +41,7 @@ async function loginMerchant(context) { const account = context.context?.merchantQualificationFixture if (!account?.username || !account?.password) throw new Error('缺少本轮客户登录凭据') const response = await context.request('POST', '/api/auth/login', { - username: account.username, password: account.password, + loginId: account.username, password: account.password, }, '') const token = success(response) ? response.body?.data?.accessToken : null if (!token) throw new Error(`本轮客户登录失败: requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`) diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index 17a498f6f..24ea42302 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -1,5 +1,4 @@ import { publishPlatformLogisticsChannels } from './ocean-booking-fixture.mjs' -import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' import { containerFieldGaps, loadingListFieldGaps, preloadFieldGaps } from './fulfillment-list-field-contract.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { iso6346ContainerNumber } from './iso6346-container-number.mjs' @@ -163,7 +162,7 @@ export async function run(context) { const realContainerNo = iso6346ContainerNumber(`${context.marker}-initial`) const wrongContainerNo = `TST-${suffix}-02`.slice(0, 64) const agentResponse = await context.request('GET', - '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=20') const agentPage = context.apiSuccess(MODULE, '查询放舱所需 ACTIVE 订舱代理', agentResponse, '', OPTIONS) const bookingAgent = records(agentPage).find((item) => item?.category === 'BOOKING' && item?.status === 'ACTIVE') context.assert(MODULE, '放舱订舱使用真实启用供应商', agentResponse, @@ -259,8 +258,8 @@ export async function run(context) { `planStatus=${containerDetail?.planStatus || 'missing'}, channel=${containerDetail?.channelCode || 'missing'}`, OPTIONS) const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo) - const planResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker }) - const plan = context.apiSuccess(MODULE, '创建智能装柜计划', planResponse, '', OPTIONS) + const planResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`) + const plan = context.apiSuccess(MODULE, '读取柜次创建时原子生成的智能装柜计划', planResponse, '', OPTIONS) const planId = id(plan?.id) context.assert(MODULE, '装柜计划与货柜关联且处于 EDITING', planResponse, Boolean(planId) && id(plan?.bookingId) === containerId && plan?.status === 'EDITING' @@ -268,9 +267,9 @@ export async function run(context) { `planId=${planId || 'missing'}, status=${plan?.status || 'missing'}`, OPTIONS) if (!planId) return - const planReplayResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: 'replay' }) - const planReplay = context.apiSuccess(MODULE, '重放货柜装柜计划创建', planReplayResponse, '', OPTIONS) - context.assert(MODULE, '同一货柜重放返回同一计划', planReplayResponse, + const planReplayResponse = await context.request('GET', `${SMART}/plans/by-booking/${encodeURIComponent(containerId)}`) + const planReplay = context.apiSuccess(MODULE, '重复读取货柜唯一装柜计划', planReplayResponse, '', OPTIONS) + context.assert(MODULE, '同一货柜始终返回同一计划', planReplayResponse, id(planReplay?.id) === planId, `first=${planId}, replay=${id(planReplay?.id) || 'missing'}`, OPTIONS) const assignPayload = { @@ -299,95 +298,28 @@ export async function run(context) { staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)), `HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS) - const pushKey = `${context.marker}-container-push`.slice(0, 128) - const pushRequest = Object.freeze({ - path: `${SMART}/plans/${planId}/push-preload`, - body: Object.freeze({ expectedVersion: Number(assigned.version) }), - headers: Object.freeze({ 'Idempotency-Key': pushKey }), - }) - const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) - const pushed = context.apiSuccess(MODULE, '无真实柜号推送预装计划成功', pushResponse, '', OPTIONS) - context.assert(MODULE, '无号 push 仅冻结 EDITING 计划且版本递增', pushResponse, - id(pushed?.id) === planId && pushed?.status === 'EDITING' && pushed?.containerNo == null - && Number(pushed?.version) > Number(assigned.version), - `status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS) - if (pushed?.status !== 'EDITING') return - - const pushedDetailResponse = await context.request('GET', + const synchronizedDetailResponse = await context.request('GET', `${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`) - const pushedDetail = context.apiSuccess(MODULE, '无号 push 后预装清单可见', pushedDetailResponse, '', OPTIONS) - const pushedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`) - const pushedLinks = context.apiSuccess(MODULE, '无号 push 自动建立 bridge', pushedLinksResponse, '', OPTIONS) - const pushedLink = Array.isArray(pushedLinks) - ? pushedLinks.find((item) => id(item.inboundId) === inboundId && id(item.loadingPlanId) === planId) : null - context.assert(MODULE, 'push 自动 bridge 保持预装和柜次稳定归属', pushedLinksResponse, - Boolean(pushedLink?.id) && Number(pushedLink?.allocatedPackageCount) === 1, - `linkId=${id(pushedLink?.id) || 'missing'}, preloadPlanId=${id(pushedLink?.preloadPlanId) || 'missing'}`, OPTIONS) - if (!pushedLink?.id) return - const postPushPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) - const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS) - context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse, - postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(pushedLink?.loadingPlanVersion) - && Number(postPushPlan?.version) > Number(assigned.version), - `pushResponseVersion=${pushed?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${pushedLink?.loadingPlanVersion ?? 'missing'}`, OPTIONS) - if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return - const pushedTaskResponse = await context.request('GET', + context.apiSuccess(MODULE, '分配后预装清单可见', synchronizedDetailResponse, '', OPTIONS) + const synchronizedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`) + const synchronizedLinks = context.apiSuccess(MODULE, '分配同步建立 bridge', synchronizedLinksResponse, '', OPTIONS) + const synchronizedLink = Array.isArray(synchronizedLinks) + ? synchronizedLinks.find((item) => id(item.inboundId) === inboundId && id(item.loadingPlanId) === planId) : null + context.assert(MODULE, '分配自动 bridge 保持预装和柜次稳定归属', synchronizedLinksResponse, + Boolean(synchronizedLink?.id) && Number(synchronizedLink?.allocatedPackageCount) === 1, + `linkId=${id(synchronizedLink?.id) || 'missing'}, preloadPlanId=${id(synchronizedLink?.preloadPlanId) || 'missing'}`, OPTIONS) + if (!synchronizedLink?.id) return + const synchronizedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) + const synchronizedPlan = context.apiSuccess(MODULE, '读取分配事务完成态柜次计划', synchronizedPlanResponse, '', OPTIONS) + context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', synchronizedPlanResponse, + synchronizedPlan?.status === 'EDITING' && Number(synchronizedPlan?.version) === Number(synchronizedLink?.loadingPlanVersion), + `persistedVersion=${synchronizedPlan?.version ?? 'missing'}, bridgeVersion=${synchronizedLink?.loadingPlanVersion ?? 'missing'}`, OPTIONS) + if (synchronizedPlan?.status !== 'EDITING' || !Number.isInteger(Number(synchronizedPlan?.version))) return + const synchronizedTaskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - context.addStep(MODULE, '无号 push 成功后 PDA 查询 A0404', pushedTaskResponse, - pushedTaskResponse.http === 200 && codeOf(pushedTaskResponse) === 'A0404', - responseEvidence(pushedTaskResponse), OPTIONS) - - const pushBaseline = projectPushPreloadStability({ - plan: postPushPlan, detail: pushedDetail, links: pushedLinks, - task: { http: pushedTaskResponse.http, code: codeOf(pushedTaskResponse) }, - }) - const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) - context.apiSuccess(MODULE, '推送预装 exact replay', pushReplayResponse, '', OPTIONS) - const replayPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) - const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS) - const replayDetailResponse = await context.request('GET', - `${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`) - const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS) - const replayLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`) - const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS) - const replayTaskResponse = await context.request('GET', - `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const pushReplaySnapshot = projectPushPreloadStability({ - plan: replayPlan, detail: replayDetail, links: replayLinks, - task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) }, - }) - const pushReplayDiff = diffPushPreloadStability(pushBaseline, pushReplaySnapshot) - context.assert(MODULE, 'push exact replay 对计划版本历史关联任务零漂移', pushReplayResponse, - pushReplayDiff.length === 0 && codeOf(replayTaskResponse) === 'A0404', - `changed=${pushReplayDiff.join('|') || 'none'}; replayTask=${responseEvidence(replayTaskResponse)}`, OPTIONS) - const pushSameKeyDifferentBody = await request(context, 'POST', pushRequest.path, - { expectedVersion: Number(postPushPlan.version) }, pushRequest.headers) - context.addStep(MODULE, 'push 同 key 异参拒绝', pushSameKeyDifferentBody, - failure(pushSameKeyDifferentBody), responseEvidence(pushSameKeyDifferentBody), OPTIONS) - const pushNewKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body, - { 'Idempotency-Key': `${context.marker}-container-push-duplicate`.slice(0, 128) }) - context.addStep(MODULE, '已推送计划使用新 key 重复 push 拒绝', pushNewKeyDuplicate, - failure(pushNewKeyDuplicate), responseEvidence(pushNewKeyDuplicate), OPTIONS) - - const rejectedPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) - const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS) - const rejectedDetailResponse = await context.request('GET', - `${FIRST_LEG}/loading-plans/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`) - const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS) - const rejectedLinksResponse = await context.request('GET', `${SMART}/plans/${planId}/preloads`) - const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS) - const rejectedTaskResponse = await context.request('GET', - `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const rejectedSnapshot = projectPushPreloadStability({ - plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks, - task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) }, - }) - const rejectedDiff = diffPushPreloadStability(pushBaseline, rejectedSnapshot) - context.assert(MODULE, '两类非法 push 后计划版本历史关联任务仍零漂移', rejectedTaskResponse, - failure(pushSameKeyDifferentBody) && failure(pushNewKeyDuplicate) - && rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404', - `changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(pushSameKeyDifferentBody)}; newKey=${responseEvidence(pushNewKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS) - if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return + context.addStep(MODULE, '无真实柜号时 PDA 查询 A0404', synchronizedTaskResponse, + synchronizedTaskResponse.http === 200 && codeOf(synchronizedTaskResponse) === 'A0404', + responseEvidence(synchronizedTaskResponse), OPTIONS) const unlinkedTaskResponse = await context.request('GET', `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index 99b554543..9137fae75 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -253,7 +253,7 @@ export async function run(context) { || !Number.isInteger(Number(carryingSlice?.packageCount)) || Number(carryingSlice.packageCount) <= 0) return const loginResponse = await request(context, 'POST', '/api/auth/login', { - username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, + loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, }, '') const merchantLogin = context.apiSuccess(MODULE, '客户账号登录用于真实报关文件协同', loginResponse, `username=${MERCHANT_USERNAME}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index f7e3e7bf0..6f8b3367c 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1,5 +1,3 @@ -import { diffPushPreloadStability, projectPushPreloadStability } from './push-preload-stability.mjs' -import { isPureStatusNoop, visibleLoadingHistories } from './loading-history-noop.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { createHash } from 'node:crypto' @@ -7,7 +5,7 @@ const MODULE = 'phase3-loading' const OPTIONS = { scenario: 'phase3-split-preload-loading', deploymentBlocker: true } const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || '' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || '' -const MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司' +const MATSON_SUPPLIER_NAME = 'MATSON 开发联调订舱代理' const LATE_CONTAINER_GATE_ONLY = process.env.LATE_CONTAINER_GATE_ONLY === '1' function id(value) { return value == null ? '' : String(value) } @@ -168,7 +166,7 @@ export async function run(context) { const smartLoadingBase = '/api/platform/v1/first-leg/smart-loading' const pdaBase = '/api/platform/v1/first-leg/pda-loading' const supplierResponse = await context.request('GET', - '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&page=1&size=200') + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=200') const supplierPage = context.apiSuccess(MODULE, '读取订舱代理与船司供应商事实源', supplierResponse, '', OPTIONS) const supplierRecords = Array.isArray(supplierPage?.records) ? supplierPage.records : [] const supplier = await ensureMatsonSupplier(context, supplierRecords) @@ -223,10 +221,9 @@ export async function run(context) { if (!candidate?.inboundDetailId || !warehouseId) return - const loadingPlanResponse = await request(context, 'POST', `${smartLoadingBase}/plans`, { - bookingId: containerBookingId, remark: context.marker, - }) - const loadingPlan = context.apiSuccess(MODULE, '创建柜次级智能装柜计划', loadingPlanResponse, '', OPTIONS) + const loadingPlanResponse = await context.request('GET', + `${smartLoadingBase}/plans/by-booking/${encodeURIComponent(containerBookingId)}`) + const loadingPlan = context.apiSuccess(MODULE, '读取柜次创建时原子生成的智能装柜计划', loadingPlanResponse, '', OPTIONS) const loadingPlanId = id(loadingPlan?.id) if (!loadingPlanId) return const assignmentResponse = await request(context, 'POST', `${smartLoadingBase}/plans/${loadingPlanId}/assignments`, { @@ -310,119 +307,37 @@ export async function run(context) { const pendingPreloadResponse = await context.request('GET', `${base}/preload?tenantId=${encodeURIComponent(tenantId)}`) const pendingPreload = context.apiSuccess(MODULE, '智能排货自动建立 PENDING 预装聚合', pendingPreloadResponse, '', OPTIONS) const planId = id(pendingPreload?.id) - context.assert(MODULE, '推送前入库单保持 WAIT_SPLIT 且预装聚合为 PENDING', pendingPreloadResponse, + context.assert(MODULE, '分配后入库单保持 WAIT_SPLIT 且预装聚合为 PENDING', pendingPreloadResponse, Boolean(planId) && pendingPreload?.status === 'PENDING' && Number.isInteger(Number(pendingPreload?.version)), `preloadPlanId=${planId || 'missing'}, status=${pendingPreload?.status || 'missing'}`, OPTIONS) if (!planId) return - const pushRequest = Object.freeze({ - path: `${smartLoadingBase}/plans/${loadingPlanId}/push-preload`, - body: Object.freeze({ expectedVersion: Number(persistedRemarkPlan.version) }), - headers: Object.freeze({ 'Idempotency-Key': `${context.marker}-bridge-push`.slice(0, 128) }), - }) - const pushResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) - const pushedLoadingPlan = context.apiSuccess(MODULE, '无真实柜号推送预装成功并冻结柜次明细', pushResponse, '', OPTIONS) - context.assert(MODULE, '无号推送保持 EDITING、身份稳定且版本递增', pushResponse, - id(pushedLoadingPlan?.id) === loadingPlanId && pushedLoadingPlan?.status === 'EDITING' - && pushedLoadingPlan?.containerNo == null - && Number(pushedLoadingPlan?.version) > Number(assignedLoadingPlan.version), - `status=${pushedLoadingPlan?.status || 'missing'}, version=${pushedLoadingPlan?.version ?? 'missing'}`, OPTIONS) - if (pushedLoadingPlan?.status !== 'EDITING') return - - const pushedDetailResponse = await context.request('GET', + const synchronizedDetailResponse = await context.request('GET', `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) - const pushedDetail = context.apiSuccess(MODULE, '无号推送后预装清单正式可见', pushedDetailResponse, '', OPTIONS) - const pushedHistories = visibleLoadingHistories(pushedDetail?.histories) - context.assert(MODULE, '首次 push 只保留一条 PUSH_PRELOAD 且无纯状态 no-op', pushedDetailResponse, - pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1 - && !(pushedDetail?.histories || []).some(isPureStatusNoop), - `raw=${pushedDetail?.histories?.length ?? 'invalid'}, effective=${pushedHistories.length}, push=${pushedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length}`, - OPTIONS) - const pushedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) - const pushedLinks = context.apiSuccess(MODULE, '无号推送自动建立 bridge 关联', pushedLinksResponse, '', OPTIONS) - const link = Array.isArray(pushedLinks) ? pushedLinks.find((item) => id(item.inboundId) === inboundId) : null - context.assert(MODULE, 'push 自动建立唯一预装关联且分配身份稳定', pushedLinksResponse, + const synchronizedDetail = context.apiSuccess(MODULE, '分配后预装清单正式可见', synchronizedDetailResponse, '', OPTIONS) + const synchronizedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) + const synchronizedLinks = context.apiSuccess(MODULE, '分配同步建立 bridge 关联', synchronizedLinksResponse, '', OPTIONS) + const link = Array.isArray(synchronizedLinks) ? synchronizedLinks.find((item) => id(item.inboundId) === inboundId) : null + context.assert(MODULE, '分配自动建立唯一预装关联且身份稳定', synchronizedLinksResponse, Boolean(link?.id) && id(link?.loadingPlanId) === loadingPlanId && id(link?.preloadPlanId) === planId && Number(link?.allocatedPackageCount) === 1, `linkId=${id(link?.id) || 'missing'}, preloadPlanId=${id(link?.preloadPlanId) || 'missing'}`, OPTIONS) if (!link?.id) return - const postPushPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) - const postPushPlan = context.apiSuccess(MODULE, '首次 push 后读取事务完成态柜次计划', postPushPlanResponse, '', OPTIONS) - context.assert(MODULE, '事务完成态计划版本与 bridge 一致且保持 EDITING', postPushPlanResponse, - postPushPlan?.status === 'EDITING' && Number(postPushPlan?.version) === Number(link?.loadingPlanVersion) - && Number(postPushPlan?.version) > Number(assignedLoadingPlan.version), - `pushResponseVersion=${pushedLoadingPlan?.version ?? 'missing'}, persistedVersion=${postPushPlan?.version ?? 'missing'}, bridgeVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS) - if (postPushPlan?.status !== 'EDITING' || !Number.isInteger(Number(postPushPlan?.version))) return + const synchronizedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) + const synchronizedPlan = context.apiSuccess(MODULE, '读取分配事务完成态柜次计划', synchronizedPlanResponse, '', OPTIONS) + context.assert(MODULE, '分配事务完成态计划与 bridge 一致且保持 EDITING', synchronizedPlanResponse, + synchronizedPlan?.status === 'EDITING' && Number(synchronizedPlan?.version) === Number(link?.loadingPlanVersion) + && Number(synchronizedPlan?.version) === Number(persistedRemarkPlan.version), + `persistedVersion=${synchronizedPlan?.version ?? 'missing'}, bridgeVersion=${link?.loadingPlanVersion ?? 'missing'}`, OPTIONS) + if (synchronizedPlan?.status !== 'EDITING' || !Number.isInteger(Number(synchronizedPlan?.version))) return const missingContainerTaskResponse = await context.request('GET', `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const missingContainerTaskAbsent = missingContainerTaskResponse.http === 200 && codeOf(missingContainerTaskResponse) === 'A0404' - context.addStep(MODULE, '无号 push 成功后 PDA 查询明确 A0404', missingContainerTaskResponse, + context.addStep(MODULE, '无真实柜号时 PDA 查询明确 A0404', missingContainerTaskResponse, missingContainerTaskAbsent, `HTTP=${missingContainerTaskResponse.http}, code=${codeOf(missingContainerTaskResponse)}, requestId=${missingContainerTaskResponse.requestId || 'missing'}, traceId=${missingContainerTaskResponse.traceId || 'missing'}`, OPTIONS) if (!missingContainerTaskAbsent) return - const replayBaseline = projectPushPreloadStability({ - plan: postPushPlan, detail: pushedDetail, links: pushedLinks, - task: { http: missingContainerTaskResponse.http, code: codeOf(missingContainerTaskResponse) }, - }) - const pushReplayResponse = await request(context, 'POST', pushRequest.path, pushRequest.body, pushRequest.headers) - context.apiSuccess(MODULE, 'push exact replay 返回首次结果', pushReplayResponse, '', OPTIONS) - const replayPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) - const replayPlan = context.apiSuccess(MODULE, '重放后回查柜次计划', replayPlanResponse, '', OPTIONS) - const replayDetailResponse = await context.request('GET', - `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) - const replayDetail = context.apiSuccess(MODULE, '重放后回查预装历史', replayDetailResponse, '', OPTIONS) - const replayHistories = visibleLoadingHistories(replayDetail?.histories) - context.assert(MODULE, 'push 同幂等请求重放历史零新增', replayDetailResponse, - replayHistories.length === pushedHistories.length - && replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1, - `before=${pushedHistories.length}, after=${replayHistories.length}, push=${replayHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length}`, - OPTIONS) - const replayLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) - const replayLinks = context.apiSuccess(MODULE, '重放后回查 bridge', replayLinksResponse, '', OPTIONS) - const replayTaskResponse = await context.request('GET', - `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const replaySnapshot = projectPushPreloadStability({ - plan: replayPlan, detail: replayDetail, links: replayLinks, - task: { http: replayTaskResponse.http, code: codeOf(replayTaskResponse) }, - }) - const replayDiff = diffPushPreloadStability(replayBaseline, replaySnapshot) - context.assert(MODULE, 'push exact replay 对计划版本历史关联和任务零漂移', pushReplayResponse, - replayDiff.length === 0 && codeOf(replayTaskResponse) === 'A0404', - `changed=${replayDiff.join('|') || 'none'}; replayTask=${responseEvidence(replayTaskResponse)}`, OPTIONS) - - const sameKeyDifferentBody = await request(context, 'POST', pushRequest.path, - { expectedVersion: Number(postPushPlan.version) }, pushRequest.headers) - context.addStep(MODULE, 'push 同 key 异参返回冲突', sameKeyDifferentBody, - failure(sameKeyDifferentBody), `HTTP=${sameKeyDifferentBody.http}, code=${codeOf(sameKeyDifferentBody)}`, OPTIONS) - const newKeyDuplicate = await request(context, 'POST', pushRequest.path, pushRequest.body, - { 'Idempotency-Key': `${context.marker}-bridge-push-duplicate`.slice(0, 128) }) - context.addStep(MODULE, '已推送计划使用新 key 重复 push 返回冲突', newKeyDuplicate, - failure(newKeyDuplicate), `HTTP=${newKeyDuplicate.http}, code=${codeOf(newKeyDuplicate)}`, OPTIONS) - - const rejectedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) - const rejectedPlan = context.apiSuccess(MODULE, '非法 push 后回查柜次计划', rejectedPlanResponse, '', OPTIONS) - const rejectedDetailResponse = await context.request('GET', - `/api/platform/v1/first-leg/loading-plans/${loadingPlanId}?warehouseId=${encodeURIComponent(warehouseId)}`) - const rejectedDetail = context.apiSuccess(MODULE, '非法 push 后回查预装历史', rejectedDetailResponse, '', OPTIONS) - const rejectedHistories = visibleLoadingHistories(rejectedDetail?.histories) - const rejectedLinksResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}/preloads`) - const rejectedLinks = context.apiSuccess(MODULE, '非法 push 后回查 bridge', rejectedLinksResponse, '', OPTIONS) - const rejectedTaskResponse = await context.request('GET', - `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) - const rejectedSnapshot = projectPushPreloadStability({ - plan: rejectedPlan, detail: rejectedDetail, links: rejectedLinks, - task: { http: rejectedTaskResponse.http, code: codeOf(rejectedTaskResponse) }, - }) - const rejectedDiff = diffPushPreloadStability(replayBaseline, rejectedSnapshot) - context.assert(MODULE, '两类非法 push 后计划版本历史关联和任务仍零漂移', rejectedTaskResponse, - failure(sameKeyDifferentBody) && failure(newKeyDuplicate) - && rejectedDiff.length === 0 && codeOf(rejectedTaskResponse) === 'A0404' - && rejectedHistories.length === pushedHistories.length - && rejectedHistories.filter((item) => item.actionType === 'PUSH_PRELOAD').length === 1, - `changed=${rejectedDiff.join('|') || 'none'}; sameKey=${responseEvidence(sameKeyDifferentBody)}; newKey=${responseEvidence(newKeyDuplicate)}; task=${responseEvidence(rejectedTaskResponse)}`, OPTIONS) - if (rejectedDiff.length !== 0 || codeOf(rejectedTaskResponse) !== 'A0404') return - const unlinkedTaskResponse = await context.request('GET', `${pdaBase}/tasks?loadingPlanId=${encodeURIComponent(loadingPlanId)}&warehouseId=${encodeURIComponent(warehouseId)}`) context.addStep(MODULE, '无号且未关联订舱仍无 PDA 任务', unlinkedTaskResponse, @@ -695,7 +610,7 @@ export async function run(context) { `taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, items=${taskItemsSignature(unchangedAfterInvalid)}, operations=${afterInvalidOperations?.length ?? 'missing'}`, OPTIONS) const merchantLogin = await context.request('POST', '/api/auth/login', { - username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, + loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, }, '') const merchantToken = merchantLogin.http === 200 && codeOf(merchantLogin) === '200' ? merchantLogin.body?.data?.accessToken : null diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 19732cb6a..b9fa0ed49 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -180,7 +180,7 @@ async function loadOneFragment(context, state) { const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100) const agentResponse = await context.request('GET', - '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&page=1&size=20') + '/api/platform/v1/suppliers?tenantId=0&category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=20') const agentPage = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 读取 ACTIVE 订舱代理`, agentResponse, '', OPTIONS) const agent = agentPage?.records?.find?.((item) => item?.id && item?.name) if (!agent) return null @@ -494,7 +494,7 @@ export async function run(context) { `loadedOrderId=${id(firstLoaded?.orderId) || 'missing'}, sourceOrderId=${id(firstLoaded?.sourceOrderId) || 'missing'}`, OPTIONS) const merchantLogin = await context.request('POST', '/api/auth/login', { - username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, + loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, }, '') const merchantAuth = context.apiSuccess(MODULE, '客户账号登录验证隔离与权限', merchantLogin, '', OPTIONS) const merchantToken = merchantAuth?.accessToken diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index d5b1bbbda..3990c537e 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -68,11 +68,11 @@ test('records LOADED and clears the reason only after a real LOADED reread', () assert.equal(result.notCleanedReason, null) }) -test('container loading follows push, claim, real pick, completed sequence', () => { +test('container loading follows allocation sync, claim, real pick, completed sequence', () => { const value = source('./phase3-container-loading.mjs') assert.match(value, /\['EDITING', 'WAIT_LOADING'\]\.includes\(containerDetail\?\.planStatus\)/) assertOrder(value, [ - "pushed?.status === 'EDITING'", + "synchronizedPlan?.status === 'EDITING'", "claimedPlan?.status === 'LOADING'", "const pickResponse = await request", "const completeResponse = await request", @@ -87,9 +87,9 @@ test('container loading follows push, claim, real pick, completed sequence', () test('bridge loading confirms preload before creating and claiming the PDA task', () => { const value = source('./phase3-loading.mjs') assertOrder(value, [ - '无真实柜号推送预装成功并冻结柜次明细', - '无号 push 成功后 PDA 查询明确 A0404', - 'push exact replay 对计划版本历史关联和任务零漂移', + '读取柜次创建时原子生成的智能装柜计划', + '分配同步建立 bridge 关联', + '无真实柜号时 PDA 查询明确 A0404', '无号且未关联订舱仍无 PDA 任务', '三期订舱 RELEASED 冻结运输快照', '模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号', @@ -110,10 +110,10 @@ test('bridge loading confirms preload before creating and claiming the PDA task' assert.match(value, /计划级确认同 key 异参返回冲突/) assert.match(value, /已确认计划使用新 key 重复确认返回冲突/) assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) - assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装成功并冻结柜次明细'), value.indexOf('const linkResponse')), + assert.doesNotMatch(value.slice(value.indexOf('分配同步建立 bridge 关联'), value.indexOf('const linkResponse')), /\/transport/) assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/) - assert.doesNotMatch(value, /无真实柜号拒绝推送柜次计划|PDA complete后补确认/) + assert.doesNotMatch(value, /push-preload|无真实柜号拒绝推送柜次计划|PDA complete后补确认/) assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/) assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/) assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/) @@ -158,24 +158,13 @@ test('PDA completion production and HTTP chain never call or gate on truck appoi assert.doesNotMatch(loading, /\/truck-appointments|completeLoadingFromPda/) }) -test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => { - for (const file of ['./phase3-loading.mjs', './phase3-container-loading.mjs']) { - const value = source(file) - assert.match(value, /const pushRequest = Object\.freeze\([\s\S]*body: Object\.freeze\(\{ expectedVersion:/) - assert.match(value, /push exact replay[\s\S]*零漂移/) - assert.match(value, /同 key 异参[\s\S]*新 key 重复 push/) - assert.match(value, /非法 push 后回查柜次计划[\s\S]*非法 push 后回查预装历史[\s\S]*非法 push 后回查 bridge/) - assert.match(value, /两类非法 push 后计划版本历史关联(?:和)?任务仍?零漂移/) - assert.match(value, /codeOf\(rejectedTaskResponse\) === 'A0404'/) - assert.match(value, /projectPushPreloadStability/) - assert.match(value, /diffPushPreloadStability/) - assert.match(value, /首次 push 后读取事务完成态柜次计划/) - assert.match(value, /事务完成态计划版本与 bridge 一致且保持 EDITING/) - assert.match(value, /plan: postPushPlan, detail:/) - assert.match(value, /expectedVersion: Number\(postPushPlan\.version\)/) - assert.match(value, /changed=\$\{(?:pushReplayDiff|replayDiff|rejectedDiff)\.join/) - assert.doesNotMatch(value, /baselineBytes=|replayBytes=|rejectedBytes=/) - } +test('allocation synchronizes the complete pre-PDA snapshot without a push protocol', () => { + const value = source('./phase3-loading.mjs') + assert.match(value, /plans\/by-booking\/\$\{encodeURIComponent\(containerBookingId\)\}/) + assert.match(value, /分配同步建立 bridge 关联/) + assert.match(value, /分配事务完成态计划与 bridge 一致且保持 EDITING/) + assert.match(value, /Number\(synchronizedPlan\?\.version\) === Number\(link\?\.loadingPlanVersion\)/) + assert.doesNotMatch(value, /push-preload|pushRequest|projectPushPreloadStability|diffPushPreloadStability/) }) test('push stability projection sorts unordered facts and reports persisted field drift', () => { @@ -214,15 +203,13 @@ test('push stability projection sorts unordered facts and reports persisted fiel ]) }) -test('push orchestration is transactional and a bridge failure cannot reach PDA creation', () => { +test('allocation orchestration is transactional and binds the preload before PDA creation', () => { const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') - const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java') - const push = service.slice(service.indexOf('public Plan pushPreload'), service.indexOf('private void requirePushReplay')) - assert.match(service, /@Transactional[\s\S]*public Plan pushPreload/) - assert.match(push, /repository\.push[\s\S]*history\([\s\S]*loadingModelBridge\.bindAllocatedBeforeLoading/) - assert.doesNotMatch(push, /loadingPickService\.beginIfReady/) - assert.match(serviceTest, /bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback/) - assert.match(serviceTest, /verify\(loadingPickService, never\(\)\)\.beginIfReady/) + const assign = service.slice(service.indexOf('private Plan assign('), service.indexOf('public Plan assignInbound')) + assert.match(service, /@Transactional[\s\S]*public Plan assign/) + assert.match(assign, /ensurePendingForLoadingAllocation[\s\S]*loadingModelBridge\.bindIncrementalAllocation/) + assert.match(assign, /loadingModelBridge\.bindIncrementalAllocation[\s\S]*loadingPickService\.sync/) + assert.doesNotMatch(source('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java'), /push-preload/) }) test('finalize rechecks the same unified order detail and list as LOADED', () => { @@ -276,9 +263,9 @@ test('container loading arranges a simulation before linking a released booking assertOrder(value, [ "const simulationResponse = await request(context, 'POST', CONTAINERS", "const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=", - "const planResponse = await request(context, 'POST', `${SMART}/plans`", + "const planResponse = await context.request('GET', `${SMART}/plans/by-booking/", "const assignResponse = await request", - '无真实柜号推送预装计划成功', + '分配同步建立 bridge', "const bookingCreate = await request", "const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`", ]) @@ -306,10 +293,10 @@ test('container loading arranges a simulation before linking a released booking assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/) assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/) assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/) - assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装计划成功'), value.indexOf('const linkResponse')), + assert.doesNotMatch(value.slice(value.indexOf('分配同步建立 bridge'), value.indexOf('const linkResponse')), /\/transport/) assert.match(value, /订舱放舱为 RELEASED 且不预生成真实货柜/) - assert.match(value, /无真实柜号推送预装计划成功/) + assert.match(value, /分配同步建立 bridge/) assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/) assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/) assert.match(value, /confirmationStatus === 'CONFIRMED'/) @@ -389,8 +376,8 @@ test('container loading enforces channel isolation and stable PDA identity acros '新增真实异渠道模拟柜', '两个真实有效渠道之间拒绝跨渠道装柜', '人工分配本轮候选货物', - '无真实柜号推送预装计划成功', - '无号 push 成功后 PDA 查询 A0404', + '分配同步建立 bridge', + '无真实柜号时 PDA 查询 A0404', '无号且未关联订舱不生成 PDA 任务', '订舱放舱为 RELEASED 且不预生成真实货柜', '首次关联 RELEASED 订舱需求并补真实柜号', @@ -422,20 +409,21 @@ test('container loading enforces channel isolation and stable PDA identity acros assert.doesNotMatch(batch, /containerNo:/) }) -test('preload and loading versions use immediate operation baselines and exact push replay', () => { +test('preload and loading versions use immediate allocation and claim baselines', () => { const container = source('./phase3-container-loading.mjs') - assert.match(container, /const pushRequest = Object\.freeze\(\{[\s\S]*path:[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/) - assert.equal((container.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) + assert.match(container, /plans\/by-booking\/\$\{encodeURIComponent\(containerId\)\}/) + assert.match(container, /分配同步建立 bridge/) + assert.doesNotMatch(container, /push-preload|pushRequest/) assert.match(container, /const beforeClaimPlanVersion = Number\(beforeClaimPlan\?\.version\)/) assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) const loading = source('./phase3-loading.mjs') - assert.match(loading, /const pushRequest = Object\.freeze\(\{[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/) - assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) - assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/) + assert.match(loading, /plans\/by-booking\/\$\{encodeURIComponent\(containerBookingId\)\}/) + assert.match(loading, /分配事务完成态计划与 bridge 一致且保持 EDITING/) + assert.doesNotMatch(loading, /push-preload|pushRequest/) assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) - assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'/) - assert.match(loading, /category=BOOKING&page=1&size=200/) + assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调订舱代理'/) + assert.match(loading, /category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=200/) assert.match(loading, /ensureMatsonSupplier\(context, supplierRecords\)/) assert.match(loading, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name/) assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index 0fc381806..031482748 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -94,7 +94,7 @@ async function loginMerchant(context, activeCustomer) { return blocker(context, '采购客户登录前置完整', null, `缺少 marker=${context.marker} 的客户账号凭据,禁止使用历史客户或降级为 coverageGap`) } - const login = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '') + const login = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '') const auth = context.apiSuccess(MODULE, '采购客户账号登录', login, trace(login), OPTIONS) const overseasToken = auth?.overseasAccessToken context.assert(MODULE, '采购客户登录返回海外Token', login, nonEmpty(overseasToken), diff --git a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs index e887bc8bf..830e9b614 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-client-services.mjs @@ -46,7 +46,7 @@ async function loginMerchant(context) { return null } const response = await context.request('POST', '/api/auth/login', { - username: fixture.username, password: fixture.password, + loginId: fixture.username, password: fixture.password, }, '') const auth = context.apiSuccess(MODULE, '客户工作台账号登录', response, trace(response), OPTIONS) context.assert(MODULE, '客户登录返回海外访问Token', response, nonBlank(auth?.overseasAccessToken), diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index 3d8c8f90e..b053edf74 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -390,7 +390,7 @@ async function verifyCanonicalPermissionDenial(context, resource) { resource.permissionProbe.membershipId = membershipId resource.permissionProbe.userStatus = 'ACTIVE' - const loginResponse = await context.request('POST', '/api/auth/login', { username, password }, '') + const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password }, '') const login = requireData(context, '无主数据权限用户登录', loginResponse) if (!login?.overseasAccessToken) throw new Error('无主数据权限用户未签发海外Token') const denied = await context.requestOverseas('GET', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index eb54b6b40..89db6a00e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -79,7 +79,7 @@ async function captureCompletedOrder(context, containerNo, expectedCustomerId) { async function loginMerchantForNegativeProbe(context) { const fixture = context.context?.merchantQualificationFixture if (!fixture?.username || !fixture?.password) return null - const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '') + const response = await context.request('POST', '/api/auth/login', { loginId: fixture.username, password: fixture.password }, '') const data = success(response) ? response.body?.data : null context.addStep(MODULE, '本轮客户登录取得海外令牌用于PDA越权反证', response, success(response) && nonBlank(data?.overseasAccessToken), @@ -87,7 +87,7 @@ async function loginMerchantForNegativeProbe(context) { return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null } -async function resolvePickerAccess(context) { +export async function resolvePickerAccess(context) { const rolesResponse = await context.request('GET', '/api/platform/v1/iam/roles') const roles = context.apiSuccess(MODULE, '查询海外PDA仓库操作员角色', rolesResponse, '', OPTIONS) const role = (Array.isArray(roles) ? roles : []).find((item) => item?.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && item?.status === 'ACTIVE') @@ -104,7 +104,7 @@ async function resolvePickerAccess(context) { return { role, operate } } -async function createPicker(context, index, overseasWarehouseId, access) { +export async function createPicker(context, index, overseasWarehouseId, access) { const role = access.role const suffix = markerSuffix(context) const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64) @@ -122,7 +122,7 @@ async function createPicker(context, index, overseasWarehouseId, access) { && (created?.roleIds || []).map(id).includes(id(role.id)), `membershipId=${membershipId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}, overseasWarehouseId=${id(created?.overseasWarehouseId) || 'missing'}`, OPTIONS) if (!longId(membershipId)) return null - const login = await context.request('POST', '/api/auth/login', { username, password }, '') + const login = await context.request('POST', '/api/auth/login', { loginId: username, password }, '') const token = success(login) ? login.body?.data?.overseasAccessToken : null context.addStep(MODULE, `海外PDA操作员${index}登录取得双区域海外令牌`, login, nonBlank(token), `overseasTokenPresent=${nonBlank(token)}; ${trace(login)}`, OPTIONS) @@ -143,7 +143,7 @@ async function createPicker(context, index, overseasWarehouseId, access) { return { membershipId, username, token: projectionReady ? token : null } } -async function disablePicker(context, picker, index) { +export async function disablePicker(context, picker, index) { if (!longId(picker?.membershipId)) return const response = await context.request('PUT', `/api/platform/v1/iam/users/${picker.membershipId}/status`, { status: 'DISABLED' }) const disabled = success(response) && response.body?.data?.status === 'DISABLED' diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs new file mode 100644 index 000000000..77c8521bd --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -0,0 +1,64 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { parseArguments } from '../admin-test-env-acceptance.mjs' + +const source = await readFile(fileURLToPath(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url)), 'utf8') +const entry = await readFile(fileURLToPath(new URL('../admin-test-env-acceptance.mjs', import.meta.url)), 'utf8') + +test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => { + assert.match(entry, /CONFIGURED_MODULES[^\n]*'phase4-overseas-dispatch', 'phase5-overseas-fulfillment'/) + assert.match(entry, /'phase5-overseas-fulfillment', \['phase4-overseas-dispatch'\]/) + const modules = parseArguments(['--modules', 'phase5-overseas-fulfillment']).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-loading-finalize', + 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-dispatch']) { + assert.ok(modules.indexOf(prerequisite) >= 0 + && modules.indexOf(prerequisite) < modules.indexOf('phase5-overseas-fulfillment'), prerequisite) + } +}) + +test('phase-five module covers dispatch edit, transfer, PDA pick/depart and delivery state', () => { + for (const token of [ + "const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'", + "const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", + '/transfer-delivery', '/items/${encodeURIComponent(id(item.dispatchItemId))}/pick', + '/complete`', '/depart`', '/outbound-orders/${encodeURIComponent(outboundNo)}', + '/delivery-orders/${encodeURIComponent(deliveryNo)}/status', + ]) assert.ok(source.includes(token), token) + assert.match(source, /WAIT_OUTBOUND/) + assert.match(source, /WAIT_DELIVERY/) + assert.match(source, /READY_TO_DEPART/) + assert.match(source, /OUTBOUNDED/) + assert.match(source, /DELIVERING/) +}) + +test('phase-five module proves completion, customer visit, manual exception and delivery return', () => { + for (const token of [ + '/delivery-orders/${encodeURIComponent(deliveryNo)}/complete', + '/customer-visits/${encodeURIComponent(visitId)}', + "visitResult: 'SATISFIED'", '/exception-orders', + '/return-inbound-options', '/mark-return', "returnDirection: 'OVERSEAS_WAREHOUSE'", + 'RETURN_PENDING_WITH_COMPLETED_VISIT', + ]) assert.ok(source.includes(token), token) + assert.match(source, /manualExceptionNo/) + assert.match(source, /returnExceptionNo/) +}) + +test('phase-five module uses marker-owned fixtures and always disables its temporary PDA identity', () => { + assert.match(source, /dispatch\?\.marker !== context\.marker/) + assert.match(source, /receiving\?\.marker !== context\.marker/) + assert.match(source, /resolvePickerAccess\(context\)/) + assert.match(source, /createPicker\(context, 5, warehouseId, access\)/) + assert.match(source, /finally \{[\s\S]*disablePicker\(context, picker, 5\)[\s\S]*picker\.token = null/) + assert.doesNotMatch(source, /8\.163\.|password\s*:/) +}) + +test('phase-five writes carry idempotency and record deployment blockers instead of passing empty fixtures', () => { + assert.match(source, /idempotencyKey: key\(context, 'TRANSFER'\)/) + assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-PICK'/) + assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-COMPLETE'\)/) + assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART'\)/) + assert.match(source, /addCoverageGap\(MODULE, SCENARIO/) + assert.match(source, /deploymentBlocker: true/) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs new file mode 100644 index 000000000..89b631dfb --- /dev/null +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -0,0 +1,282 @@ +import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs' + +const MODULE = 'phase5-overseas-fulfillment' +const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit' +const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches' +const LASTLEG_BASE = '/api/platform/v1/last-leg' +const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches' + +function id(value) { return value == null ? '' : String(value) } +function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } +function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function code(response) { return String(response?.body?.code ?? '') } +function conflict(response) { + return response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response))) +} +function trace(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${code(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} +function query(path, values) { + const params = new URLSearchParams() + for (const [name, value] of Object.entries(values || {})) { + if (value !== undefined && value !== null && String(value) !== '') params.set(name, String(value)) + } + return params.size ? `${path}?${params}` : path +} +function key(context, action, suffix = '') { + return `${context.marker}-P5-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 200) +} +function blocker(context, name, reason, response = null) { + context.addCoverageGap(MODULE, SCENARIO, name, reason, 'fixture-unavailable', { deploymentBlocker: true }) + context.assert(MODULE, `${name}覆盖前置`, response, false, `${reason}; ${trace(response)}`, OPTIONS) +} +async function platform(context, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body) + const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) + return { response, data } +} +async function pda(context, token, method, name, path, body = undefined) { + const response = await context.requestOverseas(method, path, body, {}, token) + context.addStep(MODULE, name, response, success(response), trace(response), OPTIONS) + return { response, data: success(response) ? response.body?.data : null } +} + +function fixture(context) { + const dispatch = context.createdResources?.phase4OverseasDispatch + const receiving = context.createdResources?.phase4OverseasPda + if (dispatch?.marker !== context.marker || dispatch?.finalStatus !== 'PENDING_DEPARTURE' + || !nonBlank(dispatch?.dispatchNo)) return null + if (receiving?.marker !== context.marker || receiving?.finalStatus !== 'COMPLETED' + || !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId)) return null + return { dispatch, receiving } +} + +async function pickAll(context, token, dispatchNo) { + let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + const items = Array.isArray(detail.data?.items) ? detail.data.items : [] + if (!success(detail.response) || items.length === 0) { + blocker(context, 'PDA排车拣货任务', '本轮排车未生成可操作PDA托盘任务', detail.response) + return false + } + + for (const [itemIndex, item] of items.entries()) { + let remaining = Number(item.scheduledPackages || 0) - Number(item.pickedPackages || 0) + const positions = Array.isArray(item.positions) ? item.positions : [] + for (const [positionIndex, position] of positions.entries()) { + if (remaining <= 0) break + const available = Number(position.availablePackages || 0) + if (available <= 0) continue + const packages = Math.min(remaining, available) + const body = { + positionId: id(position.id), packages, productQuantity: packages, + idempotencyKey: key(context, 'DISPATCH-PICK', `${itemIndex}-${positionIndex}`), + remark: `${context.marker}-phase5-dispatch-pick`, + } + const picked = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}库存位${positionIndex + 1}`, + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body) + if (!success(picked.response)) return false + const replay = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}同键重放`, + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body) + context.assert(MODULE, `PDA拣货托盘${itemIndex + 1}重放数量稳定`, replay.response, + success(replay.response) && Number(replay.data?.pickedPackages) === Number(picked.data?.pickedPackages), + trace(replay.response), OPTIONS) + remaining -= packages + } + if (remaining !== 0) { + blocker(context, `PDA托盘${itemIndex + 1}足量库存`, + `scheduled=${item.scheduledPackages}, picked=${item.pickedPackages}, remaining=${remaining}`, detail.response) + return false + } + } + + const completeBody = { idempotencyKey: key(context, 'DISPATCH-COMPLETE') } + const completed = await pda(context, token, 'POST', 'PDA确认排车拣货全部完成', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody) + context.assert(MODULE, '排车进入READY_TO_DEPART且数量守恒', completed.response, + completed.data?.status === 'READY_TO_DEPART' + && Number(completed.data?.plannedPackages) === Number(completed.data?.pickedPackages) + && Number(completed.data?.remainingPackages) === 0, + trace(completed.response), OPTIONS) + if (!success(completed.response)) return false + const replay = await pda(context, token, 'POST', 'PDA完成排车拣货同键重放', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody) + context.assert(MODULE, 'PDA完成拣货重放明确标记且结果稳定', replay.response, + replay.data?.replayed === true && replay.data?.status === 'READY_TO_DEPART', trace(replay.response), OPTIONS) + return success(replay.response) +} + +export async function run(context) { + context.createdResources.phase5OverseasFulfillment = { + marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, + visitId: null, manualExceptionNo: null, returnExceptionNo: null, finalStatus: null, + cleanup: 'marker-owned completed delivery, customer visit and return exception are retained as acceptance evidence', + } + const source = fixture(context) + if (!source) { + blocker(context, '取得本轮四期已收货排车前置', + `dispatchMarker=${context.createdResources?.phase4OverseasDispatch?.marker || 'missing'}, dispatchStatus=${context.createdResources?.phase4OverseasDispatch?.finalStatus || 'missing'}, receivingStatus=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}`) + return + } + const dispatchNo = source.dispatch.dispatchNo + const customerId = id(source.receiving.customerId) + const warehouseId = id(source.receiving.overseasWarehouseId) + context.createdResources.phase5OverseasFulfillment.dispatchNo = dispatchNo + + const updateBody = { + vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64), + driverName: `五期验收司机-${context.marker.slice(-6)}`, + driverPhone: '19900000000', carrierName: '五期开发验收车队', + route: `${context.marker}-PHASE5-ROUTE`, estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(), + } + const updated = await platform(context, 'PUT', '补齐本轮排车车辆司机与线路', + `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`, updateBody) + context.assert(MODULE, '排车编辑字段完整回显', updated.response, + updated.data?.dispatchNo === dispatchNo && updated.data?.vehiclePlate === updateBody.vehiclePlate + && updated.data?.driverName === updateBody.driverName && updated.data?.route === updateBody.route, + trace(updated.response), OPTIONS) + if (!success(updated.response)) return + + const transferBody = { + idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(), + deliveryMethod: 'SELF_DELIVERY', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64), + carrierName: updateBody.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', + vehicleType: 'TRUCK', vehicleSize: 'MEDIUM', driverName: updateBody.driverName, + driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate, + appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`, + fees: [{ feeType: 'DELIVERY_FEE', currency: 'USD', amount: 0, remark: `${context.marker}-acceptance` }], + } + const transferred = await platform(context, 'POST', '排车原子转出库与派送单', + `${LASTLEG_BASE}/dispatches/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody) + const outboundNo = id(transferred.data?.outboundNo) + const deliveryNo = id(transferred.data?.deliveryNo) + context.assert(MODULE, '转派送同时生成WAIT_OUTBOUND与WAIT_DELIVERY单据', transferred.response, + nonBlank(outboundNo) && nonBlank(deliveryNo) && transferred.data?.outboundStatus === 'WAIT_OUTBOUND' + && transferred.data?.deliveryStatus === 'WAIT_DELIVERY' && Number(transferred.data?.packageCount) > 0, + `outboundNo=${outboundNo || 'missing'}, deliveryNo=${deliveryNo || 'missing'}; ${trace(transferred.response)}`, OPTIONS) + if (!success(transferred.response) || !outboundNo || !deliveryNo) return + Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo }) + const transferReplay = await platform(context, 'POST', '排车转派送同键重放', + `${LASTLEG_BASE}/dispatches/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody) + context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response, + transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo + && id(transferReplay.data?.deliveryNo) === deliveryNo, trace(transferReplay.response), OPTIONS) + const transferConflict = await context.requestOverseas('POST', + `${LASTLEG_BASE}/dispatches/${encodeURIComponent(dispatchNo)}/transfer-delivery`, + { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }) + context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict, + success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo + && transferConflict.body?.data?.replayed === true, trace(transferConflict), OPTIONS) + + const access = await resolvePickerAccess(context) + const picker = access ? await createPicker(context, 5, warehouseId, access) : null + try { + if (!nonBlank(picker?.token)) { + blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪') + return + } + if (!await pickAll(context, picker.token, dispatchNo)) return + const departBody = { idempotencyKey: key(context, 'DISPATCH-DEPART'), remark: `${context.marker}-depart` } + const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) + context.assert(MODULE, '发车推进排车DEPARTED并扣减实物库存一次', departed.response, + departed.data?.status === 'DEPARTED' && Number(departed.data?.shippedPackages) > 0, + trace(departed.response), OPTIONS) + if (!success(departed.response)) return + const departReplay = await pda(context, picker.token, 'POST', 'PDA发车同键重放', + `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) + context.assert(MODULE, 'PDA发车重放明确标记且发运数量稳定', departReplay.response, + departReplay.data?.replayed === true + && Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages), + trace(departReplay.response), OPTIONS) + } finally { + await disablePicker(context, picker, 5) + if (picker) picker.token = null + } + + const outbound = await platform(context, 'GET', '回查发车后出库单状态', + query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 20 })) + context.assert(MODULE, '排车PDA发车同步出库单OUTBOUNDED', outbound.response, + outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED', + trace(outbound.response), OPTIONS) + + const delivering = await platform(context, 'PUT', '派送单推进配送中', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/status`, { customerId }), + { status: 'DELIVERING', remark: `${context.marker}-delivering` }) + context.assert(MODULE, '派送状态进入DELIVERING', delivering.response, + delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS) + const completed = await platform(context, 'POST', '确认派送完成并生成客户回访', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId })) + const visitId = id(completed.data?.visitId) + context.assert(MODULE, '派送完成生成PENDING回访记录', completed.response, + completed.data?.deliveryStatus === 'DELIVERED' && completed.data?.visitStatus === 'PENDING' + && /^\d+$/.test(visitId), `visitIdPresent=${/^\d+$/.test(visitId)}; ${trace(completed.response)}`, OPTIONS) + if (!success(completed.response) || !visitId) return + context.createdResources.phase5OverseasFulfillment.visitId = visitId + const completeReplay = await platform(context, 'POST', '确认派送完成重复调用', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId })) + context.assert(MODULE, '派送完成重放复用同一回访', completeReplay.response, + completeReplay.data?.replayed === true && id(completeReplay.data?.visitId) === visitId, + trace(completeReplay.response), OPTIONS) + + const visitDetail = await platform(context, 'GET', '读取本轮客户回访详情', + query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId })) + const inbound = Array.isArray(visitDetail.data?.inboundOrders) + ? visitDetail.data.inboundOrders.find((value) => id(value?.inboundOrderId) && nonBlank(value?.lastMileOrderNo)) : null + context.assert(MODULE, '回访详情包含派送关联的真实入仓单与尾程单', visitDetail.response, + Boolean(inbound), `inboundOption=${Boolean(inbound)}; ${trace(visitDetail.response)}`, OPTIONS) + if (!inbound) return + const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果', + query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), { + selectedInboundOrderId: id(inbound.inboundOrderId), visitResult: 'SATISFIED', + customerFeedback: `${context.marker}-customer-feedback`, remark: `${context.marker}-visit-completed`, attachments: [], + }) + context.assert(MODULE, '客户回访完成并冻结所选业务入仓码', savedVisit.response, + savedVisit.data?.visit?.status === 'COMPLETED' + && savedVisit.data?.visit?.selectedInboundCode === inbound.inboundCode + && savedVisit.data?.visit?.visitResult === 'SATISFIED', trace(savedVisit.response), OPTIONS) + + const manualException = await platform(context, 'POST', '为本轮尾程单创建人工异常', + `${LASTLEG_BASE}/exception-orders`, { + customerId, inboundCode: inbound.inboundCode, lastMileOrderNo: inbound.lastMileOrderNo, + exceptionType: 'DAMAGED', reason: `${context.marker}-manual-exception`, packageCount: 1, skuCode: null, + }) + const manualExceptionNo = id(manualException.data?.exceptionNo) + context.assert(MODULE, '人工异常进入PENDING并返回业务单号', manualException.response, + nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS) + context.createdResources.phase5OverseasFulfillment.manualExceptionNo = manualExceptionNo || null + + const returnOptions = await platform(context, 'GET', '查询派送退回可选入仓单', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { customerId })) + const returnInbound = Array.isArray(returnOptions.data) + ? returnOptions.data.find((value) => id(value?.inboundOrderId) === id(inbound.inboundOrderId)) : null + context.assert(MODULE, '退回候选严格属于当前派送单', returnOptions.response, + Boolean(returnInbound), `candidate=${Boolean(returnInbound)}; ${trace(returnOptions.response)}`, OPTIONS) + if (!returnInbound) return + const returned = await platform(context, 'POST', '标记派送退回海外仓并创建退货异常', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), { + inboundOrderId: id(returnInbound.inboundOrderId), reason: `${context.marker}-delivery-return`, + returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RETURN`.slice(0, 128), + packageCount: 1, skuCode: null, + }) + const returnExceptionNo = id(returned.data?.exceptionNo) + context.assert(MODULE, '派送退回生成独立PENDING异常单', returned.response, + nonBlank(returnExceptionNo) && returned.data?.status === 'PENDING' + && returnExceptionNo !== manualExceptionNo, trace(returned.response), OPTIONS) + context.createdResources.phase5OverseasFulfillment.returnExceptionNo = returnExceptionNo || null + + const delivery = await platform(context, 'GET', '回查派送退回终态', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) + context.assert(MODULE, '派送退回后单据状态为RETURN_PENDING且保留出库关联', delivery.response, + delivery.data?.order?.status === 'RETURN_PENDING' && delivery.data?.order?.outboundNo === outboundNo, + trace(delivery.response), OPTIONS) + const exceptionPage = await platform(context, 'GET', '分页回查本轮两类异常', + query(`${LASTLEG_BASE}/exception-orders`, { page: 1, size: 20, customerId, keyword: context.marker })) + const exceptionRows = Array.isArray(exceptionPage.data?.records) ? exceptionPage.data.records : [] + context.assert(MODULE, '异常列表同时包含人工异常与派送退回异常', exceptionPage.response, + exceptionRows.some((value) => id(value?.exceptionNo) === manualExceptionNo) + && exceptionRows.some((value) => id(value?.exceptionNo) === returnExceptionNo), + `records=${exceptionRows.length}; ${trace(exceptionPage.response)}`, OPTIONS) + context.createdResources.phase5OverseasFulfillment.finalStatus = 'RETURN_PENDING_WITH_COMPLETED_VISIT' +} diff --git a/scripts/admin-test-env-modules/suppliers.mjs b/scripts/admin-test-env-modules/suppliers.mjs index 7e31c2b22..74e909129 100644 --- a/scripts/admin-test-env-modules/suppliers.mjs +++ b/scripts/admin-test-env-modules/suppliers.mjs @@ -42,7 +42,7 @@ export async function run(context) { const nonBookingCarrierResponse=await context.request('POST',`${BASE}?tenantId=0`,payload(`${marker}NB`,{name:`非订舱供应商非法船司-${marker}`,category:'CUSTOMS',carrierCodes:['MATSON']})) context.assert(MODULE,'非BOOKING携带carrierCodes返回A0400并保留链路ID',nonBookingCarrierResponse,code(nonBookingCarrierResponse,'A0400')&&traced(nonBookingCarrierResponse),`code=${nonBookingCarrierResponse.body?.code}, requestId=${nonBookingCarrierResponse.requestId||'missing'}, traceId=${nonBookingCarrierResponse.traceId||'missing'}`,OPTIONS) - const merchantLogin=await context.request('POST','/api/auth/login',{username:MERCHANT_USERNAME,password:MERCHANT_PASSWORD},'') + const merchantLogin=await context.request('POST','/api/auth/login',{loginId:MERCHANT_USERNAME,password:MERCHANT_PASSWORD},'') const merchantToken=ok(merchantLogin)?merchantLogin.body?.data?.accessToken:'' const forbidden=merchantToken?await context.request('GET',query({tenantId:0,page:1,size:20}),undefined,merchantToken):merchantLogin context.assert(MODULE,'客户账号调用平台供应商接口被拒绝',forbidden,Boolean(merchantToken)&&(forbidden.http===403||code(forbidden,'A0403'))&&traced(forbidden),`HTTP=${forbidden.http}, code=${forbidden.body?.code??'missing'}, requestId=${forbidden.requestId||'missing'}, traceId=${forbidden.traceId||'missing'}`,OPTIONS) diff --git a/scripts/admin-test-env-modules/system.mjs b/scripts/admin-test-env-modules/system.mjs index a76a33c2a..9b2ad4273 100644 --- a/scripts/admin-test-env-modules/system.mjs +++ b/scripts/admin-test-env-modules/system.mjs @@ -137,13 +137,13 @@ export async function run(context) { updatedUser?.displayName === updateUserPayload.displayName && updatedUser?.email === updateUserPayload.email, `membershipId=${membershipId}`) - const loginResponse = await context.request('POST', '/api/auth/login', { username, password: initialPassword }, '') + const loginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: initialPassword }, '') const loginPassed = loginResponse.http === 200 && String(loginResponse.body?.code) === '200' && Boolean(loginResponse.body?.data?.accessToken) context.addStep(module, '新建内部用户可使用初始密码登录', loginResponse, loginPassed, `username=${username}`) const resetResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/password`, { newPassword: resetPassword }) context.apiSuccess(module, '重置平台内部用户密码', resetResponse) - const resetLoginResponse = await context.request('POST', '/api/auth/login', { username, password: resetPassword }, '') + const resetLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '') const resetLoginPassed = resetLoginResponse.http === 200 && String(resetLoginResponse.body?.code) === '200' && Boolean(resetLoginResponse.body?.data?.accessToken) context.addStep(module, '内部用户使用重置密码登录', resetLoginResponse, resetLoginPassed, `username=${username}`) if (context.options.modules.includes('phase4-cross-region-events')) { @@ -165,7 +165,7 @@ export async function run(context) { const disableUserResponse = await context.request('PUT', `/api/platform/v1/iam/users/${membershipId}/status`, { status: 'DISABLED' }) const disabledUser = context.apiSuccess(module, '停用平台内部用户', disableUserResponse) context.assert(module, '内部用户停用状态正确', disableUserResponse, disabledUser?.status === 'DISABLED', `status=${disabledUser?.status ?? 'missing'}`) - const disabledLoginResponse = await context.request('POST', '/api/auth/login', { username, password: resetPassword }, '') + const disabledLoginResponse = await context.request('POST', '/api/auth/login', { loginId: username, password: resetPassword }, '') context.addStep(module, '停用用户禁止登录', disabledLoginResponse, disabledLoginResponse.http === 200 && String(disabledLoginResponse.body?.code) !== '200', `HTTP=${disabledLoginResponse.http}, code=${disabledLoginResponse.body?.code ?? 'missing'}`) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index d87df2a34..afac7d656 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -75,12 +75,16 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0, } async function activeSupplier(context, category, suffix) { - const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE&page=1&size=100`) - const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE') + const carrierFilter = category === 'BOOKING' ? '&carrierCode=MATSON' : '' + const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE${carrierFilter}&page=1&size=100`) + const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE' + && (category !== 'BOOKING' || item?.carrierCodes?.includes?.('MATSON'))) if (existing) return { supplierId: id(existing.id), supplierName: existing.name } const created = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', { name: `${category}-验收-${suffix}`, category, carrierCodes: category === 'BOOKING' ? ['MATSON'] : [], - region: '深圳', contactName: '验收联系人', phone: '13800000000', status: 'ACTIVE', remark: context.marker, + region: '深圳', legalRepresentative: '验收法人', contactName: '验收联系人', phone: '13800000000', + email: 'truck-acceptance@example.com', address: '深圳市验收地址', businessLicenseNo: `TRUCK-${suffix}`.slice(0, 64), + licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker, }) const value = data(created); const supplierId = id(value?.id || value) context.apiSuccess(MODULE, `创建 ACTIVE ${category} 供应商`, created, '', OPTIONS) diff --git a/scripts/auth-login-payload-contract.test.mjs b/scripts/auth-login-payload-contract.test.mjs new file mode 100644 index 000000000..ed88b9afc --- /dev/null +++ b/scripts/auth-login-payload-contract.test.mjs @@ -0,0 +1,42 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const callers = [ + 'scripts/admin-test-env-acceptance.mjs', + 'scripts/client-test-env-acceptance.mjs', + 'scripts/client-phase2-test-env-acceptance.mjs', + 'scripts/create-pending-review-orders.mjs', + 'scripts/pda-final-complete-diagnostic.mjs', + 'scripts/admin-test-env-modules/domestic-container.mjs', + 'scripts/admin-test-env-modules/fee-items.mjs', + 'scripts/admin-test-env-modules/finance-downloads.mjs', + 'scripts/admin-test-env-modules/finance-reconciliation-full.mjs', + 'scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs', + 'scripts/admin-test-env-modules/orders.mjs', + 'scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs', + 'scripts/admin-test-env-modules/phase3-customs.mjs', + 'scripts/admin-test-env-modules/phase3-loading.mjs', + 'scripts/admin-test-env-modules/phase3-order-splitting.mjs', + 'scripts/admin-test-env-modules/phase4-overseas-business.mjs', + 'scripts/admin-test-env-modules/phase4-overseas-client-services.mjs', + 'scripts/admin-test-env-modules/phase4-overseas-master-data.mjs', + 'scripts/admin-test-env-modules/phase4-overseas-pda.mjs', + 'scripts/admin-test-env-modules/suppliers.mjs', + 'scripts/admin-test-env-modules/system.mjs', +] + +test('all acceptance login callers use the canonical loginId request field', async () => { + let loginCount = 0 + for (const file of callers) { + const source = await readFile(resolve(file), 'utf8') + const canonical = source.match(/\/api\/auth\/login[\s\S]{0,180}loginId\s*:/g) || [] + assert.ok(canonical.length > 0, `${file} must send loginId`) + loginCount += canonical.length + assert.doesNotMatch(source, + /\/api\/auth\/login[\s\S]{0,180}(?:\busername\s*:|\{\s*username\s*,)/, + `${file} must not send removed username`) + } + assert.ok(loginCount >= 20, `expected broad login coverage, got ${loginCount}`) +}) diff --git a/scripts/client-phase2-test-env-acceptance.mjs b/scripts/client-phase2-test-env-acceptance.mjs index 5252ef7c5..63e045f6d 100644 --- a/scripts/client-phase2-test-env-acceptance.mjs +++ b/scripts/client-phase2-test-env-acceptance.mjs @@ -101,7 +101,7 @@ process.exitCode = report.success ? 0 : 1 async function sessionModule() { const clientLogin = await apiStep('phase2-session', 'client login', { method: 'POST', path: '/api/auth/login', - body: { username: clientUsername, password: clientPassword }, + body: { loginId: clientUsername, password: clientPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'client accessToken') @@ -126,7 +126,7 @@ async function sessionModule() { const platformLogin = await apiStep('phase2-fixture', 'platform fixture account login', { method: 'POST', path: '/api/auth/login', - body: { username: platformUsername, password: platformPassword }, + body: { loginId: platformUsername, password: platformPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'platform accessToken') diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index a996d8588..d1f5e8f18 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -148,7 +148,7 @@ async function registrationModule() { }) const login = await apiStep('registration', 'login before approval', { - method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'accessToken') @@ -286,7 +286,7 @@ async function qualificationAndReviewModule() { }, ({ json }) => ({ tenantId: json.data.id, status: json.data.status })) const platformLogin = await apiStep('qualification-review', 'platform reviewer login', { - method: 'POST', path: '/api/auth/login', body: { username: platformUsername, password: platformPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: platformUsername, password: platformPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'platform accessToken') @@ -345,7 +345,7 @@ async function qualificationAndReviewModule() { async function merchantAfterApprovalModule() { const login = await apiStep('merchant-session', 'client re-login after approval', { - method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'accessToken') @@ -502,7 +502,7 @@ async function profileManagementModule() { }, ({ json }) => ({ userId: json.data.userId, username: json.data.username, displayName: json.data.displayName })) const relogin = await apiStep('profile-management', 're-login after personal profile token revocation', { - method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'accessToken') @@ -692,7 +692,7 @@ async function rbacAndMemberModule() { }, ({ json }) => ({ membershipId: report.resources.membershipId, rejectionCode: json.code })) const memberLogin = await apiStep('rbac-member', 'new enterprise member can login', { - method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'member accessToken') @@ -742,7 +742,7 @@ async function rbacAndMemberModule() { }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) const disabledRoleLogin = await apiStep('menu-permissions', 'member login succeeds while role is disabled', { - method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'member accessToken') @@ -769,7 +769,7 @@ async function rbacAndMemberModule() { assertEqual(json.data?.status, 'ACTIVE', 'role status') }, ({ json }) => ({ roleId: json.data.id, status: json.data.status })) const enabledRoleLogin = await apiStep('menu-permissions', 'member re-login after role is enabled', { - method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'member accessToken') @@ -800,7 +800,7 @@ async function rbacAndMemberModule() { }, ({ json }) => ({ rejectionCode: json?.code, message: json?.message })) const reducedPermissionLogin = await apiStep('menu-permissions', 'member re-login after permission removal', { - method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'member accessToken') @@ -824,7 +824,7 @@ async function rbacAndMemberModule() { body: { roleId, permissionIds }, }, ({ json }) => requireSuccess(json), () => ({ roleId, permissionCodes: desiredCodes })) const restoredPermissionLogin = await apiStep('menu-permissions', 'member re-login after permission restoration', { - method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'member accessToken') @@ -860,13 +860,13 @@ async function rbacAndMemberModule() { body: { newPassword: resetMemberPassword }, }, ({ json }) => requireSuccess(json), () => ({ membershipId })) await apiStep('rbac-member', 'member old password is rejected after reset', { - method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: memberPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: memberPassword }, }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0401', 'business code') }, ({ json }) => ({ username: memberUsername, rejectionCode: json.code })) const resetLogin = await apiStep('rbac-member', 'member can login with reset password', { - method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: resetMemberPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'member accessToken') @@ -886,7 +886,7 @@ async function rbacAndMemberModule() { assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`) }, ({ json }) => ({ membershipId, rejectionCode: json?.code })) await apiStep('rbac-member', 'disabled member login is rejected', { - method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: resetMemberPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword }, }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertTrue(['A0401', 'A0403'].includes(String(json?.code)), `expected A0401/A0403, actual=${json?.code}`) @@ -899,7 +899,7 @@ async function rbacAndMemberModule() { }, ({ json }) => ({ membershipId: json.data.membershipId, status: json.data.status })) const enabledMemberLogin = await apiStep('rbac-member', 'member re-login after account is enabled', { - method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: resetMemberPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'member accessToken') @@ -927,7 +927,7 @@ async function rbacAndMemberModule() { assertEqual(String(json?.code), 'A0401', 'business code') }, ({ json }) => ({ rejectionCode: json.code })) const noRoleLogin = await apiStep('rbac-member', 'member re-login after role clearing', { - method: 'POST', path: '/api/auth/login', body: { username: memberUsername, password: resetMemberPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: memberUsername, password: resetMemberPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'member accessToken') @@ -1294,7 +1294,7 @@ async function isolationModule() { return } const login = await apiStep('isolation', 'existing client login', { - method: 'POST', path: '/api/auth/login', body: { username: existingClientUsername, password: existingClientPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: existingClientUsername, password: existingClientPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'accessToken') @@ -1603,14 +1603,14 @@ async function accountSecurityModule() { ownerPasswordNeedsRestore = true await apiStep('account-security', 'original password is rejected after change', { - method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, }, ({ json, status }) => { assertEqual(status, 200, 'HTTP status') assertEqual(String(json?.code), 'A0401', 'business code') }, ({ json }) => ({ username, rejectionCode: json.code })) const temporaryLogin = await apiStep('account-security', 'temporary password login succeeds', { - method: 'POST', path: '/api/auth/login', body: { username, password: temporaryOwnerPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: temporaryOwnerPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'accessToken') @@ -1625,7 +1625,7 @@ async function accountSecurityModule() { temporaryOwnerPassword = undefined const restoredLogin = await apiStep('account-security', 'restored owner password login succeeds', { - method: 'POST', path: '/api/auth/login', body: { username, password: clientPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'accessToken') @@ -1655,7 +1655,7 @@ async function accountSecurityModule() { async function recoverOwnerPassword() { try { const login = await apiStep('account-security-recovery', 'login with temporary owner password for recovery', { - method: 'POST', path: '/api/auth/login', body: { username, password: temporaryOwnerPassword }, + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: temporaryOwnerPassword }, }, ({ json }) => { requireSuccess(json) assertPresent(json.data?.accessToken, 'recovery accessToken') diff --git a/scripts/create-pending-review-orders.mjs b/scripts/create-pending-review-orders.mjs index d83a44ea4..31dada7ce 100644 --- a/scripts/create-pending-review-orders.mjs +++ b/scripts/create-pending-review-orders.mjs @@ -368,7 +368,7 @@ export async function main(argv = process.argv.slice(2)) { const orders = [] const loginResponse = await request(gateway, timeoutMs, 'POST', '/api/auth/login', { - body: { username, password }, + body: { loginId: username, password }, }) const login = requireSuccess(loginResponse, 'Merchant login') if (!login?.accessToken) throw new Error('Merchant login did not return accessToken') diff --git a/scripts/pda-final-complete-diagnostic.mjs b/scripts/pda-final-complete-diagnostic.mjs index 785837ff9..d83ba5f5c 100644 --- a/scripts/pda-final-complete-diagnostic.mjs +++ b/scripts/pda-final-complete-diagnostic.mjs @@ -89,8 +89,8 @@ async function uploadEvidence(tenantId, inboundId, evidenceType) { const ok = (response) => response?.http === 200 && response?.code === '200' const id = (input) => input == null ? '' : String(input) -const login = await call('POST', '/api/auth/login', { username, password }) -login.body = { username, password: '[REDACTED]' } +const login = await call('POST', '/api/auth/login', { loginId: username, password }) +login.body = { loginId: username, password: '[REDACTED]' } if (!ok(login) || !login.data?.accessToken) { login.data = null const failedReport = { marker, startedAt: new Date().toISOString(), gateway, taskIds, stopped: true,