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 } } 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(context.createdResources.phase4OverseasDispatch, { 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)}`) const detailMarks = (detail.data?.items || []).flatMap((item) => String(item?.cartonMarks || '') .split(',').map((mark) => mark.trim()).filter(Boolean)) context.assert(MODULE, '排车详情冻结全部换标后箱唛且不混入原箱唛', detail.response, relabel.relabelCartonMarks.every((mark) => detailMarks.includes(mark)) && relabel.sourceCartonMarks.every((mark) => !detailMarks.includes(mark)), `frozenMarks=${detailMarks.join(',')}; ${trace(detail.response)}`, OPTIONS) } 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 relabel = context.createdResources?.phase4OverseasRelabel if (relabel?.marker === context.marker && relabel?.status === 'READY_FOR_OUTBOUND') { await runRelabelDispatch(context, relabel) 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) }