import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs' import { verifyGroupedDispatch } from './grouped-dispatch-acceptance.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, 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, destinationWarehouseCode: item.destinationWarehouseCode, fbaWarehouseCode: item.fbaWarehouseCode })) .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.request(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 || []), ...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' })) 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)), 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?.customerId) === id(fixture.customerId) && value?.destinationType === 'FBA' && id(value?.planId) && id(value?.planPalletId) && value?.sourceStage === 'RECEIVED' && Number(value?.packageCount) > 0) const routeFixture = selectDispatchRouteFixture(markerCandidates) if (!routeFixture) { blocker(context, 'C52 多目的地及同目的地双托前置', '本轮同客户同仓实际收货候选须至少两个FBA目的地,且至少一个目的地有两个不同实物托盘;不借历史数据') return } 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, `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], }) 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.request('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 await verifyGroupedDispatch(context, MODULE, selectedCandidates) 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.request('POST', `${BASE}/draft-locks/lock`, { planPalletId, draftToken: foreignToken, }) context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS) const preArrangeBody = { planPalletIds, 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.request('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) 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 }) 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) }