323 lines
18 KiB
JavaScript
323 lines
18 KiB
JavaScript
import { findPricedOrderRouteAcrossOrigins } from './orders.mjs'
|
|
|
|
const MODULE = 'order-inbound-status-fixtures'
|
|
const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true }
|
|
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
|
|
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
|
|
|
|
const ORDER_TARGETS = [
|
|
['DRAFT', []],
|
|
['SUBMITTED', ['submit']],
|
|
['REJECTED', ['submit', 'reject']],
|
|
['APPROVED', ['submit', 'approve']],
|
|
['ACCEPTED', ['submit', 'approve', 'accept']],
|
|
['PROCESSING', ['submit', 'approve', 'accept', 'start']],
|
|
['COMPLETED', ['submit', 'approve', 'accept', 'start', 'complete']],
|
|
['CANCELLED', ['cancel']],
|
|
]
|
|
const INBOUND_FILTER_STATUSES = [
|
|
'WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_PRICING',
|
|
'FEE_CONFIRMING', 'INBOUNDING', 'WAIT_SPLIT', 'VOIDED',
|
|
]
|
|
const UNIFIED_FILTER_STATUSES = [
|
|
'PENDING_REVIEW', 'WAIT_MEASURE', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED',
|
|
]
|
|
|
|
function id(value) { return value == null ? '' : String(value) }
|
|
function rows(value) { return Array.isArray(value?.records) ? value.records : [] }
|
|
function ok(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
|
|
function data(response) { return response?.body?.data }
|
|
function key(context, operation) { return `${context.marker}-manual-${operation}`.slice(0, 128) }
|
|
function query(value) { return encodeURIComponent(String(value)) }
|
|
function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) }
|
|
|
|
async function merchantRequest(context, method, path, body, token, headers = {}) {
|
|
const requestHeaders = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...headers }
|
|
const payload = body === undefined ? undefined : JSON.stringify(body)
|
|
if (payload !== undefined) requestHeaders['Content-Type'] = 'application/json; charset=utf-8'
|
|
try {
|
|
const response = await fetch(`${context.options.gateway}${path}`, {
|
|
method, headers: requestHeaders, body: payload, signal: AbortSignal.timeout(30_000),
|
|
})
|
|
const content = await response.text()
|
|
let parsed = null
|
|
try { parsed = content ? JSON.parse(content) : null } catch { /* Keep bounded raw evidence. */ }
|
|
return { method, path, http: response.status, body: parsed, raw: parsed ? null : content.slice(0, 500),
|
|
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 merchantGet(context, path, token) {
|
|
return merchantRequest(context, 'GET', path, undefined, token)
|
|
}
|
|
|
|
async function loadFixtureInputs(context, token) {
|
|
const profileResponse = await merchantGet(context, '/api/merchant/v1/iam/tenant/profile', token)
|
|
const profile = context.apiSuccess(MODULE, '读取 IT 测试客户资料', profileResponse)
|
|
const domesticResponse = await merchantGet(context,
|
|
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC', token)
|
|
const domesticPage = context.apiSuccess(MODULE, '读取国内收货仓', domesticResponse)
|
|
const receivings = rows(domesticPage).filter((item) => item?.status === 'ACTIVE')
|
|
const destinationResponse = await merchantGet(context,
|
|
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', token)
|
|
const destinations = rows(context.apiSuccess(MODULE, '读取 FBA 目的仓', destinationResponse))
|
|
if (!profile?.id || receivings.length === 0 || destinations.length === 0) throw new Error('状态样本缺少客户或仓库基础数据')
|
|
const priced = await findPricedOrderRouteAcrossOrigins({
|
|
receivings, destinations,
|
|
loadChannels: (receiving) => merchantGet(context,
|
|
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${query(receiving.id)}`, token),
|
|
estimate: (body) => merchantRequest(context, 'POST', '/api/merchant/v1/first-leg/pricing/estimate', body, token),
|
|
})
|
|
if (!priced.receiving || !priced.priced) throw new Error('状态样本缺少真实可计价的仓库/渠道/目的仓组合')
|
|
return { tenantId: id(profile.id), customerName: profile.tenantName || 'IT 测试客户',
|
|
domestic: priced.receiving, destination: priced.priced.destination, channel: priced.priced.channel,
|
|
pricingTaxMode: priced.priced.pricingTaxMode }
|
|
}
|
|
|
|
function orderPayload(context, fixture, target) {
|
|
const sample = `${context.marker}-${target}`
|
|
const shipmentId = `MANUAL-${target}-${context.marker}`.slice(0, 64)
|
|
const referenceId = `STATUS-${target}-${context.marker}`.slice(0, 64)
|
|
const upcNo = `${String(context.marker).replace(/\D/g, '').slice(-11)}${ORDER_TARGETS.findIndex(([status]) => status === target) + 1}`
|
|
.slice(-12).padStart(12, '0')
|
|
return {
|
|
customerId: null, customerName: fixture.customerName, transportMode: 'SEA', tradeTerm: 'FOB',
|
|
destinationCountryCode: fixture.destination.countryCode || 'US',
|
|
destinationWarehouseId: fixture.destination.id, destinationWarehouseName: fixture.destination.warehouseName,
|
|
expectedShipDate: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10),
|
|
customerRemark: `${sample} 人工列表状态样本`, pickupMethod: 'PICKUP',
|
|
receivingWarehouseId: fixture.domestic.id, receivingWarehouseName: fixture.domestic.warehouseName,
|
|
channelCode: fixture.channel.channelCode, channelName: fixture.channel.channelName,
|
|
carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION',
|
|
pricingTaxMode: fixture.pricingTaxMode, containerMode: 'LCL', customsInspection: false,
|
|
destinationType: 'FBA', amazonWarehouseCode: fixture.destination.warehouseCode,
|
|
shipmentId, referenceId,
|
|
sender: { companyName: fixture.customerName, contactName: 'IT 测试联系人', phone: '13800138000',
|
|
email: 'it-test@example.com', countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区',
|
|
addressLine: `${sample} 测试地址`, postalCode: '518000' },
|
|
receiver: null,
|
|
cargoes: [{ cargoName: `${target} 状态样本货物`, cargoNameEn: `${target} fixture cargo`, hsCode: '8518210000',
|
|
customsHsCode: '8518210000', packageType: 'CARTON', packageCount: 1, grossWeightKg: 10,
|
|
volumeCbm: 0.024, lengthCm: 40, widthCm: 30, heightCm: 20, declaredValue: 100, currency: 'USD',
|
|
dangerousGoods: false, cargoRemark: sample, skuNo: `SKU-${target}-${context.marker}`.slice(0, 64),
|
|
upcNo, shipmentId, referenceId,
|
|
unitsPerPackage: 10, totalQuantity: 10, netWeightKg: 9, unitDeclaredValue: 10,
|
|
material: '塑料', usage: '人工验收', model: `MODEL-${target}`, brand: 'IT-TEST' }],
|
|
}
|
|
}
|
|
|
|
async function createOrder(context, token, fixture, target) {
|
|
const response = await merchantRequest(context, 'POST', '/api/merchant/v1/orders/first-leg',
|
|
orderPayload(context, fixture, target), token, { 'Idempotency-Key': key(context, `${target}-create`) })
|
|
const orderId = id(context.apiSuccess(MODULE, `创建 ${target} 订单状态样本`, response))
|
|
if (!orderId) throw new Error(`${target} 状态样本未返回订单 ID`)
|
|
return orderId
|
|
}
|
|
|
|
async function transition(context, token, fixture, orderId, target, action) {
|
|
const merchantPath = `/api/merchant/v1/orders/first-leg/${query(orderId)}`
|
|
if (action === 'submit') {
|
|
const response = await merchantRequest(context, 'POST', `${merchantPath}/submit`, undefined, token,
|
|
{ 'Idempotency-Key': key(context, `${target}-submit`) })
|
|
context.apiSuccess(MODULE, `${target} 样本提交订单`, response)
|
|
return
|
|
}
|
|
const platformPath = `/api/platform/v1/orders/first-leg/${query(orderId)}/${action}?tenantId=${query(fixture.tenantId)}`
|
|
const body = action === 'reject' || action === 'cancel' ? { reason: `${context.marker} 保留 ${target} 人工样本` } : undefined
|
|
const response = await context.request('POST', platformPath, body)
|
|
context.apiSuccess(MODULE, `${target} 样本执行 ${action}`, response)
|
|
}
|
|
|
|
async function readOrder(context, fixture, orderId) {
|
|
const response = await context.request('GET',
|
|
`/api/platform/v1/orders/first-leg/${query(orderId)}?tenantId=${query(fixture.tenantId)}`)
|
|
return { response, detail: context.apiSuccess(MODULE, '回读订单状态样本', response) }
|
|
}
|
|
|
|
async function waitForInbound(context, fixture, orderId) {
|
|
const path = `/api/platform/v1/orders/${query(orderId)}?tenantId=${query(fixture.tenantId)}`
|
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
const response = await context.request('GET', path)
|
|
const summary = data(response)?.summary
|
|
if (ok(response) && summary?.inboundId
|
|
&& ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_SPLIT'].includes(summary.mainStatus)) {
|
|
return { response, summary }
|
|
}
|
|
if (attempt < 19) await wait(500)
|
|
}
|
|
return { response: null, summary: null }
|
|
}
|
|
|
|
async function prepareInboundStatus(context, target) {
|
|
const originalMarker = context.marker
|
|
const originalRequest = context.request
|
|
const fixtureMarker = `${originalMarker}-IN-${target}`
|
|
const stop = Symbol(target)
|
|
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement', 'inboundWarehouseResources']
|
|
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
|
|
const createdSnapshot = new Map(createdKeys.map((name) => [name,
|
|
{ present: Object.hasOwn(context.createdResources, name), value: context.createdResources[name] }]))
|
|
const contextSnapshot = new Map(contextKeys.map((name) => [name,
|
|
{ present: Object.hasOwn(context.context, name), value: context.context[name] }]))
|
|
try {
|
|
context.marker = fixtureMarker
|
|
const orders = await import(new URL('./orders.mjs', import.meta.url))
|
|
await orders.run(context)
|
|
const order = { ...context.createdResources.orders }
|
|
const inboundId = id(order.inboundId)
|
|
const tenantId = id(order.tenantId)
|
|
if (!inboundId || !tenantId) throw new Error(`${target} 未生成独立待测量入库单`)
|
|
const detailPath = `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}`
|
|
|
|
if (target === 'MEASURING') {
|
|
const weightOcr = await import(new URL('./inbound-weight-ocr.mjs', import.meta.url))
|
|
await weightOcr.run(context)
|
|
}
|
|
|
|
context.request = async (method, path, body, token, headers) => {
|
|
const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`)
|
|
if (target === 'MEASURE_PENDING' && isSubmit) {
|
|
const response = await originalRequest.call(context, method, path, body, token, headers)
|
|
if (!ok(response)) throw new Error(`MEASURE_PENDING 提交失败: HTTP=${response?.http}, code=${response?.body?.code}`)
|
|
throw stop
|
|
}
|
|
return originalRequest.call(context, method, path, body, token, headers)
|
|
}
|
|
if (target !== 'MEASURING') {
|
|
try {
|
|
const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url))
|
|
await measurement.run(context)
|
|
} catch (error) {
|
|
if (error !== stop) throw error
|
|
} finally {
|
|
context.request = originalRequest
|
|
}
|
|
}
|
|
|
|
const response = await originalRequest.call(context, 'GET',
|
|
`/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}`)
|
|
const detail = context.apiSuccess(MODULE, `回读 ${target} 独立入库单`, response, '', OPTIONS)
|
|
context.assert(MODULE, `${target} 入库状态样本可从详情打开`, response,
|
|
detail?.summary?.processStatus === target, `inboundId=${inboundId}, actual=${detail?.summary?.processStatus || 'missing'}`, OPTIONS)
|
|
return { inboundId, orderId: id(order.orderId), tenantId, status: target,
|
|
inboundCode: detail?.summary?.inboundCode || null }
|
|
} finally {
|
|
context.request = originalRequest
|
|
context.marker = originalMarker
|
|
for (const [name, snapshot] of createdSnapshot) {
|
|
if (snapshot.present) context.createdResources[name] = snapshot.value
|
|
else Reflect.deleteProperty(context.createdResources, name)
|
|
}
|
|
for (const [name, snapshot] of contextSnapshot) {
|
|
if (snapshot.present) context.context[name] = snapshot.value
|
|
else Reflect.deleteProperty(context.context, name)
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function run(context, matrix = context.createdResources.manualStateMatrix) {
|
|
if (!matrix) {
|
|
matrix = { marker: context.marker, generatedAt: new Date().toISOString(), pages: {} }
|
|
context.createdResources.manualStateMatrix = matrix
|
|
}
|
|
matrix.pages ||= {}
|
|
const loginResponse = await context.request('POST', '/api/auth/login',
|
|
{ username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD }, '')
|
|
const token = context.apiSuccess(MODULE, 'IT 测试客户管理员登录', loginResponse)?.accessToken
|
|
if (!token) throw new Error('客户管理员登录未返回 accessToken')
|
|
const fixture = await loadFixtureInputs(context, token)
|
|
const result = { marker: context.marker, tenantId: fixture.tenantId, orders: {}, inbounds: {}, unifiedOrders: {} }
|
|
matrix.pages.firstLegOrders = { requiredStatuses: ORDER_TARGETS.map(([status]) => status), fixtures: {} }
|
|
matrix.pages.inbounds = { requiredStatuses: INBOUND_FILTER_STATUSES, fixtures: {} }
|
|
matrix.pages.unifiedOrders = { requiredStatuses: UNIFIED_FILTER_STATUSES, fixtures: {} }
|
|
|
|
for (const [target, actions] of ORDER_TARGETS) {
|
|
const orderId = await createOrder(context, token, fixture, target)
|
|
for (const action of actions) await transition(context, token, fixture, orderId, target, action)
|
|
const { response, detail } = await readOrder(context, fixture, orderId)
|
|
context.assert(MODULE, `${target} 订单状态样本可从详情打开`, response,
|
|
id(detail?.order?.id) === orderId && detail?.order?.status === target,
|
|
`orderId=${orderId}, status=${detail?.order?.status ?? 'missing'}`)
|
|
result.orders[target] = { orderId, orderNo: detail?.order?.orderNo || null, status: target,
|
|
listPath: `/admin/first-leg-orders?status=${target}` }
|
|
matrix.pages.firstLegOrders.fixtures[target] = {
|
|
id: orderId, status: target, detailPath: `/orders/${orderId}`,
|
|
}
|
|
|
|
if (actions.includes('approve')) {
|
|
const inbound = await waitForInbound(context, fixture, orderId)
|
|
const inboundId = id(inbound.summary?.inboundId)
|
|
context.assert(MODULE, `${target} 订单生成待测量入库单`, inbound.response, Boolean(inboundId),
|
|
`orderId=${orderId}, inboundId=${inboundId || 'missing'}`)
|
|
if (inboundId && !result.inbounds.WAIT_MEASURE) result.inbounds.WAIT_MEASURE = {
|
|
inboundId, inboundCode: inbound.summary?.inboundCode || null, orderId, processStatus: 'WAIT_MEASURE',
|
|
listPath: '/admin/inbounds?processStatus=WAIT_MEASURE',
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [status, sample] of Object.entries(result.orders)) {
|
|
const response = await context.request('GET',
|
|
`/api/platform/v1/orders/first-leg?tenantId=${query(fixture.tenantId)}&status=${status}&keyword=${query(sample.orderNo)}&page=1&size=20`)
|
|
const page = context.apiSuccess(MODULE, `${status} 订单可按列表状态筛选`, response)
|
|
context.assert(MODULE, `${status} 筛选结果包含人工样本`, response,
|
|
rows(page).some((item) => id(item.id) === sample.orderId), `orderId=${sample.orderId}, total=${page?.total ?? 'n/a'}`)
|
|
}
|
|
|
|
const waitMeasure = result.inbounds.WAIT_MEASURE
|
|
if (waitMeasure) matrix.pages.inbounds.fixtures.WAIT_MEASURE = {
|
|
id: waitMeasure.inboundId, status: 'WAIT_MEASURE', detailPath: `/orders/${waitMeasure.orderId}`,
|
|
}
|
|
|
|
const submitted = result.orders.SUBMITTED
|
|
result.unifiedOrders.PENDING_REVIEW = { orderId: submitted.orderId, orderNo: submitted.orderNo,
|
|
mainStatus: 'PENDING_REVIEW', listPath: '/admin/unified-orders?mainStatus=PENDING_REVIEW' }
|
|
matrix.pages.unifiedOrders.fixtures.PENDING_REVIEW = {
|
|
id: submitted.orderId, status: 'PENDING_REVIEW', detailPath: `/orders/${submitted.orderId}`,
|
|
}
|
|
if (waitMeasure) result.unifiedOrders.WAIT_MEASURE = { orderId: waitMeasure.orderId,
|
|
inboundId: waitMeasure.inboundId, mainStatus: 'WAIT_MEASURE', listPath: '/admin/unified-orders?mainStatus=WAIT_MEASURE' }
|
|
if (waitMeasure) matrix.pages.unifiedOrders.fixtures.WAIT_MEASURE = {
|
|
id: waitMeasure.orderId, status: 'WAIT_MEASURE', detailPath: `/orders/${waitMeasure.orderId}`,
|
|
}
|
|
|
|
for (const status of ['MEASURING', 'MEASURE_PENDING', 'WAIT_SPLIT']) {
|
|
const sample = await prepareInboundStatus(context, status)
|
|
result.inbounds[status] = sample
|
|
matrix.pages.inbounds.fixtures[status] = {
|
|
id: sample.inboundId, status, detailPath: `/orders/${sample.orderId}`,
|
|
}
|
|
if (status === 'WAIT_SPLIT') {
|
|
result.unifiedOrders.WAIT_SPLIT = sample
|
|
matrix.pages.unifiedOrders.fixtures.WAIT_SPLIT = {
|
|
id: sample.orderId, status, detailPath: `/orders/${sample.orderId}`,
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!result.inbounds.WAIT_SPLIT) {
|
|
const legacyResponse = await context.request('GET',
|
|
`/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`)
|
|
const legacyPage = context.apiSuccess(MODULE, '查询存量 WAIT_SPLIT 样本', legacyResponse, '', OPTIONS)
|
|
const legacyInbound = rows(legacyPage)[0]
|
|
if (legacyInbound?.id && legacyInbound?.orderId) {
|
|
const sample = { inboundId: id(legacyInbound.id), orderId: id(legacyInbound.orderId), tenantId: fixture.tenantId,
|
|
status: 'WAIT_SPLIT', inboundCode: legacyInbound.inboundCode || null }
|
|
result.inbounds.WAIT_SPLIT = sample
|
|
result.unifiedOrders.WAIT_SPLIT = sample
|
|
matrix.pages.inbounds.fixtures.WAIT_SPLIT = {
|
|
id: sample.inboundId, status: 'WAIT_SPLIT', detailPath: `/orders/${sample.orderId}`,
|
|
}
|
|
matrix.pages.unifiedOrders.fixtures.WAIT_SPLIT = {
|
|
id: sample.orderId, status: 'WAIT_SPLIT', detailPath: `/orders/${sample.orderId}`,
|
|
}
|
|
}
|
|
}
|
|
|
|
context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true,
|
|
`orders=${Object.keys(result.orders).length}, inbounds=${Object.keys(result.inbounds).length}`, OPTIONS)
|
|
}
|