#!/usr/bin/env node 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 CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-loading-complete-fixture', 'manual-loading-complete-verify', 'coload-groups'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 export function planA0LocationIsolation(locations, requiredSlots = 1, maxMutations = A0_MAX_LOCATION_MUTATIONS) { const slots = Math.max(1, Number.isInteger(Number(requiredSlots)) ? Number(requiredSlots) : 1) const limit = Math.max(0, Number.isInteger(Number(maxMutations)) ? Number(maxMutations) : 0) const unique = new Map() for (const location of Array.isArray(locations) ? locations : []) { const locationId = location?.id == null ? '' : String(location.id) if (!locationId || location?.sharedOverflow === true || unique.has(locationId)) continue unique.set(locationId, location) } const normal = [...unique.values()].sort((left, right) => String(left?.locationCode || '').localeCompare(String(right?.locationCode || '')) || String(left.id).localeCompare(String(right.id))) const remainingNormal = Math.max(0, slots - 1) const mutationsRequired = Math.max(0, normal.length - remainingNormal) return { enabled: mutationsRequired <= limit, requiredSlots: slots, normalCount: normal.length, expectedRemainingNormal: Math.min(normal.length, remainingNormal), mutationsRequired, targets: mutationsRequired <= limit ? normal.slice(0, mutationsRequired) : [], } } const MODULE_DEPENDENCIES = new Map([ ['suppliers', ['merchant-qualification-fixture']], ['supplemental', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']], ['inbound-hold', ['orders']], ['inbound-measurement', ['orders']], ['inbound-fees', ['inbound-measurement']], ['manual-order-splitting-fixture', ['inbound-measurement']], ['coload-groups', ['inbound-measurement']], ['phase3-container-loading', ['inbound-measurement']], ['phase3-loading', ['phase3-container-loading']], ['phase3-container-overview', ['phase3-loading']], ['phase3-loading-model-bridge', ['phase3-loading']], ['phase3-pda-convergence', ['phase3-loading']], ['phase3-evidence-gate', ['phase3-pda-convergence']], ['ocean-bookings', ['phase3-loading']], ['truck-appointments', ['merchant-qualification-fixture']], ['service-center-flow', ['ocean-bookings', 'truck-appointments']], ['phase3-loading-finalize', ['phase3-loading', 'phase3-evidence-gate']], ['phase3-container-documents', ['phase3-outbound-dispatch']], ['phase3-customs', ['phase3-loading-finalize']], ['phase3-container-customs', ['merchant-qualification-fixture']], ['phase3-port-schedules', ['ocean-bookings']], ['phase3-outbound-dispatch', ['phase3-customs', 'phase3-port-schedules', 'service-center-flow']], ['phase3-outbound-aggregate', ['phase3-outbound-dispatch']], ['phase3-dispatch-gate', ['phase3-outbound-dispatch']], ['phase3-waiting-for-loading', ['phase3-outbound-dispatch']], ['phase3-carrier-schedule-queries', ['phase3-outbound-dispatch']], ['phase3-order-splitting', ['phase3-loading-finalize']], ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-container-documents']], ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries', 'phase3-container-documents']], ['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']], ['phase4-overseas-pda', ['phase4-tail-order-controls']], ['phase4-overseas-pda-operations', ['phase4-overseas-pda']], ['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']], ['phase4-overseas-client-services', ['phase4-overseas-pda-operations']], ['phase4-overseas-dispatch', ['phase4-overseas-pda-operations']], ['phase5-overseas-fulfillment', ['phase4-overseas-dispatch']], ['phase5-terminal-drayage', ['phase4-overseas-pda']], ['phase4-cross-region-events', ['system', 'phase3-customs', 'phase4-overseas-pda-operations']], ['phase4-carrier-nodes', ['phase4-overseas-pda-operations', 'phase5-terminal-drayage']], ['phase4-overseas-business', ['merchant-qualification-fixture']], ['firstleg-container-fee-allocation', []], ['finance-direct-paid-isolation', ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full']], ['phase4-overseas-finance-cross-region', ['phase4-overseas-business', 'phase4-overseas-pda-operations']], ['phase4-overseas-wallet-and-pallet-cost', ['phase4-overseas-business', 'phase4-overseas-pda-operations']], ]) function stableTopologicalOrder(modules) { const selected = new Set(modules) const pending = [...modules] const result = [] const completed = new Set() while (pending.length > 0) { const readyIndex = pending.findIndex((module) => (MODULE_DEPENDENCIES.get(module) || []) .every((dependency) => !selected.has(dependency) || completed.has(dependency))) if (readyIndex < 0) throw new Error(`HTTP 验收模块依赖存在循环: ${pending.join(',')}`) const [module] = pending.splice(readyIndex, 1) result.push(module) completed.add(module) } return result } function dependencyClosure(module, visiting = new Set()) { if (visiting.has(module)) throw new Error(`HTTP 验收模块依赖存在循环: ${[...visiting, module].join(',')}`) const next = new Set(visiting).add(module) const result = [] for (const dependency of MODULE_DEPENDENCIES.get(module) || []) { for (const transitive of dependencyClosure(dependency, next)) { if (!result.includes(transitive)) result.push(transitive) } if (!result.includes(dependency)) result.push(dependency) } return result } export function normalizeModuleOrder(modules) { const normalized = [...modules] const manualGroups = ['order-inbound-status-fixtures', 'manual-state-loading-pda', 'manual-state-ocean-trucking'] const firstManualGroupIndex = normalized.findIndex((name) => manualGroups.includes(name)) if (!normalized.includes('manual-state-matrix') && firstManualGroupIndex >= 0) { normalized.splice(firstManualGroupIndex, 0, 'manual-state-matrix') } const manualMatrixIndex = normalized.indexOf('manual-state-matrix') if (manualMatrixIndex >= 0) { for (const group of manualGroups) { const internalGroupIndex = normalized.indexOf(group) if (internalGroupIndex >= 0) normalized.splice(internalGroupIndex, 1) } const prerequisites = [ 'merchant-qualification-fixture', 'phase3-container-loading', 'phase3-loading', 'ocean-bookings', 'phase3-loading-finalize', 'phase3-customs', ] for (const prerequisite of prerequisites) { if (!normalized.includes(prerequisite)) { normalized.splice(normalized.indexOf('manual-state-matrix'), 0, prerequisite) } } } if (normalized.includes('phase3-order-splitting')) { const prerequisites = [ 'merchant-qualification-fixture', 'phase3-container-loading', 'phase3-loading', 'phase3-loading-finalize', ] for (const prerequisite of prerequisites) { if (!normalized.includes(prerequisite)) { normalized.splice(normalized.indexOf('phase3-order-splitting'), 0, prerequisite) } } const splitIndex = normalized.indexOf('phase3-order-splitting') const finalizeIndex = normalized.indexOf('phase3-loading-finalize') if (splitIndex < finalizeIndex) { normalized.splice(splitIndex, 1) normalized.splice(normalized.indexOf('phase3-loading-finalize') + 1, 0, 'phase3-order-splitting') } } if (normalized.includes('phase4-overseas-business') && !normalized.includes('merchant-qualification-fixture')) { normalized.splice(normalized.indexOf('phase4-overseas-business'), 0, 'merchant-qualification-fixture') } for (const consumer of ['phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch']) { if (normalized.includes(consumer) && !normalized.includes('phase4-overseas-pda-operations')) { normalized.splice(normalized.indexOf(consumer), 0, 'phase4-overseas-pda-operations') } } if (normalized.includes('phase4-overseas-pda') && !normalized.includes('phase4-overseas-pda-operations')) { normalized.splice(normalized.indexOf('phase4-overseas-pda') + 1, 0, 'phase4-overseas-pda-operations') } 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', 'phase3-loading-finalize', 'phase3-customs', 'phase4-overseas-pda-operations'] const targetIndex = normalized.indexOf('phase4-cross-region-events') const missing = prerequisites.filter((name) => !normalized.includes(name)) if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) } const financeChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation'] const firstFinanceIndex = normalized.findIndex((name) => financeChain.includes(name)) if (firstFinanceIndex >= 0) { const prerequisites = ['merchant-qualification-fixture', 'orders', 'inbound-measurement', 'phase3-container-loading'] const missing = prerequisites.filter((name) => !normalized.includes(name)) if (missing.length > 0) normalized.splice(firstFinanceIndex, 0, ...missing) } const financeDownloadsIndex = normalized.indexOf('finance-downloads') if (financeDownloadsIndex >= 0 && financeChain.some((name) => normalized.includes(name))) { for (const prerequisite of financeChain) { if (!normalized.includes(prerequisite)) normalized.splice(normalized.indexOf('finance-downloads'), 0, prerequisite) } const completeFinanceChain = [...financeChain, 'finance-downloads'] const firstChainIndex = normalized.findIndex((name) => completeFinanceChain.includes(name)) let predecessor = firstChainIndex > 0 ? normalized[firstChainIndex - 1] : null if (!predecessor) predecessor = ['phase3-container-loading', 'inbound-measurement', 'orders', 'merchant-qualification-fixture'] .find((name) => normalized.includes(name)) || null const withoutChain = normalized.filter((name) => !completeFinanceChain.includes(name)) const insertionIndex = predecessor ? withoutChain.indexOf(predecessor) + 1 : withoutChain.length withoutChain.splice(insertionIndex, 0, ...completeFinanceChain) normalized.splice(0, normalized.length, ...withoutChain) } if (normalized.includes('fee-items')) { const prerequisites = ['merchant-qualification-fixture', 'orders'] const feeItemsIndex = normalized.indexOf('fee-items') const ordered = prerequisites.every((name, index) => { const prerequisiteIndex = normalized.indexOf(name) const previousIndex = index === 0 ? -1 : normalized.indexOf(prerequisites[index - 1]) return prerequisiteIndex > previousIndex && prerequisiteIndex < feeItemsIndex }) if (!ordered) { const requested = normalized.filter((name) => !prerequisites.includes(name)) requested.splice(requested.indexOf('fee-items'), 0, ...prerequisites) normalized.splice(0, normalized.length, ...requested) } } if (normalized.includes('inbound-fees')) { const prerequisites = ['merchant-qualification-fixture', 'orders', 'inbound-measurement'] const targetIndex = normalized.indexOf('inbound-fees') const missing = prerequisites.filter((name) => !normalized.includes(name)) if (missing.length > 0) normalized.splice(targetIndex, 0, ...missing) } const customsIndex = normalized.indexOf('phase3-customs') const finalizeIndex = normalized.indexOf('phase3-loading-finalize') if (customsIndex >= 0 && finalizeIndex >= 0 && customsIndex < finalizeIndex) { normalized.splice(finalizeIndex, 1) normalized.splice(normalized.indexOf('phase3-customs'), 0, 'phase3-loading-finalize') } const loadingIndex = normalized.indexOf('phase3-loading') const overviewIndex = normalized.indexOf('phase3-container-overview') if (loadingIndex >= 0 && overviewIndex >= 0 && overviewIndex < loadingIndex) { normalized.splice(overviewIndex, 1) normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview') } for (const module of [...normalized]) { const missing = dependencyClosure(module).filter((dependency) => !normalized.includes(dependency)) if (missing.length > 0) normalized.splice(normalized.indexOf(module), 0, ...missing) } return stableTopologicalOrder([...new Set(normalized)]) } const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES) const WEIGHT_IMAGE_PATH = process.env.ADMIN_TEST_WEIGHT_IMAGE_PATH || '' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', 'contactName', 'contactPhone', 'contactEmail', 'contactWechat', 'servicePackage', 'salesOwnerName', 'paymentTermDays', 'enterpriseType', 'establishedDate', 'registeredCapital', 'registeredCapitalCurrency', 'registeredAddress', 'internalRemark', 'createdAt', ] const BOX_DEFAULTS = [ { boxType: 'SMALL', dimensions: ['30.000', '20.000', '20.000'], remark: '标准小箱' }, { boxType: 'MEDIUM', dimensions: ['40.000', '30.000', '30.000'], remark: '标准中箱' }, { boxType: 'LARGE', dimensions: ['50.000', '40.000', '40.000'], remark: '标准大箱' }, { boxType: 'EXTRA_LARGE', dimensions: ['60.000', '50.000', '50.000'], remark: '特大箱' }, ] function usage() { console.log(`后台管理测试环境 HTTP API 验收 用法: node scripts/admin-test-env-acceptance.mjs [options] 选项: --gateway 必填,国内 Gateway 地址 --overseas-gateway 四期海外模块必填,海外 Gateway 地址 --username 必填,管理员账号 --password 必填,管理员密码 --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} --customer-name 指定本轮隔离客户名称,默认自动生成 --merchant-username 复用既有已审核客户账号,不再创建客户 --merchant-password 复用客户账号密码,不写入报告 --origin-warehouse-name 固定国内起运仓名称 --channel-name 固定物流渠道名称 --channel-code 固定物流渠道编码 --order-profile-mode 订单档案模式:fixed 或 randomized,默认fixed --wait-split-count 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1 --wait-split-package-count 单票指定总箱数,1-1000;省略时使用既有轮换 --wait-split-sequence 单票全局序号,正整数,默认1 --wait-split-random-value 单票随机选择值,正整数,默认使用全局序号 --verify-pallet-location-guide 验证整票库位块、图片槽绑定与删除后 RESERVED_EMPTY --route-mode 线路选择:fixed 或 random,默认fixed --manual-split-fixture-report 人工分柜准备报告路径,仅只读核验模块使用 --manual-split-plan-id 人工操作产生的智能装柜计划 ID,仅只读核验模块使用 --manual-preload-source-report 人工推送预装前置或核验来源报告路径 --manual-pda-source-report 人工订舱关联、预装确认前置或核验来源报告路径 --manual-pda-batch-source-report PDA 人工领取、扫码批量提交前置或核验来源报告路径 --manual-loading-complete-source-report 五类证据与 PDA 最终完成前置或核验来源报告路径 --output JSON 报告路径,默认 reports/ --help 显示帮助 也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_OVERSEAS_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD, ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME, MERCHANT_TEST_PASSWORD, ADMIN_WAIT_SPLIT_COUNT`) } export function parseArguments(argv) { if (argv.includes('--help')) { usage(); process.exit(0) } const values = new Map() for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] if (!argument.startsWith('--')) throw new Error(`无法识别的参数: ${argument}`) const separator = argument.indexOf('=') const name = separator >= 0 ? argument.slice(2, separator) : argument.slice(2) const inline = separator >= 0 ? argument.slice(separator + 1) : undefined if (inline !== undefined) values.set(name, inline) else { const next = argv[index + 1] if (!next || next.startsWith('--')) throw new Error(`参数 --${name} 缺少值`) values.set(name, next); index += 1 } } const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(',')) .split(',').map((v) => v.trim()).filter(Boolean) const modules = normalizeModuleOrder(requestedModules) const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || '' const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' const requiresMarkerOwnedMerchantFixture = [ 'phase4-overseas-business', 'phase4-overseas-client-services', 'phase5-overseas-fulfillment', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', ].some((module) => modules.includes(module)) if (merchantUsername && merchantPassword && !requiresMarkerOwnedMerchantFixture) { const fixtureIndex = modules.indexOf('merchant-qualification-fixture') if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) } return { gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, ''), overseasGateway: (values.get('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY || '').replace(/\/$/, ''), username: values.get('username') || process.env.ADMIN_TEST_USERNAME || '', password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || '', modules, requestedModules, customerName: values.get('customer-name') || process.env.ADMIN_TEST_CUSTOMER_NAME || '', merchantUsername, merchantPassword, originWarehouseName: values.get('origin-warehouse-name') || '', channelName: values.get('channel-name') || '', channelCode: values.get('channel-code') || '', orderProfileMode: values.get('order-profile-mode') || 'fixed', waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT || (modules.includes('coload-groups') ? 8 : modules.includes('manual-order-splitting-fixture') ? 6 : 1)), waitSplitPackageCount: Number(values.get('wait-split-package-count') || 0), waitSplitSequence: Number(values.get('wait-split-sequence') || 1), waitSplitRandomValue: Number(values.get('wait-split-random-value') || values.get('wait-split-sequence') || 1), verifyPalletLocationGuide: values.has('verify-pallet-location-guide'), routeMode: values.get('route-mode') || 'fixed', quiet: values.get('quiet') === 'true', manualSplitFixtureReport: values.get('manual-split-fixture-report') || '', manualSplitPlanId: values.get('manual-split-plan-id') || '', manualPreloadSourceReport: values.get('manual-preload-source-report') || '', manualPdaSourceReport: values.get('manual-pda-source-report') || '', manualPdaBatchSourceReport: values.get('manual-pda-batch-source-report') || '', manualLoadingCompleteSourceReport: values.get('manual-loading-complete-source-report') || '', output: values.get('output') || '', } } export function validateExecutionOptions(options) { const unavailable = options.modules.filter((module) => !AVAILABLE_MODULES.has(module)) if (unavailable.length > 0) throw new Error(`未注册的 HTTP 验收模块: ${unavailable.join(',')}`) 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') } if (!Number.isInteger(options.waitSplitCount) || options.waitSplitCount < 1 || options.waitSplitCount > 100) { throw new Error('wait-split-count必须是1-100的整数') } if (!Number.isInteger(options.waitSplitPackageCount) || options.waitSplitPackageCount < 0 || options.waitSplitPackageCount > 1000) { throw new Error('wait-split-package-count必须是1-1000的整数或0') } if (!Number.isInteger(options.waitSplitSequence) || options.waitSplitSequence < 1) { throw new Error('wait-split-sequence必须是正整数') } if (!Number.isInteger(options.waitSplitRandomValue) || options.waitSplitRandomValue < 1) { throw new Error('wait-split-random-value必须是正整数') } if (options.customerName && options.customerName.trim().length > 128) { throw new Error('customer-name不能超过128个字符') } if (Boolean(options.merchantUsername) !== Boolean(options.merchantPassword)) { throw new Error('merchant-username和merchant-password必须同时提供') } for (const [name, value] of [['origin-warehouse-name', options.originWarehouseName], ['channel-name', options.channelName], ['channel-code', options.channelCode]]) { if (value && value.trim().length > 128) throw new Error(`${name}不能超过128个字符`) } if (!['fixed', 'randomized'].includes(options.orderProfileMode)) { throw new Error('order-profile-mode必须为fixed或randomized') } if (!['fixed', 'random'].includes(options.routeMode)) { throw new Error('route-mode必须为fixed或random') } if (options.routeMode === 'random' && !options.originWarehouseName) { throw new Error('random线路必须固定起运仓名称') } if (options.orderProfileMode === 'randomized' && options.routeMode !== 'random' && (!options.originWarehouseName || !options.channelName || !options.channelCode)) { throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码') } if (options.waitSplitCount > 1) { const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement', 'manual-order-splitting-fixture', 'coload-groups', 'firstleg-container-fee-allocation']) if (options.modules.some((module) => !fixtureModules.has(module))) { throw new Error('批量WAIT_SPLIT只允许执行WAIT_SPLIT造数、同柜组与柜次跨客户分摊模块') } } if (options.modules.includes('manual-order-splitting-fixture') && options.waitSplitCount < 4) { throw new Error('manual-order-splitting-fixture要求wait-split-count至少为4') } if (options.modules.includes('manual-order-splitting-verify')) { if (!options.manualSplitFixtureReport) throw new Error('manual-order-splitting-verify缺少manual-split-fixture-report') if (!/^[1-9][0-9]*$/.test(String(options.manualSplitPlanId))) { throw new Error('manual-order-splitting-verify要求manual-split-plan-id为正整数') } } for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) { if (options.modules.includes(module) && !options.manualPreloadSourceReport) { throw new Error(`${module}缺少manual-preload-source-report`) } } for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) { if (options.modules.includes(module) && !options.manualPdaSourceReport) { throw new Error(`${module}缺少manual-pda-source-report`) } } for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) { if (options.modules.includes(module) && !options.manualPdaBatchSourceReport) { throw new Error(`${module}缺少manual-pda-batch-source-report`) } } for (const module of ['manual-loading-complete-fixture', 'manual-loading-complete-verify']) { if (options.modules.includes(module) && !options.manualLoadingCompleteSourceReport) { throw new Error(`${module}缺少manual-loading-complete-source-report`) } } 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 } function halfUp3(value) { return Math.round((Number(value) + Number.EPSILON) * 1000) / 1000 } function derived(length, width, height, edge) { const outerLength = Number(length) + Number(edge) * 2 const outerWidth = Number(width) + Number(edge) * 2 const outerHeight = Number(height) + Number(edge) * 2 return { outerLength, outerWidth, outerHeight, volume: halfUp3(outerLength * outerWidth * outerHeight / 1_000_000) } } export function buildAcceptanceSummary(steps = [], modules = [], coverageGaps = []) { const failed = steps.filter((step) => !step.passed).length const executedModules = modules.filter((module) => !module.skipped) const uniqueModuleNames = new Set(executedModules.map((module) => module.name).filter(Boolean)) const uniqueRequestKeys = new Set() const uniqueStepNameSet = new Set() for (const step of steps) { if (step.name) uniqueStepNameSet.add(step.name) const method = step.request?.method const path = step.request?.path if (!method || !path) continue uniqueRequestKeys.add(`${method}\u001f${path}\u001f${step.requestId || ''}`) } const coverageGapStats = { total: coverageGaps.length, blocking: 0, nonBlocking: 0, byCategory: {}, } for (const gap of coverageGaps) { const category = gap.category || 'uncategorized' const blocking = gap.blocking === true const categoryStats = coverageGapStats.byCategory[category] || { total: 0, blocking: 0, nonBlocking: 0 } categoryStats.total += 1 if (blocking) { coverageGapStats.blocking += 1 categoryStats.blocking += 1 } else { coverageGapStats.nonBlocking += 1 categoryStats.nonBlocking += 1 } coverageGapStats.byCategory[category] = categoryStats } return { total: steps.length, passed: steps.length - failed, failed, moduleRuns: executedModules.length, uniqueModules: uniqueModuleNames.size, uniqueRequests: uniqueRequestKeys.size, uniqueStepNames: uniqueStepNameSet.size, skippedModules: modules.length - executedModules.length, coverageGaps: coverageGapStats, } } export class AcceptanceRun { constructor(options) { this.options = options const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12) this.marker = `ADMIN-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}` this.startedAt = new Date().toISOString() this.steps = [] this.modules = [] this.scenarios = [] this.createdResources = {} this.phase4ProducerAttempts = new Map() this.deploymentBlockers = [] this.coverageGaps = [] this.context = {} this.token = '' this.refreshToken = '' this.overseasToken = '' this.overseasExpiresAt = 0 this.fatal = null } async request(method, path, body = undefined, token = this.token, extraHeaders = {}) { const headers = { Accept: 'application/json', ...extraHeaders } if (token) headers.Authorization = `Bearer ${token}` if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' const maxAttempts = ['GET', 'HEAD'].includes(String(method).toUpperCase()) ? 3 : 1 for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { const response = await fetch(`${this.options.gateway}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30_000), }) const content = await response.text() let payload = null try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ } return { method, path, http: response.status, body: payload, raw: payload === null ? content.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id'), } } catch (error) { if (attempt < maxAttempts) { await new Promise((resolve) => setTimeout(resolve, attempt * 250)) continue } return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } } } throw new Error('unreachable request attempt state') } async requestOverseas(method, path, body = undefined, extraHeaders = {}, token = this.overseasToken) { const headers = { Accept: 'application/json', ...extraHeaders } if (token) headers.Authorization = `Bearer ${token}` if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' try { const response = await fetch(`${this.options.overseasGateway}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30_000), }) const content = await response.text() let payload = null try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ } return { method, path, http: response.status, body: payload, raw: payload === null ? content.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id'), } } catch (error) { return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } } } async requestMultipart(method, path, file, token = this.token, extraHeaders = {}) { const form = file instanceof FormData ? file : new FormData() if (!(file instanceof FormData)) { if (!file || file.content === undefined) throw new TypeError('multipart file requires content') const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content) form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin') } return this.requestWithBody(method, path, form, token, extraHeaders) } async requestOverseasMultipart(method, path, file, token = this.overseasToken, extraHeaders = {}) { const form = file instanceof FormData ? file : new FormData() if (!(file instanceof FormData)) { if (!file || file.content === undefined) throw new TypeError('multipart file requires content') const content = file.content instanceof Uint8Array ? file.content : new Uint8Array(file.content) form.append('file', new Blob([content], { type: file.type || 'application/octet-stream' }), file.name || 'fixture.bin') } const headers = { Accept: 'application/json', ...extraHeaders } if (token) headers.Authorization = `Bearer ${token}` try { const response = await fetch(`${this.options.overseasGateway}${path}`, { method, headers, body: form, signal: AbortSignal.timeout(60_000), }) const content = await response.text(); let payload = null try { payload = content ? JSON.parse(content) : null } catch { /* bounded raw response */ } return { method, path, http: response.status, body: payload, raw: payload === null ? content.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } } catch (error) { return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } } } async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) { const headers = { Accept: 'application/json', ...extraHeaders } if (token) headers.Authorization = `Bearer ${token}` if (!(body instanceof FormData) && body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' try { const response = await fetch(`${this.options.gateway}${path}`, { method, headers, body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), signal: AbortSignal.timeout(60_000), }) const content = await response.text(); let payload = null try { payload = content ? JSON.parse(content) : null } catch { /* bounded raw response */ } return { method, path, http: response.status, body: payload, raw: payload === null ? content.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id') } } catch (error) { return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } } } async requestBinary(method, path) { const headers = { Accept: '*/*', Authorization: `Bearer ${this.token}` } const maxAttempts = ['GET', 'HEAD'].includes(String(method).toUpperCase()) ? 3 : 1 for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { const response = await fetch(`${this.options.gateway}${path}`, { method, headers, signal: AbortSignal.timeout(120_000) }) const bytes = new Uint8Array(await response.arrayBuffer()) const contentType = response.headers.get('content-type') || '' let payload = null if (contentType.toLowerCase().includes('json')) { try { payload = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* Invalid envelope remains binary evidence. */ } } return { method, path, http: response.status, body: payload, bytes, contentType, raw: payload ? null : `${bytes.length} bytes (${contentType || 'unknown'})`, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id'), } } catch (error) { if (attempt < maxAttempts) { await new Promise((resolve) => setTimeout(resolve, attempt * 250)) continue } return { method, path, http: 0, body: null, bytes: new Uint8Array(), contentType: '', raw: String(error), requestId: null, traceId: null } } } throw new Error('unreachable binary request attempt state') } async requestOverseasBinary(method, path) { const headers = { Accept: '*/*' } if (this.overseasToken) headers.Authorization = `Bearer ${this.overseasToken}` try { const response = await fetch(`${this.options.overseasGateway}${path}`, { method, headers, signal: AbortSignal.timeout(120_000), }) const bytes = new Uint8Array(await response.arrayBuffer()) const contentType = response.headers.get('content-type') || '' let payload = null if (contentType.toLowerCase().includes('json')) { try { payload = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* Invalid envelope remains binary evidence. */ } } return { method, path, http: response.status, body: payload, bytes, contentType, raw: payload ? null : `${bytes.length} bytes (${contentType || 'unknown'})`, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id'), } } catch (error) { return { method, path, http: 0, body: null, bytes: new Uint8Array(), contentType: '', raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } } } addStep(module, name, response, passed, evidence = '', options = {}) { const step = { module, scenario: options.scenario || null, name, passed: Boolean(passed), deploymentBlocker: Boolean(options.deploymentBlocker && !passed), request: response ? { method: response.method, path: response.path } : null, http: response?.http ?? null, code: response?.body?.code === undefined ? null : String(response.body.code), message: response?.body?.message || response?.raw || null, requestId: response?.requestId || null, traceId: response?.traceId || null, evidence, checkedAt: new Date().toISOString(), } this.steps.push(step) if (step.deploymentBlocker) this.deploymentBlockers.push({ module, scenario: step.scenario, name, evidence, code: step.code, requestId: step.requestId, traceId: step.traceId }) if (!this.options.quiet) { console.log(`[${step.passed ? 'PASS' : 'FAIL'}] [${module}${step.scenario ? `/${step.scenario}` : ''}] ${name}${evidence ? ` - ${evidence}` : ''}`) } return step.passed } addCoverageGap(module, scenario, name, reason, category = 'not-implemented', metadata = {}) { const nonBlocking = new Set(['device', 'external-third-party', 'visual']) const blocking = metadata.blocking ?? !nonBlocking.has(category) const gap = { module, scenario, name, reason, category, blocking, ...metadata } const duplicate = this.coverageGaps.some((existing) => existing.module === gap.module && existing.scenario === gap.scenario && existing.name === gap.name && existing.reason === gap.reason && existing.category === gap.category && existing.blocking === gap.blocking) if (duplicate) return this.coverageGaps.push(gap) if (blocking) { this.addStep(module, `覆盖阻断:${name}`, null, false, reason, { scenario, deploymentBlocker: true }) } } apiSuccess(module, name, response, evidence = '', options = {}) { const passed = response.http === 200 && String(response.body?.code) === '200' this.addStep(module, name, response, passed, evidence, options) return passed ? response.body.data : null } assert(module, name, response, condition, evidence, options = {}) { return this.addStep(module, name, response, Boolean(condition), evidence, options) } expectConflict(module, name, response, scenario) { const passed = response.http === 200 && ['A0409', '409'].includes(String(response.body?.code)) this.addStep(module, name, response, passed, `HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`, { scenario, deploymentBlocker: true }) return passed } async executeModule(name, handler) { const startedAt = new Date().toISOString(); const start = this.steps.length; let fatal = null try { await handler() } catch (error) { fatal = error instanceof Error ? error.message : String(error) this.addStep(name, '模块执行', null, false, fatal) } const steps = this.steps.slice(start) this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal }) } captureWaitSplitFixture(sequence) { const order = this.createdResources.orders || {} const measurement = this.createdResources.inboundMeasurement || {} const ready = Boolean(order.orderId && measurement.inboundId && String(order.tenantId) === String(measurement.tenantId) && measurement.processStatus === 'WAIT_SPLIT' && measurement.finalStatus === 'APPROVED') this.assert('wait-split-fixtures', `固定客户第 ${sequence} 单停在待分柜`, null, ready, `tenantId=${measurement.tenantId || 'missing'}, orderId=${order.orderId || 'missing'}, inboundId=${measurement.inboundId || 'missing'}, processStatus=${measurement.processStatus || 'missing'}, reviewStatus=${measurement.finalStatus || 'missing'}`, { scenario: 'fixed-customer-wait-split', deploymentBlocker: true }) if (!ready) return false this.createdResources.waitSplitFixtures ??= [] this.createdResources.waitSplitFixtures.push({ sequence, tenantId: String(measurement.tenantId), orderId: String(order.orderId), orderNo: order.orderNo || null, inboundId: String(measurement.inboundId), inboundCode: order.inboundCode || null, processStatus: measurement.processStatus, reviewStatus: measurement.finalStatus, skuCount: order.skuLines?.length || null, totalPackageCount: (order.skuLines || []).reduce((sum, item) => sum + Number(item.packageCount || 0), 0), palletCount: measurement.palletCount || null, actualGrossWeightKg: measurement.actualGrossWeightKg || null, actualNetWeightKg: measurement.actualNetWeightKg || null, measurementProfile: this.context.waitSplitVariant?.measurementProfile || null, receivingWarehouseId: order.receivingWarehouseId || null, receivingWarehouseName: order.receivingWarehouseName || null, destinationWarehouseId: order.destinationWarehouseId || null, destinationWarehouseCode: order.destinationWarehouseCode || null, destinationWarehouseName: order.destinationWarehouseName || null, channelCode: order.channelCode || null, channelName: order.channelName || null, pricingTaxMode: order.pricingTaxMode || null, destinationType: order.destinationType || null, fulfillmentType: order.fulfillmentType || null, pickupMethod: order.pickupMethod || null, taxMode: order.taxMode || null, containerMode: order.containerMode || null, customsInspection: order.customsInspection ?? null, }) return true } waitSplitVariant(sequence) { const dimensionProfiles = [ { code: 'SMALL', first: { length: 29.1, width: 19.1, height: 18.1 }, replacement: { length: 29.5, width: 19.5, height: 18.5 } }, { code: 'MEDIUM', first: { length: 39.1, width: 29.1, height: 28.1 }, replacement: { length: 39.5, width: 29.5, height: 28.5 } }, { code: 'LARGE', first: { length: 49.1, width: 39.1, height: 38.1 }, replacement: { length: 49.5, width: 39.5, height: 38.5 } }, { code: 'EXTRA_LARGE', first: { length: 59.1, width: 49.1, height: 48.1 }, replacement: { length: 59.5, width: 49.5, height: 48.5 } }, ] const randomValue = this.options.waitSplitRandomValue || sequence const requestedTotal = Number(this.options.waitSplitPackageCount || 0) const skuCount = requestedTotal > 0 ? 1 + (randomValue % Math.min(4, requestedTotal)) : 1 + ((sequence - 1) % 4) const packageCountPerSku = requestedTotal > 0 ? 1 : 1 + (Math.floor((sequence - 1) / 4) % 5) const packageCounts = [] if (requestedTotal > 0) { let remaining = requestedTotal let state = randomValue >>> 0 for (let index = 0; index < skuCount - 1; index += 1) { state = (Math.imul(state, 1664525) + 1013904223) >>> 0 const maximum = remaining - (skuCount - index - 1) const count = 1 + (state % maximum) packageCounts.push(count) remaining -= count } packageCounts.push(remaining) } const totalPackageCount = requestedTotal || skuCount * packageCountPerSku const palletCount = requestedTotal > 0 ? Math.ceil(totalPackageCount / 30) : 1 + (randomValue % 3) const grossWeightPerPalletKg = 45 + (randomValue % 6) * 15 return { sequence, skuCount, packageCountPerSku, packageCounts, totalPackageCount, palletCount, grossWeightPerPalletKg, measurementProfile: dimensionProfiles[randomValue % dimensionProfiles.length], } } randomizedOrderProfile(sequence) { const combinations = [ ['FBA', 'STANDARD'], ['OVERSEAS', 'STANDARD'], ['PRIVATE', 'STANDARD'], ['FBA', 'ONE_CLICK_DROPSHIP'], ['OVERSEAS', 'ONE_CLICK_DROPSHIP'], ['PRIVATE', 'ONE_CLICK_DROPSHIP'], ] const randomValue = this.options.waitSplitRandomValue || sequence const [destinationType, fulfillmentType] = combinations[randomValue % combinations.length] const pickupMethods = ['PICKUP', 'SELF_EXPRESS', 'SELF_TRUCK'] return { destinationType, fulfillmentType, pickupMethod: pickupMethods[Math.floor(randomValue / 7) % pickupMethods.length], taxMode: randomValue % 2 === 0 ? 'TAX_REFUND' : 'BUY_DECLARATION', containerMode: Math.floor(randomValue / 11) % 2 === 0 ? 'FULL_CONTAINER' : 'LCL', customsInspection: Math.floor(randomValue / 13) % 4 === 0, } } applyWaitSplitVariant(sequence) { const variant = this.waitSplitVariant(sequence) this.context.waitSplitVariant = variant this.context.phase3LoadingIncrementalDetailCount = variant.skuCount this.context.phase3LoadingIncrementalPackageCount = variant.packageCountPerSku const profile = this.options.orderProfileMode === 'randomized' ? this.randomizedOrderProfile(sequence) : { destinationType: 'FBA', fulfillmentType: 'STANDARD', pickupMethod: 'SELF_EXPRESS', taxMode: 'BUY_DECLARATION', containerMode: 'LCL', customsInspection: false } this.context.orderProfile = profile this.context.orderDestinationType = profile.destinationType this.context.orderFulfillmentType = profile.fulfillmentType this.context.waitSplitRoute = { originWarehouseName: this.options.originWarehouseName || '', channelName: this.options.channelName || '', channelCode: this.options.channelCode || '', randomize: this.options.routeMode === 'random', randomIndex: this.options.waitSplitRandomValue || sequence, destinationIndex: (sequence * 17 + 11), taxModeIndex: sequence - 1, } } async createAdditionalWaitSplitFixtures() { const total = this.options.waitSplitCount if (total <= 1) return const baseMarker = this.marker const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) try { for (let sequence = 2; sequence <= total; sequence += 1) { this.marker = `${baseMarker}-WS${String(sequence).padStart(2, '0')}` this.applyWaitSplitVariant(sequence) const orderStepStart = this.steps.length await this.executeModule('orders', () => orders.run(this)) const orderRun = this.modules.at(-1) const orderBlockingFailure = Boolean(orderRun?.fatal) || this.steps.slice(orderStepStart).some((step) => !step.passed && step.deploymentBlocker) if (orderBlockingFailure) break const measurementStepStart = this.steps.length await this.executeModule('inbound-measurement', () => measurement.run(this)) const measurementRun = this.modules.at(-1) const measurementBlockingFailure = Boolean(measurementRun?.fatal) || this.steps.slice(measurementStepStart).some((step) => !step.passed && step.deploymentBlocker) if (measurementBlockingFailure || !this.captureWaitSplitFixture(sequence)) break } } finally { this.marker = baseMarker } } async refreshSession(reason) { const response = await this.request('POST', '/api/auth/refresh', { refreshToken: this.refreshToken }, '') const next = this.apiSuccess('authentication', '国内 refresh 签发新双区域会话', response, `reason=${reason}`, { scenario: 'dual-token-refresh', deploymentBlocker: true }) const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken) && Boolean(next?.overseasAccessToken) && Number(next?.overseasExpiresIn) > 0 this.assert('authentication', '刷新响应完整后原子替换国内与海外Token', response, valid, `accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}, overseasTokenPresent=${Boolean(next?.overseasAccessToken)}, overseasExpiresIn=${next?.overseasExpiresIn ?? 'missing'}`, { scenario: 'dual-token-refresh', deploymentBlocker: true }) if (!valid) return false const replacement = { token: next.accessToken, refreshToken: next.refreshToken, overseasToken: next.overseasAccessToken, overseasExpiresAt: Date.now() + Number(next.overseasExpiresIn) * 1000, } this.token = replacement.token this.refreshToken = replacement.refreshToken this.overseasToken = replacement.overseasToken this.overseasExpiresAt = replacement.overseasExpiresAt return true } async findAvailablePhone(module = 'customers') { this.phoneSequence = Number(this.phoneSequence || 0) + 1 for (let attempt = 0; attempt < 100; attempt += 1) { const seed = `${this.marker}:${this.phoneSequence}:${attempt}` let hash = 2166136261 for (const character of seed) { hash ^= character.charCodeAt(0) hash = Math.imul(hash, 16777619) >>> 0 } const phone = `18${String(hash).padStart(9, '0').slice(-9)}` const response = await this.request('GET', `/api/platform/v1/iam/tenants/administrator-phone-availability?phone=${encodeURIComponent(phone)}`) const available = response?.http === 200 && String(response?.body?.code) === '200' && response?.body?.data === true if (!available) continue this.addStep(module, '生成本轮隔离测试联系电话', response, true, `sequence=${this.phoneSequence}, attempts=${attempt + 1}`) return phone } this.addStep(module, '生成本轮隔离测试联系电话', null, false, `sequence=${this.phoneSequence}, attempts=100;未找到可用手机号`, { deploymentBlocker: true }) throw new Error('未找到可用的隔离测试手机号') } async runCustomerDimensionRules(tenantId) { const module = 'customers'; const scenario = 'customer-dimension-rules'; const start = this.steps.length const options = { scenario, deploymentBlocker: true } const path = `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${tenantId}` const initialResponse = await this.request('GET', path) const group = this.apiSuccess(module, '新客户尺寸规则可查询', initialResponse, `tenantId=${tenantId}`, options) const rules = Array.isArray(group?.rules) ? group.rules : [] const orderedTypes = rules.map((rule) => rule.boxType) this.assert(module, '固定四箱规顺序完整且无重复', initialResponse, rules.length === 4 && orderedTypes.join(',') === BOX_DEFAULTS.map((item) => item.boxType).join(',') && new Set(orderedTypes).size === 4, `count=${rules.length}, order=${orderedTypes.join(',')}`, options) const defaultsCorrect = BOX_DEFAULTS.every((expected, index) => { const rule = rules[index] return rule?.boxType === expected.boxType && numberEquals(rule.innerLengthCm, expected.dimensions[0]) && numberEquals(rule.innerWidthCm, expected.dimensions[1]) && numberEquals(rule.innerHeightCm, expected.dimensions[2]) && numberEquals(rule.edgeAdditionCm, '2.000') && rule.remark === expected.remark }) this.assert(module, '四箱规默认内径、加边和备注正确', initialResponse, defaultsCorrect, defaultsCorrect ? 'defaults=matched' : '默认尺寸、edge 或备注不匹配', options) const identityCorrect = typeof group?.tenantId === 'string' && group.tenantId === tenantId && rules.length === 4 && rules.every((rule) => typeof rule.id === 'string' && Number(rule.version) === 0) this.assert(module, '客户 ID、规则 ID 为字符串且初始版本为0', initialResponse, identityCorrect, `groupTenantIdType=${typeof group?.tenantId}, rows=${rules.map((r) => `${typeof r.id}/v${r.version}`).join(',')}`, options) const initialDerivedCorrect = rules.length === 4 && rules.every((rule) => { const value = derived(rule.innerLengthCm, rule.innerWidthCm, rule.innerHeightCm, rule.edgeAdditionCm) return numberEquals(rule.outerLengthCm, value.outerLength) && numberEquals(rule.outerWidthCm, value.outerWidth) && numberEquals(rule.outerHeightCm, value.outerHeight) && numberEquals(rule.volumeCbm, value.volume) }) this.assert(module, '默认规则外径与 HALF_UP 三位 CBM 正确', initialResponse, initialDerivedCorrect, `derivedRows=${rules.length}`, options) if (rules.length === 4) { const updatePayload = { rules: rules.map((rule, index) => ({ boxType: rule.boxType, innerLengthCm: Number((Number(rule.innerLengthCm) + index + 0.125).toFixed(3)), innerWidthCm: Number((Number(rule.innerWidthCm) + index + 0.250).toFixed(3)), innerHeightCm: Number((Number(rule.innerHeightCm) + index + 0.375).toFixed(3)), edgeAdditionCm: Number((2.125 + index * 0.125).toFixed(3)), remark: `${this.marker}-${rule.boxType}`, version: Number(rule.version), })) } const allowed = ['boxType', 'innerLengthCm', 'innerWidthCm', 'innerHeightCm', 'edgeAdditionCm', 'remark', 'version'] this.assert(module, 'PUT 每行只发送约定7字段并整组保存', initialResponse, updatePayload.rules.length === 4 && updatePayload.rules.every((row) => Object.keys(row).join(',') === allowed.join(',')), `fields=${Object.keys(updatePayload.rules[0]).join(',')}`, options) const updateResponse = await this.request('PUT', path, updatePayload) const updatedGroup = this.apiSuccess(module, '一次 PUT 保存四条三位小数规则', updateResponse, 'rows=4', options) const updatedRules = Array.isArray(updatedGroup?.rules) ? updatedGroup.rules : [] const updatedCorrect = updatedRules.length === 4 && updatePayload.rules.every((expected, index) => { const actual = updatedRules[index]; const value = derived(expected.innerLengthCm, expected.innerWidthCm, expected.innerHeightCm, expected.edgeAdditionCm) return actual?.boxType === expected.boxType && Number(actual.version) === expected.version + 1 && numberEquals(actual.innerLengthCm, expected.innerLengthCm) && numberEquals(actual.innerWidthCm, expected.innerWidthCm) && numberEquals(actual.innerHeightCm, expected.innerHeightCm) && numberEquals(actual.edgeAdditionCm, expected.edgeAdditionCm) && numberEquals(actual.outerLengthCm, value.outerLength) && numberEquals(actual.outerWidthCm, value.outerWidth) && numberEquals(actual.outerHeightCm, value.outerHeight) && numberEquals(actual.volumeCbm, value.volume) && actual.remark === expected.remark }) this.assert(module, '修改后四行版本+1且派生外径/CBM正确', updateResponse, updatedCorrect, `versions=${updatedRules.map((r) => r.version).join(',')}`, options) const stalePayload = { rules: updatePayload.rules.map((rule) => ({ ...rule, innerLengthCm: Number((Number(rule.innerLengthCm) + 9).toFixed(3)), })) } const staleResponse = await this.request('PUT', path, stalePayload) this.expectConflict(module, '旧 version 重放 PUT 返回冲突', staleResponse, scenario) const afterConflictResponse = await this.request('GET', path) const afterConflict = this.apiSuccess(module, '冲突后重新查询四条规则', afterConflictResponse, '', options) const afterRules = Array.isArray(afterConflict?.rules) ? afterConflict.rules : [] const stableSnapshot = (rule) => JSON.stringify({ id: String(rule.id), boxType: rule.boxType, innerLengthCm: Number(rule.innerLengthCm), innerWidthCm: Number(rule.innerWidthCm), innerHeightCm: Number(rule.innerHeightCm), edgeAdditionCm: Number(rule.edgeAdditionCm), outerLengthCm: Number(rule.outerLengthCm), outerWidthCm: Number(rule.outerWidthCm), outerHeightCm: Number(rule.outerHeightCm), volumeCbm: Number(rule.volumeCbm), remark: rule.remark ?? null, version: Number(rule.version), updatedAt: rule.updatedAt ?? null, }) const atomic = afterRules.length === 4 && updatedRules.length === 4 && afterRules.every((rule, index) => stableSnapshot(rule) === stableSnapshot(updatedRules[index])) this.assert(module, '冲突请求未造成四行部分更新', afterConflictResponse, atomic, `unchangedRows=${atomic ? 4 : 0}`, options) const currentByType = new Map(afterRules.map((rule) => [rule.boxType, rule])) const restorePayload = { rules: rules.map((original) => ({ boxType: original.boxType, innerLengthCm: Number(original.innerLengthCm), innerWidthCm: Number(original.innerWidthCm), innerHeightCm: Number(original.innerHeightCm), edgeAdditionCm: Number(original.edgeAdditionCm), remark: original.remark ?? null, version: Number(currentByType.get(original.boxType)?.version), })) } const restorable = restorePayload.rules.length === 4 && restorePayload.rules.every((rule) => Number.isInteger(rule.version) && rule.version >= 0) if (restorable) { const restoreResponse = await this.request('PUT', path, restorePayload) const restoredGroup = this.apiSuccess(module, '恢复本轮修改前的四箱规配置', restoreResponse, 'restore=original-values', options) const restoredRules = Array.isArray(restoredGroup?.rules) ? restoredGroup.rules : [] const restored = restoredRules.length === 4 && rules.every((original, index) => { const actual = restoredRules[index] return actual?.boxType === original.boxType && numberEquals(actual.innerLengthCm, original.innerLengthCm) && numberEquals(actual.innerWidthCm, original.innerWidthCm) && numberEquals(actual.innerHeightCm, original.innerHeightCm) && numberEquals(actual.edgeAdditionCm, original.edgeAdditionCm) && actual.remark === original.remark && Number(actual.version) === restorePayload.rules[index].version + 1 }) this.assert(module, '尺寸规则恢复后回显与初始值一致', restoreResponse, restored, `restoredRows=${restored ? 4 : 0}`, options) } else { this.assert(module, '恢复本轮修改前的四箱规配置', afterConflictResponse, false, '冲突后未返回完整四行及有效版本,无法安全恢复', options) } } else { this.assert(module, '尺寸规则编辑与并发冲突场景', initialResponse, false, '默认规则不是完整四条,禁止降级跳过', options) } const steps = this.steps.slice(start) this.scenarios.push({ name: scenario, module, success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker), passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length }) } async runCustomers() { const module = 'customers'; const phone = await this.findAvailablePhone(module); const suffix = this.marker.slice(-12) const payload = { tenantName: `后台客户验收-${suffix}`, legalName: `后台客户验收有限公司-${suffix}`, legalRepresentative: '接口验收法人', contactName: '接口验收联系人', contactPhone: phone, contactEmail: `admin-api-${suffix.toLowerCase()}@example.com`, servicePackage: 'API验收套餐', contactWechat: `api_${suffix.toLowerCase()}`, salesOwnerName: '接口验收销售', paymentTermDays: 30, enterpriseType: 'LIMITED_COMPANY', establishedDate: '2020-01-02', registeredCapital: 5000000, registeredCapitalCurrency: 'CNY', registeredAddress: '深圳市南山区接口验收地址', internalRemark: this.marker, } const createResponse = await this.request('POST', '/api/platform/v1/iam/tenants', payload) const createData = this.apiSuccess(module, '创建客户', createResponse) if (!createData) throw new Error('创建客户失败,后续客户模块无法继续') const customer = createData.tenant || createData.customer || createData const tenantId = String(customer.id || createData.tenantId || '') if (!tenantId) throw new Error('创建响应未返回客户 ID') this.context.customerId = tenantId this.createdResources.customer = { tenantId, marker: this.marker, contactPhone: phone } this.assert(module, '创建响应客户字段完整', createResponse, hasFields(customer, CUSTOMER_FIELDS), `missing=${CUSTOMER_FIELDS.filter((f) => !own(customer, f)).join(',') || 'none'}`) this.assert(module, '新客户基础字段正确', createResponse, customer.tenantName === payload.tenantName && customer.contactPhone === phone && customer.status === 'PROFILE_REQUIRED', `tenantId=${tenantId}, status=${customer.status}`) const ownerProvisioningOptions = { scenario: 'customer-owner-provisioning', deploymentBlocker: true } this.assert(module, '创建客户生成初始密码', createResponse, typeof createData.temporaryPassword === 'string' && createData.temporaryPassword.length >= 8 && /[A-Za-z]/.test(createData.temporaryPassword) && /\d/.test(createData.temporaryPassword), `generated=${typeof createData.temporaryPassword === 'string'}, length=${createData.temporaryPassword?.length || 0}`, ownerProvisioningOptions) const administratorUsername = String(createData.administratorUsername || '') this.assert(module, '管理员用户名作为独立登录标识返回', createResponse, administratorUsername.length > 0, `present=${administratorUsername.length > 0}`, ownerProvisioningOptions) this.assert(module, '待完善客户的审核字段保持为空', createResponse, customer.unifiedSocialCreditCode == null && customer.approvedAt == null, `creditCode=${customer.unifiedSocialCreditCode ?? 'omitted'}, approvedAt=${customer.approvedAt ?? 'omitted'}`) const dimensionInitializationPath = `/api/platform/v1/iam/tenants/${tenantId}/dimension-initialization` let dimensionInitializationResponse = null let dimensionInitialization = null for (let attempt = 0; attempt < 30; attempt += 1) { dimensionInitializationResponse = await this.request('GET', dimensionInitializationPath) dimensionInitialization = dimensionInitializationResponse.http === 200 && String(dimensionInitializationResponse.body?.code) === '200' ? dimensionInitializationResponse.body?.data : null if (dimensionInitialization?.status === 'READY') break if (attempt < 29) await new Promise((resolve) => setTimeout(resolve, 1_000)) } this.assert(module, '客户默认尺寸异步初始化完成', dimensionInitializationResponse, dimensionInitialization?.status === 'READY', `status=${dimensionInitialization?.status || 'missing'}`, ownerProvisioningOptions) if (dimensionInitialization?.status === 'READY') await this.runCustomerDimensionRules(tenantId) const listResponse = await this.request('GET', `/api/platform/v1/iam/tenants?page=1&size=20&keyword=${encodeURIComponent(payload.tenantName)}`) const page = this.apiSuccess(module, '查询客户列表', listResponse); const listed = page?.records?.find((item) => String(item.id) === tenantId) this.assert(module, '列表包含新客户且字段完整', listResponse, listed && hasFields(listed, CUSTOMER_FIELDS), `found=${Boolean(listed)}, missing=${listed ? CUSTOMER_FIELDS.filter((f) => !own(listed, f)).join(',') || 'none' : 'all'}`) const detailPath = `/api/platform/v1/iam/tenants/${tenantId}` const detailResponse = await this.request('GET', detailPath); const detail = this.apiSuccess(module, '查询客户详情', detailResponse) this.assert(module, '客户详情基本信息正确', detailResponse, detail && detail.tenantName === payload.tenantName && detail.contactPhone === phone && detail.internalRemark === payload.internalRemark, `tenantId=${tenantId}`) const editedPayload = { ...payload, tenantName: `${payload.tenantName}-已编辑`, contactName: '接口验收联系人-已编辑', contactEmail: `edited-${payload.contactEmail}`, paymentTermDays: 45, internalRemark: `${this.marker}-EDITED` } const editResponse = await this.request('PUT', detailPath, editedPayload); const edited = this.apiSuccess(module, '编辑客户', editResponse) this.assert(module, '客户编辑正确回显', editResponse, edited?.tenantName === editedPayload.tenantName && edited?.contactEmail === editedPayload.contactEmail && Number(edited?.paymentTermDays) === 45, `tenantId=${tenantId}`) const persistedResponse = await this.request('GET', detailPath); const persisted = this.apiSuccess(module, '回查客户编辑结果', persistedResponse) this.assert(module, '客户编辑已持久化', persistedResponse, persisted?.tenantName === editedPayload.tenantName && persisted?.internalRemark === editedPayload.internalRemark, `tenantId=${tenantId}`) let customerAuditPage = null let customerAuditResponse = null for (let attempt = 0; attempt < 8; attempt += 1) { customerAuditResponse = await this.request('GET', `/api/platform/v1/audit/logs?accountId=${tenantId}&eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=${tenantId}&page=1&size=20`) customerAuditPage = customerAuditResponse.http === 200 && String(customerAuditResponse.body?.code) === '200' ? customerAuditResponse.body?.data : null if (customerAuditPage?.records?.some((item) => item.eventType === 'PLATFORM_TENANT_UPDATE' && String(item.accountId) === tenantId)) break await new Promise((resolve) => setTimeout(resolve, 500)) } const customerAudit = this.apiSuccess(module, '按客户查询操作日志', customerAuditResponse) const customerAuditRow = customerAudit?.records?.find((item) => item.eventType === 'PLATFORM_TENANT_UPDATE' && String(item.accountId) === tenantId) let auditDiagnostic = 'event=missing' if (!customerAuditRow) { const unscopedResponse = await this.request('GET', `/api/platform/v1/audit/logs?eventType=PLATFORM_TENANT_UPDATE&resourceType=TENANT&resourceId=${tenantId}&page=1&size=20`) const unscopedPage = unscopedResponse.http === 200 && String(unscopedResponse.body?.code) === '200' ? unscopedResponse.body?.data : null const misplaced = unscopedPage?.records?.find((item) => item.eventType === 'PLATFORM_TENANT_UPDATE') auditDiagnostic = misplaced ? `eventAccountId=${misplaced.accountId ?? 'missing'}, expectedAccountId=${tenantId}` : `event=not-delivered, editRequestId=${editResponse.requestId || 'missing'}, editTraceId=${editResponse.traceId || 'missing'}` } this.assert(module, '客户操作日志包含本轮编辑事件', customerAuditResponse, Boolean(customerAuditRow), `tenantId=${tenantId}, ${customerAuditRow ? `event=${customerAuditRow.eventType}` : auditDiagnostic}`) if (customerAuditRow?.id) { const auditDetailResponse = await this.request('GET', `/api/platform/v1/audit/logs/${customerAuditRow.id}`) const auditDetail = this.apiSuccess(module, '查询客户操作日志详情', auditDetailResponse) const snapshotsAreJson = ['beforeData', 'afterData'].every((field) => { if (typeof auditDetail?.[field] !== 'string') return false try { JSON.parse(auditDetail[field]); return true } catch { return false } }) const snapshotText = `${auditDetail?.beforeData || ''}${auditDetail?.afterData || ''}` const snapshotsAreRedacted = !/(password|authorization|accessToken|refreshToken)\s*[\"']?\s*:/i.test(snapshotText) this.assert(module, '操作日志详情返回脱敏 JSON 快照', auditDetailResponse, snapshotsAreJson && snapshotsAreRedacted && String(auditDetail.accountId) === tenantId && String(auditDetail.resourceId) === tenantId, `accountId=${auditDetail?.accountId ?? 'missing'}, resourceId=${auditDetail?.resourceId ?? 'missing'}, snapshotsAreJson=${snapshotsAreJson}, snapshotsAreRedacted=${snapshotsAreRedacted}`) } const qualificationPackage = new FormData() const qualificationImage = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')) qualificationPackage.append('businessLicense', new Blob([qualificationImage], { type: 'image/png' }), `business-license-${suffix}.png`) qualificationPackage.append('legalPersonId', new Blob([qualificationImage], { type: 'image/png' }), `legal-person-id-${suffix}.png`) qualificationPackage.append('unifiedSocialCreditCode', `91440301${suffix.replace(/[^0-9A-Z]/gi, '').toUpperCase().padStart(10, '0').slice(-10)}`) const packageResponse = await this.requestWithBody('POST', `/api/platform/v1/files/qualifications/${tenantId}/required-package`, qualificationPackage) this.apiSuccess(module, '上传审核所需客户资质包', packageResponse, `tenantId=${tenantId}`) const qualificationResponse = await this.request('GET', `${detailPath}/qualifications`); const qualifications = this.apiSuccess(module, '业务概况查询资质', qualificationResponse) const requiredQualifications = Array.isArray(qualifications) ? qualifications.filter((item) => ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].includes(item.qualificationType)) : [] this.assert(module, '审核资质列表包含营业执照和法人身份证', qualificationResponse, requiredQualifications.length === 2 && requiredQualifications.every((item) => typeof item.documentUrl === 'string' && item.documentUrl.length > 0), `types=${requiredQualifications.map((item) => item.qualificationType).join(',') || 'missing'}`) for (const qualification of requiredQualifications) { const fileName = decodeURIComponent(new URL(qualification.documentUrl, 'http://acceptance.local').pathname.split('/').pop() || '') const downloadResponse = await this.requestBinary('GET', `/api/platform/v1/files/qualifications/${tenantId}/${encodeURIComponent(fileName)}`) this.assert(module, `审核人员可下载${qualification.qualificationType}`, downloadResponse, downloadResponse.http === 200 && downloadResponse.bytes.length > 0 && /^image\/|application\/pdf/.test(downloadResponse.contentType), `file=${fileName}, bytes=${downloadResponse.bytes.length}, contentType=${downloadResponse.contentType || 'missing'}`) } const memberResponse = await this.request('GET', `${detailPath}/members`); const members = this.apiSuccess(module, '业务概况查询成员', memberResponse) const owner = members?.find?.((item) => item.memberType === 'OWNER') this.assert(module, '独立用户名账号是企业管理员且联系电话不承担用户名约束', memberResponse, owner?.username === administratorUsername, `ownerFound=${Boolean(owner)}, matches=${owner?.username === administratorUsername}`, ownerProvisioningOptions) const countResponse = await this.request('GET', `/api/platform/v1/orders/first-leg/tenant-counts?tenantIds=${tenantId}`); const counts = this.apiSuccess(module, '业务概况查询运单数量', countResponse); const count = counts?.find?.((item) => String(item.tenantId) === tenantId) this.assert(module, '业务概况返回当前客户运单数', countResponse, count && Number.isFinite(Number(count.orderCount)), `orderCount=${count?.orderCount ?? 'missing'}`) const ordersResponse = await this.request('GET', `/api/platform/v1/orders/first-leg?tenantId=${tenantId}&page=1&size=10`); const orders = this.apiSuccess(module, '查询客户运单记录', ordersResponse) this.assert(module, '运单记录分页结构正确', ordersResponse, Array.isArray(orders?.records) && Number.isFinite(Number(orders?.total)), `records=${orders?.records?.length ?? 'n/a'}, total=${orders?.total ?? 'n/a'}`) const archiveOptions = { scenario: 'customer-draft-archive', deploymentBlocker: true } const archiveResponse = await this.request('POST', `${detailPath}/archive`) const archived = this.apiSuccess(module, '归档测试客户', archiveResponse, `tenantId=${tenantId}`, archiveOptions) if (archived) { this.assert(module, '测试客户归档后处于 ARCHIVED 状态', archiveResponse, archived.status === 'ARCHIVED', `status=${archived.status ?? 'missing'}`, archiveOptions) } } async prepareInboundMeasurementFixture() { const originalMarker = this.marker const fixtureMarker = `${originalMarker}-MOJIA` const createdKeys = ['orders', 'ocrInbound', 'testInbound'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound'] const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], }])) const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], }])) this.marker = fixtureMarker const extension = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) await this.executeModule('orders-inbound-measurement-fixture', () => extension.run(this)) const fixture = this.createdResources.orders this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), inboundMeasurement: fixture ? { marker: fixtureMarker, tenantId: fixture.tenantId, orderId: fixture.orderId, inboundId: fixture.inboundId, } : { marker: fixtureMarker, failed: true }, } return () => { this.marker = originalMarker for (const [key, snapshot] of createdSnapshot) { if (snapshot.present) this.createdResources[key] = snapshot.value else delete this.createdResources[key] } for (const [key, snapshot] of contextSnapshot) { if (snapshot.present) this.context[key] = snapshot.value else delete this.context[key] } } } async preparePhase3ContainerFixture() { const originalMarker = this.marker // Isolate order idempotency, then publish the fixture under the run marker. const fixtureMarker = `${originalMarker}-CAB` const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound'] const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], }])) const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], }])) this.marker = fixtureMarker const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) await this.executeModule('orders-phase3-container-fixture', () => orders.run(this)) const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) await this.executeModule('inbound-measurement-phase3-container-fixture', () => measurement.run(this)) const fixture = this.createdResources.inboundMeasurement if (!fixture?.inboundId || fixture.finalStatus !== 'APPROVED' || fixture.processStatus !== 'WAIT_SPLIT') { throw new Error(`独立柜次测量 fixture 未进入 WAIT_SPLIT,禁止回退使用 WAIT_PRICING 订单: processStatus=${fixture?.processStatus || 'missing'}, measurementStatus=${fixture?.finalStatus || 'missing'}`) } this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), phase3Container: fixture ? { marker: originalMarker, sourceMarker: fixtureMarker, tenantId: fixture.tenantId, orderId: fixture.orderId || this.createdResources.orders?.orderId, inboundId: fixture.inboundId, } : { marker: fixtureMarker, failed: true }, } return () => { this.marker = originalMarker for (const [key, snapshot] of createdSnapshot) { if (snapshot.present) this.createdResources[key] = snapshot.value else delete this.createdResources[key] } for (const [key, snapshot] of contextSnapshot) { if (snapshot.present) this.context[key] = snapshot.value else delete this.context[key] } } } async preparePhase3LoadingFixture(suffix = '', requireSharedOverflow = true, taxModes = null) { const originalMarker = this.marker const fixtureMarker = `${originalMarker}-LOAD${suffix}` const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound', 'phase3LoadingRequireSharedOverflow', 'phase3LoadingIncrementalPackageCount', 'phase3LoadingIncrementalDetailCount', 'orderProfile'] const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], }])) const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], }])) this.marker = fixtureMarker if (taxModes?.primary) { this.context.orderProfile = { ...(this.context.orderProfile || {}), taxMode: taxModes.primary, } } const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) // One-click dropship has a physically indivisible sealed pallet. The phase-three loading // fixture normally creates four one-carton SKUs so it can exercise partial loading, but that // would intentionally leave three cartons outside the sealed pallet and make the later // cross-region handover invalid. Keep this producer variant to one carton only; the separate // STANDARD incremental fixture below continues to cover SKIP/PARTIAL/dynamic mutations. const primaryDropship = this.context.orderFulfillmentType === 'ONE_CLICK_DROPSHIP' const hadWaitSplitVariant = Object.hasOwn(this.context, 'waitSplitVariant') const savedWaitSplitVariant = this.context.waitSplitVariant if (primaryDropship) { this.context.waitSplitVariant = { ...(savedWaitSplitVariant || {}), skuCount: 1, packageCountPerSku: 1, packageCounts: [1], } } try { await this.executeModule('orders-phase3-loading-fixture', () => orders.run(this)) } finally { if (hadWaitSplitVariant) this.context.waitSplitVariant = savedWaitSplitVariant else delete this.context.waitSplitVariant } const fixtureInbound = this.createdResources.orders const warehouseId = String(fixtureInbound?.receivingWarehouseId || '') this.assert('phase3-loading-fixture', 'A0 隔离使用本轮订单收货仓', null, Boolean(warehouseId), `warehouseId=${warehouseId || 'missing'}`, { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true }) const requiredPalletCount = Math.max(1, Number(this.context?.waitSplitVariant?.palletCount || 1)) const isolation = requireSharedOverflow ? await this.disableAvailableLocationsForA0(warehouseId, requiredPalletCount) : { forced: false, disabled: [] } this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow try { const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) await this.executeModule('inbound-measurement-phase3-loading-fixture', () => measurement.run(this)) } finally { if (requireSharedOverflow) { await this.restoreAvailableLocationsAfterA0(warehouseId, isolation) } } const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders const fixtureOrder = this.createdResources.orders || {} const loadingFixture = fixture ? { marker: originalMarker, sourceMarker: fixtureMarker, tenantId: fixture.tenantId, orderId: fixture.orderId || this.createdResources.orders?.orderId, inboundId: fixture.inboundId, boxCode: fixture.boxCode, sealedCartonMarks: Array.isArray(fixture.sealedCartonMarks) ? fixture.sealedCartonMarks : [], sealedSourcePalletNos: Array.isArray(fixture.sealedSourcePalletNos) ? fixture.sealedSourcePalletNos : [], fulfillmentType: fixtureOrder.fulfillmentType || 'STANDARD', skuCodes: Array.isArray(fixtureOrder.skuCodes) ? fixtureOrder.skuCodes : [], skuLines: Array.isArray(fixtureOrder.skuLines) ? fixtureOrder.skuLines : [], customerRemark: fixtureOrder.customerRemark, internalRemark: fixtureOrder.internalRemark, cargoRemark: fixtureOrder.cargoRemark, taxMode: fixtureOrder.taxMode || null, finalStatus: fixture.finalStatus, processStatus: fixture.processStatus, requireInventoryMutation: requireSharedOverflow, } : { marker: originalMarker, sourceMarker: fixtureMarker, failed: true } const incrementalMarker = `${originalMarker}-LOAD${suffix}-INC` this.marker = incrementalMarker this.context.phase3LoadingRequireSharedOverflow = false this.context.phase3LoadingIncrementalPackageCount = 2 this.context.phase3LoadingIncrementalDetailCount = 4 // orders.mjs derives the actual SKU/package shape from waitSplitVariant. Override that // shape for this mutation fixture as well, otherwise a one-carton dropship primary leaks // into the STANDARD incremental order and removes the SKIP/PARTIAL coverage data. const hadIncrementalWaitSplitVariant = Object.hasOwn(this.context, 'waitSplitVariant') const savedIncrementalWaitSplitVariant = this.context.waitSplitVariant this.context.waitSplitVariant = { ...(savedIncrementalWaitSplitVariant || {}), skuCount: 4, packageCountPerSku: 2, packageCounts: [2, 2, 2, 2], } this.context.orderProfile = { ...(this.context.orderProfile || {}), // The incremental order exercises partial/SKIP/PARTIAL mutations. Keep it STANDARD so a // ONE_CLICK_DROPSHIP primary order remains a physically indivisible whole-pallet handover. fulfillmentType: 'STANDARD', ...(taxModes?.incremental ? { taxMode: taxModes.incremental } : {}), } const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) // orders.mjs reads this top-level context field rather than orderProfile. Switch it as well // so this mutation fixture is genuinely STANDARD, then restore the producer variant before // the enclosing phase-four workflow records its ONE_CLICK_DROPSHIP facts. const hadOrderFulfillmentType = Object.hasOwn(this.context, 'orderFulfillmentType') const savedOrderFulfillmentType = this.context.orderFulfillmentType this.context.orderFulfillmentType = 'STANDARD' try { await this.executeModule('orders-phase3-loading-incremental-fixture', () => orders.run(this)) await this.executeModule('inbound-measurement-phase3-loading-incremental-fixture', () => measurement.run(this)) } finally { if (hadOrderFulfillmentType) this.context.orderFulfillmentType = savedOrderFulfillmentType else delete this.context.orderFulfillmentType if (hadIncrementalWaitSplitVariant) this.context.waitSplitVariant = savedIncrementalWaitSplitVariant else delete this.context.waitSplitVariant } const incrementalSource = this.createdResources.inboundMeasurement || this.createdResources.orders const incrementalFixture = incrementalSource ? { marker: originalMarker, sourceMarker: incrementalMarker, tenantId: incrementalSource.tenantId, orderId: incrementalSource.orderId, inboundId: incrementalSource.inboundId, boxCode: incrementalSource.boxCode, fulfillmentType: this.createdResources.orders?.fulfillmentType || 'STANDARD', taxMode: this.createdResources.orders?.taxMode || null, skuCodes: Array.isArray(this.createdResources.orders?.skuCodes) ? this.createdResources.orders.skuCodes : [], skuLines: Array.isArray(this.createdResources.orders?.skuLines) ? this.createdResources.orders.skuLines : [], } : { marker: originalMarker, failed: true } this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), phase3Loading: loadingFixture, phase3LoadingIncremental: incrementalFixture, inboundMeasurement: loadingFixture, } this.marker = originalMarker return () => { this.marker = originalMarker for (const [key, snapshot] of createdSnapshot) { if (snapshot.present) this.createdResources[key] = snapshot.value else delete this.createdResources[key] } for (const [key, snapshot] of contextSnapshot) { if (snapshot.present) this.context[key] = snapshot.value else delete this.context[key] } } } async runPhase4ProducerVariant(label, destinationType, fulfillmentType = 'STANDARD', pickupRequired = false) { const matrixMarker = this.marker const attempt = (this.phase4ProducerAttempts.get(label) || 0) + 1 this.phase4ProducerAttempts.set(label, attempt) const variantMarker = `${matrixMarker}-P4-${label}${attempt === 1 ? '' : `-R${attempt}`}` const contextKeys = ['orderDestinationType', 'orderFulfillmentType', 'phase4ProducerMatrixMarker'] const createdKeys = [ 'phase3Loading', 'phase3LoadingCompletedProducer', 'phase3ContainerDocuments', 'orchestrationFixtures', 'oceanBooking', 'phase3CustomsSupplier', 'phase3Customs', 'serviceCenterFlow', 'portSchedule', 'phase3OutboundDispatch', 'phase3CarrierScheduleQuery', 'phase4CarrierPickupReady', ] 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 // Each producer variant must create and advance its own loading, outbound and carrier facts. // Keeping the outer run's objects here can make a later container write PICKUP_READY nodes // to an earlier carrier task while still reporting the variant as ready. for (const key of createdKeys) delete this.createdResources[key] this.marker = variantMarker this.context.orderDestinationType = destinationType this.context.orderFulfillmentType = fulfillmentType this.context.phase4ProducerMatrixMarker = matrixMarker try { // Cross-region variants only need a normal completed loading chain. The destructive A0 // inventory-snapshot mutation is covered once by an explicitly requested phase3-loading // run; repeating it for every producer is unsafe for a populated shared warehouse and can // become impossible when the bounded isolation guard refuses bulk location mutations. restoreFixture = await this.preparePhase3LoadingFixture('', false) if (fulfillmentType === 'ONE_CLICK_DROPSHIP') { const inboundFees = await import(new URL('./admin-test-env-modules/inbound-fees.mjs', import.meta.url)) await inboundFees.ensureInitialCalculated(this, this.createdResources.orchestrationFixtures?.phase3Loading, { scenario: `phase4-${label}-initial-fee`, deploymentBlocker: true }) } 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)) const producerKey = this.createdResources.phase3LoadingCompletedProducer?.resourceKey const writeShippingEvidence = (shippingReady, carrierScheduleQueryId = null, carrierLatestActualAt = null) => { const matrix = this.createdResources.phase4InboundProducerFixtures const records = [matrix?.containers, matrix?.byDestinationType?.FBA, matrix?.byDestinationType?.OVERSEAS].filter(Array.isArray) for (const values of records) { const target = values.find((item) => item?.resourceKey === producerKey) if (target) Object.assign(target, { shippingReady, carrierScheduleQueryId, carrierLatestActualAt, }) } } if (producerKey) writeShippingEvidence(false) if (pickupRequired && !producerKey) { this.assert('phase4-overseas-pda-fixture', `${label}产生者身份完整`, null, false, 'phase3LoadingCompletedProducer.resourceKey=missing', { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) } if (pickupRequired && producerKey) { const customs = await import(new URL('./admin-test-env-modules/phase3-customs.mjs', import.meta.url)) await this.executeModule(`phase3-customs-phase4-${label}`, () => customs.run(this)) const serviceCenter = await import(new URL('./admin-test-env-modules/service-center-flow.mjs', import.meta.url)) await this.executeModule(`service-center-flow-phase4-${label}`, () => serviceCenter.run(this)) const portSchedules = await import(new URL('./admin-test-env-modules/phase3-port-schedules.mjs', import.meta.url)) await this.executeModule(`phase3-port-schedules-phase4-${label}`, () => portSchedules.run(this)) const outbound = await import(new URL('./admin-test-env-modules/phase3-outbound-dispatch.mjs', import.meta.url)) await this.executeModule(`phase3-outbound-dispatch-phase4-${label}`, () => outbound.run(this)) const documents = await import(new URL('./admin-test-env-modules/phase3-container-documents.mjs', import.meta.url)) await this.executeModule(`phase3-container-documents-phase4-${label}`, () => documents.run(this)) const carrierQueries = await import(new URL('./admin-test-env-modules/phase3-carrier-schedule-queries.mjs', import.meta.url)) await this.executeModule(`phase3-carrier-schedule-queries-phase4-${label}`, () => carrierQueries.run(this)) const carrierNodes = await import(new URL('./admin-test-env-modules/phase4-carrier-nodes.mjs', import.meta.url)) await this.executeModule(`phase4-carrier-pickup-ready-phase4-${label}`, () => carrierNodes.runUntilPickupReady(this)) const pickup = this.createdResources.phase4CarrierPickupReady const shippingReady = pickup?.marker === variantMarker && pickup?.ready === true && Boolean(pickup?.queryId) writeShippingEvidence(shippingReady, shippingReady ? String(pickup.queryId) : null, shippingReady ? pickup.latestActualAt : null) this.assert('phase4-overseas-pda-fixture', `${label}通过真实出仓与船节点进入PICKUP_READY`, null, shippingReady, `resourceKey=${producerKey}, queryId=${pickup?.queryId || 'missing'}, ready=${pickup?.ready === true}`, { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) } } 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().dropshipContainerCount || 0) < 1) { await this.runPhase4ProducerVariant('DROPSHIP-PRIMARY', 'FBA', 'ONE_CLICK_DROPSHIP', true) } const hasStandardFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || []) .some((item) => item?.destinationType === 'FBA' && item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP' && /-P4-FBA-STANDARD(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) if (!hasStandardFbaProducer()) { await this.runPhase4ProducerVariant('FBA-STANDARD', 'FBA', 'STANDARD', true) } if (Number(readiness().overseasContainerCount || 0) < 1) { await this.runPhase4ProducerVariant('OVERSEAS-PRIMARY', 'OVERSEAS', 'ONE_CLICK_DROPSHIP', true) } const hasSecondaryProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || []) .some((item) => /-P4-SECONDARY(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) if (!hasSecondaryProducer()) { await this.runPhase4ProducerVariant('SECONDARY', 'FBA', 'STANDARD', false) } const matrix = this.createdResources.phase4InboundProducerFixtures const containers = Array.isArray(matrix?.containers) ? matrix.containers : [] const dropshipReady = containers.find((item) => item?.fulfillmentType === 'ONE_CLICK_DROPSHIP') const standardFbaReady = containers.find((item) => item?.destinationType === 'FBA' && item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP' && /-P4-FBA-STANDARD(?:-R\d+)?:/.test(String(item?.resourceKey || ''))) const overseasReady = containers.find((item) => item?.destinationType === 'OVERSEAS') const awaitingVariants = containers.filter((item) => ['SECONDARY'] .some((label) => new RegExp(`-P4-${label}(?:-R\\d+)?:`).test(String(item?.resourceKey || '')))) const shippingMatrixReady = dropshipReady?.shippingReady === true && Boolean(dropshipReady?.carrierScheduleQueryId) && standardFbaReady?.shippingReady === true && Boolean(standardFbaReady?.carrierScheduleQueryId) && overseasReady?.shippingReady === true && Boolean(overseasReady?.carrierScheduleQueryId) && awaitingVariants.length >= 1 && awaitingVariants.every((item) => item.shippingReady === false && item.carrierScheduleQueryId == null) this.assert('phase4-overseas-pda-fixture', '本轮三期跨区生产矩阵已就绪', null, matrix?.marker === this.marker && matrix?.readiness?.readyForPhase4Pda === true && shippingMatrixReady, `marker=${matrix?.marker || 'missing'}, FBA=${matrix?.readiness?.fbaContainerCount ?? 0}, OVERSEAS=${matrix?.readiness?.overseasContainerCount ?? 0}, DROPSHIP=${matrix?.readiness?.dropshipContainerCount ?? 0}, containers=${matrix?.readiness?.distinctContainerCount ?? 0}, shippingReady=${containers.filter((item) => item?.shippingReady === true).length}, awaiting=${awaitingVariants.length}`, { scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true }) } async listAvailableLocationsForA0(warehouseId, module, options) { const locations = [] const seen = new Set() let expectedTotal = null for (let pageNumber = 1; pageNumber <= 100; pageNumber += 1) { const response = await this.request('GET', `/api/platform/v1/locations?tenantId=0&page=${pageNumber}&size=${A0_LOCATION_PAGE_SIZE}&warehouseId=${encodeURIComponent(warehouseId)}&status=AVAILABLE`) const page = this.apiSuccess(module, `查询 A0 隔离前可用库位第 ${pageNumber} 页`, response, `page=${pageNumber}`, options) if (!page) return null const pageTotal = Number(page.total) const records = Array.isArray(page.records) ? page.records : [] if (!Number.isInteger(pageTotal) || pageTotal < 0 || (expectedTotal !== null && pageTotal !== expectedTotal)) { this.assert(module, 'A0 隔离库位分页总数稳定', response, false, `page=${pageNumber}, firstTotal=${expectedTotal ?? 'missing'}, currentTotal=${page.total ?? 'missing'}`, options) return null } expectedTotal ??= pageTotal for (const location of records) { const locationId = location?.id == null ? '' : String(location.id) if (locationId && !seen.has(locationId)) { seen.add(locationId) locations.push(location) } } if (locations.length >= expectedTotal) break if (records.length === 0) break } const complete = locations.length === expectedTotal this.assert(module, 'A0 隔离库位分页完整且无重复', null, complete, `total=${expectedTotal ?? 'missing'}, uniqueRecords=${locations.length}`, options) return complete ? locations : null } async disableAvailableLocationsForA0(warehouseId, requiredSlots = 1) { const module = 'phase3-loading-fixture' const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true } if (!warehouseId) { this.assert(module, 'A0 隔离收货仓 ID 完整', null, false, 'warehouseId=missing', options) return { forced: false, disabled: [] } } const locations = await this.listAvailableLocationsForA0(warehouseId, module, options) if (!locations) return { forced: false, disabled: [] } const isolation = planA0LocationIsolation(locations, requiredSlots) if (!isolation.enabled) { this.addStep(module, 'A0 大仓保护:不批量停用普通库位', null, true, `normal=${isolation.normalCount}, requiredSlots=${isolation.requiredSlots}, mutationsRequired=${isolation.mutationsRequired}, safetyLimit=${A0_MAX_LOCATION_MUTATIONS}`, { scenario: options.scenario }) return { forced: false, disabled: [], isolation } } const disabled = [] const skippedOccupied = [] for (const location of isolation.targets) { const response = await this.request('PATCH', `/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`, { status: 'DISABLED', version: Number(location.version) }) const success = response?.http === 200 && String(response?.body?.code) === '200' const occupiedConflict = response?.http === 200 && String(response?.body?.code) === 'A0409' && String(response?.body?.message) === '预占或占用中的库位不能停用' this.assert(module, `临时停用 A0 隔离库位 ${location.locationCode}`, response, success || occupiedConflict, `locationId=${location.id}, beforeVersion=${location.version}${occupiedConflict ? ', skipped=active-resource' : ''}`, options) if (success) disabled.push({ id: String(location.id), code: location.locationCode, version: Number(location.version) + 1 }) if (occupiedConflict) skippedOccupied.push({ id: String(location.id), code: location.locationCode }) } const availableResponse = await this.request('GET', `/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`) const available = this.apiSuccess(module, '停用后回查收货仓剩余普通库位', availableResponse, '', options) const remainingNormal = Array.isArray(available) ? available.filter((item) => item?.sharedOverflow !== true) : null const forced = Array.isArray(remainingNormal) && remainingNormal.length < isolation.requiredSlots this.assert(module, '测量前 A0 溢出条件成立', availableResponse, forced, `requiredSlots=${isolation.requiredSlots}, disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, remainingNormal=${Array.isArray(remainingNormal) ? remainingNormal.length : 'missing'}`, options) return { forced, disabled, isolation } } async restoreAvailableLocationsAfterA0(warehouseId, result) { const module = 'phase3-loading-fixture' const options = { scenario: 'phase3-loading-a0-fixture', deploymentBlocker: true } const disabledLocations = Array.isArray(result?.disabled) ? result.disabled : [] for (const location of disabledLocations) { const response = await this.request('PATCH', `/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`, { status: 'AVAILABLE', version: location.version }) this.assert(module, `恢复 A0 隔离库位 ${location.code}`, response, response?.http === 200 && String(response?.body?.code) === '200', `locationId=${location.id}, restoreVersion=${location.version}`, options) } this.createdResources.phase3LoadingA0Isolation = { warehouseId, forced: result?.forced === true, normalCount: result?.isolation?.normalCount ?? null, mutationsRequired: result?.isolation?.mutationsRequired ?? 0, restored: disabledLocations.map((item) => item.id), marker: this.marker, } } async run() { if (this.options.merchantUsername && this.options.merchantPassword) { process.env.MERCHANT_TEST_USERNAME = this.options.merchantUsername process.env.MERCHANT_TEST_PASSWORD = this.options.merchantPassword } if (this.options.waitSplitCount > 1 || this.options.waitSplitPackageCount > 0 || this.options.orderProfileMode === 'randomized' || this.options.routeMode === 'random' || this.options.originWarehouseName || this.options.channelName || this.options.channelCode) { this.applyWaitSplitVariant(this.options.waitSplitSequence || 1) } else if (this.options.originWarehouseName || this.options.channelName || this.options.channelCode) { this.context.waitSplitRoute = { originWarehouseName: this.options.originWarehouseName || '', channelName: this.options.channelName || '', channelCode: this.options.channelCode || '', randomize: false, randomIndex: 0, destinationIndex: 0, taxModeIndex: 0, } // A fixed non-default origin may not carry the Yiwu-only TP-001/YW-TP-002 fixtures. // Reuse an existing pallet from the selected warehouse while keeping order data deterministic. this.context.allowAnyExistingPallet = true } const loginResponse = await this.request('POST', '/api/auth/login', { loginId: this.options.username, password: this.options.password }, '') const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse) if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' else { this.token = login.accessToken this.refreshToken = login.refreshToken || '' this.overseasToken = login.overseasAccessToken || '' this.overseasExpiresAt = Number(login.overseasExpiresIn) > 0 ? Date.now() + Number(login.overseasExpiresIn) * 1000 : 0 const requiresOverseas = this.options.modules.some((module) => module.startsWith('phase4-overseas-') || module.startsWith('phase5-overseas-')) if (requiresOverseas) { this.assert('authentication', '登录响应包含有效海外访问令牌', loginResponse, Boolean(this.overseasToken) && Number(login.overseasExpiresIn) > 0, `tokenPresent=${Boolean(this.overseasToken)}, expiresIn=${login.overseasExpiresIn ?? 'missing'}`, { deploymentBlocker: true, scenario: 'phase4-dual-token' }) } const handlers = { customers: () => this.runCustomers() } for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) { const module = this.options.modules[moduleIndex] if ((module.startsWith('phase4-overseas-') || module.startsWith('phase5-overseas-')) && this.overseasExpiresAt > 0 && Date.now() + 120_000 >= this.overseasExpiresAt) { await this.refreshSession(`海外模块 ${module} 执行前Token临近到期`) } const fullFinanceChain = ['finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full'].every((name) => this.options.modules.includes(name)) if (module === 'finance' && fullFinanceChain) { this.modules.push({ name: module, startedAt: new Date().toISOString(), finishedAt: new Date().toISOString(), success: true, passed: 0, failed: 0, fatal: null, skipped: 'finance-full-chain owns the finance source locks' }) continue } let restoreFixture = null if (module === 'phase4-tail-order-controls' || 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 { restoreFixture = await this.prepareInboundMeasurementFixture() } catch (error) { const reason = error instanceof Error ? error.message : String(error) this.addStep('orders-inbound-measurement-fixture', '创建独立测量入库单', null, false, reason, { scenario: 'inbound-resource-isolation' }) } } if (module === 'phase3-container-loading') { try { restoreFixture = await this.preparePhase3ContainerFixture() } catch (error) { const reason = error instanceof Error ? error.message : String(error) this.addStep('phase3-container-fixture', '创建独立柜次级入库单', null, false, reason, { scenario: 'phase3-container-resource-isolation' }) } } if (module === 'phase3-loading') { try { // The A0 stale-inventory mutation is intentionally destructive and expensive on // a large shared test warehouse. Run it only when phase3-loading was explicitly // requested; downstream phase4/finance chains need the normal loading fixture only. const requireSharedOverflow = this.options.requestedModules.includes('phase3-loading') restoreFixture = await this.preparePhase3LoadingFixture('', requireSharedOverflow) } catch (error) { this.marker = this.marker.replace(/-LOAD$/, '') const reason = error instanceof Error ? error.message : String(error) this.addStep('phase3-loading-fixture', '创建独立 WAIT_SPLIT 装柜入库单', null, false, reason, { scenario: 'phase3-loading-resource-isolation' }) } } if (module === 'phase3-container-customs') { try { const fixture = await import(new URL('./admin-test-env-modules/phase3-container-customs-fixture.mjs', import.meta.url)) restoreFixture = await fixture.prepare(this) } catch (error) { const reason = error instanceof Error ? error.message : String(error) this.addStep('phase3-container-customs-fixture', '创建独立BUY+TAX混合整柜', null, false, reason, { scenario: 'phase3-container-customs-mixed-fixture', deploymentBlocker: true }) } } let handler = handlers[module] if (!handler) { try { const extension = await import(new URL(`./admin-test-env-modules/${module}.mjs`, import.meta.url)) if (typeof extension.run !== 'function') throw new Error('模块必须导出 async function run(context)') handler = () => extension.run(this) } catch (error) { const reason = error instanceof Error ? error.message : String(error) this.addStep(module, '模块注册检查', null, false, `无法加载模块: ${reason}`) this.modules.push({ name: module, success: false, passed: 0, failed: 1, fatal: reason }) if (restoreFixture) restoreFixture() continue } } try { await this.executeModule(module, handler) } finally { if (restoreFixture) restoreFixture() } if (module === 'inbound-measurement' && this.captureWaitSplitFixture(this.options.waitSplitSequence || 1)) { await this.createAdditionalWaitSplitFixtures() } const laterOverseasModule = this.options.modules.slice(moduleIndex + 1) .some((name) => name.startsWith('phase4-overseas-')) if (module === 'system' && laterOverseasModule) { const refreshed = await this.refreshSession('system模块权限与角色变更完成') if (refreshed) { const probe = await this.requestOverseas('GET', '/api/platform/v1/last-leg/orders?page=1&size=1') this.assert('authentication', 'system后新admin海外Token只读探针可用', probe, probe.http === 200 && String(probe.body?.code) === '200', `HTTP=${probe.http}, code=${probe.body?.code ?? 'missing'}, requestId=${probe.requestId || 'missing'}, traceId=${probe.traceId || 'missing'}`, { scenario: 'dual-token-refresh', deploymentBlocker: true }) } } } } const reportSurface = JSON.stringify({ steps: this.steps, modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, }) const forbiddenValues = [this.options.password, this.options.merchantPassword, this.token, this.refreshToken, this.overseasToken] .filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ /"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i, /\bBearer\s+[A-Za-z0-9._~-]{16,}/i, /\beyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, /data:(?:image|application)\/[A-Za-z0-9.+-]+;base64,/i, /"(?:bytes|base64|ocrText|ocrRawText)"\s*:/i, ] const sensitive = forbiddenValues.some((value) => reportSurface.includes(value)) || sensitivePatterns.some((pattern) => pattern.test(reportSurface)) this.addStep('report-security', '报告敏感信息扫描', null, !sensitive, sensitive ? '检测到密码、Token、二进制/base64 或 OCR 原文字段' : '未发现密码、Token、文件二进制/base64 或 OCR 原文字段') const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps) return { reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker, gateway: this.options.gateway, overseasGateway: this.options.overseasGateway, generatedAt: new Date().toISOString(), startedAt: this.startedAt, success: this.fatal === null && summary.failed === 0, fatal: this.fatal, summary, modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, excluded: [ { scope: 'dashboard', reason: '首页为静态演示数据,不调用后端 API' }, { scope: 'hidden-prototypes', reason: '隐藏原型页面尚无已确认后端契约' }, ], securityNote: '报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。', steps: this.steps, } } } async function main() { 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() await mkdir(dirname(outputPath), { recursive: true }) await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') console.log(`REPORT=${outputPath}`) console.log(`RESULT=${report.success ? 'PASS' : 'FAIL'} total=${report.summary.total} passed=${report.summary.passed} failed=${report.summary.failed} metric=assertions moduleRuns=${report.summary.moduleRuns} uniqueModules=${report.summary.uniqueModules} uniqueRequests=${report.summary.uniqueRequests} skippedModules=${report.summary.skippedModules}`) process.exitCode = report.success ? 0 : 1 } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 }) }