test(transfer): preserve rejected bind evidence and enforce completed unit facts

This commit is contained in:
2026-10-05 02:50:29 +08:00
parent b371fab53d
commit e1cb01e6fb
3 changed files with 153 additions and 9 deletions
@@ -81,8 +81,9 @@ export const TRANSFER_STAGES = Object.freeze({
approve: {actor:'platform',permission:'platform:overseas-warehouse:relabel-order:status',replay:'SAME_STATUS_NOOP'},
claim: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'},
'create-work-pallet': {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'},
bind: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'},
unit: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'},
'unit-carton': {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'},
bind: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'},
photo: {actor:'pda',permission:'lastleg:pda:operate',replay:'NONE'},
complete: {actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED'},
'review-read': {actor:'platform',permission:'platform:overseas-warehouse:relabel-order:list',replay:'READ_ONLY'},
@@ -123,11 +124,20 @@ export function prepareTransferStage(plan, stage, context={}) {
const pdaBase=`/pda/v1/last-leg/relabel-tasks/${no}`
const platformBase=`/platform/v1/last-leg/relabel-applications/${no}`
const task=context.task
if(['claim','unit','complete'].includes(stage))demand(task?.relabelNo===plan.transfer.relabelNo&&task.customerOrderNo===TRANSFER,'OWN_CURRENT_PDA_TASK_REQUIRED')
if(['claim','unit','unit-carton','complete'].includes(stage))demand(task?.relabelNo===plan.transfer.relabelNo&&task.customerOrderNo===TRANSFER,'OWN_CURRENT_PDA_TASK_REQUIRED')
if(stage==='approve'){command.method='PUT';command.path=platformBase+'/status';command.body={status:'PENDING_PROCESSING',remark:key}}
if(stage==='claim'){command.path=pdaBase+'/claim';command.body={idempotencyKey:key}}
if(stage==='create-work-pallet'){command.path='/pda/v1/last-leg/pallet-operations/relabel-pallet-label';command.body={relabelNo:plan.transfer.relabelNo,idempotencyKey:key}}
if(stage==='bind'){
demand(task?.relabelNo===plan.transfer.relabelNo&&task.customerOrderNo===TRANSFER
&&task.taskStatus==='PROCESSING'&&Number(task.expectedPackages)===1&&Number(task.relabelledPackages)===1
&&Number(task.skippedPackages)===0&&Number(task.exceptionPackages)===0,'COMPLETE_UNIT_FACTS_BEFORE_BIND_REQUIRED')
const selectedItems=task.items?.filter(i=>context.observedCartonMarks?.includes(i.cartonMark))||[]
demand(selectedItems.length===1&&selectedItems.every(i=>{
if(!Number.isInteger(i.expectedPackages)||i.expectedPackages!==1)return false
const facts=(task.unitFacts||[]).filter(f=>String(f.itemId)===String(i.itemId)&&f.result==='RELABELLED')
return facts.length===1&&Number.isInteger(facts[0].quantity)&&facts[0].quantity===1
}), 'OWN_RELABELLED_UNIT_FACT_BEFORE_BIND_REQUIRED')
const {source,target,observedCartonMarks}=context
demand(source&&target&&String(source.warehouseId)===LA&&String(target.warehouseId)===LA
&&target.destinationWarehouseCode==='ABE4'&&source.palletCode!==target.palletCode,'FORMAL_LA_WORK_PALLETS_REQUIRED')
@@ -149,6 +159,14 @@ export function prepareTransferStage(plan, stage, context={}) {
command.path=pdaBase+'/units';command.body={itemId:item.itemId,oldLabel:context.observedOldLabel,newLabel:context.observedNewLabel,
expectedVersion:version(task.version),idempotencyKey:key,skuVerifications:context.observedSkus}
}
if(stage==='unit-carton'){
const item=task.items?.find(i=>String(i.itemId)===String(context.itemId))
demand(task.taskStatus==='PROCESSING'&&task.relabelledPackages===0&&item?.operationType==='CARTON_AND_SKU'
&&item.expectedPackages===1&&item.skuProcessedPackages===1&&item.cartonProcessedPackages===0
&&!(task.unitFacts||[]).some(f=>String(f.itemId)===String(item.itemId)), 'OWN_SKU_ONLY_PARTIAL_UNIT_REQUIRED')
demand(context.observedOldLabel===item.cartonMark&&context.observedNewLabel===item.fbaCartonMark,'OBSERVED_LABELS_MUST_MATCH_OWN_ITEM')
command.path=pdaBase+'/cartons/scan';command.body={cartonMark:context.observedOldLabel,expectedVersion:version(task.version),idempotencyKey:key}
}
if(stage==='photo'){
const photo=context.photo
demand(photo&&/^[a-f0-9]{64}$/.test(photo.sha256)&&Number.isSafeInteger(photo.sizeBytes)&&photo.sizeBytes>0
@@ -198,7 +216,14 @@ export function prepareTransferStage(plan, stage, context={}) {
command.preparedHash=commandHash(command)
return command
}
export async function runPreparedTransferStage({command,release,actor,transport,journalDirectory,binary,replay=false}) {
export function redactTransferMessage(message) {
if(typeof message!=='string')return undefined
return message.replace(/https?:\/\/\S+/gi,'[url redacted]')
.replace(/Bearer\s+\S+/gi,'Bearer [redacted]')
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,'[token redacted]')
.replace(/\b(password|accessToken|refreshToken|username|loginId)\s*[:=]\s*[^\s,;]+/gi,'$1=[redacted]').slice(0,1000)
}
export async function runPreparedTransferStage({command,release,actor,transport,journalDirectory,binary,replay=false,recovery}) {
if(!release?.execute)return {mode:'PLAN',networkIssued:false,command}
demand(release.environment==='TEST'&&release.transferNo===TRANSFER&&release.customerId===CUSTOMER
&&release.stage===command.stage&&release.runId===command.runId,'EXACT_STAGE_RELEASE_REQUIRED')
@@ -218,7 +243,36 @@ export async function runPreparedTransferStage({command,release,actor,transport,
if(command.multipart)demand(binary&&binary.length===command.multipart.sizeBytes
&&crypto.createHash('sha256').update(binary).digest('hex')===command.multipart.sha256,'PHOTO_BYTES_CHANGED')
fs.mkdirSync(journalDirectory,{recursive:true})
const file=path.join(journalDirectory,`${command.runId}-${command.stage}.json`)
const originalFile=path.join(journalDirectory,`${command.runId}-${command.stage}.json`)
let file=originalFile
if(recovery){
demand(recovery.authorization==='F33_BIND_A0409_AFTER_UNIT'&&command.stage==='bind'
&&command.runId==='f33-original-20261005-r1'
&&command.preparedHash==='bb1ccb10ebaef5f37d8f16782ae6f8f2051d31061142533a61e8ab020c1e2a0d', 'ONLY_RELEASED_ORIGINAL_F33_BIND_RECOVERY_ALLOWED')
const readProof=(name,sha)=>{
demand(typeof name==='string'&&path.basename(name)===name&&/^[a-f0-9]{64}$/.test(sha),'EXACT_LOCAL_RECOVERY_PROOF_REQUIRED')
const bytes=fs.readFileSync(path.join(journalDirectory,name))
demand(crypto.createHash('sha256').update(bytes).digest('hex')===sha,'RECOVERY_PROOF_CHANGED')
return JSON.parse(bytes)
}
demand(fs.existsSync(originalFile),'ORIGINAL_REJECTED_BIND_JOURNAL_REQUIRED')
const original=readProof(path.basename(originalFile),recovery.originalJournalSha256)
demand(original.phase==='ACKNOWLEDGED'&&original.http===200&&original.code==='A0409'
&&original.success===false&&!original.replay&&original.command.preparedHash===command.preparedHash
&&commandHash(original.command)===command.preparedHash,'ONLY_KNOWN_ACKNOWLEDGED_A0409_BIND_CAN_RECOVER')
const rollback=readProof(recovery.rollbackProofFile,recovery.rollbackProofSha256)
demand(rollback.verified===true&&['transfer','positions','ledger','linked','order','task']
.every(k=>rollback.unchanged?.includes(k)),'COMPLETE_REJECTION_ROLLBACK_PROOF_REQUIRED')
const fresh=readProof(recovery.freshProofFile,recovery.freshProofSha256)
demand(fresh.runId===command.runId&&fresh.transferNo===TRANSFER&&fresh.relabelNo===command.body.relabelNo
&&fresh.originalKeyNotExecuted===true&&fresh.unitFactsVerified===true
&&fresh.unitFactCount===1&&fresh.relabelledPackages===1&&fresh.stockAndLedgerUnchangedByUnit===true
&&fresh.sourcePalletCode===command.body.source.palletCode&&fresh.targetPalletCode===command.body.target.palletCode
&&fresh.sourcePalletVersion===command.body.source.expectedVersion&&fresh.targetPalletVersion===command.body.target.expectedVersion
&&String(fresh.sourcePositionId)===String(command.body.actualSource.positionId)
&&fresh.sourcePositionVersion===command.body.actualSource.expectedPositionVersion,'FRESH_UNIT_AND_UNCHANGED_ORIGINAL_VERSIONS_REQUIRED')
file=path.join(journalDirectory,`${command.runId}-bind-recovery.json`)
}
let journal
if(fs.existsSync(file)){
journal=JSON.parse(fs.readFileSync(file,'utf8'))
@@ -228,13 +282,15 @@ export async function runPreparedTransferStage({command,release,actor,transport,
journal.replay={phase:'INTENT'};fs.writeFileSync(file,JSON.stringify(journal,null,2))
}else{
demand(!replay,'ORIGINAL_ACKNOWLEDGED_COMMAND_REQUIRED')
journal={command,actor:{kind:actor.kind,membershipId:actor.membershipId,warehouseId:actor.warehouseId},phase:'INTENT',success:false,businessVerified:false}
journal={command,actor:{kind:actor.kind,membershipId:actor.membershipId,warehouseId:actor.warehouseId},phase:'INTENT',success:false,businessVerified:false,
...(recovery?{recovery:{authorization:recovery.authorization,originalJournalFile:path.basename(originalFile),originalJournalSha256:recovery.originalJournalSha256,
rollbackProofFile:recovery.rollbackProofFile,rollbackProofSha256:recovery.rollbackProofSha256,freshProofFile:recovery.freshProofFile,freshProofSha256:recovery.freshProofSha256}}:{})}
fs.writeFileSync(file,JSON.stringify(journal,null,2),{flag:'wx'})
}
const entry=replay?journal.replay:journal
try{
const response=await transport(command,binary)
Object.assign(entry,{phase:'ACKNOWLEDGED',http:response.http,code:String(response.code),requestId:response.requestId,traceId:response.traceId,resultDigest:digest(response.data??null)})
Object.assign(entry,{phase:'ACKNOWLEDGED',http:response.http,code:String(response.code),message:redactTransferMessage(response.message),requestId:response.requestId,traceId:response.traceId,resultDigest:digest(response.data??null)})
entry.success=response.http===200&&String(response.code)==='200'
fs.writeFileSync(file,JSON.stringify(journal,null,2))
demand(entry.success,'STAGE_REJECTED_STOP_NO_RECREATE')