fix(firstleg): preserve sealed pallet identity in atomic PDA picking

This commit is contained in:
2026-09-09 12:33:31 +08:00
parent 44ac24a9e2
commit ac3ce3703e
12 changed files with 323 additions and 47 deletions
@@ -701,6 +701,21 @@ export async function run(context) {
inventoryBlockIdentityVerified,
`inbounds=${inventoryBlockSnapshots.length}, blocks=${inventoryBlockSnapshots.map((entry) => entry.resources[0]?.blockId).join(',')}`, OPTIONS)
if (dropshipAssignment) {
const palletUnits = Array.isArray(inventory?.units) ? inventory.units : []
const taskUnitsValid = canonicalPickItems.every((item) => item.unitType === 'SEALED_PALLET'
&& id(item.palletId) === id(candidate.palletId) && item.palletNo === candidate.palletNo
&& /^DP-[0-9]{6}-[0-9]{4}$/.test(item.palletNo) && item.indivisible === true)
const inventoryUnitsValid = canonicalPickItems.every((item) => palletUnits.some((unit) =>
id(unit.pickItemId) === id(item.id) && id(unit.loadingPlanItemId) === id(item.loadingPlanItemId)
&& id(unit.inboundId) === id(item.inboundId) && unit.unitType === 'SEALED_PALLET'
&& id(unit.palletId) === id(item.palletId) && unit.palletNo === item.palletNo && unit.indivisible === true))
context.assert(MODULE, 'PDA task与inventory明确返回SEALED_PALLET及DP整托四字段', inventoryResponse,
taskUnitsValid && inventoryUnitsValid,
`taskUnits=${canonicalPickItems.length}, inventoryUnits=${palletUnits.length}`, OPTIONS)
if (!taskUnitsValid || !inventoryUnitsValid) return
}
const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch`
const batchKey = `${context.marker}-bridge-batch`.slice(0, 128)
const skipBatchKey = `${context.marker}-exception-skip-batch`.slice(0, 128)
@@ -715,8 +730,10 @@ export async function run(context) {
expectedVersion: Number(claimed?.version),
inventorySnapshotToken: inventory.snapshotToken,
items: canonicalPickItems.map((item) => ({
itemId: id(item.id), action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount),
version: Number(item.version),
itemId: id(item.id), version: Number(item.version),
...(dropshipWholePallet
? { action: 'PICK_PALLET', palletNo: item.palletNo }
: { action: 'PICK', pickedPackageCount: Number(item.plannedPackageCount) }),
})),
}
@@ -754,6 +771,30 @@ export async function run(context) {
&& operationsSignature(afterInvalidOperations) === operationsSignature(beforeInvalidOperations),
`taskVersion=${unchangedAfterInvalid?.version ?? 'missing'}, items=${taskItemsSignature(unchangedAfterInvalid)}, operations=${afterInvalidOperations?.length ?? 'missing'}`, OPTIONS)
if (dropshipWholePallet) {
for (const action of ['PICK', 'PARTIAL', 'SKIP']) {
const bypass = await request(context, 'POST', batchPath, {
...batchPayload,
items: canonicalPickItems.map((item) => ({ itemId: id(item.id), version: Number(item.version), action,
...(action === 'PICK' ? { pickedPackageCount: Number(item.plannedPackageCount) }
: action === 'PARTIAL' ? { finalPickedPackageCount: 1, reason: '整托不可部分拣货' }
: { reason: '整托不可普通跳过' }),
})),
}, { 'Idempotency-Key': `${context.marker}-pallet-bypass-${action}`.slice(0, 128) })
context.assert(MODULE, `整托不能通过普通${action}动作绕过`, bypass,
bypass.http === 200 && codeOf(bypass) === 'A0409', `code=${codeOf(bypass)}`, OPTIONS)
}
const afterBypassResponse = await context.request('GET', taskDetailPath)
const afterBypass = context.apiSuccess(MODULE, '整托绕过拒绝后任务回查', afterBypassResponse, '', OPTIONS)
const afterBypassOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
const afterBypassOperations = context.apiSuccess(MODULE, '整托绕过拒绝后审计回查', afterBypassOperationsResponse, '', OPTIONS)
context.assert(MODULE, '整托绕过失败后任务明细审计零写入', afterBypassResponse,
Number(afterBypass?.version) === Number(beforeInvalidTask?.version)
&& taskItemsSignature(afterBypass) === taskItemsSignature(beforeInvalidTask)
&& operationsSignature(afterBypassOperations) === operationsSignature(beforeInvalidOperations),
`taskVersion=${afterBypass?.version ?? 'missing'}`, OPTIONS)
}
const merchantLogin = await context.request('POST', '/api/auth/login', {
loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
}, '')
@@ -803,12 +844,8 @@ export async function run(context) {
id(restoredAssignment?.assignment?.assigneeId) === id(currentAssigneeId),
`assigneeId=${id(restoredAssignment?.assignment?.assigneeId) || 'missing'}`, OPTIONS)
const canonicalPickPath = dropshipWholePallet
? `${pdaBase}/tasks/${canonicalTaskId}/pallets/pick`
: batchPath
const canonicalPickPayload = dropshipWholePallet
? { warehouseId, palletNo: candidate.palletNo, expectedTaskVersion: Number(claimed?.version), operationId: batchKey }
: batchPayload
const canonicalPickPath = batchPath
const canonicalPickPayload = batchPayload
const canonicalPick = await request(context, 'POST', canonicalPickPath, canonicalPickPayload,
{ 'Idempotency-Key': batchKey })
const pickedCanonical = context.apiSuccess(MODULE,
@@ -831,15 +868,25 @@ export async function run(context) {
atomicBatchReplayVerified,
`HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS)
if (dropshipWholePallet) {
const palletHashConflict = await request(context, 'POST', batchPath, {
...batchPayload, items: batchPayload.items.map((item) => ({ ...item,
palletNo: item.palletNo === 'DP-991231-9999' ? 'DP-991231-9998' : 'DP-991231-9999' })),
}, { 'Idempotency-Key': batchKey })
context.assert(MODULE, '整托batch同键改托盘码必须冲突', palletHashConflict,
palletHashConflict.http === 200 && codeOf(palletHashConflict) === 'A0409',
`code=${codeOf(palletHashConflict)}`, OPTIONS)
}
const preFinalOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
const preFinalOperations = context.apiSuccess(MODULE, '回查下游门禁前拣货审计', preFinalOperationsResponse, '', OPTIONS)
const canonicalPickAction = dropshipWholePallet ? 'PICK_PALLET' : 'BATCH'
const canonicalPickAction = 'BATCH'
context.assert(MODULE, '最终确认前仅保留领取和拣货审计且无 COMPLETE_LOADING', preFinalOperationsResponse,
Array.isArray(preFinalOperations)
&& preFinalOperations.some((item) => item.actionType === 'CLAIM'
&& id(item.operatorId) && Boolean(item.createdAt))
&& preFinalOperations.some((item) => item.actionType === canonicalPickAction
&& id(item.operatorId) && Boolean(item.createdAt))
&& preFinalOperations.filter((item) => item.actionType === canonicalPickAction
&& item.operationId === batchKey && id(item.operatorId) && Boolean(item.createdAt)).length === 1
&& !preFinalOperations.some((item) => item.actionType === 'COMPLETE_LOADING')
&& !preFinalOperations.some((item) => item.actionType === 'REVIEW'),
`actions=${Array.isArray(preFinalOperations) ? preFinalOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)