2026-09-04 10:51:25 +08:00
|
|
|
#!/usr/bin/env node
|
|
|
|
|
|
2026-09-04 11:35:19 +08:00
|
|
|
import { randomInt, randomUUID } from 'node:crypto'
|
2026-09-04 10:51:25 +08:00
|
|
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
|
|
|
import { resolve } from 'node:path'
|
|
|
|
|
|
|
|
|
|
const args = parseArgs(process.argv.slice(2))
|
|
|
|
|
if (args.help) {
|
2026-09-04 11:35:19 +08:00
|
|
|
console.log('Usage: node scripts/client-quick-order-test-env-acceptance.mjs [--report-dir <path>] [--timeout-ms <ms>] [--random-count <1-50>]')
|
2026-09-04 10:51:25 +08:00
|
|
|
process.exit(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const gateway = trimSlash(process.env.ADMIN_TEST_GATEWAY)
|
|
|
|
|
const username = process.env.MERCHANT_TEST_USERNAME
|
|
|
|
|
const password = process.env.MERCHANT_TEST_PASSWORD
|
|
|
|
|
const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000)
|
2026-09-04 11:35:19 +08:00
|
|
|
const randomCount = Number(args['random-count'] ?? 0)
|
2026-09-04 10:51:25 +08:00
|
|
|
const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports')
|
|
|
|
|
const marker = `${compactTimestamp()}-${randomUUID().slice(0, 8)}`
|
|
|
|
|
const reportPath = resolve(reportDir, `client-quick-order-test-env-${marker}.json`)
|
|
|
|
|
const report = {
|
|
|
|
|
marker,
|
|
|
|
|
generatedAt: new Date().toISOString(),
|
|
|
|
|
completedAt: null,
|
|
|
|
|
success: false,
|
|
|
|
|
fatal: null,
|
2026-09-04 11:35:19 +08:00
|
|
|
resources: { batchKey: null, randomBatchKey: null, orderNos: [] },
|
2026-09-04 10:51:25 +08:00
|
|
|
steps: [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!gateway || !username || !password) {
|
|
|
|
|
console.error('ADMIN_TEST_GATEWAY, MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD are required')
|
|
|
|
|
process.exit(2)
|
|
|
|
|
}
|
|
|
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) {
|
|
|
|
|
console.error('--timeout-ms must be at least 1000')
|
|
|
|
|
process.exit(2)
|
|
|
|
|
}
|
2026-09-04 11:35:19 +08:00
|
|
|
if (!Number.isInteger(randomCount) || randomCount < 0 || randomCount > 50) {
|
|
|
|
|
console.error('--random-count must be an integer between 1 and 50')
|
|
|
|
|
process.exit(2)
|
|
|
|
|
}
|
2026-09-04 10:51:25 +08:00
|
|
|
|
|
|
|
|
let token
|
|
|
|
|
try {
|
|
|
|
|
await step('unauthenticated batch request is rejected', async () => {
|
|
|
|
|
const response = await rawRequest('/api/merchant/v1/orders/first-leg/batch', {
|
|
|
|
|
method: 'POST', body: [], headers: { 'Idempotency-Key': `quick-order-anonymous-${marker}` },
|
|
|
|
|
})
|
|
|
|
|
assert([401, 403].includes(response.status), `expected HTTP 401/403, got ${response.status}`)
|
|
|
|
|
return { httpStatus: response.status }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const login = await step('merchant login', async () => {
|
|
|
|
|
const response = await jsonRequest('/api/auth/login', {
|
|
|
|
|
method: 'POST', body: { loginId: username, password }, authenticated: false,
|
|
|
|
|
})
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
assert(response.data?.accessToken, 'accessToken is required')
|
|
|
|
|
token = response.data.accessToken
|
|
|
|
|
return { authenticated: true }
|
|
|
|
|
})
|
|
|
|
|
assert(login.authenticated, 'login did not complete')
|
|
|
|
|
|
|
|
|
|
const profile = await step('load authoritative enterprise profile', async () => {
|
|
|
|
|
const response = await jsonRequest('/api/merchant/v1/iam/tenant/profile')
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
assert(response.data?.tenantName?.trim(), 'tenantName is required')
|
|
|
|
|
return { tenantId: String(response.data.id), tenantName: response.data.tenantName.trim() }
|
|
|
|
|
})
|
|
|
|
|
const policy = await step('load inbound-code policy', async () => {
|
|
|
|
|
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/inbound-code-policy')
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
assert(['AUTO', 'LEGACY_MANUAL'].includes(response.data?.mode), `unexpected policy ${response.data?.mode}`)
|
|
|
|
|
return response.data
|
|
|
|
|
})
|
2026-09-04 11:35:19 +08:00
|
|
|
let privateAddresses = []
|
|
|
|
|
await step('load customer-owned private destination addresses', async () => {
|
2026-09-04 10:51:25 +08:00
|
|
|
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/addresses?addressType=PRIVATE_DESTINATION')
|
|
|
|
|
requireSuccess(response)
|
2026-09-04 11:35:19 +08:00
|
|
|
privateAddresses = arrayData(response.data)
|
|
|
|
|
assert(privateAddresses.length > 0, 'at least one customer-owned private destination address is required')
|
|
|
|
|
return { count: privateAddresses.length }
|
2026-09-04 10:51:25 +08:00
|
|
|
})
|
|
|
|
|
const domestic = await loadWarehouses('DOMESTIC')
|
|
|
|
|
const fba = await loadWarehouses('FBA')
|
|
|
|
|
const overseas = await loadWarehouses('OVERSEAS')
|
|
|
|
|
assert(domestic.length > 0, 'at least one ACTIVE domestic warehouse is required')
|
|
|
|
|
assert(fba.length > 0, 'at least one ACTIVE FBA warehouse is required')
|
|
|
|
|
assert(overseas.length > 0, 'at least one ACTIVE overseas warehouse is required')
|
|
|
|
|
|
|
|
|
|
let receiving
|
|
|
|
|
let channel
|
|
|
|
|
for (const warehouse of domestic) {
|
|
|
|
|
const response = await jsonRequest(`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${warehouse.id}`)
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
const candidate = arrayData(response.data).find((item) => arrayData(item.pricingTaxModes).length > 0)
|
|
|
|
|
if (candidate) { receiving = warehouse; channel = candidate; break }
|
|
|
|
|
}
|
|
|
|
|
assert(receiving && channel, 'no domestic warehouse has an active pricing channel')
|
|
|
|
|
|
2026-09-04 11:35:19 +08:00
|
|
|
const fbaDestination = (warehouse) => ({
|
|
|
|
|
destinationType: 'FBA', destinationCountryCode: warehouse.countryCode, destinationWarehouseId: warehouse.id,
|
|
|
|
|
destinationWarehouseName: warehouse.warehouseName, amazonWarehouseCode: warehouse.warehouseCode,
|
|
|
|
|
})
|
|
|
|
|
const overseasDestination = (warehouse) => ({
|
|
|
|
|
destinationType: 'OVERSEAS', destinationCountryCode: warehouse.countryCode,
|
|
|
|
|
destinationWarehouseId: warehouse.id, destinationWarehouseName: warehouse.warehouseName,
|
|
|
|
|
})
|
|
|
|
|
const privateDestination = (address) => ({
|
|
|
|
|
destinationType: 'PRIVATE', destinationCountryCode: address.countryCode,
|
|
|
|
|
destinationWarehouseName: address.addressName, privateDestinationAddressId: String(address.id),
|
|
|
|
|
})
|
|
|
|
|
const invalidChannelRowId = `quick-order-invalid-channel-${marker}`
|
|
|
|
|
const invalidPrivateRowId = `quick-order-invalid-private-${marker}`
|
2026-09-04 10:51:25 +08:00
|
|
|
const rows = [
|
2026-09-04 11:35:19 +08:00
|
|
|
{ clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
|
|
|
|
fbaDestination(fba[0]), 'FBA') },
|
|
|
|
|
{ clientRowId: `quick-order-fba-multi-cargo-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
|
|
|
|
fbaDestination(fba[1] ?? fba[0]), 'FBA-MULTI', 3) },
|
|
|
|
|
{ clientRowId: `quick-order-overseas-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
|
|
|
|
overseasDestination(overseas[1] ?? overseas[0]), 'OVERSEAS') },
|
|
|
|
|
{ clientRowId: `quick-order-private-a-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
|
|
|
|
privateDestination(privateAddresses[0]), 'PRIVATE-A') },
|
|
|
|
|
{ clientRowId: `quick-order-private-b-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
|
|
|
|
privateDestination(privateAddresses[1] ?? privateAddresses[0]), 'PRIVATE-B') },
|
|
|
|
|
{ clientRowId: invalidChannelRowId, draft: {
|
|
|
|
|
...quickDraft(profile.tenantName, policy, receiving, channel, fbaDestination(fba[0]), 'INVALID'),
|
|
|
|
|
channelCode: `INVALID-${marker}`, channelName: '不存在的渠道',
|
2026-09-04 10:51:25 +08:00
|
|
|
} },
|
2026-09-04 11:35:19 +08:00
|
|
|
{ clientRowId: invalidPrivateRowId, draft: quickDraft(profile.tenantName, policy, receiving, channel, {
|
|
|
|
|
destinationType: 'PRIVATE', destinationCountryCode: 'US', destinationWarehouseName: '越权私人仓',
|
|
|
|
|
privateDestinationAddressId: '9223372036854775807',
|
|
|
|
|
}, 'INVALID-PRIVATE') },
|
|
|
|
|
{ clientRowId: `quick-order-fba-${marker}`, draft: quickDraft(profile.tenantName, policy, receiving, channel,
|
|
|
|
|
fbaDestination(fba[0]), 'DUPLICATE') },
|
2026-09-04 10:51:25 +08:00
|
|
|
]
|
2026-09-04 11:35:19 +08:00
|
|
|
const expectedCargoCounts = new Map(rows.slice(0, 5).map((item) => [item.clientRowId, item.draft.cargoes.length]))
|
|
|
|
|
const expectedDestinationTypes = new Map(rows.slice(0, 5).map((item) => [item.clientRowId, item.draft.destinationType]))
|
2026-09-04 10:51:25 +08:00
|
|
|
|
|
|
|
|
const batchKey = `quick-order-http-${randomUUID()}`
|
|
|
|
|
report.resources.batchKey = batchKey
|
2026-09-04 11:35:19 +08:00
|
|
|
const created = await step('create five varied quick-order drafts while isolating three invalid rows', async () => {
|
2026-09-04 10:51:25 +08:00
|
|
|
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
|
|
|
|
method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey },
|
|
|
|
|
})
|
|
|
|
|
requireSuccess(response)
|
2026-09-04 11:35:19 +08:00
|
|
|
assert(response.data?.total === 8, `expected total=8, got ${response.data?.total}`)
|
2026-09-04 10:51:25 +08:00
|
|
|
const rowEvidence = arrayData(response.data?.items).map((item) => ({
|
|
|
|
|
clientRowId: item.clientRowId, success: item.success, orderId: item.orderId, orderNo: item.orderNo,
|
|
|
|
|
errorCode: item.errorCode, errorMessage: item.errorMessage,
|
|
|
|
|
}))
|
2026-09-04 11:35:19 +08:00
|
|
|
assert(response.data?.succeeded === 5,
|
|
|
|
|
`expected succeeded=5, got ${response.data?.succeeded}; rows=${JSON.stringify(rowEvidence)}`)
|
|
|
|
|
assert(response.data?.failed === 3,
|
|
|
|
|
`expected failed=3, got ${response.data?.failed}; rows=${JSON.stringify(rowEvidence)}`)
|
|
|
|
|
const invalidChannel = response.data.items.find((item) => item.clientRowId === invalidChannelRowId)
|
|
|
|
|
const invalidPrivate = response.data.items.find((item) => item.clientRowId === invalidPrivateRowId)
|
|
|
|
|
const duplicate = response.data.items[7]
|
|
|
|
|
assert(invalidChannel && !invalidChannel.success && invalidChannel.errorCode === 'A0409',
|
|
|
|
|
'invalid channel row must fail independently with A0409')
|
|
|
|
|
assert(invalidPrivate && !invalidPrivate.success && invalidPrivate.errorCode === 'A0404',
|
|
|
|
|
`unowned private address must fail closed with A0404; actual=${JSON.stringify(invalidPrivate)}`)
|
|
|
|
|
assert(duplicate && !duplicate.success && duplicate.errorCode === 'A0409',
|
|
|
|
|
'duplicate clientRowId must fail independently with A0409')
|
2026-09-04 10:51:25 +08:00
|
|
|
return { ...response.data, items: rowEvidence }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const successfulItems = created.items.filter((item) => item.success)
|
|
|
|
|
for (const item of successfulItems) {
|
|
|
|
|
await step(`verify created draft ${item.clientRowId}`, async () => {
|
|
|
|
|
assert(item.orderId != null, 'successful row must include orderId')
|
|
|
|
|
assert(item.orderNo, 'successful row must include orderNo')
|
|
|
|
|
const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`)
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
assert(response.data?.order?.status === 'DRAFT', `expected DRAFT, got ${response.data?.order?.status}`)
|
|
|
|
|
assert(response.data?.order?.customerName === profile.tenantName, 'draft must belong to the authenticated enterprise')
|
2026-09-04 11:35:19 +08:00
|
|
|
assert(response.data?.order?.destinationType === expectedDestinationTypes.get(item.clientRowId),
|
|
|
|
|
`destination type mismatch for ${item.clientRowId}`)
|
|
|
|
|
assert(arrayData(response.data?.cargoes).length === expectedCargoCounts.get(item.clientRowId),
|
|
|
|
|
`cargo count mismatch for ${item.clientRowId}`)
|
2026-09-04 10:51:25 +08:00
|
|
|
report.resources.orderNos.push(item.orderNo)
|
2026-09-04 11:35:19 +08:00
|
|
|
return { orderNo: item.orderNo, status: response.data.order.status,
|
|
|
|
|
destinationType: response.data.order.destinationType, cargoCount: response.data.cargoes.length }
|
2026-09-04 10:51:25 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await step('same idempotency key replays the same quick-order drafts', async () => {
|
|
|
|
|
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
|
|
|
|
method: 'POST', body: rows, headers: { 'Idempotency-Key': batchKey },
|
|
|
|
|
})
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
const originalIds = successfulItems.map((item) => String(item.orderId))
|
|
|
|
|
const replayIds = response.data.items.filter((item) => item.success).map((item) => String(item.orderId))
|
|
|
|
|
assert(JSON.stringify(replayIds) === JSON.stringify(originalIds), 'idempotent replay returned different order IDs')
|
|
|
|
|
return { replayedOrderNos: response.data.items.filter((item) => item.success).map((item) => item.orderNo) }
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-04 11:35:19 +08:00
|
|
|
await step('same row id and idempotency key reject a changed payload without overwriting the draft', async () => {
|
|
|
|
|
const changed = structuredClone(rows[0])
|
|
|
|
|
changed.draft.cargoes[0].packageCount = 99
|
|
|
|
|
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
|
|
|
|
method: 'POST', body: [changed], headers: { 'Idempotency-Key': batchKey },
|
|
|
|
|
})
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
assert(response.data?.succeeded === 0 && response.data?.failed === 1,
|
|
|
|
|
'changed payload with the same idempotency key must fail')
|
|
|
|
|
assert(response.data?.items?.[0]?.errorCode === 'A0409',
|
|
|
|
|
`expected idempotency conflict A0409, got ${response.data?.items?.[0]?.errorCode}`)
|
|
|
|
|
return { errorCode: response.data.items[0].errorCode, errorMessage: response.data.items[0].errorMessage }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await step('a corrected failed row succeeds under a fresh batch key', async () => {
|
|
|
|
|
const corrected = {
|
|
|
|
|
clientRowId: invalidChannelRowId,
|
|
|
|
|
draft: quickDraft(profile.tenantName, policy, receiving, channel, fbaDestination(fba[0]), 'RETRY'),
|
|
|
|
|
}
|
|
|
|
|
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
|
|
|
|
method: 'POST', body: [corrected], headers: { 'Idempotency-Key': `quick-order-retry-${randomUUID()}` },
|
|
|
|
|
})
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
const item = response.data?.items?.[0]
|
|
|
|
|
assert(response.data?.succeeded === 1 && item?.success && item?.orderId != null,
|
|
|
|
|
`corrected row did not succeed: ${JSON.stringify(response.data)}`)
|
|
|
|
|
const detail = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`)
|
|
|
|
|
requireSuccess(detail)
|
|
|
|
|
assert(detail.data?.order?.status === 'DRAFT', 'corrected row must remain a draft')
|
|
|
|
|
assert(detail.data?.order?.customerName === profile.tenantName,
|
|
|
|
|
'corrected draft must belong to the authenticated enterprise')
|
|
|
|
|
report.resources.orderNos.push(item.orderNo)
|
|
|
|
|
return { orderNo: item.orderNo, status: detail.data.order.status }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (randomCount > 0) {
|
|
|
|
|
const destinationBuilders = [
|
|
|
|
|
() => fbaDestination(fba[randomInt(fba.length)]),
|
|
|
|
|
() => overseasDestination(overseas[randomInt(overseas.length)]),
|
|
|
|
|
() => privateDestination(privateAddresses[randomInt(privateAddresses.length)]),
|
|
|
|
|
]
|
|
|
|
|
const randomRows = Array.from({ length: randomCount }, (_, index) => {
|
|
|
|
|
const destination = destinationBuilders[index % destinationBuilders.length]()
|
|
|
|
|
const cargoCount = randomInt(1, 6)
|
|
|
|
|
const draft = quickDraft(profile.tenantName, policy, receiving, channel, destination,
|
|
|
|
|
`RANDOM-${index + 1}`, cargoCount)
|
|
|
|
|
draft.cargoes.forEach((cargo) => {
|
|
|
|
|
cargo.packageCount = randomInt(1, 31)
|
|
|
|
|
cargo.totalQuantity = cargo.packageCount * randomInt(1, 11)
|
|
|
|
|
cargo.unitsPerPackage = Math.max(1, Math.floor(cargo.totalQuantity / cargo.packageCount))
|
|
|
|
|
})
|
|
|
|
|
return { clientRowId: `quick-order-random-${index + 1}-${marker}`, draft }
|
|
|
|
|
})
|
|
|
|
|
const randomBatchKey = `quick-order-random-http-${randomUUID()}`
|
|
|
|
|
report.resources.randomBatchKey = randomBatchKey
|
|
|
|
|
const randomCreated = await step(`create ${randomCount} randomized quick-order drafts`, async () => {
|
|
|
|
|
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
|
|
|
|
method: 'POST', body: randomRows, headers: { 'Idempotency-Key': randomBatchKey },
|
|
|
|
|
})
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
assert(response.data?.total === randomCount, `expected random total=${randomCount}, got ${response.data?.total}`)
|
|
|
|
|
assert(response.data?.succeeded === randomCount && response.data?.failed === 0,
|
|
|
|
|
`random batch failures: ${JSON.stringify(response.data?.items?.filter((item) => !item.success))}`)
|
|
|
|
|
return response.data.items.map((item, index) => ({
|
|
|
|
|
clientRowId: item.clientRowId, orderId: item.orderId, orderNo: item.orderNo,
|
|
|
|
|
destinationType: randomRows[index].draft.destinationType,
|
|
|
|
|
cargoCount: randomRows[index].draft.cargoes.length,
|
|
|
|
|
}))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await step(`verify all ${randomCount} randomized drafts`, async () => {
|
|
|
|
|
const evidence = []
|
|
|
|
|
for (const item of randomCreated) {
|
|
|
|
|
const response = await jsonRequest(`/api/merchant/v1/orders/first-leg/${item.orderId}`)
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
assert(response.data?.order?.status === 'DRAFT', `random draft ${item.orderNo} is not DRAFT`)
|
|
|
|
|
assert(response.data?.order?.customerName === profile.tenantName,
|
|
|
|
|
`random draft ${item.orderNo} is outside the authenticated enterprise`)
|
|
|
|
|
assert(response.data?.order?.destinationType === item.destinationType,
|
|
|
|
|
`random draft ${item.orderNo} destination type mismatch`)
|
|
|
|
|
assert(arrayData(response.data?.cargoes).length === item.cargoCount,
|
|
|
|
|
`random draft ${item.orderNo} cargo count mismatch`)
|
|
|
|
|
report.resources.orderNos.push(item.orderNo)
|
|
|
|
|
evidence.push({ orderNo: item.orderNo, destinationType: item.destinationType, cargoCount: item.cargoCount })
|
|
|
|
|
}
|
|
|
|
|
return evidence
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await step(`replay all ${randomCount} randomized drafts idempotently`, async () => {
|
|
|
|
|
const response = await jsonRequest('/api/merchant/v1/orders/first-leg/batch', {
|
|
|
|
|
method: 'POST', body: randomRows, headers: { 'Idempotency-Key': randomBatchKey },
|
|
|
|
|
})
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
const originalIds = randomCreated.map((item) => String(item.orderId))
|
|
|
|
|
const replayIds = response.data?.items?.map((item) => String(item.orderId))
|
|
|
|
|
assert(JSON.stringify(replayIds) === JSON.stringify(originalIds),
|
|
|
|
|
'randomized idempotent replay returned different order IDs')
|
|
|
|
|
return { replayed: replayIds.length }
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 10:51:25 +08:00
|
|
|
report.success = report.steps.every((item) => item.passed)
|
|
|
|
|
} catch (error) {
|
|
|
|
|
report.fatal = sanitizeError(error)
|
|
|
|
|
report.success = false
|
|
|
|
|
} finally {
|
|
|
|
|
report.completedAt = new Date().toISOString()
|
|
|
|
|
await mkdir(reportDir, { recursive: true })
|
|
|
|
|
await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
|
|
|
|
|
console.log(JSON.stringify({ success: report.success, reportPath, orderNos: report.resources.orderNos, fatal: report.fatal }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
process.exitCode = report.success ? 0 : 1
|
|
|
|
|
|
2026-09-04 11:35:19 +08:00
|
|
|
function quickDraft(customerName, policy, receiving, channel, destination, suffix, cargoCount = 1) {
|
2026-09-04 10:51:25 +08:00
|
|
|
const inboundCode = policy.mode === 'LEGACY_MANUAL' ? `QO${compactTimestamp()}${suffix.slice(0, 3)}` : undefined
|
|
|
|
|
return {
|
|
|
|
|
customerName,
|
|
|
|
|
transportMode: 'SEA', tradeTerm: 'FOB', expectedShipDate: undefined, customerRemark: `快速下单 HTTP 验收 ${marker} ${suffix}`,
|
|
|
|
|
pickupMethod: 'SELF_EXPRESS', receivingWarehouseId: receiving.id, receivingWarehouseName: receiving.warehouseName,
|
|
|
|
|
channelCode: channel.channelCode, channelName: channel.channelName,
|
|
|
|
|
pricingTaxMode: channel.pricingTaxModes[0], taxMode: 'BUY_DECLARATION', containerMode: 'FULL_CONTAINER',
|
|
|
|
|
customsInspection: null, inboundCode, ...destination,
|
2026-09-04 11:35:19 +08:00
|
|
|
cargoes: Array.from({ length: cargoCount }, (_, index) => ({
|
|
|
|
|
cargoName: '', packageType: 'CARTON', packageCount: index + 1, grossWeightKg: 0, volumeCbm: 0,
|
|
|
|
|
currency: 'USD', dangerousGoods: false, woodenProduct: false, skuNo: `SKU-${suffix}-${index + 1}-${marker}`,
|
|
|
|
|
totalQuantity: index + 1, unitsPerPackage: 1,
|
|
|
|
|
})),
|
2026-09-04 10:51:25 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadWarehouses(type) {
|
|
|
|
|
const all = []
|
|
|
|
|
for (let page = 1; ; page += 1) {
|
|
|
|
|
const response = await jsonRequest(`/api/merchant/v1/warehouses?page=${page}&size=200&status=ACTIVE&warehouseType=${type}`)
|
|
|
|
|
requireSuccess(response)
|
|
|
|
|
all.push(...pageRecords(response.data))
|
|
|
|
|
if (page >= Number(response.data?.pages ?? 1)) return all
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function step(name, action) {
|
|
|
|
|
const startedAt = new Date().toISOString()
|
|
|
|
|
try {
|
|
|
|
|
const evidence = await action()
|
|
|
|
|
report.steps.push({ name, passed: true, startedAt, completedAt: new Date().toISOString(), evidence })
|
|
|
|
|
return evidence
|
|
|
|
|
} catch (error) {
|
|
|
|
|
report.steps.push({ name, passed: false, startedAt, completedAt: new Date().toISOString(), error: sanitizeError(error) })
|
|
|
|
|
throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function rawRequest(path, { method = 'GET', body, headers = {}, authenticated = true } = {}) {
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
|
|
|
const requestHeaders = { ...headers }
|
|
|
|
|
if (authenticated && token) requestHeaders.Authorization = `Bearer ${token}`
|
|
|
|
|
let requestBody = body
|
|
|
|
|
if (body != null && !(body instanceof FormData)) {
|
|
|
|
|
requestHeaders['Content-Type'] = 'application/json'
|
|
|
|
|
requestBody = JSON.stringify(body)
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
return await fetch(`${gateway}${path}`, { method, headers: requestHeaders, body: requestBody, signal: controller.signal })
|
|
|
|
|
} finally {
|
|
|
|
|
clearTimeout(timer)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function jsonRequest(path, options) {
|
|
|
|
|
const response = await rawRequest(path, options)
|
|
|
|
|
const text = await response.text()
|
|
|
|
|
let json
|
|
|
|
|
try { json = text ? JSON.parse(text) : {} }
|
|
|
|
|
catch { throw new Error(`${path} returned non-JSON HTTP ${response.status}`) }
|
|
|
|
|
if (!response.ok) throw new Error(`${path} HTTP ${response.status} code=${json.code ?? '—'} requestId=${json.requestId ?? '—'} traceId=${json.traceId ?? '—'}`)
|
|
|
|
|
return json
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function requireSuccess(response) {
|
|
|
|
|
if (String(response?.code) !== '200') {
|
|
|
|
|
throw new Error(`business failure code=${response?.code ?? '—'} message=${response?.message ?? '—'} requestId=${response?.requestId ?? '—'} traceId=${response?.traceId ?? '—'}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseArgs(values) {
|
|
|
|
|
const result = {}
|
|
|
|
|
for (let index = 0; index < values.length; index += 1) {
|
|
|
|
|
const value = values[index]
|
|
|
|
|
if (value === '--help') { result.help = true; continue }
|
|
|
|
|
if (!value.startsWith('--')) throw new Error(`Unknown argument: ${value}`)
|
|
|
|
|
const key = value.slice(2)
|
|
|
|
|
const next = values[++index]
|
|
|
|
|
if (!next || next.startsWith('--')) throw new Error(`--${key} requires a value`)
|
|
|
|
|
result[key] = next
|
|
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function trimSlash(value) { return String(value ?? '').replace(/\/+$/, '') }
|
|
|
|
|
function compactTimestamp() { return new Date().toISOString().replace(/\D/g, '').slice(0, 14) }
|
|
|
|
|
function pageRecords(value) { return value?.records ?? value?.content ?? value?.items ?? [] }
|
|
|
|
|
function arrayData(value) { return Array.isArray(value) ? value : [] }
|
|
|
|
|
function assert(condition, message) { if (!condition) throw new Error(message) }
|
|
|
|
|
function sanitizeError(error) { return { name: error?.name ?? 'Error', message: String(error?.message ?? error) } }
|