2026-08-17 10:54:08 +08:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
2026-08-17 15:41:42 +08:00
|
|
|
import { randomInt, randomUUID } from 'node:crypto'
|
2026-08-17 10:54:08 +08:00
|
|
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
|
|
|
import { dirname, resolve } from 'node:path'
|
|
|
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
|
|
|
|
|
|
const MAX_COUNT = 100
|
2026-08-17 15:41:42 +08:00
|
|
|
const MAX_ROUTE_POOL_SIZE = 40
|
|
|
|
|
const MAX_DESTINATIONS_PER_LOGISTICS = 3
|
2026-08-17 16:45:43 +08:00
|
|
|
const TRADE_TERMS = ['FOB', 'EXW', 'CIF', 'DDP']
|
|
|
|
|
const PICKUP_METHODS = ['SELF_EXPRESS', 'PICKUP', 'SELF_TRUCK']
|
|
|
|
|
const TAX_MODES = ['BUY_DECLARATION', 'TAX_REFUND']
|
|
|
|
|
const SENDER_LOCATIONS = [
|
|
|
|
|
{ province: '广东省', city: '深圳市', district: '南山区', postalCode: '518000' },
|
|
|
|
|
{ province: '广东省', city: '东莞市', district: '南城街道', postalCode: '523000' },
|
|
|
|
|
{ province: '浙江省', city: '义乌市', district: '稠城街道', postalCode: '322000' },
|
|
|
|
|
{ province: '福建省', city: '厦门市', district: '湖里区', postalCode: '361000' },
|
|
|
|
|
]
|
2026-08-17 15:41:42 +08:00
|
|
|
|
|
|
|
|
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 },
|
|
|
|
|
]
|
2026-08-17 10:54:08 +08:00
|
|
|
|
2026-09-01 11:38:42 +08:00
|
|
|
const DEFAULT_COUNT = 50
|
2026-08-17 10:54:08 +08:00
|
|
|
|
2026-10-02 23:14:24 +08:00
|
|
|
export function pendingSubmitCommand(detail, orderId, idempotencyKey) {
|
|
|
|
|
if (String(detail?.order?.id) !== String(orderId) || !Number.isSafeInteger(detail?.order?.version)
|
|
|
|
|
|| detail.order.version < 0 || !idempotencyKey) throw new Error('Missing original order identity/version/key for submit')
|
|
|
|
|
return Object.freeze({ body: Object.freeze({ expectedVersion: detail.order.version }),
|
|
|
|
|
headers: Object.freeze({ 'Idempotency-Key': idempotencyKey }) })
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 10:54:08 +08:00
|
|
|
export function parseArguments(values) {
|
|
|
|
|
const options = {}
|
2026-08-17 17:49:03 +08:00
|
|
|
const booleanOptions = new Set(['help', 'same-channel'])
|
2026-08-17 10:54:08 +08:00
|
|
|
for (let index = 0; index < values.length; index += 1) {
|
|
|
|
|
const value = values[index]
|
|
|
|
|
if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`)
|
|
|
|
|
const [rawKey, inlineValue] = value.slice(2).split('=', 2)
|
2026-08-17 17:49:03 +08:00
|
|
|
if (booleanOptions.has(rawKey)) {
|
|
|
|
|
if (inlineValue !== undefined) throw new Error(`--${rawKey} does not accept a value`)
|
|
|
|
|
options[rawKey] = true
|
2026-08-17 10:54:08 +08:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
const next = inlineValue ?? values[index + 1]
|
|
|
|
|
if (inlineValue === undefined) index += 1
|
|
|
|
|
if (next == null || next.startsWith('--')) throw new Error(`--${rawKey} requires a value`)
|
|
|
|
|
options[rawKey] = next
|
|
|
|
|
}
|
|
|
|
|
return options
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function validateCount(value) {
|
2026-09-01 11:38:42 +08:00
|
|
|
const count = Number(value ?? DEFAULT_COUNT)
|
2026-08-17 10:54:08 +08:00
|
|
|
if (!Number.isInteger(count) || count < 1 || count > MAX_COUNT) {
|
|
|
|
|
throw new Error(`--count must be an integer between 1 and ${MAX_COUNT}`)
|
|
|
|
|
}
|
|
|
|
|
return count
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trimSlash(value) {
|
|
|
|
|
return String(value || '').replace(/\/+$/, '')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compactTimestamp(value = new Date()) {
|
|
|
|
|
return value.toISOString().replace(/\D/g, '').slice(0, 14)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addDays(value, days) {
|
|
|
|
|
const date = new Date(value)
|
|
|
|
|
date.setUTCDate(date.getUTCDate() + days)
|
|
|
|
|
return date.toISOString().slice(0, 10)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function records(value) {
|
|
|
|
|
return Array.isArray(value?.records) ? value.records : []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function items(value) {
|
|
|
|
|
return Array.isArray(value) ? value : []
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 15:41:42 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 16:45:43 +08:00
|
|
|
function pickRandom(values) {
|
|
|
|
|
return values[randomInt(values.length)]
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 15:41:42 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 17:49:03 +08:00
|
|
|
export function selectSameChannelRoutes(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 candidates = [...grouped.values()]
|
|
|
|
|
if (candidates.length === 0) throw new Error('No priced route is available for same-channel orders')
|
|
|
|
|
const maxDestinationCount = Math.max(...candidates.map((group) => group.length))
|
|
|
|
|
return shuffled(pickRandom(candidates.filter((group) => group.length === maxDestinationCount)))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 16:45:43 +08:00
|
|
|
export function randomOrderOptions() {
|
|
|
|
|
return {
|
|
|
|
|
tradeTerm: pickRandom(TRADE_TERMS),
|
|
|
|
|
pickupMethod: pickRandom(PICKUP_METHODS),
|
|
|
|
|
taxMode: pickRandom(TAX_MODES),
|
|
|
|
|
customsInspection: randomInt(2) === 1,
|
|
|
|
|
expectedShipDays: randomInt(7, 61),
|
|
|
|
|
senderLocation: pickRandom(SENDER_LOCATIONS),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 10:54:08 +08:00
|
|
|
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'}`)
|
|
|
|
|
}
|
|
|
|
|
return response.body?.data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function request(gateway, timeoutMs, method, path, { token, body, headers = {} } = {}) {
|
|
|
|
|
const requestHeaders = { Accept: 'application/json', ...headers }
|
|
|
|
|
if (token) requestHeaders.Authorization = `Bearer ${token}`
|
|
|
|
|
let requestBody
|
|
|
|
|
if (body instanceof FormData) {
|
|
|
|
|
requestBody = body
|
|
|
|
|
} else if (body !== undefined) {
|
|
|
|
|
requestHeaders['Content-Type'] = 'application/json'
|
|
|
|
|
requestBody = JSON.stringify(body)
|
|
|
|
|
}
|
|
|
|
|
const response = await fetch(`${gateway}${path}`, {
|
|
|
|
|
method,
|
|
|
|
|
headers: requestHeaders,
|
|
|
|
|
body: requestBody,
|
|
|
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
|
|
|
})
|
|
|
|
|
const text = await response.text()
|
|
|
|
|
let responseBody = null
|
|
|
|
|
try {
|
|
|
|
|
responseBody = text ? JSON.parse(text) : null
|
|
|
|
|
} catch {
|
|
|
|
|
responseBody = { code: 'INVALID_JSON', message: text.slice(0, 500) }
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
http: response.status,
|
|
|
|
|
body: responseBody,
|
|
|
|
|
requestId: response.headers.get('x-request-id') || responseBody?.requestId,
|
|
|
|
|
traceId: response.headers.get('x-trace-id') || responseBody?.traceId,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 15:41:42 +08:00
|
|
|
async function loadPricedRoutes(gateway, timeoutMs, token) {
|
2026-08-17 10:54:08 +08:00
|
|
|
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'))
|
|
|
|
|
const destinationResponse = await request(gateway, timeoutMs, 'GET',
|
|
|
|
|
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', { token })
|
|
|
|
|
const destinations = records(requireSuccess(destinationResponse, 'Query active FBA warehouses'))
|
|
|
|
|
if (domestic.length === 0 || destinations.length === 0) {
|
|
|
|
|
throw new Error('No active domestic receiving warehouse or FBA destination warehouse is available')
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 15:41:42 +08:00
|
|
|
const routes = []
|
|
|
|
|
receivingLoop: for (const receiving of shuffled(domestic)) {
|
2026-08-17 10:54:08 +08:00
|
|
|
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)
|
2026-08-17 15:41:42 +08:00
|
|
|
for (const channel of shuffled(channels)) {
|
|
|
|
|
for (const pricingTaxMode of shuffled(channel.pricingTaxModes)) {
|
|
|
|
|
let logisticsDestinationCount = 0
|
|
|
|
|
for (const destination of shuffled(destinations)) {
|
2026-08-17 10:54:08 +08:00
|
|
|
const estimateResponse = await request(gateway, timeoutMs, 'POST',
|
|
|
|
|
'/api/merchant/v1/first-leg/pricing/estimate', {
|
|
|
|
|
token,
|
|
|
|
|
body: {
|
|
|
|
|
originWarehouseId: receiving.id,
|
|
|
|
|
channelCode: channel.channelCode,
|
|
|
|
|
destinationCode: destination.warehouseCode,
|
|
|
|
|
destinationType: 'FBA',
|
|
|
|
|
destinationWarehouseId: destination.id,
|
|
|
|
|
destinationName: destination.warehouseName,
|
|
|
|
|
pricingTaxMode,
|
|
|
|
|
actualWeightKg: 20.5,
|
|
|
|
|
actualVolumeCbm: 0.024,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if (estimateResponse.http === 200 && String(estimateResponse.body?.code) === '200') {
|
2026-08-17 15:41:42 +08:00
|
|
|
routes.push({ receiving, destination, channel, pricingTaxMode })
|
|
|
|
|
logisticsDestinationCount += 1
|
|
|
|
|
if (routes.length >= MAX_ROUTE_POOL_SIZE) break receivingLoop
|
|
|
|
|
if (logisticsDestinationCount >= MAX_DESTINATIONS_PER_LOGISTICS) break
|
2026-08-17 10:54:08 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 15:41:42 +08:00
|
|
|
if (routes.length === 0) throw new Error('No priced route is available for the fixed merchant account')
|
|
|
|
|
return routes
|
2026-08-17 10:54:08 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function testPng() {
|
|
|
|
|
return Uint8Array.from(Buffer.from(
|
|
|
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
|
|
|
|
'base64'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function uploadProductImage(gateway, timeoutMs, token, marker) {
|
|
|
|
|
const form = new FormData()
|
|
|
|
|
form.append('file', new Blob([testPng()], { type: 'image/png' }), `pending-order-${marker}.png`)
|
|
|
|
|
const response = await request(gateway, timeoutMs, 'POST',
|
|
|
|
|
'/api/merchant/v1/files/product-images', { token, body: form })
|
|
|
|
|
const data = requireSuccess(response, 'Upload product image')
|
|
|
|
|
if (!data?.fileId) throw new Error('Product image upload did not return fileId')
|
|
|
|
|
return data.fileId
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 09:52:46 +08:00
|
|
|
async function ensurePickupAddress(gateway, timeoutMs, token, profile, marker) {
|
|
|
|
|
const listResponse = await request(gateway, timeoutMs, 'GET',
|
|
|
|
|
'/api/merchant/v1/orders/first-leg/addresses?addressType=PICKUP', { token })
|
|
|
|
|
const existing = items(requireSuccess(listResponse, 'Query pickup addresses'))
|
|
|
|
|
.find((address) => address?.id)
|
|
|
|
|
if (existing) return String(existing.id)
|
|
|
|
|
|
|
|
|
|
const location = pickRandom(SENDER_LOCATIONS)
|
|
|
|
|
const createResponse = await request(gateway, timeoutMs, 'POST',
|
|
|
|
|
'/api/merchant/v1/orders/first-leg/addresses', {
|
|
|
|
|
token,
|
|
|
|
|
body: {
|
|
|
|
|
addressType: 'PICKUP',
|
|
|
|
|
addressName: '待审核订单测试取货地址',
|
|
|
|
|
companyName: profile.tenantName || `固定客户-${marker}`,
|
|
|
|
|
contactName: '订单测试联系人',
|
|
|
|
|
phone: '13800138000',
|
|
|
|
|
email: 'pending-order@example.com',
|
|
|
|
|
countryCode: 'CN',
|
|
|
|
|
province: location.province,
|
|
|
|
|
city: location.city,
|
|
|
|
|
district: location.district,
|
|
|
|
|
addressLine: `${marker} 固定客户测试地址`,
|
|
|
|
|
postalCode: location.postalCode,
|
|
|
|
|
defaultAddress: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
const addressId = String(requireSuccess(createResponse, 'Create pickup address') ?? '')
|
|
|
|
|
if (!addressId) throw new Error('Create pickup address did not return address ID')
|
|
|
|
|
return addressId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildOrderPayload({ marker, index, profile, master, product, packageCount, options = randomOrderOptions(), productImageFileId, pickupAddressId }) {
|
2026-08-17 10:54:08 +08:00
|
|
|
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}`
|
2026-08-17 15:41:42 +08:00
|
|
|
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)
|
2026-08-17 10:54:08 +08:00
|
|
|
return {
|
|
|
|
|
customerId: profile.id,
|
|
|
|
|
customerName: companyName,
|
|
|
|
|
transportMode: 'SEA',
|
2026-08-17 16:45:43 +08:00
|
|
|
tradeTerm: options.tradeTerm,
|
2026-08-17 10:54:08 +08:00
|
|
|
destinationCountryCode: master.destination.countryCode || 'US',
|
|
|
|
|
destinationWarehouseId: master.destination.id,
|
|
|
|
|
destinationWarehouseName: master.destination.warehouseName,
|
2026-08-17 16:45:43 +08:00
|
|
|
expectedShipDate: addDays(new Date(), options.expectedShipDays),
|
|
|
|
|
customerRemark: `基础待审核订单 ${sequence} ${marker} ${options.tradeTerm}`,
|
|
|
|
|
pickupMethod: options.pickupMethod,
|
2026-08-17 10:54:08 +08:00
|
|
|
receivingWarehouseId: master.receiving.id,
|
|
|
|
|
receivingWarehouseName: master.receiving.warehouseName,
|
|
|
|
|
channelCode: master.channel.channelCode,
|
|
|
|
|
channelName: master.channel.channelName,
|
|
|
|
|
carrierId: null,
|
|
|
|
|
carrierName: null,
|
2026-08-17 16:45:43 +08:00
|
|
|
taxMode: options.taxMode,
|
2026-08-17 10:54:08 +08:00
|
|
|
pricingTaxMode: master.pricingTaxMode,
|
|
|
|
|
containerMode: 'LCL',
|
2026-08-17 16:45:43 +08:00
|
|
|
customsInspection: options.customsInspection,
|
2026-08-17 10:54:08 +08:00
|
|
|
destinationType: 'FBA',
|
|
|
|
|
amazonWarehouseCode: master.destination.warehouseCode,
|
|
|
|
|
shipmentId,
|
|
|
|
|
referenceId,
|
2026-09-09 09:52:46 +08:00
|
|
|
requestedPickupAt: options.pickupMethod === 'PICKUP'
|
|
|
|
|
? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null,
|
|
|
|
|
senderAddressId: options.pickupMethod === 'PICKUP' ? pickupAddressId : null,
|
|
|
|
|
sender: null,
|
2026-08-17 10:54:08 +08:00
|
|
|
receiver: null,
|
|
|
|
|
cargoes: [{
|
|
|
|
|
skuNo: `SKU-${sequence}-${marker}`.slice(0, 64),
|
2026-08-17 15:41:42 +08:00
|
|
|
cargoName: product.cargoName,
|
|
|
|
|
cargoNameEn: product.cargoNameEn,
|
|
|
|
|
hsCode: product.hsCode,
|
|
|
|
|
customsHsCode: product.hsCode,
|
2026-08-17 10:54:08 +08:00
|
|
|
packageType: 'CARTON',
|
2026-08-17 15:41:42 +08:00
|
|
|
lengthCm: product.lengthCm,
|
|
|
|
|
widthCm: product.widthCm,
|
|
|
|
|
heightCm: product.heightCm,
|
|
|
|
|
unitsPerPackage: product.unitsPerPackage,
|
|
|
|
|
packageCount,
|
|
|
|
|
grossWeightKg,
|
|
|
|
|
volumeCbm,
|
|
|
|
|
totalQuantity,
|
|
|
|
|
netWeightKg,
|
|
|
|
|
unitDeclaredValue: product.unitDeclaredValue,
|
|
|
|
|
declaredValue,
|
2026-08-17 10:54:08 +08:00
|
|
|
currency: 'USD',
|
2026-08-17 15:41:42 +08:00
|
|
|
material: product.material,
|
|
|
|
|
usage: product.usage,
|
|
|
|
|
model: `${product.modelPrefix}-${sequence}`,
|
|
|
|
|
brand: product.brand,
|
|
|
|
|
productLengthCm: product.productLengthCm,
|
|
|
|
|
productWidthCm: product.productWidthCm,
|
|
|
|
|
productHeightCm: product.productHeightCm,
|
2026-08-17 10:54:08 +08:00
|
|
|
shipmentId,
|
|
|
|
|
referenceId,
|
|
|
|
|
upcNo: `${Date.now()}${sequence}`.slice(-12),
|
|
|
|
|
productImageFileId,
|
2026-09-09 09:52:46 +08:00
|
|
|
woodenProduct: false,
|
2026-08-17 10:54:08 +08:00
|
|
|
dangerousGoods: false,
|
|
|
|
|
cargoRemark: `待审核订单货物 ${sequence} ${marker}`,
|
|
|
|
|
}],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function printHelp() {
|
|
|
|
|
console.log(`Usage:
|
2026-08-17 17:49:03 +08:00
|
|
|
node scripts/create-pending-review-orders.mjs --gateway <url> --count <1-${MAX_COUNT}> [--same-channel]
|
2026-08-17 10:54:08 +08:00
|
|
|
|
|
|
|
|
Environment:
|
2026-09-01 11:38:42 +08:00
|
|
|
ADMIN_TEST_GATEWAY Test environment Gateway URL
|
2026-08-17 10:54:08 +08:00
|
|
|
MERCHANT_TEST_PASSWORD Password for the configured fixed account
|
2026-09-01 11:38:42 +08:00
|
|
|
MERCHANT_TEST_USERNAME Username for the configured fixed account
|
|
|
|
|
PENDING_REVIEW_ORDER_COUNT Optional order count, defaults to ${DEFAULT_COUNT}
|
2026-08-17 10:54:08 +08:00
|
|
|
PENDING_ORDER_REPORT Optional JSON report path
|
|
|
|
|
|
2026-09-01 11:38:42 +08:00
|
|
|
Command-line arguments override environment variables. Credentials must not be written into this file.`)
|
2026-08-17 10:54:08 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function main(argv = process.argv.slice(2)) {
|
|
|
|
|
const args = parseArguments(argv)
|
|
|
|
|
if (args.help) {
|
|
|
|
|
printHelp()
|
|
|
|
|
return 0
|
|
|
|
|
}
|
2026-09-01 11:38:42 +08:00
|
|
|
const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY)
|
|
|
|
|
if (!gateway) throw new Error('Gateway is required via --gateway or ADMIN_TEST_GATEWAY')
|
|
|
|
|
const count = validateCount(args.count ?? process.env.PENDING_REVIEW_ORDER_COUNT)
|
|
|
|
|
const username = args.username ?? process.env.MERCHANT_TEST_USERNAME
|
|
|
|
|
const password = args.password ?? process.env.MERCHANT_TEST_PASSWORD
|
|
|
|
|
if (!username) throw new Error('Merchant username is required via --username or MERCHANT_TEST_USERNAME')
|
|
|
|
|
if (!password) throw new Error('Merchant password is required via --password or MERCHANT_TEST_PASSWORD')
|
2026-08-17 10:54:08 +08:00
|
|
|
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
|
|
|
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) throw new Error('--timeout-ms must be at least 1000')
|
|
|
|
|
|
|
|
|
|
const marker = `PENDING-${compactTimestamp()}-${randomUUID().slice(0, 8)}`
|
|
|
|
|
const outputPath = resolve(args.output ?? process.env.PENDING_ORDER_REPORT
|
|
|
|
|
?? `reports/pending-review-orders-${marker}.json`)
|
|
|
|
|
const startedAt = new Date().toISOString()
|
|
|
|
|
const orders = []
|
|
|
|
|
|
|
|
|
|
const loginResponse = await request(gateway, timeoutMs, 'POST', '/api/auth/login', {
|
2026-09-01 21:55:27 +08:00
|
|
|
body: { loginId: username, password },
|
2026-08-17 10:54:08 +08:00
|
|
|
})
|
|
|
|
|
const login = requireSuccess(loginResponse, 'Merchant login')
|
|
|
|
|
if (!login?.accessToken) throw new Error('Merchant login did not return accessToken')
|
|
|
|
|
const token = login.accessToken
|
|
|
|
|
|
|
|
|
|
const profileResponse = await request(gateway, timeoutMs, 'GET',
|
|
|
|
|
'/api/merchant/v1/iam/tenant/profile', { token })
|
|
|
|
|
const profile = requireSuccess(profileResponse, 'Query merchant profile')
|
|
|
|
|
if (!profile?.id || profile.status !== 'ACTIVE') {
|
|
|
|
|
throw new Error(`Fixed merchant account is not ready: tenantId=${profile?.id ?? 'missing'}, status=${profile?.status ?? 'missing'}`)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 15:41:42 +08:00
|
|
|
const routePool = await loadPricedRoutes(gateway, timeoutMs, token)
|
2026-08-17 10:54:08 +08:00
|
|
|
const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker)
|
2026-09-09 09:52:46 +08:00
|
|
|
const pickupAddressId = await ensurePickupAddress(gateway, timeoutMs, token, profile, marker)
|
2026-08-17 15:41:42 +08:00
|
|
|
const randomizedProducts = shuffled(PRODUCT_CATALOG)
|
2026-08-17 17:49:03 +08:00
|
|
|
const sameChannel = args['same-channel'] === true
|
|
|
|
|
const randomizedRoutes = sameChannel ? selectSameChannelRoutes(routePool) : diversifyRoutes(routePool)
|
2026-08-17 10:54:08 +08:00
|
|
|
|
|
|
|
|
for (let index = 1; index <= count; index += 1) {
|
2026-08-17 15:41:42 +08:00
|
|
|
const master = randomizedRoutes[(index - 1) % randomizedRoutes.length]
|
|
|
|
|
const product = randomizedProducts[(index - 1) % randomizedProducts.length]
|
|
|
|
|
const packageCount = randomInt(1, 31)
|
2026-08-17 16:45:43 +08:00
|
|
|
const options = randomOrderOptions()
|
2026-09-09 09:52:46 +08:00
|
|
|
const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, options, productImageFileId, pickupAddressId })
|
2026-08-17 10:54:08 +08:00
|
|
|
const createResponse = await request(gateway, timeoutMs, 'POST',
|
|
|
|
|
'/api/merchant/v1/orders/first-leg', {
|
|
|
|
|
token,
|
|
|
|
|
body: payload,
|
|
|
|
|
headers: { 'Idempotency-Key': `${marker}-create-${index}`.slice(0, 128) },
|
|
|
|
|
})
|
|
|
|
|
const orderId = String(requireSuccess(createResponse, `Create order ${index}`) ?? '')
|
|
|
|
|
if (!orderId) throw new Error(`Create order ${index} did not return orderId`)
|
|
|
|
|
|
2026-10-02 23:14:24 +08:00
|
|
|
const submitDetail = requireSuccess(await request(gateway, timeoutMs, 'GET',
|
|
|
|
|
`/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { token }), `Freeze submit version ${index}`)
|
|
|
|
|
const submitCommand = pendingSubmitCommand(submitDetail, orderId, `${marker}-submit-${index}`.slice(0, 128))
|
2026-08-17 10:54:08 +08:00
|
|
|
const submitResponse = await request(gateway, timeoutMs, 'POST',
|
|
|
|
|
`/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}/submit`, {
|
|
|
|
|
token,
|
2026-10-02 23:14:24 +08:00
|
|
|
...submitCommand,
|
2026-08-17 10:54:08 +08:00
|
|
|
})
|
|
|
|
|
requireSuccess(submitResponse, `Submit order ${index}`)
|
|
|
|
|
|
|
|
|
|
const detailResponse = await request(gateway, timeoutMs, 'GET',
|
|
|
|
|
`/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { token })
|
|
|
|
|
const detail = requireSuccess(detailResponse, `Verify order ${index}`)
|
|
|
|
|
if (detail?.order?.status !== 'SUBMITTED') {
|
|
|
|
|
throw new Error(`Order ${orderId} is not pending review: status=${detail?.order?.status ?? 'missing'}`)
|
|
|
|
|
}
|
|
|
|
|
const orderNo = String(detail.order.orderNo || '')
|
|
|
|
|
if (!orderNo) throw new Error(`Order ${orderId} detail did not return orderNo`)
|
|
|
|
|
const unifiedResponse = await request(gateway, timeoutMs, 'GET',
|
|
|
|
|
`/api/merchant/v1/orders/${encodeURIComponent(orderId)}`, { token })
|
|
|
|
|
const unifiedOrder = requireSuccess(unifiedResponse, `Verify unified order ${index}`)
|
|
|
|
|
if (unifiedOrder?.summary?.mainStatus !== 'PENDING_REVIEW') {
|
|
|
|
|
throw new Error(`Order ${orderId} unified status is not pending review: mainStatus=${unifiedOrder?.summary?.mainStatus ?? 'missing'}`)
|
|
|
|
|
}
|
|
|
|
|
const result = {
|
|
|
|
|
index,
|
2026-10-02 23:14:24 +08:00
|
|
|
submitCommand,
|
2026-08-17 10:54:08 +08:00
|
|
|
orderId,
|
|
|
|
|
orderNo,
|
|
|
|
|
clientStatus: detail.order.status,
|
|
|
|
|
mainStatus: unifiedOrder.summary.mainStatus,
|
2026-08-17 15:41:42 +08:00
|
|
|
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,
|
2026-08-17 16:45:43 +08:00
|
|
|
tradeTerm: options.tradeTerm,
|
|
|
|
|
pickupMethod: options.pickupMethod,
|
|
|
|
|
taxMode: options.taxMode,
|
|
|
|
|
customsInspection: options.customsInspection,
|
|
|
|
|
expectedShipDate: payload.expectedShipDate,
|
2026-08-17 15:41:42 +08:00
|
|
|
cargoName: product.cargoName,
|
|
|
|
|
packageCount,
|
2026-08-17 10:54:08 +08:00
|
|
|
}
|
|
|
|
|
orders.push(result)
|
2026-08-17 16:45:43 +08:00
|
|
|
console.log(`CREATED ${index}/${count} orderId=${result.orderId} orderNo=${result.orderNo} logistics=${result.channelCode}/${result.pricingTaxMode} destination=${result.destinationWarehouseCode} pickup=${result.pickupMethod} tax=${result.taxMode} inspection=${result.customsInspection} product=${result.cargoName} cartons=${result.packageCount} status=PENDING_REVIEW`)
|
2026-08-17 10:54:08 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const report = {
|
|
|
|
|
reportName: '固定客户待审核订单批量生成',
|
|
|
|
|
success: true,
|
|
|
|
|
marker,
|
|
|
|
|
username,
|
|
|
|
|
tenantId: String(profile.id),
|
|
|
|
|
requestedCount: count,
|
|
|
|
|
createdCount: orders.length,
|
2026-08-17 17:49:03 +08:00
|
|
|
sameChannel,
|
2026-08-17 10:54:08 +08:00
|
|
|
startedAt,
|
|
|
|
|
completedAt: new Date().toISOString(),
|
|
|
|
|
productImageFileId: String(productImageFileId),
|
2026-08-17 15:41:42 +08:00
|
|
|
pricedRoutePoolSize: routePool.length,
|
|
|
|
|
logisticsPoolSize: new Set(routePool.map(logisticsKey)).size,
|
|
|
|
|
productCatalogSize: PRODUCT_CATALOG.length,
|
2026-08-17 10:54:08 +08:00
|
|
|
orders,
|
|
|
|
|
securityNote: '报告不保存密码或访问令牌。',
|
|
|
|
|
}
|
|
|
|
|
await mkdir(dirname(outputPath), { recursive: true })
|
|
|
|
|
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
|
|
|
|
console.log(`REPORT=${outputPath}`)
|
|
|
|
|
console.log(`RESULT=PASS created=${orders.length}`)
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
|
|
|
main().then((code) => { process.exitCode = code }).catch((error) => {
|
|
|
|
|
console.error(error instanceof Error ? error.message : String(error))
|
|
|
|
|
process.exitCode = 1
|
|
|
|
|
})
|
|
|
|
|
}
|