2026-10-19 17:56:31 +08:00
import ElementPlus from 'element-plus'
import { flushPromises , mount } from '@vue/test-utils'
2026-10-03 15:57:53 +08:00
import { beforeEach , expect , it , vi } from 'vitest'
import SmartDispatchDialog from './SmartDispatchDialog.vue'
2026-10-19 17:56:31 +08:00
import AppDialog from '@/shared/components/AppDialog.vue'
import { listAllPlatformWarehouses } from '@/modules/warehouses'
import * as api from '../api/dispatches'
2026-10-20 10:11:31 +08:00
import { candidate , command , detail , sharedPalletCandidates } from '../dispatchTestFixtures'
2026-10-19 17:56:31 +08:00
import type { ConvertDraft , DispatchDraft , Version } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
const identity = vi . hoisted (() => ({ id : 'smart-operator' }))
const push = vi . hoisted (() => vi . fn ())
vi . mock ( '@/core/auth/auth' , () => ({ useAuthStore : () => ({ user : identity }) }))
vi . mock ( '@/core/access/accessPreview' , () => ({ useAccessPreviewStore : () => ({ can : () => true }) }))
vi . mock ( 'vue-router' , () => ({ useRouter : () => ({ push }) }))
vi . mock ( '@/modules/warehouses' , () => ({ listAllPlatformWarehouses : vi.fn () }))
vi . mock ( './DispatchResourceFields.vue' , () => ({ default : { template : '<div />' } }))
vi . mock ( '../api/dispatches' , () => ({ listDispatchDrafts : vi.fn (), listDispatchCandidates : vi.fn (), createDispatchDraft : vi.fn (), createDispatchDraftContainer : vi.fn (), lockDispatchDraftPallet : vi.fn (), unlockDispatchDraftPallet : vi.fn (), deleteDispatchDraftContainer : vi.fn (), releaseDispatchDraft : vi.fn (), convertDispatchDraft : vi.fn (), createDispatch : vi.fn (), getDispatch : vi.fn (), getDispatchCommand : vi.fn () }))
const source = () => candidate (), draft = () : DispatchDraft => ({ draftToken : 'server-token' , recordVersion : 0 , status : 'ACTIVE' , warehouseId : '321' , containers : [{ draftContainerId : '100000000000000001' , sequenceNo : 1 , capacity : 30 , sourceKeys : [ 'source-1' ] }], sources : [ source ()], expiresAt : '2026-12-01T00:00:00Z' })
2026-10-20 10:11:31 +08:00
it ( 'converts a filtered relabel group with both authoritative sources and locks it through one HTTP command' , async () => {
const [ first , second ] = sharedPalletCandidates (), original = draft ()
original . sources = [ first ! ]; original . containers [ 0 ] ! . sourceKeys = [ first ! . sourceKey ]
vi . mocked ( api . listDispatchCandidates ). mockResolvedValue ({ candidates : [ first ! ], complete : true , snapshotAt : '' })
vi . mocked ( api . listDispatchDrafts ). mockResolvedValue ([ original ]); vi . mocked ( api . lockDispatchDraftPallet ). mockResolvedValue ( original )
vi . mocked ( api . convertDispatchDraft ). mockImplementation ( async request => ({ draftToken : request.draftToken , replayed : false , vehicles : request.groups.map ( group => ({ draftContainerId : group.draftContainerId , dispatch : command ( 'CREATE' , group . dispatch . idempotencyKey ) })) }))
const wrapper = mount ( SmartDispatchDialog , options ); await flushPromises ()
const state = wrapper . vm as unknown as { restoreDraft ( token : string ) : void ; draftAction ( action : string , row : typeof first , container : string ) : Promise < void >; submit () : Promise < void > }
state . restoreDraft ( 'server-token' ); await flushPromises (); await state . draftAction ( 'LOCK' , first , original . containers [ 0 ] ! . draftContainerId )
expect ( api . lockDispatchDraftPallet ). toHaveBeenCalledExactlyOnceWith ( expect . objectContaining ({ source : first ! . selection }))
await state . submit (); expect ( vi . mocked ( api . convertDispatchDraft ). mock . calls [ 0 ] ! [ 0 ]. groups [ 0 ] ! . dispatch . cargo ). toEqual ([ first ! . selection , second ! . selection ])
wrapper . unmount ()
})
it ( 'allows one atomic removal of a restored group that is no longer selectable' , async () => {
const first = sharedPalletCandidates ()[ 0 ] ! , original = draft ()
first . selectionGroup = { ... first . selectionGroup , complete : false , blockReasons : [ '来源待核齐' ] }
original . sources = [ first ]; original . containers [ 0 ] ! . sourceKeys = [ first . sourceKey ]
vi . mocked ( api . listDispatchDrafts ). mockResolvedValue ([ original ]); vi . mocked ( api . unlockDispatchDraftPallet ). mockResolvedValue ( original )
const wrapper = mount ( SmartDispatchDialog , options ); await flushPromises ()
const state = wrapper . vm as unknown as { restoreDraft ( token : string ) : void ; draftAction ( action : string , row : typeof first , container : string ) : Promise < void > }
state . restoreDraft ( 'server-token' ); await flushPromises (); await state . draftAction ( 'UNLOCK' , first , original . containers [ 0 ] ! . draftContainerId )
expect ( api . unlockDispatchDraftPallet ). toHaveBeenCalledExactlyOnceWith ( expect . objectContaining ({ source : first.selection })); wrapper . unmount ()
})
2026-10-19 17:56:31 +08:00
const options = { props : { modelValue : true , orders : [] }, global : { plugins : [ ElementPlus ] } }
beforeEach (() => { vi . clearAllMocks (); sessionStorage . clear (); identity . id = crypto . randomUUID (); vi . stubGlobal ( 'ResizeObserver' , class { observe() { } unobserve() { } disconnect() { } }); vi . mocked ( listAllPlatformWarehouses ). mockResolvedValue ([{ id : '321' , warehouseName : '洛杉矶仓' }] as Awaited < ReturnType < typeof listAllPlatformWarehouses >>); vi . mocked ( api . listDispatchDrafts ). mockResolvedValue ([ draft ()]); vi . mocked ( api . listDispatchCandidates ). mockResolvedValue ({ candidates : [ source ()], complete : true , snapshotAt : '2026-10-19T00:00:00Z' }); vi . mocked ( api . getDispatch ). mockResolvedValue ( detail ({ recordVersion : 1 })) })
it . each ([ 0 , '0' , '9007199254740993' ])( 'transmits original authoritative batch version %s in complete draft conversion and exact retries' , async recordVersion => {
const original = draft ()
original . sources [ 0 ] ! . appointmentBatches = [{ batchKey : 'FBA_APPOINTMENT:1' , batchNo : 'AP-1' , customerId : 'C1' , recordVersion , sources : [{ sourceKey : 'ORDER:1' , destinationKey : 'FBA:ONT8' , packageCount : 2 }] }]
vi . mocked ( api . listDispatchDrafts ). mockResolvedValue ([ original ])
vi . mocked ( api . convertDispatchDraft ). mockRejectedValueOnce ( new Error ( 'unknown' )). mockImplementationOnce ( async request => ({ draftToken : request.draftToken , replayed : true , vehicles : request.groups.map ( group => ({ draftContainerId : group.draftContainerId , dispatch : command ( 'CREATE' , group . dispatch . idempotencyKey ) })) }))
const wrapper = mount ( SmartDispatchDialog , options )
try {
await flushPromises (); const state = wrapper . vm as unknown as { restoreDraft ( token : string ) : void ; submit () : Promise < void > }
state . restoreDraft ( 'server-token' ); await flushPromises (); await state . submit ()
const request = JSON . parse ( JSON . stringify ( vi . mocked ( api . convertDispatchDraft ). mock . calls [ 0 ] ! [ 0 ])) as ConvertDraft
expect ( request . groups [ 0 ] ! . dispatch . batchAllocations ). toEqual ([{ planPalletId : '11' , batchKey : 'FBA_APPOINTMENT:1' , customerId : 'C1' , sourceKey : 'ORDER:1' , destinationKey : 'FBA:ONT8' , packageCount : 2 , recordVersion }])
original . sources [ 0 ] ! . appointmentBatches [ 0 ] ! . recordVersion = '9007199254740994'
await state . submit (); expect ( api . convertDispatchDraft ). toHaveBeenNthCalledWith ( 2 , request )
} finally { wrapper . unmount () }
2026-10-05 03:30:06 +08:00
})
2026-10-19 17:56:31 +08:00
it . each ([ undefined , null , 'bad' , 9007199254740992 ])( 'requires rereading a restored candidate with missing or invalid batch version %s' , async recordVersion => {
const original = draft ()
original . sources [ 0 ] ! . appointmentBatches = [{ batchKey : 'FBA_APPOINTMENT:1' , batchNo : 'AP-1' , customerId : 'C1' , recordVersion : recordVersion as Version , sources : [] }]
vi . mocked ( api . listDispatchDrafts ). mockResolvedValue ([ original ])
const wrapper = mount ( SmartDispatchDialog , options )
try {
await flushPromises (); const state = wrapper . vm as unknown as { restoreDraft ( token : string ) : void ; submit () : Promise < void > }
state . restoreDraft ( 'server-token' ); await flushPromises (); await state . submit ()
expect ( api . convertDispatchDraft ). not . toHaveBeenCalled (); expect ( wrapper . text ()). toContain ( '预约批次版本缺失或无效,请重新读取候选' )
} finally { wrapper . unmount () }
2026-10-05 03:30:06 +08:00
})
2026-10-19 17:56:31 +08:00
it ( 'requires an explicit warehouse and allows saving a true empty dispatch with no fake cargo' , async () => {
const wrapper = mount ( SmartDispatchDialog , options ); await flushPromises ()
const state = wrapper . vm as unknown as { mode : string ; warehouseId : string ; submit : () => Promise < void > }
state . mode = 'empty' ; await wrapper . getComponent ( AppDialog ). vm . $emit ( 'confirm' ); await flushPromises (); expect ( api . createDispatch ). not . toHaveBeenCalled ()
state . warehouseId = '321' ; vi . mocked ( api . createDispatch ). mockImplementation ( async body => command ( 'CREATE' , body . idempotencyKey ))
await state . submit (); await flushPromises ()
expect ( api . createDispatch ). toHaveBeenCalledWith ( expect . objectContaining ({ warehouseId : '321' , cargo : [], vehicle : null , driver : null }))
expect ( wrapper . emitted ( 'created' )). toHaveLength ( 1 ); wrapper . unmount ()
2026-10-05 03:30:06 +08:00
})
2026-10-19 17:56:31 +08:00
it ( 'restores an explicit draft and submits complete server source versions in each container' , async () => {
2026-10-20 10:11:31 +08:00
const original = draft (); original . sources . push ( candidate ({ sourceKey : 'source-B' , customerId : 'B' , customerName : '客户乙' , selection : { ... source (). selection , planPalletId : '22' , warehousePalletId : '32' } })); original . containers [ 0 ] ! . sourceKeys . push ( 'source-B' )
2026-10-19 17:56:31 +08:00
vi . mocked ( api . listDispatchDrafts ). mockResolvedValue ([ original ])
vi . mocked ( api . convertDispatchDraft ). mockImplementation ( async request => ({ draftToken : request.draftToken , replayed : false , vehicles : request.groups.map ( group => ({ draftContainerId : group.draftContainerId , dispatch : command ( 'CREATE' , group . dispatch . idempotencyKey , 'PC/1' ) })) }))
const wrapper = mount ( SmartDispatchDialog , options ); await flushPromises (); const state = wrapper . vm as unknown as { restoreDraft : ( token : string ) => void ; submit : () => Promise < void > }
state . restoreDraft ( 'server-token' ); await flushPromises (); await state . submit (); await flushPromises ()
2026-10-20 10:11:31 +08:00
expect ( api . convertDispatchDraft ). toHaveBeenCalledWith ( expect . objectContaining ({ recordVersion : 0 , draftToken : 'server-token' , groups : [ expect . objectContaining ({ draftContainerId : '100000000000000001' , dispatch : expect.objectContaining ({ cargo : [ source (). selection , original . sources [ 1 ] ! . selection ] }) })] }))
2026-10-19 17:56:31 +08:00
expect ( wrapper . emitted ( 'created' )). toHaveLength ( 1 ); wrapper . unmount ()
2026-10-05 03:30:06 +08:00
})
2026-10-19 17:56:31 +08:00
it ( 'retains conversion payload and key after an unknown result instead of rebuilding from fresh versions' , async () => {
vi . mocked ( api . convertDispatchDraft ). mockRejectedValueOnce ( new Error ( 'network unknown' )). mockImplementationOnce ( async request => ({ draftToken : request.draftToken , replayed : true , vehicles : request.groups.map ( group => ({ draftContainerId : group.draftContainerId , dispatch : command ( 'CREATE' , group . dispatch . idempotencyKey ) })) }))
const wrapper = mount ( SmartDispatchDialog , options ); await flushPromises (); const state = wrapper . vm as unknown as { restoreDraft : ( token : string ) => void ; submit : () => Promise < void > }
state . restoreDraft ( 'server-token' ); await flushPromises (); await state . submit (); await state . submit ()
expect ( api . convertDispatchDraft ). toHaveBeenCalledTimes ( 2 ); expect ( vi . mocked ( api . convertDispatchDraft ). mock . calls [ 1 ] ! [ 0 ]). toEqual ( vi . mocked ( api . convertDispatchDraft ). mock . calls [ 0 ] ! [ 0 ]); wrapper . unmount ()
2026-10-14 13:53:15 +08:00
})
2026-10-19 17:56:31 +08:00
it ( 'does not discard draft selections when recovery fails and never submits an incomplete source list' , async () => {
vi . mocked ( api . listDispatchDrafts ). mockRejectedValue ( new Error ( 'draft unavailable' ))
const wrapper = mount ( SmartDispatchDialog , options ); await flushPromises (); expect ( wrapper . text ()). toContain ( '排车候选或草稿恢复失败' ); expect ( api . convertDispatchDraft ). not . toHaveBeenCalled (); wrapper . unmount ()
2026-10-05 03:30:06 +08:00
})
2026-10-19 17:56:31 +08:00
it ( 'refuses an incomplete candidate page without pretending its filtered rows are all available cargo' , async () => {
vi . mocked ( api . listDispatchCandidates ). mockResolvedValue ({ candidates : [], complete : false , snapshotAt : '' })
const wrapper = mount ( SmartDispatchDialog , options ); await flushPromises (); const state = wrapper . vm as unknown as { restoreDraft : ( token : string ) => void }; state . restoreDraft ( 'server-token' ); await flushPromises (); expect ( wrapper . text ()). toContain ( '尚未完整返回' ); wrapper . unmount ()
2026-10-05 03:30:06 +08:00
})
2026-10-19 17:56:31 +08:00
it ( 'keeps vehicle assignments independent between containers and reuses a proven conversion after a detail read fails' , async () => {
const original = draft (); original . containers . push ({ draftContainerId : 'container-B' , sequenceNo : 2 , capacity : 15 , sourceKeys : [ 'source-B' ] }); original . sources . push ( candidate ({ sourceKey : 'source-B' , customerId : 'B' }))
vi . mocked ( api . listDispatchDrafts ). mockResolvedValue ([ original ])
vi . mocked ( api . convertDispatchDraft ). mockImplementation ( async request => ({ draftToken : request.draftToken , replayed : false , vehicles : request.groups.map (( group , index ) => ({ draftContainerId : group.draftContainerId , dispatch : command ( 'CREATE' , group . dispatch . idempotencyKey , `PC/ ${ index + 1 } ` ) })) }))
vi . mocked ( api . getDispatch ). mockRejectedValueOnce ( new Error ( 'detail unavailable' )). mockImplementation ( async no => detail ({ dispatchNo : no , recordVersion : 1 }))
const wrapper = mount ( SmartDispatchDialog , options ); await flushPromises ()
const state = wrapper . vm as unknown as { restoreDraft ( token : string ) : void ; submit () : Promise < void >; activeContainerId : string ; vehicle : { vehicleId : string | null }; form : { remark : string } }
state . restoreDraft ( 'server-token' ); await flushPromises (); state . vehicle . vehicleId = 'vehicle-A' ; state . form . remark = '车甲'
state . activeContainerId = 'container-B' ; state . vehicle . vehicleId = 'vehicle-B' ; state . form . remark = '车乙'
await state . submit (); expect ( wrapper . emitted ( 'created' )). toBeUndefined ()
await state . submit (); expect ( api . convertDispatchDraft ). toHaveBeenCalledTimes ( 1 )
const groups = vi . mocked ( api . convertDispatchDraft ). mock . calls [ 0 ] ! [ 0 ]. groups
expect ( groups . map ( group => [ group . dispatch . vehicle ? . vehicleId , group . dispatch . remark ])). toEqual ([[ 'vehicle-A' , '车甲' ], [ 'vehicle-B' , '车乙' ]])
expect ( wrapper . emitted ( 'created' )). toHaveLength ( 1 ); wrapper . unmount ()
2026-10-05 03:30:06 +08:00
})
2026-10-19 17:56:31 +08:00
it ( 'never sends an existing departure command through the empty creation transport' , async () => {
useDispatchCommandRecovery (() => identity . id ). begin ( 'PC/1' , 'DEPART' , { recordVersion : 0 , idempotencyKey : 'original-key' })
const wrapper = mount ( SmartDispatchDialog , options ); await flushPromises ()
await ( wrapper . vm as unknown as { submit () : Promise < void > }). submit ()
expect ( api . createDispatch ). not . toHaveBeenCalled (); expect ( api . convertDispatchDraft ). not . toHaveBeenCalled (); expect ( wrapper . text ()). toContain ( '从列表的原请求恢复入口' ); wrapper . unmount ()
2026-10-05 03:30:06 +08:00
})
2026-10-19 17:56:31 +08:00
it ( 'rejects a restored source whose batch customer differs from its real cargo owner' , async () => {
const original = draft (); original . sources [ 0 ] ! . appointmentBatches = [{ batchKey : 'batch-1' , batchNo : 'BATCH-1' , recordVersion : 0 , customerId : 'other-customer' , sources : [{ sourceKey : 'source-1' , destinationKey : 'CODE:ONT8' , packageCount : 2 }] }]
vi . mocked ( api . listDispatchDrafts ). mockResolvedValue ([ original ])
const wrapper = mount ( SmartDispatchDialog , options ); await flushPromises ()
const state = wrapper . vm as unknown as { restoreDraft ( token : string ) : void ; submit () : Promise < void > }; state . restoreDraft ( 'server-token' ); await flushPromises (); await state . submit ()
expect ( api . convertDispatchDraft ). not . toHaveBeenCalled (); expect ( wrapper . text ()). toContain ( '预约客户不一致' ); wrapper . unmount ()
2026-10-18 16:37:37 +08:00
})