优化
This commit is contained in:
@@ -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/)
|
||||
})
|
||||
@@ -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`。
|
||||
|
||||
## 适用范围
|
||||
|
||||
- 用于开发、联调或经明确授权的验收环境准备人工操作数据。
|
||||
|
||||
Reference in New Issue
Block a user