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) }