#!/usr/bin/env node 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 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' }, ] 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 }, ] const DEFAULT_COUNT = 50 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 }) }) } export function parseArguments(values) { const options = {} const booleanOptions = new Set(['help', 'same-channel']) 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) if (booleanOptions.has(rawKey)) { if (inlineValue !== undefined) throw new Error(`--${rawKey} does not accept a value`) options[rawKey] = true 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) { const count = Number(value ?? DEFAULT_COUNT) 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 : [] } 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 pickRandom(values) { return values[randomInt(values.length)] } 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 } 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))) } 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), } } 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, } } 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')) 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') } 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 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, 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') { routes.push({ receiving, destination, channel, pricingTaxMode }) logisticsDestinationCount += 1 if (routes.length >= MAX_ROUTE_POOL_SIZE) break receivingLoop if (logisticsDestinationCount >= MAX_DESTINATIONS_PER_LOGISTICS) break } } } } } if (routes.length === 0) throw new Error('No priced route is available for the fixed merchant account') return routes } 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 } 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 }) { 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, transportMode: 'SEA', tradeTerm: options.tradeTerm, destinationCountryCode: master.destination.countryCode || 'US', destinationWarehouseId: master.destination.id, destinationWarehouseName: master.destination.warehouseName, expectedShipDate: addDays(new Date(), options.expectedShipDays), customerRemark: `基础待审核订单 ${sequence} ${marker} ${options.tradeTerm}`, pickupMethod: options.pickupMethod, receivingWarehouseId: master.receiving.id, receivingWarehouseName: master.receiving.warehouseName, channelCode: master.channel.channelCode, channelName: master.channel.channelName, carrierId: null, carrierName: null, taxMode: options.taxMode, pricingTaxMode: master.pricingTaxMode, containerMode: 'LCL', customsInspection: options.customsInspection, destinationType: 'FBA', amazonWarehouseCode: master.destination.warehouseCode, shipmentId, referenceId, 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, receiver: null, cargoes: [{ skuNo: `SKU-${sequence}-${marker}`.slice(0, 64), cargoName: product.cargoName, cargoNameEn: product.cargoNameEn, hsCode: product.hsCode, customsHsCode: product.hsCode, packageType: 'CARTON', lengthCm: product.lengthCm, widthCm: product.widthCm, heightCm: product.heightCm, unitsPerPackage: product.unitsPerPackage, packageCount, grossWeightKg, volumeCbm, totalQuantity, netWeightKg, unitDeclaredValue: product.unitDeclaredValue, declaredValue, currency: 'USD', 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), productImageFileId, woodenProduct: false, dangerousGoods: false, cargoRemark: `待审核订单货物 ${sequence} ${marker}`, }], } } function printHelp() { console.log(`Usage: node scripts/create-pending-review-orders.mjs --gateway --count <1-${MAX_COUNT}> [--same-channel] Environment: ADMIN_TEST_GATEWAY Test environment Gateway URL MERCHANT_TEST_PASSWORD Password for the configured fixed account MERCHANT_TEST_USERNAME Username for the configured fixed account PENDING_REVIEW_ORDER_COUNT Optional order count, defaults to ${DEFAULT_COUNT} PENDING_ORDER_REPORT Optional JSON report path Command-line arguments override environment variables. Credentials must not be written into this file.`) } export async function main(argv = process.argv.slice(2)) { const args = parseArguments(argv) if (args.help) { printHelp() return 0 } 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') 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', { body: { loginId: username, password }, }) 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'}`) } const routePool = await loadPricedRoutes(gateway, timeoutMs, token) const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker) const pickupAddressId = await ensurePickupAddress(gateway, timeoutMs, token, profile, marker) const randomizedProducts = shuffled(PRODUCT_CATALOG) const sameChannel = args['same-channel'] === true const randomizedRoutes = sameChannel ? selectSameChannelRoutes(routePool) : diversifyRoutes(routePool) for (let index = 1; index <= count; index += 1) { const master = randomizedRoutes[(index - 1) % randomizedRoutes.length] const product = randomizedProducts[(index - 1) % randomizedProducts.length] const packageCount = randomInt(1, 31) const options = randomOrderOptions() const payload = buildOrderPayload({ marker, index, profile, master, product, packageCount, options, productImageFileId, pickupAddressId }) 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`) 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)) const submitResponse = await request(gateway, timeoutMs, 'POST', `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}/submit`, { token, ...submitCommand, }) 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, submitCommand, orderId, 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, tradeTerm: options.tradeTerm, pickupMethod: options.pickupMethod, taxMode: options.taxMode, customsInspection: options.customsInspection, expectedShipDate: payload.expectedShipDate, cargoName: product.cargoName, packageCount, } orders.push(result) 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`) } const report = { reportName: '固定客户待审核订单批量生成', success: true, marker, username, tenantId: String(profile.id), requestedCount: count, createdCount: orders.length, sameChannel, startedAt, completedAt: new Date().toISOString(), productImageFileId: String(productImageFileId), pricedRoutePoolSize: routePool.length, logisticsPoolSize: new Set(routePool.map(logisticsKey)).size, productCatalogSize: PRODUCT_CATALOG.length, 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 }) }