2026-10-20 10:11:31 +08:00
import ElementPlus from 'element-plus'
2026-10-21 07:45:36 +08:00
import { DOMWrapper , enableAutoUnmount , flushPromises , mount , type VueWrapper } from '@vue/test-utils'
2026-10-20 10:11:31 +08:00
import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
import DispatchWorkflowDialog from './DispatchWorkflowDialog.vue'
import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
import DispatchResourceFields from './DispatchResourceFields.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { ApiResponseError } from '@/shared/api/http'
import * as api from '../api/dispatches'
2026-10-21 14:30:33 +08:00
import { command , detail , loadingPreview , resources } from '../dispatchTestFixtures'
import type { DispatchAction , DispatchActions , DispatchEvidenceFile , UpdateDispatch } from '../dispatchContracts'
2026-10-20 10:11:31 +08:00
const identity = vi . hoisted (() => ({ id : '' }))
const can = vi . hoisted (() => vi . fn (( _code : string ) => true ))
vi . mock ( '@/core/auth/auth' , () => ({ useAuthStore : () => ({ user : identity }) }))
vi . mock ( '@/core/access/accessPreview' , () => ({ useAccessPreviewStore : () => ({ can }) }))
vi . mock ( '@/modules/overseas-master-data' , () => ({ listVehicles : vi.fn (), listDrivers : vi.fn (), overseasMasterDataPermissions : { vehicleList : 'vehicle:list' , driverList : 'driver:list' } }))
2026-10-21 14:30:33 +08:00
vi . mock ( '../api/dispatches' , () => ({ getDispatch : vi.fn (), getDispatchItems : vi.fn (), getDispatchRoute : vi.fn (), reorderDispatchRoute : vi.fn (), removeDispatchItem : vi.fn (), getDispatchLoading : vi.fn (), getDispatchCommand : vi.fn (), listDispatches : vi.fn (), listDispatchCandidates : vi.fn (), completeDispatchLoading : vi.fn (), reviewDispatch : vi.fn (), reviewDispatchLoading : vi.fn (), returnDispatchToReview : vi.fn (), cancelDispatch : vi.fn (), updateDispatch : vi.fn (), addDispatchCargo : vi.fn (), submitDispatchReview : vi.fn (), getDispatchEvidence : vi.fn (), getDispatchEvidenceContent : vi.fn (), uploadDispatchEvidence : vi.fn () }))
2026-10-20 10:11:31 +08:00
enableAutoUnmount ( afterEach )
afterEach (() => vi . unstubAllGlobals ())
const gate = { allowed : true , blockCode : null , blockReason : null }
const actions : DispatchActions = { EDIT : gate , ADD_CARGO : gate , REMOVE_CARGO : gate , CANCEL : gate , SUBMIT_REVIEW : gate , DISPATCH_REVIEW : gate , LOADING_REVIEW : gate , RETURN_TO_REVIEW : gate , LOADING_COMPLETE : gate , ARRANGE_DELIVERY : gate , DEPART : gate }
const evidence = ( fileId = '301' ) : DispatchEvidenceFile => ({ fileId , fileName : fileId === '301' ? '旧附件.png' : '新附件.png' , contentType : 'image/png' , sizeBytes : 3 })
beforeEach (() => {
vi . resetAllMocks (); sessionStorage . clear (); identity . id = crypto . randomUUID (); can . mockReturnValue ( true )
vi . stubGlobal ( 'ResizeObserver' , class { observe() { } unobserve() { } disconnect() { } })
URL . createObjectURL = vi . fn (). mockReturnValue ( 'blob:owned-dispatch' ); URL . revokeObjectURL = vi . fn ()
vi . mocked ( api . getDispatch ). mockResolvedValue ( detail ({ recordVersion : 1 , actions }))
vi . mocked ( api . getDispatchCommand ). mockRejectedValue ( new ApiResponseError ( 'not found' , 'A0404' , 404 ))
vi . mocked ( api . updateDispatch ). mockImplementation ( async ( _ , body ) => command ( 'EDIT' , body . idempotencyKey ))
vi . mocked ( api . reviewDispatch ). mockImplementation ( async ( _ , body ) => command ( 'DISPATCH_REVIEW' , body . idempotencyKey ))
vi . mocked ( api . reviewDispatchLoading ). mockImplementation ( async ( _ , body ) => command ( 'LOADING_REVIEW' , body . idempotencyKey ))
vi . mocked ( api . cancelDispatch ). mockImplementation ( async ( _ , body ) => command ( 'CANCEL' , body . idempotencyKey ))
vi . mocked ( api . returnDispatchToReview ). mockImplementation ( async ( _ , body ) => command ( 'RETURN_TO_REVIEW' , body . idempotencyKey ))
vi . mocked ( api . submitDispatchReview ). mockImplementation ( async ( _ , body ) => command ( 'SUBMIT_REVIEW' , body . idempotencyKey ))
vi . mocked ( api . reorderDispatchRoute ). mockImplementation ( async ( _ , body ) => command ( 'EDIT' , body . idempotencyKey ))
vi . mocked ( api . completeDispatchLoading ). mockImplementation ( async ( _ , body ) => command ( 'LOADING_COMPLETE' , body . idempotencyKey ))
vi . mocked ( api . getDispatchLoading ). mockResolvedValue ( loadingPreview ())
vi . mocked ( api . getDispatchRoute ). mockResolvedValue ({ dispatchNo : 'PC/1' , recordVersion : 0 , edit : gate , stops : [{ index : 0 , name : '起运仓' , address : '起点地址' , destinationKey : null }, { index : 1 , name : '目的仓甲' , address : '甲地址' , destinationKey : 'A' }, { index : 2 , name : '外接卸货点' , address : '乙地址' , destinationKey : 'B' }] })
vi . mocked ( api . getDispatchEvidence ). mockImplementation ( async ( _ , __ , id ) => evidence ( id ))
vi . mocked ( api . getDispatchEvidenceContent ). mockResolvedValue ( new Blob ([ 'png' ], { type : 'image/png' }))
vi . mocked ( api . uploadDispatchEvidence ). mockResolvedValue ( evidence ( '302' ))
})
async function render ( action : DispatchAction , operation ?: 'ROUTE' , files = false ) {
const initial = detail ({ actions }); if ( files ) initial . vehicleInfo . vehicle ! . evidenceFileIds = [ '301' ]
vi . mocked ( api . getDispatch ). mockResolvedValueOnce ( initial )
const wrapper = mount ( DispatchWorkflowDialog , { attachTo : document.body , props : { modelValue : true , dispatchNo : 'PC/1' , action , operation }, global : { plugins : [ ElementPlus ] } }); await flushPromises ()
return { wrapper , dialog : wrapper.findComponent ( AppDialog ) }
}
function field ( wrapper : Pick < VueWrapper , 'findAllComponents' >, label : string ) { return wrapper . findAllComponents ({ name : 'ElFormItem' }). find ( item => item . props ( 'label' ) === label ) ! }
describe ( 'dispatch workflow native layout' , () => {
it ( 'submits all edited resources, dates and zero version through the continuous fullscreen form' , async () => {
const { dialog } = await render ( 'EDIT' )
expect ( dialog . props ( 'title' )). toBe ( '维护车辆信息 · PC/1' )
expect ( dialog . getComponent ( DispatchResourceFields ). props ( 'continuous' )). toBe ( true )
await field ( dialog , '车牌号' ). get ( 'input' ). setValue ( 'CA-NEW' )
field ( dialog , '实际托位容量' ). getComponent ({ name : 'ElInputNumber' }). vm . $emit ( 'update:modelValue' , 22 )
await field ( dialog , '司机姓名' ). get ( 'input' ). setValue ( '新司机' )
field ( dialog , '计划发车时间' ). getComponent ({ name : 'ElDatePicker' }). vm . $emit ( 'update:modelValue' , '2026-11-01T10:00:00Z' )
2026-10-20 14:52:06 +08:00
await dialog . get ( 'textarea[aria-label="本次操作备注"]' ). setValue ( '维护备注' )
2026-10-20 10:11:31 +08:00
await dialog . get ( 'button[aria-label="展开全屏"]' ). trigger ( 'click' ); await flushPromises ()
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
const { vehicleStatus : _status , ... vehicle } = resources (). vehicle
expect ( api . updateDispatch ). toHaveBeenCalledExactlyOnceWith ( 'PC/1' , { recordVersion : 0 , idempotencyKey : expect.any ( String ), vehicle : { ... vehicle , plate : 'CA-NEW' , capacity : { ... vehicle . capacity , palletSpaces : 22 } }, driver : { ... resources (). driver , name : '新司机' }, plannedDepartureAt : '2026-11-01T10:00:00Z' , estimatedArrivalAt : null , remark : '维护备注' })
})
it ( 'retains the locally submitted draft after close and reopen and retries exactly the same failed request' , async () => {
vi . mocked ( api . updateDispatch ). mockRejectedValueOnce ( new ApiResponseError ( '结果未确认' , 'A0503' , 503 , null , 'workflow-request' )). mockImplementationOnce ( async ( _ , body ) => command ( 'EDIT' , body . idempotencyKey ))
const { wrapper , dialog } = await render ( 'EDIT' )
await field ( dialog , '车牌号' ). get ( 'input' ). setValue ( 'CA-PRESERVED' )
2026-10-20 14:52:06 +08:00
await dialog . get ( 'textarea[aria-label="本次操作备注"]' ). setValue ( '保留本次输入' )
2026-10-20 10:11:31 +08:00
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
const original = JSON . parse ( JSON . stringify ( vi . mocked ( api . updateDispatch ). mock . calls [ 0 ] ! [ 1 ])) as UpdateDispatch
expect ( dialog . text ()). toContain ( 'workflow-request' )
expect ( field ( dialog , '车牌号' ). get ( 'input' ). attributes ( 'disabled' )). toBeDefined ()
await wrapper . setProps ({ dispatchNo : 'PC/2' , action : 'CANCEL' }); await flushPromises ()
expect ( dialog . props ( 'title' )). toBe ( '维护车辆信息 · PC/1' )
await wrapper . setProps ({ modelValue : false }); await wrapper . setProps ({ modelValue : true }); await flushPromises ()
expect (( field ( dialog , '车牌号' ). get ( 'input' ). element as HTMLInputElement ). value ). toBe ( 'CA-PRESERVED' )
2026-10-20 14:52:06 +08:00
expect (( dialog . get ( 'textarea[aria-label="本次操作备注"]' ). element as HTMLTextAreaElement ). value ). toBe ( '保留本次输入' )
2026-10-20 10:11:31 +08:00
dialog . findComponent ( LoadErrorState ). vm . $emit ( 'retry' ); await flushPromises ()
expect ( vi . mocked ( api . updateDispatch ). mock . calls [ 1 ] ! [ 1 ]). toEqual ( original )
})
it . each ([ 'DISPATCH_REVIEW' , 'LOADING_REVIEW' ] as const )( 'keeps %s facts read-only and requires a rejection reason' , async action => {
const { dialog } = await render ( action )
expect ( dialog . get ( '[aria-label="本车审核依据"]' ). findAll ( 'input' )). toHaveLength ( 0 )
expect ( dialog . text ()). toContain ( '审核通过不代替后续现场扫码' )
field ( dialog , '审核结果' ). getComponent ({ name : 'ElRadioGroup' }). vm . $emit ( 'update:modelValue' , 'REJECTED' ); await flushPromises ()
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
dialog . vm . $emit ( 'confirm' ); await flushPromises (); expect ( review ). not . toHaveBeenCalled ()
await dialog . get ( 'textarea' ). setValue ( ' 证据不完整 ' ); dialog . vm . $emit ( 'confirm' ); await flushPromises ()
expect ( review ). toHaveBeenCalledExactlyOnceWith ( 'PC/1' , { recordVersion : 0 , idempotencyKey : expect.any ( String ), decision : 'REJECTED' , reason : '证据不完整' })
2026-10-21 15:10:54 +08:00
expect ( dialog . findAll ( 'input[type="file"]' )). toHaveLength ( 3 )
})
it . each ([ 'DISPATCH_REVIEW' , 'LOADING_REVIEW' ] as const )( 'uploads three audit attachments with only %s permission and retries the same rejected review' , async action => {
can . mockImplementation ( code => code === ( action === 'DISPATCH_REVIEW' ? 'platform:fleet:trip:dispatch-review' : 'platform:fleet:trip:loading-review' ))
const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
vi . mocked ( review ). mockRejectedValueOnce ( new Error ( 'unknown review' )). mockImplementationOnce ( async ( _ , body ) => command ( action , body . idempotencyKey ))
const { wrapper , dialog } = await render ( action , undefined , true )
const fields = dialog . findAllComponents ( DispatchEvidenceFiles )
expect ( fields . map ( item => item . props ( 'label' ))). toEqual ([ '车辆图片' , '司机照片' , '驾驶证照片' ])
expect ( fields [ 0 ] ! . props ( 'referenceIds' )). toEqual ([ '301' ])
expect ( fields [ 0 ] ! . findAll ( 'button' ). some ( button => button . text () === '替换' )). toBe ( false )
for ( const [ index , files ] of fields . entries ()) {
let resolve !: ( value : DispatchEvidenceFile ) => void
vi . mocked ( api . uploadDispatchEvidence ). mockReturnValueOnce ( new Promise ( done => { resolve = done }))
const input = files . get ( 'input[type="file"]' ), file = new File ([ 'png' ], 'audit.png' , { type : 'image/png' })
Object . defineProperty ( input . element , 'files' , { configurable : true , value : [ file ] })
await input . trigger ( 'change' ); await flushPromises ()
expect ( dialog . props ( 'confirmLoading' )). toBe ( true )
dialog . vm . $emit ( 'confirm' ); await flushPromises (); expect ( review ). not . toHaveBeenCalled ()
resolve ( evidence ( String ( 401 + index ))); await flushPromises ()
}
expect ( vi . mocked ( api . uploadDispatchEvidence ). mock . calls . map ( call => call [ 1 ])). toEqual ([ 'VEHICLE' , 'DRIVER_PHOTO' , 'DRIVER' ])
field ( dialog , '审核结果' ). getComponent ({ name : 'ElRadioGroup' }). vm . $emit ( 'update:modelValue' , 'REJECTED' ); await flushPromises ()
await dialog . get ( 'textarea' ). setValue ( '需重新核对车牌' )
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
const original = structuredClone ( vi . mocked ( review ). mock . calls [ 0 ] ! [ 1 ])
expect ( original ). toMatchObject ({ decision : 'REJECTED' , reason : '需重新核对车牌' , attachments : { vehicleFileIds : [ '401' ], driverPhotoFileIds : [ '402' ], licenseFileIds : [ '403' ] } })
await wrapper . setProps ({ modelValue : false }); await wrapper . setProps ({ modelValue : true }); await flushPromises ()
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
expect ( vi . mocked ( review ). mock . calls [ 1 ] ! [ 1 ]). toEqual ( original )
expect ( api . updateDispatch ). not . toHaveBeenCalled ()
2026-10-20 10:11:31 +08:00
})
2026-10-21 14:30:33 +08:00
it . each ([ 'CANCEL' , 'RETURN_TO_REVIEW' , 'SUBMIT_REVIEW' ] as const )( 'preserves the %s confirmation branch and its exact reason or remark' , async action => {
2026-10-20 10:11:31 +08:00
const { dialog } = await render ( action )
const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW'
2026-10-21 14:30:33 +08:00
const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview
2026-10-20 10:11:31 +08:00
if ( needsReason ) { dialog . vm . $emit ( 'confirm' ); await flushPromises (); expect ( send ). not . toHaveBeenCalled () }
await dialog . get ( 'textarea' ). setValue ( needsReason ? ' 现场原因 ' : '本次交接' )
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
expect ( send ). toHaveBeenCalledExactlyOnceWith ( 'PC/1' , { recordVersion : 0 , idempotencyKey : expect.any ( String ), ...( needsReason ? { reason : '现场原因' } : { remark : '本次交接' }) })
})
it ( 'reorders only real unload stops and preserves the external stop and zero route version' , async () => {
const { dialog } = await render ( 'EDIT' , 'ROUTE' )
const rows = dialog . get ( '[aria-label="本车卸货顺序"]' ). findAll ( 'tbody tr' ). filter ( row => row . findAll ( 'button' ). length )
expect ( rows ). toHaveLength ( 2 )
expect ( rows [ 0 ] ! . findAll ( 'button' )[ 0 ] ! . attributes ( 'disabled' )). toBeDefined ()
await rows [ 1 ] ! . findAll ( 'button' ). find ( button => button . text () === '上移' ) ! . trigger ( 'click' ); await flushPromises ()
await dialog . get ( 'textarea' ). setValue ( ' 顺序调整 ' )
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
expect ( api . reorderDispatchRoute ). toHaveBeenCalledExactlyOnceWith ( 'PC/1' , { recordVersion : 0 , idempotencyKey : expect.any ( String ), destinationKeys : [ 'B' , 'A' ], reason : '顺序调整' })
})
2026-10-21 13:22:42 +08:00
it . each ([ 'ADD_CARGO' , 'REMOVE_CARGO' ] as const )( 'replaces the old %s editor with the smart dispatch entry notice' , async action => {
const { dialog } = await render ( action )
expect ( dialog . text ()). toContain ( '货物安排统一在智能排车中修改' )
expect ( dialog . props ( 'confirmDisabled' )). toBe ( true )
expect ( dialog . findComponent ({ name : 'ElForm' }). exists ()). toBe ( false )
2026-10-20 10:11:31 +08:00
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
2026-10-21 13:22:42 +08:00
expect ( api . addDispatchCargo ). not . toHaveBeenCalled ()
expect ( api . removeDispatchItem ). not . toHaveBeenCalled ()
expect ( api . getDispatchItems ). not . toHaveBeenCalled ()
expect ( api . listDispatchCandidates ). not . toHaveBeenCalled ()
2026-10-20 10:11:31 +08:00
})
it ( 'keeps references separate from real loading measurements and submits the exact manifest' , async () => {
const { dialog } = await render ( 'LOADING_COMPLETE' )
const numbers = dialog . get ( '[aria-label="真实实载登记"]' ). findAllComponents ({ name : 'ElInputNumber' })
expect ( numbers [ 1 ] ! . props ( 'modelValue' )). toBeNull ()
expect ( dialog . get ( '[aria-label="实载与参考依据"]' ). text ()). toContain ( '仅供参考' )
dialog . vm . $emit ( 'confirm' ); await flushPromises (); expect ( api . completeDispatchLoading ). not . toHaveBeenCalled ()
numbers [ 0 ] ! . vm . $emit ( 'update:modelValue' , 1 ); numbers [ 1 ] ! . vm . $emit ( 'update:modelValue' , 120 ); numbers [ 2 ] ! . vm . $emit ( 'update:modelValue' , 1.2 ); await flushPromises ()
await dialog . get ( 'button[aria-label="展开全屏"]' ). trigger ( 'click' ); await flushPromises ()
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
expect ( api . completeDispatchLoading ). toHaveBeenCalledExactlyOnceWith ( 'PC/1' , { recordVersion : 0 , idempotencyKey : expect.any ( String ), manifestHash : 'manifest' , measurements : [{ warehousePalletId : '21' , palletSpaces : 1 , weightKg : 120 , volumeCbm : 1.2 , deliveryIndex : 1 }], remark : '真实备注' })
})
it ( 'previews owned images fullscreen and locks the parent while a real file reference upload is pending' , async () => {
let resolve !: ( value : DispatchEvidenceFile ) => void
vi . mocked ( api . uploadDispatchEvidence ). mockReturnValue ( new Promise ( done => { resolve = done }))
const { dialog } = await render ( 'EDIT' , undefined , true )
const files = dialog . findAllComponents ( DispatchEvidenceFiles )[ 0 ] !
await files . findAll ( 'button' ). find ( button => button . text () === '预览' ) ! . trigger ( 'click' ); await flushPromises ()
const image = files . findComponent ( AppDialog )
expect ( image . props ( 'modelValue' )). toBe ( true )
2026-10-21 07:45:36 +08:00
const preview = new DOMWrapper ( document . querySelector ( '[aria-label="本车附件预览信息"]' ) ! . closest ( '.el-dialog' ) ! )
expect ( preview . get ( 'img' ). attributes ( 'src' )). toBe ( 'blob:owned-dispatch' )
await preview . get ( 'button[aria-label="展开全屏"]' ). trigger ( 'click' ); await flushPromises ()
expect ( preview . classes ()). toContain ( 'is-fullscreen' )
await preview . findAll ( 'button' ). find ( button => button . text () === '关闭' ) ! . trigger ( 'click' ); await flushPromises ()
2026-10-20 10:11:31 +08:00
expect ( URL . revokeObjectURL ). toHaveBeenCalledWith ( 'blob:owned-dispatch' )
const input = files . get ( 'input[type="file"]' ), file = new File ([ 'png' ], 'new.png' , { type : 'image/png' })
Object . defineProperty ( input . element , 'files' , { configurable : true , value : [ file ] }); await input . trigger ( 'change' ); await flushPromises ()
expect ( dialog . props ( 'confirmLoading' )). toBe ( true )
expect ( dialog . findComponent ({ name : 'ElDialog' }). props ()). toMatchObject ({ showClose : false , closeOnPressEscape : false })
expect ( field ( dialog , '车牌号' ). get ( 'input' ). attributes ( 'disabled' )). toBeDefined ()
dialog . vm . $emit ( 'confirm' ); await flushPromises (); expect ( api . updateDispatch ). not . toHaveBeenCalled ()
resolve ( evidence ( '302' )); await flushPromises ()
expect ( dialog . props ( 'confirmLoading' )). toBe ( false )
expect ( files . props ( 'modelValue' )). toEqual ([ '301' , '302' ])
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
expect ( api . updateDispatch ). toHaveBeenCalledWith ( 'PC/1' , expect . objectContaining ({ vehicle : expect.objectContaining ({ evidenceFileIds : [ '301' , '302' ] }) }))
})
it ( 'keeps server gates and write permissions independent from readable facts' , async () => {
can . mockReturnValue ( false )
const { dialog } = await render ( 'EDIT' )
expect ( dialog . props ( 'confirmDisabled' )). toBe ( true )
expect ( field ( dialog , '车牌号' ). get ( 'input' ). attributes ( 'disabled' )). toBeDefined ()
dialog . vm . $emit ( 'confirm' ); await flushPromises ()
expect ( api . updateDispatch ). not . toHaveBeenCalled ()
})
})