#!/usr/bin/env node import { spawn } from 'node:child_process' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { basename, dirname, extname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' export const PALLET_LOCATION_SCENARIOS = Object.freeze([ { name: 'single-image-single-slot', description: '1 箱、1 张称重图,验证最小库位块与删除补传复用', expectedPalletCount: 1, args: ['--wait-split-package-count', '1', '--wait-split-random-value', '4'], expectedGuideEvidence: /reserved=1, actual=1, empty=0, slots=1/, }, { name: 'single-image-two-reserved-slots', description: '18 箱、1 张称重图,验证体积预留 2 个连续槽且空槽不阻塞', expectedPalletCount: 1, args: ['--wait-split-package-count', '18', '--wait-split-random-value', '74'], expectedGuideEvidence: /reserved=2, actual=1, empty=1, slots=2/, }, { name: 'measured-volume-growth', description: '12 箱、1 张称重图,验证实测体积跨档后扩为 2 个槽', expectedPalletCount: 1, args: ['--wait-split-package-count', '12', '--wait-split-random-value', '87'], expectedGuideEvidence: /reserved=2, actual=1, empty=1, slots=2/, }, { name: 'two-image-incremental-growth', description: '2 张称重图,验证第二张只追加槽且原槽身份不漂移', expectedPalletCount: 2, args: ['--route-mode', 'random', '--wait-split-random-value', '1'], requiredSteps: ['第2张确认后扩容只追加且既有槽身份不漂移'], }, { name: 'three-image-incremental-growth', description: '小体积订单上传 3 张称重图,强制验证库位块 1→2→3 连续扩容', expectedPalletCount: 3, args: ['--route-mode', 'random', '--wait-split-random-value', '2'], requiredSteps: [ '第2张确认后扩容只追加且既有槽身份不漂移', '第3张确认后扩容只追加且既有槽身份不漂移', ], requiredStepEvidence: [ { name: '第2张确认后扩容只追加且既有槽身份不漂移', pattern: /beforeSlots=1, afterSlots=2/ }, { name: '第3张确认后扩容只追加且既有槽身份不漂移', pattern: /beforeSlots=2, afterSlots=3/ }, ], }, ]) const COMMON_REQUIRED_STEPS = Object.freeze([ '删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY', '替代图片按新 resultId 复用原空槽', '固定客户第 1 单停在待分柜', '报告敏感信息扫描', ]) export function parseMatrixArguments(argv) { const result = { help: false, originWarehouseName: '义乌仓', scenarioNames: PALLET_LOCATION_SCENARIOS.map((item) => item.name), output: '', } for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] if (argument === '--help') { result.help = true; continue } 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 const value = inline === undefined ? argv[++index] : inline if (!value || value.startsWith('--')) throw new Error(`参数 --${name} 缺少值`) if (name === 'origin-warehouse-name') result.originWarehouseName = value.trim() else if (name === 'scenarios') result.scenarioNames = value.split(',').map((item) => item.trim()).filter(Boolean) else if (name === 'output') result.output = value else throw new Error(`无法识别的参数: --${name}`) } if (!result.originWarehouseName) throw new Error('origin-warehouse-name不能为空') if (result.scenarioNames.length === 0) throw new Error('scenarios至少包含一个场景') const available = new Set(PALLET_LOCATION_SCENARIOS.map((item) => item.name)) const unknown = result.scenarioNames.filter((name) => !available.has(name)) if (unknown.length > 0) throw new Error(`未知场景: ${unknown.join(',')}`) if (new Set(result.scenarioNames).size !== result.scenarioNames.length) { throw new Error('scenarios不能包含重复场景') } return result } export function inspectScenarioReport(scenario, report, exitCode = 0) { const steps = Array.isArray(report?.steps) ? report.steps : [] const stepByName = new Map(steps.map((step) => [step.name, step])) const requiredSteps = [...COMMON_REQUIRED_STEPS, ...(scenario.requiredSteps || [])] const missingOrFailedSteps = requiredSteps.filter((name) => stepByName.get(name)?.passed !== true) const mismatchedStepEvidence = (scenario.requiredStepEvidence || []) .filter(({ name, pattern }) => !pattern.test(String(stepByName.get(name)?.evidence || ''))) .map(({ name }) => name) const measurement = report?.createdResources?.inboundMeasurement || {} const guide = report?.createdResources?.inboundMeasurementLocationGuide || {} const guideEvidence = steps .filter((step) => /整票库位块计数与槽身份完整$/.test(String(step.name || ''))) .map((step) => String(step.evidence || '')) const guideEvidenceMatched = !scenario.expectedGuideEvidence || guideEvidence.some((evidence) => scenario.expectedGuideEvidence.test(evidence)) const assertions = { childExitCode: exitCode === 0, childReportSuccess: report?.success === true, requiredSteps: missingOrFailedSteps.length === 0, requiredStepEvidence: mismatchedStepEvidence.length === 0, expectedPalletCount: Number(measurement.palletCount) === scenario.expectedPalletCount, waitSplitReached: measurement.processStatus === 'WAIT_SPLIT', locationIdentityRecorded: Boolean(guide.blockId && guide.reservationId && guide.locationId), deletedSlotReused: guide.deleteSucceeded === true && guide.slotReused === true, expectedGuideShape: guideEvidenceMatched, } const passed = Object.values(assertions).every(Boolean) const sharedOverflowObserved = steps.some((step) => /sharedOverflow=true/.test(String(step.evidence || ''))) return { name: scenario.name, description: scenario.description, passed, assertions, missingOrFailedSteps, mismatchedStepEvidence, summary: report?.summary || null, resources: { marker: report?.marker || null, inboundId: measurement.inboundId || guide.inboundId || null, measurementId: measurement.measurementId || null, blockId: guide.blockId || null, reservationId: guide.reservationId || null, locationId: guide.locationId || null, palletCount: measurement.palletCount ?? null, processStatus: measurement.processStatus || null, sharedOverflowObserved, }, } } export function inspectCrossScenarioIsolation(results) { const fields = ['inboundId', 'blockId', 'reservationId'] const assertions = Object.fromEntries(fields.map((field) => { const values = results.map((result) => result.resources?.[field]).filter(Boolean).map(String) return [`unique${field[0].toUpperCase()}${field.slice(1)}`, values.length === results.length && new Set(values).size === values.length] })) return { passed: Object.values(assertions).every(Boolean), assertions } } function usage() { console.log(`用法: node scripts/pallet-location-test-env-acceptance.mjs [选项] 选项: --origin-warehouse-name <名称> 固定国内收货仓,默认“义乌仓” --scenarios <逗号列表> 只执行指定场景,默认执行全部 --output <报告路径> 汇总报告路径;明细写入同名 -details 目录 --help 显示帮助 可选场景: ${PALLET_LOCATION_SCENARIOS.map((item) => `${item.name}: ${item.description}`).join('\n ')}`) } async function runChild(scriptPath, args) { return await new Promise((resolvePromise) => { const child = spawn(process.execPath, [scriptPath, ...args], { cwd: process.cwd(), env: process.env, stdio: 'inherit', shell: false, }) child.once('error', (error) => resolvePromise({ exitCode: 1, error: error.message })) child.once('exit', (code, signal) => resolvePromise({ exitCode: Number.isInteger(code) ? code : 1, error: signal ? `子进程被信号 ${signal} 终止` : null, })) }) } async function main() { const options = parseMatrixArguments(process.argv.slice(2)) if (options.help) { usage(); return } const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14) const outputPath = resolve(options.output || `reports/pallet-location-http-matrix-${timestamp}.json`) const extension = extname(outputPath) const detailsName = `${basename(outputPath, extension)}-details` const detailsDir = resolve(dirname(outputPath), detailsName) const runnerPath = resolve('scripts/admin-test-env-acceptance.mjs') await mkdir(detailsDir, { recursive: true }) const selected = options.scenarioNames.map((name) => PALLET_LOCATION_SCENARIOS.find((scenario) => scenario.name === name)) const startedAt = new Date().toISOString() const results = [] for (const scenario of selected) { const scenarioReportPath = resolve(detailsDir, `${scenario.name}.json`) console.log(`\n[PALLET-LOCATION] START ${scenario.name}: ${scenario.description}`) const child = await runChild(runnerPath, [ '--modules', 'orders,inbound-measurement', '--origin-warehouse-name', options.originWarehouseName, '--verify-pallet-location-guide', 'true', '--output', scenarioReportPath, ...scenario.args, ]) let report = null let reportReadError = child.error try { report = JSON.parse(await readFile(scenarioReportPath, 'utf8')) } catch (error) { reportReadError ||= error instanceof Error ? error.message : String(error) } const inspected = inspectScenarioReport(scenario, report, child.exitCode) results.push({ ...inspected, reportPath: scenarioReportPath, reportReadError }) console.log(`[PALLET-LOCATION] ${inspected.passed ? 'PASS' : 'FAIL'} ${scenario.name}`) } const isolation = inspectCrossScenarioIsolation(results) const success = results.every((result) => result.passed) && isolation.passed const aggregate = { reportName: '订单测量与连续库位真实 HTTP 扩展矩阵', generatedAt: new Date().toISOString(), startedAt, success, originWarehouseName: options.originWarehouseName, summary: { totalScenarios: results.length, passedScenarios: results.filter((result) => result.passed).length, failedScenarios: results.filter((result) => !result.passed).length, sharedOverflowObserved: results.some((result) => result.resources.sharedOverflowObserved), }, isolation, results, coverageNotes: [ '每个场景使用独立订单、入库单和库位块,并验证跨场景 reservation 不串票。', 'A0 仅在普通库位自然不足时观测;脚本不会批量停用普通库位制造容量不足。', 'Warehouse 调用异常时不回滚订单测量主流程由服务单元测试覆盖;真实 HTTP 矩阵不关闭共享服务做故障注入。', '脚本不执行 SQL、不部署服务、不直接操作数据库。', ], } await mkdir(dirname(outputPath), { recursive: true }) await writeFile(outputPath, `${JSON.stringify(aggregate, null, 2)}\n`, 'utf8') console.log(`\nREPORT=${outputPath}`) console.log(`RESULT=${success ? 'PASS' : 'FAIL'} scenarios=${results.length} passed=${aggregate.summary.passedScenarios} failed=${aggregate.summary.failedScenarios}`) process.exitCode = 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 }) }