2026-08-21 11:20:09 +08:00
import { beforeEach , describe , expect , it , vi } from 'vitest'
2026-09-29 17:56:00 +08:00
import { http } from '@/shared/api/http'
2026-10-19 17:56:31 +08:00
import * as api from './dispatches'
import { candidate , command , detail , loadingPreview , page } from '../dispatchTestFixtures'
import type { ArrangeDelivery , CreateDispatch , DispatchBatchAllocation , DraftSelection , LoadingComplete , ReasonCommand , ReviewCommand , UpdateDispatch , Version , VersionCommand } from '../dispatchContracts'
vi . mock ( '@/shared/api/http' , () => ({ http : { get : vi . fn (), post : vi.fn (), put : vi.fn (), delete : vi . fn () }, unwrapApiResponse : ( value : { data : unknown }) => value . data }))
const body = { recordVersion : 0 , idempotencyKey : 'original-key' , reason : '核对原因' }
const draft = () => ({ draftToken : 'server-token' , recordVersion : 0 , status : 'ACTIVE' , warehouseId : '321' , containers : [], sources : [ candidate ()], expiresAt : '' })
const file = () => ({ fileId : '9007199254740993' , fileName : '证据.png' , contentType : 'image/png' , sizeBytes : 3 })
beforeEach (() => {
vi . clearAllMocks ()
vi . mocked ( http . get ). mockImplementation ( async url => ({ data : url.endsWith ( '/items' ) ? { dispatchNo : 'PC/1' , recordVersion : 0 , status : 'ACTIVE' , stage : 'BEFORE_LOADING' , items : [] } : url . includes ( '/evidence/' ) ? file () : url . endsWith ( '/loading' ) ? loadingPreview () : url . includes ( '/commands/' ) ? command ( 'EDIT' ) : detail () }))
vi . mocked ( http . post ). mockImplementation ( async url => ({ data : url.endsWith ( '/pre-arrange' ) ? { draftToken : 'server-token' , vehicles : [], replayed : false } : url . includes ( '/draft' ) ? draft () : url . includes ( '/evidence/' ) ? file () : command ( 'EDIT' ) }))
vi . mocked ( http . put ). mockResolvedValue ({ data : command ( 'EDIT' ) })
vi . mocked ( http . delete ). mockResolvedValue ({ data : draft () })
})
describe ( 'native whole-vehicle dispatch API' , () => {
it . each ([ 0 , '0' , '9007199254740993' ])( 'preserves authoritative batch version %s across direct create, add cargo and grouped conversion' , async recordVersion => {
const allocation : DispatchBatchAllocation = { planPalletId : '11' , batchKey : 'FBA_APPOINTMENT:1' , customerId : 'C1' , sourceKey : 'ORDER:1' , destinationKey : 'FBA:ONT8' , packageCount : 2 , recordVersion }
const create : CreateDispatch = { warehouseId : '321' , idempotencyKey : 'create' , vehicle : null , driver : null , plannedDepartureAt : null , estimatedArrivalAt : null , remark : null , cargo : [ candidate (). selection ], batchAllocations : [ allocation ] }
await api . createDispatch ( create ); await api . addDispatchCargo ( 'PC/1' , { ... body , cargo : create.cargo , batchAllocations : [ allocation ] }); await api . convertDispatchDraft ({ ... body , draftToken : 'draft' , groups : [{ draftContainerId : 'container' , dispatch : create }] })
expect ( vi . mocked ( http . post ). mock . calls [ 0 ] ! [ 1 ]). toBe ( create )
expect ( vi . mocked ( http . post ). mock . calls [ 1 ] ! [ 1 ]). toMatchObject ({ batchAllocations : [{ recordVersion }] })
expect ( vi . mocked ( http . post ). mock . calls [ 2 ] ! [ 1 ]). toMatchObject ({ groups : [{ dispatch : { batchAllocations : [{ recordVersion }] } }] })
})
it . each ([ undefined , null , - 1 , '' , 'bad' , 9007199254740992 ])( 'rejects missing or invalid nested batch version %s before any write or candidate handoff' , async value => {
const allocation = { planPalletId : '11' , batchKey : 'FBA_APPOINTMENT:1' , customerId : 'C1' , sourceKey : 'ORDER:1' , destinationKey : 'FBA:ONT8' , packageCount : 2 , recordVersion : value } as unknown as DispatchBatchAllocation
const create : CreateDispatch = { warehouseId : '321' , idempotencyKey : 'create' , vehicle : null , driver : null , plannedDepartureAt : null , estimatedArrivalAt : null , remark : null , cargo : [ candidate (). selection ], batchAllocations : [ allocation ] }
await expect ( Promise . resolve (). then (() => api . createDispatch ( create ))). rejects . toThrow ( '重新读取候选' )
await expect ( Promise . resolve (). then (() => api . addDispatchCargo ( 'PC/1' , { ... body , cargo : create.cargo , batchAllocations : [ allocation ] }))). rejects . toThrow ( '重新读取候选' )
await expect ( Promise . resolve (). then (() => api . convertDispatchDraft ({ ... body , draftToken : 'draft' , groups : [{ draftContainerId : 'container' , dispatch : create }] }))). rejects . toThrow ( '重新读取候选' )
expect ( http . post ). not . toHaveBeenCalled ()
vi . mocked ( http . get ). mockResolvedValueOnce ({ data : { batchKey : allocation.batchKey , version : 5 , candidates : [{ allocations : [ allocation ] }], blockReasons : [] } })
await expect ( api . getBatchDispatchCandidates ( allocation . batchKey )). rejects . toThrow ( '重新读取候选' )
})
it ( 'rejects inconsistent original versions of the same batch across conversion groups' , () => {
const allocation = { planPalletId : '11' , batchKey : 'FBA_APPOINTMENT:1' , customerId : 'C1' , sourceKey : 'ORDER:1' , destinationKey : 'FBA:ONT8' , packageCount : 2 , recordVersion : '9007199254740993' }
const create : CreateDispatch = { warehouseId : '321' , idempotencyKey : 'create' , vehicle : null , driver : null , plannedDepartureAt : null , estimatedArrivalAt : null , remark : null , cargo : [], batchAllocations : [ allocation ] }
expect (() => api . convertDispatchDraft ({ ... body , draftToken : 'draft' , groups : [{ draftContainerId : 'A' , dispatch : create }, { draftContainerId : 'B' , dispatch : { ... create , batchAllocations : [{ ... allocation , recordVersion : '9007199254740994' }] } }] })). toThrow ( '同一预约批次的版本不一致' )
expect ( http . post ). not . toHaveBeenCalled ()
})
it ( 'uses actual item, route and evidence endpoints with native multipart and authenticated blob content' , async () => {
await api . getDispatchItems ( 'PC/1' ); await api . getDispatchRoute ( 'PC/1' ); await api . getDispatchEvidence ( 'PC/1' , 'DRIVER' , '9007199254740993' )
expect ( vi . mocked ( http . get ). mock . calls . map ( call => call [ 0 ])). toEqual ([ '/platform/v1/last-leg/dispatches/PC%2F1/items' , '/platform/v1/last-leg/dispatches/PC%2F1/route' , '/platform/v1/last-leg/dispatches/PC%2F1/evidence/DRIVER/9007199254740993' ])
const file = new File ([ 'png' ], '证据.png' , { type : 'image/png' }); await api . uploadDispatchEvidence ( 'PC/1' , 'VEHICLE' , file )
const upload = vi . mocked ( http . post ). mock . calls [ 0 ] !
expect ( upload [ 0 ]). toBe ( '/platform/v1/last-leg/dispatches/PC%2F1/evidence/VEHICLE' ); expect ( upload [ 1 ]). toBeInstanceOf ( FormData ); expect (( upload [ 1 ] as FormData ). get ( 'file' )). toBe ( file )
await api . getDispatchEvidenceContent ( 'PC/1' , 'VEHICLE' , '301' ); expect ( http . get ). toHaveBeenLastCalledWith ( '/platform/v1/last-leg/dispatches/PC%2F1/evidence/VEHICLE/301/content' , { responseType : 'blob' })
})
it ( 'reorders only the supplied existing destination keys with explicit native version zero' , async () => {
await expect ( api . reorderDispatchRoute ( 'PC/1' , { recordVersion : null as unknown as number , idempotencyKey : 'key' , destinationKeys : [], reason : '路线调整' })). rejects . toThrow ( '版本' )
expect ( http . put ). not . toHaveBeenCalled ()
const request = { recordVersion : 0 , idempotencyKey : 'key' , destinationKeys : [ 'CODE:ONT8' , 'PRIVATE:actual' ], reason : '现场卸货顺序调整' }
await api . reorderDispatchRoute ( 'PC/1' , request ); expect ( http . put ). toHaveBeenCalledExactlyOnceWith ( '/platform/v1/last-leg/dispatches/PC%2F1/route' , request )
})
it ( 'queries five-stage rows with server review sections and preserves statistics' , async () => {
vi . mocked ( http . get ). mockResolvedValueOnce ({ data : page () })
const query = { page : 2 , size : 20 , stage : 'BEFORE_LOADING' as const , beforeLoadingSection : 'DISPATCH_REVIEW' as const , reviewStatus : 'PENDING' as const , status : 'ACTIVE' as const }
const result = await api . listDispatches ( query )
expect ( http . get ). toHaveBeenCalledWith ( '/platform/v1/last-leg/dispatches' , { params : query }); expect ( result . statistics . stageCounts . DELIVERING ). toBe ( 1 )
})
it ( 'reads the four-object detail and loading without customer scope, encoding business numbers' , async () => {
await api . getDispatch ( 'PC/1' ); await api . getDispatchLoading ( 'PC/1' ); await api . getDispatchCommand ( 'PC/1' , 'key/1' )
expect ( vi . mocked ( http . get ). mock . calls . map ( call => call [ 0 ])). toEqual ([ '/platform/v1/last-leg/dispatches/PC%2F1' , '/platform/v1/last-leg/dispatches/PC%2F1/loading' , '/platform/v1/last-leg/dispatches/PC%2F1/commands/key%2F1' ])
})
it ( 'reads candidates with their exact sourceVersion and repeated plan query parameters' , async () => {
2026-10-21 07:19:57 +08:00
const data = { total : 1 , page : 1 , size : 20 , pages : 1 , candidates : [ candidate ()], snapshotAt : '2026-10-19T10:00:00Z' , complete : true }
2026-10-19 17:56:31 +08:00
vi . mocked ( http . get ). mockResolvedValueOnce ({ data })
2026-10-21 07:19:57 +08:00
expect ( await api . listDispatchCandidates ({ warehouseId : '321' , planIds : [ '11' , '12' ], page : 2 , size : 20 , warehouseTab : 'NYC' })). toEqual ( data )
expect ( http . get ). toHaveBeenCalledWith ( '/platform/v1/last-leg/dispatches/candidates' , { params : { warehouseId : '321' , planIds : [ '11' , '12' ], page : 2 , size : 20 , warehouseTab : 'NYC' }, paramsSerializer : { indexes : null } })
2026-10-19 17:56:31 +08:00
})
it ( 'creates an empty real dispatch without an artificial customer or cargo' , async () => {
const request : CreateDispatch = { idempotencyKey : 'create' , warehouseId : '321' , vehicle : null , driver : null , plannedDepartureAt : null , estimatedArrivalAt : null , remark : null , cargo : [], batchAllocations : [] }
await api . createDispatch ( request ); expect ( http . post ). toHaveBeenCalledWith ( '/platform/v1/last-leg/dispatches' , request )
})
it . each ([ 'cancel' , 'remove' , 'review' , 'loading-review' , 'return' , 'loading' , 'depart' , 'arrange' , 'add' , 'submit' , 'draft-container' , 'draft-select' , 'draft-release' , 'draft-convert' , 'draft-snapshot' ])( 'rejects missing/null/negative versions before %s requests and accepts zero' , async action => {
const invoke = ( value : unknown ) => {
const request = { ... body , recordVersion : value as Version }
switch ( action ) {
case 'cancel' : return api . cancelDispatch ( 'PC/1' , request )
case 'remove' : return api . removeDispatchItem ( 'PC/1' , '11' , request )
case 'review' : return api . reviewDispatch ( 'PC/1' , request as unknown as ReviewCommand )
case 'loading-review' : return api . reviewDispatchLoading ( 'PC/1' , request as unknown as ReviewCommand )
case 'return' : return api . returnDispatchToReview ( 'PC/1' , request )
case 'loading' : return api . completeDispatchLoading ( 'PC/1' , request as unknown as LoadingComplete )
case 'depart' : return api . departDispatch ( 'PC/1' , { ... request , remark : null })
case 'arrange' : return api . transferDispatchToDelivery ( 'PC/1' , request as unknown as ArrangeDelivery )
case 'add' : return api . addDispatchCargo ( 'PC/1' , { ... request , cargo : [], batchAllocations : [] })
case 'submit' : return api . submitDispatchReview ( 'PC/1' , { ... request , remark : null })
case 'draft-container' : return api . createDispatchDraftContainer ({ ... request , draftToken : 'draft' , capacity : 30 })
case 'draft-select' : return api . lockDispatchDraftPallet ( request as unknown as DraftSelection )
case 'draft-release' : return api . releaseDispatchDraft ({ ... request , draftToken : 'draft' })
case 'draft-convert' : return api . convertDispatchDraft ({ ... request , draftToken : 'draft' , groups : [] })
default : return api . saveDispatchDraftSnapshot ({ ... request , draftToken : 'draft' , planIds : [], containers : [] })
}
}
for ( const invalid of [ undefined , null , - 1 , '' , 'bad' , Number . MAX_SAFE_INTEGER + 1 ]) await expect ( Promise . resolve (). then < unknown >(() => invoke ( invalid ))). rejects . toThrow ( '版本' )
expect ( http . post ). not . toHaveBeenCalled (); await invoke ( 0 ); expect ( vi . mocked ( http . post ). mock . calls [ 0 ] ! [ 1 ]). toEqual ( expect . objectContaining ({ recordVersion : 0 }))
})
2026-10-20 10:11:31 +08:00
it ( 'updates vehicle resources and timing with the explicit dispatch version' , async () => {
const request : UpdateDispatch = { ... body , vehicle : null , driver : null , plannedDepartureAt : null , estimatedArrivalAt : null , remark : null }
2026-10-19 17:56:31 +08:00
await api . updateDispatch ( 'PC/1' , request ); expect ( http . put ). toHaveBeenCalledWith ( '/platform/v1/last-leg/dispatches/PC%2F1' , request )
await expect ( api . updateDispatch ( 'PC/1' , { ... request , recordVersion : undefined as unknown as Version })). rejects . toThrow ( '版本' )
})
it ( 'uses versioned removal and cancellation POSTs instead of unversioned deletes' , async () => {
await api . cancelDispatch ( 'PC/1' , body ); await api . removeDispatchItem ( 'PC/1' , 'item/1' , body )
expect ( vi . mocked ( http . post ). mock . calls . map ( call => call [ 0 ])). toEqual ([ '/platform/v1/last-leg/dispatches/PC%2F1/cancel' , '/platform/v1/last-leg/dispatches/PC%2F1/items/item%2F1/remove' ]); expect ( http . delete ). not . toHaveBeenCalled ()
})
it ( 'preserves complete grouped arrangement evidence and returns an immutable command envelope' , async () => {
const outbound = ( customerId : string ) => ({ customerId , customerName : `客户 ${ customerId } ` , outboundNo : `OUT- ${ customerId } ` , status : 'WAIT_OUTBOUND' , packageCount : 2 , palletCount : 1 })
const delivery = ( customerId : string ) => ({ ... outbound ( customerId ), groupKey : customerId , deliveryNo : `DEL- ${ customerId } ` , destination : candidate (). destination })
const data = { ... command ( 'ARRANGE_DELIVERY' ), result : { dispatchNo : 'PC/1' , recordVersion : '9007199254740993' , arrangementKey : 'arrange' , cargoHash : 'cargo' , arrangedAt : '2026-10-19T10:00:00Z' , outbounds : [ outbound ( 'A' ), outbound ( 'B' )], deliveries : [ delivery ( 'A' ), delivery ( 'B' )] } }
vi . mocked ( http . post ). mockResolvedValueOnce ({ data })
const request : ArrangeDelivery = { ... body , recordVersion : '9007199254740993' , cargoHash : 'cargo' , loadingCompletionHash : 'loaded' , groups : [] }
expect ( await api . transferDispatchToDelivery ( 'PC/1' , request )). toEqual ( data ); expect ( http . post ). toHaveBeenCalledWith ( '/platform/v1/last-leg/dispatches/PC%2F1/transfer-delivery' , request )
})
it ( 'keeps draft version, server token and source evidence and converts explicit groups atomically' , async () => {
const source = candidate (). selection
await api . lockDispatchDraftPallet ({ ... body , draftToken : 'server-token' , draftContainerId : '190000000000000011' , source })
expect ( http . post ). toHaveBeenLastCalledWith ( '/platform/v1/last-leg/dispatches/draft-locks/lock' , { ... body , draftToken : 'server-token' , draftContainerId : '190000000000000011' , source })
await api . convertDispatchDraft ({ ... body , draftToken : 'server-token' , groups : [] }); expect ( http . post ). toHaveBeenLastCalledWith ( '/platform/v1/last-leg/dispatches/drafts/pre-arrange' , { ... body , draftToken : 'server-token' , groups : [] })
})
it ( 'deletes draft containers with the body version and never invents successful response data' , async () => {
vi . mocked ( http . delete ). mockRejectedValueOnce ( new Error ( 'server failure' ))
await expect ( api . deleteDispatchDraftContainer ( 'draft/1' , 'container/1' , body as ReasonCommand )). rejects . toThrow ( 'server failure' )
expect ( http . delete ). toHaveBeenCalledWith ( '/platform/v1/last-leg/dispatches/drafts/draft%2F1/containers/container%2F1' , { data : body })
})
it ( 'reads BOL as a PDF blob' , async () => { await api . getDispatchBolPdf ( 'PC/1' ); expect ( http . get ). toHaveBeenCalledWith ( '/platform/v1/last-leg/dispatches/PC%2F1/prints/bol' , { responseType : 'blob' }) })
2026-08-21 11:20:09 +08:00
})