test(fba): preserve verified fleet and fulfillment acceptance workflows
This commit is contained in:
@@ -11,10 +11,11 @@ function codeOf(response) { return response?.body?.code == null ? '' : String(re
|
||||
function success(response) { return response?.http === 200 && codeOf(response) === '200' }
|
||||
export function dispatchBusinessSnapshot(value) {
|
||||
return JSON.stringify({ id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status,
|
||||
packageCount: value?.packageCount, palletCount: value?.palletCount,
|
||||
route: value?.route, packageCount: value?.packageCount, palletCount: value?.palletCount,
|
||||
items: (value?.items || []).map(item => ({ planPalletId: id(item.planPalletId),
|
||||
warehousePalletId: id(item.warehousePalletId), sourceStage: item.sourceStage,
|
||||
packageCount: item.packageCount, inboundCode: item.inboundCode }))
|
||||
packageCount: item.packageCount, inboundCode: item.inboundCode,
|
||||
destinationWarehouseCode: item.destinationWarehouseCode, fbaWarehouseCode: item.fbaWarehouseCode }))
|
||||
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
||||
})
|
||||
}
|
||||
@@ -45,11 +46,150 @@ function blocker(context, name, evidence, response = null) {
|
||||
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))
|
||||
const palletIds = new Set([...(fixture.palletIds || []),
|
||||
...Object.values(fixture.generatedPalletsByInbound || {}).flat().filter(row =>
|
||||
id(row.warehouseId) === id(fixture.overseasWarehouseId)).map(row => row.palletId)].map(id).filter(Boolean))
|
||||
if (!fixture.overseasWarehouseId || palletIds.size === 0) return null
|
||||
return { ...fixture, palletIds }
|
||||
}
|
||||
|
||||
const destination = row => String(row?.fbaWarehouseCode || row?.destinationWarehouseCode || '').trim()
|
||||
const sorted = values => [...values].sort()
|
||||
const same = (left, right) => JSON.stringify(left) === JSON.stringify(right)
|
||||
// Item IDs are included here: editable pre-arrange retains existing rows, and rejected
|
||||
// commands must preserve the exact assigned facts, not merely their counts.
|
||||
export function dispatchRouteSnapshot(value) {
|
||||
return { id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status,
|
||||
route: value?.route ?? '', warehouseId: id(value?.overseasWarehouseId),
|
||||
itemCount: value?.itemCount, palletCount: value?.palletCount, packageCount: value?.packageCount,
|
||||
pickedPackageCount: value?.pickedPackageCount, shippedPackageCount: value?.shippedPackageCount,
|
||||
items: (value?.items || []).map(row => ({ id: id(row.id), planPalletId: id(row.planPalletId),
|
||||
warehousePalletId: id(row.warehousePalletId), palletNo: row.palletNo,
|
||||
warehouseId: id(row.overseasWarehouseId), inboundCode: row.inboundCode,
|
||||
orderNo: row.orderNo, orderRefs: row.orderRefs, sourceStage: row.sourceStage,
|
||||
destinationWarehouseCode: row.destinationWarehouseCode, fbaWarehouseCode: row.fbaWarehouseCode,
|
||||
packageCount: row.packageCount })).sort((a, b) => a.planPalletId.localeCompare(b.planPalletId)) }
|
||||
}
|
||||
|
||||
export function dispatchRouteMatches(value, expectedCandidates) {
|
||||
if (!Array.isArray(value?.items) || !Array.isArray(expectedCandidates)) return false
|
||||
const items = value.items, route = String(value.route || '').trim()
|
||||
const routeParts = route ? route.split(',').map(part => part.trim()) : []
|
||||
const actualDestinations = sorted(new Set(items.map(destination)))
|
||||
return value.status === 'PENDING_DEPARTURE'
|
||||
&& Number(value.itemCount) === items.length && Number(value.palletCount) === items.length
|
||||
&& Number(value.packageCount) === items.reduce((total, row) => total + Number(row.packageCount), 0)
|
||||
&& items.length === expectedCandidates.length
|
||||
&& new Set(items.map(row => id(row.planPalletId))).size === items.length
|
||||
&& new Set(items.map(row => id(row.warehousePalletId))).size === items.length
|
||||
&& items.every(row => /^[1-9][0-9]*$/.test(id(row.id)) && destination(row)
|
||||
&& expectedCandidates.some(expected => id(expected.planPalletId) === id(row.planPalletId)
|
||||
&& id(expected.warehousePalletId) === id(row.warehousePalletId)
|
||||
&& id(expected.overseasWarehouseId) === id(row.overseasWarehouseId)
|
||||
&& id(value.overseasWarehouseId) === id(row.overseasWarehouseId)
|
||||
&& expected.sourceStage === row.sourceStage && expected.inboundCode === row.inboundCode
|
||||
&& Number(expected.packageCount) === Number(row.packageCount) && Number(row.packageCount) > 0
|
||||
&& destination(expected) === destination(row)))
|
||||
&& routeParts.every(Boolean) && routeParts.length === new Set(routeParts).size
|
||||
&& same(sorted(routeParts), actualDestinations)
|
||||
}
|
||||
|
||||
export function selectDispatchRouteFixture(candidates) {
|
||||
const unique = [...new Map(candidates.map(row => [id(row.warehousePalletId), row])).values()]
|
||||
const groups = new Map()
|
||||
for (const row of unique) {
|
||||
const code = destination(row)
|
||||
if (code) groups.set(code, [...(groups.get(code) || []), row])
|
||||
}
|
||||
const repeated = [...groups.values()].find(rows => rows.length >= 2)
|
||||
if (groups.size < 2 || !repeated) return null
|
||||
return { selected: [...groups.values()].map(rows => rows[0]), duplicate: repeated[1] }
|
||||
}
|
||||
|
||||
export async function verifyDispatchRouteLifecycle(context, { dispatchNo, candidates, duplicate, draftToken, draftContainerId }) {
|
||||
const proof = context.createdResources.phase4OverseasDispatch.routeLifecycle = {
|
||||
marker: context.marker, dispatchNo, status: 'UNPROVEN', stages: [],
|
||||
}
|
||||
const detailPath = `${BASE}/${encodeURIComponent(dispatchNo)}`
|
||||
let identity = null
|
||||
const check = (name, response, passed, evidence) => {
|
||||
context.assert(MODULE, `C52 ${name}`, response, passed, evidence, OPTIONS)
|
||||
if (!passed) throw new Error(`C52 ${name}`)
|
||||
}
|
||||
const read = async (name, expected) => {
|
||||
const current = await api(context, 'GET', `C52 ${name}回查实际分配明细`, detailPath)
|
||||
const snapshot = dispatchRouteSnapshot(current.data)
|
||||
const passed = success(current.response) && current.data?.dispatchNo === dispatchNo
|
||||
&& (!identity || snapshot.id === identity) && dispatchRouteMatches(current.data, expected)
|
||||
proof.stages.push({ name, passed, snapshot, expectedPlanPalletIds: expected.map(row => id(row.planPalletId)),
|
||||
requestId: current.response?.requestId, traceId: current.response?.traceId })
|
||||
check(`${name}实际明细独立推导路线及本轮来源核平`, current.response, passed, JSON.stringify(snapshot))
|
||||
identity ||= snapshot.id
|
||||
return current.data
|
||||
}
|
||||
const lock = async row => {
|
||||
const result = await api(context, 'POST', 'C52 新增本轮托盘到原草稿锁', `${BASE}/draft-locks/lock`,
|
||||
{ planPalletId: id(row.planPalletId), draftToken, draftContainerId })
|
||||
check('新增草稿锁成功', result.response, success(result.response), trace(result.response))
|
||||
}
|
||||
const arrange = async (name, selected) => {
|
||||
const body = { planPalletIds: selected.map(row => id(row.planPalletId)), draftToken }
|
||||
const result = await api(context, 'POST', `C52 ${name}`, `${BASE}/pre-arrange`, body)
|
||||
check(`${name}复用原排车`, result.response, success(result.response) && result.data?.dispatchNo === dispatchNo, trace(result.response))
|
||||
const current = await read(name, selected)
|
||||
const replay = await api(context, 'POST', `C52 ${name}同草稿同载荷重放`, `${BASE}/pre-arrange`, body)
|
||||
const after = await read(`${name}重放后`, selected)
|
||||
check(`${name}重放路线及完整明细零漂移`, replay.response, success(replay.response)
|
||||
&& same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(replay.data))
|
||||
&& same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(replay.response))
|
||||
return after
|
||||
}
|
||||
const rejectUnchanged = async (name, method, path, body, expectedCode, current, selected) => {
|
||||
const result = await context.request(method, path, body)
|
||||
// These business errors use the current gateway envelope and must retain traces.
|
||||
check(name, result, result?.http === 200 && codeOf(result) === expectedCode
|
||||
&& Boolean(result.requestId) && Boolean(result.traceId), trace(result))
|
||||
const after = await read(`${name}后`, selected)
|
||||
check(`${name}路线数量及明细零漂移`, result,
|
||||
same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(result))
|
||||
}
|
||||
const initial = await read('初始多目的地', candidates)
|
||||
const expanded = [...candidates, duplicate]
|
||||
await lock(duplicate)
|
||||
const added = await arrange('新增同目的地第二托', expanded)
|
||||
check('同目的地第二托增加箱托数但路线稳定去重', null,
|
||||
initial.route === added.route && added.palletCount === initial.palletCount + 1,
|
||||
`before=${initial.route}, after=${added.route}`)
|
||||
const extraItem = added.items.find(row => id(row.planPalletId) === id(duplicate.planPalletId))
|
||||
const removeExtra = await api(context, 'DELETE', 'C52 移除同目的地第二托', `${detailPath}/items/${id(extraItem.id)}`)
|
||||
const dedup = await read('移除同目的地第二托', candidates)
|
||||
check('同目的地仍有托盘时路线不消失', removeExtra.response, success(removeExtra.response) && dedup.route === initial.route, trace(removeExtra.response))
|
||||
const removedCandidate = candidates.find(row => destination(row) !== destination(duplicate))
|
||||
const removedItem = dedup.items.find(row => id(row.planPalletId) === id(removedCandidate.planPalletId))
|
||||
const reducedCandidates = candidates.filter(row => row !== removedCandidate)
|
||||
const removePath = `${detailPath}/items/${id(removedItem.id)}`
|
||||
const removed = await api(context, 'DELETE', 'C52 移除某目的地最后一托', removePath)
|
||||
check('移除最后一托请求成功', removed.response, success(removed.response), trace(removed.response))
|
||||
const reduced = await read('移除目的地最后一托', reducedCandidates)
|
||||
check('最后一托移除后路线删除该目的地', null,
|
||||
!reduced.route.split(',').map(part => part.trim()).includes(destination(removedCandidate)), `route=${reduced.route}`)
|
||||
await rejectUnchanged('重复移除已删除明细A0404', 'DELETE', removePath, undefined, 'A0404', reduced, reducedCandidates)
|
||||
await lock(removedCandidate)
|
||||
const restored = await arrange('重新加入已移除目的地', candidates)
|
||||
check('重新加入后恢复原目的地集合', null,
|
||||
same(sorted(restored.route.split(',')), sorted(initial.route.split(','))), `route=${restored.route}`)
|
||||
const body = { planPalletIds: candidates.map(row => id(row.planPalletId)), draftToken }
|
||||
await rejectUnchanged('重复托盘ID拒绝A0400', 'POST', `${BASE}/pre-arrange`,
|
||||
{ ...body, planPalletIds: [...body.planPalletIds, body.planPalletIds[0]] }, 'A0400', restored, candidates)
|
||||
await rejectUnchanged('其他草稿抢占原排车拒绝A0409', 'POST', `${BASE}/pre-arrange`,
|
||||
{ ...body, draftToken: `${context.marker}-C52-FOREIGN`.slice(0, 64) }, 'A0409', restored, candidates)
|
||||
const freed = await api(context, 'GET', 'C52 移除重复目的地托盘后候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
||||
check('额外托盘正式锁已释放', freed.response, success(freed.response) && Array.isArray(freed.data)
|
||||
&& freed.data.some(row => id(row.planPalletId) === id(duplicate.planPalletId)
|
||||
&& id(row.warehousePalletId) === id(duplicate.warehousePalletId)), trace(freed.response))
|
||||
proof.status = 'VERIFIED'
|
||||
}
|
||||
|
||||
async function runRelabelDispatch(context, relabel) {
|
||||
const candidateResult = await api(context, 'GET', '查询本轮换标托盘智能排车候选',
|
||||
query(`${BASE}/candidates`, { orderNo: relabel.relabelNo, sourceStage: 'RECEIVED' }))
|
||||
@@ -68,7 +208,7 @@ async function runRelabelDispatch(context, relabel) {
|
||||
|
||||
const arranged = await api(context, 'POST', '将本轮换标托盘创建待出库排车', `${BASE}/pre-arrange`, {
|
||||
planPalletIds: candidates.map((value) => id(value.warehousePalletId)),
|
||||
route: `${context.marker}-RELABEL-ROUTE`, fbaAppointmentNo: null,
|
||||
fbaAppointmentNo: null,
|
||||
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, relabelNo: relabel.relabelNo,
|
||||
})
|
||||
const dispatchNo = id(arranged.data?.dispatchNo)
|
||||
@@ -124,14 +264,16 @@ export async function run(context) {
|
||||
const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : []
|
||||
const markerCandidates = candidates.filter((value) => fixture.palletIds.has(id(value?.warehousePalletId))
|
||||
&& id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId)
|
||||
&& id(value?.customerId) === id(fixture.customerId) && value?.destinationType === 'FBA'
|
||||
&& id(value?.planId) && id(value?.planPalletId)
|
||||
&& value?.sourceStage === 'RECEIVED' && Number(value?.packageCount) > 0)
|
||||
const candidatesByDestination = new Map()
|
||||
for (const value of markerCandidates) {
|
||||
const destination = String(value?.destinationWarehouseCode || value?.fbaWarehouseCode || '').trim().toUpperCase()
|
||||
if (destination && !candidatesByDestination.has(destination)) candidatesByDestination.set(destination, value)
|
||||
const routeFixture = selectDispatchRouteFixture(markerCandidates)
|
||||
if (!routeFixture) {
|
||||
blocker(context, 'C52 多目的地及同目的地双托前置', '本轮同客户同仓实际收货候选须至少两个FBA目的地,且至少一个目的地有两个不同实物托盘;不借历史数据')
|
||||
return
|
||||
}
|
||||
const selectedCandidates = [...candidatesByDestination.values()]
|
||||
const selectedCandidates = routeFixture.selected
|
||||
const candidatesByDestination = new Map(selectedCandidates.map(row => [destination(row), row]))
|
||||
const candidate = selectedCandidates[0]
|
||||
context.assert(MODULE, '候选严格命中本轮PDA库存托盘且覆盖多个FBA仓', candidateResult.response,
|
||||
selectedCandidates.length >= 2,
|
||||
@@ -144,7 +286,7 @@ export async function run(context) {
|
||||
const planPalletIds = selectedCandidates.map(value => id(value.planPalletId))
|
||||
// Exercise removal on a disposable dispatch, then reuse the released current-run pallet.
|
||||
const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, {
|
||||
planPalletIds: [planPalletId], route: `${context.marker}-REMOVE`,
|
||||
planPalletIds: [planPalletId],
|
||||
})
|
||||
const removableNo = id(removable.data?.dispatchNo)
|
||||
const removableItem = removable.data?.items?.find(item => id(item.planPalletId) === planPalletId)
|
||||
@@ -241,7 +383,7 @@ export async function run(context) {
|
||||
context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS)
|
||||
|
||||
const preArrangeBody = {
|
||||
planPalletIds, route: `${context.marker}-ROUTE`, fbaAppointmentNo: null,
|
||||
planPalletIds, fbaAppointmentNo: null,
|
||||
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken,
|
||||
}
|
||||
const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody)
|
||||
@@ -278,6 +420,9 @@ export async function run(context) {
|
||||
&& 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)
|
||||
|
||||
await verifyDispatchRouteLifecycle(context, { dispatchNo, candidates: selectedCandidates,
|
||||
duplicate: routeFixture.duplicate, draftToken, draftContainerId: id(finalContainer.data.id) })
|
||||
|
||||
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 })
|
||||
|
||||
Reference in New Issue
Block a user