fix(firstleg): preserve sealed pallet identity in atomic PDA picking
This commit is contained in:
@@ -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/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user