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
+2
View File
@@ -1,5 +1,7 @@
# 后端脚本管理与全量 HTTP 验收索引
F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`:只有完整逐箱作业事实后才可绑定实物托盘,不用默认数量补齐缺失事实。已明确拒绝的绑定仅允许当前已冻结原票、原批次、原命令摘要的 `F33_BIND_A0409_AFTER_UNIT` 恢复,必须同时核对不可变失败journal、完整回滚证明和新作业事实/原资源版本,恢复写入独立journal,不覆盖原失败。未知结果、其他拒绝和其他阶段仍禁止自动重试;响应message脱敏留存。该受限恢复只用于原TEST验收,不放宽生产接口或变更库存规则。
`inbound-plan-resource-fault-proxy.py --config <权限0600的私有配置>` 是柜级入仓计划资源确认的 TEST 定向故障工具。导入不执行,运行才监听业务端口与回环控制端口;复用 `order-submit-dependency-fault-proxy.py` 的 HTTP 传输和服务,配置必须显式 `environment=TEST`,冻结柜ID、海外仓ID、按当前协调器JSON字符串算法计算的柜批次前缀、Lastleg来源IP和内部身份。默认直通;持有私有控制凭据的 `CONFIRM_ONCE` 最长60秒,只让该柜精确 `/internal/v1/warehouse-pallet-batches/confirm` POST 返回一次503,随后自动直通。其他柜、预留/查询、错身份及不匹配请求保留原请求响应。凭据只读私有配置,脱敏证据写同目录 `proxy-evidence.jsonl`,不保存正文或令牌。工具不修改SQL或Docker,不自行重试业务或恢复服务路由;运行者须先安排独占故障窗口、记录并恢复原Lastleg配置/镜像及健康、停止本代理,结果未知先核对,不盲目重启。`inbound-plan-resource-fault-proxy.test.mjs` 为离线范围、单次并发、TTL与脱敏测试;工具检查通过不等于已执行真实故障与页面恢复。
`partial-loading-actual-test-env.mjs` 是 F21/F22 两柜实装与部分装柜的 TEST 专项入口,默认无参数或 `--plan` 零网络。使用显式授权 TEST 环境文件,匹配 Firstleg/触发器就绪并开放独占样本窗口后,才可传 `--execute --environment=TEST --run-id=PARTIAL-ACTUAL-<新批次> --fixture=NEW`,或用 `--fixture=<manifest.json>` 提供本轮已核对来源。新准备只走客户资格、下单、测量、装柜正式接口;A 覆盖混合客户/税制、短拣和跳过,B 的全部实装行均为 PARTIAL、退税来源全部跳过。检查实际数量/指标、报关范围及同命令重放;合成图片只证明 HTTP,不是现场证据。报告保存至 `reports/partial-loading-actual-PARTIAL-ACTUAL-<批次>/`,先记录写入意图,拒绝覆盖;失败保留原样本和报告,未知结果不得自动重造或重试。仅在明确续跑授权和原快照核对后接续,退出自己的会话;不执行 SQL/部署、船节点或历史回填,不代替事件 ACK/下游库存、完整报关文件和页面/设备验收。直接测试 `partial-loading-actual-test-env.test.mjs`。
@@ -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')
@@ -5,7 +5,7 @@ import path from 'node:path'
import os from 'node:os'
import crypto from 'node:crypto'
import {validateSource,validateDestination,relabelPrerequisite,TRANSFER,CUSTOMER} from './fba-remaining-nodes-acceptance-transfer.mjs'
import {LA,NY,prepareOriginalTransferPlan,prepareTransferStage,runPreparedTransferStage,verifyTransferMilestone} from './fba-remaining-nodes-acceptance-transfer.mjs'
import {LA,NY,prepareOriginalTransferPlan,prepareTransferStage,runPreparedTransferStage,verifyTransferMilestone,redactTransferMessage} from './fba-remaining-nodes-acceptance-transfer.mjs'
const detail={transferNo:TRANSFER,status:'APPROVED',packageCount:1,allocations:[],operatingWarehouseId:'w',sourceOrders:[{inboundOrderId:'o'}]}
const balance={id:'b',customerId:CUSTOMER,inboundOrderId:'o',overseasWarehouseId:'w'}
const position={id:'p',balanceId:'b',customerId:CUSTOMER,palletId:'t',availablePackages:1,frozenPackages:0,pickedPackages:0}
@@ -41,7 +41,8 @@ function linkedFixture(status='WAIT_RELABEL'){
const linked={application:{id:'app',relabelNo:transfer.relabelNo,lastlegOrderId:'tail',customerOrderNo:TRANSFER,overseasWarehouseId:LA},items:[{cartonMark:'BOX-1'}]}
const task={relabelNo:transfer.relabelNo,customerOrderNo:TRANSFER,taskStatus:'PROCESSING',version:2,
expectedPackages:1,relabelledPackages:1,skippedPackages:0,exceptionPackages:0,
items:[{itemId:'item',cartonMark:'BOX-1',fbaCartonMark:'FBA-BOX-1',operationType:'CARTON_AND_SKU',skuItems:[{skuCode:'OLD',newSkuCode:'NEW',quantity:1}]}],
items:[{itemId:'item',expectedPackages:1,cartonMark:'BOX-1',fbaCartonMark:'FBA-BOX-1',operationType:'CARTON_AND_SKU',skuItems:[{skuCode:'OLD',newSkuCode:'NEW',quantity:1}]}],
unitFacts:[{factId:'fact',itemId:'item',result:'RELABELLED',quantity:1}],
history:[{action:'PALLET_BIND',detail:JSON.stringify({round:'INITIAL',cartonMarks:['BOX-1'],targetPallets:['WORK-1']})}]}
return {transfer,linked,task,plan:prepareOriginalTransferPlan(transfer,'offline-contract')}
}
@@ -128,7 +129,7 @@ test('photo upload bytes are frozen and an acknowledged no-key upload cannot be
}finally{temp.cleanup()}
})
test('physical binding uses selected source and current versions without client customer/warehouse scope in PDA body',()=>{
const f=linkedFixture(),context={linked:f.linked,source:{warehouseId:LA,positionId:'selected-old',palletId:'source-pallet',palletCode:'SOURCE',palletVersion:3,positionVersion:4},target:{warehouseId:LA,palletCode:'WORK',palletVersion:0,destinationWarehouseCode:'ABE4'},observedCartonMarks:['BOX-1']}
const f=linkedFixture(),context={linked:f.linked,task:f.task,source:{warehouseId:LA,positionId:'selected-old',palletId:'source-pallet',palletCode:'SOURCE',palletVersion:3,positionVersion:4},target:{warehouseId:LA,palletCode:'WORK',palletVersion:0,destinationWarehouseCode:'ABE4'},observedCartonMarks:['BOX-1']}
const c=prepareTransferStage(f.plan,'bind',context)
assert.equal(c.body.actualSource.positionId,'selected-old');assert.equal(c.body.actualSource.packages,1);assert.equal(c.body.target.expectedVersion,0)
assert.equal(c.body.customerId,undefined);assert.equal(c.body.warehouseId,undefined)
@@ -147,3 +148,88 @@ test('one original carton stays conserved and existing HOLD and append-only fact
assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1,ledgerAfter:[{id:'old',packages:2}]}))
assert.throws(()=>verifyTransferMilestone('receipt',{...base,sourceTotal:3,targetTotal:1,ledgerAfter:[ship,{...receipt,skuCode:'OLD'},{id:'old',packages:1}]}))
})
test('actual PDA detail shape requires unit facts before bind and allows unit without PALLET_BIND history',()=>{
const f=linkedFixture(),context={linked:f.linked,task:f.task,
source:{warehouseId:LA,positionId:'selected-old',palletId:'source-pallet',palletCode:'SOURCE',palletVersion:1,positionVersion:4},
target:{warehouseId:LA,palletCode:'WORK',palletVersion:0,destinationWarehouseCode:'ABE4'},observedCartonMarks:['BOX-1']}
assert.ok(f.plan.phases.findIndex(x=>x.stage==='unit')<f.plan.phases.findIndex(x=>x.stage==='bind'))
assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,task:{...f.task,relabelledPackages:0,unitFacts:[]}}),/COMPLETE_UNIT_FACTS/)
for(const facts of [[],[{itemId:'other',result:'RELABELLED',quantity:1}],[{itemId:'item',result:'SKIPPED',quantity:1}],[{itemId:'item',result:'RELABELLED',quantity:2}]])
assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,task:{...f.task,unitFacts:facts}}),/OWN_RELABELLED_UNIT_FACT/)
for(const expectedPackages of [undefined,null,0,NaN,2,'1'])
assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,task:{...f.task,items:f.task.items.map(i=>({...i,expectedPackages}))}}),/OWN_RELABELLED_UNIT_FACT/)
for(const quantity of [undefined,null,0,NaN,-1,'1'])
assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,task:{...f.task,unitFacts:[{itemId:'item',result:'RELABELLED',quantity}]}}),/OWN_RELABELLED_UNIT_FACT/)
assert.throws(()=>prepareTransferStage(f.plan,'bind',{...context,task:{...f.task,unitFacts:[{itemId:'item',result:'RELABELLED',quantity:-1},{itemId:'item',result:'RELABELLED',quantity:2}]}}),/OWN_RELABELLED_UNIT_FACT/)
const actual={...f.task,history:[],unitFacts:[{factId:'2106799999999999999',itemId:'item',oldLabel:'BOX-1',newLabel:'FBA-BOX-1',result:'RELABELLED',quantity:1,operatorName:'SYNTHETIC',occurredAt:'2026-10-05T00:00:00'}]}
assert.ok(prepareTransferStage(f.plan,'bind',{...context,task:actual}))
assert.ok(prepareTransferStage(f.plan,'unit',{linked:f.linked,task:{...actual,relabelledPackages:0,unitFacts:[]},itemId:'item',observedOldLabel:'BOX-1',observedNewLabel:'FBA-BOX-1',observedSkus:[{skuCode:'OLD',newSkuCode:'NEW',quantity:1}]}))
})
function originalBindRecoveryFixture(directory){
const command={stage:'bind',runId:'f33-original-20261005-r1',transferNo:TRANSFER,customerId:CUSTOMER,actor:'pda',permission:'lastleg:pda:operate',replay:'KEYED',method:'POST',path:'/pda/v1/last-leg/pallet-operations/relabel-disassemble',body:{relabelNo:'RL-WH-20261005-174913',idempotencyKey:'F33-WT-430594-f33-original-20261005-r1-bind',source:{palletCode:'DP-261002-0018',expectedVersion:1},target:{palletCode:'PLT-USLAX01-261005-G08C1U1NQMMA',expectedVersion:0},cartonMarks:['CM-ADMIN-API-20261002042009973'],actualSource:{positionId:'2105878074206765058',expectedPositionVersion:4,packages:1},reason:'F33-WT-430594-f33-original-20261005-r1-bind'},preparedHash:'bb1ccb10ebaef5f37d8f16782ae6f8f2051d31061142533a61e8ab020c1e2a0d'}
const journal={command,phase:'ACKNOWLEDGED',http:200,code:'A0409',success:false,businessVerified:false}
const rollback={verified:true,unchanged:['transfer','positions','ledger','linked','order','task']}
const fresh={runId:command.runId,transferNo:TRANSFER,relabelNo:command.body.relabelNo,originalKeyNotExecuted:true,unitFactsVerified:true,unitFactCount:1,relabelledPackages:1,stockAndLedgerUnchangedByUnit:true,sourcePalletCode:command.body.source.palletCode,targetPalletCode:command.body.target.palletCode,sourcePalletVersion:1,targetPalletVersion:0,sourcePositionId:command.body.actualSource.positionId,sourcePositionVersion:4}
const journalFile=path.join(directory,command.runId+'-bind.json'),rollbackFile=path.join(directory,'rollback.json'),freshFile=path.join(directory,'fresh.json')
const store=()=>{fs.writeFileSync(journalFile,JSON.stringify(journal));fs.writeFileSync(rollbackFile,JSON.stringify(rollback));fs.writeFileSync(freshFile,JSON.stringify(fresh))}
const sha=p=>crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex')
store()
const recovery=()=>({authorization:'F33_BIND_A0409_AFTER_UNIT',originalJournalSha256:sha(journalFile),rollbackProofFile:'rollback.json',rollbackProofSha256:sha(rollbackFile),freshProofFile:'fresh.json',freshProofSha256:sha(freshFile)})
const actor={kind:'pda',appCode:'PLATFORM',tenantId:'0',membershipId:'2106727769572478980',warehouseId:LA,roles:['OVERSEAS_WAREHOUSE_PDA_OPERATOR'],authorities:['lastleg:pda:operate']}
return {command,journal,rollback,fresh,journalFile,store,recovery,options:{command,release:{...release('bind'),runId:command.runId},actor,journalDirectory:directory}}
}
test('released exact A0409 binding recovers once into durable side journal and preserves original failure bytes',async()=>{
const temp=temporaryJournal();let calls=0
try{
const f=originalBindRecoveryFixture(temp.directory),original=fs.readFileSync(f.journalFile)
const side=path.join(temp.directory,f.command.runId+'-bind-recovery.json')
const transport=async()=>{calls++;const j=JSON.parse(fs.readFileSync(side));assert.equal(calls===1?j.phase:j.replay.phase,'INTENT');return {http:200,code:'200',data:{replayed:calls>1},message:'完成'}}
const options={...f.options,recovery:f.recovery(),transport}
await assert.rejects(runPreparedTransferStage({...options,recovery:undefined}));assert.equal(calls,0)
await runPreparedTransferStage(options);assert.deepEqual(fs.readFileSync(f.journalFile),original)
await assert.rejects(runPreparedTransferStage(options));assert.equal(calls,1)
await runPreparedTransferStage({...options,replay:true});assert.deepEqual(fs.readFileSync(f.journalFile),original)
await assert.rejects(runPreparedTransferStage({...options,replay:true}));assert.equal(calls,2)
}finally{temp.cleanup()}
})
test('recovery never releases unknown, other rejects, changed payload, incomplete proof or changed live versions',async()=>{
for(const patch of [f=>f.journal.phase='OUTCOME_UNKNOWN',f=>f.journal.code='A0500',f=>f.journal.http=409,
f=>f.journal.success=true,f=>f.rollback.unchanged=['transfer'],f=>f.fresh.sourcePositionVersion=5,
f=>f.fresh.targetPalletVersion=1,f=>f.fresh.unitFactCount=0,f=>f.fresh.originalKeyNotExecuted=false]){
const temp=temporaryJournal();let calls=0
try{const f=originalBindRecoveryFixture(temp.directory);patch(f);f.store();await assert.rejects(runPreparedTransferStage({...f.options,recovery:f.recovery(),transport:async()=>{calls++}}));assert.equal(calls,0)}finally{temp.cleanup()}
}
const temp=temporaryJournal();let calls=0
try{const f=originalBindRecoveryFixture(temp.directory),options={...f.options,transport:async()=>{calls++}}
await assert.rejects(runPreparedTransferStage({...options,recovery:{...f.recovery(),authorization:'OTHER'}}))
await assert.rejects(runPreparedTransferStage({...options,recovery:{...f.recovery(),originalJournalSha256:'0'.repeat(64)}}))
await assert.rejects(runPreparedTransferStage({...options,recovery:f.recovery(),command:{...f.command,body:{...f.command.body,reason:'NEW PAYLOAD'}}}))
assert.equal(calls,0)
}finally{temp.cleanup()}
})
test('business rejection message is retained with tokens credentials and endpoint URLs redacted',async()=>{
assert.equal(redactTransferMessage('请先完成本箱全部换标作业,再进行组托'),'请先完成本箱全部换标作业,再进行组托')
const temp=temporaryJournal()
try{const f=linkedFixture(),command=prepareTransferStage(f.plan,'approve',{linked:f.linked});await assert.rejects(runPreparedTransferStage({command,release:release('approve'),actor:platform,journalDirectory:temp.directory,transport:async()=>({http:200,code:'A0409',message:'业务拒绝 password=secret Bearer token123 https://private.example/path'})}));
const raw=fs.readFileSync(path.join(temp.directory,'offline-contract-approve.json'),'utf8');assert.match(raw,/业务拒绝/);assert.doesNotMatch(raw,/secret|token123|private\.example/)
}finally{temp.cleanup()}
})
test('combined /units SKU-only actual response needs carton scan before one final fact and physical bind',()=>{
const f=linkedFixture();const task={...f.task,version:1,relabelledPackages:0,unitFacts:[],history:[{action:'MIXED_SKU',detail:JSON.stringify({itemId:'item',round:'INITIAL',stage:'SKU'})}],
items:f.task.items.map(i=>({...i,relabelledPackages:0,cartonProcessedPackages:0,skuProcessedPackages:1}))}
const context={linked:f.linked,task,itemId:'item',observedOldLabel:'BOX-1',observedNewLabel:'FBA-BOX-1'}
const command=prepareTransferStage(f.plan,'unit-carton',context)
assert.match(command.path,/\/cartons\/scan$/);assert.equal(command.body.expectedVersion,1);assert.equal(command.body.cartonMark,'BOX-1')
assert.ok(f.plan.phases.findIndex(p=>p.stage==='unit')<f.plan.phases.findIndex(p=>p.stage==='unit-carton'))
assert.ok(f.plan.phases.findIndex(p=>p.stage==='unit-carton')<f.plan.phases.findIndex(p=>p.stage==='bind'))
for(const patch of [{skuProcessedPackages:0},{cartonProcessedPackages:1},{expectedPackages:undefined}])
assert.throws(()=>prepareTransferStage(f.plan,'unit-carton',{...context,task:{...task,items:task.items.map(i=>({...i,...patch}))}}),/OWN_SKU_ONLY_PARTIAL/)
assert.throws(()=>prepareTransferStage(f.plan,'unit-carton',{...context,task:{...task,relabelledPackages:1}}))
assert.throws(()=>prepareTransferStage(f.plan,'unit-carton',{...context,observedNewLabel:'OTHER'}))
})