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
+2
View File
@@ -144,6 +144,8 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-dev-acceptance.env" s
`remaining = total - assigned`;随后尝试把箱数调整为权威总数加一,必须得到 `A0400`,并回查
plan/item 版本、分配箱数及总数投影全部零漂移。脚本不从当前已分配数量反推订单总箱数。
`phase3-loading` 复用本轮一件代发 fixture,核对 task items 与 inventory units 的 `unitType=SEALED_PALLET/palletId/palletNo/indivisible`,整托统一通过 `/batch` 提交 `PICK_PALLET + palletNo`。合法整托行混入非法普通行必须整批零写;普通 `PICK/PARTIAL/SKIP` 不得绕过整托限制。同参同键重放不得增加版本或审计,同键更换托盘码必须冲突。以上接入现有全量链,不建立独立 HTTP 入口。
`phase3-pda-convergence` 同时回查 `/tasks/pending` 与稳定 task detail:两者的 `workflowStatus`、`itemCount`、`pendingItemCount` 必须来自同一任务明细事实。全拣任务必须在两处均为 `AWAITING_REVIEW`,可执行证据 GET,但派生 `actions=[]`,不得重复发送 batch;操作历史必须恰有一次有效 `BATCH`。若 pending 仍按 assignment 返回 `IN_PROGRESS`,该轮以真实 HTTP 失败阻断,不由脚本或前端改写状态。
部署后需要定向验证该完整链时,模块顺序固定为:
@@ -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)
@@ -104,9 +104,9 @@ test('measurement and loading pass destination identity into the LOADED producer
assert.match(loading, /canonicalPickItems\.map/)
assert.match(loading, /pickedPackageCount: Number\(item\.plannedPackageCount\)/)
assert.match(loading, /const dropshipWholePallet = source\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'/)
assert.match(loading, /tasks\/\$\{canonicalTaskId\}\/pallets\/pick/)
assert.match(loading, /palletNo: candidate\.palletNo/)
assert.match(loading, /canonicalPickAction = dropshipWholePallet \? 'PICK_PALLET' : 'BATCH'/)
assert.match(loading, /const canonicalPickPath = batchPath/)
assert.match(loading, /action: 'PICK_PALLET', palletNo: item\.palletNo/)
assert.match(loading, /const canonicalPickAction = 'BATCH'/)
assert.match(loading, /一件代发整托生产者不执行可拆分动态增量场景/)
assert.match(loading, /!dropshipWholePallet && Array\.isArray\(incrementalFixture\?\.skuCodes\)/)
assert.match(pda, /find\(\(item\) => item\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP'\)/)
@@ -148,3 +148,22 @@ test('only PDA-operated variants traverse real outbound and carrier nodes before
assert.doesNotMatch(entry, /awaitingVariants\.length === 2/)
assert.match(entry, /awaitingVariants\.every\(\(item\) => item\.shippingReady === false/)
})
test('full phase3 chain validates sealed pallet query metadata and atomic batch protection', () => {
const loading = read('phase3-loading.mjs')
assert.match(loading, /canonicalPickItems\.every\(\(item\) => item\.unitType === 'SEALED_PALLET'/)
assert.match(loading, /unit\.unitType === 'SEALED_PALLET'/)
for (const field of ['pickItemId', 'loadingPlanItemId', 'inboundId', 'palletId', 'palletNo', 'indivisible']) {
assert.ok(loading.includes(`unit.${field}`), `inventory units must verify ${field}`)
}
assert.match(loading, /if \(!taskUnitsValid \|\| !inventoryUnitsValid\) return/)
assert.match(loading, /items: \[\.\.\.batchPayload\.items, \{/)
assert.match(loading, /taskItemsSignature\(unchangedAfterInvalid\) === taskItemsSignature\(beforeInvalidTask\)/)
assert.match(loading, /operationsSignature\(afterInvalidOperations\) === operationsSignature\(beforeInvalidOperations\)/)
assert.match(loading, /for \(const action of \['PICK', 'PARTIAL', 'SKIP'\]\)/)
assert.match(loading, /operationsSignature\(afterBypassOperations\) === operationsSignature\(beforeInvalidOperations\)/)
assert.match(loading, /整托batch同键改托盘码必须冲突/)
assert.match(loading, /item\.operationId === batchKey[\s\S]*?\.length === 1/)
assert.doesNotMatch(loading, /tasks\/\$\{canonicalTaskId\}\/pallets\/pick/)
})