From c93fdc44d52883f00b8cfba293c575b62fa97da1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 24 Aug 2026 19:58:35 +0800 Subject: [PATCH] 1 --- scripts/admin-test-env-acceptance.mjs | 127 ++++-- scripts/admin-test-env-modules/README.md | 4 +- .../inbound-measurement.mjs | 3 + scripts/admin-test-env-modules/orders.mjs | 50 ++- .../phase3-dispatch-gate.mjs | 3 + .../phase3-evidence-gate.mjs | 5 + .../phase3-http-fail-closed-contract.test.mjs | 50 +++ ...e3-loading-dynamic-final-contract.test.mjs | 2 +- .../phase3-loading-finalize.mjs | 52 +++ .../phase3-loading-model-bridge.mjs | 3 + .../admin-test-env-modules/phase3-loading.mjs | 10 +- .../phase3-outbound-aggregate.mjs | 5 + .../phase3-outbound-dispatch.mjs | 37 ++ .../phase3-pda-convergence.mjs | 23 +- ...-pda-pending-consistency-contract.test.mjs | 3 + ...-phase4-producer-fixture-contract.test.mjs | 67 +++ .../phase3-producer-chain-contract.test.mjs | 11 + .../phase3-waiting-for-loading.mjs | 3 + ...se4-overseas-master-data-contract.test.mjs | 127 ++++-- .../phase4-overseas-master-data.mjs | 144 +++++- .../phase4-overseas-pda-contract.test.mjs | 88 ++-- .../phase4-overseas-pda.mjs | 424 ++++++++++-------- ...verseas-acceptance-entry-contract.test.mjs | 37 +- 23 files changed, 965 insertions(+), 313 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 0afc3b1f3..4bd6fb8ad 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -1,18 +1,10 @@ #!/usr/bin/env node -// Test environment administrator / 测试环境管理员 -// Username: platform_admin -// Password: ChangeMe123! - import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' -const DEFAULT_OVERSEAS_GATEWAY = 'http://8.163.30.128:9300' -const DEFAULT_USERNAME = 'platform_admin' -const DEFAULT_PASSWORD = 'ChangeMe123!' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-overseas-pda', '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'] export function normalizeModuleOrder(modules) { @@ -65,6 +57,13 @@ export function normalizeModuleOrder(modules) { && !normalized.includes('merchant-qualification-fixture')) { normalized.splice(normalized.indexOf('phase4-overseas-business'), 0, 'merchant-qualification-fixture') } + if (normalized.includes('phase4-overseas-pda')) { + const prerequisites = ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence', + 'phase3-evidence-gate', 'phase3-loading-finalize'] + const targetIndex = normalized.indexOf('phase4-overseas-pda') + const missing = prerequisites.filter((name) => !normalized.includes(name)) + if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) + } if (normalized.includes('phase4-cross-region-events')) { const prerequisites = ['system', 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'phase3-pda-convergence', 'phase3-evidence-gate', @@ -130,7 +129,7 @@ export function normalizeModuleOrder(modules) { } const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES) -const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' +const WEIGHT_IMAGE_PATH = process.env.ADMIN_TEST_WEIGHT_IMAGE_PATH || '' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', 'contactName', 'contactPhone', 'contactEmail', 'contactWechat', @@ -148,21 +147,20 @@ const BOX_DEFAULTS = [ function usage() { console.log(`后台管理测试环境 HTTP API 验收 -测试管理员: ${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD} - 用法: node scripts/admin-test-env-acceptance.mjs [options] 选项: - --gateway Gateway 地址,默认 ${DEFAULT_GATEWAY} - --overseas-gateway 海外 Gateway 地址,默认 ${DEFAULT_OVERSEAS_GATEWAY} - --username 管理员账号,默认 ${DEFAULT_USERNAME} - --password 管理员密码,默认使用上方测试密码 + --gateway 必填,国内 Gateway 地址 + --overseas-gateway 四期海外模块必填,海外 Gateway 地址 + --username 必填,管理员账号 + --password 必填,管理员密码 --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} --output JSON 报告路径,默认 reports/ --help 显示帮助 -环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`) +也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, +ADMIN_TEST_WEIGHT_IMAGE_PATH`) } export function parseArguments(argv) { @@ -182,15 +180,26 @@ export function parseArguments(argv) { } } return { - gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || DEFAULT_GATEWAY).replace(/\/$/, ''), - overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || DEFAULT_OVERSEAS_GATEWAY).replace(/\/$/, ''), - username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME, - password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || DEFAULT_PASSWORD, + gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''), + overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''), + username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '', + password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '', modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)), output: values.get('output') || '', } } +export function validateExecutionOptions(options) { + for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) { + if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`) + } + if (options.modules.some((module) => module.startsWith('phase4-overseas-')) + && (!options.overseasGateway || options.overseasGateway.trim() === '')) { + throw new Error('四期海外模块缺少必填执行参数: overseas-gateway') + } + return options +} + function own(value, field) { return value !== null && typeof value === 'object' && Object.hasOwn(value, field) } function hasFields(value, fields) { return fields.every((field) => own(value, field)) } function numberEquals(actual, expected) { return Math.abs(Number(actual) - Number(expected)) < 0.0005 } @@ -383,7 +392,13 @@ export class AcceptanceRun { } addCoverageGap(module, scenario, name, reason, category = 'not-implemented', metadata = {}) { - this.coverageGaps.push({ module, scenario, name, reason, category, ...metadata }) + const nonBlocking = new Set(['device', 'external-third-party', 'visual']) + const blocking = metadata.blocking ?? !nonBlocking.has(category) + this.coverageGaps.push({ module, scenario, name, reason, category, blocking, ...metadata }) + if (blocking) { + this.addStep(module, `覆盖阻断:${name}`, null, false, reason, + { scenario, deploymentBlocker: true }) + } } apiSuccess(module, name, response, evidence = '', options = {}) { @@ -757,9 +772,9 @@ export class AcceptanceRun { } } - async preparePhase3LoadingFixture() { + async preparePhase3LoadingFixture(suffix = '') { const originalMarker = this.marker - const fixtureMarker = `${originalMarker}-LOAD` + const fixtureMarker = `${originalMarker}-LOAD${suffix}` const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount'] @@ -798,7 +813,7 @@ export class AcceptanceRun { internalRemark: fixtureOrder.internalRemark, cargoRemark: fixtureOrder.cargoRemark, } : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true } - const incrementalMarker = `${originalMarker}-LOAD-INC` + const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC` this.marker = incrementalMarker this.context.phase3LoadingRequireSharedOverflow = false this.context.phase3LoadingIncrementalPackageCount = 2 @@ -834,6 +849,59 @@ export class AcceptanceRun { } } + async runPhase4ProducerVariant(label, destinationType) { + const matrixMarker = this.marker + const variantMarker = `${matrixMarker}-P4-${label}` + const contextKeys = ['orderDestinationType', 'phase4ProducerMatrixMarker'] + const createdKeys = ['phase3Loading', 'phase3LoadingCompletedProducer'] + const contextSnapshot = new Map(contextKeys.map((key) => [key, { + present: Object.hasOwn(this.context, key), value: this.context[key], + }])) + const createdSnapshot = new Map(createdKeys.map((key) => [key, { + present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], + }])) + let restoreFixture = null + this.marker = variantMarker + this.context.orderDestinationType = destinationType + this.context.phase4ProducerMatrixMarker = matrixMarker + try { + restoreFixture = await this.preparePhase3LoadingFixture() + const loading = await import(new URL('./admin-test-env-modules/phase3-loading.mjs', import.meta.url)) + await this.executeModule(`phase3-loading-phase4-${label}`, () => loading.run(this)) + const finalize = await import(new URL('./admin-test-env-modules/phase3-loading-finalize.mjs', import.meta.url)) + await this.executeModule(`phase3-loading-finalize-phase4-${label}`, () => finalize.run(this)) + } finally { + if (restoreFixture) restoreFixture() + this.marker = matrixMarker + for (const [key, snapshot] of contextSnapshot) { + if (snapshot.present) this.context[key] = snapshot.value + else delete this.context[key] + } + for (const [key, snapshot] of createdSnapshot) { + if (snapshot.present) this.createdResources[key] = snapshot.value + else delete this.createdResources[key] + } + } + } + + async ensurePhase4PdaProducerMatrix() { + const readiness = () => this.createdResources.phase4InboundProducerFixtures?.readiness || {} + if (Number(readiness().fbaContainerCount || 0) < 1) { + await this.runPhase4ProducerVariant('FBA-PRIMARY', 'FBA') + } + if (Number(readiness().overseasContainerCount || 0) < 1) { + await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS') + } + if (Number(readiness().distinctContainerCount || 0) < 3) { + await this.runPhase4ProducerVariant('SECONDARY', 'FBA') + } + const matrix = this.createdResources.phase4InboundProducerFixtures + this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null, + matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true, + `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}`, + { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } + async disableAvailableLocationsForA0(warehouseId) { const module = 'phase3-loading-fixture' const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true } @@ -912,6 +980,15 @@ export class AcceptanceRun { continue } let restoreFixture = null + if (module === 'phase4-overseas-pda') { + try { + await this.ensurePhase4PdaProducerMatrix() + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + this.addStep('phase4-overseas-pda-fixture', '创建FBA/OVERSEAS/第二柜三期生产矩阵', null, + false, reason, { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) + } + } const ocrIndex = this.options.modules.indexOf('inbound-weight-ocr') if (module === 'inbound-measurement' && ocrIndex >= 0 && ocrIndex < moduleIndex) { try { @@ -1001,7 +1078,7 @@ export class AcceptanceRun { } async function main() { - const options = parseArguments(process.argv.slice(2)) + const options = validateExecutionOptions(parseArguments(process.argv.slice(2))) const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14) const outputPath = resolve(options.output || `reports/admin-test-env-acceptance-${timestamp}.json`) const report = await new AcceptanceRun(options).run() diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 7ea762f81..80a224f18 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -55,7 +55,7 @@ export async function run(context) { | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | | 四期海外仓 HTTP 生命周期 | `phase4-overseas-warehouse.mjs` | `phase4-overseas-warehouse-contract.test.mjs` | -| 四期海外 PDA 入仓 HTTP 生命周期 | `phase4-overseas-pda.mjs`(自动选择未转换尾程单、创建目标仓/库位/托盘并完成入库;完成事实保留审计) | `phase4-overseas-pda-contract.test.mjs` | +| 四期海外 PDA 柜号收货 HTTP 生命周期 | `phase4-overseas-pda.mjs`(客户海外 Token 按柜号执行 candidates/detail/prepare、三角色照片签收、FBA SKU 箱数或 OVERSEAS 逐箱实收、完成与幂等/冲突边界;真实柜、箱唛或托盘前置缺失时 fail-closed) | `phase4-overseas-pda-contract.test.mjs` | | 三四期跨区事件与安全门禁 | `phase4-cross-region-events.mjs`(双 Token Audience 互斥、canonical 权限 403、国内装柜完成到 Lastleg 建单、海外柜完成到 Firstleg 回写、轮询重试/幂等/诊断证据;缺本轮 marker 前置时阻断) | `phase4-cross-region-events-contract.test.mjs` | | 四期海外业务 HTTP 生命周期 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 四期海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs` | `phase4-overseas-master-data-contract.test.mjs` | @@ -64,7 +64,7 @@ export async function run(context) { 表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 -四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块只调用 `/api/pda/v1/last-leg/inbound-plans/**` 完成入仓,不调用出库、派送或内部接口;船节点禁止手工创建任务。 +四期 HTTP 验收使用国内登录响应同时签发的短时海外 Token。总入口通过 `--gateway` 访问国内 Gateway,通过 `--overseas-gateway` 访问海外 Gateway。海外仓、海外业务和海外主数据模块只对带本轮 marker 的资源执行已确认的新增、编辑、状态与导出,并在结束前按契约停用;独立 PDA 模块使用本轮客户账号的海外 Token,只调用 `/api/pda/v1/last-leg/receiving-containers/**` 柜号收货入口,不接受 `customerId`、`planNo` 或分配 ID,也不调用出库、派送或内部接口。收货占用的托盘作为库存事实保留,无法自动取得真实柜、箱唛或托盘时必须形成 coverage gap 和部署阻断,不能按空场景通过;船节点禁止手工创建任务。 ```powershell node scripts/admin-test-env-acceptance.mjs --gateway --overseas-gateway --modules phase4-overseas-warehouse,phase4-overseas-pda,phase4-cross-region-events,phase4-overseas-business,phase4-overseas-master-data,phase4-carrier-nodes diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index c19b7765f..e3707c25e 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -491,6 +491,9 @@ export async function run(context) { boxCode, finalStatus: finalMeasurement?.measurementStatus || null, processStatus: finalMeasurement?.processStatus || null, palletCount: weightEvidence.count, actualGrossWeightKg: weightEvidence.grossWeightKg, + destinationType: context.createdResources?.orders?.destinationType || null, + destinationWarehouseId: context.createdResources?.orders?.destinationWarehouseId || null, + destinationWarehouseCode: context.createdResources?.orders?.destinationWarehouseCode || null, } finishScenario(context, start) } diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index a7a7c3fd8..14ff86e4b 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -20,6 +20,10 @@ function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite function success(response) { return Boolean(response) && response.http === 200 && String(response.body?.code) === '200' } function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } +export function requestedOrderDestinationType(context) { + return String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() === 'OVERSEAS' + ? 'OVERSEAS' : 'FBA' +} export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) { const summary = orderDetail?.summary const task = success(taskResponse) ? taskResponse.body?.data : null @@ -33,12 +37,12 @@ function tomorrow(days = 30) { return date.toISOString().slice(0, 10) } -function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) { +function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType = 'FBA') { return { originWarehouseId: receiving.id, channelCode: channel.channelCode, destinationCode: destination.warehouseCode, - destinationType: 'FBA', + destinationType, destinationWarehouseId: destination.id, destinationName: destination.warehouseName, pricingTaxMode, @@ -56,7 +60,7 @@ function estimateMatches(response, candidate) { } export async function findPricedOrderRoute({ receiving, destinations, channels, estimate, - concurrency = PRICING_PROBE_CONCURRENCY }) { + concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) { const candidates = [] for (const channel of channels) { if (!channel?.priceBookVersionId || !channel?.channelCode || !Array.isArray(channel.pricingTaxModes)) continue @@ -65,7 +69,7 @@ export async function findPricedOrderRoute({ receiving, destinations, channels, for (const destination of destinations) { if (!destination?.id || !destination?.warehouseCode) continue candidates.push({ channel, pricingTaxMode, destination, - request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination) }) + request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType) }) } } } @@ -98,7 +102,7 @@ export async function findPricedOrderRoute({ receiving, destinations, channels, } export async function findPricedOrderRouteAcrossOrigins({ receivings, destinations, loadChannels, estimate, - concurrency = PRICING_PROBE_CONCURRENCY }) { + concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) { const originFailures = [] let attempts = 0 let response = null @@ -115,7 +119,8 @@ export async function findPricedOrderRouteAcrossOrigins({ receivings, destinatio traceId: channelResponse?.traceId ?? 'missing', count: channels.length }) continue } - const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels, estimate, concurrency }) + const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels, + estimate, concurrency, destinationType }) attempts += result.attempts response = result.response || response if (result.priced) return { receiving, ...result, originFailures } @@ -196,19 +201,21 @@ async function assertProductImageDownload(context, merchantToken, fileId) { } async function loadMasterData(context, merchantToken) { + const destinationType = requestedOrderDestinationType(context) const domesticResponse = await request(context, 'GET', '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', undefined, merchantToken) const domesticPage = context.apiSuccess(MODULE, 'Client 查询国内收货仓', domesticResponse) context.assert(MODULE, '存在可用国内收货仓', domesticResponse, records(domesticPage).length > 0, `count=${records(domesticPage).length}`) - const fbaResponse = await request(context, 'GET', - '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', undefined, merchantToken) - const fbaPage = context.apiSuccess(MODULE, 'Client 查询 FBA 目的仓', fbaResponse) - context.assert(MODULE, '存在可用 FBA 目的仓', fbaResponse, records(fbaPage).length > 0, - `count=${records(fbaPage).length}`) + const destinationResponse = await request(context, 'GET', + `/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`, + undefined, merchantToken) + const destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse) + context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse, + records(destinationPage).length > 0, `count=${records(destinationPage).length}`) const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE') - const destinations = records(fbaPage) + const destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE') if (receivings.length === 0 || destinations.length === 0) throw new Error('缺少创建测试订单所需的收货仓或目的仓') const pricingResult = await findPricedOrderRouteAcrossOrigins({ @@ -217,6 +224,7 @@ async function loadMasterData(context, merchantToken) { `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, undefined, merchantToken), estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), + destinationType, }) const priced = pricingResult.priced const receiving = pricingResult.receiving @@ -225,7 +233,7 @@ async function loadMasterData(context, merchantToken) { ? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination.warehouseCode}, attempts=${pricingResult.attempts}` : originPricingFailureEvidence(pricingResult), CONTRACT_OPTIONS) - if (!priced) throw new Error('当前 ACTIVE 国内收货仓、渠道、报价税制和FBA目的仓没有可计价组合') + if (!priced) throw new Error(`当前 ACTIVE 国内收货仓、渠道、报价税制和 ${destinationType} 目的仓没有可计价组合`) const partnerResponse = await request(context, 'GET', '/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken) @@ -234,7 +242,7 @@ async function loadMasterData(context, merchantToken) { ? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER')) : null return { receiving, destination: priced.destination, channel: priced.channel, - pricingTaxMode: priced.pricingTaxMode, partner } + pricingTaxMode: priced.pricingTaxMode, destinationType, partner } } function buildPayload(context, profile, master, productImageFileId) { @@ -254,9 +262,10 @@ function buildPayload(context, profile, master, productImageFileId) { channelCode: master.channel.channelCode, channelName: master.channel.channelName, carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION', pricingTaxMode: master.pricingTaxMode, containerMode: 'LCL', - customsInspection: false, destinationType: 'FBA', - amazonWarehouseCode: master.destination.warehouseCode, - shipmentId, referenceId, + customsInspection: false, destinationType: master.destinationType, + amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination.warehouseCode : null, + shipmentId: master.destinationType === 'FBA' ? shipmentId : null, + referenceId: master.destinationType === 'FBA' ? referenceId : null, sender: { companyName, contactName: '订单验收联系人', phone: '13800138000', email: 'order-acceptance@example.com', countryCode: 'CN', province: '广东省', @@ -279,7 +288,9 @@ function buildPayload(context, profile, master, productImageFileId) { material: '塑料 / Plastic', usage: '接口验收 / API acceptance', model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO', productLengthCm: 12 + index, productWidthCm: 8 + index, productHeightCm: 6 + index, - shipmentId, referenceId, upcNo: `${Date.now()}${index}`.slice(-12), + shipmentId: master.destinationType === 'FBA' ? shipmentId : null, + referenceId: master.destinationType === 'FBA' ? referenceId : null, + upcNo: `${Date.now()}${index}`.slice(-12), productImageFileId, dangerousGoods: false, cargoRemark: `${context.marker} boxType=${box.boxType}`, } @@ -387,6 +398,9 @@ export async function run(context) { marker: context.marker, tenantId, orderId, productImageFileId, boxTypes: BOXES.map((box) => box.boxType), inboundId: null, receivingWarehouseId: id(master.receiving.id), + destinationType: master.destinationType, + destinationWarehouseId: id(master.destination.id), + destinationWarehouseCode: master.destination.warehouseCode, } const replayResponse = await request(context, 'POST', createPath, payload, merchantToken, diff --git a/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs b/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs index 2b85a49fb..2dcc9aada 100644 --- a/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-dispatch-gate.mjs @@ -5,6 +5,9 @@ const BASE = '/api/platform/v1/first-leg/outbound-dispatches' export async function run(context) { const fixture = context.createdResources?.phase3OutboundDispatch if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的国内出仓门禁前置', null, false, + `inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '订舱/拖车/报关/PDA/五类证据统一硬门禁', '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') return diff --git a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs index f7085d98d..9639671a9 100644 --- a/scripts/admin-test-env-modules/phase3-evidence-gate.mjs +++ b/scripts/admin-test-env-modules/phase3-evidence-gate.mjs @@ -14,6 +14,9 @@ export async function run(context) { const warehouseId = id(fixture?.warehouseId) if (!tenantId || !inboundId || !taskId || !loadingPlanId || !containerBookingId || !warehouseId || fixture.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的五证据门禁前置', null, false, + `tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, taskId=${taskId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '五类证据多图追加、作废审计和有效证据门禁', '本轮未取得带 taskId/inboundId/loadingPlanId/containerBookingId 的 phase3-loading marker 隔离证据 fixture,未使用历史资源', 'fixture') return @@ -44,6 +47,8 @@ export async function run(context) { evidenceReadable, `processStatus=${inbound?.summary?.processStatus || 'missing'}, taskWorkflow=${task?.workflowStatus || 'missing'}`, OPTIONS) if (!evidenceReadable) { + context.addStep(MODULE, '装柜证据读取前置必须由服务端进入 LOADING', inboundResponse, false, + `processStatus=${inbound?.summary?.processStatus || 'missing'};不能以 AWAITING_REVIEW 或 coverageGap 代替装柜阶段事实`, OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '服务端装柜阶段前不请求证据', `taskId=${taskId}, inboundId=${inboundId}, loadingPlanId=${loadingPlanId}, containerBookingId=${containerBookingId}, processStatus=${inbound?.summary?.processStatus || 'missing'};AWAITING_REVIEW 不作为证据开放条件`, 'deployment-blocker') return diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs new file mode 100644 index 000000000..9f2107dbf --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const read = (name) => readFileSync(resolve(here, name), 'utf8') + +test('phase3 downstream modules turn missing current-marker prerequisites into deployment blockers', () => { + const expectations = new Map([ + ['phase3-loading-model-bridge.mjs', '取得同 marker 的预装与柜次桥接前置'], + ['phase3-pda-convergence.mjs', '取得同 marker 的 PDA 收敛前置'], + ['phase3-evidence-gate.mjs', '取得同 marker 的五证据门禁前置'], + ['phase3-outbound-dispatch.mjs', '取得同 marker 的 LOADED 国内出仓前置'], + ['phase3-outbound-aggregate.mjs', '取得同 marker 的出仓主聚合前置'], + ['phase3-dispatch-gate.mjs', '取得同 marker 的国内出仓门禁前置'], + ['phase3-waiting-for-loading.mjs', '取得同 marker 的 WAITING_FOR_LOADING 前置'], + ]) + for (const [file, step] of expectations) { + const source = read(file) + assert.match(source, /deploymentBlocker: true/, file) + assert.match(source, new RegExp(`addStep\\(MODULE, '${step}'[\\s\\S]{0,500}false`), file) + assert.match(source, /fixtureMarker=.*runMarker=/, file) + } +}) + +test('phase3 inventory mutation and downstream gates cannot pass as coverage gaps', () => { + const loading = read('phase3-loading.mjs') + const convergence = read('phase3-pda-convergence.mjs') + const dispatch = read('phase3-outbound-dispatch.mjs') + const aggregate = read('phase3-outbound-aggregate.mjs') + assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/) + assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/) + assert.match(dispatch, /国内出仓八项门禁必须在同轮全部通过[\s\S]{0,300}false/) + assert.match(aggregate, /统一时间线必须包含报关与三期船期历史[\s\S]{0,300}false/) +}) + +test('outbound lifecycle checks replay, hash conflict, stale version and final reread', () => { + const source = read('phase3-outbound-dispatch.mjs') + for (const step of [ + 'confirm-dispatch 幂等重放', + '相同 Idempotency-Key 不同请求拒绝', + 'reverse-dispatch 幂等重放', + 'reverse-dispatch 同键异参返回 A0409', + '撤销后旧版本再次确认返回 A0409', + '负向与幂等验证后回查出仓聚合', + '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', + ]) assert.match(source, new RegExp(step)) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs index d08a32442..c48a31309 100644 --- a/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-dynamic-final-contract.test.mjs @@ -79,7 +79,7 @@ test('HTTP chain refreshes after stale inventory and keeps one stable PDA identi }) test('downstream finalizer uses the frozen truck-coupled complete contract', () => { - assert.match(convergence, /summary\?\.status === 'LOADING'/) + assert.match(convergence, /loadingPlan\?\.status === 'LOADING'/) assert.match(convergence, /!operations\.some\(\(item\) => item\.actionType === 'COMPLETE_LOADING'\)/) assert.match(customs, /\['AWAITING_REVIEW', 'LOADED'\]/) assert.match(customs, /ensureReleasedOceanBooking/) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 4a81d6c8a..0c7b0f567 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -27,6 +27,35 @@ export function buildFinalizeResource(fixture, finalData, completeResponse) { } } +export function collectPhase4InboundProducerFixture(previous, producer) { + const existing = previous?.marker === producer?.marker && Array.isArray(previous?.containers) + ? previous.containers : [] + const containers = [...existing.filter((item) => item.eventKey !== producer.eventKey), producer] + const byDestinationType = Object.fromEntries(['FBA', 'OVERSEAS'].map((destinationType) => [ + destinationType, + containers.filter((item) => item.destinationType === destinationType), + ])) + const distinctContainerCount = new Set(containers.map((item) => item.containerNo).filter(Boolean)).size + return { + marker: producer.marker, + containers, + byDestinationType, + readiness: { + fbaContainerCount: byDestinationType.FBA.length, + overseasContainerCount: byDestinationType.OVERSEAS.length, + distinctContainerCount, + readyForPhase4Pda: byDestinationType.FBA.length >= 1 + && byDestinationType.OVERSEAS.length >= 1 + && distinctContainerCount >= 3, + }, + requiredEntryOrchestration: [ + 'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + 'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + 'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + ], + } +} + async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { @@ -323,4 +352,27 @@ export async function run(context) { settlementMode, settledOrderId: id(loadedSlice?.childOrderId) || orderId, } + const producer = { + marker: context.context?.phase4ProducerMatrixMarker || context.marker, + eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', + eventVersion: 2, + eventKey: `FIRSTLEG_LOADING_COMPLETED:${loadingPlanId}:${tenantId}:2`, + resourceKey: `${context.marker}:${fixture.destinationType || 'UNKNOWN'}:${fixture.containerNo || containerBookingId}`, + tenantId, + aggregateType: 'LOADING_PLAN', + aggregateId: loadingPlanId, + loadingPlanId, + containerBookingId, + sourceOrderId: orderId, + sourceInboundId: inboundId, + destinationType: fixture.destinationType || null, + destinationWarehouseId: id(fixture.destinationWarehouseId), + destinationWarehouseCode: fixture.destinationWarehouseCode || null, + containerNo: fixture.containerNo || null, + sourceStatus: 'LOADED', + verification: 'PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP', + } + context.createdResources.phase3LoadingCompletedProducer = producer + context.createdResources.phase4InboundProducerFixtures = collectPhase4InboundProducerFixture( + context.createdResources.phase4InboundProducerFixtures, producer) } diff --git a/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs b/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs index a749c8b50..bbe1da56d 100644 --- a/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-model-bridge.mjs @@ -32,6 +32,9 @@ export async function run(context) { const tenantId = id(fixture?.tenantId); const inboundId = id(fixture?.inboundId) const loadingPlanId = id(fixture?.loadingPlanId) if (!tenantId || !inboundId || !loadingPlanId || fixture?.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的预装与柜次桥接前置', null, false, + `tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '入库单级预装与柜次级智能装柜串联', '本轮未取得同一 marker 的预装/柜次计划关联 fixture,未使用历史资源', 'fixture', { activation: { state: 'pending-fixture', assertions: ['双向关联可查', '双版本冲突原子回滚', '终态门禁一致'] } }) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 42923a2f0..0b64243f8 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -101,9 +101,10 @@ export async function run(context) { const source = fixture(context) const tenantId = id(source?.tenantId) const inboundId = id(source?.inboundId) - if (!tenantId || !inboundId) { + if (!tenantId || !inboundId || source?.marker !== context.marker) { context.addStep(MODULE, '取得本轮 WAIT_SPLIT 隔离入库单', null, false, - '未找到 orders/inbound-measurement 本轮隔离 fixture,未使用历史入库单', OPTIONS) + `未找到 orders/inbound-measurement 本轮隔离 fixture,未使用历史入库单;fixtureMarker=${source?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) return } @@ -697,6 +698,8 @@ export async function run(context) { effectiveBatchPayload = { ...batchPayload, inventorySnapshotToken: refreshedInventory.snapshotToken } } } else { + context.addStep(MODULE, '自动制造库存变化并验证旧 snapshot token A0409', null, false, + `本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用没有可安全换绑的 A0 临时占用;不得以 coverageGap 作为通过`, OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试', `inventory-snapshot 已真实返回 taskId=${canonicalTaskId}、warehouseId=${warehouseId} 并验证稳定 token/version=${inventorySnapshotVerified};本轮 ${inventoryInbound?.resources?.length ?? 0} 条占用均为普通库位,当前唯一平台代理库存变更入口只允许 A0 临时占用换绑,不能安全改动普通占用或调用内部 Warehouse 接口`, 'fixture') @@ -1159,6 +1162,9 @@ export async function run(context) { context.createdResources.phase3Loading = { tenantId, orderId: id(source?.orderId), inboundId, preloadPlanId: planId, + destinationType: initial?.summary?.destinationType || source?.destinationType || null, + destinationWarehouseId: id(initial?.summary?.destinationWarehouseId || source?.destinationWarehouseId), + destinationWarehouseCode: initial?.summary?.destinationCode || source?.destinationWarehouseCode || null, evidenceIds: evidenceItems.map((item) => id(item.id)), voidedEvidenceId: id(voidTarget?.id), evidenceTypes, sealNo: `SEAL-${suffix}`, missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId, diff --git a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs index 833587cb3..aee6aa3d9 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-aggregate.mjs @@ -16,6 +16,9 @@ function ordered(items) { export async function run(context) { const fixture = context.createdResources?.phase3OutboundDispatch if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的出仓主聚合前置', null, false, + `inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '出仓单/柜次主聚合与整体时间线', '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') return @@ -49,4 +52,6 @@ export async function run(context) { context.addCoverageGap(MODULE, OPTIONS.scenario, '报关与船期历史并入统一时间线', '当前 OutboundDispatchReadMapper 时间线仅聚合入库、装柜计划、PDA、证据、订舱、拖车和出仓历史,未公开报关与船期历史投影', 'contract-gap') + context.addStep(MODULE, '统一时间线必须包含报关与三期船期历史', response, false, + '当前服务端聚合未公开 CUSTOMS 与 SAILING_SCHEDULE 时间线,不能以 coverageGap 计为自动验收通过', OPTIONS) } diff --git a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs index 7a83ff4df..30d553185 100644 --- a/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase3-outbound-dispatch.mjs @@ -36,6 +36,9 @@ export async function run(context) { const containerBookingId = id(fixture?.containerBookingId) if (!inboundId || !containerBookingId || !['COMPLETED', 'LOADED'].includes(fixture?.finalStatus) || fixture?.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的 LOADED 国内出仓前置', null, false, + `inboundId=${inboundId || 'missing'}, containerBookingId=${containerBookingId || 'missing'}, finalStatus=${fixture?.finalStatus || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, '国内出仓主聚合真实闭环', '本轮未取得 marker 隔离的柜次装柜 fixture,未使用历史资源', 'fixture') return @@ -85,6 +88,9 @@ export async function run(context) { { 'Idempotency-Key': confirmKey }) context.addStep(MODULE, '门禁未通过时 confirm-dispatch fail-closed', blocked, rejected(blocked), `HTTP=${blocked.http}, code=${codeOf(blocked)}`, OPTIONS) + context.addStep(MODULE, '国内出仓八项门禁必须在同轮全部通过', gateResponse, false, + `failed=${gateItems.filter((item) => !item.passed).map((item) => `${item.code}:${item.actual || 'missing'}`).join(',') || 'gate.passed=false'}`, + OPTIONS) for (const item of gateItems.filter((value) => !value.passed)) { context.addCoverageGap(MODULE, OPTIONS.scenario, item.name || item.code, `${item.code}: actual=${item.actual || 'missing'}, requirement=${item.requirement || 'missing'}`, @@ -124,6 +130,37 @@ export async function run(context) { reversed?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' && reversed?.aggregate?.inboundStatus === 'LOADED' && reversed?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'), `dispatch=${reversed?.aggregate?.dispatchStatus || 'missing'}, inbound=${reversed?.aggregate?.inboundStatus || 'missing'}`, OPTIONS) + const reverseReplayResponse = await request(context, 'POST', `${BASE}/reverse-dispatch`, { + inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: context.marker, + }, { 'Idempotency-Key': reverseKey }) + const reverseReplay = context.apiSuccess(MODULE, 'reverse-dispatch 幂等重放', reverseReplayResponse, '', OPTIONS) + context.assert(MODULE, '撤销重放保持 READY_FOR_DISPATCH 与同一版本', reverseReplayResponse, + reverseReplay?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' + && Number(reverseReplay?.aggregate?.version) === Number(reversed?.aggregate?.version), + `dispatch=${reverseReplay?.aggregate?.dispatchStatus || 'missing'}, version=${reverseReplay?.aggregate?.version ?? 'missing'}`, OPTIONS) + + const reverseHashConflict = await request(context, 'POST', `${BASE}/reverse-dispatch`, { + inboundId, containerBookingId, expectedVersion: Number(confirmed.aggregate.version), remark: 'forged', + }, { 'Idempotency-Key': reverseKey }) + context.addStep(MODULE, 'reverse-dispatch 同键异参返回 A0409', reverseHashConflict, + reverseHashConflict.http === 200 && codeOf(reverseHashConflict) === 'A0409', + `HTTP=${reverseHashConflict.http}, code=${codeOf(reverseHashConflict)}`, OPTIONS) + + const staleConfirmResponse = await request(context, 'POST', `${BASE}/confirm-dispatch`, actionBody, + { 'Idempotency-Key': `${context.marker}-outbound-confirm-stale`.slice(0, 128) }) + context.addStep(MODULE, '撤销后旧版本再次确认返回 A0409', staleConfirmResponse, + staleConfirmResponse.http === 200 && codeOf(staleConfirmResponse) === 'A0409', + `HTTP=${staleConfirmResponse.http}, code=${codeOf(staleConfirmResponse)}`, OPTIONS) + + const finalDetailResponse = await request(context, 'GET', `${BASE}/detail?${selector}`) + const finalDetail = context.apiSuccess(MODULE, '负向与幂等验证后回查出仓聚合', finalDetailResponse, '', OPTIONS) + context.assert(MODULE, '出仓聚合保持 READY_FOR_DISPATCH、LOADED 和确认撤销历史', finalDetailResponse, + finalDetail?.aggregate?.dispatchStatus === 'READY_FOR_DISPATCH' + && finalDetail?.aggregate?.inboundStatus === 'LOADED' + && Number(finalDetail?.aggregate?.version) === Number(reversed?.aggregate?.version) + && finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'CONFIRM_DISPATCH') + && finalDetail?.timeline?.some?.((item) => item.module === 'OUTBOUND_DISPATCH' && item.action === 'REVERSE_DISPATCH'), + `dispatch=${finalDetail?.aggregate?.dispatchStatus || 'missing'}, inbound=${finalDetail?.aggregate?.inboundStatus || 'missing'}, version=${finalDetail?.aggregate?.version ?? 'missing'}`, OPTIONS) if (reversed?.aggregate) { context.createdResources.phase3OutboundDispatch = { marker: context.marker, inboundId, containerBookingId, diff --git a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs index f7e7e6232..40a7d541c 100644 --- a/scripts/admin-test-env-modules/phase3-pda-convergence.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-convergence.mjs @@ -27,6 +27,9 @@ export async function run(context) { const warehouseId = id(fixture?.warehouseId); const loadingPlanId = id(fixture?.loadingPlanId) if (!tenantId || !inboundId || !taskId || !containerNo || !warehouseId || !loadingPlanId || fixture?.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的 PDA 收敛前置', null, false, + `tenantId=${tenantId || 'missing'}, inboundId=${inboundId || 'missing'}, taskId=${taskId || 'missing'}, loadingPlanId=${loadingPlanId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 柜次拣货与入库单汇总拣货收敛', '本轮未取得 marker 隔离的柜次 PDA 与入库汇总 fixture,未使用历史资源', 'fixture') return @@ -76,6 +79,10 @@ export async function run(context) { const operationsResponse = await request(context, 'GET', `${taskPath}/operations`) const operations = context.apiSuccess(MODULE, '查询柜次 PDA 不可变操作历史', operationsResponse, '', OPTIONS) + const batchOperations = Array.isArray(operations) + ? operations.filter((item) => item.actionType === 'BATCH' + && Boolean(item.operatorId) && Boolean(item.createdAt)) : [] + const expectedBatchCount = fixture?.dynamicIncrementVerified === true ? 2 : 1 context.assert(MODULE, '最终完成前保留领取操作且不伪造复核或完成历史', operationsResponse, Array.isArray(operations) && operations.some((item) => item.actionType === 'CLAIM' && Boolean(item.operatorId) && Boolean(item.createdAt)) @@ -84,9 +91,9 @@ export async function run(context) { `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) context.assert(MODULE, 'PDA 原子批次完成审计包含 BATCH 操作', operationsResponse, - Array.isArray(operations) && operations.filter((item) => item.actionType === 'BATCH' - && Boolean(item.operatorId) && Boolean(item.createdAt)).length >= 1, - `actions=${Array.isArray(operations) ? operations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) + batchOperations.length === expectedBatchCount + && new Set(batchOperations.map((item) => item.operationId)).size === expectedBatchCount, + `expectedBatchCount=${expectedBatchCount}, actualBatchCount=${batchOperations.length}, operationIds=${batchOperations.map((item) => item.operationId || 'missing').join(',')}`, OPTIONS) const forged = await request(context, 'POST', `/api/platform/v1/inbounds/${inboundId}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, { expectedVersion: Number(projection?.version), expectedCount: 999, pickedCount: 999, remark: 'forged' }, @@ -95,21 +102,31 @@ export async function run(context) { `HTTP=${forged.http}, code=${codeOf(forged)}`, OPTIONS) if (fixture?.atomicBatchVerified !== true) { + context.addStep(MODULE, 'PDA 原子批次成功且同键重放零新增', null, false, + 'phase3-loading 未发布 atomicBatchVerified=true;不能以 coverageGap 代替真实批次与幂等断言', OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 原子批量成功与批次幂等闭环', '原子 batch 端点已部署,但本轮合法整批提交或同键重放未通过真实 HTTP 断言;保留真实失败证据', 'deployment-blocker') } if (fixture?.inventorySnapshotVerified !== true) { + context.addStep(MODULE, 'PDA 实时库存快照身份与合法 token 提交', null, false, + 'phase3-loading 未发布 inventorySnapshotVerified=true;不能以 coverageGap 代替真实库存快照断言', OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 实时库存快照身份、稳定性与合法 token 提交', 'inventory-snapshot 端点已公开,但本轮未完成归属、稳定 token/version、数量及合法 batch 的联合真实 HTTP 验证', 'deployment-blocker') } if (fixture?.inventoryMutationVerified !== true && !context.coverageGaps.some((gap) => gap.name === 'PDA 真实库存变化后的旧快照冲突与刷新重试')) { + context.addStep(MODULE, 'PDA 真实库存变化后旧 token A0409 与刷新重试', null, false, + '本轮未取得 inventoryMutationVerified=true;无法安全制造库存变化时必须作为部署阻断失败', OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA 真实库存变化后的旧快照冲突与刷新重试', 'inventory-snapshot 已公开;本轮没有可通过平台代理安全变更的 A0 临时占用,不能用普通库位改写或内部 Warehouse 接口伪造库存变化', 'fixture') } + if (fixture?.dynamicIncrementVerified !== true) { + context.addStep(MODULE, 'LOADING 增量重开、刷新续拣与已拣不可删减', null, false, + 'phase3-loading 未发布 dynamicIncrementVerified=true;不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护', OPTIONS) + } context.addCoverageGap(MODULE, OPTIONS.scenario, 'PDA Android/HBuilderX 真机完整闭环', '原子 batch 真实 HTTP 契约已自动化;扫码、弱网恢复、在线草稿和设备交互仍需 Android/HBuilderX 真机验收', 'device') diff --git a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs index bc7df2e3a..653a53ab0 100644 --- a/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-pda-pending-consistency-contract.test.mjs @@ -25,6 +25,9 @@ test('AWAITING_REVIEW derives zero client actions and never needs a repeated bat assert.match(convergence, /AWAITING_REVIEW 已全拣任务 actions 为空且不再提交 batch/) assert.match(convergence, /AWAITING_REVIEW 可进入装柜取证只读入口/) assert.match(convergence, /filter\(\(item\) => item\.actionType === 'BATCH'/) + assert.match(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified === true \? 2 : 1/) + assert.match(convergence, /new Set\(batchOperations\.map\(\(item\) => item\.operationId\)\)\.size === expectedBatchCount/) + assert.match(convergence, /LOADING 增量重开、刷新续拣与已拣不可删减/) assert.doesNotMatch(convergence, /pendingActions[\s\S]{0,240}request\(context, 'POST'/) }) diff --git a/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs new file mode 100644 index 000000000..25c535b2a --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-phase4-producer-fixture-contract.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +import { requestedOrderDestinationType } from './orders.mjs' +import { collectPhase4InboundProducerFixture } from './phase3-loading-finalize.mjs' + +const here = dirname(fileURLToPath(import.meta.url)) +const read = (name) => readFileSync(resolve(here, name), 'utf8') + +test('order fixture defaults to FBA and accepts only the explicit OVERSEAS variant', () => { + assert.equal(requestedOrderDestinationType({ context: {} }), 'FBA') + assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'overseas' } }), 'OVERSEAS') + assert.equal(requestedOrderDestinationType({ context: { orderDestinationType: 'PRIVATE' } }), 'FBA') + const orders = read('orders.mjs') + assert.match(orders, /warehouseType=\$\{encodeURIComponent\(destinationType\)\}/) + assert.match(orders, /destinationType: master\.destinationType/) + assert.match(orders, /destinationType,\s*\n\s*\}\)/) + assert.doesNotMatch(orders, /const fbaResponse =/) +}) + +test('producer collector requires one FBA, one OVERSEAS and a third distinct container', () => { + const common = { marker: 'RUN-1', eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED', sourceStatus: 'LOADED' } + const fba = { ...common, eventKey: 'FBA-EVENT', destinationType: 'FBA', containerNo: 'FBA-001' } + const overseasPrimary = { ...common, eventKey: 'OS-EVENT-1', destinationType: 'OVERSEAS', containerNo: 'OS-001' } + const secondary = { ...common, eventKey: 'FBA-EVENT-2', destinationType: 'FBA', containerNo: 'FBA-002' } + const one = collectPhase4InboundProducerFixture(null, fba) + const two = collectPhase4InboundProducerFixture(one, overseasPrimary) + const ready = collectPhase4InboundProducerFixture(two, secondary) + assert.equal(one.readiness.readyForPhase4Pda, false) + assert.equal(two.readiness.readyForPhase4Pda, false) + assert.equal(ready.readiness.readyForPhase4Pda, true) + assert.equal(ready.readiness.fbaContainerCount, 2) + assert.equal(ready.readiness.overseasContainerCount, 1) + assert.equal(ready.readiness.distinctContainerCount, 3) + assert.equal(ready.byDestinationType.FBA[1].eventKey, 'FBA-EVENT-2') +}) + +test('same event key is idempotently replaced and another marker starts a new matrix', () => { + const first = collectPhase4InboundProducerFixture(null, + { marker: 'RUN-1', eventKey: 'EVENT-1', destinationType: 'FBA', containerNo: 'FBA-001' }) + const replay = collectPhase4InboundProducerFixture(first, + { marker: 'RUN-1', eventKey: 'EVENT-1', destinationType: 'FBA', containerNo: 'FBA-001' }) + assert.equal(replay.containers.length, 1) + const nextRun = collectPhase4InboundProducerFixture(replay, + { marker: 'RUN-2', eventKey: 'EVENT-2', destinationType: 'OVERSEAS', containerNo: 'OS-002' }) + assert.equal(nextRun.containers.length, 1) + assert.equal(nextRun.marker, 'RUN-2') + assert.deepEqual(nextRun.requiredEntryOrchestration, [ + 'FBA: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + 'OVERSEAS-PRIMARY: orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + 'SECONDARY(FBA|OVERSEAS): orders -> inbound-measurement -> phase3-loading -> phase3-customs -> phase3-loading-finalize', + ]) +}) + +test('measurement and loading pass destination identity into the LOADED producer key', () => { + const measurement = read('inbound-measurement.mjs') + const loading = read('phase3-loading.mjs') + const finalize = read('phase3-loading-finalize.mjs') + assert.match(measurement, /destinationType: context\.createdResources\?\.orders\?\.destinationType/) + assert.match(loading, /destinationType: initial\?\.summary\?\.destinationType \|\| source\?\.destinationType/) + assert.match(finalize, /destinationType: fixture\.destinationType \|\| null/) + assert.match(finalize, /resourceKey: `\$\{context\.marker\}:\$\{fixture\.destinationType \|\| 'UNKNOWN'\}:\$\{fixture\.containerNo \|\| containerBookingId\}`/) + assert.match(finalize, /phase4InboundProducerFixtures = collectPhase4InboundProducerFixture/) +}) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 6aa4e44af..b087ab7bb 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -627,3 +627,14 @@ test('loaded boundary acceptance proves evidence and transport immutability with assert.match(finalize, /afterHistorySignature === historySignature/) assert.match(finalize, /afterPlanSignature === immutablePlanSignature/) }) + +test('finalize publishes a current-marker producer correlation for cross-region verification', () => { + const finalize = source('./phase3-loading-finalize.mjs') + assert.match(finalize, /const producer = \{/) + assert.match(finalize, /createdResources\.phase3LoadingCompletedProducer = producer/) + assert.match(finalize, /marker: context\.context\?\.phase4ProducerMatrixMarker \|\| context\.marker/) + assert.match(finalize, /eventType: 'FIRST_LEG_LOADING_PLAN_COMPLETED'/) + assert.match(finalize, /eventKey: `FIRSTLEG_LOADING_COMPLETED:\$\{loadingPlanId\}:\$\{tenantId\}:2`/) + assert.match(finalize, /sourceStatus: 'LOADED'/) + assert.match(finalize, /PRODUCED_IN_COMPLETE_TRANSACTION_PENDING_BRIDGE_CONSUMER_HTTP/) +}) diff --git a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs index 59b488555..21789ecc5 100644 --- a/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-waiting-for-loading.mjs @@ -5,6 +5,9 @@ const BASE = '/api/platform/v1/first-leg/outbound-dispatches' export async function run(context) { const fixture = context.createdResources?.phase3OutboundDispatch if (!fixture?.inboundId || !fixture?.containerBookingId || fixture.marker !== context.marker) { + context.addStep(MODULE, '取得同 marker 的 WAITING_FOR_LOADING 前置', null, false, + `inboundId=${fixture?.inboundId || 'missing'}, containerBookingId=${fixture?.containerBookingId || 'missing'}, fixtureMarker=${fixture?.marker || 'missing'}, runMarker=${context.marker}`, + OPTIONS) context.addCoverageGap(MODULE, OPTIONS.scenario, 'WAITING_FOR_LOADING 状态与历史', '本轮未取得 phase3-outbound-dispatch 的 marker 隔离聚合,未使用历史资源', 'fixture') return diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index a83d35208..7ffc77066 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -4,10 +4,16 @@ import test from 'node:test' import { run } from './phase4-overseas-master-data.mjs' const source = readFileSync(new URL('./phase4-overseas-master-data.mjs', import.meta.url), 'utf8') +const controllerSource = [ + '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java', + '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java', + '../../lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java', +].map((path) => readFileSync(new URL(path, import.meta.url), 'utf8')).join('\n') const DOMESTIC_BASE = '/api/platform/v1/warehouse-admin' const OVERSEAS_BASE = '/api/platform/v1/last-leg/master-data' const ok = (data) => ({ http: 200, body: { code: '200', message: 'success', data }, requestId: 'req-p4', traceId: 'trace-p4' }) const conflict = () => ({ http: 200, body: { code: 'A0409', message: 'record version conflict', data: null }, requestId: 'req-conflict' }) +const forbidden = () => ({ http: 403, body: { code: 'A0403', message: 'Access denied', data: null }, requestId: 'req-forbidden' }) function detailQuote(state) { return { @@ -22,21 +28,25 @@ function vehicleWire(state) { return { ...state.vehicle, mileageKm: String(state.vehicle.mileageKm), recordVersion: String(state.vehicle.recordVersion) } } -function fakeContext() { +function fakeContext({ softAssertions = false, refreshEmpty = false } = {}) { const calls = [] const coverageGaps = [] + const steps = [] const marker = 'ADMIN-API-20260821123456789' - const state = { warehouse: null, quote: null, exchange: null, vehicle: null } + const state = { warehouse: null, quote: null, exchange: null, vehicle: null, optionReads: 0, role: null, user: null } const context = { - marker, calls, coverageGaps, createdResources: {}, + marker, calls, coverageGaps, steps, createdResources: {}, + async findAvailablePhone() { return '13800138000' }, apiSuccess(_module, name, response) { assert.equal(response?.http, 200, `${name}: HTTP`) assert.equal(String(response?.body?.code), '200', `${name}: code`) return response.body.data }, - assert(_module, name, _response, condition, evidence) { - assert.equal(Boolean(condition), true, `${name}: ${evidence}`) - return true + assert(_module, name, _response, condition, evidence, options = {}) { + const passed = Boolean(condition) + steps.push({ name, passed, deploymentBlocker: Boolean(options.deploymentBlocker && !passed), evidence }) + if (!passed && !softAssertions) assert.equal(passed, true, `${name}: ${evidence}`) + return passed }, expectConflict(_module, name, response) { assert.equal(response.http, 200, `${name}: HTTP`) @@ -64,12 +74,40 @@ function fakeContext() { state.warehouse.status = 'INACTIVE' return ok(null) } + if (method === 'POST' && path === '/api/platform/v1/iam/roles') { + state.role = { id: '940000000000000001', ...body, status: 'ACTIVE' } + return ok({ ...state.role }) + } + if (method === 'POST' && path === '/api/platform/v1/iam/users') { + state.user = { membershipId: '950000000000000001', ...body, status: 'ACTIVE' } + return ok({ ...state.user }) + } + if (method === 'POST' && path === '/api/auth/login') { + return ok({ accessToken: 'deny-domestic-token', overseasAccessToken: 'deny-overseas-token', overseasExpiresIn: 900 }) + } + if (method === 'PUT' && path === '/api/platform/v1/iam/users/950000000000000001/status') { + state.user.status = body.status + return ok(null) + } + if (method === 'PUT' && path === '/api/platform/v1/iam/roles/940000000000000001/status') { + state.role.status = body.status + return ok(null) + } assert.fail(`unexpected domestic call ${method} ${path}`) }, - async requestOverseas(method, path, body) { - calls.push({ gateway: 'overseas', method, path, body }) + async requestOverseas(method, path, body, extraHeaders = {}, token = 'admin-overseas-token') { + calls.push({ gateway: 'overseas', method, path, body, extraHeaders, tokenKind: token === 'deny-overseas-token' ? 'denied' : 'admin' }) assert.notEqual(method, 'DELETE') + if (token === 'deny-overseas-token') return forbidden() if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) { + state.optionReads += 1 + if (!state.warehouse) return ok([]) + return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode, + warehouseName: state.warehouse.warehouseName, countryCode: 'US' }]) + } + if (method === 'GET' && path === `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) { + state.optionReads += 1 + if (refreshEmpty) return ok([]) return ok([{ warehouseId: '910000000000000001', warehouseCode: state.warehouse.warehouseCode, warehouseName: state.warehouse.warehouseName, countryCode: 'US' }]) } @@ -81,6 +119,10 @@ function fakeContext() { } return ok(detailQuote(state.quote)) } + if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=`)) { + return ok({ records: state.quote ? [{ ...state.quote.summary }] : [], page: 1, size: 20, + total: state.quote ? 1 : 0, pages: state.quote ? 1 : 0 }) + } if (path === `${OVERSEAS_BASE}/overseas-quotes/920000000000000001`) { if (method === 'GET') return ok(detailQuote(state.quote)) if (method === 'PUT') { @@ -100,7 +142,10 @@ function fakeContext() { return ok(detailQuote(state.quote)) } if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=`)) { - return ok({ records: [], page: 1, size: 20, total: 0, pages: 0 }) + const matches = state.exchange && path.includes(`monthFrom=${state.exchange.month}`) + && (!path.includes('status=') || path.includes(`status=${state.exchange.status}`)) + return ok({ records: matches ? [{ ...state.exchange }] : [], page: 1, size: 20, + total: matches ? 1 : 0, pages: matches ? 1 : 0 }) } if (method === 'POST' && path === `${OVERSEAS_BASE}/exchange-rates`) { state.exchange = { ...body, baseCurrency: 'USD', quoteCurrency: 'CNY', status: 'ACTIVE', recordVersion: 0 } @@ -139,13 +184,21 @@ function fakeContext() { state.vehicle = { ...state.vehicle, status: body.status, recordVersion: state.vehicle.recordVersion + 1 } return ok(vehicleWire(state)) } + if (method === 'GET' && path.startsWith(`${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=`)) { + return ok({ records: state.vehicle ? [vehicleWire(state)] : [], page: 1, size: 20, + total: state.vehicle ? 1 : 0, pages: state.vehicle ? 1 : 0 }) + } assert.fail(`unexpected overseas call ${method} ${path}`) }, async requestOverseasBinary(method, path) { calls.push({ gateway: 'overseas-binary', method, path }) assert.equal(method, 'GET') assert.match(path, /\/(?:overseas-quotes|exchange-rates|vehicles)\/export\?/) - return { method, path, http: 200, body: null, bytes: new TextEncoder().encode('id,status\n1,ACTIVE\n'), + let csv = '' + if (path.includes('/overseas-quotes/')) csv = `报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间\r\n${state.quote.summary.id},${state.quote.summary.name},${state.quote.summary.warehouseCode},${state.quote.summary.warehouseName},${state.quote.summary.providerName},USD,2098-01-01,2098-12-31,V1,ACTIVE,2026-08-21\r\n` + else if (path.includes('/exchange-rates/')) csv = `月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间\r\n${state.exchange.month},USD,CNY,${state.exchange.usdToCnyAverage},${state.exchange.status},admin,2026-08-21\r\n` + else csv = `车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n${state.vehicle.id},${state.vehicle.plateNumber},${state.vehicle.model},18.75,101,Carrier,123,OVERSEAS,OWNED,${state.vehicle.status},2026-08-21\r\n` + return { method, path, http: 200, body: null, bytes: new TextEncoder().encode(`\uFEFF${csv}`), contentType: 'text/csv;charset=UTF-8', requestId: 'req-csv' } }, } @@ -160,13 +213,20 @@ test('静态锁定真实双Gateway生命周期、CSV与安全边界', () => { assert.match(source, /context\.requestOverseas\('PATCH'/) assert.match(source, /context\.requestOverseasBinary\('GET'/) assert.match(source, /context\.expectConflict/) + assert.match(source, /warehouse-options\?refresh=true/) + assert.match(source, /空候选不负缓存30分钟/) + assert.match(source, /30分钟正缓存与刷新快照一致/) + assert.match(source, /无quote:list权限访问海外报价被拒绝/) + assert.match(source, /表头与marker内容/) assert.match(source, /finally\s*\{/) assert.match(source, /context\.createdResources\.phase4OverseasMasterData/) - assert.match(source, /context\.addCoverageGap/) + assert.doesNotMatch(source, /context\.addCoverageGap/) assert.match(source, /2090 \+ Math\.floor/) assert.doesNotMatch(source, /\/internal\/|overseas-partners|https?:\/\/|fetch\(|process\.env|request(?:Overseas)?\('DELETE'/) for (const endpoint of ['overseas-quotes/warehouse-options', 'overseas-quotes/export', 'exchange-rates/export', 'vehicles/export']) assert.ok(source.includes(endpoint)) + assert.doesNotMatch(controllerSource, /Idempotency-Key|IdempotencyKey/) + assert.doesNotMatch(source, /Idempotency-Key/) }) test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全部停用', async () => { @@ -186,30 +246,39 @@ test('marker资源完成创建、详情、编辑、冲突、状态、CSV并全 assert.equal(state.quote.summary.status, 'INACTIVE') assert.equal(state.exchange.status, 'INACTIVE') assert.equal(state.vehicle.status, 'INACTIVE') + assert.equal(state.role.status, 'DISABLED') + assert.equal(state.user.status, 'DISABLED') + assert.equal(state.optionReads, 4) assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 3) assert.equal(context.calls.filter((call) => call.method === 'DELETE').length, 0) - assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 7) + assert.equal(context.calls.filter((call) => call.method === 'PUT').length, 9) + assert.equal(context.calls.filter((call) => call.tokenKind === 'denied').length, 1) + assert.equal(context.coverageGaps.length, 0) }) -test('候选空缓存登记覆盖缺口并继续汇率车辆,finally停用国内合作仓', async () => { - const { context, state } = fakeContext() - const original = context.requestOverseas - context.requestOverseas = async (method, path, body) => { - if (method === 'GET' && path.endsWith('/overseas-quotes/warehouse-options')) return ok([]) - return original(method, path, body) - } +test('创建前空候选不形成假PASS,刷新后本轮合作仓进入报价完整生命周期', async () => { + const { context } = fakeContext() + await run(context) + const optionCalls = context.calls.filter((call) => call.path.includes('warehouse-options')) + assert.equal(optionCalls.length, 4) + assert.equal(optionCalls[0].path.endsWith('warehouse-options'), true) + assert.equal(optionCalls[1].path.endsWith('warehouse-options'), true) + assert.equal(optionCalls[2].path.endsWith('warehouse-options?refresh=true'), true) + assert.equal(optionCalls[3].path.endsWith('warehouse-options'), true) + assert.equal(context.createdResources.phase4OverseasMasterData.quote.id, '920000000000000001') + assert.equal(context.coverageGaps.length, 0) +}) + +test('refresh后仍为空时报价前置明确deployment blocker且不产生coverageGap假PASS', async () => { + const { context, state } = fakeContext({ softAssertions: true, refreshEmpty: true }) await run(context) - assert.equal(state.warehouse.status, 'INACTIVE') assert.equal(state.quote, null) + assert.equal(state.warehouse.status, 'INACTIVE') assert.equal(state.exchange.status, 'INACTIVE') assert.equal(state.vehicle.status, 'INACTIVE') - assert.equal(context.createdResources.phase4OverseasMasterData.quote.skipped, true) - assert.equal(context.createdResources.phase4OverseasMasterData.partnerWarehouse.finalStatus, 'INACTIVE') - assert.equal(context.createdResources.phase4OverseasMasterData.exchangeRate.finalStatus, 'INACTIVE') - assert.equal(context.createdResources.phase4OverseasMasterData.vehicle.finalStatus, 'INACTIVE') - assert.equal(context.coverageGaps.length, 1) - assert.equal(context.coverageGaps[0].name, '海外报价完整写生命周期') - assert.equal(context.coverageGaps[0].category, 'deployment-prerequisite') - assert.match(context.coverageGaps[0].reason, /30分钟空缓存/) - assert.equal(context.calls.filter((call) => call.gateway === 'overseas-binary').length, 2) + assert.equal(context.coverageGaps.length, 0) + assert.equal(context.steps.some((step) => step.name === '刷新后新建合作仓立即可见' + && !step.passed && step.deploymentBlocker), true) + assert.equal(context.steps.some((step) => step.name === '海外报价生命周期执行' + && !step.passed && step.deploymentBlocker), true) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index 8e181bae6..3d8c8f90e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -14,13 +14,26 @@ function requireData(context, name, response) { return value } -function assertBinaryCsv(context, name, response) { +function assertBinaryCsv(context, name, response, expectedHeader, expectedTokens) { + const text = response?.bytes instanceof Uint8Array ? new TextDecoder().decode(response.bytes).replace(/^\uFEFF/, '') : '' + const header = text.split(/\r?\n/, 1)[0] context.assert(MODULE, name, response, response?.http === 200 && String(response.contentType || '').toLowerCase().startsWith('text/csv') && response.bytes instanceof Uint8Array && response.bytes.length > 0, `HTTP=${response?.http ?? 0}, contentType=${response?.contentType || 'missing'}, bytes=${response?.bytes?.length || 0}`, BLOCKER) + context.assert(MODULE, `${name}表头与marker内容`, response, + header === expectedHeader && expectedTokens.every((token) => text.includes(String(token))), + `header=${header || 'missing'}, expectedTokens=${expectedTokens.join('|')}`, BLOCKER) +} + +const optionIds = (values) => Array.isArray(values) ? values.map((item) => asId(item?.warehouseId)).filter(Boolean) : [] + +function assertForbidden(context, name, response) { + context.assert(MODULE, name, response, + response?.http === 403 || (response?.http === 200 && ['403', 'A0403'].includes(String(response?.body?.code))), + `HTTP=${response?.http ?? 0}, code=${response?.body?.code ?? 'missing'}`, BLOCKER) } async function safeCleanup(context, name, action) { @@ -97,6 +110,16 @@ async function createAndVerifyPartnerWarehouse(context, resource) { BLOCKER) } +async function captureWarehouseOptionsBeforeCreate(context, resource) { + const response = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) + const options = requireData(context, '创建合作仓前查询报价候选缓存基线', response) + context.assert(MODULE, '合作仓候选基线为规范数组', response, + Array.isArray(options) && options.every((item) => asId(item?.warehouseId) + && item.warehouseCode && item.warehouseName && /^[A-Z]{2}$/.test(item.countryCode)), + `count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER) + resource.partnerWarehouseOptionsBefore = optionIds(options) +} + function quoteCreatePayload(context, warehouseId) { return { name: `${context.marker} 海外报价`, warehouseId, @@ -127,27 +150,25 @@ async function quoteLifecycle(context, resource) { Array.isArray(options) && options.every((item) => asId(item.warehouseId) && item.warehouseCode && item.warehouseName && item.countryCode), `count=${Array.isArray(options) ? options.length : 'invalid'}`, BLOCKER) - if (Array.isArray(options) && options.length === 0) { - resource.quote = { - skipped: true, - reason: 'warehouse-options-empty-cache', - finalStatus: null, - } - context.addCoverageGap(MODULE, BLOCKER.scenario, '海外报价完整写生命周期', - 'warehouse-options 返回空数组;已创建的 OVERSEAS_PARTNER 可能尚未穿透服务端30分钟空缓存。本轮跳过报价且不猜测 warehouseId,继续验收汇率与车辆。', - 'deployment-prerequisite', { - marker: context.marker, - partnerWarehouseId: resource.partnerWarehouse?.id || null, - http: optionsResponse?.http ?? null, - code: optionsResponse?.body?.code ?? null, - requestId: optionsResponse?.requestId ?? null, - traceId: optionsResponse?.traceId ?? null, - }) - return false + const createdId = resource.partnerWarehouse?.id + if (resource.partnerWarehouseOptionsBefore?.length === 0) { + context.assert(MODULE, '空候选不负缓存30分钟', optionsResponse, + optionIds(options).includes(createdId), `createdId=${createdId}, ids=${optionIds(options).join(',') || 'empty'}`, BLOCKER) } - const createdOption = options.find((item) => asId(item.warehouseId) === resource.partnerWarehouse?.id) - const option = createdOption || options[0] - if (!option) throw new Error('海外报价接口未返回可用合作仓候选') + + const refreshResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/overseas-quotes/warehouse-options?refresh=true`) + const refreshed = requireData(context, '强制刷新海外报价合作仓候选', refreshResponse) + const option = Array.isArray(refreshed) ? refreshed.find((item) => asId(item.warehouseId) === createdId) : null + context.assert(MODULE, '刷新后新建合作仓立即可见', refreshResponse, Boolean(option), + `createdId=${createdId}, ids=${optionIds(refreshed).join(',') || 'empty'}`, BLOCKER) + if (!option) throw new Error(`warehouse-options refresh=true 未返回本轮合作仓 ${createdId}`) + + const cachedResponse = await context.requestOverseas('GET', `${OVERSEAS_BASE}/overseas-quotes/warehouse-options`) + const cached = requireData(context, '刷新后读取30分钟正缓存', cachedResponse) + context.assert(MODULE, '30分钟正缓存与刷新快照一致', cachedResponse, + JSON.stringify(cached) === JSON.stringify(refreshed), + `refreshed=${optionIds(refreshed).join(',')}, cached=${optionIds(cached).join(',')}`, BLOCKER) const payload = quoteCreatePayload(context, asId(option.warehouseId)) const createResponse = await context.requestOverseas('POST', `${OVERSEAS_BASE}/overseas-quotes`, payload) @@ -164,6 +185,13 @@ async function quoteLifecycle(context, resource) { && Array.isArray(detail?.warehouseFees) && detail.warehouseFees.length > 0, `id=${asId(detail?.summary?.id) || 'missing'}, version=${detail?.summary?.recordVersion ?? 'missing'}`, BLOCKER) + const listResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/overseas-quotes?page=1&size=20&keyword=${encodeURIComponent(context.marker)}`) + const page = requireData(context, '分页查询marker海外报价', listResponse) + context.assert(MODULE, '报价列表包含本轮长ID与recordVersion', listResponse, + Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), + `id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER) + const staleVersion = Number(detail.summary.recordVersion) const updatePayload = quoteUpdatePayload({ ...payload, transitPrices: payload.transitPrices }, context, staleVersion) const updateResponse = await context.requestOverseas('PUT', path, updatePayload) @@ -191,7 +219,9 @@ async function quoteLifecycle(context, resource) { detail.summary.status === 'ACTIVE', `status=${detail.summary.status}`, BLOCKER) const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/overseas-quotes/export?keyword=${encodeURIComponent(context.marker)}`) - assertBinaryCsv(context, '导出海外报价CSV', csv) + assertBinaryCsv(context, '导出海外报价CSV', csv, + '报价ID,报价名称,合作仓编码,合作仓名称,报价方,币种,生效开始,生效结束,版本号,状态,更新时间', + [id, context.marker]) return true } @@ -252,9 +282,17 @@ async function exchangeLifecycle(context, resource) { context.assert(MODULE, '汇率状态切换为INACTIVE', statusResponse, rate.status === 'INACTIVE', `status=${rate.status}`, BLOCKER) + const listResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/exchange-rates?page=1&size=20&monthFrom=${month}&monthTo=${month}&status=INACTIVE`) + const page = requireData(context, '分页查询marker月汇率', listResponse) + context.assert(MODULE, '汇率列表包含本轮月份与recordVersion', listResponse, + Array.isArray(page?.records) && page.records.some((item) => item.month === month && recordVersion(item.recordVersion)), + `month=${month}, total=${page?.total ?? 'missing'}`, BLOCKER) + const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/exchange-rates/export?monthFrom=${month}&monthTo=${month}`) - assertBinaryCsv(context, '导出marker月汇率CSV', csv) + assertBinaryCsv(context, '导出marker月汇率CSV', csv, + '月份,基准币种,报价币种,月平均汇率,状态,更新人,更新时间', [month, 'INACTIVE']) } function vehiclePayload(context, edited = false) { @@ -316,8 +354,48 @@ async function vehicleLifecycle(context, resource) { context.assert(MODULE, '车辆状态切换为MAINTENANCE', statusResponse, vehicle.status === 'MAINTENANCE', `status=${vehicle.status}`, BLOCKER) + const listResponse = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/vehicles?page=1&size=20&keyword=${encodeURIComponent(createPayload.plateNumber)}`) + const page = requireData(context, '分页查询marker车辆', listResponse) + context.assert(MODULE, '车辆列表包含本轮长ID与recordVersion', listResponse, + Array.isArray(page?.records) && page.records.some((item) => asId(item.id) === id && recordVersion(item.recordVersion)), + `id=${id}, total=${page?.total ?? 'missing'}`, BLOCKER) + const csv = await context.requestOverseasBinary('GET', `${OVERSEAS_BASE}/vehicles/export?keyword=${encodeURIComponent(createPayload.plateNumber)}`) - assertBinaryCsv(context, '导出marker车辆CSV', csv) + assertBinaryCsv(context, '导出marker车辆CSV', csv, + '车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间', + [id, createPayload.plateNumber, 'MAINTENANCE']) +} + +async function verifyCanonicalPermissionDenial(context, resource) { + const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() + const roleResponse = await context.request('POST', '/api/platform/v1/iam/roles', { + roleCode: `P4_MD_DENY_${suffix}`.slice(0, 64), roleName: `四期主数据拒绝-${suffix}`, description: context.marker, + }) + const role = requireData(context, '创建无主数据权限角色', roleResponse) + const roleId = asId(role?.id) + if (!roleId) throw new Error('无主数据权限角色未返回ID') + resource.permissionProbe = { roleId, membershipId: null, roleStatus: 'ACTIVE', userStatus: null } + + const phone = await context.findAvailablePhone(MODULE) + const username = `p4_md_deny_${suffix}`.slice(0, 64).toLowerCase() + const password = `P4md${suffix.slice(-8)}A1!` + const userResponse = await context.request('POST', '/api/platform/v1/iam/users', { + username, displayName: `四期主数据拒绝-${suffix}`, phone, + email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE', + }) + const user = requireData(context, '创建无主数据权限用户', userResponse) + const membershipId = asId(user?.membershipId) + if (!membershipId) throw new Error('无主数据权限用户未返回membershipId') + resource.permissionProbe.membershipId = membershipId + resource.permissionProbe.userStatus = 'ACTIVE' + + const loginResponse = await context.request('POST', '/api/auth/login', { username, password }, '') + const login = requireData(context, '无主数据权限用户登录', loginResponse) + if (!login?.overseasAccessToken) throw new Error('无主数据权限用户未签发海外Token') + const denied = await context.requestOverseas('GET', + `${OVERSEAS_BASE}/overseas-quotes?page=1&size=1`, undefined, {}, login.overseasAccessToken) + assertForbidden(context, '无quote:list权限访问海外报价被拒绝', denied) } async function cleanupPartnerWarehouse(context, resource) { @@ -355,16 +433,33 @@ async function cleanupVersioned(context, resource, key, path, extract) { } } +async function cleanupPermissionProbe(context, resource) { + const probe = resource.permissionProbe + if (!probe) return + if (probe.membershipId && probe.userStatus !== 'DISABLED') { + const response = await context.request('PUT', `/api/platform/v1/iam/users/${probe.membershipId}/status`, { status: 'DISABLED' }) + context.apiSuccess(MODULE, '无权限探针用户finally停用', response, `membershipId=${probe.membershipId}`, BLOCKER) + if (success(response)) probe.userStatus = 'DISABLED' + } + if (probe.roleId && probe.roleStatus !== 'DISABLED') { + const response = await context.request('PUT', `/api/platform/v1/iam/roles/${probe.roleId}/status`, { status: 'DISABLED' }) + context.apiSuccess(MODULE, '无权限探针角色finally停用', response, `roleId=${probe.roleId}`, BLOCKER) + if (success(response)) probe.roleStatus = 'DISABLED' + } +} + export async function run(context) { const resource = { marker: context.marker } context.createdResources.phase4OverseasMasterData = resource try { await continueLifecycle(context, '海外报价', async () => { + await captureWarehouseOptionsBeforeCreate(context, resource) await createAndVerifyPartnerWarehouse(context, resource) await quoteLifecycle(context, resource) }) await continueLifecycle(context, '月度汇率', () => exchangeLifecycle(context, resource)) await continueLifecycle(context, '车辆主数据', () => vehicleLifecycle(context, resource)) + await continueLifecycle(context, 'canonical权限拒绝', () => verifyCanonicalPermissionDenial(context, resource)) } finally { await safeCleanup(context, '海外报价', async () => { if (!resource.quote?.id) return @@ -382,5 +477,6 @@ export async function run(context) { await cleanupVersioned(context, resource, 'vehicle', path, (value) => value) }) await safeCleanup(context, '合作仓', () => cleanupPartnerWarehouse(context, resource)) + await safeCleanup(context, 'canonical权限探针', () => cleanupPermissionProbe(context, resource)) } } diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs index f144909b3..e614ef63e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-contract.test.mjs @@ -9,41 +9,79 @@ import { run } from './phase4-overseas-pda.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-pda.mjs'), 'utf8') -test('PDA module uses only injected development acceptance requests', () => { +test('PDA module is injected, token-scoped, traceable, and contains no direct network or secret source', () => { assert.match(source, /export async function run\(context\)/) - assert.match(source, /context\.requestOverseas\(/) - assert.match(source, /context\.requestOverseasMultipart\(/) - assert.doesNotMatch(source, /https?:\/\//) - assert.doesNotMatch(source, /process\.env|fetch\(/) -}) - -test('PDA module covers the complete receiving lifecycle and excludes outbound domains', () => { - for (const token of ['/destination', '/inbound-plans/convert', '/pallets/', '/push', '/cartons/scan', '/allocations/', - '/count', '/pick', '/unload-photos/files', '/unload-photos', '/complete', '/inventory/balances']) { - assert.ok(source.includes(token), token) - } - for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden) + assert.match(source, /requestOverseas\(/) + assert.match(source, /requestOverseasMultipart\(/) assert.match(source, /requestId=/) assert.match(source, /traceId=/) - assert.match(source, /PENDING_ASSIGNMENT/) - assert.match(source, /nonBlank\(item\?\.containerNo\)/) - assert.match(source, /expectedCartons/) + assert.doesNotMatch(source, /https?:\/\//) + assert.doesNotMatch(source, /fetch\(|process\.env/) + assert.doesNotMatch(source, /planNo|allocationId/) }) -test('missing source order is reported as a fixture prerequisite without business writes', async () => { +test('PDA module covers the container-number receiving contract and excludes outbound domains', () => { + for (const token of ['/candidates', '/prepare', '/current/unload-photos/files', '/current/sign', + '/current/skus/resolve', '/current/cartons/resolve', '/current/pallets/resolve', + '/current/receipts/bulk', '/current/receipts/carton', '/current/complete']) assert.ok(source.includes(token), token) + for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) assert.ok(source.includes(role), role) + for (const forbidden of ['/outbound', '/dispatch', '/delivery', '/internal/']) assert.equal(source.includes(forbidden), false, forbidden) + assert.match(source, /同键同载荷重放/) + assert.match(source, /同键异载荷冲突/) + assert.match(source, /跨柜切换锁返回409/) + assert.match(source, /超收被拒绝/) + assert.match(source, /重复箱新键被拒绝/) + assert.match(source, /模式硬拒绝/) + assert.match(source, /current返回404正常空态/) + assert.match(source, /安全扫描被拒绝/) + assert.match(source, /retained as acceptance evidence/) +}) + +test('missing customer fixture is a deployment blocker and performs no HTTP request', async () => { const calls = []; const gaps = []; const steps = [] const context = { - marker: 'PDA-CONTRACT', createdResources: {}, - async request(method, requestPath) { calls.push({ channel: 'domestic', method, requestPath }); return { http: 200, body: { code: '200', data: { records: [], page: 1, size: 200, total: 0, pages: 0 } } } }, - async requestOverseas(method, requestPath) { calls.push({ channel: 'overseas', method, requestPath }); throw new Error('overseas should not be called without customers') }, + marker: 'PDA-CONTRACT', context: {}, createdResources: {}, + async request(...args) { calls.push(args); throw new Error('must not request without credentials') }, + async requestOverseas(...args) { calls.push(args); throw new Error('must not request without credentials') }, + async requestOverseasMultipart(...args) { calls.push(args); throw new Error('must not request without credentials') }, addStep(...args) { steps.push(args) }, - assert(...args) { steps.push(args) }, + assert(module, name, response, passed, evidence, options) { steps.push({ module, name, passed, evidence, options }) }, addCoverageGap(...args) { gaps.push(args) }, } await run(context) - assert.equal(calls.length, 1) - assert.equal(calls[0].method, 'GET') + assert.equal(calls.length, 0) assert.equal(gaps.length, 1) - assert.match(gaps[0][3], /三期装柜完成事件链/) - assert.deepEqual(context.createdResources.phase4OverseasPda.finalStatus, null) + assert.equal(gaps[0][5].deploymentBlocker, true) + assert.equal(steps.length, 1) + assert.equal(steps[0].passed, false) + assert.equal(steps[0].options.deploymentBlocker, true) +}) + +test('empty candidate response cannot become a zero-business-request pass', async () => { + const calls = []; const gaps = []; const steps = [] + const response = (data, requestPath, method = 'GET') => ({ method, path: requestPath, http: 200, + body: { code: '200', data }, requestId: 'req-contract', traceId: 'trace-contract' }) + const context = { + marker: 'PDA-CONTRACT', overseasToken: '', createdResources: {}, + context: { merchantQualificationFixture: { tenantId: '9', username: 'merchant', password: 'generated' } }, + async request(method, requestPath) { + calls.push({ channel: 'domestic', method, requestPath }) + return response({ overseasAccessToken: 'pda-token' }, requestPath, method) + }, + async requestOverseas(method, requestPath) { + calls.push({ channel: 'overseas', method, requestPath }) + if (requestPath.endsWith('/current')) return { method, path: requestPath, http: 200, + body: { code: 'A0404', message: 'not found' }, requestId: 'req-current', traceId: 'trace-current' } + return response([], requestPath, method) + }, + addStep(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) }, + assert(module, name, result, passed, evidence, options) { steps.push({ module, name, result, passed, evidence, options }) }, + addCoverageGap(...args) { gaps.push(args) }, + } + await run(context) + assert.ok(calls.some((call) => call.requestPath.endsWith('/candidates'))) + assert.ok(steps.some((step) => step.name.includes('正常空态') && step.passed)) + assert.ok(steps.some((step) => step.name.includes('PDA柜号候选覆盖前置') && !step.passed && step.options.deploymentBlocker)) + assert.equal(gaps.length, 1) + assert.equal(context.createdResources.phase4OverseasPda.finalStatus, null) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index d312975c3..4a66ab7e1 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -1,212 +1,270 @@ const MODULE = 'phase4-overseas-pda' -const SCENARIO = 'phase4-overseas-pda-inbound-lifecycle' +const SCENARIO = 'phase4-overseas-pda-container-receiving' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } +const PHOTO_ROLES = ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN'] +const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')) function id(value) { return value == null ? '' : String(value) } function longId(value) { return /^\d+$/.test(id(value)) && id(value) !== '0' } function nonBlank(value) { return typeof value === 'string' && value.trim().length > 0 } -function records(page) { return Array.isArray(page?.records) ? page.records : [] } -function query(path, params) { - const search = new URLSearchParams() - for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && value !== '') search.set(key, String(value)) - return `${path}?${search}` -} -function trace(response) { - return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` -} function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } +function code(response) { return String(response?.body?.code ?? '') } +function traced(response) { return nonBlank(response?.requestId) && nonBlank(response?.traceId) } +function conflict(response) { return (response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(code(response)))) && traced(response) } +function conflictMessage(response, pattern) { return conflict(response) && pattern.test(String(response?.body?.message || '')) } +function notFound(response) { return (response?.http === 404 || (response?.http === 200 && ['404', 'A0404'].includes(code(response)))) && traced(response) } +function forbidden(response) { return [401, 403].includes(response?.http) || (response?.http === 200 && ['A0401', 'A0403'].includes(code(response))) } +function trace(response) { return `HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` } function key(context, action, suffix = '') { return `${context.marker}-${action}-${suffix}`.replace(/[^A-Za-z0-9:_-]/g, '').slice(0, 120) } +function markerSuffix(context) { return String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() } -async function domesticGet(context, name, path) { - const response = await context.request('GET', path) - const passed = success(response) - context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) - return passed ? response.body.data : null -} -async function overseas(context, method, name, path, body = undefined) { - const response = await context.requestOverseas(method, path, body) - const passed = success(response) - context.addStep(MODULE, name, response, passed, trace(response), OPTIONS) - return { response, data: passed ? response.body.data : null } +function blockerGap(context, name, reason, category = 'fixture-unavailable', response = null) { + context.addCoverageGap(MODULE, SCENARIO, name, reason, category, { deploymentBlocker: true }) + context.assert(MODULE, `${name}覆盖前置`, response, false, reason, OPTIONS) } -async function findUnplannedOrder(context) { - const tenants = records(await domesticGet(context, '查询PDA可用客户', '/api/platform/v1/iam/tenants?page=1&size=200&status=ACTIVE')) - for (const tenant of tenants) { - const customerId = id(tenant?.id) - if (!longId(customerId)) continue - const plans = await overseas(context, 'GET', `查询客户${customerId}既有入仓计划`, query('/api/platform/v1/last-leg/inbound-plans', { customerId, page: 1, size: 200 })) - if (!success(plans.response)) continue - const planned = new Set(records(plans.data).map((item) => id(item?.sourceOrderId)).filter(Boolean)) - const orders = await overseas(context, 'GET', `查询客户${customerId}待规划尾程单`, query('/api/platform/v1/last-leg/orders', { customerId, currentStatus: 'PENDING_ASSIGNMENT', page: 1, size: 200 })) - if (!success(orders.response)) continue - const order = records(orders.data).find((item) => longId(item?.id) && nonBlank(item?.containerNo) - && !planned.has(id(item.id))) - if (order) return { customerId, order } +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 } +} + +async function expected(context, token, method, name, path, body, predicate) { + const response = await context.requestOverseas(method, path, body, {}, token) + context.addStep(MODULE, name, response, predicate(response), trace(response), OPTIONS) + return response +} + +async function loginPda(context) { + const fixture = context.context?.merchantQualificationFixture + if (!fixture?.username || !fixture?.password) { + blockerGap(context, 'PDA客户账号', '缺少 merchant-qualification-fixture 生成的本轮客户账号;请保留默认全量前置模块') + return null } - return null + const response = await context.request('POST', '/api/auth/login', { username: fixture.username, password: fixture.password }, '') + const data = success(response) ? response.body?.data : null + context.addStep(MODULE, '本轮客户登录并取得海外PDA令牌', response, + success(response) && nonBlank(data?.overseasAccessToken), + `overseasTokenPresent=${nonBlank(data?.overseasAccessToken)}; ${trace(response)}`, OPTIONS) + return nonBlank(data?.overseasAccessToken) ? data.overseasAccessToken : null } -async function createWarehouse(context, customerId) { - const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-18).toUpperCase() - const warehouseCode = `PDA-${suffix}`.slice(0, 50) - const created = await overseas(context, 'POST', '创建PDA入仓目标海外仓', '/api/platform/v1/warehouse-admin', { - customerId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓`, warehouseType: 'OVERSEAS', - countryCode: 'US', timezone: 'America/Los_Angeles', contactName: 'Phase4 PDA', - contactPhone: '+1-213-555-0199', contactEmail: null, status: 'ACTIVE', remark: `${context.marker}-pda-inbound`, - address: { addressType: 'WAREHOUSE', countryCode: 'US', stateProvince: 'California', city: 'Los Angeles', - district: 'Commerce', postalCode: '90040', addressLine1: '502 Phase Four PDA Avenue', addressLine2: null, - latitude: null, longitude: null }, - capabilities: [{ capabilityCode: 'STORAGE', capabilityName: 'Storage', enabled: true, cutoffTime: null, - description: `${context.marker} PDA inbound acceptance` }], +async function createPallet(context, inbound, index) { + const warehouseId = id(inbound?.destinationWarehouseId) + if (!longId(warehouseId)) return null + const suffix = markerSuffix(context) + const location = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { + zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64), + locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: `${context.marker}-receiving`, }) - const warehouseId = id(created.data) - context.assert(MODULE, 'PDA目标海外仓返回长ID', created.response, longId(warehouseId), - `warehouseId=${warehouseId || 'missing'}; ${trace(created.response)}`, OPTIONS) - return { warehouseId, warehouseCode, warehouseName: `${context.marker} PDA入仓仓` } + context.addStep(MODULE, `创建当前柜实收库位${index + 1}`, location, success(location), trace(location), OPTIONS) + const locationId = id(location?.body?.data) + if (!longId(locationId)) return null + context.createdResources.phase4OverseasPda.locationIds.push(locationId) + const palletCode = `PDA-${suffix}-${index + 1}`.slice(0, 128) + const pallet = await context.requestOverseas('POST', `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { + palletCode, palletType: '木托盘', capacityCbm: 10, unitPrice: 0, status: 'IDLE', locationId, + remark: `${context.marker}-receiving-retained`, + fbaWarehouseCode: inbound.destinationType === 'FBA' ? inbound.fbaWarehouseCode : null, + }) + context.addStep(MODULE, `创建当前柜实收托盘${index + 1}`, pallet, success(pallet), trace(pallet), OPTIONS) + const palletId = id(pallet?.body?.data) + return longId(palletId) ? { warehouseId, locationId, palletId, palletCode, retained: true } : null } -async function createResource(context, warehouseId, index, fbaWarehouseCode) { - const suffix = String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-14).toUpperCase() - const location = await overseas(context, 'POST', `创建PDA库位${index + 1}`, - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/locations`, { - zoneCode: `PDA-${index + 1}`, locationCode: `PDA-${suffix}-${index + 1}`.slice(0, 64), - locationType: 'STORAGE', maxLoadKg: 5000, status: 'IDLE', remark: context.marker, - }) - const locationId = id(location.data) - context.assert(MODULE, `PDA库位${index + 1}返回长ID`, location.response, longId(locationId), - `locationId=${locationId || 'missing'}; ${trace(location.response)}`, OPTIONS) - const pallet = await overseas(context, 'POST', `创建PDA实物托盘${index + 1}`, - `/api/platform/v1/warehouse-admin/${encodeURIComponent(warehouseId)}/pallets`, { - palletCode: `PDA-${suffix}-${index + 1}`.slice(0, 128), palletType: '木托盘', capacityCbm: 2, - unitPrice: 0, status: 'IDLE', locationId: longId(locationId) ? locationId : undefined, - remark: context.marker, fbaWarehouseCode: fbaWarehouseCode || null, - }) - const palletId = id(pallet.data) - context.assert(MODULE, `PDA实物托盘${index + 1}返回长ID`, pallet.response, longId(palletId), - `palletId=${palletId || 'missing'}; ${trace(pallet.response)}`, OPTIONS) - return { locationId, palletId } -} - -async function operateExpectedCartons(context, customerId, planNo, task) { - for (const [index, carton] of (task.expectedCartons || []).entries()) { - if (carton.status === 'ON_PALLET' || carton.status === 'AVAILABLE') continue - const scanned = await overseas(context, 'POST', `扫描国内冻结箱唛${index + 1}`, - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/cartons/scan`, { customerId }), { - cartonMark: carton.cartonMark, planPalletId: id(carton.planPalletId), - idempotencyKey: key(context, 'scan', String(index + 1)), operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) - context.assert(MODULE, `箱唛${index + 1}已绑定托盘`, scanned.response, - scanned.data?.result === 'SCANNED' && scanned.data?.cartonMark === carton.cartonMark, - `cartonMark=${carton.cartonMark || 'missing'}, result=${scanned.data?.result || 'missing'}; ${trace(scanned.response)}`, OPTIONS) +async function uploadPhotos(context, token) { + const malicious = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { + name: `${context.marker}-forged.png`, type: 'image/png', content: Uint8Array.from(Buffer.from('')), + }, token) + context.addStep(MODULE, '伪造图片内容安全扫描被拒绝', malicious, !success(malicious) && traced(malicious), trace(malicious), OPTIONS) + const photos = [] + for (const role of PHOTO_ROLES) { + const response = await context.requestOverseasMultipart('POST', '/api/pda/v1/last-leg/receiving-containers/current/unload-photos/files', { + name: `${context.marker}-${role.toLowerCase()}.png`, type: 'image/png', content: PNG, + }, token) + const fileId = id(response?.body?.data?.fileId) + context.addStep(MODULE, `上传${role}真实签收照片`, response, success(response) && longId(fileId), + `role=${role}, fileId=${fileId || 'missing'}; ${trace(response)}`, OPTIONS) + if (!longId(fileId)) return null + photos.push({ role, fileId }) } + return photos } -async function operateLegacyAllocations(context, customerId, planNo, task) { - for (const [index, allocation] of (task.allocations || []).entries()) { - const remaining = Number(allocation.plannedPackageCount || 0) - Number(allocation.pickedPackageCount || 0) - if (remaining <= 0) continue - await overseas(context, 'POST', `清点入仓分配${index + 1}`, - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/count`, { customerId }), { - packageCount: Number(allocation.plannedPackageCount), idempotencyKey: key(context, 'count', String(index + 1)), - operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) - await overseas(context, 'POST', `实收入仓分配${index + 1}`, - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/allocations/${encodeURIComponent(id(allocation.allocationId))}/pick`, { customerId }), { - packageCount: remaining, idempotencyKey: key(context, 'pick', String(index + 1)), - operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) +function skuFixtures(context) { + const boxes = context.createdResources?.orders?.boxTypes + const orchestration = context.createdResources?.orchestrationFixtures || {} + const markers = [context.marker, orchestration.phase3Loading?.sourceMarker, + orchestration.phase3LoadingIncremental?.sourceMarker].filter(nonBlank) + return Array.isArray(boxes) + ? [...new Set(markers.flatMap((marker) => boxes.map((boxType) => `SKU-${boxType}-${marker}`.slice(0, 64))))] + : [] +} + +async function receiveFba(context, token, inbound, pallet) { + const skus = skuFixtures(context) + if (!skus.length) { + blockerGap(context, `FBA入仓码${inbound.inboundCode} SKU`, '本轮订单 fixture 未交接冻结 SKU,无法安全猜测实收 SKU') + return false } + let accepted = 0 + for (const [index, skuCode] of skus.entries()) { + const resolvedResponse = await context.requestOverseas('POST', + '/api/pda/v1/last-leg/receiving-containers/current/skus/resolve', { skuCode }, {}, token) + const resolvable = success(resolvedResponse) || notFound(resolvedResponse) + context.addStep(MODULE, `解析FBA SKU候选 ${index + 1}`, resolvedResponse, resolvable, trace(resolvedResponse), OPTIONS) + const resolved = { response: resolvedResponse, data: success(resolvedResponse) ? resolvedResponse.body?.data : null } + if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode || resolved.data?.destinationType !== 'FBA') continue + const packageCount = skuCode.includes('-LOAD-INC') ? 2 : 1 + const body = { inboundCode: inbound.inboundCode, skuCode, palletCode: pallet.palletCode, + packageCount, idempotencyKey: key(context, 'fba-bulk', skuCode) } + const first = await pda(context, token, 'POST', `FBA SKU ${skuCode}按箱数实收`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + if (!success(first.response)) return false + accepted += Number(first.data?.acceptedPackages || 0) + const replay = await pda(context, token, 'POST', `FBA SKU ${skuCode}同键同载荷重放`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', body) + context.assert(MODULE, `FBA SKU ${skuCode}幂等结果一致`, replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', `FBA SKU ${skuCode}同键异载荷冲突`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, packageCount: packageCount + 1 }, conflict) + await expected(context, token, 'POST', `FBA SKU ${skuCode}超收被拒绝`, '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', + { ...body, packageCount: Number(inbound.totalPackages) + 1, idempotencyKey: key(context, 'fba-over', skuCode) }, conflict) + } + if (accepted <= 0) { + blockerGap(context, `FBA入仓码${inbound.inboundCode}实收`, `候选 SKU 均未唯一解析到当前入仓码;skuCount=${skus.length}`) + return false + } + return true +} + +async function receiveOverseas(context, token, inbound, pallet) { + const cartonMark = context.createdResources?.inboundMeasurement?.boxCode + if (!nonBlank(cartonMark)) { + blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}逐箱`, '缺少本轮冻结箱唛,逐箱实收不能使用伪造箱号') + return false + } + const resolved = await pda(context, token, 'POST', '解析OVERSEAS冻结箱唛', '/api/pda/v1/last-leg/receiving-containers/current/cartons/resolve', { cartonMark }) + if (!success(resolved.response) || resolved.data?.inboundCode !== inbound.inboundCode) { + blockerGap(context, `OVERSEAS入仓码${inbound.inboundCode}箱唛归属`, `冻结箱唛未解析到目标入仓码;cartonMark=${cartonMark}`, 'fixture-mismatch', resolved.response) + return false + } + const body = { cartonMark, palletCode: pallet.palletCode, idempotencyKey: key(context, 'overseas-carton') } + const first = await pda(context, token, 'POST', 'OVERSEAS逐箱实收', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + if (!success(first.response)) return false + const replay = await pda(context, token, 'POST', 'OVERSEAS逐箱同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', body) + context.assert(MODULE, 'OVERSEAS逐箱幂等结果一致', replay.response, + success(replay.response) && JSON.stringify(replay.data) === JSON.stringify(first.data), trace(replay.response), OPTIONS) + await expected(context, token, 'POST', 'OVERSEAS逐箱同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, palletCode: `${pallet.palletCode}-MUTATED` }, conflict) + await expected(context, token, 'POST', 'OVERSEAS重复箱新键被拒绝', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', + { ...body, idempotencyKey: key(context, 'overseas-duplicate') }, conflict) + return true } export async function run(context) { - context.createdResources.phase4OverseasPda = { marker: context.marker, customerId: null, orderId: null, - planNo: null, warehouseId: null, taskId: null, finalStatus: null, retained: true } - const source = await findUnplannedOrder(context) - if (!source) { - context.addCoverageGap(MODULE, SCENARIO, 'PDA入仓来源尾程单', - '所有ACTIVE客户均无尚未转换、状态为PENDING_ASSIGNMENT且具有柜号的尾程单;请先运行三期装柜完成事件链', 'fixture-unavailable') + context.createdResources.phase4OverseasPda = { + marker: context.marker, containerNo: null, customerId: context.context?.merchantQualificationFixture?.tenantId || null, + palletIds: [], locationIds: [], finalStatus: null, + cleanup: 'Pallets and locations used by completed inventory are retained as acceptance evidence', + } + const token = await loginPda(context) + if (!token) return + if (context.overseasToken) { + const denied = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/candidates', undefined, {}, context.overseasToken) + context.addStep(MODULE, '平台海外令牌不能冒充客户PDA账号', denied, forbidden(denied) && traced(denied), trace(denied), OPTIONS) + } + const before = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + const noCurrent = notFound(before) + context.addStep(MODULE, '无当前柜返回404属于正常空态', before, noCurrent || success(before), + `${noCurrent ? 'empty=true' : `resumeStatus=${before?.body?.data?.status || 'missing'}`}; ${trace(before)}`, OPTIONS) + if (success(before)) { + blockerGap(context, '可重复的完整柜号验收', `账号已有未完成柜 ${before.body?.data?.containerNo || 'unknown'},本轮不覆盖或篡改遗留现场会话`, 'dirty-fixture', before) return } - const { customerId, order } = source - const warehouse = await createWarehouse(context, customerId) - if (!longId(warehouse.warehouseId)) return - Object.assign(context.createdResources.phase4OverseasPda, { customerId, orderId: id(order.id), warehouseId: warehouse.warehouseId }) - - const transferred = await overseas(context, 'PUT', '尾程单指定本轮PDA目标仓', - query(`/api/platform/v1/last-leg/orders/${encodeURIComponent(id(order.id))}/destination`, { customerId }), { - warehouseId: warehouse.warehouseId, warehouseCode: warehouse.warehouseCode, - warehouseName: warehouse.warehouseName, remark: `${context.marker} PDA lifecycle`, - }) - if (!success(transferred.response)) return - const converted = await overseas(context, 'POST', '尾程单转换入仓计划', '/api/platform/v1/last-leg/inbound-plans/convert', { - orderId: id(order.id), customerId, - }) - const plan = converted.data - const planNo = plan?.planNo || '' - context.assert(MODULE, '入仓计划转换返回完整作业结构', converted.response, - Boolean(planNo) && Array.isArray(plan?.pallets) && plan.pallets.length > 0 && Array.isArray(plan?.allocations) && plan.allocations.length > 0, - `planNo=${planNo || 'missing'}, pallets=${plan?.pallets?.length ?? 'missing'}, allocations=${plan?.allocations?.length ?? 'missing'}; ${trace(converted.response)}`, OPTIONS) - if (!planNo || !Array.isArray(plan?.pallets) || !plan.pallets.length) return - context.createdResources.phase4OverseasPda.planNo = planNo - - for (const [index, planPallet] of plan.pallets.entries()) { - const resource = await createResource(context, warehouse.warehouseId, index, planPallet.destinationWarehouseCode) - if (!longId(resource.locationId) || !longId(resource.palletId)) return - const assigned = await overseas(context, 'POST', `分配计划托盘${index + 1}`, - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/pallets/${encodeURIComponent(id(planPallet.id))}/assign`, { customerId }), { - warehouseId: warehouse.warehouseId, palletId: resource.palletId, locationId: resource.locationId, - idempotencyKey: key(context, 'assign', String(index + 1)), - }) - if (!success(assigned.response)) return + const candidates = await pda(context, token, 'GET', '查询当前客户待收柜号', '/api/pda/v1/last-leg/receiving-containers/candidates') + if (!Array.isArray(candidates.data) || candidates.data.length === 0) { + blockerGap(context, 'PDA柜号候选', `当前客户没有由三期装柜完成事件生成的待收入仓计划;${trace(candidates.response)}`, 'producer-chain', candidates.response) + return } - - const pushed = await overseas(context, 'POST', '推送PDA入仓任务', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/push`, { customerId }), { - idempotencyKey: key(context, 'push'), operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) - const task = pushed.data - context.assert(MODULE, 'PDA任务包含托盘、分配和箱唛指令', pushed.response, - longId(task?.taskId) && Array.isArray(task?.pallets) && task.pallets.length === plan.pallets.length - && Array.isArray(task?.allocations) && task.allocations.length > 0 && Array.isArray(task?.expectedCartons), - `taskId=${id(task?.taskId) || 'missing'}, pallets=${task?.pallets?.length ?? 'missing'}, allocations=${task?.allocations?.length ?? 'missing'}, cartons=${task?.expectedCartons?.length ?? 'missing'}; ${trace(pushed.response)}`, OPTIONS) - if (!longId(task?.taskId)) return - context.createdResources.phase4OverseasPda.taskId = id(task.taskId) - - if ((task.expectedCartons || []).length > 0) await operateExpectedCartons(context, customerId, planNo, task) - else await operateLegacyAllocations(context, customerId, planNo, task) - - const photo = await context.requestOverseasMultipart('POST', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos/files`, { customerId }), { - name: `${context.marker}-unload.png`, type: 'image/png', - content: Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')), - }) - context.addStep(MODULE, '上传真实卸柜照片文件', photo, success(photo), trace(photo), OPTIONS) - const fileId = id(photo?.body?.data?.fileId) - context.assert(MODULE, '卸柜照片返回长文件ID', photo, longId(fileId), `fileId=${fileId || 'missing'}; ${trace(photo)}`, OPTIONS) - if (!longId(fileId)) return - const bound = await overseas(context, 'POST', '绑定卸柜照片并生成预计报空时间', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/unload-photos`, { customerId }), { - fileIds: [fileId], idempotencyKey: key(context, 'photos'), operatorId: 'phase4-http', operatorName: '四期HTTP验收', - }) - context.assert(MODULE, '照片凭证含上传时间和预计报空时间', bound.response, - bound.data?.unloadPhotoFileIds?.map(id).includes(fileId) && Boolean(bound.data?.unloadPhotoUploadedAt) && Boolean(bound.data?.estimatedEmptyReturnAt), - `fileIds=${bound.data?.unloadPhotoFileIds?.map(id).join(',') || 'missing'}, estimated=${bound.data?.estimatedEmptyReturnAt || 'missing'}; ${trace(bound.response)}`, OPTIONS) - - const completed = await overseas(context, 'POST', '完成PDA入仓并写库存', - query(`/api/pda/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}/complete`, { customerId })) - context.assert(MODULE, 'PDA任务进入COMPLETED终态', completed.response, - completed.data?.status === 'COMPLETED' && Boolean(completed.data?.completedAt), - `status=${completed.data?.status || 'missing'}, completedAt=${completed.data?.completedAt || 'missing'}; ${trace(completed.response)}`, OPTIONS) + const preferredContainer = context.createdResources?.phase3Loading?.containerNo + const candidate = candidates.data.find((item) => item.containerNo === preferredContainer) || candidates.data[0] + const containerNo = id(candidate?.containerNo) + context.assert(MODULE, '柜号候选来自本轮生产链且包含进度', candidates.response, + nonBlank(containerNo) && Number(candidate?.totalPackages) > 0 && Number(candidate?.receivedPackages) >= 0, + `containerNo=${containerNo || 'missing'}, total=${candidate?.totalPackages ?? 'missing'}; ${trace(candidates.response)}`, OPTIONS) + if (!containerNo) return + context.createdResources.phase4OverseasPda.containerNo = containerNo + const detail = await pda(context, token, 'GET', '查询柜号完整入仓码详情', `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}`) + if (!Array.isArray(detail.data?.inbounds) || detail.data.inbounds.length === 0) return + const prepareBody = { idempotencyKey: key(context, 'prepare') } + const prepared = await pda(context, token, 'POST', '选择柜号并建立WAIT_SIGN会话', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) + if (!success(prepared.response)) return + const prepareReplay = await pda(context, token, 'POST', '选择柜号同键重放', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(containerNo)}/prepare`, prepareBody) + context.assert(MODULE, '选择柜号幂等结果一致', prepareReplay.response, + success(prepareReplay.response) && prepareReplay.data?.containerNo === containerNo && prepareReplay.data?.status === 'WAIT_SIGN', trace(prepareReplay.response), OPTIONS) + const other = candidates.data.find((item) => item.containerNo !== containerNo) + if (other) await expected(context, token, 'GET', '当前账号跨柜切换锁返回409', + `/api/pda/v1/last-leg/receiving-containers/${encodeURIComponent(other.containerNo)}`, undefined, conflict) + else blockerGap(context, '跨柜锁409', '当前客户只有一个待收柜,无法在不伪造柜数据的前提下验证跨柜锁') + const photos = await uploadPhotos(context, token) + if (!photos) return + const signBody = { photos, idempotencyKey: key(context, 'sign') } + const signed = await pda(context, token, 'POST', '绑定车牌封签开柜三角色照片并签收', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody) + if (!success(signed.response)) return + const signReplay = await pda(context, token, 'POST', '签收同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/sign', signBody) + context.assert(MODULE, '三角色照片各一次且签收幂等', signReplay.response, + success(signReplay.response) && signReplay.data?.status === 'SIGNED' + && PHOTO_ROLES.every((role) => signReplay.data?.photos?.filter((item) => item.role === role).length === 1), trace(signReplay.response), OPTIONS) + await expected(context, token, 'POST', '签收同键异载荷冲突', '/api/pda/v1/last-leg/receiving-containers/current/sign', + { ...signBody, photos: [...photos].reverse() }, conflict) + const current = await pda(context, token, 'GET', '签收后按账号恢复当前柜', '/api/pda/v1/last-leg/receiving-containers/current') + if (!success(current.response)) return + const pallets = new Map() + for (const [index, inbound] of current.data.inbounds.entries()) { + const pallet = await createPallet(context, inbound, index) + if (!pallet) { + blockerGap(context, `${inbound.destinationType}入仓码${inbound.inboundCode}实物托盘`, + `无法在目的仓 ${inbound.destinationWarehouseId || 'missing'} 创建可解析真实托盘`, 'warehouse-resource') + continue + } + pallets.set(inbound.inboundCode, pallet) + context.createdResources.phase4OverseasPda.palletIds.push(pallet.palletId) + const resolved = await pda(context, token, 'POST', `解析${inbound.destinationType}实物托盘`, + '/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve', { palletCode: pallet.palletCode, inboundCode: inbound.inboundCode }) + context.assert(MODULE, `${inbound.destinationType}托盘目的模式匹配`, resolved.response, + success(resolved.response) && resolved.data?.compatible === true && resolved.data?.destinationType === inbound.destinationType, trace(resolved.response), OPTIONS) + } + const fbaInbounds = current.data.inbounds.filter((item) => item.destinationType === 'FBA') + const overseasInbound = current.data.inbounds.find((item) => item.destinationType === 'OVERSEAS') + if (fbaInbounds.length && fbaInbounds.every((item) => pallets.has(item.inboundCode))) { + const firstFba = fbaInbounds[0] + await expected(context, token, 'POST', 'FBA模式硬拒绝逐箱收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/carton', { + cartonMark: context.createdResources?.inboundMeasurement?.boxCode || 'MODE-REJECT-FBA', + palletCode: pallets.get(firstFba.inboundCode).palletCode, idempotencyKey: key(context, 'fba-carton-forbidden'), + }, (response) => conflictMessage(response, /FBA.*bulk|bulk.*FBA/i)) + for (const inbound of fbaInbounds) await receiveFba(context, token, inbound, pallets.get(inbound.inboundCode)) + } else blockerGap(context, 'FBA SKU按箱数链', '当前柜没有带真实托盘的 FBA 入仓码') + if (overseasInbound && pallets.has(overseasInbound.inboundCode)) { + const pallet = pallets.get(overseasInbound.inboundCode) + await expected(context, token, 'POST', 'OVERSEAS模式硬拒绝批量收货', '/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk', { + inboundCode: overseasInbound.inboundCode, skuCode: 'MODE-REJECT', palletCode: pallet.palletCode, + packageCount: 1, idempotencyKey: key(context, 'overseas-bulk-forbidden'), + }, (response) => conflictMessage(response, /OVERSEAS.*carton|bulk.*forbidden/i)) + await receiveOverseas(context, token, overseasInbound, pallet) + } else blockerGap(context, 'OVERSEAS逐箱链', '当前柜没有带真实冻结箱唛与托盘的 OVERSEAS 入仓码') + const refreshed = await pda(context, token, 'GET', '回查柜内逐入仓码实收进度', '/api/pda/v1/last-leg/receiving-containers/current') + if (!success(refreshed.response) || Number(refreshed.data?.pendingPackages) !== 0) { + blockerGap(context, '完成当前柜', `柜内实收尚未精确核平;pendingPackages=${refreshed.data?.pendingPackages ?? 'missing'}`, 'receiving-incomplete', refreshed.response) + return + } + const completeBody = { idempotencyKey: key(context, 'complete') } + const completed = await pda(context, token, 'POST', '完成当前柜并写入库存与卸柜事件', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) + if (!success(completed.response)) return + const completeReplay = await pda(context, token, 'POST', '完成当前柜同键同载荷重放', '/api/pda/v1/last-leg/receiving-containers/current/complete', completeBody) + context.assert(MODULE, '完成幂等重放仍返回COMPLETED', completeReplay.response, + success(completeReplay.response) && completeReplay.data?.status === 'COMPLETED', trace(completeReplay.response), OPTIONS) + const after = await context.requestOverseas('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, {}, token) + context.addStep(MODULE, '完成后current返回404正常空态', after, notFound(after), trace(after), OPTIONS) context.createdResources.phase4OverseasPda.finalStatus = completed.data?.status || null - - const planAfter = await overseas(context, 'GET', '回查入仓计划完成状态', - query(`/api/platform/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}`, { customerId })) - context.assert(MODULE, '入仓计划与PDA任务同步完成', planAfter.response, planAfter.data?.status === 'COMPLETED', - `planStatus=${planAfter.data?.status || 'missing'}; ${trace(planAfter.response)}`, OPTIONS) - const balances = await overseas(context, 'GET', '回查PDA入仓SKU库存', - query('/api/platform/v1/last-leg/inventory/balances', { customerId, page: 1, size: 200 })) - context.assert(MODULE, 'PDA完成后生成SKU库存余额', balances.response, - records(balances.data).some((item) => Number(item?.totalPackages) > 0 && id(item?.customerId) === customerId), - `balances=${records(balances.data).length}; ${trace(balances.response)}`, OPTIONS) } diff --git a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs index aefb917c2..f4e1db7f6 100644 --- a/scripts/phase4-overseas-acceptance-entry-contract.test.mjs +++ b/scripts/phase4-overseas-acceptance-entry-contract.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { parseArguments } from './admin-test-env-acceptance.mjs' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' const source = readFileSync(resolve('scripts/admin-test-env-acceptance.mjs'), 'utf8') @@ -33,3 +33,38 @@ test('entry accepts a separate overseas Gateway without exposing token state', ( assert.match(source, /async requestOverseasMultipart\(/) assert.match(source, /forbiddenValues = \[this\.options\.password, this\.token, this\.overseasToken\]/) }) + +test('entry has no built-in environment address or password and requires explicit execution options', () => { + assert.doesNotMatch(source, /8\.163\.|ChangeMe123|DEFAULT_PASSWORD|DEFAULT_GATEWAY/) + assert.throws(() => validateExecutionOptions(parseArguments(['--modules', 'phase4-overseas-pda'])), + /缺少必填执行参数/) + const options = parseArguments([ + '--gateway', 'http://localhost:9000', '--overseas-gateway', 'http://localhost:9300', + '--username', 'contract-user', '--password', 'contract-password', '--modules', 'phase4-overseas-pda', + ]) + assert.equal(validateExecutionOptions(options), options) +}) + +test('core coverage gaps fail closed while device and external-provider gaps remain report-only', () => { + const run = new AcceptanceRun({ gateway: 'http://localhost:9000', overseasGateway: 'http://localhost:9300', + username: 'contract-user', password: 'contract-password', modules: [], output: '' }) + run.addCoverageGap('phase4', 'core', 'fixture', 'missing fixture', 'fixture') + assert.equal(run.steps.at(-1)?.passed, false) + assert.equal(run.steps.at(-1)?.deploymentBlocker, true) + const stepCount = run.steps.length + run.addCoverageGap('phase3', 'device', 'Android', 'manual device validation', 'device') + run.addCoverageGap('phase3', 'provider', 'captcha', 'external captcha', 'external-third-party') + assert.equal(run.steps.length, stepCount) +}) + +test('PDA entry expands the real phase-three producer chain and builds an FBA/OVERSEAS/second-container matrix', () => { + const modules = parseArguments(['--modules', 'phase4-overseas-pda']).modules + for (const prerequisite of ['merchant-qualification-fixture', 'phase3-loading', 'phase3-pda-convergence', + 'phase3-evidence-gate', 'phase3-loading-finalize']) { + assert.ok(modules.indexOf(prerequisite) >= 0 && modules.indexOf(prerequisite) < modules.indexOf('phase4-overseas-pda')) + } + assert.match(source, /async ensurePhase4PdaProducerMatrix\(\)/) + assert.match(source, /runPhase4ProducerVariant\('OVERSEAS-PRIMARY', 'OVERSEAS'\)/) + assert.match(source, /runPhase4ProducerVariant\('SECONDARY', 'FBA'\)/) + assert.match(source, /readyForPhase4Pda === true/) +})