This commit is contained in:
2026-10-20 10:35:52 +08:00
parent ce189f062f
commit c13175bf77
202 changed files with 38397 additions and 7138 deletions
@@ -1,440 +1,197 @@
import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs'
import { verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs'
import { DISPATCH_BASE as BASE, id, canonical, version, success, DispatchNotRun, sourceSelection, createBody, markerCandidates,
notRun, dispatchIo, dispatchEvidenceContext, versionAfter, query, candidatePage, replay, readDispatch, dispatchMatches, newDraft, activeDraft, 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 destination = candidate => candidate?.destination?.warehouseCode
const same = (a, b) => canonical(a) === canonical(b)
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)) }
// 读取对象保持三个原生响应独立,不给Detail补回旧items/route字段。
export function dispatchRouteSnapshot(bundle) {
return { dispatchNo: bundle?.data?.dispatchNo, status: bundle?.data?.status, stage: bundle?.data?.stage,
recordVersion: bundle?.data?.recordVersion, warehouseId: bundle?.warehouseId, counts: bundle?.data?.counts,
route: bundle?.data?.dispatchInfo?.route, stops: bundle?.route?.stops,
items: bundle?.items?.items?.map(i => ({ dispatchItemId: i.dispatchItemId, sourceKey: i.sourceKey, sourceVersion: i.sourceVersion,
customerId: i.customerId, planPalletId: i.planPalletId, warehousePalletId: i.warehousePalletId,
destination: i.destination, inboundCode: i.inboundCode, scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages })).sort((a, b) => a.sourceKey.localeCompare(b.sourceKey)) }
}
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 dispatchBusinessSnapshot(bundle) { return canonical(dispatchRouteSnapshot(bundle)) }
export function dispatchRouteMatches(bundle, candidates) {
if (!dispatchMatches(bundle, candidates) || !Array.isArray(bundle.route?.stops) || bundle.route.stops[0]?.index !== 0
|| bundle.route.stops[0].destinationKey !== `ORIGIN:${bundle.warehouseId}`) return false
const codes = String(bundle.data.dispatchInfo?.route || '').split(',').map(s => s.trim()).filter(Boolean)
const expected = sorted(new Set(candidates.map(destination)))
const keys = bundle.route.stops.slice(1).map(s => s.destinationKey)
return expected.every(Boolean) && codes.length === new Set(codes).size && same(sorted(codes), expected)
&& keys.length === new Set(keys).size && same(sorted(keys), sorted(new Set(candidates.map(c => c.destination.key))))
}
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])
for (const candidate of new Map(candidates.map(c => [id(c.selection?.warehousePalletId), c])).values()) {
const code = destination(candidate); if (code) groups.set(code, [...(groups.get(code) || []), candidate])
}
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] }
const other = [...groups.values()].find(rows => rows !== repeated)
return groups.size >= 2 && repeated ? { selected: [repeated[0], other[0]], duplicate: repeated[1] } : null
}
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 unchangedAfterRejection(io, method, path, body, code, before, dispatchNo, candidates) {
const result = await io.send(method, path, body, `拒绝${code}`)
io.check(`业务错误${code}有诊断回执`, result, [200, Number(code.slice(2))].includes(result?.http)
&& String(result.body?.code) === code && Boolean(result.requestId) && Boolean(result.traceId))
const after = await readDispatch(io, dispatchNo)
io.check('拒绝后路线、版本及实物零漂移', null, dispatchRouteMatches(after, candidates) && dispatchBusinessSnapshot(after) === dispatchBusinessSnapshot(before))
return after
}
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)
// C52只使用当前货物命令与路线命令,绝不借旧预排对已建主单做隐式编辑。
export async function verifyDispatchRouteLifecycle(context, { dispatchNo, candidates, duplicate }) {
const proof = context.createdResources.phase4OverseasDispatch.routeLifecycle = { marker: context.marker, dispatchNo, status: 'UNPROVEN', stages: [], requests: [] }
const io = dispatchIo(context, MODULE, proof, 'C52'), path = `${BASE}/${encodeURIComponent(dispatchNo)}`
const read = async (label, expected) => {
const current = await readDispatch(io, dispatchNo)
io.check(label, null, dispatchRouteMatches(current, expected)); proof.stages.push({ label, snapshot: dispatchRouteSnapshot(current) }); return current
}
const mutate = async (action, suffix, fields, expected, method = 'POST') => {
const before = await readDispatch(io, dispatchNo)
const body = { recordVersion: version(before.data.recordVersion), idempotencyKey: io.key(action), ...fields }
const result = await io.command(method, `${path}${suffix}`, body, action)
io.check(`${action}恢复同一整车命令身份`, null, result?.dispatchNo === dispatchNo && versionAfter(result.recordVersion, before.data.recordVersion))
const current = await read(action, expected)
await replay(io, `${path}${suffix}`, body, result, `${action}原键重放`, method)
io.check(`${action}重放不漂移实际行身份`, null, dispatchBusinessSnapshot(current) === dispatchBusinessSnapshot(await readDispatch(io, dispatchNo)))
return current
}
try {
if (markerCandidates(context, [...candidates, duplicate]).length !== candidates.length + 1) throw new DispatchNotRun('C52来源不属于本轮客户仓')
const initial = await read('初始多目的地', candidates), expanded = [...candidates, duplicate]
const extra = createBody(context.marker, 'C52-extra', [duplicate])
let current = await mutate('新增同目的地第二托', '/items', { cargo: extra.cargo, batchAllocations: extra.batchAllocations }, expanded)
io.check('同目的地第二托增加箱托数但路线稳定去重', null, initial.data.dispatchInfo.route === current.data.dispatchInfo.route && Number(current.data.counts.palletCount) === Number(initial.data.counts.palletCount) + 1)
const item = current.items.items.find(i => i.sourceKey === duplicate.sourceKey)
current = await mutate('移除同目的地第二托', `/items/${encodeURIComponent(item.dispatchItemId)}/remove`, { reason: `${context.marker}-释放额外测试实物` }, candidates)
const removed = candidates.find(c => destination(c) !== destination(duplicate)), row = current.items.items.find(i => i.sourceKey === removed.sourceKey)
const reduced = candidates.filter(c => c.sourceKey !== removed.sourceKey), removePath = `${path}/items/${encodeURIComponent(row.dispatchItemId)}/remove`
current = await mutate('移除目的地最后一托', `/items/${encodeURIComponent(row.dispatchItemId)}/remove`, { reason: `${context.marker}-路线核验` }, reduced)
io.check('最后一托移除后路线删除该目的地', null, !String(current.data.dispatchInfo.route).split(',').includes(destination(removed)))
await unchangedAfterRejection(io, 'POST', removePath, { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('missing-item'), reason: '明确缺失明细' }, 'A0404', current, dispatchNo, reduced)
const add = createBody(context.marker, 'C52-restore', [removed])
current = await mutate('重新加入已移除目的地', '/items', { cargo: add.cargo, batchAllocations: add.batchAllocations }, candidates)
const duplicateSource = sourceSelection(candidates[0])
await unchangedAfterRejection(io, 'POST', `${path}/items`, { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('duplicate-source'), cargo: [duplicateSource, duplicateSource], batchAllocations: [] }, 'A0400', current, dispatchNo, candidates)
const otherIo = dispatchIo(context, MODULE, proof, 'C52-FOREIGN')
let other = await newDraft(otherIo, candidates[0].warehouseId)
other = await otherIo.command('POST', `${BASE}/drafts/containers`, { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('container'), draftToken: other.draftToken, capacity: 15 }, '创建本轮独立冲突草稿容器')
await unchangedAfterRejection(io, 'POST', `${BASE}/draft-locks/lock`, { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('lock-conflict'), draftToken: other.draftToken, draftContainerId: other.containers[0].draftContainerId, source: sourceSelection(candidates[0]) }, 'A0409', current, dispatchNo, candidates)
const releasedBody = { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('release'), draftToken: other.draftToken }
const released = await otherIo.command('POST', `${BASE}/draft-locks/release`, releasedBody, '释放确认未取得来源的冲突草稿')
await replay(otherIo, `${BASE}/draft-locks/release`, releasedBody, released, '释放原键重放')
const originalStops = current.route.stops, keys = originalStops.slice(1).map(s => s.destinationKey)
current = await mutate('重排实际卸货顺序', '/route', { destinationKeys: [...keys].reverse(), reason: `${context.marker}-明确既有站序` }, candidates, 'PUT')
io.check('真实起点保留且卸货顺序反转', null, same(current.route.stops[0], originalStops[0]) && same(current.route.stops.slice(1).map(s => s.destinationKey), [...keys].reverse()))
current = await mutate('恢复实际卸货顺序', '/route', { destinationKeys: keys, reason: `${context.marker}-恢复原站序` }, candidates, 'PUT')
io.check('原实际站序恢复', null, same(current.route.stops, originalStops))
const available = await candidatePage(io, candidates[0].warehouseId)
io.check('额外实物已释放为真实候选', null, available.some(c => c.sourceKey === duplicate.sourceKey && c.blockReasons?.length === 0))
proof.status = 'VERIFIED'; return current
} catch (error) { proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; throw error }
}
async function removeControl(context, io, candidate) {
const body = createBody(context.marker, 'REMOVAL-CONTROL', [candidate])
const command = await io.command('POST', BASE, body, '正式Create创建独立移货对照')
await replay(io, BASE, body, command, '正式创建原键恢复')
let current = await readDispatch(io, command.dispatchNo)
io.check('对照车来自本轮真实来源', null, dispatchRouteMatches(current, [candidate]))
const item = current.items.items[0]
const remove = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('control-remove'), reason: `${context.marker}-未拣实物释放` }
await io.command('POST', `${BASE}/${encodeURIComponent(command.dispatchNo)}/items/${encodeURIComponent(item.dispatchItemId)}/remove`, remove, '移出原生明细')
current = await readDispatch(io, command.dispatchNo)
io.check('最后实物移出后真实数量归零', null, dispatchRouteMatches(current, [])); return command.dispatchNo
}
async function prepareDraft(context, io, candidates) {
let draft = await newDraft(io, candidates[0].warehouseId)
const change = async (action, path, fields, method = 'POST') => {
const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key(action), draftToken: draft.draftToken, ...fields }
draft = await io.command(method, path, body, action); await replay(io, path, body, draft, `${action}原键重放`, method); return draft
}
await change('临时容器', `${BASE}/drafts/containers`, { capacity: 15 })
const temporary = draft.containers[0].draftContainerId, source = sourceSelection(candidates[0])
await change('临时选货', `${BASE}/draft-locks/lock`, { draftContainerId: temporary, source })
const occupied = await activeDraft(io, draft.draftToken)
const invalid = await io.send('DELETE', `${BASE}/drafts/${encodeURIComponent(draft.draftToken)}/containers/${encodeURIComponent(temporary)}`, { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('nonempty-delete'), reason: '非空容器删除负例' }, '非空容器拒绝')
io.check('非空容器不能删除', invalid, [200, 409].includes(invalid?.http) && String(invalid.body?.code) === 'A0409')
io.check('拒绝删除后草稿不变', null, same(await activeDraft(io, draft.draftToken), occupied))
await change('解除原容器来源', `${BASE}/draft-locks/unlock`, { draftContainerId: temporary, source })
const free = await candidatePage(io, candidates[0].warehouseId)
io.check('解锁恢复真实候选', null, free.some(c => c.sourceKey === candidates[0].sourceKey && c.blockReasons?.length === 0))
await change('删除已空容器', `${BASE}/drafts/${encodeURIComponent(draft.draftToken)}/containers/${encodeURIComponent(temporary)}`, { reason: `${context.marker}-空容器` }, 'DELETE')
await change('正式容器', `${BASE}/drafts/containers`, { capacity: 15 })
const container = draft.containers[0].draftContainerId
for (const [n, candidate] of candidates.entries()) await change(`正式选货-${n}`, `${BASE}/draft-locks/lock`, { draftContainerId: container, source: sourceSelection(candidate) })
await change('完整布局', `${BASE}/drafts/snapshot`, { planIds: [], containers: draft.containers })
const own = await candidatePage(io, candidates[0].warehouseId, { draftToken: draft.draftToken })
io.check('原草稿仅放行自己的可选来源', null, candidates.every(c => own.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)))
const hidden = await candidatePage(io, candidates[0].warehouseId)
io.check('无草稿身份不能选择活动占用', null, candidates.every(c => !hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)))
return draft
}
async function createRelabel(context, io, proof) {
const fixture = context.createdResources?.phase4OverseasRelabel
if (fixture?.marker !== context.marker || fixture.status !== 'READY_FOR_OUTBOUND') return
const page = await candidatePage(io, fixture.overseasWarehouseId, { orderNo: fixture.relabelNo, sourceStage: 'RELABEL' })
const candidates = page.filter(c => c.selection?.sourceType === 'RELABEL' && c.selection.relabelNo === fixture.relabelNo && id(c.customerId) === id(fixture.customerId)
&& id(c.warehouseId) === id(fixture.overseasWarehouseId) && fixture.targetPalletIds?.map(id).includes(id(c.selection.warehousePalletId)))
if (!candidates.length || candidates.length !== fixture.targetPalletIds.length) { proof.relabel = { status: 'NOT_RUN', reason: '没有本轮完整换标申请来源' }; return }
const body = createBody(context.marker, 'RELABEL', candidates), created = await io.command('POST', BASE, body, '真实换标整单Create')
await replay(io, BASE, body, created, '换标Create原键恢复')
const current = await readDispatch(io, created.dispatchNo)
io.check('整单换标来源、客户仓及数量回查', null, dispatchMatches(current, candidates))
Object.assign(fixture, { dispatchNo: created.dispatchNo, finalStatus: current.data.status, stage: current.data.stage, data: current.data })
proof.relabel = { dispatchNo: created.dispatchNo, status: 'CREATED_BEFORE_LOADING', sourceKeys: candidates.map(c => c.sourceKey), cartonMarksVerification: 'NOT_RUN_PUBLIC_DETAIL_OMITS_MARKS' }
}
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',
context.createdResources ??= {}
if (context.createdResources.phase4OverseasDispatch?.requests?.some(r => !['GET', 'HEAD'].includes(r.method))) throw Error('本轮排车已经尝试写入,须审核原回执,禁止自动重建')
const proof = context.createdResources.phase4OverseasDispatch = { marker: context.marker, status: 'CHECKING', dispatchNo: null, finalStatus: null, stage: null, requests: [] }
try {
const fixture = context.createdResources.phase4OverseasPda
markerCandidates(context, [])
let io = dispatchIo(context, MODULE, proof, 'MAIN')
const page = await candidatePage(io, fixture.overseasWarehouseId)
const routeFixture = selectDispatchRouteFixture(markerCandidates(context, page))
if (!routeFixture) throw new DispatchNotRun('本轮同客户仓需两个FBA目的地及同目的地双实托,不借历史样本')
const selected = routeFixture.selected
;[...selected, routeFixture.duplicate].forEach((c, n) => createBody(context.marker, `PRECHECK-${n}`, [c]))
context = await dispatchEvidenceContext(context)
io = dispatchIo(context, MODULE, proof, 'MAIN')
proof.removalDispatchNo = await removeControl(context, io, selected[0])
const grouped = await verifyGroupedDispatch(context, MODULE, selected)
if (grouped.status !== 'GROUPED_DISPATCH_VERIFIED') throw new DispatchNotRun(grouped.reason)
const draft = await prepareDraft(context, io, selected)
proof.draftToken = draft.draftToken
const groups = [{ draftContainerId: draft.containers[0].draftContainerId, dispatch: createBody(context.marker, 'MAIN-CHILD', draft.sources) }]
const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('convert'), draftToken: draft.draftToken, groups }
const converted = await io.command('POST', `${BASE}/drafts/pre-arrange`, body, '真实草稿容器转换主链整车')
io.check('转换返回原服务器草稿和唯一子命令', null, converted?.draftToken === draft.draftToken && converted.vehicles?.length === 1 && converted.vehicles[0].dispatch?.action === 'CREATE')
const dispatchNo = converted.vehicles[0].dispatch.dispatchNo
let current = await readDispatch(io, dispatchNo)
io.check('转换成功后回查真实来源', null, dispatchRouteMatches(current, selected))
await replay(io, `${BASE}/drafts/pre-arrange`, body, converted, '主链转换原键恢复')
const active = await io.command('GET', `${BASE}/drafts`, undefined, '转换后不再出现在活动草稿')
io.check('已转换草稿退出活动列表', null, Array.isArray(active) && !active.some(d => d.draftToken === draft.draftToken))
current = await verifyDispatchRouteLifecycle(context, { dispatchNo, candidates: selected, duplicate: routeFixture.duplicate })
Object.assign(proof, { dispatchNo, warehouseId: current.warehouseId, customerId: fixture.customerId,
customerIds: sorted(new Set(current.items.items.map(i => i.customerId))), inboundCodes: sorted(new Set(current.items.items.map(i => i.inboundCode))),
planPalletIds: selected.map(c => c.selection.planPalletId), destinationWarehouseCodes: sorted(new Set(current.data.orderDetails.map(o => o.destinationFbaWarehouse))),
sourceKeys: sorted(new Set(current.data.orderDetails.map(o => o.sourceKey))), recordVersion: current.data.recordVersion,
finalStatus: current.data.status, stage: current.data.stage, data: current.data, items: current.items, route: current.route,
sourceCandidates: selected, status: 'VERIFIED', loadingEvidenceStatus: 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS' })
io.check('交接Detail真实来源键和目的地守恒', null, same(proof.sourceKeys, sorted(selected.map(c => c.sourceKey))) && proof.finalStatus === 'ACTIVE' && proof.stage === 'BEFORE_LOADING')
await createRelabel(context, io, proof)
await context.checkpoint()
} catch (error) {
if (error.notRun && !proof.requests.some(r => !['GET', 'HEAD'].includes(r.method))) return notRun(context, proof, error.message)
proof.outcomeUnknown ||= context.createdResources.dispatchWriteBoundary?.outcomeUnknown === true
proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message
try { await context.checkpoint?.() } catch (persistError) { proof.persistenceError = persistError.message }
throw error
}
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)
return proof
}