diff --git a/scripts/README.md b/scripts/README.md index 5058291d1..4dd6b2a24 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -63,6 +63,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套 | 分类 | 入口 | 用途与副作用 | |---|---|---| | 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 | +| 测试环境 HTTP | `pallet-location-test-env-acceptance.mjs` | 订单测量与连续库位扩展矩阵;串行创建 5 票独立订单,覆盖 1/2/3 张称重图、体积跨档、删图释放、补传复用和跨票隔离,不主动停用库位制造 A0 | | 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 | | 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 | | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 二期入库、测量、费用和权限专项;写测试数据 | @@ -123,6 +124,18 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" 报告写入 `reports/client-quick-order-test-env-*.json`。脚本不提交、不审核、不取消订单,成功草稿保留为可丢弃测试数据;真实 HTTP 仅在当前轮明确授权测试环境验收后执行。 +## 订单测量与连续库位扩展矩阵 + +部署完成后,可用独立入口串行执行订单创建、审核建入库单、称重确认、库位指引、删除释放、补传复用、测量提交/审核至 `WAIT_SPLIT`。默认固定“义乌仓”并执行 5 个确定性场景:单图单槽、单图两预留槽、实测体积跨档、两图逐次扩容、三图连续扩容。每票使用独立订单、入库单、库位块和 reservation,最后额外验证跨票不串数据。 + +```powershell +node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/pallet-location-test-env-acceptance.mjs +``` + +只回归部分场景时使用 `--scenarios single-image-single-slot,measured-volume-growth`;仓库可用 `--origin-warehouse-name <名称>` 覆盖。汇总默认写入 `reports/pallet-location-http-matrix-*.json`,每票完整证据写入相邻的 `*-details/` 目录。任一场景失败后仍继续执行剩余场景,最终以非零退出码和汇总报告统一暴露失败。 + +该入口只复用正式 HTTP 验收模块,不执行 SQL、不部署服务,也不会为了制造容量不足而批量停用普通库位;普通库位自然不足时记录是否落到共享 `A0`。共享 Warehouse 故障不做停服注入,其“不回滚测量主流程”语义由服务单元测试覆盖。真实 HTTP 仅在当前轮明确授权测试环境验收后执行。 + ## 固定客户待审核订单 需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。 diff --git a/scripts/pallet-location-test-env-acceptance.contract.test.mjs b/scripts/pallet-location-test-env-acceptance.contract.test.mjs new file mode 100644 index 000000000..46374bed6 --- /dev/null +++ b/scripts/pallet-location-test-env-acceptance.contract.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +import { + PALLET_LOCATION_SCENARIOS, + inspectCrossScenarioIsolation, + inspectScenarioReport, + parseMatrixArguments, +} from './pallet-location-test-env-acceptance.mjs' + +const source = await readFile(new URL('./pallet-location-test-env-acceptance.mjs', import.meta.url), 'utf8') + +test('pallet location matrix declares deterministic one, two, and three-image scenarios', () => { + assert.equal(PALLET_LOCATION_SCENARIOS.length, 5) + assert.deepEqual(PALLET_LOCATION_SCENARIOS.map((item) => item.expectedPalletCount), [1, 1, 1, 2, 3]) + assert.match(source, /--modules', 'orders,inbound-measurement'/) + assert.match(source, /--verify-pallet-location-guide', 'true'/) + assert.match(source, /--wait-split-package-count', '18'/) + assert.match(source, /--wait-split-package-count', '12'/) + assert.match(source, /--wait-split-package-count', '61'/) + assert.match(source, /第3张确认后扩容只追加且既有槽身份不漂移/) +}) + +test('matrix options select known scenarios without duplicate or unknown names', () => { + const parsed = parseMatrixArguments([ + '--origin-warehouse-name', '测试仓', + '--scenarios', 'single-image-single-slot,two-image-incremental-growth', + '--output=reports/custom.json', + ]) + assert.equal(parsed.originWarehouseName, '测试仓') + assert.deepEqual(parsed.scenarioNames, ['single-image-single-slot', 'two-image-incremental-growth']) + assert.equal(parsed.output, 'reports/custom.json') + assert.throws(() => parseMatrixArguments(['--scenarios', 'unknown']), /未知场景/) + assert.throws(() => parseMatrixArguments(['--scenarios', 'measured-volume-growth,measured-volume-growth']), /重复场景/) +}) + +test('scenario inspection requires deletion release, refill reuse, WAIT_SPLIT, and guide identity', () => { + const scenario = PALLET_LOCATION_SCENARIOS[0] + const requiredNames = [ + '删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY', + '替代图片按新 resultId 复用原空槽', + '固定客户第 1 单停在待分柜', + '报告敏感信息扫描', + ] + const report = { + success: true, + marker: 'TEST', + summary: { total: 10, failed: 0 }, + createdResources: { + inboundMeasurement: { inboundId: '11', measurementId: '12', palletCount: 1, processStatus: 'WAIT_SPLIT' }, + inboundMeasurementLocationGuide: { + inboundId: '11', blockId: '13', reservationId: '14', locationId: '15', + deleteSucceeded: true, slotReused: true, + }, + }, + steps: [ + ...requiredNames.map((name) => ({ name, passed: true })), + { name: '第1张确认后整票库位块计数与槽身份完整', passed: true, + evidence: 'blockId=13, reserved=1, actual=1, empty=0, slots=1' }, + ], + } + const inspected = inspectScenarioReport(scenario, report, 0) + assert.equal(inspected.passed, true) + report.steps.find((step) => step.name.startsWith('删除只解绑')).passed = false + assert.equal(inspectScenarioReport(scenario, report, 0).passed, false) +}) + +test('cross-scenario inspection permits shared A0 location but rejects shared reservation or block', () => { + const results = [ + { resources: { inboundId: '1', blockId: '11', reservationId: '21', locationId: 'A0' } }, + { resources: { inboundId: '2', blockId: '12', reservationId: '22', locationId: 'A0' } }, + ] + assert.equal(inspectCrossScenarioIsolation(results).passed, true) + results[1].resources.reservationId = '21' + assert.equal(inspectCrossScenarioIsolation(results).passed, false) +}) + +test('matrix does not disable locations, execute SQL, deploy, or use a shell child process', () => { + assert.doesNotMatch(source, /location-status|batch-disable|UPDATE\s+|DELETE\s+FROM|INSERT\s+INTO/i) + assert.doesNotMatch(source, /jenkins|docker\s+compose|kubectl/i) + assert.match(source, /shell: false/) + assert.match(source, /A0 仅在普通库位自然不足时观测/) +}) diff --git a/scripts/pallet-location-test-env-acceptance.mjs b/scripts/pallet-location-test-env-acceptance.mjs new file mode 100644 index 000000000..475d46669 --- /dev/null +++ b/scripts/pallet-location-test-env-acceptance.mjs @@ -0,0 +1,238 @@ +#!/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) + const overflowStep = steps.find((step) => String(step.name || '').includes('A0共享溢出位状态可见')) + 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, + sharedOverflowObserved: /sharedOverflow=true/.test(String(overflowStep?.evidence || '')), + }, + } +} + +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 + }) +}