2026-10-20 10:35:52 +08:00
|
|
|
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'
|
2026-10-08 17:07:57 +08:00
|
|
|
|
2026-08-25 00:24:43 +08:00
|
|
|
const MODULE = 'phase4-overseas-dispatch'
|
2026-10-20 10:35:52 +08:00
|
|
|
const destination = candidate => candidate?.destination?.warehouseCode
|
|
|
|
|
const same = (a, b) => canonical(a) === canonical(b)
|
2026-10-03 06:26:33 +08:00
|
|
|
const sorted = values => [...values].sort()
|
2026-10-20 10:35:52 +08:00
|
|
|
// 读取对象保持三个原生响应独立,不给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)) }
|
2026-10-03 06:26:33 +08:00
|
|
|
}
|
2026-10-20 10:35:52 +08:00
|
|
|
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))))
|
2026-10-03 06:26:33 +08:00
|
|
|
}
|
|
|
|
|
export function selectDispatchRouteFixture(candidates) {
|
|
|
|
|
const groups = new Map()
|
2026-10-20 10:35:52 +08:00
|
|
|
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])
|
2026-10-03 06:26:33 +08:00
|
|
|
}
|
|
|
|
|
const repeated = [...groups.values()].find(rows => rows.length >= 2)
|
2026-10-20 10:35:52 +08:00
|
|
|
const other = [...groups.values()].find(rows => rows !== repeated)
|
|
|
|
|
return groups.size >= 2 && repeated ? { selected: [repeated[0], other[0]], duplicate: repeated[1] } : null
|
2026-10-03 06:26:33 +08:00
|
|
|
}
|
2026-10-20 10:35:52 +08:00
|
|
|
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
|
2026-10-03 06:26:33 +08:00
|
|
|
}
|
2026-10-20 10:35:52 +08:00
|
|
|
// 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)
|
2026-10-20 15:48:38 +08:00
|
|
|
io.check('草稿锁为软占用不阻断其他草稿候选', null,
|
|
|
|
|
candidates.every(c => hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)),
|
|
|
|
|
`hidden=${JSON.stringify(candidates.map(c => ({ key: c.sourceKey, reasons: hidden.filter(a => a.sourceKey === c.sourceKey).map(a => a.blockReasons) }))).slice(0, 600)}`)
|
|
|
|
|
context.addCoverageGap?.(MODULE, 'dispatch-occupancy', '正式排车活动占用阻断',
|
|
|
|
|
'草稿锁是软占用,不阻断其他草稿;正式排车占用阻断由 grouped 正式整车转换与 removeControl 回查覆盖',
|
|
|
|
|
'not-implemented', { blocking: false })
|
2026-10-20 10:35:52 +08:00
|
|
|
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' }
|
2026-09-14 09:58:30 +08:00
|
|
|
}
|
2026-10-22 15:41:50 +08:00
|
|
|
|
|
|
|
|
/** 客户首页贴标原申请独立排车;不可借用旧PDA换标样本的通过结果。 */
|
|
|
|
|
export async function createClientRelabelDispatch(context, io, proof) {
|
|
|
|
|
const fixture = context.createdResources?.phase4OverseasClientServices?.relabelFulfillment
|
|
|
|
|
io.check('客户贴标排车前置为本轮已确认原申请', null, fixture?.marker === context.marker
|
|
|
|
|
&& fixture.status === 'READY_FOR_OUTBOUND' && fixture.execution?.status === 'HTTP_VERIFIED_READY_FOR_DISPATCH'
|
|
|
|
|
&& fixture.relabelNo && fixture.customerId && fixture.warehouseId && fixture.targetPalletIds?.length > 0
|
|
|
|
|
&& new Set(fixture.targetPalletIds.map(id)).size === fixture.targetPalletIds.length && !fixture.dispatchNo && !proof.clientRelabel)
|
|
|
|
|
const evidence = proof.clientRelabel = { marker: context.marker, relabelNo: fixture.relabelNo, status: 'CREATING' }
|
|
|
|
|
try {
|
|
|
|
|
const candidates = (await candidatePage(io, fixture.warehouseId,
|
|
|
|
|
{ orderNo: fixture.relabelNo, sourceStage: 'RELABEL', planIds: [] }))
|
|
|
|
|
.filter(c => c.selection?.sourceType === 'RELABEL' && c.selection.relabelNo === fixture.relabelNo
|
|
|
|
|
&& id(c.customerId) === id(fixture.customerId) && id(c.warehouseId) === id(fixture.warehouseId))
|
|
|
|
|
io.check('客户原申请目标托盘候选完整且唯一', null, candidates.length === fixture.targetPalletIds.length
|
|
|
|
|
&& new Set(candidates.map(c => id(c.selection.warehousePalletId))).size === candidates.length
|
|
|
|
|
&& candidates.every(c => fixture.targetPalletIds.map(id).includes(id(c.selection.warehousePalletId))))
|
|
|
|
|
const body = createBody(context.marker, 'CLIENT-RELABEL', candidates)
|
|
|
|
|
evidence.createRequest = body
|
|
|
|
|
const created = await io.command('POST', BASE, body, '客户贴标原申请创建独立排车')
|
|
|
|
|
io.check('客户原申请排车返回业务单号', null, typeof created?.dispatchNo === 'string' && created.dispatchNo.length > 0)
|
|
|
|
|
evidence.dispatchNo = created.dispatchNo
|
|
|
|
|
await replay(io, BASE, body, created, '客户原贴标申请排车创建重放')
|
|
|
|
|
const current = await readDispatch(io, created.dispatchNo)
|
|
|
|
|
io.check('客户贴标排车逐行匹配原申请实托与箱数', null, dispatchMatches(current, candidates))
|
|
|
|
|
Object.assign(fixture, { dispatchNo: created.dispatchNo, dispatchStage: current.data.stage })
|
|
|
|
|
Object.assign(evidence, { status: 'CREATED_BEFORE_LOADING', sourceKeys: candidates.map(c => c.sourceKey) })
|
|
|
|
|
await context.checkpoint()
|
|
|
|
|
} catch (error) {
|
|
|
|
|
evidence.status = 'FAILED'; evidence.failure = error.message
|
|
|
|
|
await context.checkpoint(); throw error
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-25 00:24:43 +08:00
|
|
|
export async function run(context) {
|
2026-10-20 10:35:52 +08:00
|
|
|
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
|
2026-10-20 15:48:38 +08:00
|
|
|
const dispatchPlanIds = () => (context.createdResources?.phase4OverseasPda?.generatedPlanIds || []).map(String)
|
2026-10-20 10:35:52 +08:00
|
|
|
markerCandidates(context, [])
|
|
|
|
|
let io = dispatchIo(context, MODULE, proof, 'MAIN')
|
2026-10-20 15:48:38 +08:00
|
|
|
io.planIds = dispatchPlanIds()
|
2026-10-20 10:35:52 +08:00
|
|
|
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')
|
2026-10-20 15:48:38 +08:00
|
|
|
io.planIds = dispatchPlanIds()
|
2026-10-20 10:35:52 +08:00
|
|
|
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)
|
2026-10-22 15:41:50 +08:00
|
|
|
await createClientRelabelDispatch(context, io, proof)
|
2026-10-20 10:35:52 +08:00
|
|
|
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
|
2026-08-25 00:24:43 +08:00
|
|
|
}
|
2026-10-20 10:35:52 +08:00
|
|
|
return proof
|
2026-08-25 00:24:43 +08:00
|
|
|
}
|