This commit is contained in:
2026-11-04 18:08:19 +08:00
parent 0993dd446e
commit 51291b4c27
16 changed files with 468 additions and 47 deletions
+65 -17
View File
@@ -5,17 +5,17 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
import { normalizeFixedCustomerId, requireFixedSecondaryCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs'
import { FIXED_RESOURCE_READONLY_MODULES, runFixedResourceReadOnly } from './admin-test-env-modules/fixed-resource-readonly.mjs'
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 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', 'mixed-container-receiving']
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']
'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', '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 FIXED_RESOURCE_SKIPPED_MODULES = new Set(['supplemental-boundaries'])
const A0_LOCATION_PAGE_SIZE = 200
const A0_MAX_LOCATION_MUTATIONS = 8
@@ -295,6 +295,7 @@ export function describeAcceptanceScope() {
defaultModules: [...DEFAULT_MODULES],
optionalModules: OPTIONAL_MODULES.filter((name) => !DEFAULT_MODULES.includes(name)),
fixedResourceSkippedModules: [...FIXED_RESOURCE_SKIPPED_MODULES],
fixedResourceReadOnlyModules: [...FIXED_RESOURCE_READONLY_MODULES],
}
}
const WEIGHT_IMAGE_PATH = process.env.ADMIN_TEST_WEIGHT_IMAGE_PATH || ''
@@ -675,6 +676,33 @@ export function buildAcceptanceSummary(steps = [], modules = [], coverageGaps =
}
}
/** 同一规则用于正常结束及 finally 清理之后,覆盖率不能脱离实际执行结果。 */
export function finalizeAcceptanceReport(report) {
const steps = report.steps || [], modules = report.modules || [], gaps = report.coverageGaps || []
const scope = report.executionScope
const planned = [...new Set(scope.plannedModules)]
const rows = name => modules.filter(module => module.name === name)
scope.completedModules = planned.filter(name => rows(name).length > 0 && rows(name).every(module =>
!module.skipped && module.success === true && Number(module.passed) > 0 && Number(module.failed) === 0))
scope.skippedModules = planned.filter(name => rows(name).some(module => module.skipped))
scope.failedModules = planned.filter(name => rows(name).some(module => !module.skipped
&& (module.success === false || module.fatal || Number(module.failed) > 0)))
scope.unexecutedModules = planned.filter(name => rows(name).length === 0)
scope.unverifiedModules = planned.filter(name => !scope.completedModules.includes(name)
&& !scope.skippedModules.includes(name) && !scope.failedModules.includes(name))
scope.defaultPlanSelected = scope.defaultModules.length > 0 && scope.defaultModules.every(name => planned.includes(name))
scope.planCompleted = planned.length > 0 && scope.completedModules.length === planned.length
report.summary = buildAcceptanceSummary(steps, modules, gaps)
report.success = !report.fatal && !scope.stoppedByRequest && planned.length > 0
&& report.summary.failed === 0 && !gaps.some(gap => gap.blocking || gap.deploymentBlocker)
&& !(report.deploymentBlockers || []).length && !scope.failedModules.length && !scope.unverifiedModules.length
report.fullCoverage = report.success && scope.planCompleted && scope.defaultPlanSelected
&& !scope.preparationOnly && !scope.returnMainChainOnly && report.fullAcceptance !== false
&& gaps.length === 0 && !steps.some(step => step.skipped)
report.coverageStatus = !report.success ? 'FAILED' : report.fullCoverage ? 'FULL' : 'PARTIAL'
return report
}
export class AcceptanceRun {
constructor(options) {
assertGrayExecutionScope(options)
@@ -885,13 +913,35 @@ export class AcceptanceRun {
reason: '固定资源模式排除生命周期;不计为验收通过' })
return
}
const readOnly = Boolean(this.options.fixedCustomerId && FIXED_RESOURCE_READONLY_MODULES.includes(name))
const startedAt = new Date().toISOString(); const start = this.steps.length; let fatal = null
try { await handler() } catch (error) {
try {
if (readOnly) await runFixedResourceReadOnly(this, name)
else 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 })
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,
...(readOnly ? { coverageMode: 'READ_ONLY', lifecycleSkipped: true } : {}) })
}
async runIsolatedModule(name, handler) {
const child = Object.create(this)
child.options = { ...this.options }
child.context = { ...this.context }
child.createdResources = {}
child.phase4ProducerAttempts = new Map()
child.marker = `${this.marker}-MIXED`
try { await handler(child) }
finally {
for (const field of ['token', 'refreshToken', 'expiresAt']) {
if (Object.hasOwn(child, field)) this[field] = child[field]
}
this.createdResources.isolatedModules ??= {}
this.createdResources.isolatedModules[name] = child.createdResources
}
}
/** 在模块边界结束,随后仍由main的finally恢复故障注入配置并写出失败报告。 */
@@ -2040,7 +2090,9 @@ export class AcceptanceRun {
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)
handler = module === 'mixed-container-receiving'
? () => this.runIsolatedModule(module, child => extension.run(child))
: () => extension.run(this)
} catch (error) {
const reason = error instanceof Error ? error.message : String(error)
this.addStep(module, '模块注册检查', null, false, `无法加载模块: ${reason}`)
@@ -2067,7 +2119,7 @@ export class AcceptanceRun {
}
const laterOverseasModule = this.options.modules.slice(moduleIndex + 1)
.some((name) => name.startsWith('phase4-overseas-'))
if (module === 'system' && laterOverseasModule) {
if (module === 'system' && laterOverseasModule && !this.options.fixedCustomerId) {
const refreshed = await this.refreshSession('system模块权限与角色变更完成')
if (refreshed) {
const probe = await this.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1')
@@ -2105,15 +2157,14 @@ export class AcceptanceRun {
this.addStep('report-security', '报告敏感信息扫描', null, !sensitive,
sensitive ? '检测到密码、Token、二进制/base64 或 OCR 原文字段' : '未发现密码、Token、文件二进制/base64 或 OCR 原文字段')
const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps)
return {
return finalizeAcceptanceReport({
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)),
notSelectedModules: [...AVAILABLE_MODULES].filter(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,
@@ -2121,11 +2172,9 @@ export class AcceptanceRun {
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),
fatal: this.fatal,
...(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,
@@ -2136,7 +2185,7 @@ export class AcceptanceRun {
],
securityNote: '报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。',
steps: this.steps,
}
})
}
}
@@ -2208,8 +2257,7 @@ async function main() {
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
finalizeAcceptanceReport(report)
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.log(`REPORT=${outputPath}`)