125 lines
8.0 KiB
JavaScript
125 lines
8.0 KiB
JavaScript
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
|
||
|
|
}
|