test: complete phase3 phase4 acceptance coverage

This commit is contained in:
2026-08-25 00:24:43 +08:00
parent 84ad5e779a
commit 73d206e3f3
21 changed files with 908 additions and 69 deletions
@@ -0,0 +1,169 @@
const MODULE = 'phase4-overseas-dispatch'
const SCENARIO = 'phase4-overseas-dispatch-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const BASE = '/api/platform/v1/last-leg/dispatches'
function id(value) { return value == null ? '' : String(value) }
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) }
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') }
function trace(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function query(path, values) {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(values || {})) {
if (value !== null && value !== undefined && String(value) !== '') params.set(key, String(value))
}
const encoded = params.toString()
return encoded ? `${path}?${encoded}` : path
}
async function api(context, method, name, path, body) {
const response = await context.requestOverseas(method, path, body)
const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS)
return { response, data }
}
function blocker(context, name, evidence, response = null) {
context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS)
context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true })
}
function markerFixture(context) {
const fixture = context.createdResources?.phase4OverseasPda
if (fixture?.marker !== context.marker || fixture?.finalStatus !== 'COMPLETED') return null
const palletIds = new Set((fixture.palletIds || []).map(id).filter(Boolean))
if (!fixture.overseasWarehouseId || palletIds.size === 0) return null
return { ...fixture, palletIds }
}
export async function run(context) {
context.createdResources.phase4OverseasDispatch = {
marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null,
planPalletId: null, finalStatus: null,
cleanup: 'PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence; outbound, PDA and delivery APIs are excluded',
}
const initialPage = await api(context, 'GET', '查询海外排车分页', query(BASE, { page: 1, size: 20 }))
context.assert(MODULE, '排车分页包络稳定', initialPage.response,
Number.isFinite(Number(initialPage.data?.page)) && Number.isFinite(Number(initialPage.data?.size))
&& Number.isFinite(Number(initialPage.data?.total)) && Array.isArray(initialPage.data?.records),
`records=${records(initialPage.data).length}; ${trace(initialPage.response)}`, OPTIONS)
if (!success(initialPage.response)) return
const fixture = markerFixture(context)
if (!fixture) {
blocker(context, '取得本轮海外PDA已收货托盘前置',
`marker=${context.marker}, pdaMarker=${context.createdResources?.phase4OverseasPda?.marker || 'missing'}, status=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}`)
return
}
const candidateResult = await api(context, 'GET', '查询本轮已收货托盘智能预排候选',
query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : []
const candidate = candidates.find((value) => fixture.palletIds.has(id(value?.warehousePalletId))
&& id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId)
&& id(value?.planId) && id(value?.planPalletId))
context.assert(MODULE, '候选严格命中本轮PDA库存托盘与实际海外仓', candidateResult.response,
Boolean(candidate) && candidate.sourceStage === 'RECEIVED' && Number(candidate.packageCount) > 0,
`candidate=${Boolean(candidate)}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS)
if (!candidate) return
const planId = id(candidate.planId)
const planPalletId = id(candidate.planPalletId)
const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64)
const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }])
const snapshotBody = { draftToken, planIds: [planId], containersJson }
context.createdResources.phase4OverseasDispatch.draftToken = draftToken
context.createdResources.phase4OverseasDispatch.planPalletId = planPalletId
const snapshot = await api(context, 'POST', '保存智能预排草稿快照', `${BASE}/drafts/snapshot`, snapshotBody)
context.assert(MODULE, 'Void草稿快照允许data为空且请求成功', snapshot.response,
success(snapshot.response) && (snapshot.response.body?.data == null), trace(snapshot.response), OPTIONS)
if (!success(snapshot.response)) return
const snapshotReplay = await api(context, 'POST', '相同草稿快照重复保存', `${BASE}/drafts/snapshot`, snapshotBody)
if (!success(snapshotReplay.response)) return
const draftsResult = await api(context, 'GET', '回查当前操作员智能预排草稿', `${BASE}/drafts`)
const draftRows = Array.isArray(draftsResult.data) ? draftsResult.data : []
const matchingDrafts = draftRows.filter((value) => value?.draftToken === draftToken)
context.assert(MODULE, '同token同载荷快照只保留一份活动草稿', draftsResult.response,
matchingDrafts.length === 1 && String(matchingDrafts[0].planIdsJson || '').split(',').includes(planId)
&& matchingDrafts[0].containersJson === containersJson,
`matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS)
const lockBody = { planPalletId, draftToken }
const locked = await api(context, 'POST', '锁定本轮预排托盘', `${BASE}/draft-locks/lock`, lockBody)
if (!success(locked.response)) return
const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody)
context.assert(MODULE, '重复锁定不创建冲突且Void响应可为空', lockReplay.response,
success(lockReplay.response) && lockReplay.response.body?.data == null, trace(lockReplay.response), OPTIONS)
const visibleInDraft = await api(context, 'GET', '当前草稿仍可查看自身锁定候选',
query(`${BASE}/candidates`, { sourceStage: 'RECEIVED', draftToken }))
const visible = Array.isArray(visibleInDraft.data) && visibleInDraft.data.some((value) => id(value?.planPalletId) === planPalletId)
context.assert(MODULE, 'draftToken仅放行当前草稿自己的锁', visibleInDraft.response, visible,
`planPalletId=${planPalletId}; ${trace(visibleInDraft.response)}`, OPTIONS)
const hiddenResult = await api(context, 'GET', '无草稿标识时隐藏已锁托盘',
query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
const hidden = Array.isArray(hiddenResult.data) && !hiddenResult.data.some((value) => id(value?.planPalletId) === planPalletId)
context.assert(MODULE, '活动草稿锁阻止其他预排重复选择', hiddenResult.response, hidden,
`planPalletId=${planPalletId}; ${trace(hiddenResult.response)}`, OPTIONS)
const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64)
const foreignLock = await context.requestOverseas('POST', `${BASE}/draft-locks/lock`, {
planPalletId, draftToken: foreignToken,
})
context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS)
const preArrangeBody = {
planPalletIds: [planPalletId], route: `${context.marker}-ROUTE`, fbaAppointmentNo: null,
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken,
}
const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody)
const dispatchNo = id(arranged.data?.dispatchNo)
context.assert(MODULE, '智能预排仅创建PENDING_DEPARTURE排车并冻结托盘', arranged.response,
Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE'
&& Array.isArray(arranged.data?.items)
&& arranged.data.items.some((item) => id(item?.planPalletId) === planPalletId)
&& Number(arranged.data?.palletCount) === 1,
`dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}; ${trace(arranged.response)}`, OPTIONS)
if (!dispatchNo) return
context.createdResources.phase4OverseasDispatch.dispatchNo = dispatchNo
context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id)
context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null
const duplicateArrange = await context.requestOverseas('POST', `${BASE}/pre-arrange`, preArrangeBody)
context.addStep(MODULE, '已转换正式锁的旧草稿载荷再次预排返回A0409', duplicateArrange,
conflict(duplicateArrange), trace(duplicateArrange), OPTIONS)
const pageResult = await api(context, 'GET', '按排车单号回查跨客户分页', query(BASE, {
page: 1, size: 20, dispatchNo,
}))
const row = records(pageResult.data).find((value) => id(value?.dispatchNo) === dispatchNo)
context.assert(MODULE, '排车分页包含本轮marker预排且状态未推进', pageResult.response,
row?.status === 'PENDING_DEPARTURE' && id(row?.id) === id(arranged.data?.id),
`dispatchNo=${dispatchNo}, status=${row?.status || 'missing'}; ${trace(pageResult.response)}`, OPTIONS)
const detailResult = await api(context, 'GET', '查询本轮排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`)
context.assert(MODULE, '排车详情按记录自身客户范围加载托盘明细', detailResult.response,
id(detailResult.data?.dispatchNo) === dispatchNo && detailResult.data?.status === 'PENDING_DEPARTURE'
&& Array.isArray(detailResult.data?.items)
&& detailResult.data.items.some((item) => id(item?.planPalletId) === planPalletId),
`dispatchNo=${id(detailResult.data?.dispatchNo) || 'missing'}, items=${detailResult.data?.items?.length ?? 'missing'}; ${trace(detailResult.response)}`, OPTIONS)
const released = await api(context, 'POST', '释放已转换后的草稿资源', `${BASE}/draft-locks/release`, { draftToken })
if (success(released.response)) {
const releaseReplay = await api(context, 'POST', '重复释放草稿幂等成功', `${BASE}/draft-locks/release`, { draftToken })
context.assert(MODULE, '重复释放不影响正式排车锁且Void响应可为空', releaseReplay.response,
success(releaseReplay.response) && releaseReplay.response.body?.data == null, trace(releaseReplay.response), OPTIONS)
}
const afterRelease = await api(context, 'GET', '释放后回查活动草稿', `${BASE}/drafts`)
context.assert(MODULE, '释放后当前token不再出现在活动草稿', afterRelease.response,
Array.isArray(afterRelease.data) && !afterRelease.data.some((value) => value?.draftToken === draftToken),
`draftToken=${draftToken}; ${trace(afterRelease.response)}`, OPTIONS)
}