const BASE = '/api/platform/v1/last-leg/dispatches' const OPTIONS = { scenario: 'grouped-dispatch-atomic-draft', deploymentBlocker: true } const id = value => value == null ? '' : String(value) const ok = response => response?.http === 200 && String(response.body?.code) === '200' const sorted = values => [...values].map(id).sort() export function groupedDispatchMatches(result, groups, candidates) { if (!Array.isArray(result) || result.length !== groups.length) return false const dispatchNos = new Set(), seen = new Set() return result.every(row => { const group = groups.find(group => id(group.draftContainerId) === id(row.draftContainerId)) const dispatch = row.dispatch if (!group || seen.has(id(row.draftContainerId)) || !dispatch?.dispatchNo || dispatchNos.has(dispatch.dispatchNo) || dispatch.status !== 'PENDING_DEPARTURE' || !Array.isArray(dispatch.items)) return false seen.add(id(row.draftContainerId)); dispatchNos.add(dispatch.dispatchNo) const expected = candidates.filter(candidate => group.planPalletIds.includes(id(candidate.planPalletId))) if (JSON.stringify(sorted(dispatch.items.map(item => item.planPalletId))) !== JSON.stringify(sorted(group.planPalletIds)) || dispatch.items.length !== group.planPalletIds.length || Number(dispatch.palletCount) !== expected.length || Number(dispatch.packageCount) !== expected.reduce((sum, item) => sum + Number(item.packageCount), 0)) return false return dispatch.items.every(item => expected.some(candidate => id(candidate.planPalletId) === id(item.planPalletId) && id(candidate.warehousePalletId) === id(item.warehousePalletId) && id(candidate.overseasWarehouseId) === id(item.overseasWarehouseId) && candidate.inboundCode === item.inboundCode && Number(candidate.packageCount) === Number(item.packageCount))) }) } // Reuses only current-run unpicked stock; independent empty dispatch records remain as audit evidence. export async function verifyGroupedDispatch(context, module, candidates) { if (!Array.isArray(candidates) || candidates.length < 2 || new Set(candidates.map(row => id(row.planPalletId))).size !== candidates.length || new Set(candidates.map(row => id(row.warehousePalletId))).size !== candidates.length || candidates.some(row => ['planId', 'planPalletId', 'warehousePalletId', 'overseasWarehouseId', 'customerId'] .some(key => typeof row[key] !== 'string' || !/^[1-9]\d*$/.test(row[key])) || typeof row.inboundCode !== 'string' || !row.inboundCode.trim() || !Number.isSafeInteger(Number(row.packageCount)) || Number(row.packageCount) < 1)) { throw Error('分组预排缺少本轮两个独立实物托盘') } const draftToken = `${context.marker}-GROUPED`.slice(0, 64) const proof = { marker: context.marker, draftToken, status: 'CHECKING', groups: [], results: [], cleanup: 'PENDING' } context.createdResources.groupedDispatch = proof const check = (label, response, passed) => { context.assert(module, label, response, passed, `draftToken=${draftToken}`, OPTIONS) if (!passed) throw Error(`分组预排:${label}`) } const request = async (method, path, body) => { let response try { response = await context.request(method, path, body) } catch (error) { if (!['GET', 'HEAD'].includes(method)) { proof.outcomeUnknown = true proof.cleanup = 'PRESERVED_FOR_REVIEW' } throw error } if (!['GET', 'HEAD'].includes(method) && (!response?.http || response.http >= 500)) { proof.outcomeUnknown = true proof.cleanup = 'PRESERVED_FOR_REVIEW' } return response } const command = async (method, path, body, label) => { const response = await request(method, path, body) check(label, response, ok(response)); return response.body.data } const ownDraft = async () => { const rows = await command('GET', `${BASE}/drafts`, undefined, '读取分组预排草稿作用域') if (!Array.isArray(rows)) throw Error('分组预排草稿返回格式无效') return rows.filter(row => row.draftToken === draftToken) } try { await command('POST', `${BASE}/drafts/snapshot`, { draftToken, planIds: [...new Set(candidates.map(row => id(row.planId)))], containersJson: '[]' }, '保存本轮分组草稿') for (const candidate of candidates) { const container = await command('POST', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }, '建立独立装车分组') if (!container?.id) throw Error('分组缺少服务端柜ID') const group = { draftContainerId: id(container.id), planPalletIds: [id(candidate.planPalletId)], returnReceiptIds: [] } proof.groups.push(group) await command('POST', `${BASE}/draft-locks/lock`, { draftToken, draftContainerId: group.draftContainerId, planPalletId: group.planPalletIds[0] }, '将本轮托盘绑定到所属装车分组') } const before = JSON.stringify(await ownDraft()) const swapped = proof.groups.map((group, index) => ({ ...group, planPalletIds: proof.groups[(index + 1) % proof.groups.length].planPalletIds })) const rejected = await request('POST', `${BASE}/drafts/pre-arrange`, { draftToken, groups: swapped }) check('来源与分组不一致明确拒绝A0409', rejected, [200, 409].includes(rejected.http) && String(rejected.body?.code) === 'A0409') check('分组拒绝后原草稿全部来源与绑定不变', null, JSON.stringify(await ownDraft()) === before) const body = { draftToken, groups: proof.groups } const result = await command('POST', `${BASE}/drafts/pre-arrange`, body, '原子创建每组独立客户排车') if (Array.isArray(result)) proof.results = result check('每组一张排车且实物托盘与数量不跨组', null, groupedDispatchMatches(result, proof.groups, candidates)) const replay = await command('POST', `${BASE}/drafts/pre-arrange`, body, '同草稿同分组重放') check('分组重放保留原排车业务号与明细', null, JSON.stringify(replay) === JSON.stringify(result)) proof.status = 'GROUPED_PREARRANGE_CHECKED' } finally { // Only release resources whose successful response identified an owned dispatch; never retry an unknown POST. if (!proof.outcomeUnknown) { for (const row of proof.results) { const dispatchNo = row.dispatch?.dispatchNo if (!dispatchNo) continue const detail = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '清理前回查分组排车仍未拣货') check('仅清理本轮分组未拣托盘', null, detail?.status === 'PENDING_DEPARTURE' && groupedDispatchMatches([{ ...row, dispatch: detail }], proof.groups.filter(group => id(group.draftContainerId) === id(row.draftContainerId)), candidates) && detail.items.every(item => /^[1-9]\d*$/.test(id(item.id))) && Number(detail.pickedPackageCount) === 0 && Number(detail.shippedPackageCount) === 0) for (const item of detail.items) { await command('DELETE', `${BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.id))}`, undefined, '移除分组测试明细释放正式托盘锁') } const empty = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '回查分组对照单明细归零') check('分组对照排车释放后数量归零', null, empty.items?.length === 0 && Number(empty.palletCount) === 0 && Number(empty.packageCount) === 0) } await command('POST', `${BASE}/draft-locks/release`, { draftToken }, '释放本轮分组草稿') const available = await command('GET', `${BASE}/candidates?sourceStage=RECEIVED`, undefined, '分组检查后回查托盘可继续主链预排') check('分组验收来源回到正式候选', null, Array.isArray(available) && candidates.every(candidate => available.some(row => id(row.planPalletId) === id(candidate.planPalletId)))) proof.cleanup = 'OWNED_UNPICKED_SOURCES_RELEASED' } } proof.status = 'GROUPED_PREARRANGE_VERIFIED' return proof }