Files
baiyue-firstleg/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs
T
2026-08-15 11:51:15 +08:00

360 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 || 'merchant_admin'
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!'
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)) }
function recordCoverageGap(context, name, reason, category = 'fixture-unavailable') {
context.addCoverageGap?.(MODULE, OPTIONS.scenario, name, reason, category, { marker: context.marker })
context.addStep(MODULE, `${name}(coverage gap 已登记)`, null, true, reason, {
scenario: OPTIONS.scenario, deploymentBlocker: false,
})
}
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
}
if (target === 'MEASURE_REJECTED' && isSubmit) {
const submitted = await originalRequest.call(context, method, path, body, token, headers)
if (!ok(submitted)) throw new Error(`MEASURE_REJECTED 提交失败: HTTP=${submitted?.http}, code=${submitted?.body?.code}`)
const latest = await originalRequest.call(context, 'GET', detailPath, undefined, token)
const version = Number(data(latest)?.summary?.version)
const rejectPath = `/api/platform/v1/inbounds/${query(inboundId)}/measurements/reject?tenantId=${query(tenantId)}`
const rejected = await originalRequest.call(context, 'POST', rejectPath,
{ measurementId: body?.measurementId, expectedVersion: version, reason: `${fixtureMarker} 保留审核驳回样本` },
token, { 'Idempotency-Key': `${fixtureMarker}-reject`.slice(0, 128) })
if (!ok(rejected)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${rejected?.http}, code=${rejected?.body?.code}, requestId=${rejected?.requestId || 'missing'}, traceId=${rejected?.traceId || 'missing'}`)
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', 'MEASURE_REJECTED', '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}`,
}
}
}
const blockers = {
WAIT_PRICING: '审核通过先进入 WAIT_PRICING,但自动计费与入库编排会继续推进,不能稳定停留',
FEE_CONFIRMING: '需要先取得可匹配的真实计价快照并停在费用确认前',
INBOUNDING: 'AutomaticInboundCompletionService 在同一调用内立即从 INBOUNDING 推进 WAIT_SPLIT,无可暂停 API',
VOIDED: 'InboundProcessStatus 声明了 VOIDED,但当前 Controller/Service 没有作废入库单 API',
}
for (const status of INBOUND_FILTER_STATUSES) if (!matrix.pages.inbounds.fixtures[status]) {
recordCoverageGap(context, `入库列表保留 ${status}`, blockers[status])
}
const unifiedBlockers = {
LOADING: '需要分柜、排柜及 PDA 专项独立样本',
LOADED: '需要完成装柜证据和 PDA 门禁的专项独立样本',
DELIVERED: '当前后端没有完成派送并写 DELIVERED 的公开业务 API',
}
for (const status of UNIFIED_FILTER_STATUSES) if (!matrix.pages.unifiedOrders.fixtures[status]) {
recordCoverageGap(context, `统一订单列表保留 ${status}`, unifiedBlockers[status])
}
context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true,
`orders=${Object.keys(result.orders).length}, inbounds=${Object.keys(result.inbounds).length}`, OPTIONS)
}