This commit is contained in:
2026-08-12 20:36:11 +08:00
parent 0380c0553b
commit 0ed1531683
26 changed files with 1690 additions and 15 deletions
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./manual-state-loading-pda.mjs', import.meta.url), 'utf8')
test('retains all clickable container, loading-list and PDA workflow states through public APIs', () => {
for (const status of ['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED',
'UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW']) assert.match(source, new RegExp(`['"]${status}['"]`))
for (const endpoint of ['/ocean-bookings', '/smart-loading', '/push-preload', '/inventory-snapshot',
'/batch', '/complete', '/loading-plans', '/loading-lists']) assert.match(source, new RegExp(endpoint))
assert.match(source, /requiredStatuses/)
assert.match(source, /detailPath/)
assert.match(source, /loading-plans\/\$\{fixture\.id\}/)
assert.match(source, /loading-lists\/\$\{listFixture\.id\}/)
assert.match(source, /prepareInbound/)
assert.match(source, /\.\/orders\.mjs/)
assert.match(source, /\.\/inbound-measurement\.mjs/)
})
test('does not mutate historical fixtures or remove retained records', () => {
assert.doesNotMatch(source, /\bDELETE\b/)
assert.doesNotMatch(source, /createdResources\?\.phase3ContainerLoading/)
assert.doesNotMatch(source, /orchestrationFixtures\?\./)
assert.match(source, /fixtureMarker/)
assert.match(source, /context\.marker = originalMarker/)
})
@@ -0,0 +1,286 @@
const MODULE = 'manual-state-loading-pda'
const OPTIONS = { scenario: 'manual-loading-pda-state-matrix', deploymentBlocker: true }
const CONTAINERS = '/api/platform/v1/containers'
const SMART = '/api/platform/v1/first-leg/smart-loading'
const LOADING = '/api/platform/v1/first-leg'
const PDA = `${LOADING}/pda-loading`
const CONTAINER_STATUSES = ['WAIT_LOADING', 'EDITING', 'LOADING', 'COMPLETED']
const PRELOAD_STATUSES = ['EDITING', 'LOADING']
const PDA_STATUSES = ['UNASSIGNED', 'IN_PROGRESS', 'AWAITING_REVIEW', 'COMPLETED']
function id(value) { return value == null ? '' : String(value) }
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
function suffix(context, label) {
return `${String(context.marker).replace(/[^A-Za-z0-9]/g, '').slice(-10)}${label}`.slice(-20)
}
function operationId(context, label) {
return `${context.marker}-manual-loading-${label}-${crypto.randomUUID()}`.slice(0, 128)
}
async function post(context, path, body, label) {
return context.request('POST', path, body, context.token,
{ 'Idempotency-Key': operationId(context, label) })
}
async function prepareInbound(context, label) {
const originalMarker = context.marker
const fixtureMarker = `${originalMarker}-MAN-${label}`
const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement', 'inboundWarehouseResources']
const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']
const createdSnapshot = new Map(createdKeys.map((key) => [key,
{ present: Object.hasOwn(context.createdResources, key), value: context.createdResources[key] }]))
const contextSnapshot = new Map(contextKeys.map((key) => [key,
{ present: Object.hasOwn(context.context, key), value: context.context[key] }]))
try {
context.marker = fixtureMarker
const orders = await import(new URL('./orders.mjs', import.meta.url))
await orders.run(context)
const measurement = await import(new URL('./inbound-measurement.mjs', import.meta.url))
await measurement.run(context)
const measured = context.createdResources.inboundMeasurement
const order = context.createdResources.orders
if (!measured?.inboundId || !measured?.tenantId) throw new Error(`${label} 未生成独立 INBOUNDED 入库单`)
return { marker: fixtureMarker, tenantId: id(measured.tenantId), inboundId: id(measured.inboundId),
orderId: id(order?.orderId) }
} finally {
context.marker = originalMarker
for (const [key, snapshot] of createdSnapshot) {
if (snapshot.present) context.createdResources[key] = snapshot.value
else delete context.createdResources[key]
}
for (const [key, snapshot] of contextSnapshot) {
if (snapshot.present) context.context[key] = snapshot.value
else delete context.context[key]
}
}
}
async function readInbound(context, fixture) {
const response = await context.request('GET',
`/api/platform/inbounds/${fixture.inboundId}?tenantId=${encodeURIComponent(fixture.tenantId)}`)
const value = context.apiSuccess(MODULE, '读取人工装柜独立入库单', response, '', OPTIONS)
const warehouseId = id(value?.summary?.warehouseId)
context.assert(MODULE, '人工装柜来源入库单可独立排柜', response,
value?.summary?.processStatus === 'INBOUNDED' && value?.summary?.measureReviewStatus === 'APPROVED'
&& Boolean(warehouseId),
`inboundId=${fixture.inboundId}, status=${value?.summary?.processStatus || 'missing'}`, OPTIONS)
return { ...fixture, detail: value, warehouseId }
}
async function createWaitingContainer(context, warehouseId, label) {
const value = suffix(context, label)
const response = await context.request('POST', CONTAINERS, {
containerNo: null, cabinetNo: `MAN-CAB-${value}`, warehouseId, warehouseName: '人工验收仓',
billOfLadingNo: null, vesselName: null, voyageNo: null, containerType: '40HQ',
shippingCompanyId: null, shippingCompanyName: null, departurePortCode: 'CNSZX',
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
destinationWarehouseCode: null, destinationWarehouseName: null, capacityCbm: 68,
maxWeightKg: 28000, terminalTruckingCompany: null, loadingAt: null,
estimatedSailingAt: null, actualSailingAt: null, estimatedPortArrivalAt: null,
actualPortArrivalAt: null, estimatedWarehouseArrivalAt: null, actualWarehouseArrivalAt: null,
})
const container = context.apiSuccess(MODULE, `创建 ${label} 货柜`, response, '', OPTIONS)
if (!container?.id) throw new Error(`${label} 货柜创建失败`)
return container
}
async function createConfirmedContainer(context, source, label) {
const value = suffix(context, label)
const summary = source.detail.summary
const draft = {
customerId: source.tenantId, customerName: summary.customerName || 'IT 测试客户',
channelCode: summary.channelCode, channelName: summary.channelName, warehouseId: source.warehouseId,
warehouseName: summary.warehouseName, shippingCompanyId: null, shippingCompanyName: `人工船司-${value}`,
bookingAgentId: null, bookingAgentName: `人工代理-${value}`, departurePortCode: 'CNSZX',
departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶',
placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: `${context.marker}-${label}`,
parties: [{ partyType: 'SHIPPER', partyName: `发货方-${value}` },
{ partyType: 'CONSIGNEE', partyName: `收货方-${value}` },
{ partyType: 'NOTIFY_PARTY', partyName: `通知方-${value}` }],
containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68,
maxWeightKg: 28000, quantity: 1 }],
}
const createdResponse = await post(context, '/api/platform/v1/ocean-bookings',
{ expectedVersion: 0, data: draft }, `${label}-booking-create`)
const created = context.apiSuccess(MODULE, `${label} 创建订舱`, createdResponse, '', OPTIONS)
const bookingId = id(created?.oceanBooking?.id)
const submittedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/submit`,
{ expectedVersion: Number(created?.oceanBooking?.version), reason: context.marker }, `${label}-booking-submit`)
const submitted = context.apiSuccess(MODULE, `${label} 提交订舱`, submittedResponse, '', OPTIONS)
const confirmedResponse = await post(context, `/api/platform/v1/ocean-bookings/${bookingId}/confirm`,
{ expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker }, `${label}-booking-confirm`)
const confirmed = context.apiSuccess(MODULE, `${label} 确认订舱`, confirmedResponse, '', OPTIONS)
const containerId = id(confirmed?.containerAllocations?.[0]?.containerBookingId)
if (!containerId) throw new Error(`${label} 确认订舱未生成柜次`)
return containerId
}
async function createPlanTask(context, label) {
const source = await readInbound(context, await prepareInbound(context, label))
const containerId = await createConfirmedContainer(context, source, label)
const candidateResponse = await context.request('GET',
`${SMART}/candidates?bookingId=${containerId}&warehouseId=${source.warehouseId}`)
const candidates = context.apiSuccess(MODULE, `${label} 查询同渠道候选货物`, candidateResponse, '', OPTIONS)
const candidate = Array.isArray(candidates)
? candidates.find((item) => id(item.inboundId) === source.inboundId && Number(item.availablePackageCount) > 0)
: null
if (!candidate?.inboundDetailId) throw new Error(`${label} 独立入库单未进入同渠道排柜候选`)
const planResponse = await context.request('POST', `${SMART}/plans`,
{ bookingId: containerId, remark: `${context.marker}-${label}` })
let plan = context.apiSuccess(MODULE, `${label} 创建智能排柜计划`, planResponse, '', OPTIONS)
const assignResponse = await context.request('POST', `${SMART}/plans/${plan.id}/assignments`, {
inboundId: source.inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1,
expectedVersion: Number(plan.version), operationId: operationId(context, `${label}-assign`),
})
plan = context.apiSuccess(MODULE, `${label} 分配独立候选货物`, assignResponse, '', OPTIONS)
const pushResponse = await post(context, `${SMART}/plans/${plan.id}/push-preload`,
{ expectedVersion: Number(plan.version) }, `${label}-push`)
plan = context.apiSuccess(MODULE, `${label} 推送预装`, pushResponse, '', OPTIONS)
const detailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`)
const container = context.apiSuccess(MODULE, `${label} 读取柜次`, detailResponse, '', OPTIONS)
const containerNo = `MAN-${suffix(context, label)}`.slice(0, 64)
const transportResponse = await context.request('PATCH', `${CONTAINERS}/${containerId}/transport`, {
containerNo, voyageNo: `V-${suffix(context, label)}`.slice(0, 64), version: Number(container.version),
reason: `${context.marker} 人工状态样本`,
})
context.apiSuccess(MODULE, `${label} 写入真实集装箱号并开放 PDA`, transportResponse, '', OPTIONS)
const taskResponse = await context.request('GET',
`${PDA}/tasks?loadingPlanId=${plan.id}&warehouseId=${source.warehouseId}`)
const task = context.apiSuccess(MODULE, `${label} 读取 PDA 任务`, taskResponse, '', OPTIONS)
if (!task?.id || !task?.items?.[0]?.id) throw new Error(`${label} PDA 任务未生成`)
return { source, containerId, containerNo, plan, task }
}
async function claim(context, chain, label) {
const response = await context.request('POST', `${PDA}/tasks/${chain.task.id}/claim`, {
expectedAssignmentVersion: Number(chain.task?.assignment?.version || 0),
operationId: operationId(context, `${label}-claim`),
})
chain.task = context.apiSuccess(MODULE, `${label} 领取 PDA 任务`, response, '', OPTIONS)
return chain
}
async function pickAll(context, chain, label) {
const snapshotResponse = await context.request('GET',
`${PDA}/tasks/${chain.task.id}/inventory-snapshot?warehouseId=${chain.source.warehouseId}`)
const snapshot = context.apiSuccess(MODULE, `${label} 获取 PDA 库存快照`, snapshotResponse, '', OPTIONS)
const response = await post(context, `${PDA}/tasks/${chain.task.id}/batch`, {
warehouseId: chain.source.warehouseId, loadingPlanId: id(chain.task.loadingPlanId),
containerBookingId: chain.containerId, containerNo: chain.task.currentContainerNo || null,
expectedVersion: Number(chain.task.version), inventorySnapshotToken: snapshot.snapshotToken,
items: chain.task.items.map((item) => ({ itemId: id(item.id), action: 'PICK',
pickedPackageCount: Number(item.plannedPackageCount), finalPickedPackageCount: null,
releaseMode: 'MANUAL', locationIds: [], version: Number(item.version), reason: null })),
}, `${label}-batch`)
chain.task = context.apiSuccess(MODULE, `${label} 完成 PDA 拣货`, response, '', OPTIONS)
return chain
}
async function complete(context, chain, label) {
const response = await context.request('POST', `${PDA}/tasks/${chain.task.id}/complete`, {
expectedTaskVersion: Number(chain.task.version), operationId: operationId(context, `${label}-complete`),
remark: `${context.marker} 人工完成状态样本`,
})
chain.task = context.apiSuccess(MODULE, `${label} 人工确认装柜完成`, response, '', OPTIONS)
return chain
}
async function verifyContainer(context, status, fixture) {
const response = await context.request('GET', `${CONTAINERS}?status=${status}&page=1&size=100`)
const page = context.apiSuccess(MODULE, `${status} 货柜可按列表状态筛选`, response, '', OPTIONS)
context.assert(MODULE, `${status} 货柜样本保留`, response,
records(page).some((item) => id(item.id) === fixture.id && item.planStatus === status),
`id=${fixture.id}`, OPTIONS)
}
async function verifyPlan(context, status, fixture) {
const response = await context.request('GET',
`${LOADING}/loading-plans?status=${status}&warehouseId=${fixture.warehouseId}&page=1&size=100`)
const page = context.apiSuccess(MODULE, `${status} 预装清单可筛选`, response, '', OPTIONS)
context.assert(MODULE, `${status} 预装清单样本保留`, response,
records(page).some((item) => id(item.planId) === fixture.id && item.status === status),
`planId=${fixture.id}`, OPTIONS)
const detailResponse = await context.request('GET',
`${LOADING}/loading-plans/${fixture.id}?warehouseId=${fixture.warehouseId}`)
const detail = context.apiSuccess(MODULE, `${status} 预装清单详情可打开`, detailResponse, '', OPTIONS)
context.assert(MODULE, `${status} 预装清单详情身份正确`, detailResponse,
id(detail?.summary?.planId) === fixture.id && detail?.summary?.status === status,
`planId=${fixture.id}, actual=${detail?.summary?.status || 'missing'}`, OPTIONS)
}
async function verifyTask(context, status, fixture) {
const response = await context.request('GET',
`${PDA}/tasks/${fixture.id}?warehouseId=${fixture.warehouseId}`)
const task = context.apiSuccess(MODULE, `${status} PDA 任务可打开`, response, '', OPTIONS)
context.assert(MODULE, `${status} PDA 任务样本保留`, response,
id(task?.id) === fixture.id && task?.workflowStatus === status,
`taskId=${fixture.id}, actual=${task?.workflowStatus || 'missing'}`, OPTIONS)
}
function fixture(value, status, detailPath, extra = {}) {
return { id: id(value), status, detailPath, ...extra }
}
export async function run(context, matrix = context.createdResources.manualStateMatrix) {
matrix.pages.containerLoading = { requiredStatuses: CONTAINER_STATUSES, fixtures: {} }
matrix.pages.preloadPlans = { requiredStatuses: PRELOAD_STATUSES, fixtures: {} }
matrix.pages.loadingLists = { requiredStatuses: ['COMPLETED'], fixtures: {} }
matrix.pages.pdaTasks = { requiredStatuses: PDA_STATUSES, fixtures: {} }
const warehouseId = '193000000000200001'
const waiting = await createWaitingContainer(context, warehouseId, 'WAIT')
matrix.pages.containerLoading.fixtures.WAIT_LOADING = fixture(waiting.id, 'WAIT_LOADING',
`/container-loading?containerId=${id(waiting.id)}`)
const editing = await createPlanTask(context, 'EDIT')
const inProgress = await claim(context, await createPlanTask(context, 'PROGRESS'), 'PROGRESS')
const awaiting = await pickAll(context,
await claim(context, await createPlanTask(context, 'REVIEW'), 'REVIEW'), 'REVIEW')
const completed = await complete(context, await pickAll(context,
await claim(context, await createPlanTask(context, 'DONE'), 'DONE'), 'DONE'), 'DONE')
const chains = { EDITING: editing, LOADING: inProgress, COMPLETED: completed }
for (const [status, chain] of Object.entries(chains)) {
matrix.pages.containerLoading.fixtures[status] = fixture(chain.containerId, status,
`/container-loading/${chain.containerId}/smart-loading`, { planId: id(chain.plan.id) })
}
for (const [status, chain] of Object.entries({ EDITING: editing, LOADING: inProgress })) {
matrix.pages.preloadPlans.fixtures[status] = fixture(chain.plan.id, status,
`/loading-plans/${chain.plan.id}?warehouseId=${chain.source.warehouseId}`,
{ warehouseId: chain.source.warehouseId })
}
matrix.pages.loadingLists.fixtures.COMPLETED = fixture(completed.plan.id, 'COMPLETED',
`/loading-lists?planId=${completed.plan.id}&warehouseId=${completed.source.warehouseId}`,
{ warehouseId: completed.source.warehouseId })
const taskChains = { UNASSIGNED: editing, IN_PROGRESS: inProgress, AWAITING_REVIEW: awaiting, COMPLETED: completed }
for (const [status, chain] of Object.entries(taskChains)) {
matrix.pages.pdaTasks.fixtures[status] = fixture(chain.task.id, status,
`/pages/picking/picking?taskId=${chain.task.id}&warehouseId=${chain.source.warehouseId}`,
{ warehouseId: chain.source.warehouseId, loadingPlanId: id(chain.plan.id) })
}
for (const [status, value] of Object.entries(matrix.pages.containerLoading.fixtures))
await verifyContainer(context, status, value)
for (const [status, value] of Object.entries(matrix.pages.preloadPlans.fixtures))
await verifyPlan(context, status, value)
for (const [status, value] of Object.entries(matrix.pages.pdaTasks.fixtures))
await verifyTask(context, status, value)
const listFixture = matrix.pages.loadingLists.fixtures.COMPLETED
const listResponse = await context.request('GET',
`${LOADING}/loading-lists?warehouseId=${listFixture.warehouseId}&page=1&size=100`)
const list = context.apiSuccess(MODULE, 'COMPLETED 装柜清单可从列表打开', listResponse, '', OPTIONS)
context.assert(MODULE, 'COMPLETED 装柜清单样本保留', listResponse,
records(list).some((item) => id(item.planId) === listFixture.id && item.status === 'COMPLETED'),
`planId=${listFixture.id}`, OPTIONS)
const listDetailResponse = await context.request('GET',
`${LOADING}/loading-lists/${listFixture.id}?warehouseId=${listFixture.warehouseId}`)
const listDetail = context.apiSuccess(MODULE, 'COMPLETED 装柜清单详情可打开', listDetailResponse, '', OPTIONS)
context.assert(MODULE, 'COMPLETED 装柜清单详情身份正确', listDetailResponse,
id(listDetail?.summary?.planId) === listFixture.id && listDetail?.summary?.status === 'COMPLETED',
`planId=${listFixture.id}, actual=${listDetail?.summary?.status || 'missing'}`, OPTIONS)
}
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
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')
test('default full acceptance includes the retained manual state matrix after lifecycle modules', () => {
assert.match(entry, /'manual-state-matrix'/)
assert.ok(entry.indexOf("'phase3-order-splitting'") < entry.indexOf("'manual-state-matrix'"))
assert.ok(entry.indexOf("'manual-state-matrix'") < entry.indexOf("'finance-full-fixtures'"))
})
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, /requiredStatuses/)
assert.match(source, /fixtures\?\.\[status\]\?\.id/)
})
@@ -0,0 +1,29 @@
const MODULE = 'manual-state-matrix'
const OPTIONS = { scenario: 'manual-clickable-list-state-matrix', deploymentBlocker: true }
const GROUPS = [
'order-inbound-status-fixtures',
'manual-state-loading-pda',
'manual-state-ocean-trucking',
]
function rows(value) { return Array.isArray(value) ? value : [] }
export async function run(context) {
const matrix = { marker: context.marker, generatedAt: new Date().toISOString(), pages: {} }
context.createdResources.manualStateMatrix = matrix
for (const name of GROUPS) {
const extension = await import(new URL(`./${name}.mjs`, import.meta.url))
if (typeof extension.run !== 'function') throw new Error(`${name} 必须导出 async function run(context)`)
await extension.run(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}`))
context.assert(MODULE, '人工验收列表状态数据全部生成并保留', null,
pages.length > 0 && missing.length === 0,
`pages=${pages.length}, missing=${missing.join(',') || 'none'}`, OPTIONS)
}
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./manual-state-ocean-trucking.mjs', import.meta.url), 'utf8')
test('retains every ocean booking and truck appointment list status through real APIs', () => {
for (const status of ['DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED',
'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED',
'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`))
assert.match(source, /change-request/)
assert.match(source, /\/reschedule\?plannedLoadingAt=/)
assert.match(source, /page=1&size=100&status=/)
assert.doesNotMatch(source, /DELETE/)
})
@@ -0,0 +1,158 @@
const MODULE = 'manual-state-ocean-trucking'
const OPTIONS = { scenario: 'manual-ocean-trucking-state-matrix', deploymentBlocker: true }
const OCEAN_BASE = '/api/platform/v1/first-leg/ocean-bookings'
const TRUCK_BASE = '/api/platform/v1/truck-appointments'
const OCEAN_STATUSES = ['DRAFT', 'SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED']
const TRUCK_STATUSES = ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', 'ARRIVED_WAREHOUSE',
'LOADING', 'DEPARTED', 'COMPLETED', 'RESCHEDULED', 'EXCEPTION', 'CANCELLED']
function id(value) { return value == null ? '' : String(value) }
function code(response) { return String(response?.body?.code ?? 'missing') }
function data(response) { return response?.http === 200 && code(response) === '200' ? response.body.data : null }
function key(context, value) { return `${context.marker}-manual-${value}-${crypto.randomUUID()}`.slice(0, 128) }
function suffix(context, index) { return `${context.marker.replace(/\D/g, '').slice(-8)}${String(index).padStart(2, '0')}` }
async function post(context, path, body, operation) {
return context.request('POST', path, body, context.token, { 'Idempotency-Key': key(context, operation) })
}
function oceanPayload(context, template, index) {
const value = suffix(context, index); 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,
shippingCompanyName: source.shippingCompanyName, bookingAgentId: source.bookingAgentId,
bookingAgentName: source.bookingAgentName, vesselName: `人工验收轮-${value}`, voyageNo: `V${value}`,
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 })),
containerRequirements: template.containerRequirements.map(({ containerType, containerKind, capacityCbm, maxWeightKg, quantity }) =>
({ containerType, containerKind, capacityCbm, maxWeightKg, quantity })),
} }
}
async function createOcean(context, template, index) {
const response = await post(context, OCEAN_BASE, oceanPayload(context, template, index), `ocean-create-${index}`)
const value = context.apiSuccess(MODULE, `创建订舱状态样本 ${index}`, response, '', OPTIONS)
if (!value?.oceanBooking?.id) throw new Error(`创建订舱样本 ${index} 失败`)
return value
}
async function oceanAction(context, value, endpoint, body = {}) {
const response = await post(context, `${OCEAN_BASE}/${id(value.oceanBooking.id)}/${endpoint}`,
{ expectedVersion: Number(value.oceanBooking.version), reason: `${context.marker}-${endpoint}`, ...body }, `ocean-${endpoint}`)
const next = context.apiSuccess(MODULE, `订舱推进 ${endpoint}`, response, `id=${id(value.oceanBooking.id)}`, OPTIONS)
if (!next?.oceanBooking?.id) throw new Error(`订舱 ${id(value.oceanBooking.id)} 推进 ${endpoint} 失败`)
return next
}
async function buildOcean(context, template, status, index) {
let value = await createOcean(context, template, index)
if (status === 'DRAFT') return value
value = await oceanAction(context, value, 'submit')
if (status === 'SUBMITTED') return value
if (status === 'FAILED') return oceanAction(context, value, 'fail')
if (status === 'CANCELLED') return oceanAction(context, value, 'cancel')
value = await oceanAction(context, value, 'confirm')
if (status === 'CONFIRMED') return value
if (status === 'CHANGE_PENDING') return oceanAction(context, value, 'change-request', {
vesselName: `变更轮-${suffix(context, index)}`, voyageNo: `CV${suffix(context, index)}`,
estimatedSailingAt: '2026-09-16T08:00:00', estimatedArrivalAt: '2026-10-16T08:00:00' })
if (status === 'RELEASED') return oceanAction(context, value, 'release', {
billOfLadingNo: `REL-BL-${suffix(context, index)}`, vesselName: `放舱轮-${suffix(context, index)}`,
voyageNo: `RV${suffix(context, index)}`, estimatedSailingAt: '2026-09-16T08:00:00',
estimatedArrivalAt: '2026-10-16T08:00:00' })
throw new Error(`unsupported ocean status ${status}`)
}
function truckPayload(context, oceanBookingId, index) {
const value = suffix(context, index)
return { outboundOrderId: null, oceanBookingId, containerBookingId: null, loadingPlanId: null,
warehouseId: '193000000000200001', customerId: '10001', customerName: 'IT 测试客户',
carrierName: `人工验收车队-${value}`, fleetContact: '人工验收联系人', fleetPhone: '13800000004',
driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`,
storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006',
storeAddress: '深圳市南山区人工验收地址', storeCity: '深圳市', storeZipcode: '518000',
packageCount: 10, grossWeightKg: 100, volumeCbm: 2.5, palletCount: 1, poNo: `PO-${value}`,
plannedLoadingAt: '2026-09-16T08:00:00', transitMinutes: 120, payableAmount: 100,
receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 }
}
async function createTruck(context, oceanBookingId, index) {
const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBookingId, index))
const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS)
if (!value?.id) throw new Error(`创建拖车样本 ${index} 失败`)
return value
}
async function truckAction(context, value, endpoint) {
const response = await post(context, `${TRUCK_BASE}/${id(value.id)}/${endpoint}`,
{ expectedVersion: Number(value.version), reason: `${context.marker}-${endpoint}` }, `truck-${endpoint}`)
const next = context.apiSuccess(MODULE, `拖车推进 ${endpoint}`, response, `id=${id(value.id)}`, OPTIONS)
if (!next?.id) throw new Error(`拖车 ${id(value.id)} 推进 ${endpoint} 失败`)
return next
}
async function buildTruck(context, oceanBookingId, status, index) {
let value = await createTruck(context, oceanBookingId, index)
if (status === 'DRAFT') return value
if (status === 'RESCHEDULED') {
const response = await post(context,
`${TRUCK_BASE}/${id(value.id)}/reschedule?plannedLoadingAt=2026-09-18T08%3A00%3A00`,
{ expectedVersion: Number(value.version), reason: `${context.marker}-reschedule` }, 'truck-reschedule')
return context.apiSuccess(MODULE, '拖车推进 reschedule', response, '', OPTIONS)
}
if (status === 'EXCEPTION') return truckAction(context, value, 'exception')
if (status === 'CANCELLED') return truckAction(context, value, 'cancel')
const chain = [['submit', 'PENDING_DISPATCH'], ['dispatch', 'DISPATCHED'], ['pickup', 'PICKUP_IN_PROGRESS'],
['arrive', 'ARRIVED_WAREHOUSE'], ['load', 'LOADING'], ['depart', 'DEPARTED'], ['complete', 'COMPLETED']]
for (const [endpoint, target] of chain) {
value = await truckAction(context, value, endpoint)
if (target === status) return value
}
throw new Error(`unsupported truck status ${status}`)
}
async function verify(context, base, status, resourceId) {
const response = await context.request('GET', `${base}?page=1&size=100&status=${status}`)
const page = data(response)
context.assert(MODULE, `${status} 样本可从列表筛选并打开`, response,
Array.isArray(page?.records) && page.records.some((item) => id(item.id) === resourceId),
`status=${status}, id=${resourceId}`, OPTIONS)
}
export async function run(context, matrix = context.createdResources.manualStateMatrix) {
const sourceOceanId = id(context.createdResources?.oceanBooking?.id)
if (!sourceOceanId) throw new Error('缺少现有订舱生命周期模块生成的模板资源')
const templateResponse = await context.request('GET', `${OCEAN_BASE}/${sourceOceanId}`)
const template = context.apiSuccess(MODULE, '读取订舱状态矩阵模板', templateResponse, '', OPTIONS)
if (!template?.oceanBooking || !template?.parties?.length || !template?.containerRequirements?.length) {
throw new Error('订舱模板缺少渠道、仓库、参与方或箱型需求')
}
matrix.pages.oceanBookings = { requiredStatuses: OCEAN_STATUSES, fixtures: {} }
for (const [index, status] of OCEAN_STATUSES.entries()) {
const value = await buildOcean(context, template, status, index)
const resourceId = id(value?.oceanBooking?.id); const actual = value?.oceanBooking?.status
matrix.pages.oceanBookings.fixtures[status] = { id: resourceId, status: actual, detailPath: `/ocean-bookings/${resourceId}` }
context.assert(MODULE, `订舱保留 ${status}`, null, resourceId && actual === status,
`id=${resourceId || 'missing'}, actual=${actual || 'missing'}`, OPTIONS)
await verify(context, OCEAN_BASE, status, resourceId)
}
const releasedOceanId = matrix.pages.oceanBookings.fixtures.RELEASED.id
matrix.pages.truckAppointments = { requiredStatuses: TRUCK_STATUSES, fixtures: {} }
for (const [index, status] of TRUCK_STATUSES.entries()) {
const value = await buildTruck(context, releasedOceanId, status, index)
const resourceId = id(value?.id)
matrix.pages.truckAppointments.fixtures[status] = { id: resourceId, status: value?.status, detailPath: `/truck-appointments/${resourceId}` }
context.assert(MODULE, `拖车保留 ${status}`, null, resourceId && value?.status === status,
`id=${resourceId || 'missing'}, actual=${value?.status || 'missing'}`, OPTIONS)
await verify(context, TRUCK_BASE, status, resourceId)
}
}
@@ -0,0 +1,62 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'order-inbound-status-fixtures.mjs'), 'utf8')
test('为头程订单列表八个真实状态分别创建独立样本', () => {
for (const status of ['DRAFT', 'SUBMITTED', 'REJECTED', 'APPROVED', 'ACCEPTED', 'PROCESSING', 'COMPLETED', 'CANCELLED']) {
assert.match(source, new RegExp(`\\['${status}', \\[`))
assert.match(source, new RegExp(`result\\.orders\\[target\\]`))
}
assert.match(source, /status=\$\{status\}/)
assert.match(source, /筛选结果包含人工样本/)
})
test('所有写入均走订单生命周期 HTTP API 且不清理保留数据', () => {
assert.match(source, /\/api\/merchant\/v1\/orders\/first-leg/)
assert.match(source, /\/api\/platform\/v1\/orders\/first-leg/)
assert.match(source, /'Idempotency-Key': key\(context,/)
assert.doesNotMatch(source, /\b(?:INSERT|UPDATE|DELETE|TRUNCATE)\b/i)
assert.doesNotMatch(source, /cleanup|remove|unlink/i)
})
test('入库通过真实订单测量链保留四个测量态和 INBOUNDED', () => {
assert.match(source, /result\.inbounds\.WAIT_MEASURE/)
assert.match(source, /processStatus: 'WAIT_MEASURE'/)
for (const status of ['MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'INBOUNDED']) {
assert.match(source, new RegExp(`'${status}'`))
}
assert.match(source, /\.\/inbound-measurement\.mjs/)
assert.match(source, /path\.replace\('\/approve', '\/reject'\)/)
})
test('入库不可独立保留状态显式阻断', () => {
for (const status of ['WAIT_PRICING', 'FEE_CONFIRMING', 'INBOUNDING', 'VOIDED']) {
assert.match(source, new RegExp(`${status}:`))
}
assert.match(source, /AutomaticInboundCompletionService 在同一调用内立即从 INBOUNDING 推进 INBOUNDED/)
assert.match(source, /没有作废入库单 API/)
assert.match(source, /context\.assert\(MODULE, `入库列表保留 \$\{status\}`/)
})
test('统一订单声明合法可达状态并显式阻断废弃或无 API 状态', () => {
assert.match(source, /result\.unifiedOrders\.PENDING_REVIEW/)
assert.match(source, /result\.unifiedOrders\.WAIT_MEASURE/)
for (const status of ['INBOUNDED', 'WAIT_SPLIT', 'LOADING', 'LOADED', 'DELIVERED']) {
assert.match(source, new RegExp(`'${status}'`))
}
assert.match(source, /WAIT_SPLIT 标记为历史废弃状态/)
assert.match(source, /没有完成派送并写 DELIVERED 的公开业务 API/)
})
test('按总门禁格式写入 matrix.pages 的 requiredStatuses 和 fixtures', () => {
assert.match(source, /run\(context, matrix = context\.createdResources\.manualStateMatrix\)/)
for (const page of ['firstLegOrders', 'inbounds', 'unifiedOrders']) {
assert.match(source, new RegExp(`matrix\\.pages\\.${page} = \\{ requiredStatuses:`))
}
assert.match(source, /detailPath: `\/orders\/\$\{orderId\}`/)
})
@@ -0,0 +1,300 @@
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', 'INBOUNDED', 'VOIDED',
]
const UNIFIED_FILTER_STATUSES = [
'PENDING_REVIEW', 'WAIT_MEASURE', 'INBOUNDED', '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 domestic = rows(context.apiSuccess(MODULE, '读取国内收货仓', domesticResponse))[0]
const destinationResponse = await merchantGet(context,
'/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=FBA', token)
const destination = rows(context.apiSuccess(MODULE, '读取 FBA 目的仓', destinationResponse))[0]
if (!profile?.id || !domestic?.id || !destination?.id) throw new Error('状态样本缺少客户或仓库基础数据')
const channelResponse = await merchantGet(context,
`/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${query(domestic.id)}`, token)
const channels = context.apiSuccess(MODULE, '读取可下单渠道', channelResponse)
const channel = Array.isArray(channels)
? channels.find((item) => Array.isArray(item.pricingTaxModes) && item.pricingTaxModes.length > 0)
: null
if (!channel) throw new Error('状态样本缺少带计价税制的可用渠道')
return { tenantId: id(profile.id), customerName: profile.tenantName || 'IT 测试客户', domestic, destination, channel }
}
function orderPayload(context, fixture, target) {
const sample = `${context.marker}-${target}`
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.channel.pricingTaxModes[0], containerMode: 'LCL', customsInspection: false,
destinationType: 'FBA', amazonWarehouseCode: fixture.destination.warehouseCode,
shipmentId: `MANUAL-${target}-${context.marker}`.slice(0, 64), referenceId: `STATUS-${target}-${context.marker}`.slice(0, 64),
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),
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) 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} 未生成独立待测量入库单`)
context.request = async (method, path, body, token, headers) => {
const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`)
const isApprove = path.includes(`/inbounds/${inboundId}/measurements/approve`)
if (target === 'MEASURING' && isSubmit) throw stop
if (target === 'MEASURE_PENDING' && isApprove) throw stop
if (target === 'MEASURE_REJECTED' && isApprove) {
const response = await originalRequest.call(context, method, path.replace('/approve', '/reject'),
{ ...body, reason: `${fixtureMarker} 保留审核驳回样本` }, token, headers)
if (!ok(response)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${response?.http}, code=${response?.body?.code}`)
throw stop
}
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
}
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) {
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', 'INBOUNDED']) {
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 === 'INBOUNDED') {
result.unifiedOrders.INBOUNDED = sample
matrix.pages.unifiedOrders.fixtures.INBOUNDED = {
id: sample.orderId, status, detailPath: `/orders/${sample.orderId}`,
}
}
}
const blockers = {
WAIT_PRICING: '当前审核通过 API 直接写 INBOUNDED;仅异常放行开关路径可写 WAIT_PRICING',
FEE_CONFIRMING: '需要先取得可匹配的真实计价快照并停在费用确认前',
INBOUNDING: 'AutomaticInboundCompletionService 在同一调用内立即从 INBOUNDING 推进 INBOUNDED,无可暂停 API',
VOIDED: 'InboundProcessStatus 声明了 VOIDED,但当前 Controller/Service 没有作废入库单 API',
}
for (const status of INBOUND_FILTER_STATUSES) if (!matrix.pages.inbounds.fixtures[status]) {
context.assert(MODULE, `入库列表保留 ${status}`, null, false, blockers[status], OPTIONS)
}
const unifiedBlockers = {
WAIT_SPLIT: '服务端已将 WAIT_SPLIT 标记为历史废弃状态,查询时统一归一为 INBOUNDED,不能再写入或返回',
LOADING: '需要分柜、排柜及 PDA 专项独立样本',
LOADED: '需要完成装柜证据和 PDA 门禁的专项独立样本',
DELIVERED: '当前后端没有完成派送并写 DELIVERED 的公开业务 API',
}
for (const status of UNIFIED_FILTER_STATUSES) if (!matrix.pages.unifiedOrders.fixtures[status]) {
context.assert(MODULE, `统一订单列表保留 ${status}`, null, false, unifiedBlockers[status], OPTIONS)
}
context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true,
`orders=${Object.keys(result.orders).length}, inbounds=${Object.keys(result.inbounds).length}`, OPTIONS)
}