Files
baiyue-firstleg/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs
T
ouyangxq 25ffd62e14 Merge remote-tracking branch 'origin/test' into dev
# Conflicts:
#	firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java
#	firstleg/src/test/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceServiceTest.java
#	scripts/admin-test-env-modules/ocean-bookings.mjs
#	scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs
#	scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs
#	scripts/admin-test-env-modules/phase3-container-loading.mjs
#	scripts/admin-test-env-modules/phase3-customs.mjs
#	scripts/admin-test-env-modules/phase3-loading-finalize.mjs
#	scripts/admin-test-env-modules/phase3-loading.mjs
#	scripts/admin-test-env-modules/phase3-port-schedules.mjs
#	scripts/admin-test-env-modules/service-center-flow.mjs
2026-09-02 14:00:44 +08:00

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: 'SELF_EXPRESS',
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, woodenProduct: 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',
{ loginId: 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)
}