2026-10-19 17:56:31 +08:00
import ElementPlus , { ElMessage } from 'element-plus'
import { ApiResponseError , http } from '@/shared/api/http'
import { parseApiJsonResponse } from '@chongchong/shared'
import { flushPromises , mount } from '@vue/test-utils'
import { beforeEach , expect , it , vi } from 'vitest'
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import * as api from '../api/dispatches'
2026-10-20 10:11:31 +08:00
import { candidate , command , detail , destination , loadingPreview , page , sharedPalletCandidates } from '../dispatchTestFixtures'
2026-10-19 17:56:31 +08:00
import type { ArrangeGroup , CargoLine , CommandResult , DispatchAction , DispatchDetail , PreviewGroup } from '../dispatchContracts'
const identity = vi . hoisted (() => ({ id : '' }))
vi . mock ( '@/core/auth/auth' , () => ({ useAuthStore : () => ({ user : identity }) }))
vi . mock ( '@/core/access/accessPreview' , () => ({ useAccessPreviewStore : () => ({ can : () => true }) }))
vi . mock ( './DispatchResourceFields.vue' , () => ({ default : { template : '<div />' } }))
vi . mock ( '../api/dispatches' , () => ({ getDispatch : vi.fn (), getDispatchItems : vi.fn (), getDispatchRoute : vi.fn (), reorderDispatchRoute : vi.fn (), removeDispatchItem : vi.fn (), getDispatchLoading : vi.fn (), getDispatchArrangement : vi.fn (), getDispatchCommand : vi.fn (), listDispatches : vi.fn (), listDispatchCandidates : vi.fn (), completeDispatchLoading : vi.fn (), departDispatch : vi.fn (), reviewDispatch : vi.fn (), reviewDispatchLoading : vi.fn (), returnDispatchToReview : vi.fn (), cancelDispatch : vi.fn (), updateDispatch : vi.fn (), addDispatchCargo : vi.fn (), submitDispatchReview : vi.fn (), transferDispatchToDelivery : vi.fn () }))
interface State { submit () : Promise < void >; measurements : Array < { weightKg : number | null ; volumeCbm : number | null ; deliveryIndex : number | null ; palletSpaces : number | null } > ; form : { reason : string ; decision : string }; detail : DispatchDetail }
2026-10-20 10:11:31 +08:00
it ( 'adds all hidden shared-pallet sources once and rejects a changed group composition before another command' , async () => {
const [ first , second ] = sharedPalletCandidates ()
vi . mocked ( api . listDispatches ). mockResolvedValue ( page ()); vi . mocked ( api . listDispatchCandidates ). mockResolvedValue ({ candidates : [ first ! ], complete : true , snapshotAt : '' })
const { wrapper , state } = await open ( 'ADD_CARGO' )
const selection = wrapper . vm as unknown as { selectCargo ( row : NonNullable < typeof first >, checked : boolean ) : void }
selection . selectCargo ( first ! , true )
first ! . selectionGroup = { ... first ! . selectionGroup , groupVersion : 'composition-2' }
await state . submit (); expect ( api . addDispatchCargo ). not . toHaveBeenCalled (); expect ( wrapper . text ()). toContain ( '组成已变化' )
first ! . selectionGroup . complete = false ; selection . selectCargo ( first ! , false )
await state . submit (); expect ( api . addDispatchCargo ). not . toHaveBeenCalled (); expect ( wrapper . text ()). toContain ( '请选择来源完整' )
first ! . selectionGroup . complete = true
selection . selectCargo ( first ! , true )
vi . mocked ( api . addDispatchCargo ). mockImplementation ( async ( _ , request ) => command ( 'ADD_CARGO' , request . idempotencyKey )); vi . mocked ( api . getDispatch ). mockResolvedValue ( detail ({ recordVersion : 1 }))
await state . submit (); expect ( api . addDispatchCargo ). toHaveBeenCalledExactlyOnceWith ( 'PC/1' , expect . objectContaining ({ cargo : [ first ! . selection , second ! . selection ] }))
wrapper . unmount ()
})
2026-10-19 17:56:31 +08:00
it . each ([ 0 , '0' , '9007199254740993' ])( 'adds cargo with original batch version %s while keeping the parent dispatch version independent' , async recordVersion => {
const value = candidate ({ appointmentBatches : [{ batchKey : 'FBA_APPOINTMENT:1' , batchNo : 'AP-1' , customerId : 'C1' , recordVersion , sources : [{ sourceKey : 'ORDER:1' , destinationKey : 'FBA:ONT8' , packageCount : 2 }] }] })
vi . mocked ( api . listDispatches ). mockResolvedValue ( page ()); vi . mocked ( api . listDispatchCandidates ). mockResolvedValue ({ candidates : [ value ], complete : true , snapshotAt : '' })
const { wrapper , state } = await open ( 'ADD_CARGO' )
try {
2026-10-20 10:11:31 +08:00
( wrapper . vm as unknown as { selectCargo ( row : typeof value , checked : boolean ) : void }). selectCargo ( value , true )
2026-10-19 17:56:31 +08:00
vi . mocked ( api . addDispatchCargo ). mockImplementation ( async ( _ , request ) => command ( 'ADD_CARGO' , request . idempotencyKey )); vi . mocked ( api . getDispatch ). mockResolvedValue ( detail ({ recordVersion : 1 }))
await state . submit ()
expect ( api . addDispatchCargo ). toHaveBeenCalledWith ( 'PC/1' , expect . objectContaining ({ recordVersion : 0 , batchAllocations : [{ planPalletId : '11' , batchKey : 'FBA_APPOINTMENT:1' , customerId : 'C1' , sourceKey : 'ORDER:1' , destinationKey : 'FBA:ONT8' , packageCount : 2 , recordVersion }] }))
} finally { wrapper . unmount () }
})
it ( 'blocks cargo addition when the authoritative batch version is absent' , async () => {
const value = candidate ({ appointmentBatches : [{ batchKey : 'FBA_APPOINTMENT:1' , batchNo : 'AP-1' , customerId : 'C1' , recordVersion : undefined as unknown as number , sources : [] }] })
vi . mocked ( api . listDispatches ). mockResolvedValue ( page ()); vi . mocked ( api . listDispatchCandidates ). mockResolvedValue ({ candidates : [ value ], complete : true , snapshotAt : '' })
const { wrapper , state } = await open ( 'ADD_CARGO' )
try {
2026-10-20 10:11:31 +08:00
( wrapper . vm as unknown as { selectCargo ( row : typeof value , checked : boolean ) : void }). selectCargo ( value , true ); await state . submit ()
2026-10-19 17:56:31 +08:00
expect ( api . addDispatchCargo ). not . toHaveBeenCalled (); expect ( wrapper . text ()). toContain ( '预约批次版本缺失或无效,请重新读取候选' )
} finally { wrapper . unmount () }
})
it ( 'removes an unpicked authoritative item after real JSON and API normalization of pickedPackages string zero' , async () => {
const actual = await vi . importActual < typeof import ( '../api/dispatches' ) >( '../api/dispatches' )
const source = candidate ()
const data = { dispatchNo : 'PC/1' , recordVersion : '0' , status : 'ACTIVE' , stage : 'BEFORE_LOADING' , items : [{ ... source . selection , dispatchItemId : '9007199254740993' , sourceKey : source.sourceKey , sourceVersion : source.sourceVersion , customerId : 'C1' , customerName : '客户甲' , palletNo : 'PALLET-1' , orderNo : 'LM-1' , inboundCode : 'IN-1' , destination : source.destination , scheduledPackages : '2' , pickedPackages : '0' , remove : { allowed : true , blockCode : null , blockReason : null } }] }
const get = vi . spyOn ( http , 'get' ). mockResolvedValue ( parseApiJsonResponse ( JSON . stringify ({ code : '200' , data })))
vi . mocked ( api . getDispatchItems ). mockImplementation ( actual . getDispatchItems )
const { wrapper , state } = await open ( 'REMOVE_CARGO' )
try {
( wrapper . vm as unknown as { itemId : string }). itemId = '9007199254740993' ; state . form . reason = '本车计划调整'
vi . mocked ( api . removeDispatchItem ). mockImplementation ( async ( _ , __ , request ) => command ( 'REMOVE_CARGO' , request . idempotencyKey )); vi . mocked ( api . getDispatch ). mockResolvedValue ( detail ({ recordVersion : 1 }))
await state . submit ()
expect ( api . removeDispatchItem ). toHaveBeenCalledWith ( 'PC/1' , '9007199254740993' , expect . objectContaining ({ recordVersion : '0' , reason : '本车计划调整' })); expect ( wrapper . emitted ( 'saved' )). toHaveLength ( 1 )
} finally { wrapper . unmount (); get . mockRestore () }
})
async function open ( action : DispatchAction , allowed = true ) {
vi . mocked ( api . getDispatch ). mockResolvedValue ( detail ({ actions : { [ action ] : { allowed , blockCode : null , blockReason : allowed ? null : '前置审核未通过' } } }))
const wrapper = mount ( DispatchWorkflowDialog , { props : { modelValue : true , dispatchNo : 'PC/1' , action }, global : { plugins : [ ElementPlus ] } }); await flushPromises ()
return { wrapper , state : wrapper.vm as unknown as State }
}
beforeEach (() => { vi . clearAllMocks (); sessionStorage . clear (); identity . id = crypto . randomUUID (); vi . stubGlobal ( 'ResizeObserver' , class { observe() { } unobserve() { } disconnect() { } }); vi . mocked ( api . getDispatchLoading ). mockResolvedValue ( loadingPreview ()) })
it ( 'keeps order allocation references out of actual measurements and submits native version zero only after real input' , async () => {
const value = loadingPreview (); value . pallets [ 0 ] ! . weightKg = 100 ; value . pallets [ 0 ] ! . volumeCbm = 2
vi . mocked ( api . getDispatchLoading ). mockResolvedValue ( value )
const { wrapper , state } = await open ( 'LOADING_COMPLETE' )
expect ( state . measurements [ 0 ]). toMatchObject ({ weightKg : null , volumeCbm : null })
await state . submit (); expect ( api . completeDispatchLoading ). not . toHaveBeenCalled (); expect ( wrapper . text ()). toContain ( '真实托位' )
state . measurements [ 0 ] ! . weightKg = 92 ; state . measurements [ 0 ] ! . volumeCbm = 1.5
vi . mocked ( api . completeDispatchLoading ). mockImplementation ( async ( _ , body ) => command ( 'LOADING_COMPLETE' , body . idempotencyKey ))
vi . mocked ( api . getDispatch ). mockResolvedValue ( detail ({ stage : 'AFTER_LOADING' , recordVersion : 1 }))
await state . submit ()
expect ( api . completeDispatchLoading ). toHaveBeenCalledWith ( 'PC/1' , expect . objectContaining ({ recordVersion : 0 , manifestHash : 'manifest' , measurements : [{ warehousePalletId : '21' , palletSpaces : 1 , weightKg : 92 , volumeCbm : 1.5 , deliveryIndex : 1 }] }))
expect ( api . departDispatch ). not . toHaveBeenCalled (); expect ( wrapper . emitted ( 'saved' )). toHaveLength ( 1 ); wrapper . unmount ()
})
it ( 'accepts actual evidence and a matching confirmed manifest while refusing an unknown unloading index' , async () => {
const value = loadingPreview (); value . pallets [ 0 ] ! . weightKg = 92 ; value . pallets [ 0 ] ! . volumeCbm = 1.5 ; value . pallets [ 0 ] ! . deliveryIndex = null
value . pallets [ 0 ] ! . evidence . push ({ field : 'weightKg' , actual : true , value : 92 , sourceType : 'MEASURED' , sourceBusinessNo : 'PALLET-1' , observedAt : null })
value . confirmedLoad = { manifestHash : 'manifest' , operatorId : 'operator' , confirmedAt : '2026-10-19T00:00:00Z' , pallets : [{ warehousePalletId : '21' , palletSpaces : 1 , weightKg : 92 , volumeCbm : 1.5 , deliveryIndex : 1 }] }
vi . mocked ( api . getDispatchLoading ). mockResolvedValue ( value )
const { wrapper , state } = await open ( 'LOADING_COMPLETE' ); expect ( state . measurements [ 0 ]). toMatchObject ({ weightKg : 92 , volumeCbm : 1.5 , deliveryIndex : null })
await state . submit (); expect ( api . completeDispatchLoading ). not . toHaveBeenCalled (); wrapper . unmount ()
})
it ( 'requires a rejection reason and retains the full original command after an unknown review result' , async () => {
const { wrapper , state } = await open ( 'DISPATCH_REVIEW' ); state . form . decision = 'REJECTED'
await state . submit (); expect ( api . reviewDispatch ). not . toHaveBeenCalled ()
state . form . reason = '预约未核实' ; vi . mocked ( api . reviewDispatch ). mockRejectedValue ( new Error ( 'unknown result' ))
await state . submit (); state . form . reason = 'later changed'
vi . mocked ( api . getDispatchCommand ). mockImplementation ( async ( _ , key ) => command ( 'DISPATCH_REVIEW' , key ))
vi . mocked ( api . getDispatch ). mockResolvedValue ( detail ({ recordVersion : 1 }))
await state . submit (); expect ( api . reviewDispatch ). toHaveBeenCalledTimes ( 1 )
expect ( vi . mocked ( api . reviewDispatch ). mock . calls [ 0 ] ! [ 1 ]). toMatchObject ({ recordVersion : 0 , decision : 'REJECTED' , reason : '预约未核实' })
expect ( wrapper . emitted ( 'saved' )). toHaveLength ( 1 ); wrapper . unmount ()
})
it ( 'blocks an action without a literal service allowance' , async () => {
const { wrapper , state } = await open ( 'DEPART' , false ); await state . submit (); expect ( api . departDispatch ). not . toHaveBeenCalled (); expect ( wrapper . text ()). toContain ( '前置审核未通过' ); wrapper . unmount ()
})
it ( 'submits every customer destination group in one arrangement and does not waive another customer fee' , async () => {
2026-10-20 10:11:31 +08:00
const previews : PreviewGroup [] = [ 'A' , 'B' ]. map ( customerId => ({ groupKey : `group- ${ customerId } ` , customerId , customerName : `客户 ${ customerId } ` , destination : destination (), groupCargoHash : `cargo- ${ customerId } ` , cargo : [{ customerId , sourceKey : `source- ${ customerId } ` , inboundCode : `IN- ${ customerId } ` , orderNo : `LM- ${ customerId } ` , packages : 2 , sourceOrigin : null } as CargoLine ], externalFreightHash : `external- ${ customerId } ` , externalQuotes : customerId === 'A' ? [{ customerId : 'A' , groupKey : 'group-A' , bookingNo : 'BOOK-A' , inboundCode : 'IN-A' , currency : 'USD' , amount : 100 , sourceHash : 'quote-A' }] : [], feeCoverageCargoHash : `coverage- ${ customerId } ` , feeSources : [], requiredServices : [ 'DELIVERY_FEE' ], blockReasons : [] }))
2026-10-19 17:56:31 +08:00
vi . mocked ( api . getDispatchArrangement ). mockResolvedValue ({ dispatchNo : 'PC/1' , recordVersion : 0 , status : 'ACTIVE' , stage : 'AFTER_LOADING' , cargoHash : 'whole-cargo' , loadingCompletionHash : 'load-hash' , arrange : { allowed : true , blockCode : null , blockReason : null }, groups : previews , arrangement : null })
const { wrapper , state } = await open ( 'ARRANGE_DELIVERY' )
const groups = ( wrapper . vm as unknown as { groups : ArrangeGroup [] }). groups
groups . forEach ( group => Object . assign ( group , { plannedDeliveryAt : '2026-10-20T00:00:00Z' , deliveryMethod : 'TRUCK_PALLET' , pickupMethod : 'ONE_PICKUP_MULTI_DROP' , loadingType : 'PALLET' }))
await state . submit (); expect ( api . transferDispatchToDelivery ). not . toHaveBeenCalled ()
groups [ 1 ] ! . fees . find ( fee => fee . feeType === 'DELIVERY_FEE' ) ! . amount = 77
vi . mocked ( api . transferDispatchToDelivery ). mockImplementation ( async ( _ , body ) : Promise < CommandResult > => ({ ... command ( 'ARRANGE_DELIVERY' , body . idempotencyKey ), action : 'ARRANGE_DELIVERY' , result : { dispatchNo : 'PC/1' , recordVersion : 1 , arrangementKey : 'arrangement' , cargoHash : 'whole-cargo' , arrangedAt : '2026-10-19T00:00:00Z' , outbounds : [], deliveries : [] } }))
vi . mocked ( api . getDispatch ). mockResolvedValue ( detail ({ recordVersion : 1 , stage : 'AFTER_LOADING' })); await state . submit ()
const sent = vi . mocked ( api . transferDispatchToDelivery ). mock . calls [ 0 ] ! [ 1 ]
expect ( sent ). toMatchObject ({ recordVersion : 0 , cargoHash : 'whole-cargo' , loadingCompletionHash : 'load-hash' })
expect ( sent . groups ). toHaveLength ( 2 ); expect ( sent . groups [ 0 ] ! . fees ). toEqual ([]); expect ( sent . groups [ 1 ] ! . fees ). toEqual ([ expect . objectContaining ({ feeType : 'DELIVERY_FEE' , amount : 77 })])
expect ( api . getDispatchArrangement ). toHaveBeenCalledTimes ( 1 ); expect ( wrapper . emitted ( 'saved' )). toHaveLength ( 1 ); wrapper . unmount ()
})
it ( 'does not reuse another dispatch detail when the target changes while its dialog remains open' , async () => {
const { wrapper , state } = await open ( 'DEPART' )
let resolve !: ( value : DispatchDetail ) => void
vi . mocked ( api . getDispatch ). mockImplementationOnce (() => new Promise ( done => { resolve = done }))
await wrapper . setProps ({ dispatchNo : 'PC/2' }); await state . submit (); expect ( api . departDispatch ). not . toHaveBeenCalled ()
resolve ( detail ({ dispatchNo : 'PC/2' , actions : { DEPART : { allowed : false , blockCode : 'NOT_READY' , blockReason : '另一车尚未就绪' } } })); await flushPromises ()
await state . submit (); expect ( api . departDispatch ). not . toHaveBeenCalled (); expect ( wrapper . text ()). toContain ( '另一车尚未就绪' ); wrapper . unmount ()
})
it ( 'removes only the authoritative unpicked item using its item ID as path identity and retains that original target on recovery' , async () => {
const source = candidate ()
vi . mocked ( api . getDispatchItems ). mockResolvedValue ({ dispatchNo : 'PC/1' , recordVersion : 0 , status : 'ACTIVE' , stage : 'BEFORE_LOADING' , items : [{ ... source . selection , dispatchItemId : '9007199254740993' , sourceKey : source.sourceKey , sourceVersion : source.sourceVersion , customerId : 'C1' , customerName : '客户甲' , palletNo : 'PALLET-1' , orderNo : 'LM-1' , inboundCode : 'IN-1' , destination : source.destination , scheduledPackages : 2 , pickedPackages : 0 , remove : { allowed : true , blockCode : null , blockReason : null } }] })
const { wrapper , state } = await open ( 'REMOVE_CARGO' ); const target = wrapper . vm as unknown as { itemId : string }; target . itemId = '9007199254740993'
await state . submit (); expect ( api . removeDispatchItem ). not . toHaveBeenCalled ()
state . form . reason = '本车计划调整' ; vi . mocked ( api . removeDispatchItem ). mockRejectedValueOnce ( new Error ( 'unknown' )); await state . submit ()
target . itemId = 'another-item' ; state . form . reason = 'later changed'
vi . mocked ( api . getDispatchCommand ). mockImplementation ( async ( _ , key ) => command ( 'REMOVE_CARGO' , key )); vi . mocked ( api . getDispatch ). mockResolvedValue ( detail ({ recordVersion : 1 }))
await state . submit (); expect ( api . removeDispatchItem ). toHaveBeenCalledExactlyOnceWith ( 'PC/1' , '9007199254740993' , expect . objectContaining ({ recordVersion : 0 , reason : '本车计划调整' })); expect ( wrapper . emitted ( 'saved' )). toHaveLength ( 1 ); wrapper . unmount ()
})
it ( 'preserves external destinations and retries route EDIT through its original route endpoint instead of a resource update' , async () => {
const toast = vi . spyOn ( ElMessage , 'success' )
vi . mocked ( api . getDispatchRoute ). mockResolvedValue ({ dispatchNo : 'PC/1' , recordVersion : 0 , edit : { allowed : true , blockCode : null , blockReason : null }, stops : [{ index : 0 , name : '起运仓' , address : '实际起点' , destinationKey : null }, { index : 1 , name : 'ONT8' , address : '实际地址A' , destinationKey : 'CODE:ONT8' }, { index : 2 , name : '外接卸货点' , address : '实际地址B' , destinationKey : 'PRIVATE:actual' }] })
const { wrapper , state } = await open ( 'EDIT' ); await wrapper . setProps ({ operation : 'ROUTE' }); await flushPromises ()
const routeState = wrapper . vm as unknown as { moveStop ( index : number , offset : number ) : void }; routeState . moveStop ( 1 , - 1 ); state . form . reason = '按本车现场顺序卸货'
vi . mocked ( api . reorderDispatchRoute ). mockRejectedValueOnce ( new Error ( 'unknown route' )). mockImplementationOnce ( async ( _ , body ) => command ( 'EDIT' , body . idempotencyKey ))
await state . submit (); vi . mocked ( api . getDispatchCommand ). mockRejectedValueOnce ( new ApiResponseError ( 'not found' , 'HTTP_404' ))
vi . mocked ( api . getDispatch ). mockResolvedValue ( detail ({ recordVersion : 1 })); await state . submit ()
const first = vi . mocked ( api . reorderDispatchRoute ). mock . calls [ 0 ] ! [ 1 ]
expect ( first ). toMatchObject ({ recordVersion : 0 , destinationKeys : [ 'PRIVATE:actual' , 'CODE:ONT8' ], reason : '按本车现场顺序卸货' }); expect ( api . reorderDispatchRoute ). toHaveBeenNthCalledWith ( 2 , 'PC/1' , first ); expect ( api . updateDispatch ). not . toHaveBeenCalled (); expect ( toast ). toHaveBeenLastCalledWith ( '卸货顺序调整已确认,请重新核对两级审核' ); toast . mockRestore (); wrapper . unmount ()
})
it ( 'refuses already picked item removal and a route that drops an authoritative destination' , async () => {
const source = candidate ()
vi . mocked ( api . getDispatchItems ). mockResolvedValue ({ dispatchNo : 'PC/1' , recordVersion : 0 , status : 'ACTIVE' , stage : 'BEFORE_LOADING' , items : [{ ... source . selection , dispatchItemId : 'item-picked' , sourceKey : source.sourceKey , sourceVersion : source.sourceVersion , customerId : 'C1' , customerName : '客户甲' , palletNo : 'PALLET-1' , orderNo : 'LM-1' , inboundCode : 'IN-1' , destination : source.destination , scheduledPackages : 2 , pickedPackages : 1 , remove : { allowed : false , blockCode : 'PICKED' , blockReason : '本来源已经真实扫码' } }] })
const removed = await open ( 'REMOVE_CARGO' ); ( removed . wrapper . vm as unknown as { itemId : string }). itemId = 'item-picked' ; removed . state . form . reason = '调整' ; await removed . state . submit (); expect ( api . removeDispatchItem ). not . toHaveBeenCalled (); expect ( removed . wrapper . text ()). toContain ( '已经真实扫码' ); removed . wrapper . unmount ()
vi . mocked ( api . getDispatchRoute ). mockResolvedValue ({ dispatchNo : 'PC/1' , recordVersion : 0 , edit : { allowed : true , blockCode : null , blockReason : null }, stops : [{ index : 0 , name : '起运仓' , address : null , destinationKey : null }, { index : 1 , name : 'ONT8' , address : null , destinationKey : 'CODE:ONT8' }, { index : 2 , name : '外接点' , address : null , destinationKey : 'EXTERNAL:destination' }] })
const route = await open ( 'EDIT' ); await route . wrapper . setProps ({ operation : 'ROUTE' }); await flushPromises (); ( route . wrapper . vm as unknown as { stops : unknown [] }). stops . pop (); route . state . form . reason = '调整' ; await route . state . submit (); expect ( api . reorderDispatchRoute ). not . toHaveBeenCalled (); expect ( route . wrapper . text ()). toContain ( '完整保留' ); route . wrapper . unmount ()
})