Files
baiyue-firstleg/scripts/create-pending-review-orders.mjs
T
2026-08-17 10:54:08 +08:00

365 lines
14 KiB
JavaScript

#!/usr/bin/env node
import { 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
// 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,
}
export function parseArguments(values) {
const options = {}
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 (rawKey === 'help') {
options.help = 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 ?? CONFIG.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 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 loadMasterData(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')
}
for (const receiving of 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) {
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') {
return { receiving, destination, channel, pricingTaxMode }
}
}
}
}
}
throw new Error('No priced route is available for the fixed merchant account')
}
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
}
export function buildOrderPayload({ marker, index, profile, master, 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}`
return {
customerId: profile.id,
customerName: companyName,
transportMode: 'SEA',
tradeTerm: 'FOB',
destinationCountryCode: master.destination.countryCode || 'US',
destinationWarehouseId: master.destination.id,
destinationWarehouseName: master.destination.warehouseName,
expectedShipDate: addDays(new Date(), 30),
customerRemark: `待审核订单 ${sequence} ${marker}`,
pickupMethod: 'PICKUP',
receivingWarehouseId: master.receiving.id,
receivingWarehouseName: master.receiving.warehouseName,
channelCode: master.channel.channelCode,
channelName: master.channel.channelName,
carrierId: null,
carrierName: null,
taxMode: 'BUY_DECLARATION',
pricingTaxMode: master.pricingTaxMode,
containerMode: 'LCL',
customsInspection: false,
destinationType: 'FBA',
amazonWarehouseCode: master.destination.warehouseCode,
shipmentId,
referenceId,
sender: {
companyName,
contactName: '订单测试联系人',
phone: '13800138000',
email: 'pending-order@example.com',
countryCode: 'CN',
province: '广东省',
city: '深圳市',
district: '南山区',
addressLine: `${marker} 固定客户测试地址`,
postalCode: '518000',
},
receiver: null,
cargoes: [{
skuNo: `SKU-${sequence}-${marker}`.slice(0, 64),
cargoName: '待审核测试产品',
cargoNameEn: 'Pending review test product',
hsCode: '8518210000',
customsHsCode: '8518210000',
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,
currency: 'USD',
material: '塑料 / Plastic',
usage: '接口测试 / API testing',
model: `MODEL-${sequence}`,
brand: 'TEST-NO-LOGO',
productLengthCm: 12,
productWidthCm: 8,
productHeightCm: 6,
shipmentId,
referenceId,
upcNo: `${Date.now()}${sequence}`.slice(-12),
productImageFileId,
dangerousGoods: false,
cargoRemark: `待审核订单货物 ${sequence} ${marker}`,
}],
}
}
function printHelp() {
console.log(`Usage:
node scripts/create-pending-review-orders.mjs --gateway <url> --count <1-${MAX_COUNT}>
Environment:
CLIENT_TEST_GATEWAY Test environment Gateway URL
MERCHANT_TEST_PASSWORD Password for the configured fixed account
MERCHANT_TEST_USERNAME Optional username override
PENDING_ORDER_REPORT Optional JSON report path
The GATEWAY, USERNAME, PASSWORD, and COUNT fields can also be filled in CONFIG at the top of 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.CLIENT_TEST_GATEWAY ?? CONFIG.GATEWAY)
if (!gateway) throw new Error('Gateway is required via CONFIG.GATEWAY, --gateway, or CLIENT_TEST_GATEWAY')
const count = validateCount(args.count)
const username = args.username ?? process.env.MERCHANT_TEST_USERNAME ?? CONFIG.USERNAME
const password = args.password ?? process.env.MERCHANT_TEST_PASSWORD ?? CONFIG.PASSWORD
if (!username) throw new Error('Fixed merchant username is required in CONFIG.USERNAME')
if (!password) throw new Error('Merchant password is required in CONFIG.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: { 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 master = await loadMasterData(gateway, timeoutMs, token)
const productImageFileId = await uploadProductImage(gateway, timeoutMs, token, marker)
for (let index = 1; index <= count; index += 1) {
const payload = buildOrderPayload({ marker, index, profile, master, productImageFileId })
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 submitResponse = await request(gateway, timeoutMs, 'POST',
`/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}/submit`, {
token,
headers: { 'Idempotency-Key': `${marker}-submit-${index}`.slice(0, 128) },
})
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,
orderId,
orderNo,
clientStatus: detail.order.status,
mainStatus: unifiedOrder.summary.mainStatus,
}
orders.push(result)
console.log(`CREATED ${index}/${count} orderId=${result.orderId} orderNo=${result.orderNo} status=PENDING_REVIEW`)
}
const report = {
reportName: '固定客户待审核订单批量生成',
success: true,
marker,
username,
tenantId: String(profile.id),
requestedCount: count,
createdCount: orders.length,
startedAt,
completedAt: new Date().toISOString(),
productImageFileId: String(productImageFileId),
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
})
}