2026-09-04 15:24:45 +08:00
|
|
|
#!/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: '61 箱、3 张称重图,验证连续两次增托与既有槽身份稳定',
|
|
|
|
|
expectedPalletCount: 3,
|
|
|
|
|
args: ['--wait-split-package-count', '61', '--wait-split-random-value', '1'],
|
|
|
|
|
requiredSteps: [
|
|
|
|
|
'第2张确认后扩容只追加且既有槽身份不漂移',
|
|
|
|
|
'第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 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,
|
|
|
|
|
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)
|
2026-09-04 15:29:06 +08:00
|
|
|
const sharedOverflowObserved = steps.some((step) =>
|
|
|
|
|
/sharedOverflow=true/.test(String(step.evidence || '')))
|
2026-09-04 15:24:45 +08:00
|
|
|
return {
|
|
|
|
|
name: scenario.name,
|
|
|
|
|
description: scenario.description,
|
|
|
|
|
passed,
|
|
|
|
|
assertions,
|
|
|
|
|
missingOrFailedSteps,
|
|
|
|
|
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,
|
2026-09-04 15:29:06 +08:00
|
|
|
sharedOverflowObserved,
|
2026-09-04 15:24:45 +08:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
})
|
|
|
|
|
}
|