This commit is contained in:
2026-08-14 12:50:37 +08:00
parent 91e781b3ff
commit ce6963a25e
46 changed files with 664 additions and 76 deletions
+17
View File
@@ -16,6 +16,23 @@ const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'over
export function normalizeModuleOrder(modules) {
const normalized = [...modules]
const manualMatrixIndex = normalized.indexOf('manual-state-matrix')
if (manualMatrixIndex >= 0) {
const internalGroupIndex = normalized.indexOf('order-inbound-status-fixtures')
if (internalGroupIndex >= 0) normalized.splice(internalGroupIndex, 1)
const prerequisites = [
'merchant-qualification-fixture',
'phase3-container-loading',
'phase3-loading',
'phase3-customs',
'phase3-loading-finalize',
]
for (const prerequisite of prerequisites) {
if (!normalized.includes(prerequisite)) {
normalized.splice(normalized.indexOf('manual-state-matrix'), 0, prerequisite)
}
}
}
if (normalized.includes('fee-items')) {
const prerequisites = ['merchant-qualification-fixture', 'orders']
const feeItemsIndex = normalized.indexOf('fee-items')
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { missingRequiredStateFixtures, normalizeStateRequirements } from './manual-state-matrix.mjs'
import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs'
const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8')
const source = readFileSync(new URL('./manual-state-matrix.mjs', import.meta.url), 'utf8')
@@ -11,14 +13,77 @@ test('default full acceptance includes the retained manual state matrix after li
assert.ok(entry.indexOf("'manual-state-matrix'") < entry.indexOf("'finance-full-fixtures'"))
})
test('focused matrix execution injects real container lifecycle prerequisites and removes its internal group', () => {
const expected = [
'merchant-qualification-fixture',
'phase3-container-loading',
'phase3-loading',
'phase3-customs',
'phase3-loading-finalize',
'manual-state-matrix',
]
assert.deepEqual(normalizeModuleOrder(['order-inbound-status-fixtures', 'manual-state-matrix']), expected)
assert.deepEqual(parseArguments([
'--modules', 'order-inbound-status-fixtures,manual-state-matrix',
]).modules, expected)
})
test('matrix delegates bounded groups and asserts every declared status has a retained id', () => {
for (const group of ['order-inbound-status-fixtures', 'manual-state-loading-pda',
'manual-state-ocean-trucking']) assert.match(source, new RegExp(group))
assert.doesNotMatch(source, /manual-state-customs-dispatch/)
assert.match(source, /createdResources\.manualStateMatrix/)
assert.match(source, /supportedStatuses/)
assert.match(source, /requiredStatuses/)
assert.match(source, /fixtures\?\.\[status\]\?\.id/)
assert.match(source, /requiredActions/)
assert.match(source, /actions\?\.\[action\]\?\.documentId/)
assert.match(source, /actions\?\.\[action\]\?\.completedAt/)
})
test('fixture-unavailable statuses stay supported without becoming hard requirements', () => {
const matrix = { pages: {
inbounds: { requiredStatuses: ['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'], fixtures: {
WAIT_MEASURE: { id: '1' },
} },
unifiedOrders: { requiredStatuses: ['WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED'], fixtures: {
WAIT_SPLIT: { id: '2' }, LOADING: { id: '3' }, LOADED: { id: '4' },
} },
} }
normalizeStateRequirements(matrix)
assert.deepEqual(matrix.pages.inbounds.supportedStatuses,
['WAIT_MEASURE', 'WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED'])
assert.deepEqual(matrix.pages.inbounds.requiredStatuses, ['WAIT_MEASURE'])
assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'LOADING', 'LOADED'])
assert.equal(matrix.pages.inbounds.coverageGaps.WAIT_PRICING.category, 'fixture-unavailable')
assert.equal(matrix.pages.unifiedOrders.coverageGaps.DELIVERED.category, 'fixture-unavailable')
assert.deepEqual(missingRequiredStateFixtures(matrix), [])
})
test('unknown missing status is never swallowed by the known coverage-gap allowlist', () => {
const matrix = { pages: {
unifiedOrders: { supportedStatuses: ['WAIT_SPLIT', 'UNKNOWN_FUTURE_STATUS'], requiredStatuses: [], fixtures: {
WAIT_SPLIT: { id: '1' },
} },
} }
normalizeStateRequirements(matrix)
assert.deepEqual(matrix.pages.unifiedOrders.requiredStatuses, ['WAIT_SPLIT', 'UNKNOWN_FUTURE_STATUS'])
assert.deepEqual(missingRequiredStateFixtures(matrix), ['unifiedOrders:UNKNOWN_FUTURE_STATUS'])
})
test('LOADING and LOADED fixtures are resolved through inbound and unified-order details', () => {
assert.match(source, /attachUnifiedOrderFixture\(context, matrix, 'phase3ContainerLoading', 'LOADING'\)/)
assert.match(source, /attachUnifiedOrderFixture\(context, matrix, 'phase3Loading', 'LOADED'\)/)
assert.match(source, /\/api\/platform\/inbounds\/\$\{encodeURIComponent\(inboundId\)\}/)
assert.match(source, /id\(inbound\?\.summary\?\.id\) === inboundId/)
assert.match(source, /inbound\?\.summary\?\.processStatus === expectedStatus/)
assert.match(source, /\/api\/platform\/v1\/orders\/\$\{encodeURIComponent\(orderId\)\}/)
assert.match(source, /unified\?\.summary\?\.mainStatus === expectedStatus/)
assert.match(source, /matrix\.pages\.unifiedOrders\.fixtures\[expectedStatus\]/)
assert.match(source, /clearResolvedProvisionalGap\(context, expectedStatus\)/)
assert.match(source, /gap\?\.name === `统一订单列表保留 \$\{status\}`/)
})
@@ -7,7 +7,100 @@ const GROUPS = [
'manual-state-ocean-trucking',
]
const FIXTURE_UNAVAILABLE = {
inbounds: {
WAIT_PRICING: '自动计费会继续推进,无法稳定保留 WAIT_PRICING',
FEE_CONFIRMING: '当前无可稳定保留的费用确认前公开生成链路',
INBOUNDING: '自动入库在同一调用内继续推进 WAIT_SPLIT,无可暂停 API',
VOIDED: '当前 Controller/Service 没有作废入库单的公开 API',
},
unifiedOrders: {
DELIVERED: '当前后端没有完成派送并写入 DELIVERED 的公开业务 API',
},
}
function rows(value) { return Array.isArray(value) ? value : [] }
function id(value) { return value == null ? '' : String(value) }
export function normalizeStateRequirements(matrix) {
for (const [page, value] of Object.entries(matrix?.pages || {})) {
const supportedStatuses = [...new Set([
...rows(value?.supportedStatuses), ...rows(value?.requiredStatuses),
])]
const unavailable = FIXTURE_UNAVAILABLE[page] || {}
value.supportedStatuses = supportedStatuses
value.requiredStatuses = supportedStatuses.filter((status) => !unavailable[status])
if (Object.keys(unavailable).length > 0) {
value.coverageGaps = Object.fromEntries(Object.entries(unavailable).map(([status, reason]) => [status, {
category: 'fixture-unavailable', reason,
}]))
}
}
return matrix
}
export function missingRequiredStateFixtures(matrix) {
return Object.entries(matrix?.pages || {}).flatMap(([page, value]) => rows(value?.requiredStatuses)
.filter((status) => !value?.fixtures?.[status]?.id)
.map((status) => `${page}:${status}`))
}
function registerFixtureUnavailableGaps(context, matrix) {
for (const [page, value] of Object.entries(matrix?.pages || {})) {
for (const [status, gap] of Object.entries(value?.coverageGaps || {})) {
if (value?.fixtures?.[status]?.id) continue
if (rows(context.coverageGaps).some((item) => item?.category === gap.category
&& String(item?.name || '').includes(status))) continue
context.addCoverageGap?.(MODULE, OPTIONS.scenario, `${page}:${status}`, gap.reason,
gap.category, { marker: context.marker, page, status })
}
}
}
function clearResolvedProvisionalGap(context, status) {
if (!Array.isArray(context.coverageGaps)) return
for (let index = context.coverageGaps.length - 1; index >= 0; index -= 1) {
const gap = context.coverageGaps[index]
if (gap?.module === 'order-inbound-status-fixtures'
&& gap?.scenario === 'manual-order-inbound-status-matrix'
&& gap?.category === 'fixture-unavailable'
&& gap?.name === `统一订单列表保留 ${status}`) context.coverageGaps.splice(index, 1)
}
}
async function attachUnifiedOrderFixture(context, matrix, resourceName, expectedStatus) {
const resource = context.createdResources?.[resourceName]
const inboundId = id(resource?.inboundId)
const tenantId = id(resource?.tenantId)
context.assert(MODULE, `${expectedStatus} 专项资源已生成`, null,
Boolean(inboundId && tenantId),
`resource=${resourceName}, inboundId=${inboundId || 'missing'}, tenantId=${tenantId || 'missing'}`, OPTIONS)
if (!inboundId || !tenantId) return
const inboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(inboundId)}?tenantId=${encodeURIComponent(tenantId)}`)
const inbound = context.apiSuccess(MODULE, `读取 ${expectedStatus} 专项入库详情`, inboundResponse, '', OPTIONS)
const orderId = id(inbound?.summary?.orderId)
context.assert(MODULE, `${expectedStatus} 入库详情身份与状态正确`, inboundResponse,
id(inbound?.summary?.id) === inboundId && id(inbound?.summary?.tenantId) === tenantId
&& Boolean(orderId) && inbound?.summary?.processStatus === expectedStatus,
`inboundId=${id(inbound?.summary?.id) || 'missing'}, orderId=${orderId || 'missing'}, status=${inbound?.summary?.processStatus || 'missing'}`, OPTIONS)
if (!orderId || inbound?.summary?.processStatus !== expectedStatus) return
const unifiedResponse = await context.request('GET',
`/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`)
const unified = context.apiSuccess(MODULE, `读取 ${expectedStatus} 统一订单详情`, unifiedResponse, '', OPTIONS)
const valid = id(unified?.summary?.orderId) === orderId && unified?.summary?.mainStatus === expectedStatus
context.assert(MODULE, `${expectedStatus} 统一订单详情可打开`, unifiedResponse, valid,
`orderId=${id(unified?.summary?.orderId) || 'missing'}, mainStatus=${unified?.summary?.mainStatus || 'missing'}`, OPTIONS)
if (!valid) return
matrix.pages.unifiedOrders.fixtures[expectedStatus] = {
id: orderId, status: expectedStatus, inboundId,
detailPath: `/orders/${orderId}`,
}
clearResolvedProvisionalGap(context, expectedStatus)
}
export async function run(context) {
const matrix = { marker: context.marker, generatedAt: new Date().toISOString(), pages: {} }
@@ -19,10 +112,13 @@ export async function run(context) {
await extension.run(context, matrix)
}
await attachUnifiedOrderFixture(context, matrix, 'phase3ContainerLoading', 'LOADING')
await attachUnifiedOrderFixture(context, matrix, 'phase3Loading', 'LOADED')
normalizeStateRequirements(matrix)
registerFixtureUnavailableGaps(context, matrix)
const pages = Object.entries(matrix.pages)
const missing = pages.flatMap(([page, value]) => rows(value?.requiredStatuses)
.filter((status) => !value?.fixtures?.[status]?.id)
.map((status) => `${page}:${status}`))
const missing = missingRequiredStateFixtures(matrix)
const missingActions = pages.flatMap(([page, value]) => rows(value?.requiredActions)
.filter((action) => !value?.actions?.[action]?.id
|| !value?.actions?.[action]?.documentId || !value?.actions?.[action]?.completedAt)
@@ -11,6 +11,14 @@ test('retains every ocean booking and truck appointment list status through real
'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED',
'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`))
assert.match(source, /change-request/)
const draftPayload = source.slice(source.indexOf('function oceanPayload'), source.indexOf('async function createOcean'))
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.doesNotMatch(draftPayload, new RegExp(`${field}:`))
}
const lifecycle = source.slice(source.indexOf('async function buildOcean'), source.indexOf('function truckPayload'))
for (const field of ['billOfLadingNo', 'vesselName', 'voyageNo', 'estimatedSailingAt', 'estimatedArrivalAt']) {
assert.match(lifecycle, new RegExp(`${field}:`))
}
assert.match(source, /\/reschedule\?plannedLoadingAt=/)
assert.match(source, /page=1&size=100&status=/)
assert.match(source, /customerId: oceanBooking\.customerId/)
@@ -17,18 +17,17 @@ async function post(context, path, body, operation) {
}
function oceanPayload(context, template, index) {
const value = suffix(context, index); const source = template.oceanBooking
const source = template.oceanBooking
return { expectedVersion: 0, data: {
customerId: source.customerId, customerName: source.customerName,
channelCode: source.channelCode, channelName: source.channelName,
warehouseId: source.warehouseId, warehouseName: source.warehouseName,
billOfLadingNo: `MAN-BL-${value}`, shippingCompanyId: source.shippingCompanyId,
shippingCompanyId: source.shippingCompanyId,
shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId,
bookingAgentName: source.bookingAgentName, vesselName: `人工验收轮-${value}`, voyageNo: `V${value}`,
bookingAgentName: source.bookingAgentName,
departurePortCode: source.departurePortCode, departurePortName: source.departurePortName,
destinationPortCode: source.destinationPortCode, destinationPortName: source.destinationPortName,
placeOfDelivery: source.placeOfDelivery, serviceTerm: source.serviceTerm, freightTerm: source.freightTerm,
estimatedSailingAt: '2026-09-15T08:00:00', estimatedArrivalAt: '2026-10-15T08:00:00',
remark: `${context.marker} 人工列表状态 ${index}`,
parties: template.parties.map(({ partyType, partyName, address, contactName, contactPhone }) =>
({ partyType, partyName, address, contactName, contactPhone })),
@@ -45,7 +45,11 @@ test('FBA 状态样本为订单头和每条货物行提供四个必填标识', (
test('测量状态截停发生在正确的 HTTP 边界', () => {
assert.match(source, /includes\(summary\.mainStatus\)/)
assert.doesNotMatch(source, /includes\(summary\.processStatus\)/)
assert.match(source, /target === 'MEASURING' && ok\(response\) && data\(response\)\?\.measurementId/)
assert.match(source, /target === 'MEASURING'/)
assert.match(source, /\.\/inbound-weight-ocr\.mjs/)
assert.match(source, /await weightOcr\.run\(context\)/)
assert.match(source, /target !== 'MEASURING'/)
assert.doesNotMatch(source, /target === 'MEASURING' && ok\(response\)/)
assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/)
assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/)
assert.match(source, /measurements\/reject/)
@@ -174,14 +174,13 @@ async function prepareInboundStatus(context, target) {
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`)
const isMeasurementQuery = method === 'GET' && path.includes(`/inbounds/${inboundId}/measurement?`)
if (isMeasurementQuery) {
const response = await originalRequest.call(context, method, path, body, token, headers)
if (target === 'MEASURING' && ok(response) && data(response)?.measurementId) throw stop
return response
}
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}`)
@@ -201,13 +200,15 @@ async function prepareInboundStatus(context, target) {
}
return originalRequest.call(context, method, path, body, token, headers)
}
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
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',
@@ -232,6 +233,11 @@ async function prepareInboundStatus(context, target) {
}
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