Files
baiyue-firstleg/scripts/admin-test-env-acceptance.mjs
T

2185 lines
133 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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', 'pda-releases', 'audit', 'dashboard', 'self-operated-analytics', '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', 'sailing-calendar', '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', 'appointment-work', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-return-followups', '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', 'fleet-management', '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 = ['phase5-root-fulfillment', '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', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
const FIXED_RESOURCE_SKIPPED_MODULES = new Set(['customers', 'supplemental-boundaries', 'system', 'master-data',
'notifications', 'pricing', 'phase4-overseas-master-data'])
const A0_LOCATION_PAGE_SIZE = 200
const A0_MAX_LOCATION_MUTATIONS = 8
export function transportFailureDetails(error) {
const causes = []
const visited = new Set()
const symbol = value => typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_]{0,79}$/.test(value) ? value : null
function visit(value) {
if (!value || typeof value !== 'object' || visited.has(value) || causes.length >= 6) return
visited.add(value)
causes.push({ name: symbol(value.name), code: symbol(value.code), syscall: symbol(value.syscall) })
visit(value.cause)
if (Array.isArray(value.errors)) for (const nested of value.errors) visit(nested)
}
visit(error)
return { causes }
}
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([
['customers', ['merchant-qualification-fixture']],
['overseas-storage-fees', ['merchant-qualification-fixture']],
['inbound-weight-ocr', ['orders']],
['orders', ['merchant-qualification-fixture']],
['phase5-root-fulfillment', ['merchant-qualification-fixture']],
['pda-loading-link-regression', ['merchant-qualification-fixture']],
['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']],
['dropship-pallet-matrix', ['merchant-qualification-fixture']],
['dropship-mixed-palletization', ['merchant-qualification-fixture']],
['dropship-physical-carton-chain', ['merchant-qualification-fixture']],
['dropship-browser-fixture', ['merchant-qualification-fixture']],
['mixed-container-receiving', ['merchant-qualification-fixture']],
['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', 'phase5-overseas-fulfillment']],
['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries', 'phase3-container-documents']],
['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']],
['appointment-work', ['phase4-carrier-pickup-ready']],
['phase4-overseas-pda', ['phase4-tail-order-controls']],
['phase4-overseas-pda-operations', ['phase4-overseas-pda']],
['phase4-overseas-return-receipt', ['merchant-qualification-fixture']],
['phase4-overseas-inbound-operations', ['phase4-overseas-pda-operations']],
['phase4-overseas-client-services', ['phase4-overseas-pda-operations']],
['phase4-overseas-dispatch', ['phase4-overseas-pda-operations', 'phase4-overseas-client-services']],
['phase5-overseas-fulfillment', ['phase4-overseas-dispatch', 'phase4-overseas-business']],
['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)
/** 从执行注册表返回范围,供离线总览使用,避免维护第二套模块清单。 */
export function describeAcceptanceScope() {
return {
defaultModules: [...DEFAULT_MODULES],
optionalModules: OPTIONAL_MODULES.filter((name) => !DEFAULT_MODULES.includes(name)),
fixedResourceSkippedModules: [...FIXED_RESOURCE_SKIPPED_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 <url> 必填,国内 Gateway 地址
--username <name> 必填,管理员账号
--password <value> 必填,管理员密码
--pda-username <name> 国内装柜链必填,实际领取任务的 PDA 作业员账号
--pda-password <value> 国内装柜链必填,PDA 作业员密码
--environment <name> 默认 test;gray 仅允许显式 FBA 定向闭环
--modules <names> 逗号分隔,默认 ${DEFAULT_MODULES.join(',')}
--f40-tax-mode <mode> F40 税制:BUY_DECLARATION 或 TAX_REFUND
--customer-name <name> 指定本轮隔离客户名称,默认自动生成
--merchant-username <name> 复用既有已审核客户账号,不再创建客户
--merchant-password <value> 复用客户账号密码,不写入报告
--origin-warehouse-name <name> 固定国内起运仓名称
--prepare-domestic-pda-h5 true 仅准备新待拣任务供H5,不执行完整履约验收
--channel-name <name> 固定物流渠道名称
--channel-code <code> 固定物流渠道编码
--order-profile-mode <mode> 订单档案模式:fixed 或 randomized,默认fixed
--wait-split-count <n> 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1
--wait-split-package-count <n> 单票指定总箱数,1-1000;省略时使用既有轮换
--wait-split-sequence <n> 单票全局序号,正整数,默认1
--wait-split-random-value <n> 单票随机选择值,正整数,默认使用全局序号
--prepare-dropship-manual-count <true> 一件代发停在PDA清点前,不领取、不清点、不归托
--verify-pallet-location-guide <true> 验证整票库位块、图片槽绑定与删除后 RESERVED_EMPTY
--route-mode <mode> 线路选择:fixed 或 random,默认fixed
--manual-split-fixture-report <path> 人工分柜准备报告路径,仅只读核验模块使用
--manual-split-plan-id <id> 人工操作产生的智能装柜计划 ID,仅只读核验模块使用
--manual-preload-source-report <path> 人工推送预装前置或核验来源报告路径
--manual-pda-source-report <path> 人工订舱关联、预装确认前置或核验来源报告路径
--manual-pda-batch-source-report <path> PDA 人工领取、扫码批量提交前置或核验来源报告路径
--manual-loading-complete-source-report <path> 五类证据与 PDA 最终完成前置或核验来源报告路径
--output <path> JSON 报告路径,默认 reports/
--help 显示帮助
也可使用环境变量: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD,
ADMIN_TEST_WEIGHT_IMAGE_PATH, ADMIN_TEST_CUSTOMER_NAME, MERCHANT_TEST_USERNAME,
MERCHANT_TEST_PASSWORD, ADMIN_PDA_USERNAME, ADMIN_PDA_PASSWORD, ADMIN_WAIT_SPLIT_COUNT。
gray 使用独立 ADMIN_GRAY_GATEWAY/USERNAME/PASSWORD、ADMIN_GRAY_PDA_USERNAME/PASSWORD、
ADMIN_GRAY_CUSTOMER_NAME、MERCHANT_GRAY_USERNAME/PASSWORD;不回落 TEST 凭据。`)
}
const fleetPoolGrayOptions = new WeakSet()
/** Dedicated pool orchestrator transport only: no generic module execution or CLI scope switch. */
export function createFleetPoolGrayOptions(input) {
const keys = ['gateway', 'username', 'password', 'output', 'markerNamespace']
if (!input || Object.keys(input).some(key => !keys.includes(key))
|| keys.some(key => typeof input[key] !== 'string' || !input[key].trim())) throw Error('Explicit gray pool transport configuration required')
const target = new URL(input.gateway)
if (target.protocol !== 'https:' || target.username || target.password || target.search || target.hash || target.pathname !== '/')
throw Error('Gray pool requires the unified HTTPS origin')
const options = Object.freeze({ ...input, environment: 'gray', executionScope: 'FLEET_CAPACITY_POOL_GRAY',
modules: Object.freeze([]), requestedModules: Object.freeze([]), quiet: true, waitSplitCount: 1, verifyUnloadEventRecovery: false })
fleetPoolGrayOptions.add(options)
return options
}
function assertGrayExecutionScope(options) {
if (options.environment !== 'gray') return
if (options.verifyUnloadEventRecovery) throw new Error('gray 禁止 TEST 故障注入')
if (fleetPoolGrayOptions.has(options)) return
if (!Array.isArray(options.requestedModules) || options.requestedModules.length !== 1
|| options.requestedModules[0] !== 'phase5-root-fulfillment'
|| !Array.isArray(options.modules) || options.modules.length !== 2
|| options.modules[0] !== 'merchant-qualification-fixture'
|| options.modules[1] !== 'phase5-root-fulfillment') {
throw new Error('gray 仅支持显式 phase5-root-fulfillment 及其本轮客户依赖;其他模块尚未核对环境副作用')
}
if (options.waitSplitCount !== 1) throw new Error('gray 定向闭环只支持单轮订单,不运行批量造数')
}
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
}
}
if (values.has('overseas-gateway') || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Remove overseas-gateway / ADMIN_OVERSEAS_GATEWAY; use one TEST gateway')
const environment = values.get('environment') || 'test'
if (!['test', 'gray'].includes(environment)) throw new Error('Only test or explicit gray acceptance is supported')
if (environment === 'gray' && !values.get('modules')) throw new Error('gray 必须显式指定 --modules phase5-root-fulfillment,不能运行默认全量')
const envValue = (testKey, grayKey) => process.env[environment === 'gray' ? grayKey : testKey] || ''
const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(','))
.split(',').map((v) => v.trim()).filter(Boolean)
const modules = normalizeModuleOrder(requestedModules)
if (environment === 'test') {
const fixtureIndex = modules.indexOf('merchant-qualification-fixture')
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
modules.unshift('merchant-qualification-fixture')
}
const merchantUsername = values.get('merchant-username') || envValue('MERCHANT_TEST_USERNAME', 'MERCHANT_GRAY_USERNAME')
const merchantPassword = values.get('merchant-password') || envValue('MERCHANT_TEST_PASSWORD', 'MERCHANT_GRAY_PASSWORD')
const requiresMarkerOwnedMerchantFixture = [
'phase5-root-fulfillment',
'phase4-overseas-business',
'phase4-overseas-return-receipt',
'phase4-overseas-client-services',
'phase5-overseas-fulfillment',
'phase4-overseas-finance-cross-region',
'phase4-overseas-wallet-and-pallet-cost',
].some((module) => modules.includes(module))
if (environment !== 'test' && merchantUsername && merchantPassword && !requiresMarkerOwnedMerchantFixture) {
const fixtureIndex = modules.indexOf('merchant-qualification-fixture')
if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1)
}
if (values.has('prepare-domestic-pda-h5') && values.get('prepare-domestic-pda-h5') !== 'true') throw new Error('prepare-domestic-pda-h5 必须明确为 true;完整验收请省略此参数')
if (values.has('prepare-measurement-review') && values.get('prepare-measurement-review') !== 'true') throw new Error('prepare-measurement-review 必须明确为 true')
return {
environment,
gateway: (values.get('gateway') || envValue('ADMIN_TEST_GATEWAY', 'ADMIN_GRAY_GATEWAY')).replace(/\/$/, ''),
username: values.get('username') || envValue('ADMIN_TEST_USERNAME', 'ADMIN_GRAY_USERNAME'),
password: values.get('password') || envValue('ADMIN_TEST_PASSWORD', 'ADMIN_GRAY_PASSWORD'),
pdaUsername: values.get('pda-username') || envValue('ADMIN_PDA_USERNAME', 'ADMIN_GRAY_PDA_USERNAME'),
pdaPassword: values.get('pda-password') || envValue('ADMIN_PDA_PASSWORD', 'ADMIN_GRAY_PDA_PASSWORD'),
modules,
requestedModules,
stopFile: values.get('stop-file') || '',
fixedCustomerId: environment === 'test' ? process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || '' : '',
customerName: values.get('customer-name') || envValue('ADMIN_TEST_CUSTOMER_NAME', 'ADMIN_GRAY_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') || envValue('ADMIN_WAIT_SPLIT_COUNT', 'ADMIN_GRAY_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),
prepareDropshipManualCount: values.get('prepare-dropship-manual-count') === 'true',
prepareMeasurementReview: values.get('prepare-measurement-review') === 'true',
verifyNonIsoContainer: values.get('verify-non-iso-container') === 'true',
verifyPalletLocationGuide: values.has('verify-pallet-location-guide'),
routeMode: values.get('route-mode') || 'fixed',
quiet: values.get('quiet') === 'true',
returnMainChainOnly: values.get('return-main-chain-only') === 'true',
verifyUnloadEventRecovery: values.get('verify-unload-event-recovery') === 'true',
f40TaxMode: values.get('f40-tax-mode') || '',
prepareDomesticPdaH5: values.get('prepare-domestic-pda-h5') === '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) {
assertGrayExecutionScope(options)
if (options.environment === 'gray') {
const target = new URL(options.gateway)
if (target.protocol !== 'https:' || target.username || target.password || target.search || target.hash
|| target.pathname !== '/') throw new Error('gray gateway 必须为统一 HTTPS 源站,不带 /api、凭据或查询参数')
if (!options.output?.trim()) throw new Error('gray 必须指定独立 --output 报告路径')
}
if (options.verifyUnloadEventRecovery && !options.modules.includes('phase4-overseas-pda')) {
throw new Error('卸柜事件恢复验收必须包含正式 phase4-overseas-pda 收货链')
}
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.environment && !['test', 'gray'].includes(options.environment)) throw new Error('Only test or explicit gray acceptance is supported')
if (options.f40TaxMode && !['BUY_DECLARATION', 'TAX_REFUND'].includes(options.f40TaxMode)) throw new Error('f40-tax-mode 必须为 BUY_DECLARATION 或 TAX_REFUND')
if (options.prepareDomesticPdaH5 && (options.requestedModules?.length !== 1 || options.requestedModules[0] !== 'phase5-root-fulfillment')) throw new Error('prepare-domestic-pda-h5 仅允许单独指定 phase5-root-fulfillment')
if (options.verifyNonIsoContainer && (options.environment !== 'test'
|| options.requestedModules?.length !== 1 || options.requestedModules[0] !== 'phase3-container-loading'
|| options.verifyPalletLocationGuide)) {
throw new Error('verify-non-iso-container 仅允许 TEST 单独柜号链,不能混用库位块删除探针')
}
if (options.prepareMeasurementReview && (options.environment !== 'test'
|| options.requestedModules?.length !== 1 || options.requestedModules[0] !== 'inbound-measurement'
|| options.waitSplitCount !== 1 || options.prepareDomesticPdaH5 || options.prepareDropshipManualCount)) {
throw new Error('prepare-measurement-review 仅允许 TEST 单票 inbound-measurement 待审准备,不能混用其他准备模式或后续模块')
}
if ('overseasGateway' in options) throw new Error('Use the single gateway option')
if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading', 'phase5-root-fulfillment'].includes(module))
&& (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) {
throw new Error('国内装柜链缺少 PDA 作业员凭据: pda-username / pda-password(ADMIN_PDA_USERNAME / ADMIN_PDA_PASSWORD)')
}
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.prepareDropshipManualCount
&& (!options.modules.includes('orders') || !options.modules.includes('inbound-measurement'))) {
throw new Error('prepare-dropship-manual-count要求同时执行orders和inbound-measurement模块')
}
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) {
assertGrayExecutionScope(options)
this.options = { ...options }
if (options.environment === 'test') {
this.options.fixedCustomerId ||= process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || ''
this.options.merchantUsername ||= process.env.MERCHANT_TEST_USERNAME || ''
this.options.merchantPassword ||= process.env.MERCHANT_TEST_PASSWORD || ''
this.options.fixedPdaActors ??= JSON.parse(process.env.ADMIN_TEST_OVERSEAS_PDA_ACTORS || '[]')
this.options.fixedDomesticDenyActor ??= JSON.parse(process.env.ADMIN_TEST_DOMESTIC_DENY_ACTOR || 'null')
this.options.fixedFleetResources ??= JSON.parse(process.env.ADMIN_TEST_FLEET_RESOURCES || '[]')
if (!this.options.fixedSecondaryCustomer && process.env.ADMIN_TEST_SECONDARY_CUSTOMER_ID) {
this.options.fixedSecondaryCustomer = { customerId: process.env.ADMIN_TEST_SECONDARY_CUSTOMER_ID,
username: process.env.ADMIN_TEST_SECONDARY_MERCHANT_USERNAME || '', password: process.env.ADMIN_TEST_SECONDARY_MERCHANT_PASSWORD || '' }
}
}
const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12)
this.marker = `${options.environment === 'gray' ? 'GRAY' : '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 = { ...(options.f40TaxMode ? { f40TaxMode: options.f40TaxMode } : {}), ...(options.prepareDomesticPdaH5 ? { prepareDomesticPdaH5: true } : {}) }
this.token = ''
this.pdaToken = ''
this.refreshToken = ''
this.expiresAt = 0
this.fatal = null
}
assertCustomerWriteAllowed(method, path) {
if (this.options.environment !== 'test') return
const pathname = new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, '')
if (String(method).toUpperCase() === 'POST' && (pathname === '/api/platform/v1/iam/tenants'
|| pathname === '/api/auth/register'
|| /^\/api\/platform\/v1\/iam\/tenants\/[^/]+\/(archive|submit-review)$/.test(pathname))) {
throw new Error('TEST 固定客户模式禁止创建、归档客户或重提资质;请复用已配置客户')
}
}
async request(method, path, body = undefined, token = this.token, extraHeaders = {}) {
this.assertCustomerWriteAllowed(method, path)
const headers = { Accept: 'application/json', ...extraHeaders }
if (this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) token = this.pdaToken
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, transportError: transportFailureDetails(error) }
}
}
throw new Error('unreachable request attempt state')
}
async requestMultipart(method, path, file, token = this.token, extraHeaders = {}) {
if (this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) token = this.pdaToken
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 requestWithBody(method, path, body, token = this.token, extraHeaders = {}) {
this.assertCustomerWriteAllowed(method, path)
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, transportError: transportFailureDetails(error) }
}
}
async requestBinary(method, path) {
const token = this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path)) ? this.pdaToken : this.token
const headers = { Accept: '*/*', ...(token ? { Authorization: `Bearer ${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')
}
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,
...(response?.transportError ? { transportError: response.transportError } : {}),
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) {
if (this.options.fixedCustomerId && FIXED_RESOURCE_SKIPPED_MODULES.has(name)) {
this.addCoverageGap(name, 'fixed-resource-lifecycle', '固定资源模式排除新增主档及账户生命周期',
'业务流程复用现有资源;不执行主档创建、账号创建、价格发布或钱包初始化', 'not-implemented', { blocking: false })
this.modules.push({ name, success: false, skipped: true, passed: 0, failed: 0,
reason: '固定资源模式排除生命周期;不计为验收通过' })
return
}
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 })
}
/** 在模块边界结束,随后仍由main的finally恢复故障注入配置并写出失败报告。 */
async stopAtBoundary() {
if (this.stopRequested) return true
if (!this.options.stopFile) return false
try { await readFile(this.options.stopFile) }
catch (error) { if (error.code === 'ENOENT') return false; throw error }
this.stopRequested = true
this.fatal = '收到停止文件,未继续执行后续模块;本报告为中止结果'
this.addStep('execution-control', '停止后续模块并进入正常清理', null, false,
'等待当前模块边界,不强杀进程;故障配置清理由总入口finally执行', { deploymentBlocker: true })
return true
}
captureWaitSplitFixture(sequence) {
const order = this.createdResources.orders || {}
const measurement = this.createdResources.inboundMeasurement || {}
const expectedProcessStatus = this.options.prepareDropshipManualCount ? 'WAIT_PALLETIZE' : 'WAIT_SPLIT'
const ready = Boolean(order.orderId && measurement.inboundId
&& String(order.tenantId) === String(measurement.tenantId)
&& measurement.processStatus === expectedProcessStatus && measurement.finalStatus === 'APPROVED')
this.assert('wait-split-fixtures', this.options.prepareDropshipManualCount
? `固定客户第 ${sequence} 单停在PDA清点前` : `固定客户第 ${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: measurement.actualPackageCount
|| (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', '刷新统一会话', response,
`reason=${reason}`, { scenario: 'session-refresh', deploymentBlocker: true })
const valid = Boolean(next?.accessToken) && Boolean(next?.refreshToken) && Number(next?.expiresIn) > 0
this.assert('authentication', '刷新响应完整后原子替换会话', response, valid,
`accessTokenPresent=${Boolean(next?.accessToken)}, refreshTokenPresent=${Boolean(next?.refreshToken)}`,
{ scenario: 'session-refresh', deploymentBlocker: true })
if (!valid) return false
this.token = next.accessToken
this.refreshToken = next.refreshToken
this.expiresAt = Date.now() + Number(next.expiresIn) * 1000
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() {
if (this.options.fixedCustomerId) {
this.context.customerId = this.context.merchantQualificationFixture?.tenantId
this.addCoverageGap('customers', 'customer-lifecycle', '按用户要求排除建客及归档生命周期',
'复用固定客户;不改其资质、状态和账户,不把该生命周期列为通过', 'not-implemented', {blocking:false})
return
}
const module = 'customers'; const phone = await this.findAvailablePhone(module); const suffix = this.marker.slice(-12)
const payload = {
tenantName: `后台客户验收-${suffix}`, customerNickname: `后台客户-${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}-已编辑`, customerNickname: `${payload.customerNickname}-已编辑`, 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))
// Keep the primary one-click producer deliberately multi-pallet. This is the regression
// fixture for the real business shape where one inbound has more than one sealed DP pallet:
// every pallet must remain independently assignable while the handover stays indivisible.
// The separate STANDARD incremental fixture below continues to cover SKIP/PARTIAL mutations.
const primaryDropship = this.context.orderFulfillmentType === 'ONE_CLICK_DROPSHIP'
const hadWaitSplitVariant = Object.hasOwn(this.context, 'waitSplitVariant')
const savedWaitSplitVariant = this.context.waitSplitVariant
const primaryDropshipVariant = {
// 三箱PDA换标、一箱客户换标、一箱转运,共用同一订单,不另造订单或主档。
...(savedWaitSplitVariant || {}), skuCount: 1, packageCountPerSku: 5,
packageCounts: [5], palletCount: 2,
}
if (primaryDropship) {
this.context.waitSplitVariant = primaryDropshipVariant
}
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 = primaryDropship ? primaryDropshipVariant.palletCount
: Math.max(1, Number(this.context?.waitSplitVariant?.palletCount || 1))
const isolation = requireSharedOverflow
? await this.disableAvailableLocationsForA0(warehouseId, requiredPalletCount)
: { forced: false, disabled: [] }
this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow
if (primaryDropship) this.context.waitSplitVariant = primaryDropshipVariant
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)
}
if (hadWaitSplitVariant) this.context.waitSplitVariant = savedWaitSplitVariant
else delete this.context.waitSplitVariant
}
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 shippingRequired = pickupRequired || label === 'SECONDARY'
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', 'waitSplitRoute']
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 {
if (label === 'DROPSHIP-PRIMARY' && this.options.fixedCustomerId
&& this.options.modules.includes('phase4-overseas-client-services')) {
// 同一五箱订单还供应转运收货;造单前按既有异仓资源选线,避免出库后才发现无目标托盘。
const { findReusableTransferReceiptTarget } = await import(new URL('./admin-test-env-modules/warehouse-transfer-receipt.mjs', import.meta.url))
const target = await findReusableTransferReceiptTarget({
sourceWarehouseId: String(this.options.fixedPdaActors?.[0]?.warehouseId || ''),
request: async (method, path) => {
const response = await this.request(method, path)
const data = this.apiSuccess('phase4-overseas-pda-fixture', '转运造单前核验既有目标资源', response)
if (response.http !== 200 || String(response.body?.code) !== '200') throw Error('转运目标资源查询失败')
return data
},
check: (name, ok) => { if (!ok) throw Error(name) },
})
if (!target) throw Error('缺少转运收货既有异仓空闲托盘;未创建来源订单')
this.createdResources.phase4TransferReceiptTargetPlan = target
this.context.waitSplitRoute = { ...(this.context.waitSplitRoute || {}),
includeDestinationCodes: [target.pallet.fbaWarehouseCode] }
}
if (destinationType === 'FBA' && label === 'FBA-SECOND') {
const existingFba = (this.createdResources.phase4InboundProducerFixtures?.containers || [])
.filter(item => item?.destinationType === 'FBA'
&& item?.fulfillmentType !== 'ONE_CLICK_DROPSHIP')
const primaryCode = existingFba.map(item => String(item?.destinationWarehouseCode || '').trim().toUpperCase()).find(Boolean)
this.context.waitSplitRoute = {
...(this.context.waitSplitRoute || {}),
// The normal order route rotates the destination list from randomIndex;
// force this producer onto the next distinct priced FBA warehouse so
// dispatch can exercise a multi-destination candidate set.
randomIndex: 17,
destinationIndex: 1,
excludeDestinationCodes: primaryCode ? [primaryCode] : [],
}
}
// 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 (shippingRequired && !producerKey) {
this.assert('phase4-overseas-pda-fixture', `${label}产生者身份完整`, null, false,
'phase3LoadingCompletedProducer.resourceKey=missing',
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
}
if (shippingRequired && 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))
if (!pickupRequired) {
// 第二柜先真实出仓并建立原生船期任务,保留不可接柜状态供PDA拒绝用例;Admin预约前再推进开船。
const source = this.createdResources.phase3CarrierScheduleQuery
const ready = source?.ownedByModule === true && source.marker === variantMarker && Boolean(source.queryId)
writeShippingEvidence(false, ready ? String(source.queryId) : null)
this.assert('phase4-overseas-pda-fixture', `${label}已真实出仓但尚未开放接柜`, null, ready,
`resourceKey=${producerKey}, queryPresent=${Boolean(source?.queryId)}`,
{ scenario: 'phase3-phase4-producer-fixture', deploymentBlocker: true })
} else {
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)
}
const hasSecondStandardFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
.some((item) => /-P4-FBA-SECOND(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
if (!hasSecondStandardFbaProducer()) {
await this.runPhase4ProducerVariant('FBA-SECOND', 'FBA', 'STANDARD', true)
}
const hasOperationsFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
.some((item) => /-P4-FBA-OPERATIONS(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
if (!hasOperationsFbaProducer()) {
await this.runPhase4ProducerVariant('FBA-OPERATIONS', 'FBA', 'STANDARD', true)
}
const hasRouteFbaProducer = () => (this.createdResources.phase4InboundProducerFixtures?.containers || [])
.some((item) => /-P4-FBA-ROUTE(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
if (!hasRouteFbaProducer()) {
await this.runPhase4ProducerVariant('FBA-ROUTE', '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 secondStandardFbaReady = containers.find((item) =>
/-P4-FBA-SECOND(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
const operationsFbaReady = containers.find((item) =>
/-P4-FBA-OPERATIONS(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
const routeFbaReady = containers.find((item) =>
/-P4-FBA-ROUTE(?:-R\d+)?:/.test(String(item?.resourceKey || '')))
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)
&& secondStandardFbaReady?.shippingReady === true && Boolean(secondStandardFbaReady?.carrierScheduleQueryId)
&& operationsFbaReady?.shippingReady === true && Boolean(operationsFbaReady?.carrierScheduleQueryId)
&& routeFbaReady?.shippingReady === true && Boolean(routeFbaReady?.carrierScheduleQueryId)
&& overseasReady?.shippingReady === true && Boolean(overseasReady?.carrierScheduleQueryId)
&& awaitingVariants.length >= 1
&& awaitingVariants.every((item) => item.shippingReady === false
&& Boolean(item.carrierScheduleQueryId))
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.environment === 'test' && !this.options.fixedCustomerId) {
throw new Error('TEST 验收必须配置 ADMIN_TEST_FIXED_CUSTOMER_ID;禁止自动创建测试客户')
}
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
if (this.options.pdaUsername && this.options.pdaPassword) {
const pdaLoginResponse = await this.request('POST', '/api/auth/login', {
loginId: this.options.pdaUsername, password: this.options.pdaPassword,
}, '')
const pdaLogin = this.apiSuccess('authentication', '国内 PDA 作业员登录', pdaLoginResponse)
this.pdaToken = pdaLogin?.accessToken || ''
this.assert('authentication', 'PDA 作业员登录成功', pdaLoginResponse, Boolean(this.pdaToken),
`tokenPresent=${Boolean(this.pdaToken)}`, { deploymentBlocker: true, scenario: 'phase3-pda' })
}
this.refreshToken = login.refreshToken || ''
this.expiresAt = Number(login.expiresIn) > 0 ? Date.now() + Number(login.expiresIn) * 1000 : 0
const handlers = { customers: () => this.runCustomers() }
for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) {
if (await this.stopAtBoundary()) break
const module = this.options.modules[moduleIndex]
if (this.expiresAt > 0 && Date.now() + 120_000 >= this.expiresAt) {
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 })
}
}
if (await this.stopAtBoundary()) {
if (restoreFixture) restoreFixture()
break
}
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 (await this.stopAtBoundary()) break
if (module === 'merchant-qualification-fixture' && this.options.fixedCustomerId
&& !this.context.merchantQualificationFixture?.reused) {
this.fatal = '固定客户身份校验失败,停止后续造数'
break
}
if (module === 'inbound-measurement'
&& !this.options.prepareMeasurementReview
&& 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.request('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: 'session-refresh', deploymentBlocker: true })
}
}
}
}
if (this.options.verifyUnloadEventRecovery) {
this.addStep('phase4-overseas-pda', '显式卸柜故障场景必须实际完成且只验证本轮一个柜', null,
unloadRecoveryWasVerified(this), '不以未触发故障或普通收货成功代替事件恢复证据',
{ scenario: 'reverse-unload-event-recovery', 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.options.fixedSecondaryCustomer?.password,
this.token, this.refreshToken]
.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: this.options.prepareDomesticPdaH5 ? '国内 PDA H5 待拣任务准备(非全链路验收)' : this.options.environment === 'gray' ? '灰度环境 FBA 定向闭环 HTTP API 验收' : '后台管理测试环境 HTTP API 验收', marker: this.marker,
environment: this.options.environment || 'test', gateway: this.options.gateway,
executionScope: {
requestedModules: [...(this.options.requestedModules || [])],
plannedModules: [...this.options.modules],
defaultModules: [...DEFAULT_MODULES],
defaultPlanSelected: DEFAULT_MODULES.every((name) => this.options.modules.includes(name)),
stoppedByRequest: this.stopRequested === true,
preparationOnly: this.options.prepareDomesticPdaH5 === true || this.options.prepareDropshipManualCount === true || this.options.prepareMeasurementReview === true,
returnMainChainOnly: this.options.returnMainChainOnly === true,
evidenceType: 'HTTP',
pageAcceptance: 'NOT_VERIFIED',
},
generatedAt: new Date().toISOString(), startedAt: this.startedAt,
success: this.fatal === null && summary.failed === 0, fatal: this.fatal,
fullCoverage: this.coverageGaps.length === 0 && !this.modules.some(module => module.skipped),
...(this.options.prepareDomesticPdaH5 ? { fullAcceptance: false, purpose: 'DOMESTIC_PDA_H5_FIXTURE' } : {}),
...(this.options.prepareMeasurementReview ? { fullAcceptance: false, purpose: 'MEASUREMENT_REVIEW_UI_FIXTURE' } : {}),
summary,
modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers,
coverageGaps: this.coverageGaps,
createdResources: this.createdResources,
excluded: [
{ scope: 'hidden-prototypes', reason: '隐藏原型页面尚无已确认后端契约' },
...(this.options.environment === 'gray' ? [{ scope: 'full-regression-and-fault-injection',
reason: '仅执行显式 FBA 闭环及所需正式业务依赖;不覆盖默认全量、TEST 故障注入、真实邮件或供应商自动查询' }] : []),
],
securityNote: '报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。',
steps: this.steps,
}
}
}
export function unloadRecoveryWasVerified(run) {
const records = Object.entries(run.createdResources?.phase4OverseasPda?.receivingCompletionEvidence || {})
.filter(([, row]) => row.eventRecovery)
if (records.length !== 1) return false
const [bookingId, row] = records[0]
return row.status === 'VERIFIED' && row.eventRecoveryInventoryUnchanged === true
&& row.eventRecovery.restored === true && row.eventRecovery.marker === run.marker
&& row.eventRecovery.containerBookingId === bookingId
}
export async function configureReadOnlyEvidence(run, environment, createReaders, faultFactories) {
assertGrayExecutionScope(run.options)
if (!run.options.modules.some(name => ['phase4-overseas-pda', 'phase5-root-fulfillment', 'manual-state-matrix'].includes(name))) return
const factory = createReaders || (run.options.environment === 'gray'
? (await import('./gray-regression-evidence.mjs')).createGrayEvidenceReaders
: (await import('./fba-core-regression-evidence.mjs')).createTestEvidenceReaders)
const readers = factory(environment)
await readers.preflight()
run.context.phase4ReceivingEvidenceReader = readers.readReceiving
run.context.f40ReadOnlyOutboxEvidence = readers.readCompletion
if (run.options.environment === 'gray') return
const faults = []
if (run.options.modules.includes('phase4-cross-region-events')) {
const { createTestFaultExecutor } = faultFactories || await import('./test-event-fault-executor.mjs')
const fault = createTestFaultExecutor(environment)
run.context.beforeLoadingComplete = fault.prepare
run.context.directDeliveryReplayEvidenceReader = fault.readProof
faults.push(fault)
}
if (run.options.verifyUnloadEventRecovery) {
const { createTestUnloadFaultExecutor } = faultFactories || await import('./test-event-fault-executor.mjs')
const fault = createTestUnloadFaultExecutor(environment)
faults.push(fault)
let selected = null
run.context.beforeReceivingComplete = async scope => {
if (selected) return
selected = { marker: run.marker, customerId: scope.customerId, containerBookingId: scope.bookingId }
await fault.prepare(selected)
}
run.context.afterReceivingComplete = async scope => {
if (!selected || scope.bookingId !== selected.containerBookingId || scope.customerId !== selected.customerId) return null
return fault.readProof(selected)
}
}
if (faults.length) return {
cleanup: async () => {
const errors = []
for (const fault of [...faults].reverse()) {
try { await fault.cleanup() } catch { errors.push('TEST事件故障验收恢复失败,保留原任务恢复记录') }
}
if (errors.length) throw new Error(errors.join('; '))
},
}
}
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 run = new AcceptanceRun(options)
const fault = await configureReadOnlyEvidence(run, process.env)
let report
try { report = await run.run() }
finally {
try { await fault?.cleanup() }
catch (error) { run.addStep('phase4-cross-region-events', '故障验收后必须恢复原服务配置与健康状态', null,
false, String(error.message), { scenario: 'direct-delivery-replay', deploymentBlocker: true }) }
}
report.summary = buildAcceptanceSummary(run.steps, run.modules, run.coverageGaps)
report.success = report.fatal === null && report.summary.failed === 0
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 })
}