This commit is contained in:
2026-08-27 09:21:01 +08:00
parent 817e356c21
commit 554b6c93b7
8 changed files with 419 additions and 27 deletions
+71 -16
View File
@@ -250,6 +250,10 @@ function usage() {
--channel-code <code> 固定物流渠道编码
--order-profile-mode <mode> 订单档案模式:fixed 或 randomized,默认fixed
--wait-split-count <n> 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1
--wait-split-package-count <n> 单票指定总箱数,1-1000;省略时使用既有轮换
--wait-split-sequence <n> 单票全局序号,正整数,默认1
--wait-split-random-value <n> 单票随机选择值,正整数,默认使用全局序号
--route-mode <mode> 线路选择:fixed 或 random,默认fixed
--manual-split-fixture-report <path> 人工分柜准备报告路径,仅只读核验模块使用
--manual-split-plan-id <id> 人工操作产生的智能装柜计划 ID,仅只读核验模块使用
--manual-preload-source-report <path> 人工推送预装前置或核验来源报告路径
@@ -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)
@@ -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、不绕过正式状态机。
- 清理测试数据或文件必须另行获得明确授权。
@@ -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/)
})
@@ -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)
@@ -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 <n>/)
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 <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/)
})
+41 -1
View File
@@ -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)
@@ -2,6 +2,8 @@
本文说明如何复用 `scripts/admin-test-env-acceptance.mjs`,通过正式 HTTP 业务接口批量生成停在“测量完成待分柜”的头程订单。生成链路包含客户登录、下单、平台审核、入库、称重、测量、测量审核,不直接写业务数据库,也不模拟成功。
固定“IT测试”客户和“义乌仓”、其余业务字段随机、每票独立随机 `1–300` 箱并尽力生成 8,000 票的专项入口,见 `IT测试客户八千票待分柜数据生成方案.md` 和 `../it-test-wait-split-bulk.mjs`。
## 适用范围
- 用于开发、联调或经明确授权的验收环境准备人工操作数据。
+146
View File
@@ -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
})