fix: restore phase five development acceptance chain
This commit is contained in:
@@ -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', '订单内部备注表:保存平台运营或履约人员的内部协作记录,不向商户客户展示');
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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'")
|
||||
|
||||
+6
@@ -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 {
|
||||
|
||||
@@ -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='客户自有下单地址簿;不承载平台仓库主档';
|
||||
|
||||
+3
-1
@@ -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 全量入口 |
|
||||
|
||||
@@ -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临近到期`)
|
||||
}
|
||||
|
||||
@@ -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 <development-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` 串行执行货柜主链。冻结时序为:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') })
|
||||
|
||||
@@ -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'}`)
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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'}`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}`)
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user