Files
baiyue-firstleg/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs
T

229 lines
15 KiB
JavaScript

import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs'
import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs'
const source = await readFile(fileURLToPath(new URL('./phase4-tail-order-controls.mjs', import.meta.url)), 'utf8')
test('hold positive lifecycle stays after receiving; pre-receipt verifies refusal without mutation', async () => {
assert.match(source, /入库前暂扣必须拒绝/)
assert.match(source, /拒绝暂扣后订单事实零漂移/)
assert.match(source, /representative\?\.currentStatus !== 'INBOUND_COMPLETED'/)
const operations = await readFile(new URL('./phase4-overseas-pda-operations.mjs', import.meta.url), 'utf8')
assert.ok(operations.indexOf('await verifyReceivedOrderHold(context)') > operations.indexOf('handoff.completedFixtures'))
assert.ok(operations.indexOf('await verifyReceivedOrderHold(context)') < operations.indexOf('await verifyStocktake(context'))
await assert.rejects(verifyReceivedOrderHold({ marker: 'run', createdResources: {} }), /缺少本轮/)
})
test('tail order controls execute after current producer and before overseas PDA', () => {
const modules = normalizeModuleOrder(['phase4-overseas-pda'])
const controls = modules.indexOf('phase4-tail-order-controls')
assert.ok(controls > modules.indexOf('phase4-carrier-pickup-ready'))
assert.ok(controls < modules.indexOf('phase4-overseas-pda'))
})
test('module covers the public tail-order write surface before PDA receiving', () => {
for (const path of ['/receiving-warehouse', '/destination', '/pallet-merge-required', '/fees', '/export',
'/hold', '/manual-nodes', '/api/platform/v1/last-leg/inbound-plans', '/manual']) {
assert.ok(source.includes(path), `missing ${path}`)
}
for (const evidence of [
'接柜仓变更同键同载荷重放', '接柜仓变更同键异载荷返回A0409', '接柜仓变更旧版本返回A0409',
'整柜恢复原公共接柜仓', '单票变更FBA目的仓', '批量变更FBA目的仓',
'单票开启PDA合托提示', '批量开启PDA合托分组', '批量取消PDA合托分组',
'尾程费用详情可追溯', '导出本轮尾程订单CSV',
'暂扣命令同键同载荷重放', '暂扣同键异载荷返回A0409', '暂扣尾程订单不能生成人工计划',
'解除本轮单票尾程暂扣', '保存尾程八个人工节点快照', '人工节点同键同载荷重放',
'人工节点同键异载荷返回A0409', '人工节点读取固定为MANUAL且不回退柜级节点',
'预览整柜待生成入仓计划清单', '按柜确认并自动生成入仓计划与实体托盘',
'按柜确认同键同快照幂等重放', '确认后预览待生成票数和托数归零',
]) assert.ok(source.includes(evidence), `missing ${evidence}`)
assert.match(source, /nodes\.data\?\.nodes\?\.length === 8/)
assert.match(source, /creationMode === 'AUTO'/)
assert.match(source, /requestBinary\('GET'/)
assert.match(source, /palletMergeBatchId/)
assert.match(source, /feeItemCode/)
assert.match(source, /feeItemName/)
assert.match(source, /direction: 'RECEIVABLE'/)
assert.match(source, /allIds\.length === 1 \|\| nonBlank\(groupRead\.data\?\.order\?\.palletMergeBatchId\)/)
assert.match(source, /businessDate:/)
assert.match(source, /originalCurrency === 'USD'/)
assert.match(source, /currency === 'CNY'/)
assert.match(source, /reviewStatus === 'PENDING'/)
assert.match(source, /'Idempotency-Key': `\$\{context\.marker\}-tail-fee`/)
assert.match(source, /ensureCurrentUsdRate/)
assert.match(source, /monthFrom=\$\{month\}&monthTo=\$\{month\}/)
assert.match(source, /existing\?\.status === 'ACTIVE'/)
assert.match(source, /businessDate: `\$\{feeMonth\}-01`/)
assert.doesNotMatch(source, /supplierDescription/)
})
test('receiving warehouse verification reuses public master data and restores the original assignment', () => {
assert.match(source, /tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE/)
assert.match(source, /id\(row\.id\) !== originalWarehouseId/)
assert.match(source, /overseasWarehouseId: originalWarehouseId, expectedVersion: Number\(changed\.data\.assignmentVersion\)/)
assert.doesNotMatch(source, /request\('POST', '\/api\/platform\/v1\/warehouse-admin'/)
})
test('module is marker-owned and never borrows historical tail orders', () => {
assert.match(source, /matrix\?\.marker !== context\.marker/)
assert.match(source, /containerNo: producer\.containerNo/)
assert.match(source, /customerId\) === id\(producer\.tenantId\)/)
assert.match(source, /禁止借历史尾程订单/)
assert.doesNotMatch(source, /records\([^)]*\)\[0\]/)
})
test('container conversion preserves long identifiers and generated pallets for PDA consumption', () => {
assert.match(source, /orderId: id\(order\.id\)/)
assert.doesNotMatch(source, /plannedWarehouse(?:Id|Code|Name):/)
assert.match(source, /!order\.overseasWarehouseId/)
assert.match(source, /container-preview/)
assert.match(source, /snapshotVersion/)
assert.match(source, /snapshotHash/)
assert.match(source, /convert-container/)
assert.match(source, /自动托盘进入托盘列表并保留FBA目的仓/)
assert.match(source, /planNos: generated\.map/)
assert.match(source, /generatedPlans: generated/)
assert.match(source, /AUTO_CONTAINER_PLANS_READY/)
assert.match(source, /remain as marker-owned prerequisites consumed by overseas PDA receiving/)
})
function holdModel(fault='') {
const buckets={availablePackages:'4',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0',inTransitPackages:'0',procurementPackages:'0'}
const order={id:'9007199254740993',customerId:'C1',overseasWarehouseId:'WH1',inboundCode:'IN-1',currentStatus:'INBOUND_COMPLETED',detained:false,heldChargeable:false,version:4}
let positions=[1,3].map((n,i)=>({id:String(21+i),balanceId:'20',customerId:'C1',overseasWarehouseId:'WH1',inboundOrderId:order.id,
palletId:String(31+i),palletNo:`PALLET-${i}`,batchNo:'PLAN',totalPackages:String(n),availablePackages:String(n),holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0'}))
const original=structuredClone(positions)
const balance={id:'20',customerId:'C1',overseasWarehouseId:'WH1',inboundOrderId:order.id,inboundCode:'IN-1',totalPackages:'4',...buckets}
const receipt=positions.map(row=>({customerId:'C1',orderId:order.id,inboundCode:'IN-1',inventoryBalanceId:'20',inventoryPositionId:row.id,receivedPackages:row.availablePackages}))
const commands=new Map(),calls=[];let tick=0,cycle=0,other=false,replayRead=false,failedRead=false
const context={marker:'RUN',token:'admin',createdResources:{
phase4TailOrderControls:{marker:'RUN',representativeOrderId:order.id},
phase4InboundProducerFixtures:{marker:'RUN',containers:[{fulfillmentType:'ONE_CLICK_DROPSHIP',tenantId:'C1',containerNo:'BOX'}]},
phase4OverseasPda:{marker:'RUN',receivingCompletionEvidence:{booking:{status:'VERIFIED',proof:{passed:true},scope:{customerId:'C1',warehouseId:'WH1',containerNo:'BOX'},
stages:{AFTER_COMPLETE:{captured:true,evidence:{balances:[structuredClone(balance)],positions:original,stockReceipts:receipt}}}}}}},
addStep(){},assert(_m,name,_r,passed){assert.ok(passed,name)},apiSuccess(_m,name,r){assert.equal(r.body.code,'200',name);return r.body.data},
async request(method,raw,body,_token,headers={}) {
calls.push({method,path:raw,body:structuredClone(body),headers})
const path=new URL(raw,'https://offline.invalid').pathname
const result=data=>({http:200,body:{code:'200',data:structuredClone(data)}})
const conflict=()=>({http:200,body:{code:'A0409'},requestId:'req',traceId:'trace'})
const counts=()=>{
for(const field of ['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages']) balance[field]=String(positions.reduce((sum,row)=>sum+Number(row[field]),0))
balance.updatedAt=String(tick)
}
if(method==='GET' && path.endsWith('/positions')) {
if(replayRead && ['read failure','foreign hold'].includes(fault) && !failedRead){failedRead=true;if(fault==='foreign hold'){order.version++;order.holdReason='ANOTHER-OPERATION'}throw new Error('read failed')}
return result(fault==='missing position'?positions.slice(1):positions.slice().reverse())
}
if(method==='GET' && path.includes('/inventory/balances/')) {counts();return result(balance)}
if(method==='GET' && path.includes('/orders/')) return result({order})
if(path.endsWith('/manual')) {if(fault==='ambiguous plan')return {http:0};return conflict()}
if(path.includes('/orders/') && method==='PUT') {
const key=headers['Idempotency-Key'],prior=commands.get(key)
if(prior) {
if(JSON.stringify(body)!==JSON.stringify(prior.body)) return conflict()
replayRead=true
if(fault==='replay drift'){positions[0].allocatedPackages='1';positions[0].availablePackages='0';tick++}
return result(prior.response)
}
if(body.held) {
cycle++
if(!(fault==='second cycle no movement' && cycle===2))for(const row of positions){row.holdPackages=String(Number(row.holdPackages)+Number(row.availablePackages));row.availablePackages='0'}
} else {
for(const row of positions){const retained=other&&row.id==='22'&&fault!=='release steals independent'?1:0;row.availablePackages=String(Number(row.availablePackages)+Number(row.holdPackages)-retained);row.holdPackages=String(retained)}
}
order.detained=body.held;order.heldChargeable=body.chargeable;order.holdReason=body.held?body.reason:null;order.version++;tick++
const response={orderId:order.id,held:body.held,chargeable:body.chargeable,reason:order.holdReason,version:order.version}
commands.set(key,{body:structuredClone(body),response})
if(fault==='ambiguous order hold' && body.held)return {http:0}
return result(response)
}
if(method==='POST' && path.includes('/inventory/')) {
const row=positions.find(p=>p.id===body.positionId),held=path.endsWith('/hold')
row.availablePackages=String(Number(row.availablePackages)+(held?-1:1));row.holdPackages=String(Number(row.holdPackages)+(held?1:-1));other=held;tick++
if(fault==='ambiguous independent hold' && held)return {http:0}
return result({replayed:false})
}
throw new Error(`unexpected ${method} ${raw}`)
}}
if(fault==='stale marker')context.createdResources.phase4OverseasPda.marker='OLD'
if(fault==='no evidence')delete context.createdResources.phase4OverseasPda.receivingCompletionEvidence
if(fault==='wrong immutable customer')receipt[0].customerId='OTHER'
if(fault==='unsafe bucket')balance.procurementPackages='9007199254740993'
if(fault==='moved source')positions[1].palletNo='MOVED'
if(fault==='not enough stock'){
positions[1].availablePackages='1';positions[1].totalPackages='1'
const e=context.createdResources.phase4OverseasPda.receivingCompletionEvidence.booking.stages.AFTER_COMPLETE.evidence
e.positions[1].availablePackages='1';e.positions[1].totalPackages='1';e.stockReceipts[1].receivedPackages='1'
e.balances[0].availablePackages='2';e.balances[0].totalPackages='2';balance.totalPackages='2'
}
return {context,calls,order,positions}
}
test('a failed repeated verification clears previous success evidence before any write',async()=>{
const {context,calls}=holdModel('no evidence')
context.createdResources.phase4TailOrderControls.holdVerifiedAfterReceipt=true
context.createdResources.phase4TailOrderControls.holdInventoryProof={status:'VERIFIED'}
await assert.rejects(verifyReceivedOrderHold(context))
assert.equal(context.createdResources.phase4TailOrderControls.holdVerifiedAfterReceipt,false)
assert.equal(context.createdResources.phase4TailOrderControls.holdInventoryProof,undefined)
assert.ok(calls.every(call=>call.method==='GET'))
})
test('fresh immutable IDs drive two full cycles and preserve independent HOLD across every raw position',async()=>{
const {context,calls,order}=holdModel()
await verifyReceivedOrderHold(context)
const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof
assert.equal(proof.status,'VERIFIED');assert.equal(proof.restored,true);assert.equal(order.version,8)
assert.equal(proof.snapshots.C2_RELEASED.positions.find(row=>row.id==='22').holdPackages,1)
assert.equal(proof.snapshots.AFTER.balances[0].availablePackages,4)
assert.equal(calls.filter(c=>c.path.endsWith('/manual')).length,2)
assert.ok(!calls.some(c=>/inventory\/(balances|summary)\?/.test(c.path)))
assert.equal(calls.filter(c=>c.method==='POST'&&c.path.endsWith('/inventory/release')).length,1)
})
for(const fault of ['stale marker','no evidence','wrong immutable customer','missing position','unsafe bucket','not enough stock','moved source']) {
test(`missing or unsafe fresh hold prerequisite blocks all writes: ${fault}`,async()=>{
const {context,calls}=holdModel(fault)
await assert.rejects(verifyReceivedOrderHold(context))
assert.ok(calls.every(c=>c.method==='GET'))
})
}
for(const fault of ['second cycle no movement','release steals independent','replay drift']) {
test(`inventory regression is detected and never declared verified: ${fault}`,async()=>{
const {context}=holdModel(fault)
await assert.rejects(verifyReceivedOrderHold(context))
assert.equal(context.createdResources.phase4TailOrderControls.holdInventoryProof.status,'FAILED')
assert.notEqual(context.createdResources.phase4TailOrderControls.holdVerifiedAfterReceipt,true)
})
}
for(const fault of ['ambiguous independent hold','ambiguous order hold','ambiguous plan']) {
test(`unknown mutation response never triggers a blind recovery release: ${fault}`,async()=>{
const {context,calls}=holdModel(fault)
await assert.rejects(verifyReceivedOrderHold(context))
const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof
assert.equal(proof.uncertainWrite,true);assert.equal(proof.restored,false)
assert.match(proof.recoveryError,/禁止自动释放/)
assert.ok(!calls.some(c=>c.path.endsWith('/inventory/release')||c.body?.held===false))
})
}
test('read failure after confirmed hold recovers only the exact known order cycle and independent one box',async()=>{
const {context,calls}=holdModel('read failure')
await assert.rejects(verifyReceivedOrderHold(context),/read failed/)
const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof
assert.equal(proof.status,'FAILED');assert.equal(proof.restored,true)
assert.equal(calls.filter(c=>c.body?.held===false).length,1)
assert.equal(proof.snapshots.RECOVERED.balances[0].holdPackages,0)
})
test('recovery refuses to release a concurrently changed order hold',async()=>{
const {context,calls}=holdModel('foreign hold')
await assert.rejects(verifyReceivedOrderHold(context),/read failed/)
const proof=context.createdResources.phase4TailOrderControls.holdInventoryProof
assert.equal(proof.restored,false);assert.match(proof.recoveryError,/只恢复本轮/)
assert.ok(!calls.some(c=>c.path.endsWith('/inventory/release')||c.body?.held===false))
})