2185 lines
133 KiB
JavaScript
2185 lines
133 KiB
JavaScript
#!/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 })
|
||
}
|