This commit is contained in:
2026-08-17 15:41:42 +08:00
parent 4abcb2b80f
commit 5f79616623
60 changed files with 1457 additions and 270 deletions
+111 -35
View File
@@ -1,18 +1,31 @@
#!/usr/bin/env node
import { randomUUID } from 'node:crypto'
import { randomInt, randomUUID } from 'node:crypto'
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const MAX_COUNT = 100
const MAX_ROUTE_POOL_SIZE = 40
const MAX_DESTINATIONS_PER_LOGISTICS = 3
export const PRODUCT_CATALOG = [
{ cargoName: '蓝牙音箱', cargoNameEn: 'Bluetooth speaker', hsCode: '8518210000', material: '塑料和电子元件 / Plastic and electronic components', usage: '音频播放 / Audio playback', brand: 'SOUNDTEST', modelPrefix: 'SPK', lengthCm: 42, widthCm: 32, heightCm: 28, unitsPerPackage: 8, cartonGrossWeightKg: 12.8, unitDeclaredValue: 18.5, productLengthCm: 18, productWidthCm: 10, productHeightCm: 8 },
{ cargoName: '手机保护壳', cargoNameEn: 'Mobile phone case', hsCode: '3926909090', material: '热塑性聚氨酯 / TPU', usage: '手机保护 / Mobile phone protection', brand: 'CASETEST', modelPrefix: 'CASE', lengthCm: 48, widthCm: 38, heightCm: 32, unitsPerPackage: 100, cartonGrossWeightKg: 16.5, unitDeclaredValue: 2.2, productLengthCm: 17, productWidthCm: 8, productHeightCm: 1.5 },
{ cargoName: '不锈钢保温杯', cargoNameEn: 'Stainless steel vacuum bottle', hsCode: '9617009000', material: '不锈钢 / Stainless steel', usage: '饮品保温 / Beverage insulation', brand: 'CUPTEST', modelPrefix: 'CUP', lengthCm: 50, widthCm: 40, heightCm: 32, unitsPerPackage: 24, cartonGrossWeightKg: 15.2, unitDeclaredValue: 9.8, productLengthCm: 8, productWidthCm: 8, productHeightCm: 25 },
{ cargoName: '棉质短袖T恤', cargoNameEn: 'Cotton short sleeve T-shirt', hsCode: '6109100021', material: '棉 / Cotton', usage: '日常穿着 / Daily wear', brand: 'SHIRTTEST', modelPrefix: 'TS', lengthCm: 55, widthCm: 42, heightCm: 35, unitsPerPackage: 60, cartonGrossWeightKg: 18.6, unitDeclaredValue: 6.5, productLengthCm: 30, productWidthCm: 25, productHeightCm: 2 },
{ cargoName: '瑜伽垫', cargoNameEn: 'Yoga mat', hsCode: '9506919000', material: '热塑性弹性体 / TPE', usage: '健身运动 / Fitness exercise', brand: 'YOGATEST', modelPrefix: 'YOGA', lengthCm: 65, widthCm: 45, heightCm: 45, unitsPerPackage: 10, cartonGrossWeightKg: 13.5, unitDeclaredValue: 12, productLengthCm: 61, productWidthCm: 18, productHeightCm: 18 },
{ cargoName: '塑料收纳盒', cargoNameEn: 'Plastic storage box', hsCode: '3924900000', material: '聚丙烯 / Polypropylene', usage: '家居收纳 / Household storage', brand: 'BOXTEST', modelPrefix: 'BOX', lengthCm: 62, widthCm: 48, heightCm: 40, unitsPerPackage: 12, cartonGrossWeightKg: 17.8, unitDeclaredValue: 7.2, productLengthCm: 38, productWidthCm: 28, productHeightCm: 20 },
{ cargoName: 'LED台灯', cargoNameEn: 'LED desk lamp', hsCode: '9405210090', material: '铝和塑料 / Aluminum and plastic', usage: '桌面照明 / Desk lighting', brand: 'LAMPTEST', modelPrefix: 'LAMP', lengthCm: 58, widthCm: 42, heightCm: 36, unitsPerPackage: 12, cartonGrossWeightKg: 14.4, unitDeclaredValue: 15.6, productLengthCm: 35, productWidthCm: 15, productHeightCm: 40 },
{ cargoName: '宠物橡胶玩具', cargoNameEn: 'Pet rubber toy', hsCode: '4016999090', material: '天然橡胶 / Natural rubber', usage: '宠物玩耍 / Pet play', brand: 'PETTEST', modelPrefix: 'PET', lengthCm: 46, widthCm: 36, heightCm: 34, unitsPerPackage: 48, cartonGrossWeightKg: 15.6, unitDeclaredValue: 3.4, productLengthCm: 12, productWidthCm: 7, productHeightCm: 7 },
]
// Local test-environment settings. Keep PASSWORD empty in committed code.
const CONFIG = {
GATEWAY: 'http://8.163.30.128:9000',
USERNAME: '17512095155',
PASSWORD: 'd6KW2ZC4vv44WJ4a',
COUNT: 10,
COUNT: 50,
}
export function parseArguments(values) {
@@ -63,6 +76,40 @@ function items(value) {
return Array.isArray(value) ? value : []
}
function shuffled(values) {
const result = [...values]
for (let index = result.length - 1; index > 0; index -= 1) {
const target = randomInt(index + 1)
;[result[index], result[target]] = [result[target], result[index]]
}
return result
}
function round(value, scale) {
return Number(value.toFixed(scale))
}
function logisticsKey(route) {
return `${route.receiving.id}|${route.channel.channelCode}|${route.pricingTaxMode}`
}
export function diversifyRoutes(routes) {
const grouped = new Map()
for (const route of routes) {
const key = logisticsKey(route)
if (!grouped.has(key)) grouped.set(key, [])
grouped.get(key).push(route)
}
const queues = shuffled([...grouped.values()]).map((group) => shuffled(group))
const result = []
while (queues.some((queue) => queue.length > 0)) {
for (const queue of queues) {
if (queue.length > 0) result.push(queue.shift())
}
}
return result
}
function requireSuccess(response, action) {
if (response.http !== 200 || String(response.body?.code) !== '200') {
throw new Error(`${action} failed: http=${response.http}, code=${response.body?.code ?? 'missing'}, message=${response.body?.message ?? 'missing'}, requestId=${response.requestId || 'missing'}, traceId=${response.traceId || 'missing'}`)
@@ -101,7 +148,7 @@ async function request(gateway, timeoutMs, method, path, { token, body, headers
}
}
async function loadMasterData(gateway, timeoutMs, token) {
async function loadPricedRoutes(gateway, timeoutMs, token) {
const domesticResponse = await request(gateway, timeoutMs, 'GET',
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', { token })
const domestic = records(requireSuccess(domesticResponse, 'Query active domestic warehouses'))
@@ -112,14 +159,16 @@ async function loadMasterData(gateway, timeoutMs, token) {
throw new Error('No active domestic receiving warehouse or FBA destination warehouse is available')
}
for (const receiving of domestic) {
const routes = []
receivingLoop: for (const receiving of shuffled(domestic)) {
const channelResponse = await request(gateway, timeoutMs, 'GET',
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`, { token })
const channels = items(requireSuccess(channelResponse, 'Query active pricing channels'))
.filter((channel) => channel?.channelCode && items(channel.pricingTaxModes).length > 0)
for (const channel of channels) {
for (const pricingTaxMode of channel.pricingTaxModes) {
for (const destination of destinations) {
for (const channel of shuffled(channels)) {
for (const pricingTaxMode of shuffled(channel.pricingTaxModes)) {
let logisticsDestinationCount = 0
for (const destination of shuffled(destinations)) {
const estimateResponse = await request(gateway, timeoutMs, 'POST',
'/api/merchant/v1/first-leg/pricing/estimate', {
token,
@@ -136,13 +185,17 @@ async function loadMasterData(gateway, timeoutMs, token) {
},
})
if (estimateResponse.http === 200 && String(estimateResponse.body?.code) === '200') {
return { receiving, destination, channel, pricingTaxMode }
routes.push({ receiving, destination, channel, pricingTaxMode })
logisticsDestinationCount += 1
if (routes.length >= MAX_ROUTE_POOL_SIZE) break receivingLoop
if (logisticsDestinationCount >= MAX_DESTINATIONS_PER_LOGISTICS) break
}
}
}
}
}
throw new Error('No priced route is available for the fixed merchant account')
if (routes.length === 0) throw new Error('No priced route is available for the fixed merchant account')
return routes
}
function testPng() {
@@ -161,11 +214,16 @@ async function uploadProductImage(gateway, timeoutMs, token, marker) {
return data.fileId
}
export function buildOrderPayload({ marker, index, profile, master, productImageFileId }) {
export function buildOrderPayload({ marker, index, profile, master, product, packageCount, productImageFileId }) {
const sequence = String(index).padStart(3, '0')
const shipmentId = `SHIP-${sequence}-${marker}`.slice(0, 64)
const referenceId = `REF-${sequence}-${marker}`.slice(0, 64)
const companyName = profile.tenantName || `固定客户-${marker}`
const totalQuantity = product.unitsPerPackage * packageCount
const grossWeightKg = round(product.cartonGrossWeightKg * packageCount, 3)
const netWeightKg = round(grossWeightKg * 0.9, 3)
const volumeCbm = round(product.lengthCm * product.widthCm * product.heightCm / 1_000_000 * packageCount, 6)
const declaredValue = round(product.unitDeclaredValue * totalQuantity, 2)
return {
customerId: profile.id,
customerName: companyName,
@@ -206,30 +264,30 @@ export function buildOrderPayload({ marker, index, profile, master, productImage
receiver: null,
cargoes: [{
skuNo: `SKU-${sequence}-${marker}`.slice(0, 64),
cargoName: '待审核测试产品',
cargoNameEn: 'Pending review test product',
hsCode: '8518210000',
customsHsCode: '8518210000',
cargoName: product.cargoName,
cargoNameEn: product.cargoNameEn,
hsCode: product.hsCode,
customsHsCode: product.hsCode,
packageType: 'CARTON',
lengthCm: 30,
widthCm: 20,
heightCm: 20,
unitsPerPackage: 12,
packageCount: 2,
grossWeightKg: 20.5,
volumeCbm: 0.024,
totalQuantity: 24,
netWeightKg: 18,
unitDeclaredValue: 8.5,
declaredValue: 204,
lengthCm: product.lengthCm,
widthCm: product.widthCm,
heightCm: product.heightCm,
unitsPerPackage: product.unitsPerPackage,
packageCount,
grossWeightKg,
volumeCbm,
totalQuantity,
netWeightKg,
unitDeclaredValue: product.unitDeclaredValue,
declaredValue,
currency: 'USD',
material: '塑料 / Plastic',
usage: '接口测试 / API testing',
model: `MODEL-${sequence}`,
brand: 'TEST-NO-LOGO',
productLengthCm: 12,
productWidthCm: 8,
productHeightCm: 6,
material: product.material,
usage: product.usage,
model: `${product.modelPrefix}-${sequence}`,
brand: product.brand,
productLengthCm: product.productLengthCm,
productWidthCm: product.productWidthCm,
productHeightCm: product.productHeightCm,
shipmentId,
referenceId,
upcNo: `${Date.now()}${sequence}`.slice(-12),
@@ -289,11 +347,16 @@ export async function main(argv = process.argv.slice(2)) {
throw new Error(`Fixed merchant account is not ready: tenantId=${profile?.id ?? 'missing'}, status=${profile?.status ?? 'missing'}`)
}
const master = await loadMasterData(gateway, timeoutMs, token)
const routePool = await loadPricedRoutes(gateway, timeoutMs, token)
const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker)
const randomizedProducts = shuffled(PRODUCT_CATALOG)
const randomizedRoutes = diversifyRoutes(routePool)
for (let index = 1; index <= count; index += 1) {
const payload = buildOrderPayload({ marker, index, profile, master, productImageFileId })
const master = randomizedRoutes[(index - 1) % randomizedRoutes.length]
const product = randomizedProducts[(index - 1) % randomizedProducts.length]
const packageCount = randomInt(1, 31)
const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, productImageFileId })
const createResponse = await request(gateway, timeoutMs, 'POST',
'/api/merchant/v1/orders/first-leg', {
token,
@@ -330,9 +393,19 @@ export async function main(argv = process.argv.slice(2)) {
orderNo,
clientStatus: detail.order.status,
mainStatus: unifiedOrder.summary.mainStatus,
destinationWarehouseId: String(master.destination.id),
destinationWarehouseCode: master.destination.warehouseCode,
destinationWarehouseName: master.destination.warehouseName,
receivingWarehouseId: String(master.receiving.id),
receivingWarehouseName: master.receiving.warehouseName,
channelCode: master.channel.channelCode,
channelName: master.channel.channelName,
pricingTaxMode: master.pricingTaxMode,
cargoName: product.cargoName,
packageCount,
}
orders.push(result)
console.log(`CREATED ${index}/${count} orderId=${result.orderId} orderNo=${result.orderNo} status=PENDING_REVIEW`)
console.log(`CREATED ${index}/${count} orderId=${result.orderId} orderNo=${result.orderNo} logistics=${result.channelCode}/${result.pricingTaxMode} destination=${result.destinationWarehouseCode} product=${result.cargoName} cartons=${result.packageCount} status=PENDING_REVIEW`)
}
const report = {
@@ -346,6 +419,9 @@ export async function main(argv = process.argv.slice(2)) {
startedAt,
completedAt: new Date().toISOString(),
productImageFileId: String(productImageFileId),
pricedRoutePoolSize: routePool.length,
logisticsPoolSize: new Set(routePool.map(logisticsKey)).size,
productCatalogSize: PRODUCT_CATALOG.length,
orders,
securityNote: '报告不保存密码或访问令牌。',
}