2026-10-19 17:56:30 +08:00
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { fulfillment , beginDispatchCommand , patchDispatchCommand , finishDispatchCommand , discardRejectedDispatchCommand , restoreFulfillmentDraft , updateFulfillmentDraft , resetFulfillmentSession } from '../src/stores/fulfillment.js'
import { requireDispatchVersion , dispatchVersionAtLeast , normalizeDispatchTask } from '../src/api/fulfillment.js'
const source = ( await readFile ( new URL ( '../src/pages/dispatch/dispatch.vue' , import . meta . url ), 'utf8' )). match ( /<script>([\s\S]*?)<\/script>/ )[ 1 ]
2026-10-20 14:24:59 +08:00
const item = ( overrides = {}) => ({ dispatchItemId : 'item-1' , sourceKey : 'source-1' , sourceBusinessType : 'RELABEL_ORDER' , sourceBusinessNo : 'RL-1' , customerId : 'customer-1' , customerName : '客户甲' , palletNo : 'PALLET-1' , scheduledPackages : 2 , pickedPackages : 0 , positions : [{ positionId : 'position-1' , availablePackages : 2 , allocatedPackages : 0 , pickedPackages : 0 }], returnCartons : [], ... overrides })
2026-10-19 17:56:30 +08:00
test ( 'native string quantities normalize without changing Long identity or version' , () => {
const id = '9007199254740993'
2026-10-20 14:24:59 +08:00
const value = normalizeDispatchTask ( JSON . parse ( JSON . stringify ({ dispatchNo : 'PC/1' , recordVersion : id , items : [ item ({ dispatchItemId : id , scheduledPackages : '12' , pickedPackages : '0' , positions : [{ positionId : id , balanceId : id , availablePackages : '12' , allocatedPackages : '0' , pickedPackages : '0' }], returnCartons : [{ returnCartonId : id , productQuantity : '24' }] })] })))
2026-10-19 17:56:30 +08:00
assert . equal ( value . recordVersion , id ); assert . equal ( value . items [ 0 ]. dispatchItemId , id )
assert . equal ( value . items [ 0 ]. scheduledPackages , 12 ); assert . equal ( value . items [ 0 ]. pickedPackages , 0 )
2026-10-20 14:24:59 +08:00
assert . deepEqual ( value . items [ 0 ]. positions [ 0 ], { positionId : id , balanceId : id , availablePackages : 12 , allocatedPackages : 0 , pickedPackages : 0 })
2026-10-19 17:56:30 +08:00
assert . equal ( value . items [ 0 ]. returnCartons [ 0 ]. returnCartonId , id ); assert . equal ( value . items [ 0 ]. returnCartons [ 0 ]. productQuantity , 24 )
assert . equal ( normalizeDispatchTask ({ dispatchNo : 'PC/1' , items : [ item ({ scheduledPackages : String ( Number . MAX_SAFE_INTEGER ) })] }). items [ 0 ]. scheduledPackages , Number . MAX_SAFE_INTEGER )
})
test ( 'invalid, missing and unsafe native quantities fail at every item, position and carton boundary' , () => {
for ( const invalid of [ undefined , null , true , '' , ' ' , '01' , '+1' , '-1' , '1.0' , '1e2' , - 1 , 0.5 , NaN , Infinity , '9007199254740992' , 9007199254740992 ]) {
for ( const field of [ 'scheduledPackages' , 'pickedPackages' ]) assert . throws (() => normalizeDispatchTask ({ dispatchNo : 'PC/1' , items : [ item ({ [ field ] : invalid })] }), /排车数量/ )
for ( const field of [ 'availablePackages' , 'allocatedPackages' , 'pickedPackages' ]) assert . throws (() => normalizeDispatchTask ({ dispatchNo : 'PC/1' , items : [ item ({ positions : [{ ... item (). positions [ 0 ], [ field ] : invalid }] })] }), /排车数量/ )
assert . throws (() => normalizeDispatchTask ({ dispatchNo : 'PC/1' , items : [ item ({ returnCartons : [{ productQuantity : invalid }] })] }), /排车数量/ )
}
})
2026-10-21 14:30:45 +08:00
const task = ( overrides = {}) => normalizeDispatchTask ({ taskNo : 'TASK-1' , dispatchNo : 'PC/1' , recordVersion : 0 , status : 'ACTIVE' , stage : 'LOADING' , pickingReconciled : false , actions : { PICK : { allowed : true }, LOADING_COMPLETE : { allowed : true } }, items : [ item ()], ... overrides })
2026-10-19 17:56:30 +08:00
function storage () { const values = new Map (); globalThis . uni = { setStorageSync : ( key , value ) => values . set ( key , structuredClone ( value )), getStorageSync : key => structuredClone ( values . get ( key )), removeStorageSync : key => values . delete ( key ), showModal : async () => ({ confirm : true }) }; resetFulfillmentSession (); restoreFulfillmentDraft ( 'operator:warehouse' ); return values }
function page ( dependencies = {}) {
2026-10-21 17:27:13 +08:00
const deps = { PortalHeader : {}, ScanTargetInput : {}, DispatchHandoverStatus : {}, DispatchLoadingDocuments : {}, session : { currentUser : {} }, fulfillment , requireDispatchVersion , dispatchVersionAtLeast , beginDispatchCommand , patchDispatchCommand , finishDispatchCommand , discardRejectedDispatchCommand , updateFulfillmentDraft , playSuccessSound () {}, playErrorSound () {}, toDispatchError : error => ({ message : error . message }), ... dependencies }
2026-10-19 17:56:30 +08:00
const definition = new Function (... Object . keys ( deps ), source . replace ( /^\s*import .*$/gm , '' ). replace ( 'export default' , 'return' ))(... Object . values ( deps ))
const state = { ... definition . data (), current : task () }
for ( const [ name , method ] of Object . entries ( definition . methods )) state [ name ] = method . bind ( state )
for ( const [ name , getter ] of Object . entries ( definition . computed )) Object . defineProperty ( state , name , { get : () => getter . call ( state ) })
return state
}
2026-10-21 17:27:13 +08:00
const proof = ( command , version = 1 ) => ({ dispatchNo : command . dispatchNo , action : command . action , idempotencyKey : command . body . idempotencyKey , recordVersion : version , stage : command . action === 'LOADING_COMPLETE' ? 'AFTER_LOADING' : 'LOADING' , result : { confirmed : true } })
2026-10-19 17:56:30 +08:00
test ( 'full pending command survives restoration, rejects another operation, and isolates the next operator' , () => {
storage (); const pending = beginDispatchCommand ( 'PC/1' , 'PICK' , { recordVersion : 0 , dispatchItemId : 'item-1' , packages : 1 })
const original = structuredClone ( JSON . parse ( JSON . stringify ( pending )))
fulfillment . dispatch . pendingCommand = null ; restoreFulfillmentDraft ( 'operator:warehouse' )
assert . deepEqual ( JSON . parse ( JSON . stringify ( fulfillment . dispatch . pendingCommand )), original )
2026-10-21 14:30:45 +08:00
assert . throws (() => beginDispatchCommand ( 'PC/2' , 'LOADING_COMPLETE' , { recordVersion : 2 }), /原排车/ )
2026-10-19 17:56:30 +08:00
restoreFulfillmentDraft ( 'another:warehouse' ); assert . equal ( fulfillment . dispatch . pendingCommand , null )
})
test ( 'real picking requires the native gate, physical source and current stage; empty or merely counted tasks cannot complete loading' , async () => {
storage (); const state = page (); state . selectPallet ( state . current . items [ 0 ]); assert . equal ( state . canPick , true )
for ( const allowed of [ false , 'true' , undefined ]) { state . current . actions . PICK . allowed = allowed ; assert . equal ( state . canPick , false ) }
state . current . actions . PICK . allowed = true ; state . current . stage = 'BEFORE_LOADING' ; assert . equal ( state . canPick , false )
state . current = task ({ items : [] , pickingReconciled : true }); assert . equal ( state . canComplete , false )
state . current = task ({ items : [ item ({ pickedPackages : 2 })] }); assert . equal ( state . canComplete , false )
2026-10-21 14:30:45 +08:00
state . current . pickingReconciled = true ; assert . equal ( state . canComplete , true )
2026-10-19 17:56:30 +08:00
})
test ( 'return carton pick keeps its exact item, quantity and explicit version zero without inventing a position' , async () => {
storage (); let sent
const state = page ({ pickDispatchItem : async (... args ) => { sent = args ; return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchTask : async () => task ({ recordVersion : 1 }) })
state . current = task ({ items : [ item ({ returnCartons : [{ returnCartonId : 'rc-1' , cartonMark : 'RC-1' , status : 'OUTBOUND_PENDING' , productQuantity : 5 }] })] })
state . handleScan ( 'RC-1' ); await state . submitPick ()
assert . equal ( sent [ 0 ], 'PC/1' ); assert . equal ( sent [ 1 ], 'item-1' )
assert . deepEqual ({ ... sent [ 2 ], idempotencyKey : 'key' }, { recordVersion : 0 , positionId : null , packages : 1 , productQuantity : 5 , cartonMark : 'RC-1' , remark : null , idempotencyKey : 'key' })
assert . equal ( fulfillment . dispatch . pendingCommand , null )
})
test ( 'unknown POST queries the original key and retries the unchanged payload; a subsequent equal physical pick gets a new key' , async () => {
storage (); const sent = []; let fail = true
const state = page ({ pickDispatchItem : async ( no , id , body ) => { sent . push ({ no , id , body }); if ( fail ) { fail = false ; throw { network : true , message : 'offline' } } return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchCommand : async () => { throw { code : 'HTTP_404' } }, getDispatchTask : async () => task ({ recordVersion : 1 }) })
state . selectPallet ( state . current . items [ 0 ]); await state . submitPick (); assert . ok ( fulfillment . dispatch . pendingCommand )
state . current . recordVersion = 80 ; await state . recoverCommand (); assert . deepEqual ( sent [ 0 ], sent [ 1 ]); assert . equal ( sent [ 1 ]. body . recordVersion , 0 )
state . selectPallet ( state . current . items [ 0 ]); await state . submitPick (); assert . notEqual ( sent [ 2 ]. body . idempotencyKey , sent [ 1 ]. body . idempotencyKey )
})
2026-10-21 14:30:45 +08:00
test ( 'proven loading completion already delivered to the driver survives a failed GET without another POST' , async () => {
2026-10-19 17:56:30 +08:00
storage (); let posts = 0 , reads = 0
2026-10-21 19:44:32 +08:00
beginDispatchCommand ( 'PC/1' , 'LOADING_COMPLETE' , { recordVersion : 0 , manifestHash : 'manifest' })
2026-10-21 17:27:13 +08:00
const state = page ({ completeDispatchLoading : async () => { posts ++ ; return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchTask : async () => { if ( ++ reads === 1 ) throw Error ( 'read failed' ); return task ({ stage : 'AFTER_LOADING' , recordVersion : 3 }) } })
await state . recoverCommand (); assert . equal ( fulfillment . dispatch . pendingCommand . result . stage , 'AFTER_LOADING' )
await state . recoverCommand (); assert . equal ( posts , 1 ); assert . equal ( state . current . stage , 'AFTER_LOADING' ); assert . equal ( fulfillment . dispatch . pendingCommand , null )
2026-10-19 17:56:30 +08:00
})
test ( 'later rejection cannot erase an earlier unknown write and older details cannot release a proven result' , async () => {
2026-10-21 14:30:45 +08:00
storage (); beginDispatchCommand ( 'PC/1' , 'LOADING_COMPLETE' , { recordVersion : 0 }); patchDispatchCommand ({ attempted : true })
const state = page ({ getDispatchCommand : async () => { throw { code : '404' } }, completeDispatchLoading : async () => { throw { code : 'A0409' , message : 'conflict' } } })
2026-10-19 17:56:30 +08:00
await state . recoverCommand (); assert . equal ( fulfillment . dispatch . pendingCommand . rejected , false )
state . discardRejected (); assert . ok ( fulfillment . dispatch . pendingCommand )
patchDispatchCommand ({ result : proof ( fulfillment . dispatch . pendingCommand , '9007199254740993' ) })
const stale = page ({ getDispatchTask : async () => task ({ recordVersion : '9007199254740992' }) }); await stale . recoverCommand (); assert . ok ( fulfillment . dispatch . pendingCommand ); assert . match ( stale . error . message , /版本早于/ )
})
test ( 'actual loading confirmation leaves reference measurements blank and blocks missing unloading mapping' , async () => {
storage (); const preview = { dispatchNo : 'PC/1' , recordVersion : 0 , manifestHash : 'manifest' , pickingReconciled : true , loadingComplete : { allowed : true }, pallets : [{ warehousePalletId : 'pallet-1' , palletNo : 'PALLET-1' , palletSpaces : 1 , deliveryIndex : null , weightKg : 100 , volumeCbm : 2 , evidence : [{ field : 'weightKg' , actual : false , sourceType : 'ORDER_SNAPSHOT_ALLOCATION' }] }] }
let posts = 0 ; const state = page ({ getDispatchLoading : async () => preview , completeDispatchLoading : async () => { posts ++ } })
state . current = task ({ pickingReconciled : true , items : [ item ({ pickedPackages : 2 })] }); await state . prepareLoading ()
2026-10-21 19:44:32 +08:00
assert . equal ( state . loadingRows [ 0 ]. deliveryIndex , null )
2026-10-19 17:56:30 +08:00
Object . assign ( state . loadingRows [ 0 ], { weightKg : 92 , volumeCbm : 1.5 }); await state . completeLoading (); assert . equal ( posts , 0 ); assert . match ( state . error . message , /卸货站/ )
})
2026-10-21 14:30:45 +08:00
test ( 'missing versions surface a local error before picking or loading completion and never produce a write' , async () => {
2026-10-19 17:56:30 +08:00
storage (); const state = page (); state . selectPallet ( state . current . items [ 0 ]); state . current . recordVersion = undefined
await state . submitPick (); assert . match ( state . error . message , /版本/ ); assert . equal ( fulfillment . dispatch . pendingCommand , null )
2026-10-21 14:30:45 +08:00
state . loadingPreview = { dispatchNo : 'PC/1' , recordVersion : undefined , manifestHash : 'manifest' }; state . loadingRows = [{ warehousePalletId : '21' , weightKg : 1 , volumeCbm : 1 , palletSpaces : 1 , deliveryIndex : 1 }]; await state . completeLoading (); assert . match ( state . error . message , /版本/ ); assert . equal ( fulfillment . dispatch . pendingCommand , null )
2026-10-19 17:56:30 +08:00
})
test ( 'a first server error remains unknown and cannot be discarded as a definite business rejection' , async () => {
storage (); beginDispatchCommand ( 'PC/1' , 'PICK' , { recordVersion : 0 , dispatchItemId : 'item-1' , packages : 1 })
const state = page ({ pickDispatchItem : async () => { throw { code : 'A0500' , message : 'server failure' } } })
await state . recoverCommand (); state . discardRejected (); assert . equal ( fulfillment . dispatch . pendingCommand . rejected , false )
})
2026-10-20 14:24:59 +08:00
test ( 'position picking uses available stock and this source remaining instead of warehouse allocation or picked buckets' , () => {
storage (); const state = page (); state . current = task ({ items : [ item ({ scheduledPackages : '3' , pickedPackages : '1' , positions : [{ positionId : 'empty-position' , availablePackages : '0' , allocatedPackages : '20' , pickedPackages : '0' }, { positionId : 'actual-position' , availablePackages : '20' , allocatedPackages : '0' , pickedPackages : '7' }] })] })
state . selectPallet ( state . current . items [ 0 ]); assert . equal ( state . selectedPosition . positionId , 'actual-position' ); assert . equal ( state . selectedRemaining , 2 )
assert . equal ( state . pickablePackages ( state . current . items [ 0 ]), 2 ); assert . equal ( state . canPick , true )
updateFulfillmentDraft ( 'dispatch' , { packages : 2 }); assert . equal ( state . canPick , true )
updateFulfillmentDraft ( 'dispatch' , { packages : 3 }); assert . equal ( state . canPick , false )
})
for ( const scenario of [
{ name : 'zero available stock' , scheduled : '2' , picked : '0' , available : '0' , allocated : '20' , stockPicked : '0' , packages : '1' , maximum : 0 , position : null },
{ name : 'source already reconciled' , scheduled : '1' , picked : '1' , available : '8' , allocated : '0' , stockPicked : '0' , packages : '1' , maximum : 0 , position : null },
{ name : 'quantity above this dispatch source remaining' , scheduled : '3' , picked : '2' , available : '9' , allocated : '0' , stockPicked : '8' , packages : '2' , maximum : 1 , position : 'position-1' },
{ name : 'quantity above this position available stock' , scheduled : '5' , picked : '0' , available : '2' , allocated : '0' , stockPicked : '0' , packages : '3' , maximum : 2 , position : 'position-1' },
]) test ( `real stock buckets prevent a pick for ${ scenario . name } ` , async () => {
storage (); let posts = 0
const state = page ({ pickDispatchItem : async () => { posts ++ } })
state . current = task ({ pickingReconciled : scenario . scheduled === scenario . picked , items : [ item ({ scheduledPackages : scenario . scheduled , pickedPackages : scenario . picked , positions : [{ positionId : 'position-1' , availablePackages : scenario . available , allocatedPackages : scenario . allocated , pickedPackages : scenario . stockPicked }] })] })
state . selectPallet ( state . current . items [ 0 ]); state . updatePackages ({ detail : { value : scenario . packages } })
assert . equal ( state . selectedPosition ? . positionId ?? null , scenario . position ); assert . equal ( state . selectedRemaining , scenario . maximum )
assert . equal ( state . pickablePackages ( state . current . items [ 0 ]), scenario . maximum ); assert . equal ( state . canPick , false )
await state . submitPick (); assert . equal ( posts , 0 ); assert . equal ( fulfillment . dispatch . pendingCommand , null )
2026-10-19 17:56:30 +08:00
})
2026-10-20 10:36:04 +08:00
test ( 'a shared pallet exposes both relabel sources and picking the second preserves its exact position and quantity' , async () => {
2026-10-20 14:24:59 +08:00
storage (); const rows = [ item ({ scheduledPackages : 3 }), item ({ dispatchItemId : 'item-2' , sourceKey : 'source-2' , sourceBusinessNo : 'RL-2' , customerId : 'customer-2' , customerName : '客户乙' , scheduledPackages : 5 , positions : [{ positionId : 'position-2' , availablePackages : 5 , allocatedPackages : 0 , pickedPackages : 0 }] })]
2026-10-20 10:36:04 +08:00
let sent
const state = page ({ pickDispatchItem : async (... args ) => { sent = args ; return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchTask : async () => task ({ recordVersion : 1 , items : rows . map (( row , index ) => index ? { ... row , pickedPackages : 5 } : row ) }) })
state . current = task ({ items : rows }); state . handleScan ( 'PALLET-1' )
assert . equal ( fulfillment . dispatch . dispatchItemId , '' ); assert . deepEqual ( state . sourceChoices . map ( row => [ row . sourceBusinessNo , row . customerId , state . pickablePackages ( row )]), [[ 'RL-1' , 'customer-1' , 2 ], [ 'RL-2' , 'customer-2' , 5 ]])
state . chooseSource ( state . sourceChoices [ 1 ]); state . updatePackages ({ detail : { value : '5' } }); await state . submitPick ()
assert . equal ( sent [ 1 ], 'item-2' ); assert . equal ( sent [ 2 ]. positionId , 'position-2' ); assert . equal ( sent [ 2 ]. packages , 5 ); assert . equal ( state . canComplete , false )
})
test ( 'an ambiguous source carton requires explicit source selection and never picks the first matching item' , async () => {
storage (); let sent
const rows = [ item ({ returnCartons : [{ returnCartonId : 'carton-1' , cartonMark : 'SHARED-MARK' , status : 'OUTBOUND_PENDING' , productQuantity : 2 }] }), item ({ dispatchItemId : 'item-2' , sourceKey : 'source-2' , sourceBusinessNo : 'RL-2' , customerId : 'customer-2' , returnCartons : [{ returnCartonId : 'carton-2' , cartonMark : 'SHARED-MARK' , status : 'OUTBOUND_PENDING' , productQuantity : 7 }] })]
const state = page ({ pickDispatchItem : async (... args ) => { sent = args ; return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchTask : async () => task ({ recordVersion : 1 , items : rows }) })
state . current = task ({ items : rows }); state . handleScan ( 'SHARED-MARK' )
assert . equal ( state . sourceChoices . length , 2 ); assert . equal ( fulfillment . dispatch . dispatchItemId , '' ); assert . equal ( state . canPick , false )
state . chooseSource ( state . sourceChoices [ 1 ]); await state . submitPick ()
assert . equal ( sent [ 1 ], 'item-2' ); assert . equal ( sent [ 2 ]. cartonMark , 'SHARED-MARK' ); assert . equal ( sent [ 2 ]. productQuantity , 7 ); assert . equal ( sent [ 2 ]. positionId , null )
})
test ( 'selection restoration cannot reuse the same item ID for a different logical source' , () => {
storage (); const state = page (); state . selectPallet ( state . current . items [ 0 ]); assert . equal ( state . canPick , true )
state . current . items [ 0 ]. sourceKey = 'different-source' ; assert . equal ( state . selectedItem , null ); assert . equal ( state . canPick , false )
})
2026-10-20 14:24:59 +08:00
for ( const sourceType of [ 'INBOUND_PLAN' , 'RETURN_RECEIPT' ]) test ( `ordinary ${ sourceType } real 1/0/0 stock and null business fields allow one exact pick` , async () => {
2026-10-20 10:36:04 +08:00
storage (); let sent
2026-10-20 14:24:59 +08:00
const raw = JSON . parse ( JSON . stringify ({ dispatchNo : 'PC/1' , taskNo : 'TASK-1' , recordVersion : '0' , status : 'ACTIVE' , stage : 'LOADING' , actions : { PICK : { allowed : true } }, pickingReconciled : false , items : [ item ({ sourceKey : ` ${ sourceType } :source-1` , sourceBusinessType : null , sourceBusinessNo : null , scheduledPackages : '1' , pickedPackages : '0' , positions : [{ positionId : '9007199254740993' , balanceId : '9007199254740994' , availablePackages : '1' , allocatedPackages : '0' , pickedPackages : '0' }] })] }))
2026-10-20 10:36:04 +08:00
const normalized = normalizeDispatchTask ( raw )
assert . equal ( normalized . items [ 0 ]. sourceBusinessType , null ); assert . equal ( normalized . items [ 0 ]. sourceBusinessNo , null )
const state = page ({ pickDispatchItem : async (... args ) => { sent = args ; return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchTask : async () => normalizeDispatchTask ({ ... raw , recordVersion : 1 }) })
2026-10-20 14:24:59 +08:00
state . current = normalized ; state . handleScan ( 'PALLET-1' ); assert . equal ( state . canPick , true ); assert . equal ( state . selectedRemaining , 1 )
assert . equal ( state . pickablePackages ( state . current . items [ 0 ]), 1 )
state . updatePackages ({ detail : { value : '1' } }); await state . submitPick ()
assert . equal ( sent [ 1 ], 'item-1' ); assert . equal ( sent [ 2 ]. positionId , '9007199254740993' ); assert . equal ( sent [ 2 ]. packages , 1 ); assert . equal ( sent [ 2 ]. recordVersion , '0' )
2026-10-20 10:36:04 +08:00
assert . equal ( 'customerId' in sent [ 2 ], false )
})
2026-10-20 14:24:59 +08:00
test ( 'shared relabel pallet with real string 1/0/0 positions requires a source choice and picks each exact source once' , async () => {
storage (); const sent = []; let version = 5
let rows = [ '1' , '2' ]. map ( number => item ({ dispatchItemId : `item- ${ number } ` , sourceKey : `source- ${ number } ` , sourceBusinessNo : `RL- ${ number } ` , customerId : `customer- ${ number } ` , scheduledPackages : '1' , pickedPackages : '0' , positions : [{ positionId : `position- ${ number } ` , availablePackages : '1' , allocatedPackages : '0' , pickedPackages : '0' }] }))
const state = page ({ pickDispatchItem : async ( no , itemId , body ) => {
sent . push ({ no , itemId , body }); rows = rows . map ( row => row . dispatchItemId === itemId ? { ... row , pickedPackages : '1' , positions : row . positions . map ( position => ({ ... position , availablePackages : '0' })) } : row )
return proof ( fulfillment . dispatch . pendingCommand , ++ version )
}, getDispatchTask : async () => task ({ recordVersion : version , items : rows , pickingReconciled : rows . every ( row => row . pickedPackages === '1' ) }) })
state . current = task ({ recordVersion : version , items : rows }); state . handleScan ( 'PALLET-1' )
assert . equal ( fulfillment . dispatch . dispatchItemId , '' ); assert . equal ( state . canPick , false )
assert . deepEqual ( state . sourceChoices . map ( row => [ row . sourceKey , state . pickablePackages ( row )]), [[ 'source-1' , 1 ], [ 'source-2' , 1 ]])
state . chooseSource ( state . sourceChoices [ 1 ]); assert . equal ( state . selectedRemaining , 1 ); assert . equal ( state . canPick , true ); await state . submitPick ()
assert . equal ( state . current . stage , 'LOADING' ); assert . equal ( state . canComplete , false )
assert . equal ( state . current . items [ 0 ]. pickedPackages , 0 ); assert . equal ( state . current . items [ 1 ]. pickedPackages , 1 )
state . handleScan ( 'PALLET-1' ); assert . equal ( state . selectedItem . sourceKey , 'source-1' ); assert . equal ( state . canPick , true ); await state . submitPick ()
assert . deepEqual ( sent . map ( entry => [ entry . no , entry . itemId , entry . body . positionId , entry . body . packages , entry . body . recordVersion ]), [[ 'PC/1' , 'item-2' , 'position-2' , 1 , 5 ], [ 'PC/1' , 'item-1' , 'position-1' , 1 , 6 ]])
assert . notEqual ( sent [ 0 ]. body . idempotencyKey , sent [ 1 ]. body . idempotencyKey )
assert . ok ( sent . every ( entry => ! ( 'customerId' in entry . body ) && ! ( 'warehouseId' in entry . body )))
assert . equal ( fulfillment . dispatch . pendingCommand , null ); assert . equal ( state . canPick , false ); assert . equal ( state . canComplete , true )
})
2026-10-20 10:36:04 +08:00
test ( 'RELABEL_ORDER still requires its true business number and all sources require sourceKey' , () => {
storage (); const state = page ()
state . selectPallet ( item ({ sourceBusinessNo : null })); assert . equal ( fulfillment . dispatch . dispatchItemId , '' ); assert . match ( state . error . message , /来源业务身份/ )
state . selectPallet ( item ({ sourceKey : '' , sourceBusinessType : null , sourceBusinessNo : null })); assert . equal ( fulfillment . dispatch . dispatchItemId , '' )
})
test ( 'duplicate matching carton facts inside one source fail closed rather than taking its first carton' , () => {
storage (); const state = page ()
state . current = task ({ items : [ item ({ returnCartons : [ 'one' , 'two' ]. map ( returnCartonId => ({ returnCartonId , cartonMark : 'DUPLICATE' , status : 'OUTBOUND_PENDING' , productQuantity : 1 })) })] })
state . handleScan ( 'DUPLICATE' ); assert . match ( state . error . message , /重复待拣箱唛/ ); assert . equal ( fulfillment . dispatch . dispatchItemId , '' ); assert . equal ( state . canPick , false )
})
test ( 'two picked logical sources use the single authoritative physical loading row without duplicating measurements' , async () => {
storage (); const rows = [ item ({ pickedPackages : 2 }), item ({ dispatchItemId : 'item-2' , sourceKey : 'source-2' , sourceBusinessNo : 'RL-2' , pickedPackages : 2 })]
const preview = { dispatchNo : 'PC/1' , recordVersion : 0 , manifestHash : 'one-physical-pallet' , pickingReconciled : true , loadingComplete : { allowed : true }, pallets : [{ warehousePalletId : '21' , palletNo : 'PALLET-1' , palletSpaces : 1 , deliveryIndex : 1 , weightKg : 100 , volumeCbm : 2 , evidence : [{ field : 'weightKg' , actual : true }, { field : 'volumeCbm' , actual : true }] }] }
let sent
2026-10-21 17:27:13 +08:00
const state = page ({ getDispatchLoading : async () => preview , completeDispatchLoading : async (... args ) => { sent = args ; return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchTask : async () => task ({ recordVersion : 1 , stage : 'AFTER_LOADING' , items : rows , pickingReconciled : true }) })
2026-10-20 10:36:04 +08:00
state . current = task ({ items : rows , pickingReconciled : true }); await state . prepareLoading (); assert . equal ( state . loadingRows . length , 1 )
2026-10-21 19:44:32 +08:00
await state . completeLoading (); assert . equal ( state . current . stage , 'AFTER_LOADING' ); assert . equal ( state . departed , false ); assert . equal ( state . canComplete , false ); assert . equal ( state . loadingPreview , null ); assert . equal ( 'measurements' in sent [ 1 ], false )
2026-10-21 14:30:45 +08:00
})
const reviewTask = ( overrides = {}) => task ({ stage : 'BEFORE_LOADING' , actions : { LOADING_REVIEW : { allowed : true }, PICK : { allowed : false } }, resources : { vehicle : { plate : 'ABC123' , carrierName : '车队' }, driver : { name : '司机' , phone : '123' } }, reviews : { dispatchReview : { status : 'APPROVED' , reason : '已核对预约' }, loadingReview : { status : 'PENDING' } }, ... overrides })
2026-10-21 19:44:32 +08:00
test ( 'missing appointment blocks approval while preserving rejection to customer service' , async () => {
storage (); const sent = []
const state = page ({ reviewDispatchLoading : async (... args ) => { sent . push ( args ); return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchTask : async () => task ({ recordVersion : 1 }) })
state . current = reviewTask ({ actions : { LOADING_REVIEW : { allowed : true , blockCode : 'APPROVAL_BLOCKED' , blockReason : '预约收货时间尚未确认' } } })
assert . equal ( state . reviewTime ( '2026-10-21T17:00:00Z' ), '2026-10-22 01:00' )
assert . equal ( state . reviewTime ( null ), '未填写' )
state . reviewReason = '请客服补齐预约时间'
await state . submitReview ( 'APPROVED' ); assert . equal ( sent . length , 0 ); assert . match ( state . error . message , /预约收货时间尚未确认/ )
await state . submitReview ( 'REJECTED' ); assert . equal ( sent . length , 1 ); assert . equal ( sent [ 0 ][ 1 ]. decision , 'REJECTED' )
})
2026-10-21 14:30:45 +08:00
test ( 'review task is reachable in the existing list and blocks actual source selection until approval' , async () => {
storage (); const state = page ({ listDispatchTasks : async () => [ reviewTask ()], getDispatchTask : async () => reviewTask () })
await state . loadTasks (); assert . equal ( state . tasks . length , 1 ); assert . equal ( state . stageLabel ( state . tasks [ 0 ]), '待理货审核' )
await state . openTask ( 'PC/1' ); assert . equal ( state . canReview , true ); assert . equal ( state . canPick , false )
state . selectPallet ( state . current . items [ 0 ]); assert . equal ( state . selectedItem , null )
state . handleScan ( 'PALLET-1' ); assert . match ( state . error . message , /审核通过/ ); assert . equal ( state . selectedItem , null )
for ( const allowed of [ false , undefined , 'true' ]) { state . current . actions . LOADING_REVIEW . allowed = allowed ; assert . equal ( state . canReview , false ) }
})
test ( 'reject requires a nonblank reason and approval uses original zero version then refreshes real pick eligibility' , async () => {
storage (); let sent
const state = page ({ reviewDispatchLoading : async (... args ) => { sent = args ; return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchTask : async () => task ({ recordVersion : 1 }) })
state . current = reviewTask (); state . reviewReason = ' ' ; await state . submitReview ( 'REJECTED' )
assert . match ( state . error . message , /原因/ ); assert . equal ( fulfillment . dispatch . pendingCommand , null )
state . reviewReason = ' 已核对车辆和货物 ' ; await state . submitReview ( 'APPROVED' )
assert . deepEqual ({ ... sent [ 1 ], idempotencyKey : 'key' }, { recordVersion : 0 , decision : 'APPROVED' , reason : '已核对车辆和货物' , idempotencyKey : 'key' })
assert . equal ( state . current . stage , 'LOADING' ); state . selectPallet ( state . current . items [ 0 ]); assert . equal ( state . canPick , true )
assert . equal ( state . reviewReason , '' ); assert . equal ( fulfillment . dispatch . pendingCommand , null )
})
test ( 'rejected loading review returns to customer service with its reason and never enables picking' , async () => {
storage (); let sent
const rejected = reviewTask ({ recordVersion : 1 , actions : { LOADING_REVIEW : { allowed : false } }, reviews : { dispatchReview : { status : 'NOT_SUBMITTED' , invalidatedReason : '理货审核驳回,请客服修改后重新提交调度审核' }, loadingReview : { status : 'REJECTED' , reason : '车牌不符' } } })
const state = page ({ reviewDispatchLoading : async ( no , body ) => { sent = body ; return { ... proof ( fulfillment . dispatch . pendingCommand ), stage : 'BEFORE_LOADING' } }, getDispatchTask : async () => rejected })
state . current = reviewTask (); state . reviewReason = '车牌不符' ; await state . submitReview ( 'REJECTED' )
assert . equal ( sent . decision , 'REJECTED' ); assert . equal ( state . rejectionReason , '车牌不符' ); assert . equal ( state . canReview , false )
assert . equal ( state . stageLabel ( state . current ), '已退回客服处理' ); assert . equal ( state . canPick , false ); assert . match ( state . successMessage , /客服处理/ )
})
test ( 'review network uncertainty preserves exact decision reason version and key across restore and replay' , async () => {
storage (); const sent = []; let fail = true
const state = page ({ reviewDispatchLoading : async ( no , body ) => { sent . push ( body ); if ( fail ) { fail = false ; throw { network : true } }; return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchCommand : async () => { throw { code : 'HTTP_404' } }, getDispatchTask : async () => task ({ recordVersion : 1 }) })
state . current = reviewTask (); state . reviewReason = '现场已核对' ; await state . submitReview ( 'APPROVED' )
assert . equal ( fulfillment . dispatch . pendingCommand . rejected , false ); restoreFulfillmentDraft ( 'operator:warehouse' )
state . current . recordVersion = 99 ; state . reviewReason = '不得覆盖原文' ; await state . recoverCommand ()
assert . deepEqual ( sent [ 0 ], sent [ 1 ]); assert . equal ( sent [ 1 ]. recordVersion , 0 ); assert . equal ( sent [ 1 ]. reason , '现场已核对' ); assert . equal ( fulfillment . dispatch . pendingCommand , null )
})
test ( 'Admin wins a review race: definite conflict preserves command for explicit refresh and no automatic new approval' , async () => {
storage (); let writes = 0
const state = page ({ reviewDispatchLoading : async () => { writes ++ ; throw { code : 'A0409' , message : '版本已变化' } }, getDispatchTask : async () => task ({ recordVersion : 1 }) })
state . current = reviewTask (); await state . submitReview ( 'APPROVED' )
assert . equal ( fulfillment . dispatch . pendingCommand . rejected , true ); assert . equal ( writes , 1 )
state . discardRejected (); await new Promise ( resolve => setTimeout ( resolve , 0 ))
assert . equal ( fulfillment . dispatch . pendingCommand , null ); assert . equal ( state . canReview , false ); assert . equal ( writes , 1 )
})
test ( 'review confirmation cannot submit a different version or a revoked gate' , async () => {
for ( const change of [ state => { state . current . recordVersion = 2 }, state => { state . current . actions . LOADING_REVIEW . allowed = false }]) {
storage (); const state = page (); state . current = reviewTask (); uni . showModal = async () => { change ( state ); return { confirm : true } }
await state . submitReview ( 'APPROVED' ); assert . equal ( fulfillment . dispatch . pendingCommand , null )
}
})
test ( 'wrong review result stage or stale refresh retains original command until authoritative confirmation' , async () => {
storage (); beginDispatchCommand ( 'PC/1' , 'LOADING_REVIEW' , { recordVersion : 0 , decision : 'REJECTED' , reason : '车牌不符' })
const state = page ({ reviewDispatchLoading : async () => proof ( fulfillment . dispatch . pendingCommand ) })
await state . recoverCommand (); assert . ok ( fulfillment . dispatch . pendingCommand ); assert . match ( state . error . message , /返回结果未确认/ )
patchDispatchCommand ({ result : { ... proof ( fulfillment . dispatch . pendingCommand , 3 ), stage : 'BEFORE_LOADING' } })
const stale = page ({ getDispatchTask : async () => reviewTask ({ recordVersion : 2 }) }); await stale . recoverCommand ()
assert . ok ( fulfillment . dispatch . pendingCommand ); assert . match ( stale . error . message , /版本早于/ )
})
test ( 'review UI exposes authoritative resources and reason without reviewer database IDs or extra dispatch review action' , async () => {
const template = ( await readFile ( new URL ( '../src/pages/dispatch/dispatch.vue' , import . meta . url ), 'utf8' )). split ( '<script>' )[ 0 ]
for ( const field of [ 'resources?.vehicle?.plate' , 'resources?.driver?.name' , 'resources?.driver?.phone' , 'reviews?.dispatchReview?.reason' , 'rejectionReason' ]) assert . ok ( template . includes ( field ))
assert . ok ( template . includes ( "submitReview('APPROVED')" )); assert . ok ( template . includes ( "submitReview('REJECTED')" ))
assert . doesNotMatch ( template , /reviewerId|vehicleId|driverId|DISPATCH_REVIEW/ )
})
2026-10-21 19:44:32 +08:00
test ( 'loading completion keeps original manifest through a network replay and immediately enters delivery' , async () => {
2026-10-21 14:30:45 +08:00
storage (); let fail = true ; const sent = []
2026-10-21 19:44:32 +08:00
beginDispatchCommand ( 'PC/1' , 'LOADING_COMPLETE' , { recordVersion : 0 , manifestHash : 'original-manifest' })
2026-10-21 17:27:13 +08:00
const state = page ({ completeDispatchLoading : async ( no , body ) => { sent . push ( body ); if ( fail ) { fail = false ; throw { network : true } }; return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchCommand : async () => { throw { code : '404' } }, getDispatchTask : async () => task ({ recordVersion : 1 , stage : 'AFTER_LOADING' }) })
2026-10-21 14:30:45 +08:00
await state . recoverCommand (); restoreFulfillmentDraft ( 'operator:warehouse' ); state . current . recordVersion = 9 ; await state . recoverCommand ()
2026-10-21 17:27:13 +08:00
assert . deepEqual ( sent [ 0 ], sent [ 1 ]); assert . equal ( sent [ 1 ]. recordVersion , 0 ); assert . equal ( state . current . stage , 'AFTER_LOADING' ); assert . equal ( state . canComplete , false ); assert . equal ( fulfillment . dispatch . pendingCommand , null )
2026-10-21 14:30:45 +08:00
await state . completeLoading (); await state . recoverCommand (); assert . equal ( sent . length , 2 )
})
2026-10-21 17:27:13 +08:00
test ( 'loading completion requires an after-loading result and does not accept an intermediate stage as success' , async () => {
2026-10-21 19:44:32 +08:00
storage (); beginDispatchCommand ( 'PC/1' , 'LOADING_COMPLETE' , { recordVersion : 0 , manifestHash : 'manifest' })
2026-10-21 14:30:45 +08:00
const state = page ({ completeDispatchLoading : async () => ({ ... proof ( fulfillment . dispatch . pendingCommand ), stage : 'LOADING' }) })
await state . recoverCommand (); assert . ok ( fulfillment . dispatch . pendingCommand ); assert . match ( state . error . message , /返回结果未确认/ )
2026-10-20 10:36:04 +08:00
})
2026-10-21 17:27:13 +08:00
test ( 'documents already complete allow loading confirmation to return DELIVERING and recover without reposting' , async () => {
2026-10-21 19:44:32 +08:00
storage (); beginDispatchCommand ( 'PC/1' , 'LOADING_COMPLETE' , { recordVersion : 0 , manifestHash : 'hash' })
2026-10-21 17:27:13 +08:00
let posts = 0 , reads = 0
const state = page ({ completeDispatchLoading : async () => { posts ++ ; return { ... proof ( fulfillment . dispatch . pendingCommand , 3 ), stage : 'DELIVERING' } }, getDispatchTask : async () => { if ( ++ reads === 1 ) throw Error ( 'read failed' ); return task ({ stage : 'DELIVERING' , recordVersion : 3 }) } })
await state . recoverCommand (); assert . equal ( fulfillment . dispatch . pendingCommand . result . stage , 'DELIVERING' )
await state . recoverCommand (); assert . equal ( posts , 1 ); assert . equal ( state . current . stage , 'DELIVERING' ); assert . equal ( fulfillment . dispatch . pendingCommand , null )
assert . match ( state . successMessage , /已进入派送中/ )
})
test ( 'scanning received pallets defaults to real nine and eleven boxes without manually reentering estimates' , async () => {
storage (); const sent = []
let rows = [ 9 , 11 ]. map (( quantity , index ) => item ({ dispatchItemId : `received- ${ index } ` , sourceKey : `PLAN: ${ index } ` , sourceBusinessType : null , sourceBusinessNo : null , palletNo : `PALLET- ${ index } ` , scheduledPackages : quantity , positions : [{ positionId : `position- ${ index } ` , availablePackages : quantity , allocatedPackages : 0 , pickedPackages : 0 }] }))
const state = page ({ pickDispatchItem : async ( number , id , body ) => { sent . push ( body . packages ); rows = rows . map ( row => row . dispatchItemId === id ? { ... row , pickedPackages : body . packages , positions : [] } : row ); return proof ( fulfillment . dispatch . pendingCommand , sent . length ) }, getDispatchTask : async () => task ({ items : rows , recordVersion : sent . length }) })
state . current = task ({ items : rows })
state . handleScan ( 'PALLET-0' ); assert . equal ( state . packages , 9 ); assert . equal ( state . canPick , true ); await state . submitPick ()
state . handleScan ( 'PALLET-1' ); assert . equal ( state . packages , 11 ); assert . equal ( state . canPick , true ); await state . submitPick ()
assert . deepEqual ( sent , [ 9 , 11 ]); assert . equal ( state . pickedPackages , 20 ); assert . equal ( state . remainingPackages , 0 )
})
2026-10-21 19:44:32 +08:00
test ( 'photo tab preserves the selected pallet and quantity and ignores picking broadcasts' , () => {
storage (); const state = page (); state . handleScan ( 'PALLET-1' ); state . updatePackages ({ detail : { value : '1' } })
const before = JSON . parse ( JSON . stringify ( fulfillment . dispatch ))
state . selectTab ( 'photos' ); state . handleScan ( 'UNKNOWN-PALLET' )
assert . equal ( state . activeTab , 'photos' ); assert . equal ( state . error , null )
assert . deepEqual ( JSON . parse ( JSON . stringify ( fulfillment . dispatch )), before )
state . selectTab ( 'picking' ); assert . equal ( state . selectedItem . palletNo , 'PALLET-1' ); assert . equal ( state . packages , 1 )
})
test ( 'same-task photo refresh stays on photos while a new task starts on picking' , async () => {
storage (); const state = page ({ getDispatchTask : async no => task ({ dispatchNo : no , recordVersion : 2 }) })
state . selectTab ( 'photos' ); await state . openTask ( 'PC/1' ); assert . equal ( state . activeTab , 'photos' )
await state . openTask ( 'PC/2' ); assert . equal ( state . activeTab , 'picking' )
})
test ( 'tab changes cannot interrupt an upload or pending command, or open photos before loading' , () => {
storage (); const state = page (); state . busy = true ; state . selectTab ( 'photos' ); assert . equal ( state . activeTab , 'picking' )
state . busy = false ; state . current . stage = 'BEFORE_LOADING' ; state . selectTab ( 'photos' ); assert . equal ( state . activeTab , 'picking' )
state . current . stage = 'LOADING' ; beginDispatchCommand ( 'PC/1' , 'PICK' , { recordVersion : 0 })
state . selectTab ( 'photos' ); assert . equal ( state . activeTab , 'picking' )
})
test ( 'thirty scanned pallets confirm once without weight volume or pallet entry' , async () => {
storage (); let sent
const preview = { dispatchNo : 'PC/1' , recordVersion : 0 , manifestHash : 'thirty' , pickingReconciled : true , loadingComplete : { allowed : true }, pallets : Array . from ({ length : 30 }, ( _ , i ) => ({ warehousePalletId : String ( i ), palletNo : `PALLET- ${ i } ` , deliveryIndex : 1 , pickedPackages : 10 , weightKg : null , volumeCbm : null , evidence : [] })) }
const state = page ({ getDispatchLoading : async () => preview , completeDispatchLoading : async ( _ , body ) => { sent = body ; return proof ( fulfillment . dispatch . pendingCommand ) }, getDispatchTask : async () => task ({ recordVersion : 1 , stage : 'AFTER_LOADING' , pickingReconciled : true }) })
state . current = task ({ pickingReconciled : true , items : [ item ({ pickedPackages : 2 })] }); await state . prepareLoading (); await state . completeLoading ()
assert . equal ( state . error , null ); assert . equal ( state . current . stage , 'AFTER_LOADING' ); assert . equal ( sent . manifestHash , 'thirty' ); assert . equal ( 'measurements' in sent , false )
})