293 lines
20 KiB
JavaScript
293 lines
20 KiB
JavaScript
import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs'
|
|
|
|
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' }
|
|
export function dispatchBusinessSnapshot(value) {
|
|
return JSON.stringify({ id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status,
|
|
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 }))
|
|
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
})
|
|
}
|
|
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 }
|
|
}
|
|
|
|
async function runRelabelDispatch(context, relabel) {
|
|
const candidateResult = await api(context, 'GET', '查询本轮换标托盘智能排车候选',
|
|
query(`${BASE}/candidates`, { orderNo: relabel.relabelNo, sourceStage: 'RECEIVED' }))
|
|
const candidates = (Array.isArray(candidateResult.data) ? candidateResult.data : [])
|
|
.filter((value) => value?.sourceStage === 'RELABEL'
|
|
&& relabel.targetPalletIds.includes(id(value?.warehousePalletId)))
|
|
const actualMarks = candidates.flatMap((value) => String(value?.relabelCartonMarks || '')
|
|
.split(',').map((mark) => mark.trim()).filter(Boolean))
|
|
context.assert(MODULE, '智能排车只带出本轮换标后的FBA箱唛', candidateResult.response,
|
|
candidates.length === relabel.targetPalletIds.length
|
|
&& relabel.relabelCartonMarks.every((mark) => actualMarks.includes(mark))
|
|
&& candidates.every((value) => !String(value?.cartonMarks || '').trim()),
|
|
`pallets=${candidates.length}, relabelMarks=${actualMarks.join(',')}, normalMarks=${candidates.map((value) => value?.cartonMarks).filter(Boolean).join(',')}; ${trace(candidateResult.response)}`,
|
|
OPTIONS)
|
|
if (candidates.length !== relabel.targetPalletIds.length) return
|
|
|
|
const arranged = await api(context, 'POST', '将本轮换标托盘创建待出库排车', `${BASE}/pre-arrange`, {
|
|
planPalletIds: candidates.map((value) => id(value.warehousePalletId)),
|
|
route: `${context.marker}-RELABEL-ROUTE`, fbaAppointmentNo: null,
|
|
fbaAppointmentStatus: null, vehicleAppointmentStatus: null, relabelNo: relabel.relabelNo,
|
|
})
|
|
const dispatchNo = id(arranged.data?.dispatchNo)
|
|
context.assert(MODULE, '换标多托盘原子预排并冻结换标箱唛', arranged.response,
|
|
Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE'
|
|
&& Number(arranged.data?.palletCount) === candidates.length
|
|
&& Array.isArray(arranged.data?.items)
|
|
&& arranged.data.items.every((item) => item?.sourceStage === 'RELABEL'),
|
|
`dispatchNo=${dispatchNo || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}; ${trace(arranged.response)}`,
|
|
OPTIONS)
|
|
if (!dispatchNo) return
|
|
Object.assign(relabel, {
|
|
dispatchNo, dispatchId: id(arranged.data?.id), finalStatus: arranged.data?.status || null,
|
|
relabelNo: relabel.relabelNo, planPalletId: null,
|
|
})
|
|
const detail = await api(context, 'GET', '回查本轮换标排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`)
|
|
// DispatchResponse.Item omits cartonMarks. Phase5 verifies the persisted marks
|
|
// through OutboundDetail.pallets after transferring this exact dispatch.
|
|
context.assert(MODULE, '排车详情保留本轮换标托盘与箱数快照', detail.response,
|
|
success(detail.response) && dispatchBusinessSnapshot(detail.data) === dispatchBusinessSnapshot(arranged.data),
|
|
trace(detail.response), OPTIONS)
|
|
}
|
|
|
|
export async function run(context) {
|
|
context.createdResources.phase4OverseasDispatch = {
|
|
marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null,
|
|
planPalletId: null, planPalletIds: [], destinationWarehouseCodes: [], 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 relabel = context.createdResources?.phase4OverseasRelabel
|
|
if (relabel?.marker === context.marker && relabel?.status === 'READY_FOR_OUTBOUND') {
|
|
await runRelabelDispatch(context, relabel)
|
|
if (relabel.dispatchNo) await settleRelabelFulfillment(context, relabel)
|
|
}
|
|
|
|
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 markerCandidates = candidates.filter((value) => fixture.palletIds.has(id(value?.warehousePalletId))
|
|
&& id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId)
|
|
&& 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 selectedCandidates = [...candidatesByDestination.values()]
|
|
const candidate = selectedCandidates[0]
|
|
context.assert(MODULE, '候选严格命中本轮PDA库存托盘且覆盖多个FBA仓', candidateResult.response,
|
|
selectedCandidates.length >= 2,
|
|
`candidates=${selectedCandidates.length}, destinations=${[...candidatesByDestination.keys()].join(',')}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS)
|
|
if (selectedCandidates.length < 2) return
|
|
|
|
const planId = id(candidate.planId)
|
|
const planPalletId = id(candidate.planPalletId)
|
|
const planIds = [...new Set(selectedCandidates.map(value => id(value.planId)))]
|
|
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`,
|
|
})
|
|
const removableNo = id(removable.data?.dispatchNo)
|
|
const removableItem = removable.data?.items?.find(item => id(item.planPalletId) === planPalletId)
|
|
if (!success(removable.response) || !removableNo || !removableItem?.id) {
|
|
blocker(context, '移除对照排车前置', '本轮托盘未生成排车明细', removable.response)
|
|
return
|
|
}
|
|
const removePath = `${BASE}/${encodeURIComponent(removableNo)}/items/${encodeURIComponent(id(removableItem.id))}`
|
|
const removed = await api(context, 'DELETE', '排车移除未拣托盘并释放正式锁', removePath)
|
|
const removedDetail = await api(context, 'GET', '回查移除后排车数量', `${BASE}/${encodeURIComponent(removableNo)}`)
|
|
context.assert(MODULE, '移除最后托盘后箱数托数明细归零', removedDetail.response,
|
|
success(removed.response) && success(removedDetail.response)
|
|
&& Number(removedDetail.data?.packageCount) === 0 && Number(removedDetail.data?.palletCount) === 0
|
|
&& removedDetail.data?.items?.length === 0, trace(removedDetail.response), OPTIONS)
|
|
const removedAgain = await context.requestOverseas('DELETE', removePath)
|
|
context.addStep(MODULE, '重复删除已不存在排车明细返回A0404', removedAgain,
|
|
removedAgain?.http === 404 || (removedAgain?.http === 200 && codeOf(removedAgain) === 'A0404'), trace(removedAgain), OPTIONS)
|
|
const freed = await api(context, 'GET', '移除后本轮托盘重新可预排', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
|
context.assert(MODULE, '正式锁释放后候选恢复', freed.response,
|
|
Array.isArray(freed.data) && freed.data.some(item => id(item.planPalletId) === planPalletId), trace(freed.response), OPTIONS)
|
|
context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo
|
|
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, containersJson }
|
|
context.createdResources.phase4OverseasDispatch.draftToken = draftToken
|
|
context.createdResources.phase4OverseasDispatch.planPalletId = planPalletId
|
|
context.createdResources.phase4OverseasDispatch.planPalletIds = planPalletIds
|
|
context.createdResources.phase4OverseasDispatch.destinationWarehouseCodes = [...candidatesByDestination.keys()]
|
|
|
|
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 && planIds.every(value => String(matchingDrafts[0].planIdsJson || '').split(',').includes(value))
|
|
&& matchingDrafts[0].containersJson === containersJson,
|
|
`matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS)
|
|
|
|
const disposableContainer = await api(context, 'POST', '创建真实智能预排草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 })
|
|
const containerId = id(disposableContainer.data?.id)
|
|
if (!success(disposableContainer.response) || !containerId) return
|
|
const boundBody = { planPalletId, draftToken, draftContainerId: containerId }
|
|
const bound = await api(context, 'POST', '托盘绑定真实草稿柜', `${BASE}/draft-locks/lock`, boundBody)
|
|
if (!success(bound.response)) return
|
|
const boundDrafts = await api(context, 'GET', '回查真实草稿柜及托盘绑定', `${BASE}/drafts`)
|
|
context.assert(MODULE, '草稿结构回显服务端柜ID和托盘', boundDrafts.response,
|
|
boundDrafts.data?.find(draft => draft.draftToken === draftToken)?.containers?.some(container =>
|
|
id(container.id) === containerId && container.palletIds?.map(id).includes(planPalletId)), trace(boundDrafts.response), OPTIONS)
|
|
await api(context, 'POST', '移出草稿托盘解锁', `${BASE}/draft-locks/unlock`, boundBody)
|
|
const unlocked = await api(context, 'GET', '解锁后回查候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
|
context.assert(MODULE, '草稿解锁恢复候选', unlocked.response,
|
|
unlocked.data?.some(item => id(item.planPalletId) === planPalletId), trace(unlocked.response), OPTIONS)
|
|
await api(context, 'POST', '重新锁定以验证删除柜释放锁', `${BASE}/draft-locks/lock`, boundBody)
|
|
await api(context, 'DELETE', '删除本轮草稿柜并释放绑定托盘', `${BASE}/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`)
|
|
const deletedDrafts = await api(context, 'GET', '删除后回查草稿柜消失', `${BASE}/drafts`)
|
|
context.assert(MODULE, '删除草稿柜不再出现在结构响应', deletedDrafts.response,
|
|
Array.isArray(deletedDrafts.data) && !deletedDrafts.data.find(draft => draft.draftToken === draftToken)?.containers?.some(container => id(container.id) === containerId), trace(deletedDrafts.response), OPTIONS)
|
|
const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 })
|
|
if (!success(finalContainer.response) || !finalContainer.data?.id) return
|
|
const lockBodies = planPalletIds.map(value => ({ planPalletId: value, draftToken, draftContainerId: id(finalContainer.data.id) }))
|
|
for (const [index, body] of lockBodies.entries()) {
|
|
const locked = await api(context, 'POST', `锁定本轮预排托盘${index + 1}`, `${BASE}/draft-locks/lock`, body)
|
|
if (!success(locked.response)) return
|
|
}
|
|
const lockBody = lockBodies[0]
|
|
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 visibleIds = new Set((Array.isArray(visibleInDraft.data) ? visibleInDraft.data : []).map(value => id(value?.planPalletId)))
|
|
const visible = planPalletIds.every(value => visibleIds.has(value))
|
|
context.assert(MODULE, 'draftToken仅放行当前草稿自己的锁', visibleInDraft.response, visible,
|
|
`planPalletIds=${planPalletIds.join(',')}; ${trace(visibleInDraft.response)}`, OPTIONS)
|
|
|
|
const hiddenResult = await api(context, 'GET', '无草稿标识时隐藏已锁托盘',
|
|
query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' }))
|
|
const hiddenIds = new Set((Array.isArray(hiddenResult.data) ? hiddenResult.data : []).map(value => id(value?.planPalletId)))
|
|
const hidden = planPalletIds.every(value => !hiddenIds.has(value))
|
|
context.assert(MODULE, '活动草稿锁阻止其他预排重复选择', hiddenResult.response, hidden,
|
|
`planPalletIds=${planPalletIds.join(',')}; ${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, 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)
|
|
&& planPalletIds.every(value => arranged.data.items.some(item => id(item?.planPalletId) === value))
|
|
&& Number(arranged.data?.palletCount) === planPalletIds.length,
|
|
`dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}, destinations=${[...candidatesByDestination.keys()].join(',')}; ${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, '出库前同草稿重复预排复用原排车且业务快照不漂移', duplicateArrange,
|
|
success(duplicateArrange)
|
|
&& dispatchBusinessSnapshot(duplicateArrange.body?.data) === dispatchBusinessSnapshot(arranged.data),
|
|
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)
|
|
}
|