diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 1a69e9b83..664946bfc 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -250,6 +250,10 @@ function usage() { --channel-code 固定物流渠道编码 --order-profile-mode 订单档案模式:fixed 或 randomized,默认fixed --wait-split-count 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1 + --wait-split-package-count 单票指定总箱数,1-1000;省略时使用既有轮换 + --wait-split-sequence 单票全局序号,正整数,默认1 + --wait-split-random-value 单票随机选择值,正整数,默认使用全局序号 + --route-mode 线路选择:fixed 或 random,默认fixed --manual-split-fixture-report 人工分柜准备报告路径,仅只读核验模块使用 --manual-split-plan-id 人工操作产生的智能装柜计划 ID,仅只读核验模块使用 --manual-preload-source-report 人工推送预装前置或核验来源报告路径 @@ -302,6 +306,11 @@ export function parseArguments(argv) { orderProfileMode: values.get('order-profile-mode') || 'fixed', waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT || (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), + routeMode: values.get('route-mode') || 'fixed', + quiet: values.get('quiet') === 'true', manualSplitFixtureReport: values.get('manual-split-fixture-report') || '', manualSplitPlanId: values.get('manual-split-plan-id') || '', manualPreloadSourceReport: values.get('manual-preload-source-report') || '', @@ -325,6 +334,16 @@ export function validateExecutionOptions(options) { 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个字符') } @@ -338,7 +357,13 @@ export function validateExecutionOptions(options) { if (!['fixed', 'randomized'].includes(options.orderProfileMode)) { throw new Error('order-profile-mode必须为fixed或randomized') } - if (options.orderProfileMode === '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订单档案必须同时固定起运仓、渠道名称和渠道编码') } @@ -445,7 +470,8 @@ export function buildAcceptanceSummary(steps = [], modules = [], coverageGaps = export class AcceptanceRun { constructor(options) { this.options = options - this.marker = `ADMIN-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}` + const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12) + this.marker = `ADMIN-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}` this.startedAt = new Date().toISOString() this.steps = [] this.modules = [] @@ -620,7 +646,9 @@ export class AcceptanceRun { } 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 }) - console.log(`[${step.passed ? 'PASS' : 'FAIL'}] [${module}${step.scenario ? `/${step.scenario}` : ''}] ${name}${evidence ? ` - ${evidence}` : ''}`) + if (!this.options.quiet) { + console.log(`[${step.passed ? 'PASS' : 'FAIL'}] [${module}${step.scenario ? `/${step.scenario}` : ''}] ${name}${evidence ? ` - ${evidence}` : ''}`) + } return step.passed } @@ -706,13 +734,33 @@ export class AcceptanceRun { { code: 'LARGE', first: { length: 49.1, width: 39.1, height: 38.1 }, replacement: { length: 49.5, width: 39.5, height: 38.5 }, outer: { length: 54, width: 44, height: 44 } }, { code: 'EXTRA_LARGE', first: { length: 59.1, width: 49.1, height: 48.1 }, replacement: { length: 59.5, width: 49.5, height: 48.5 }, outer: { length: 64, width: 54, height: 54 } }, ] - const skuCount = 1 + ((sequence - 1) % 4) - const packageCountPerSku = 1 + (Math.floor((sequence - 1) / 4) % 5) - const palletCount = 1 + ((sequence - 1) % 3) - const grossWeightPerPalletKg = 45 + ((sequence - 1) % 6) * 15 + 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, palletCount, grossWeightPerPalletKg, - measurementProfile: dimensionProfiles[(sequence - 1) % dimensionProfiles.length], + sequence, skuCount, packageCountPerSku, packageCounts, + totalPackageCount, + palletCount, grossWeightPerPalletKg, + measurementProfile: dimensionProfiles[randomValue % dimensionProfiles.length], } } @@ -725,15 +773,16 @@ export class AcceptanceRun { ['OVERSEAS', 'ONE_CLICK_DROPSHIP'], ['PRIVATE', 'ONE_CLICK_DROPSHIP'], ] - const [destinationType, fulfillmentType] = combinations[(sequence * 5 + 1) % combinations.length] + 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[(sequence * 7 + 2) % pickupMethods.length], - taxMode: sequence % 2 === 0 ? 'TAX_REFUND' : 'BUY_DECLARATION', - containerMode: sequence % 2 === 0 ? 'FULL_CONTAINER' : 'LCL', - customsInspection: sequence % 4 === 0, + 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, } } @@ -753,6 +802,8 @@ export class AcceptanceRun { 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, } @@ -1421,7 +1472,10 @@ export class AcceptanceRun { process.env.MERCHANT_TEST_USERNAME = this.options.merchantUsername process.env.MERCHANT_TEST_PASSWORD = this.options.merchantPassword } - if (this.options.waitSplitCount > 1) this.applyWaitSplitVariant(1) + if (this.options.waitSplitCount > 1 || this.options.waitSplitPackageCount > 0 + || this.options.orderProfileMode === 'randomized' || this.options.routeMode === 'random') { + this.applyWaitSplitVariant(this.options.waitSplitSequence || 1) + } const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '') const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse) if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' @@ -1508,7 +1562,8 @@ export class AcceptanceRun { } finally { if (restoreFixture) restoreFixture() } - if (module === 'inbound-measurement' && this.captureWaitSplitFixture(1)) { + if (module === 'inbound-measurement' + && this.captureWaitSplitFixture(this.options.waitSplitSequence || 1)) { await this.createAdditionalWaitSplitFixtures() } const laterOverseasModule = this.options.modules.slice(moduleIndex + 1) diff --git a/scripts/admin-test-env-modules/IT测试客户八千票待分柜数据生成方案.md b/scripts/admin-test-env-modules/IT测试客户八千票待分柜数据生成方案.md new file mode 100644 index 000000000..aa8a03352 --- /dev/null +++ b/scripts/admin-test-env-modules/IT测试客户八千票待分柜数据生成方案.md @@ -0,0 +1,96 @@ +# IT测试客户八千票待分柜数据生成方案 + +## 目标 + +通过测试环境正式 HTTP 接口,最多尝试生成 8,000 票头程订单并推进到: + +- 入库单 `processStatus=WAIT_SPLIT` +- 测量审核 `reviewStatus=APPROVED` + +只固定两个条件: + +1. 商户账号登录后的企业名称必须精确等于“IT测试”。 +2. 国内起运仓必须精确等于启用状态的“义乌仓”。 + +不固定渠道、报价税制、目的类型、目的仓、履约类型、提货方式、买单/退税、装载方式、是否查验、SKU 数、箱型、尺寸、托数和重量。它们从当前客户真实可选择且服务端校验通过的数据中选择。 + +## 数量规则 + +- 最大尝试数:8,000 票。 +- 每票独立随机 `1–300` 箱,随机到多少就生成多少。 +- 不要求 `1–300` 全部出现,允许重复和缺号。 +- 每票随机总箱数必须等于所有 SKU 箱数之和,并与入库、测量和待分柜箱数保持一致。 +- 托数固定按每托最多 30 箱计算:`palletCount = ceil(totalPackageCount / 30)`;1–30 箱为 1 托,31–60 箱为 2 托,300 箱为 10 托。 +- 理论平均每票 `150.5` 箱,8,000 票理论期望总箱数约 `1,204,000` 箱;实际数量以成功结果为准。 +- 执行按 100 票统计一个批次,共最多 80 批。 + +入仓码流水为四位数。8,000 票只能降低触及 `9999` 上限的概率,不能保证所有渠道前缀都有足够余量;测试环境已有流水、渠道各自计数和并发生成都会影响实际可用数量。 + +## 随机线路 + +每票执行时: + +1. 查询“IT测试”客户可见的启用国内仓,并只保留“义乌仓”。 +2. 查询义乌仓当前客户可见且具有报价税制的渠道。 +3. 随机轮换渠道和报价税制候选。 +4. 对 FBA/海外仓线路,通过正式估价接口选择真实可计价目的仓。 +5. 私人地址只使用客户当前可选择的渠道,不伪造目的仓 ID。 +6. 候选为空、价格变更或服务错误时记录失败,不回退到硬编码渠道或历史价格。 + +## SKU 与测量 + +- SKU 数随机为 `1–4`,但不得超过本票总箱数。 +- 总箱数随机拆分到各 SKU,每个 SKU 至少 1 箱。 +- 毛重、净重、数量、申报价值和体积根据各 SKU 箱数计算。 +- 称重图片、托盘和占用库位数量必须与上述托数完全一致。 +- 箱唛打印冻结真实总箱数;多 SKU 测量使用各 SKU 实际箱数和对应箱序号。 +- 通过正式文件、称重 OCR、托盘库位、测量提交和审核接口推进状态,不直接写数据库。 + +## 尽力执行语义 + +执行入口为: + +```powershell +node scripts/it-test-wait-split-bulk.mjs ` + --count 8000 ` + --batch-size 100 ` + --customer-name 'IT测试' ` + --origin-warehouse-name '义乌仓' ` + --box-min 1 ` + --box-max 300 ` + --output-dir '<本地报告目录>' +``` + +- 随机数在每票请求前直接产生,不设置预览或人工确认步骤。 +- 每票先把本次箱数和随机值写入 `results.jsonl`,然后请求测试环境。 +- 单票失败不会停止后续票,失败报告保存到 `failures/`。 +- 每 100 票及每次失败后更新 `summary.json`。 +- 成功标准以该票真实到达 `WAIT_SPLIT / APPROVED` 且箱数、客户、义乌仓一致为准。 +- 最终允许不足 8,000 票成功,实际成功数、失败数和总箱数以 `summary.json` 为准。 + +## 报告内容 + +`results.jsonl` 对每票记录: + +- 全局序号、批次、随机箱数 +- 订单 ID/订单号 +- 入库单 ID/入仓码 +- 渠道、目的类型和目的仓 +- 是否到达待分柜 +- 断言失败、deployment blocker、`requestId`/`traceId` + +`summary.json` 汇总: + +- 已尝试、成功和失败票数 +- 成功票总箱数、最小/最大/平均箱数 +- 各渠道成功票数 +- 各目的类型成功票数 +- 进程 ID、开始时间、更新时间和报告目录 + +## 安全边界 + +- 仅用于已明确授权的测试环境,禁止用于生产环境。 +- 账号、密码、Token 和环境地址只从本机环境配置加载,不进入源码或报告。 +- 不自动删除已成功或部分成功的数据。 +- 不重置数据库、不执行 SQL、不绕过正式状态机。 +- 清理测试数据或文件必须另行获得明确授权。 diff --git a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs index 0fed93283..394982e55 100644 --- a/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs @@ -28,3 +28,7 @@ test('measurement fixture derives pallet resources from confirmed image count', assert.doesNotMatch(source, /Math\.ceil\(Number\(totalVolumeCbm\)/) assert.doesNotMatch(source, /Math\.ceil\(totalVolume \/ DEFAULT_PALLET_CAPACITY_CBM\)/) }) + +test('volume ceiling removes binary floating point noise before scaling', () => { + assert.match(source, /Number\.EPSILON \* Math\.max\(1, Math\.abs\(value\)\) \* 4/) +}) diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index f68f7d3d0..c6ebf4f85 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -14,7 +14,8 @@ function closeTo(actual, expected, tolerance = 0.000001) { } function ceiling(value, scale) { const factor = 10 ** scale - return Math.ceil((value - Number.EPSILON) * factor) / factor + const floatingPointNoise = Number.EPSILON * Math.max(1, Math.abs(value)) * 4 + return Math.ceil((value - floatingPointNoise) * factor) / factor } function halfUp(value, scale) { const factor = 10 ** scale @@ -375,24 +376,31 @@ export async function run(context) { const incrementalDetailCount = Number(context.context?.phase3LoadingIncrementalDetailCount || 1) if (incrementalDetailCount > 1) { const packagesPerDetail = Number(context.context?.phase3LoadingIncrementalPackageCount || 1) - const expectedPackages = incrementalDetailCount * packagesPerDetail + const configuredPackageCounts = context.context?.waitSplitVariant?.packageCounts + const detailPackageCounts = Array.isArray(configuredPackageCounts) + && configuredPackageCounts.length === incrementalDetailCount + ? configuredPackageCounts.map(Number) + : Array.from({ length: incrementalDetailCount }, () => packagesPerDetail) + const expectedPackages = detailPackageCounts.reduce((sum, count) => sum + count, 0) context.assert(MODULE, '动态增量多明细箱数前置与冻结总数一致', measuredResponse, expectedPackages === originalPackageCount, - `detailCount=${incrementalDetailCount}, packagesPerDetail=${packagesPerDetail}, frozen=${originalPackageCount}`, OPTIONS) + `detailCount=${incrementalDetailCount}, packageCounts=${detailPackageCounts.join('+')}, frozen=${originalPackageCount}`, OPTIONS) if (expectedPackages === originalPackageCount) { + const firstDetailPackageCount = detailPackageCounts[0] const resetFirstCountResponse = await context.request('PUT', `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${firstScan.itemId}/confirmed-package-count?${query(tenantId)}`, - { measurementVersion: Number(measured?.version), confirmedPackageCount: packagesPerDetail }) + { measurementVersion: Number(measured?.version), confirmedPackageCount: firstDetailPackageCount }) const resetFirstCount = context.apiSuccess(MODULE, '增量 fixture 将首条货物确认箱数恢复为自身箱数', resetFirstCountResponse, - `itemId=${asId(firstScan.itemId)}, confirmedPackageCount=${packagesPerDetail}`, OPTIONS) + `itemId=${asId(firstScan.itemId)}, confirmedPackageCount=${firstDetailPackageCount}`, OPTIONS) context.assert(MODULE, '增量首条货物箱数和版本更新正确', resetFirstCountResponse, - Number(resetFirstCount?.confirmedPackageCount) === packagesPerDetail + Number(resetFirstCount?.confirmedPackageCount) === firstDetailPackageCount && Number(resetFirstCount?.measurementVersion) === Number(measured?.version) + 1, `version=${resetFirstCount?.measurementVersion ?? 'missing'}`, OPTIONS) for (let detailIndex = 1; detailIndex < incrementalDetailCount; detailIndex += 1) { - const cartonIndex = detailIndex * packagesPerDetail + 1 + const detailPackageCount = detailPackageCounts[detailIndex] + const cartonIndex = detailPackageCounts.slice(0, detailIndex).reduce((sum, count) => sum + count, 0) + 1 const detailBoxCode = `${inboundCode}#${originalPackageCount}-${cartonIndex}` const scanResponse = await multipartRequest(context, scanPath, { boxCode: detailBoxCode, ...replacement, @@ -410,12 +418,12 @@ export async function run(context) { if (!scanned?.itemId) continue const countDetailResponse = await context.request('PUT', `/api/platform/v1/first-leg/inbounds/${inboundId}/measurement/items/${scanned.itemId}/confirmed-package-count?${query(tenantId)}`, - { measurementVersion: Number(scanned.measurementVersion), confirmedPackageCount: packagesPerDetail }) + { measurementVersion: Number(scanned.measurementVersion), confirmedPackageCount: detailPackageCount }) const countDetail = context.apiSuccess(MODULE, `增量第${detailIndex + 1}条货物确认自身箱数`, countDetailResponse, - `confirmedPackageCount=${packagesPerDetail}`, OPTIONS) + `confirmedPackageCount=${detailPackageCount}`, OPTIONS) context.assert(MODULE, `增量第${detailIndex + 1}条货物箱数更新正确`, countDetailResponse, - Number(countDetail?.confirmedPackageCount) === packagesPerDetail, + Number(countDetail?.confirmedPackageCount) === detailPackageCount, `measurementVersion=${countDetail?.measurementVersion ?? 'missing'}`, OPTIONS) } measuredResponse = await context.request('GET', measurementPath) diff --git a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs new file mode 100644 index 000000000..608494f7f --- /dev/null +++ b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') +const measurement = readFileSync(new URL('./inbound-measurement.mjs', import.meta.url), 'utf8') +const runner = readFileSync(new URL('../it-test-wait-split-bulk.mjs', import.meta.url), 'utf8') + +test('单票随机箱数严格分配到各 SKU 并限制为1到1000', () => { + assert.match(acceptance, /--wait-split-package-count /) + assert.match(acceptance, /wait-split-package-count必须是1-1000的整数或0/) + assert.match(acceptance, /packageCounts\.push\(remaining\)/) + assert.match(acceptance, /Math\.ceil\(totalPackageCount \/ 30\)/) + assert.match(orders, /packageCounts\[index\] \|\| packageCountPerSku/) + assert.match(measurement, /detailPackageCounts\.reduce\(\(sum, count\) => sum \+ count, 0\)/) + assert.match(measurement, /detailPackageCounts\.slice\(0, detailIndex\)/) +}) + +test('随机线路只固定义乌仓并从客户真实可计价候选选择', () => { + assert.match(acceptance, /route-mode /) + assert.match(acceptance, /random线路必须固定起运仓名称/) + assert.match(orders, /Client 查询固定起运仓随机渠道池/) + assert.match(orders, /findPricedOrderRoute\(\{/) + assert.match(orders, /Client 登录企业与指定客户名称一致/) + assert.doesNotMatch(runner, /channel-name|channel-code/) +}) + +test('八千票执行器逐票记录且失败后继续', () => { + assert.match(runner, /positiveInteger\(values\.get\('count'\), 8000/) + assert.match(runner, /positiveInteger\(values\.get\('box-max'\), 300/) + assert.match(runner, /randomInt\(boxMin, boxMax \+ 1\)/) + assert.match(runner, /Math\.ceil\(packageCount \/ 30\)/) + assert.match(runner, /for \(let offset = 0; offset < count; offset \+= 1\)/) + assert.match(runner, /event: 'planned'/) + assert.match(runner, /event: 'result'/) + assert.match(runner, /options\.markerNamespace = workerId/) + assert.match(runner, /failureDirectory/) + assert.match(runner, /summary\.failed \+= 1/) + assert.match(runner, /summary\.attempted % batchSize === 0/) +}) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index deecb0b78..9e8e4e7c8 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -19,6 +19,11 @@ function records(value) { return Array.isArray(value?.records) ? value.records : function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite(Number(value?.total)) } function success(response) { return Boolean(response) && response.http === 200 && String(response.body?.code) === '200' } function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } +function rotate(values, offset) { + if (!Array.isArray(values) || values.length < 2) return Array.isArray(values) ? [...values] : [] + const shift = Math.abs(Number(offset) || 0) % values.length + return [...values.slice(shift), ...values.slice(0, shift)] +} function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } export function requestedOrderDestinationType(context) { const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase() @@ -269,6 +274,33 @@ async function loadMasterData(context, merchantToken) { }) pricingResult = { receiving, ...result, originFailures: [] } } + } else if (fixedRoute.originWarehouseName) { + const receiving = receivings.find((warehouse) => warehouse.warehouseName === fixedRoute.originWarehouseName) + if (!receiving) throw new Error(`未找到固定起运仓: ${fixedRoute.originWarehouseName}`) + const channelsResponse = await request(context, 'GET', + `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, + undefined, merchantToken) + const availableChannels = context.apiSuccess(MODULE, 'Client 查询固定起运仓随机渠道池', channelsResponse) + const channels = rotate((Array.isArray(availableChannels) ? availableChannels : []) + .filter((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0), + fixedRoute.randomIndex) + .map((channel, index) => ({ ...channel, + pricingTaxModes: rotate(channel.pricingTaxModes, Number(fixedRoute.randomIndex) + index) })) + if (channels.length === 0) throw new Error('固定起运仓没有客户可选择的可计价渠道') + if (destinationType === 'PRIVATE') { + const channel = channels[0] + pricingResult = { receiving, priced: { channel, + pricingTaxMode: channel.pricingTaxModes[0], destination: null }, + response: channelsResponse, attempts: 0, failures: [], originFailures: [] } + } else { + const result = await findPricedOrderRoute({ + receiving, destinations: rotate(destinations, Math.floor(Number(fixedRoute.randomIndex) / 17)), + channels, + estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken), + destinationType, + }) + pricingResult = { receiving, ...result, originFailures: [] } + } } else { pricingResult = await findPricedOrderRouteAcrossOrigins({ receivings, destinations, @@ -307,6 +339,8 @@ function buildPayload(context, profile, master, productImageFileId) { const packageCountPerSku = Math.max(1, Number(context.context?.waitSplitVariant?.packageCountPerSku || context.context?.phase3LoadingIncrementalPackageCount || 1)) + const packageCounts = Array.isArray(context.context?.waitSplitVariant?.packageCounts) + ? context.context.waitSplitVariant.packageCounts : [] const selectedBoxes = BOXES.slice(0, skuCount) const orderProfile = context.context?.orderProfile || {} const isPrivate = master.destinationType === 'PRIVATE' @@ -346,7 +380,7 @@ function buildPayload(context, profile, master, productImageFileId) { cargoes: selectedBoxes.map((box, index) => { const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000 const unitsPerPackage = 10 + index - const packageCount = packageCountPerSku + const packageCount = Number(packageCounts[index] || packageCountPerSku) return { skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64), cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`, @@ -453,6 +487,12 @@ export async function run(context) { context.assert(MODULE, 'Client 测试企业已审核且客户 ID 有效', profileResponse, Boolean(tenantId) && profile?.status === 'ACTIVE', `tenantId=${tenantId || 'missing'}, status=${profile?.status ?? 'missing'}`) if (!tenantId) throw new Error('Client 企业资料未返回客户 ID') + if (context.options?.customerName) { + context.assert(MODULE, 'Client 登录企业与指定客户名称一致', profileResponse, + profile?.tenantName === context.options.customerName, + `expected=${context.options.customerName}, actual=${profile?.tenantName || 'missing'}`, CONTRACT_OPTIONS) + if (profile?.tenantName !== context.options.customerName) throw new Error('Client 登录企业不是指定客户') + } await assertDimensionConfig(context, tenantId) const master = await loadMasterData(context, merchantToken) diff --git a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md index 92fdfa315..1f9b50166 100644 --- a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md +++ b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md @@ -2,6 +2,8 @@ 本文说明如何复用 `scripts/admin-test-env-acceptance.mjs`,通过正式 HTTP 业务接口批量生成停在“测量完成待分柜”的头程订单。生成链路包含客户登录、下单、平台审核、入库、称重、测量、测量审核,不直接写业务数据库,也不模拟成功。 +固定“IT测试”客户和“义乌仓”、其余业务字段随机、每票独立随机 `1–300` 箱并尽力生成 8,000 票的专项入口,见 `IT测试客户八千票待分柜数据生成方案.md` 和 `../it-test-wait-split-bulk.mjs`。 + ## 适用范围 - 用于开发、联调或经明确授权的验收环境准备人工操作数据。 diff --git a/scripts/it-test-wait-split-bulk.mjs b/scripts/it-test-wait-split-bulk.mjs new file mode 100644 index 000000000..877f72fdf --- /dev/null +++ b/scripts/it-test-wait-split-bulk.mjs @@ -0,0 +1,146 @@ +import { randomInt } from 'node:crypto' +import { appendFile, mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import process from 'node:process' + +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' + +function argumentsMap(argv) { + 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 name = argument.slice(2) + const value = argv[index + 1] + if (!value || value.startsWith('--')) throw new Error(`参数 --${name} 缺少值`) + values.set(name, value) + index += 1 + } + return values +} + +function positiveInteger(value, fallback, name) { + const parsed = Number(value ?? fallback) + if (!Number.isInteger(parsed) || parsed < 1) throw new Error(`${name}必须是正整数`) + return parsed +} + +function increment(record, key) { + const normalized = String(key || 'UNKNOWN') + record[normalized] = Number(record[normalized] || 0) + 1 +} + +async function main() { + const values = argumentsMap(process.argv.slice(2)) + const count = positiveInteger(values.get('count'), 8000, 'count') + const batchSize = positiveInteger(values.get('batch-size'), 100, 'batch-size') + const startIndex = positiveInteger(values.get('start-index'), 1, 'start-index') + const boxMin = positiveInteger(values.get('box-min'), 1, 'box-min') + const boxMax = positiveInteger(values.get('box-max'), 300, 'box-max') + if (boxMax < boxMin || boxMax > 300) throw new Error('箱数范围必须满足1 <= box-min <= box-max <= 300') + const customerName = values.get('customer-name') || 'IT测试' + const originWarehouseName = values.get('origin-warehouse-name') || '义乌仓' + const workerId = values.get('worker-id') || 'worker-1' + const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14) + const outputDirectory = resolve(values.get('output-dir') || `reports/it-test-wait-split-8000-${timestamp}`) + const journalPath = resolve(outputDirectory, 'results.jsonl') + const summaryPath = resolve(outputDirectory, 'summary.json') + const failureDirectory = resolve(outputDirectory, 'failures') + await mkdir(failureDirectory, { recursive: true }) + + const summary = { + workerId, customerName, originWarehouseName, requested: count, startIndex, batchSize, + boxMin, boxMax, attempted: 0, succeeded: 0, failed: 0, totalPackages: 0, totalPallets: 0, + minPackages: null, maxPackages: null, channels: {}, destinationTypes: {}, + processId: process.pid, startedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), outputDirectory, + } + await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + console.log(`OUTPUT_DIR=${outputDirectory}`) + + for (let offset = 0; offset < count; offset += 1) { + const sequence = startIndex + offset + const packageCount = randomInt(boxMin, boxMax + 1) + const randomValue = randomInt(1, 2_147_483_647) + const batchNo = Math.floor(offset / batchSize) + 1 + const planned = { event: 'planned', sequence, batchNo, packageCount, randomValue, + plannedAt: new Date().toISOString() } + await appendFile(journalPath, `${JSON.stringify(planned)}\n`, 'utf8') + + let report = null + let thrown = null + try { + const options = validateExecutionOptions(parseArguments([ + '--modules', 'orders,inbound-measurement', + '--customer-name', customerName, + '--origin-warehouse-name', originWarehouseName, + '--order-profile-mode', 'randomized', + '--route-mode', 'random', + '--wait-split-count', '1', + '--wait-split-package-count', String(packageCount), + '--wait-split-sequence', String(sequence), + '--wait-split-random-value', String(randomValue), + '--quiet', 'true', + ])) + options.markerNamespace = workerId + report = await new AcceptanceRun(options).run() + } catch (error) { + thrown = error instanceof Error ? error.message : String(error) + } + + const fixture = report?.createdResources?.waitSplitFixtures?.find?.( + (item) => Number(item.sequence) === sequence) + const completed = Boolean(fixture?.processStatus === 'WAIT_SPLIT' + && fixture?.reviewStatus === 'APPROVED' + && Number(fixture?.totalPackageCount) === packageCount + && Number(fixture?.palletCount) === Math.ceil(packageCount / 30) + && fixture?.receivingWarehouseName === originWarehouseName) + const result = { + event: 'result', sequence, batchNo, packageCount, completed, + orderId: fixture?.orderId || null, orderNo: fixture?.orderNo || null, + inboundId: fixture?.inboundId || null, inboundCode: fixture?.inboundCode || null, + palletCount: fixture?.palletCount || null, + channelCode: fixture?.channelCode || null, channelName: fixture?.channelName || null, + destinationType: fixture?.destinationType || null, + destinationWarehouseCode: fixture?.destinationWarehouseCode || null, + reportSuccess: report?.success ?? false, assertionFailures: report?.summary?.failed ?? null, + deploymentBlockers: report?.deploymentBlockers || [], error: thrown, + finishedAt: new Date().toISOString(), + } + await appendFile(journalPath, `${JSON.stringify(result)}\n`, 'utf8') + summary.attempted += 1 + if (completed) { + summary.succeeded += 1 + summary.totalPackages += packageCount + summary.totalPallets += Number(fixture.palletCount) + summary.minPackages = summary.minPackages === null ? packageCount : Math.min(summary.minPackages, packageCount) + summary.maxPackages = summary.maxPackages === null ? packageCount : Math.max(summary.maxPackages, packageCount) + increment(summary.channels, fixture.channelCode) + increment(summary.destinationTypes, fixture.destinationType) + } else { + summary.failed += 1 + if (report) { + await writeFile(resolve(failureDirectory, `sequence-${String(sequence).padStart(5, '0')}.json`), + `${JSON.stringify(report, null, 2)}\n`, 'utf8') + } + } + summary.updatedAt = new Date().toISOString() + summary.averagePackages = summary.succeeded > 0 + ? Number((summary.totalPackages / summary.succeeded).toFixed(3)) : null + summary.averagePallets = summary.succeeded > 0 + ? Number((summary.totalPallets / summary.succeeded).toFixed(3)) : null + if (summary.attempted % batchSize === 0 || !completed || summary.attempted === count) { + await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + console.log(`PROGRESS attempted=${summary.attempted}/${count} succeeded=${summary.succeeded} failed=${summary.failed} batch=${batchNo}`) + } + } + + summary.finishedAt = new Date().toISOString() + await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8') + console.log(`RESULT attempted=${summary.attempted} succeeded=${summary.succeeded} failed=${summary.failed}`) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 +})