test(transfer): preserve rejected bind evidence and enforce completed unit facts
This commit is contained in:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user