Files
baiyue-firstleg/scripts/admin-test-env-modules/orders.mjs
T

636 lines
39 KiB
JavaScript
Raw Normal View History

2026-08-08 10:11:04 +08:00
const MODULE = 'orders'
const CONTRACT_OPTIONS = { scenario: 'isolated-order-chain', deploymentBlocker: true }
2026-08-26 11:10:42 +08:00
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
2026-08-08 10:11:04 +08:00
const BOXES = [
{ boxType: 'SMALL', lengthCm: 30, widthCm: 20, heightCm: 20 },
{ boxType: 'MEDIUM', lengthCm: 40, widthCm: 30, heightCm: 30 },
{ boxType: 'LARGE', lengthCm: 50, widthCm: 40, heightCm: 40 },
{ boxType: 'EXTRA_LARGE', lengthCm: 60, widthCm: 50, heightCm: 50 },
]
const MEASURABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED'])
2026-08-13 19:00:33 +08:00
const PRICING_PROBE_CONCURRENCY = 6
const ORDER_ESTIMATE_WEIGHT_KG = BOXES.reduce((total, _box, index) => total + 10 + index, 0)
const ORDER_ESTIMATE_VOLUME_CBM = BOXES.reduce((total, box) =>
total + box.lengthCm * box.widthCm * box.heightCm / 1_000_000, 0)
2026-08-08 10:11:04 +08:00
function id(value) { return value == null ? '' : String(value) }
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
function pageOk(value) { return Array.isArray(value?.records) && Number.isFinite(Number(value?.total)) }
2026-08-20 15:20:05 +08:00
function success(response) { return Boolean(response) && response.http === 200 && String(response.body?.code) === '200' }
2026-08-08 10:11:04 +08:00
function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
2026-08-24 19:58:35 +08:00
export function requestedOrderDestinationType(context) {
2026-08-26 11:10:42 +08:00
const value = String(context?.context?.orderDestinationType || 'FBA').trim().toUpperCase()
return ['FBA', 'OVERSEAS', 'PRIVATE'].includes(value) ? value : 'FBA'
2026-08-24 19:58:35 +08:00
}
2026-08-13 18:34:44 +08:00
export function approvedInboundReady(orderDetail, taskResponse, tenantId, orderId) {
const summary = orderDetail?.summary
const task = success(taskResponse) ? taskResponse.body?.data : null
return Boolean(summary?.inboundId)
&& MEASURABLE_STATUSES.has(summary?.mainStatus)
&& id(task?.orderId) === id(orderId)
&& id(task?.tenantId) === id(tenantId)
}
2026-08-08 10:11:04 +08:00
function tomorrow(days = 30) {
const date = new Date(); date.setUTCDate(date.getUTCDate() + days)
return date.toISOString().slice(0, 10)
}
2026-08-24 19:58:35 +08:00
function pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType = 'FBA') {
2026-08-13 19:00:33 +08:00
return {
originWarehouseId: receiving.id,
channelCode: channel.channelCode,
destinationCode: destination.warehouseCode,
2026-08-24 19:58:35 +08:00
destinationType,
2026-08-13 19:00:33 +08:00
destinationWarehouseId: destination.id,
destinationName: destination.warehouseName,
pricingTaxMode,
actualWeightKg: ORDER_ESTIMATE_WEIGHT_KG,
actualVolumeCbm: ORDER_ESTIMATE_VOLUME_CBM,
}
}
function estimateMatches(response, candidate) {
const estimate = success(response) ? response.body?.data : null
return Boolean(estimate)
&& id(estimate.priceBookVersionId) === id(candidate.channel.priceBookVersionId)
&& id(estimate.channelCode) === id(candidate.channel.channelCode)
&& id(estimate.pricingTaxMode) === id(candidate.pricingTaxMode)
}
export async function findPricedOrderRoute({ receiving, destinations, channels, estimate,
2026-08-24 19:58:35 +08:00
concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) {
2026-08-13 19:00:33 +08:00
const candidates = []
for (const channel of channels) {
if (!channel?.priceBookVersionId || !channel?.channelCode || !Array.isArray(channel.pricingTaxModes)) continue
for (const pricingTaxMode of channel.pricingTaxModes) {
if (!pricingTaxMode) continue
for (const destination of destinations) {
if (!destination?.id || !destination?.warehouseCode) continue
candidates.push({ channel, pricingTaxMode, destination,
2026-08-24 19:58:35 +08:00
request: pricingEstimateRequest(receiving, channel, pricingTaxMode, destination, destinationType) })
2026-08-13 19:00:33 +08:00
}
}
}
const failures = []
let lastResponse = null
const batchSize = Math.max(1, Math.min(Number(concurrency) || 1, PRICING_PROBE_CONCURRENCY))
let attempts = 0
for (let offset = 0; offset < candidates.length; offset += batchSize) {
const batch = candidates.slice(offset, offset + batchSize)
const results = await Promise.all(batch.map(async (candidate) => ({
candidate, response: await estimate(candidate.request),
})))
attempts += results.length
lastResponse = results.at(-1)?.response || lastResponse
const match = results.find(({ candidate, response }) => estimateMatches(response, candidate))
if (match) return { priced: match.candidate, response: match.response, attempts, failures }
for (const { candidate, response } of results) {
const returned = success(response) ? response.body?.data : null
failures.push({
tuple: `${candidate.channel.channelCode}/${candidate.pricingTaxMode}/${candidate.destination.warehouseCode}`,
http: response?.http ?? 'missing', code: response?.body?.code ?? 'missing',
requestId: response?.requestId ?? 'missing', traceId: response?.traceId ?? 'missing',
mismatch: returned
? `version=${id(returned.priceBookVersionId) || 'missing'},channel=${id(returned.channelCode) || 'missing'},tax=${id(returned.pricingTaxMode) || 'missing'}`
: null,
})
}
}
return { priced: null, response: lastResponse, attempts, failures }
}
2026-08-15 11:36:53 +08:00
export async function findPricedOrderRouteAcrossOrigins({ receivings, destinations, loadChannels, estimate,
2026-08-24 19:58:35 +08:00
concurrency = PRICING_PROBE_CONCURRENCY, destinationType = 'FBA' }) {
2026-08-15 11:36:53 +08:00
const originFailures = []
let attempts = 0
let response = null
for (const receiving of receivings) {
if (!receiving?.id) continue
const channelResponse = await loadChannels(receiving)
response = channelResponse || response
const channels = success(channelResponse) && Array.isArray(channelResponse.body?.data)
? channelResponse.body.data : []
const priceableChannels = channels.filter((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0)
if (priceableChannels.length === 0) {
originFailures.push({ originWarehouseId: id(receiving.id), stage: 'channels', http: channelResponse?.http ?? 'missing',
code: channelResponse?.body?.code ?? 'missing', requestId: channelResponse?.requestId ?? 'missing',
traceId: channelResponse?.traceId ?? 'missing', count: channels.length })
continue
}
2026-08-24 19:58:35 +08:00
const result = await findPricedOrderRoute({ receiving, destinations, channels: priceableChannels,
estimate, concurrency, destinationType })
2026-08-15 11:36:53 +08:00
attempts += result.attempts
response = result.response || response
if (result.priced) return { receiving, ...result, originFailures }
originFailures.push({ originWarehouseId: id(receiving.id), stage: 'estimate', attempts: result.attempts,
evidence: pricingFailureEvidence(result) })
}
return { receiving: null, priced: null, response, attempts, originFailures }
}
2026-08-13 19:00:33 +08:00
function pricingFailureEvidence(result) {
const describe = (failure) => failure
? `${failure.tuple}[HTTP=${failure.http},code=${failure.code},requestId=${failure.requestId},traceId=${failure.traceId}${failure.mismatch ? `,response=${failure.mismatch}` : ''}]`
: 'missing'
return `没有可计价组合;attempts=${result.attempts}, first=${describe(result.failures[0])}, last=${describe(result.failures.at(-1))}`
}
2026-08-15 11:36:53 +08:00
function originPricingFailureEvidence(result) {
const describe = (failure) => !failure ? 'missing'
: failure.stage === 'channels'
? `origin=${failure.originWarehouseId},channels[HTTP=${failure.http},code=${failure.code},count=${failure.count},requestId=${failure.requestId},traceId=${failure.traceId}]`
: `origin=${failure.originWarehouseId},estimate[attempts=${failure.attempts},${failure.evidence}]`
return `没有 ACTIVE 国内仓可计价组合;origins=${result.originFailures?.length ?? 0}, attempts=${result.attempts}, first=${describe(result.originFailures?.[0])}, last=${describe(result.originFailures?.at(-1))}`
}
2026-08-08 10:11:04 +08:00
async function request(context, method, path, body, token, extraHeaders = {}) {
const headers = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...extraHeaders }
let payload = body
if (body !== undefined && !(body instanceof FormData)) {
headers['Content-Type'] = 'application/json; charset=utf-8'
payload = JSON.stringify(body)
}
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method, headers, body: payload, signal: AbortSignal.timeout(30_000),
})
const content = await response.text()
let parsed = null
try { parsed = content ? JSON.parse(content) : null } catch { /* Recorded as a bounded raw response. */ }
return {
method, path, http: response.status, body: parsed,
raw: parsed === null ? content.slice(0, 500) : null,
requestId: parsed?.requestId || response.headers.get('x-request-id'),
traceId: parsed?.traceId || response.headers.get('x-trace-id'),
}
} catch (error) {
return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null }
}
}
async function uploadProductImage(context, merchantToken) {
const form = new FormData()
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')
form.append('file', new Blob([png], { type: 'image/png' }), `product-${context.marker}.png`)
const response = await request(context, 'POST', '/api/merchant/v1/files/product-images', form, merchantToken)
const data = context.apiSuccess(MODULE, 'Client 上传产品图片', response, 'contentType=image/png', CONTRACT_OPTIONS)
context.assert(MODULE, '产品图片上传返回字符串文件 ID', response,
Boolean(data?.fileId) && typeof data.fileId === 'string' && data.contentType === 'image/png',
`fileIdType=${typeof data?.fileId}, contentType=${data?.contentType ?? 'missing'}`, CONTRACT_OPTIONS)
return id(data?.fileId)
}
async function assertProductImageDownload(context, merchantToken, fileId) {
const path = `/api/merchant/v1/files/product-images/${encodeURIComponent(fileId)}`
try {
const response = await fetch(`${context.options.gateway}${path}`, {
headers: { Accept: 'image/*', Authorization: `Bearer ${merchantToken}` },
signal: AbortSignal.timeout(30_000),
})
const bytes = await response.arrayBuffer()
const evidence = { method: 'GET', path, http: response.status, body: null, raw: null,
requestId: response.headers.get('x-request-id'), traceId: response.headers.get('x-trace-id') }
context.assert(MODULE, 'Client 可下载本轮产品图片', evidence,
response.status === 200 && bytes.byteLength > 0 && (response.headers.get('content-type') || '').startsWith('image/'),
`bytes=${bytes.byteLength}, contentType=${response.headers.get('content-type') || 'missing'}`, CONTRACT_OPTIONS)
} catch (error) {
context.assert(MODULE, 'Client 可下载本轮产品图片', null, false, String(error), CONTRACT_OPTIONS)
}
}
async function loadMasterData(context, merchantToken) {
2026-08-24 19:58:35 +08:00
const destinationType = requestedOrderDestinationType(context)
2026-08-26 11:10:42 +08:00
const fixedRoute = context.context?.waitSplitRoute || {}
2026-08-08 10:11:04 +08:00
const domesticResponse = await request(context, 'GET',
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', undefined, merchantToken)
const domesticPage = context.apiSuccess(MODULE, 'Client 查询国内收货仓', domesticResponse)
context.assert(MODULE, '存在可用国内收货仓', domesticResponse, records(domesticPage).length > 0,
`count=${records(domesticPage).length}`)
2026-08-26 11:10:42 +08:00
let destinationResponse = null
let destinationPage = null
if (destinationType !== 'PRIVATE') {
destinationResponse = await request(context, 'GET',
`/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
undefined, merchantToken)
destinationPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓`, destinationResponse)
context.assert(MODULE, `存在可用 ${destinationType} 目的仓`, destinationResponse,
records(destinationPage).length > 0, `count=${records(destinationPage).length}`)
}
2026-08-15 11:36:53 +08:00
const receivings = records(domesticPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
2026-08-26 11:10:42 +08:00
let destinations = records(destinationPage).filter((warehouse) => warehouse?.status === 'ACTIVE')
if (Number(destinationPage?.total) > destinations.length) {
const nextResponse = await request(context, 'GET',
`/api/merchant/v1/warehouses?page=2&size=200&status=ACTIVE&warehouseType=${encodeURIComponent(destinationType)}`,
undefined, merchantToken)
const nextPage = context.apiSuccess(MODULE, `Client 查询 ${destinationType} 目的仓第二页`, nextResponse)
destinations = [...destinations, ...records(nextPage).filter((warehouse) => warehouse?.status === 'ACTIVE')]
}
if (receivings.length === 0 || (destinationType !== 'PRIVATE' && destinations.length === 0)) {
throw new Error('缺少创建测试订单所需的收货仓或目的仓')
}
2026-08-08 10:11:04 +08:00
2026-08-26 11:10:42 +08:00
let pricingResult
if (fixedRoute.originWarehouseName && (fixedRoute.channelCode || fixedRoute.channelName)) {
const receiving = receivings.find((warehouse) => warehouse.warehouseName === fixedRoute.originWarehouseName)
if (!receiving) throw new Error(`未找到固定起运仓: ${fixedRoute.originWarehouseName}`)
const channelsResponse = await request(context, 'GET',
2026-08-15 11:36:53 +08:00
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
2026-08-26 11:10:42 +08:00
undefined, merchantToken)
const channels = context.apiSuccess(MODULE, 'Client 查询固定起运仓物流渠道', channelsResponse)
const channel = (Array.isArray(channels) ? channels : []).find((item) =>
(!fixedRoute.channelCode || item.channelCode === fixedRoute.channelCode)
&& (!fixedRoute.channelName || item.channelName === fixedRoute.channelName))
if (!channel) throw new Error(`未找到固定物流渠道: ${fixedRoute.channelCode || fixedRoute.channelName}`)
const modes = Array.isArray(channel.pricingTaxModes) ? [...channel.pricingTaxModes] : []
if (modes.length === 0) throw new Error('固定物流渠道没有可用报价税制')
const shift = modes.length ? Number(fixedRoute.taxModeIndex || 0) % modes.length : 0
const selectedChannel = { ...channel, pricingTaxModes: [...modes.slice(shift), ...modes.slice(0, shift)] }
if (destinationType === 'PRIVATE') {
pricingResult = { receiving, priced: { channel: selectedChannel,
pricingTaxMode: selectedChannel.pricingTaxModes[0], destination: null },
response: channelsResponse, attempts: 0, failures: [], originFailures: [] }
} else {
const detailResponse = await context.request('GET',
`/api/platform/v1/fba-pricing/carrier-rates/price-books/${encodeURIComponent(channel.priceBookVersionId)}`)
const detail = context.apiSuccess(MODULE, 'Admin 查询固定渠道当前价格明细', detailResponse)
const pricedCodes = new Set((detail?.rates || [])
.filter((rate) => rate.channelCode === channel.channelCode && rate.destinationType === destinationType)
.map((rate) => String(rate.destinationCode || '').trim()).filter(Boolean))
const eligible = destinations.filter((destination) => pricedCodes.has(String(destination.warehouseCode)))
if (eligible.length < 1) throw new Error(`固定渠道没有可计价目的仓: ${destinationType}`)
const destination = eligible[Number(fixedRoute.destinationIndex || 0) % eligible.length]
const result = await findPricedOrderRoute({
receiving, destinations: [destination], channels: [selectedChannel],
estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken),
destinationType,
})
pricingResult = { receiving, ...result, originFailures: [] }
}
} else {
pricingResult = await findPricedOrderRouteAcrossOrigins({
receivings, destinations,
loadChannels: (receiving) => request(context, 'GET',
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(receiving.id)}`,
undefined, merchantToken),
estimate: (body) => request(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, merchantToken),
destinationType,
})
}
2026-08-13 19:00:33 +08:00
const priced = pricingResult.priced
2026-08-15 11:36:53 +08:00
const receiving = pricingResult.receiving
context.addStep(MODULE, 'Client 从 ACTIVE 国内仓选择具有真实价格覆盖的渠道和目的仓', pricingResult.response, Boolean(priced),
2026-08-13 19:00:33 +08:00
priced
2026-08-26 11:10:42 +08:00
? `origin=${receiving.id}, priceBookVersionId=${priced.channel.priceBookVersionId}, channel=${priced.channel.channelCode}, pricingTaxMode=${priced.pricingTaxMode}, destination=${priced.destination?.warehouseCode || 'PRIVATE_ADDRESS'}, attempts=${pricingResult.attempts}`
2026-08-15 11:36:53 +08:00
: originPricingFailureEvidence(pricingResult),
2026-08-13 19:00:33 +08:00
CONTRACT_OPTIONS)
2026-08-24 19:58:35 +08:00
if (!priced) throw new Error(`当前 ACTIVE 国内收货仓、渠道、报价税制和 ${destinationType} 目的仓没有可计价组合`)
2026-08-08 10:11:04 +08:00
const partnerResponse = await request(context, 'GET',
'/api/merchant/v1/partners/candidates?type=SHIPPER', undefined, merchantToken)
const partners = context.apiSuccess(MODULE, 'Client 查询发货客户候选', partnerResponse)
const partner = Array.isArray(partners)
? partners.find((item) => item.status === 'ACTIVE' && item.types?.includes?.('SHIPPER'))
: null
2026-08-13 19:00:33 +08:00
return { receiving, destination: priced.destination, channel: priced.channel,
2026-08-24 19:58:35 +08:00
pricingTaxMode: priced.pricingTaxMode, destinationType, partner }
2026-08-08 10:11:04 +08:00
}
function buildPayload(context, profile, master, productImageFileId) {
const shipmentId = `SHIP-${context.marker}`.slice(0, 64)
const referenceId = `REF-${context.marker}`.slice(0, 64)
const companyName = profile.tenantName || `订单验收客户-${context.marker}`
2026-08-26 11:10:42 +08:00
const requestedSkuCount = Number(context.context?.waitSplitVariant?.skuCount || BOXES.length)
const skuCount = Math.max(1, Math.min(BOXES.length, requestedSkuCount))
const packageCountPerSku = Math.max(1,
Number(context.context?.waitSplitVariant?.packageCountPerSku
|| context.context?.phase3LoadingIncrementalPackageCount || 1))
const selectedBoxes = BOXES.slice(0, skuCount)
const orderProfile = context.context?.orderProfile || {}
const isPrivate = master.destinationType === 'PRIVATE'
const pickupMethod = orderProfile.pickupMethod || 'PICKUP'
const receiver = isPrivate ? {
companyName: `${companyName} 私人收件人`, contactName: `收件人-${context.marker}`.slice(0, 64),
phone: '12025550188', email: 'private-receiver@example.com', countryCode: 'US',
province: 'New York', city: 'New York', district: 'Queens',
addressLine: `${context.marker} 88 Randomized Test Ave`, postalCode: '11354',
} : null
2026-08-08 10:11:04 +08:00
return {
fulfillmentType: String(context.context?.orderFulfillmentType || 'STANDARD').trim().toUpperCase(),
2026-08-26 11:10:42 +08:00
customerId: profile.id,
customerName: companyName,
2026-08-08 10:11:04 +08:00
transportMode: 'SEA', tradeTerm: 'FOB',
2026-08-26 11:10:42 +08:00
destinationCountryCode: master.destination?.countryCode || receiver?.countryCode || 'US',
destinationWarehouseId: master.destination?.id || null,
destinationWarehouseName: master.destination?.warehouseName || (isPrivate ? '私人地址' : null),
2026-08-08 10:11:04 +08:00
expectedShipDate: tomorrow(), customerRemark: `${context.marker} Client 创建隔离订单`,
2026-08-26 11:10:42 +08:00
pickupMethod, requestedPickupAt: pickupMethod === 'PICKUP'
? new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 19) : null,
receivingWarehouseId: master.receiving.id,
2026-08-08 10:11:04 +08:00
receivingWarehouseName: master.receiving.warehouseName,
channelCode: master.channel.channelCode, channelName: master.channel.channelName,
2026-08-26 11:10:42 +08:00
carrierId: null, carrierName: null, taxMode: orderProfile.taxMode || 'BUY_DECLARATION',
pricingTaxMode: master.pricingTaxMode, containerMode: orderProfile.containerMode || 'LCL',
customsInspection: orderProfile.customsInspection ?? false, destinationType: master.destinationType,
amazonWarehouseCode: master.destinationType === 'FBA' ? master.destination?.warehouseCode : null,
2026-08-24 19:58:35 +08:00
shipmentId: master.destinationType === 'FBA' ? shipmentId : null,
referenceId: master.destinationType === 'FBA' ? referenceId : null,
2026-08-08 10:11:04 +08:00
sender: {
companyName, contactName: '订单验收联系人', phone: '13800138000',
email: 'order-acceptance@example.com', countryCode: 'CN', province: '广东省',
city: '深圳市', district: '南山区', addressLine: `${context.marker} 测试地址`, postalCode: '518000',
},
2026-08-26 11:10:42 +08:00
receiver,
cargoes: selectedBoxes.map((box, index) => {
2026-08-08 10:11:04 +08:00
const volumeCbm = box.lengthCm * box.widthCm * box.heightCm / 1_000_000
const unitsPerPackage = 10 + index
2026-08-26 11:10:42 +08:00
const packageCount = packageCountPerSku
2026-08-08 10:11:04 +08:00
return {
skuNo: `SKU-${box.boxType}-${context.marker}`.slice(0, 64),
cargoName: `${box.boxType} 测试产品`, cargoNameEn: `${box.boxType} test product`,
hsCode: '8518210000', customsHsCode: '8518210000', packageType: 'CARTON',
lengthCm: box.lengthCm, widthCm: box.widthCm, heightCm: box.heightCm,
2026-08-17 15:41:42 +08:00
unitsPerPackage, packageCount, grossWeightKg: (10 + index) * packageCount,
volumeCbm: volumeCbm * packageCount, totalQuantity: unitsPerPackage * packageCount,
netWeightKg: (9 + index) * packageCount,
unitDeclaredValue: 8.5, declaredValue: unitsPerPackage * packageCount * 8.5, currency: 'USD',
2026-08-08 10:11:04 +08:00
material: '塑料 / Plastic', usage: '接口验收 / API acceptance',
model: `MODEL-${box.boxType}`, brand: 'TEST-NO-LOGO',
productLengthCm: 12 + index, productWidthCm: 8 + index, productHeightCm: 6 + index,
2026-08-24 19:58:35 +08:00
shipmentId: master.destinationType === 'FBA' ? shipmentId : null,
referenceId: master.destinationType === 'FBA' ? referenceId : null,
upcNo: `${Date.now()}${index}`.slice(-12),
2026-08-08 10:11:04 +08:00
productImageFileId, dangerousGoods: false,
cargoRemark: `${context.marker} boxType=${box.boxType}`,
}
}),
}
}
function assertCargoContract(context, response, cargoes, payload) {
2026-08-26 11:10:42 +08:00
const expectedCargoes = payload.cargoes || []
const complete = Array.isArray(cargoes) && cargoes.length === expectedCargoes.length
&& expectedCargoes.every((expected, index) => {
const actual = cargoes[index]
return Number(actual?.lengthCm) === expected.lengthCm && Number(actual?.widthCm) === expected.widthCm
&& Number(actual?.heightCm) === expected.heightCm && id(actual?.productImageFileId) === id(expected.productImageFileId)
2026-08-08 10:11:04 +08:00
&& actual?.skuNo === expected.skuNo && actual?.cargoName === expected.cargoName
&& actual?.cargoNameEn === expected.cargoNameEn && actual?.customsHsCode === expected.customsHsCode
&& Number(actual?.unitsPerPackage) === expected.unitsPerPackage
2026-08-17 15:41:42 +08:00
&& Number(actual?.packageCount) === expected.packageCount && Number(actual?.totalQuantity) === expected.totalQuantity
2026-08-08 10:11:04 +08:00
&& Number(actual?.netWeightKg) === expected.netWeightKg
&& Number(actual?.unitDeclaredValue) === expected.unitDeclaredValue
&& actual?.material === expected.material && actual?.productUsage === expected.usage
&& actual?.model === expected.model && actual?.brand === expected.brand
&& Number(actual?.productLengthCm) === expected.productLengthCm
&& Number(actual?.productWidthCm) === expected.productWidthCm
&& Number(actual?.productHeightCm) === expected.productHeightCm
2026-08-24 21:00:22 +08:00
&& (actual?.shipmentId ?? null) === (expected.shipmentId ?? null)
&& (actual?.referenceId ?? null) === (expected.referenceId ?? null)
2026-08-08 10:11:04 +08:00
&& actual?.upcNo === expected.upcNo && actual?.cargoRemark.includes(context.marker)
})
2026-08-26 11:10:42 +08:00
context.assert(MODULE, '动态SKU产品字段及图片契约完整回显', response, complete,
2026-08-08 10:11:04 +08:00
`cargoes=${Array.isArray(cargoes) ? cargoes.length : 'n/a'}, imageFileId=${payload.cargoes[0].productImageFileId}`,
CONTRACT_OPTIONS)
}
async function assertDimensionConfig(context, tenantId) {
const path = `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${encodeURIComponent(tenantId)}`
const response = await context.request('GET', path)
const data = context.apiSuccess(MODULE, 'Admin 查询客户固定箱型', response, `tenantId=${tenantId}`, CONTRACT_OPTIONS)
const rules = Array.isArray(data?.rules) ? data.rules : []
const exact = BOXES.every((box, index) => {
const rule = rules[index]
return rule?.boxType === box.boxType && Number(rule.innerLengthCm) === box.lengthCm
&& Number(rule.innerWidthCm) === box.widthCm && Number(rule.innerHeightCm) === box.heightCm
&& Number(rule.edgeAdditionCm) === 2
})
context.assert(MODULE, '固定四箱型顺序、内径及每边加2cm正确', response,
rules.length === BOXES.length && exact,
`types=${rules.map((rule) => rule.boxType).join(',') || 'missing'}`, CONTRACT_OPTIONS)
}
async function waitForInbound(context, tenantId, orderId) {
const path = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
2026-08-13 18:34:44 +08:00
const taskPath = `/api/platform/v1/first-leg/tasks/by-order/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
let response = null; let detail = null; let taskResponse = null; let task = null
for (let attempt = 1; attempt <= 30; attempt += 1) {
2026-08-08 10:11:04 +08:00
response = await context.request('GET', path)
2026-08-13 18:34:44 +08:00
const candidate = success(response) ? response.body?.data : null
if (candidate?.summary?.inboundId) {
taskResponse = await context.request('GET', taskPath)
}
if (approvedInboundReady(candidate, taskResponse, tenantId, orderId)) {
detail = candidate
task = taskResponse.body.data
2026-08-08 10:11:04 +08:00
break
}
2026-08-13 18:34:44 +08:00
if (attempt < 30) await sleep(1_000)
2026-08-08 10:11:04 +08:00
}
2026-08-13 18:34:44 +08:00
context.addStep(MODULE, '审核后入库单与履约任务异步收敛', response, Boolean(detail && task),
detail
? `inboundId=${detail.summary.inboundId}, status=${detail.summary.mainStatus}, taskId=${id(task?.id)}`
: `30秒内未收敛;status=${response?.body?.data?.summary?.mainStatus ?? 'missing'}, taskCode=${taskResponse?.body?.code ?? 'not-requested'}`,
CONTRACT_OPTIONS)
return { response, detail, taskResponse, task }
2026-08-08 10:11:04 +08:00
}
export async function run(context) {
const loginResponse = await context.request('POST', '/api/auth/login', {
username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
}, '')
const login = context.apiSuccess(MODULE, 'Client 测试账号登录', loginResponse,
`username=${MERCHANT_USERNAME}`)
const merchantToken = login?.accessToken
if (!merchantToken) throw new Error('Client 登录未返回 accessToken')
const profileResponse = await request(context, 'GET', '/api/merchant/v1/iam/tenant/profile',
undefined, merchantToken)
const profile = context.apiSuccess(MODULE, 'Client 查询当前企业资料', profileResponse)
const tenantId = id(profile?.id)
context.assert(MODULE, 'Client 测试企业已审核且客户 ID 有效', profileResponse,
Boolean(tenantId) && profile?.status === 'ACTIVE', `tenantId=${tenantId || 'missing'}, status=${profile?.status ?? 'missing'}`)
if (!tenantId) throw new Error('Client 企业资料未返回客户 ID')
await assertDimensionConfig(context, tenantId)
const master = await loadMasterData(context, merchantToken)
const productImageFileId = await uploadProductImage(context, merchantToken)
if (!productImageFileId) throw new Error('产品图片新契约未部署,无法创建带图片的隔离订单')
await assertProductImageDownload(context, merchantToken, productImageFileId)
const payload = buildPayload(context, profile, master, productImageFileId)
const createPath = '/api/merchant/v1/orders/first-leg'
const createResponse = await request(context, 'POST', createPath, payload, merchantToken,
{ 'Idempotency-Key': key(context, 'client-create') })
const orderId = id(context.apiSuccess(MODULE, 'Client 创建四箱型隔离订单', createResponse,
`marker=${context.marker}`, CONTRACT_OPTIONS))
if (!orderId) throw new Error('Client 订单创建失败,后续 Admin 链路停止')
context.createdResources.orders = {
marker: context.marker, tenantId, orderId, productImageFileId,
fulfillmentType: payload.fulfillmentType,
skuCodes: payload.cargoes.map((cargo) => cargo.skuNo),
skuLines: payload.cargoes.map((cargo) => ({
skuCode: cargo.skuNo, packageCount: cargo.packageCount,
})),
2026-08-08 10:11:04 +08:00
boxTypes: BOXES.map((box) => box.boxType), inboundId: null,
2026-08-12 13:24:17 +08:00
receivingWarehouseId: id(master.receiving.id),
2026-08-26 11:10:42 +08:00
receivingWarehouseName: master.receiving.warehouseName,
2026-08-24 19:58:35 +08:00
destinationType: master.destinationType,
2026-08-26 11:10:42 +08:00
destinationWarehouseId: id(master.destination?.id),
destinationWarehouseCode: master.destination?.warehouseCode || null,
destinationWarehouseName: master.destination?.warehouseName || (master.destinationType === 'PRIVATE' ? '私人地址' : null),
channelCode: master.channel.channelCode,
channelName: master.channel.channelName,
pricingTaxMode: master.pricingTaxMode,
pickupMethod: payload.pickupMethod,
taxMode: payload.taxMode,
containerMode: payload.containerMode,
customsInspection: payload.customsInspection,
2026-08-08 10:11:04 +08:00
}
const replayResponse = await request(context, 'POST', createPath, payload, merchantToken,
{ 'Idempotency-Key': key(context, 'client-create') })
const replayedId = id(context.apiSuccess(MODULE, 'Client 创建订单幂等重放', replayResponse))
context.assert(MODULE, 'Client 创建幂等结果一致', replayResponse, replayedId === orderId,
`orderId=${orderId}, replayedId=${replayedId}`)
const clientDetailPath = `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}`
const draftResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
const draft = context.apiSuccess(MODULE, 'Client 查询隔离订单草稿', draftResponse)
const orderNo = id(draft?.order?.orderNo)
context.assert(MODULE, 'Client 草稿身份、marker 和状态正确', draftResponse,
id(draft?.order?.id) === orderId && draft?.order?.status === 'DRAFT'
&& String(draft?.order?.customerRemark || '').includes(context.marker),
`orderNo=${orderNo || 'missing'}, status=${draft?.order?.status ?? 'missing'}`)
assertCargoContract(context, draftResponse, draft?.cargoes, payload)
context.createdResources.orders.orderNo = orderNo
const submitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined,
merchantToken, { 'Idempotency-Key': key(context, 'client-submit') })
context.apiSuccess(MODULE, 'Client 提交隔离订单', submitResponse, `orderId=${orderId}`)
const submittedResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
const submitted = context.apiSuccess(MODULE, 'Client 回查已提交订单', submittedResponse)
context.assert(MODULE, 'Client 订单已进入待审核状态', submittedResponse,
submitted?.order?.status === 'SUBMITTED', `status=${submitted?.order?.status ?? 'missing'}`)
const unifiedListResponse = await context.request('GET',
`/api/platform/v1/orders?page=1&size=100&tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}`)
const unifiedPage = context.apiSuccess(MODULE, 'Admin 查询统一订单列表', unifiedListResponse)
const unifiedRow = records(unifiedPage).find((row) => id(row.orderId) === orderId)
context.assert(MODULE, 'Admin 统一订单列表只核对本轮订单', unifiedListResponse,
pageOk(unifiedPage) && Boolean(unifiedRow), `orderId=${orderId}, found=${Boolean(unifiedRow)}`)
const unifiedDetailPath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
const unifiedDetailResponse = await context.request('GET', unifiedDetailPath)
const unifiedDetail = context.apiSuccess(MODULE, 'Admin 查询统一订单详情', unifiedDetailResponse)
context.assert(MODULE, 'Admin 统一订单详情身份与待审核状态正确', unifiedDetailResponse,
id(unifiedDetail?.summary?.orderId) === orderId && unifiedDetail?.summary?.mainStatus === 'PENDING_REVIEW'
&& Array.isArray(unifiedDetail?.timeline),
`mainStatus=${unifiedDetail?.summary?.mainStatus ?? 'missing'}, timeline=${unifiedDetail?.timeline?.length ?? 'n/a'}`)
const firstLegListResponse = await context.request('GET',
`/api/platform/v1/orders/first-leg?tenantId=${encodeURIComponent(tenantId)}&keyword=${encodeURIComponent(orderNo)}&page=1&size=100`)
const firstLegPage = context.apiSuccess(MODULE, 'Admin 查询头程订单列表', firstLegListResponse)
const firstLegRow = records(firstLegPage).find((row) => id(row.id) === orderId)
context.assert(MODULE, 'Admin 头程订单列表只核对本轮订单', firstLegListResponse,
pageOk(firstLegPage) && Boolean(firstLegRow), `orderId=${orderId}, found=${Boolean(firstLegRow)}`)
const firstLegDetailPath = `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
const firstLegDetailResponse = await context.request('GET', firstLegDetailPath)
const firstLegDetail = context.apiSuccess(MODULE, 'Admin 查询头程订单详情', firstLegDetailResponse)
context.assert(MODULE, 'Admin 头程订单详情聚合结构完整', firstLegDetailResponse,
id(firstLegDetail?.order?.id) === orderId && Array.isArray(firstLegDetail?.cargoes)
&& Array.isArray(firstLegDetail?.parties) && Array.isArray(firstLegDetail?.internalNotes)
&& Array.isArray(firstLegDetail?.statusHistory),
`cargoes=${firstLegDetail?.cargoes?.length ?? 'n/a'}, history=${firstLegDetail?.statusHistory?.length ?? 'n/a'}`)
assertCargoContract(context, firstLegDetailResponse, firstLegDetail?.cargoes, payload)
const noteResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/internal-notes?tenantId=${encodeURIComponent(tenantId)}`,
{ content: `${context.marker} Admin 订单链路验收` })
context.apiSuccess(MODULE, 'Admin 为本轮订单添加内部备注', noteResponse)
const approveResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/approve?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, 'Admin 审核通过本轮订单', approveResponse, `orderId=${orderId}`)
const inboundResult = await waitForInbound(context, tenantId, orderId)
const inboundId = id(inboundResult.detail?.summary?.inboundId)
2026-08-13 18:34:44 +08:00
if (!inboundId || !inboundResult.task) {
throw new Error('订单审核后的入库单与履约任务未在30秒内收敛,无法向 OCR/墨家模块交接')
}
2026-08-08 10:11:04 +08:00
const inboundPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`
const inboundResponse = await context.request('GET', inboundPath)
const inbound = context.apiSuccess(MODULE, 'Admin 查询本轮入库单详情', inboundResponse,
`inboundId=${inboundId}`, CONTRACT_OPTIONS)
const markerValues = [
inbound?.publicRemark, inbound?.summary?.orderNo, inbound?.summary?.inboundCode,
...(Array.isArray(inbound?.internalNotes) ? inbound.internalNotes.map((note) => note.content) : []),
]
const markerFound = markerValues.some((value) => String(value || '').includes(context.marker))
const measurable = MEASURABLE_STATUSES.has(inbound?.summary?.processStatus)
context.assert(MODULE, '隔离入库单 marker 来源与可测量状态正确', inboundResponse,
id(inbound?.summary?.id) === inboundId && id(inbound?.summary?.tenantId) === tenantId
&& markerFound && measurable,
`markerFound=${markerFound}, status=${inbound?.summary?.processStatus ?? 'missing'}`, CONTRACT_OPTIONS)
context.assert(MODULE, '入库单详情包含托盘库位、文件、备注和状态历史', inboundResponse,
Array.isArray(inbound?.palletLocations) && Array.isArray(inbound?.files)
&& Array.isArray(inbound?.internalNotes) && Array.isArray(inbound?.statusHistory),
`inboundId=${inboundId}, history=${inbound?.statusHistory?.length ?? 'n/a'}`)
const inboundListResponse = await context.request('GET',
`/api/platform/inbounds?tenantId=${encodeURIComponent(tenantId)}&inboundCode=${encodeURIComponent(inbound.summary.inboundCode)}&page=1&size=100`)
const inboundPage = context.apiSuccess(MODULE, 'Admin 查询入库单列表', inboundListResponse)
context.assert(MODULE, 'Admin 入库单列表只核对本轮入库单', inboundListResponse,
pageOk(inboundPage) && records(inboundPage).some((row) => id(row.id) === inboundId),
`inboundId=${inboundId}, total=${inboundPage?.total ?? 'n/a'}`)
context.createdResources.orders.inboundId = inboundId
context.createdResources.orders.inboundCode = inbound.summary.inboundCode
2026-08-18 16:39:39 +08:00
context.createdResources.orders.customerRemark = payload.customerRemark
context.createdResources.orders.internalRemark = `${context.marker} Admin 订单链路验收`
context.createdResources.orders.cargoRemark = payload.cargoes?.[0]?.cargoRemark || null
2026-08-08 10:11:04 +08:00
context.createdResources.orders.processStatus = inbound.summary.processStatus
context.createdResources.ocrInbound = { tenantId, inboundId, marker: context.marker }
context.createdResources.testInbound = { tenantId, inboundId, marker: context.marker }
context.context.inboundWeightOcr = { tenantId, inboundId, marker: context.marker }
context.context.ocrInbound = context.context.inboundWeightOcr
context.context.testInbound = context.context.inboundWeightOcr
context.context.createdInbound = context.context.inboundWeightOcr
2026-08-13 18:34:44 +08:00
const taskResponse = inboundResult.taskResponse
const task = inboundResult.task
context.apiSuccess(MODULE, 'Admin 查询本轮订单履约任务', taskResponse,
'审批 Outbox 收敛后应生成可查询的履约任务', CONTRACT_OPTIONS)
2026-08-08 10:11:04 +08:00
context.assert(MODULE, '履约任务与本轮订单和客户一致', taskResponse,
id(task?.orderId) === orderId && id(task?.tenantId) === tenantId,
`taskId=${id(task?.id) || 'missing'}, status=${task?.status ?? 'missing'}`, CONTRACT_OPTIONS)
if (task?.id) {
context.createdResources.orders.taskId = id(task.id)
const historyResponse = await context.request('GET',
`/api/platform/v1/first-leg/tasks/${encodeURIComponent(task.id)}/assignment-history?tenantId=${encodeURIComponent(tenantId)}&page=1&size=20`)
const history = context.apiSuccess(MODULE, 'Admin 查询本轮履约任务分配历史', historyResponse)
context.assert(MODULE, '履约分配历史分页结构正确', historyResponse, pageOk(history),
`taskId=${task.id}, total=${history?.total ?? 'n/a'}`)
}
const acceptResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/accept?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, 'Admin 受理本轮订单', acceptResponse)
const startResponse = await context.request('POST',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}/start?tenantId=${encodeURIComponent(tenantId)}`)
context.apiSuccess(MODULE, 'Admin 启动本轮订单履约', startResponse)
const finalDetailResponse = await context.request('GET', firstLegDetailPath)
const finalDetail = context.apiSuccess(MODULE, 'Admin 回查本轮订单履约状态', finalDetailResponse)
context.assert(MODULE, '本轮订单已进入处理中且保留状态历史', finalDetailResponse,
finalDetail?.order?.status === 'PROCESSING'
&& finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'APPROVED')
&& finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'ACCEPTED')
&& finalDetail?.statusHistory?.some?.((item) => item.toStatus === 'PROCESSING'),
`status=${finalDetail?.order?.status ?? 'missing'}, history=${finalDetail?.statusHistory?.length ?? 'n/a'}`)
}