This commit is contained in:
2026-10-08 17:07:57 +08:00
parent 16d2d9111a
commit c1ddb658bd
111 changed files with 2914 additions and 941 deletions
@@ -0,0 +1,124 @@
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
}