Files
admin-web/src/modules/overseas-warehouse/pages/DispatchesView.test.ts
T

538 lines
44 KiB
TypeScript

import { createPinia } from 'pinia'
import { defineComponent } from 'vue'
import ElementPlus, { ElMessageBox, ElSelect, ElTag } from 'element-plus'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, expect, it, vi } from 'vitest'
import DispatchesView from './DispatchesView.vue'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import RowActions from '@/shared/components/RowActions.vue'
import { getDispatch, getDispatchBolPdf, listDispatches } from '../api/dispatches'
import { cancelTransport, getTransport } from '../api/transportTasks'
import { detail, listRow, page, reviews } from '../dispatchTestFixtures'
import { formatOverseasDateTime } from '../lastMilePresentation'
import type { BeforeLoadingSection, DispatchPage, DispatchStage } from '../dispatchContracts'
const identity = vi.hoisted(() => ({ id: '' }))
const push = vi.hoisted(() => vi.fn())
const can = vi.hoisted(() => vi.fn(() => true))
vi.mock('vue-router', () => ({ useRoute: () => ({ query: {} }), useRouter: () => ({ push }) }))
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) }))
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
vi.mock('@/modules/appointment-calendar', () => ({ appointmentReturnTarget: () => null }))
vi.mock('@/modules/overseas-master-data', () => ({ TransportRouteDialog: { template: '<div />' }, routeLocation: (label: string) => ({ label }) }))
vi.mock('../api/dispatches', () => ({ listDispatches: vi.fn(), getDispatch: vi.fn(), getDispatchBolPdf: vi.fn() }))
vi.mock('../api/transportTasks', () => ({ transportLabels: { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' }, cancelTransport: vi.fn(), getTransport: vi.fn() }))
const options = { global: { plugins: [createPinia(), ElementPlus], stubs: { SmartDispatchDialog: true, DispatchWorkflowDialog: true, TransportTaskCreateDialog: true, TransportExecutionPanel: true } } }
beforeEach(() => { vi.clearAllMocks(); identity.id = crypto.randomUUID(); sessionStorage.clear(); can.mockReturnValue(true); vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); localStorage.clear(); vi.mocked(listDispatches).mockResolvedValue(page()); vi.mocked(getDispatch).mockResolvedValue(detail()) })
it('places truck completion next to close in the outer dialog footer', async () => {
const submitCompletion = vi.fn()
const panel = defineComponent({
name: 'TransportExecutionPanel',
setup(_, { expose }) {
expose({ completionVisible: true, completionText: '完成', completionLoading: false, completionDisabled: false, submitCompletion })
return () => null
},
})
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'LOCAL_TRUCK', stage: 'LOADING', reviewState: 'APPROVED', actions: {} })]))
const wrapper = mount(DispatchesView, { global: { ...options.global, stubs: { ...options.global.stubs, TransportExecutionPanel: panel } } }); await flushPromises()
wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'transport'); await flushPromises()
const footer = wrapper.get('.app-dialog__footer')
expect(footer.findAll('button').map(button => button.text())).toEqual(['关闭', '完成'])
await footer.findAll('button')[1]!.trigger('click')
expect(submitCompletion).toHaveBeenCalledOnce()
wrapper.unmount()
})
it('offers pickup detail, transport vehicle maintenance and the shared review workflow', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['EDIT', 'SUBMIT_REVIEW', 'CANCEL', 'detail'])
expect(row.findAll('button').map(button => button.text())).toEqual(['维护车辆', '提交审核', '详情', '更多'])
row.vm.$emit('action', 'EDIT'); await flushPromises()
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('dispatchNo')).toBe('PC/1')
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('editResources')).toBe(true)
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
wrapper.unmount()
})
it('confirms cancelling pickup and linked transfers using the transport version', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', recordVersion: 10, actions: { CANCEL: allowed } })]))
vi.mocked(getTransport).mockResolvedValue({ execution: { trip: { recordVersion: 4 } }, pickups: [{ transferNo: 'WT-1' }] } as Awaited<ReturnType<typeof getTransport>>)
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue('confirm' as Awaited<ReturnType<typeof ElMessageBox.confirm>>)
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
row.vm.$emit('action', 'CANCEL'); await flushPromises()
expect(confirm.mock.calls[0]?.[0]).toContain('WT-1')
expect(cancelTransport).toHaveBeenCalledWith('PC/1', { recordVersion: 4, reason: '平台取消取货排车及对应转运单' })
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
expect(listDispatches).toHaveBeenCalledTimes(2)
wrapper.unmount(); confirm.mockRestore()
})
it.each([['LOADING', '上传装货照片'], ['DELIVERING', '上传到仓照片']] as const)('opens pickup %s work from the list while detail stays separate', async (stage, label) => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage, reviewState: 'APPROVED', actions: { EDIT: allowed, CANCEL: allowed } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toEqual([expect.objectContaining({ key: 'transport', label }), expect.objectContaining({ key: 'detail', label: '详情' })])
row.vm.$emit('action', 'EDIT'); row.vm.$emit('action', 'CANCEL'); await flushPromises()
expect(wrapper.findComponent({ name: 'TransportExecutionPanel' }).exists()).toBe(false)
expect(cancelTransport).not.toHaveBeenCalled()
row.vm.$emit('action', 'transport'); await flushPromises()
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props()).toMatchObject({ dispatchNo: 'PC/1', pickupApproved: true, editResources: false })
expect(push).not.toHaveBeenCalled()
wrapper.unmount()
})
it('does not expose pickup work for a cancelled order or an operator without edit permission', async () => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage: 'DELIVERING', reviewState: 'APPROVED', status: 'CANCELLED', actions: {} })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['detail'])
row.vm.$emit('action', 'transport'); await flushPromises()
expect(wrapper.findComponent({ name: 'TransportExecutionPanel' }).exists()).toBe(false)
wrapper.unmount()
can.mockReturnValue(false)
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'PICKUP', stage: 'DELIVERING', reviewState: 'APPROVED', actions: {} })]))
const readonly = mount(DispatchesView, options); await flushPromises()
expect(readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions').map((item: { key: string }) => item.key)).toEqual(['detail'])
readonly.unmount()
})
it('never offers a second arrangement or departure after loading completion', async () => {
const gate = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ stage: 'DELIVERING', actions: { ARRANGE_DELIVERY: gate, DEPART: gate } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
expect(wrapper.findAll('[role="tab"]').some(tab => tab.text().startsWith('装车后'))).toBe(true)
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
const keys = (row.props('actions') as Array<{ key: string }>).map(action => action.key)
expect(keys).not.toContain('ARRANGE_DELIVERY'); expect(keys).not.toContain('DEPART')
row.vm.$emit('action', 'DEPART'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
wrapper.unmount()
})
it('keeps return-to-review on the loading list with its service gate and permission', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ stage: 'LOADING', actions: { RETURN_TO_REVIEW: allowed } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'RETURN_TO_REVIEW' })]))
row.vm.$emit('action', 'RETURN_TO_REVIEW'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'RETURN_TO_REVIEW', dispatchNo: 'PC/1' })
wrapper.unmount(); can.mockReturnValue(false)
const denied = mount(DispatchesView, options); await flushPromises()
const deniedRow = denied.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(deniedRow.props('actions').map((item: { key: string }) => item.key)).not.toContain('RETURN_TO_REVIEW')
deniedRow.vm.$emit('action', 'RETURN_TO_REVIEW'); await flushPromises()
expect(denied.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
denied.unmount()
})
it('does not let an old pending command hide or block filtered list operations',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC-OTHER',action:'LOADING_COMPLETE',attempted:true,body:{idempotencyKey:'old'}}))
const wrapper=mount(DispatchesView,options);await flushPromises()
expect(wrapper.text()).not.toContain('结果尚待核对')
expect(wrapper.findAll('button').some(button=>button.text()==='核对原请求')).toBe(false)
expect(wrapper.getComponent({name:'DispatchWorkflowDialog'}).props('modelValue')).toBe(false)
expect(wrapper.text()).toContain('PC/1');wrapper.unmount()
})
it('centralizes cargo changes in smart dispatch and never exposes row add/remove actions', async () => {
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { ADD_CARGO: { allowed: true, blockCode: null, blockReason: null }, REMOVE_CARGO: { allowed: true, blockCode: null, blockReason: null } } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
for (const action of ['ADD_CARGO', 'REMOVE_CARGO']) {
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).not.toContain(action)
row.vm.$emit('action', action); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
}
await wrapper.findAll('button').find(button => button.text() === '智能排车')!.trigger('click')
expect(wrapper.getComponent({ name: 'SmartDispatchDialog' }).props('modelValue')).toBe(true)
wrapper.unmount()
})
it('keeps vehicle maintenance but hides standalone review submission and route ordering', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
const keys = (row.props('actions') as Array<{ key: string }>).map(item => item.key)
expect(keys).toContain('EDIT'); expect(keys).toContain('detail')
expect(keys).not.toContain('SUBMIT_REVIEW'); expect(keys).not.toContain('route-order')
row.vm.$emit('action', 'SUBMIT_REVIEW'); row.vm.$emit('action', 'route-order'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
row.vm.$emit('action', 'EDIT'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'EDIT', dispatchNo: 'PC/1' })
wrapper.unmount()
})
it.each([['DISPATCH_REVIEW', 1, '调度审核'], ['LOADING_REVIEW', 2, '理货员审核']] as const)('prioritizes the current %s queue action with a short label', async (action, index, label) => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, CANCEL: allowed, SUBMIT_REVIEW: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click'); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.findAll('button').map(button => button.text())).toEqual(['审核', '详情'])
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual([action, 'detail'])
for (const key of ['EDIT', 'CANCEL', 'SUBMIT_REVIEW', 'route-order', 'bol']) {
row.vm.$emit('action', key); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
}
expect(getDispatchBolPdf).not.toHaveBeenCalled()
await row.get(`button[aria-label="${label}"]`).trigger('click'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action, dispatchNo: 'PC/1' })
wrapper.unmount()
})
it.each(['ADD_CARGO','REMOVE_CARGO'] as const)('ignores old %s requests while allowing smart dispatch to open normally',async action=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC-OTHER',action,attempted:true,body:{idempotencyKey:'old'}}))
const wrapper=mount(DispatchesView,options);await flushPromises()
await wrapper.findAll('button').find(button=>button.text()==='智能排车')!.trigger('click')
expect(wrapper.getComponent({name:'SmartDispatchDialog'}).props('modelValue')).toBe(true)
expect(wrapper.getComponent({name:'DispatchWorkflowDialog'}).props('modelValue')).toBe(false)
expect(wrapper.text()).not.toContain('结果尚待核对');wrapper.unmount()
})
it.each([1, 2])('keeps only details in audit tab %s without its review permission', async index => {
can.mockReturnValue(false)
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { EDIT: allowed, DISPATCH_REVIEW: allowed, LOADING_REVIEW: allowed } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click'); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual(['detail'])
wrapper.unmount()
})
it('shows five working phases and uses server stage counts instead of current page length', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const tabs = wrapper.get('.data-table__toolbar-start .workbench-list-tabs[aria-label="排车执行阶段"]').findAll('button')
expect(wrapper.find('.data-table__filters [aria-label="装车前审核分区"]').exists()).toBe(true)
expect(tabs.map(tab => tab.text())).toEqual(['装车前 3', '装车中 1', '装车后 1', '派送中 1', '派送完成 1'])
expect(listDispatches).toHaveBeenCalledWith(expect.objectContaining({ stage: 'BEFORE_LOADING', status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
await tabs[3]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'DELIVERING' })); expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(false); wrapper.unmount()
})
it.each([0, 1, 2])('preserves section %s and its query after clearing the active dispatch filter', async index => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const sectionValues = ['VEHICLE_INFO', 'DISPATCH_REVIEW', 'LOADING_REVIEW']
const sectionSelector = '[aria-label="装车前审核分区"]'
await wrapper.get(sectionSelector).findAll('button')[index]!.trigger('click'); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
toolbar.findAllComponents(ElSelect).find(select => select.props('placeholder') === '单据范围')!.vm.$emit('update:modelValue', '')
await flushPromises()
expect(wrapper.get(sectionSelector).findAll('button').map(button => button.text())).toEqual(['车辆信息', '调度审核', '理货员审核'])
expect(wrapper.get(sectionSelector).findAll('button')[index]!.attributes('aria-selected')).toBe('true')
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[index] }))
const nextIndex = (index + 1) % 3
await wrapper.get(sectionSelector).findAll('button')[nextIndex]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] }))
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
expect(wrapper.get(sectionSelector).findAll('button')).toHaveLength(3)
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', beforeLoadingSection: sectionValues[nextIndex] }))
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click'); await flushPromises()
expect(wrapper.find(sectionSelector).exists()).toBe(false)
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
wrapper.unmount()
})
it.each([
['WAIT_DEPARTURE', 'BEFORE_LOADING', '装车前'],
['DELIVERING', 'DELIVERING', '派送中'],
['DELIVERED', 'DELIVERED', '派送完成'],
] as const)('queries %s through server pagination and selects the corresponding stage', async (vehicleStatus, stage, label) => {
const wrapper = mount(DispatchesView, options); await flushPromises()
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[3]!.trigger('click'); await flushPromises()
const table = wrapper.getComponent(DataTable)
table.vm.$emit('update:page', 3); await flushPromises()
const result = { ...page([listRow({ stage })]), total: 21, pages: 3 }
vi.mocked(listDispatches).mockResolvedValueOnce(result)
const toolbar = wrapper.getComponent(QueryToolbar)
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '车辆状态')!
select.vm.$emit('update:modelValue', vehicleStatus); select.vm.$emit('change', vehicleStatus); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, size: 10, stage, status: 'ACTIVE' }))
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('vehicleStatus')
expect(wrapper.get('[aria-label="排车执行阶段"] [aria-selected="true"]').text()).toContain(label)
expect(table.props('total')).toBe(21)
expect(table.props('data')).toEqual(result.records)
expect(wrapper.find('[aria-label="装车前审核分区"]').exists()).toBe(stage === 'BEFORE_LOADING')
wrapper.unmount()
})
it('keeps waiting vehicles in the current loading phase and synchronizes an active status when changing phases', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const tabs = wrapper.get('[aria-label="排车执行阶段"]').findAll('button')
await tabs[1]!.trigger('click'); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
toolbar.vm.$emit('search', { vehicleStatus: 'WAIT_DEPARTURE', status: 'ACTIVE' }); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage: 'LOADING' }))
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
await tabs[3]!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue').vehicleStatus).toBe('DELIVERING')
await tabs[0]!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue').vehicleStatus).toBe('WAIT_DEPARTURE')
wrapper.unmount()
})
it('shows trip progress independently of master vehicle status and leaves cancelled vehicles without progress', async () => {
const records = [
listRow({ dispatchNo: 'PC-WAIT', stage: 'BEFORE_LOADING' }),
listRow({ dispatchNo: 'PC-LOADING', stage: 'LOADING' }),
listRow({ dispatchNo: 'PC-DELIVERING', stage: 'DELIVERING' }),
listRow({ dispatchNo: 'PC-DELIVERED', stage: 'DELIVERED' }),
listRow({ dispatchNo: 'PC-CANCELLED', status: 'CANCELLED' }),
]
for (const row of records) row.resources.vehicle!.vehicleStatus = 'MAINTENANCE'
vi.mocked(listDispatches).mockResolvedValueOnce(page(records))
const wrapper = mount(DispatchesView, options); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
expect(toolbar.props('fields').map(field => field.key)).toEqual(['dispatchNo', 'vehiclePlate', 'vehicleStatus'])
expect(toolbar.props('fields').find(field => field.key === 'vehicleStatus')?.options?.map(option => option.label)).toEqual(['待发车', '配送中', '已送达'])
expect(wrapper.getComponent(DataTable).props('columns').some(column => column.key === 'vehicleStatus' && column.label === '车辆状态')).toBe(true)
expect(wrapper.findAllComponents(ElTag).map(tag => tag.text())).toEqual(['待发车', '待发车', '配送中', '已送达'])
expect(wrapper.text()).toContain('已取消')
expect(wrapper.text()).not.toContain('维修中')
wrapper.unmount()
})
it.each(['CANCELLED', 'ALL'])('retains advanced %s lookup and resets its hidden value', async status => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
toolbar.vm.$emit('search', { status: 'ACTIVE', vehicleStatus: 'WAIT_DEPARTURE' }); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '高级筛选')!.trigger('click')
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '单据范围')!
select.vm.$emit('update:modelValue', status); select.vm.$emit('change', status); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue').vehicleStatus).toBe('')
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status }))
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('beforeLoadingSection')
await toolbar.findAll('button').find(button => button.text().startsWith('收起筛选'))!.trigger('click')
expect(toolbar.props('fields').some(field => field.key === 'status')).toBe(false)
expect(toolbar.text()).toContain(status === 'CANCELLED' ? '高级筛选 · 已取消' : '高级筛选 · 全部单据')
await toolbar.findAll('button').find(button => button.text() === '重置')!.trigger('click'); await flushPromises()
expect(toolbar.props('modelValue')).toMatchObject({ status: 'ACTIVE', vehicleStatus: '' })
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ page: 1, status: 'ACTIVE', beforeLoadingSection: 'VEHICLE_INFO' }))
wrapper.unmount()
})
it('returns to active dispatches when selecting vehicle progress from cancelled lookup', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const toolbar = wrapper.getComponent(QueryToolbar)
toolbar.vm.$emit('search', { status: 'CANCELLED', vehicleStatus: '' }); await flushPromises()
const select = toolbar.findAllComponents(ElSelect).find(item => item.props('placeholder') === '车辆状态')!
select.vm.$emit('update:modelValue', 'DELIVERING'); select.vm.$emit('change', 'DELIVERING'); await flushPromises()
await toolbar.findAll('button').find(button => button.text() === '查询')!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ status: 'ACTIVE', stage: 'DELIVERING' }))
expect(toolbar.props('modelValue')).toMatchObject({ status: 'ACTIVE', vehicleStatus: 'DELIVERING' })
wrapper.unmount()
})
it('queries the current review queue without offering historical result filters', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[1]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ beforeLoadingSection: 'DISPATCH_REVIEW' }))
expect(wrapper.getComponent(QueryToolbar).props('fields').some((field: { key: string }) => field.key === 'reviewStatus')).toBe(false)
wrapper.getComponent(QueryToolbar).vm.$emit('search', { status: 'ACTIVE', reviewStatus: 'REJECTED', dispatchNo: '', vehiclePlate: '' }); await flushPromises()
expect(vi.mocked(listDispatches).mock.calls.at(-1)?.[0]).not.toHaveProperty('reviewStatus'); wrapper.unmount()
})
it.each(['dispatchReview', 'loadingReview'] as const)('shows %s rejection in the initial customer-service section', async reviewKey => {
const row = listRow()
row.reviews[reviewKey] = { ...row.reviews[reviewKey], status: 'REJECTED', reason: '车辆证照需补齐' }
vi.mocked(listDispatches).mockResolvedValue(page([row]))
const wrapper = mount(DispatchesView, options); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ beforeLoadingSection: 'VEHICLE_INFO' }))
expect(wrapper.text()).toContain(`${reviewKey === 'dispatchReview' ? '调度审核' : '理货员审核'}退回:车辆证照需补齐`)
wrapper.unmount()
})
it('discards a late page response after switching phase', async () => {
let old!: (value: DispatchPage) => void
vi.mocked(listDispatches).mockImplementationOnce(() => new Promise(resolve => { old = resolve }))
const wrapper = mount(DispatchesView, options)
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[1]!.trigger('click'); await flushPromises()
old(page([])); await flushPromises(); expect(wrapper.text()).toContain('PC/1'); wrapper.unmount()
})
it('gates every mutation with both its literal service capability and independent permission', async () => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ actions: { DEPART: { allowed: false, blockReason: '未安排', blockCode: 'NO' }, EDIT: { allowed: true, blockCode: null, blockReason: null } } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>
expect(actions.map(row => row.key)).toContain('EDIT'); expect(actions.map(row => row.key)).not.toContain('DEPART')
can.mockReturnValue(false); wrapper.unmount()
const readonly = mount(DispatchesView, options); await flushPromises(); expect((readonly.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.props('actions') as Array<{ key: string }>).map(row => row.key)).toEqual(['detail']); readonly.unmount()
})
it('keeps server errors visible with the integrated query and retry context', async () => {
vi.mocked(listDispatches).mockRejectedValueOnce(new Error('service unavailable'))
const wrapper = mount(DispatchesView, options); await flushPromises(); expect(wrapper.text()).toContain('排车列表加载失败,请重试'); expect(wrapper.findComponent(QueryToolbar).exists()).toBe(true); wrapper.unmount()
})
it('navigates by the business number without arranging a separate trip', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'detail'); await flushPromises()
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } }); expect(wrapper.text()).not.toContain('安排车次'); wrapper.unmount()
})
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('opens %s through the existing dispatch detail and omits warehouse commands', async taskType => {
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toEqual([...(taskType === 'LOCAL_TRUCK' ? [expect.objectContaining({ key: 'transport', label: '维护车辆' })] : []), expect.objectContaining({ key: 'detail', label: '详情' })])
row.vm.$emit('action', 'detail'); await flushPromises()
expect(push).toHaveBeenCalledWith({ name: 'overseas-dispatch-detail', params: { dispatchNo: 'PC/1' } })
expect(wrapper.text()).toContain('安排到仓取货'); expect(wrapper.text()).toContain('安排本土卡车'); wrapper.unmount()
})
it('opens local truck delivery stage directly on the delivery photo node', async () => {
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType: 'LOCAL_TRUCK', stage: 'DELIVERING', reviewState: 'APPROVED' })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toEqual([expect.objectContaining({ key: 'transport', label: '上传送达照片' }), expect.objectContaining({ key: 'detail', label: '详情' })])
row.vm.$emit('action', 'transport'); await flushPromises()
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props()).toMatchObject({ dispatchNo: 'PC/1', pickupStage: 'DELIVERING', editResources: false })
wrapper.unmount()
})
it('keeps pickup workflow actions but hides route ordering and BOL even when editing is allowed', async () => {
const allowed = { allowed: true, blockCode: null, blockReason: null }
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ taskType: 'PICKUP', actions: { EDIT: allowed, SUBMIT_REVIEW: allowed, CANCEL: allowed } })]))
const wrapper = mount(DispatchesView, { global: { ...options.global, stubs: { ...options.global.stubs, TransportExecutionPanel: true, AppDialog: { template: '<div><slot /></div>' } } } }); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((row.props('actions') as Array<{ key: string }>).map(item => item.key)).toEqual(['EDIT', 'SUBMIT_REVIEW', 'CANCEL', 'detail'])
row.vm.$emit('action', 'EDIT'); await flushPromises()
expect(wrapper.getComponent({ name: 'TransportExecutionPanel' }).props('dispatchNo')).toBe('PC/1')
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props('modelValue')).toBe(false)
row.vm.$emit('action', 'SUBMIT_REVIEW'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'SUBMIT_REVIEW', dispatchNo: 'PC/1' })
wrapper.unmount()
})
it('reads one BOL on demand and preserves popup failure handling', async () => {
const current = reviews()
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ reviews: { ...current, dispatchReview: { ...current.dispatchReview, status: 'APPROVED' } } })]))
vi.stubGlobal('open', vi.fn().mockReturnValue(null)); const wrapper = mount(DispatchesView, options); await flushPromises(); wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!.vm.$emit('action', 'bol'); await flushPromises(); expect(getDispatchBolPdf).not.toHaveBeenCalled(); wrapper.unmount()
})
it.each(['NOT_SUBMITTED', 'PENDING', 'REJECTED', 'APPROVED'] as const)('offers BOL only after current dispatch review is approved: %s', async status => {
const current = reviews()
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ reviews: { ...current, dispatchReview: { ...current.dispatchReview, status } } })]))
const popup = { close: vi.fn(), location: { href: '' } }
vi.stubGlobal('open', vi.fn().mockReturnValue(popup))
vi.mocked(getDispatchBolPdf).mockRejectedValue(new Error('print unavailable'))
const wrapper = mount(DispatchesView, options); await flushPromises()
const actions = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((actions.props('actions') as Array<{ key: string }>).some(item => item.key === 'bol')).toBe(status === 'APPROVED')
expect(actions.findAll('button').some(button => button.text() === '下载BOL')).toBe(status === 'APPROVED')
actions.vm.$emit('action', 'bol'); await flushPromises()
expect(getDispatchBolPdf).toHaveBeenCalledTimes(status === 'APPROVED' ? 1 : 0)
expect(window.open).toHaveBeenCalledTimes(status === 'APPROVED' ? 1 : 0)
if (status === 'APPROVED') expect(popup.close).toHaveBeenCalledOnce()
wrapper.unmount()
})
it.each(['saved', 'rejected'] as const)('refreshes list actions after workflow %s without retaining a stale submit button', async event => {
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toContainEqual(expect.objectContaining({ key: 'EDIT' }))
row.vm.$emit('action', 'EDIT'); await flushPromises()
vi.mocked(listDispatches).mockResolvedValueOnce(page([listRow({ recordVersion: 1, actions: { EDIT: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } })]))
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
if (event === 'rejected') dialog.vm.$emit('rejected', { message: '本轮审核已经提交', code: 'A0409', requestId: 'list-reject-request' })
else dialog.vm.$emit('saved')
dialog.vm.$emit('update:modelValue', false); await flushPromises()
expect(listDispatches).toHaveBeenCalledTimes(2)
expect(row.props('actions')).not.toContainEqual(expect.objectContaining({ key: 'EDIT' }))
if (event === 'rejected') {
expect(wrapper.text()).toContain('本轮审核已经提交')
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('list-reject-request')
}
expect(wrapper.text()).not.toContain('结果尚待核对')
wrapper.unmount()
})
it('does not restore stored failed commands or expose replay controls in the list',async()=>{
sessionStorage.setItem('dispatch-command:'+identity.id,JSON.stringify({dispatchNo:'PC/1',action:'SUBMIT_REVIEW',attempted:true,result:{recordVersion:1},body:{idempotencyKey:'old'}}))
const wrapper=mount(DispatchesView,options);await flushPromises()
expect(wrapper.text()).not.toContain('结果尚待核对')
expect(wrapper.text()).not.toContain('刷新页面资料')
expect(wrapper.getComponent({name:'DispatchWorkflowDialog'}).props('modelValue')).toBe(false)
wrapper.unmount()
})
it('clears an earlier input rejection after the corrected workflow succeeds', async () => {
const wrapper = mount(DispatchesView, options); await flushPromises()
const dialog = wrapper.getComponent({ name: 'DispatchWorkflowDialog' })
dialog.vm.$emit('rejected', { message: '请补齐本趟资料', code: 'A0400' }); await flushPromises()
expect(wrapper.text()).toContain('请补齐本趟资料')
dialog.vm.$emit('saved'); await flushPromises()
expect(wrapper.text()).not.toContain('请补齐本趟资料')
wrapper.unmount()
})
it.each([
['DELIVERING', 3, true],
['DELIVERED', 4, false],
] as const)('only opens list document management before delivery completes in %s', async (stage, tabIndex, available) => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType: 'DELIVERY', stage, actions: {} })]))
const wrapper = mount(DispatchesView, { global: { ...options.global, stubs: { ...options.global.stubs, DispatchDocumentsDialog: true } } }); await flushPromises()
await wrapper.get('[aria-label="排车执行阶段"]').findAll('button')[tabIndex]!.trigger('click'); await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({ stage }))
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((row.props('actions') as Array<{ key: string }>).some(item => item.key === 'documents')).toBe(available)
expect(row.findAll('button').some(button => button.text() === '管理资料')).toBe(available)
row.vm.$emit('action', 'documents'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchDocumentsDialog' }).props()).toMatchObject({ modelValue: available, dispatchNo: available ? 'PC/1' : '' })
wrapper.unmount()
})
it('opens the exception form from the dispatching row and hides it without permission', async () => {
const gate = { allowed: true, blockCode: null, blockReason: null }
const current = reviews()
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ stage: 'DELIVERING', reviews: { ...current, dispatchReview: { ...current.dispatchReview, status: 'APPROVED' } }, actions: { MARK_EXCEPTION: gate } })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions')).toEqual(expect.arrayContaining([expect.objectContaining({ key: 'MARK_EXCEPTION', label: '标记异常', tone: 'danger' })]))
expect(row.findAll('button').map(button => button.text())).toEqual(['管理资料', '标记异常', '下载BOL', '更多'])
row.vm.$emit('action', 'MARK_EXCEPTION'); await flushPromises()
expect(wrapper.getComponent({ name: 'DispatchWorkflowDialog' }).props()).toMatchObject({ modelValue: true, action: 'MARK_EXCEPTION', dispatchNo: 'PC/1' })
wrapper.unmount(); can.mockReturnValue(false)
const denied = mount(DispatchesView, options); await flushPromises()
const deniedRow = denied.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect((deniedRow.props('actions') as Array<{ key: string }>).map(item => item.key)).not.toContain('MARK_EXCEPTION')
denied.unmount()
})
it.each([['DISPATCH_REVIEW',1,'DISPATCH_REJECTED'],['LOADING_REVIEW',2,'LOADING_REJECTED']] as const)('returns a rejected %s to vehicle information',async(action,index,reviewState)=>{
const allowed={allowed:true,blockCode:null,blockReason:null}
vi.mocked(listDispatches).mockResolvedValue(page([listRow({actions:{[action]:allowed}})]))
const wrapper=mount(DispatchesView,options);await flushPromises()
await wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[index]!.trigger('click');await flushPromises()
const row=wrapper.findAllComponents(RowActions).find(entry=>entry.props('moreLabel')==='更多操作 · PC/1')!
row.vm.$emit('action',action);await flushPromises()
vi.mocked(getDispatch).mockResolvedValueOnce(detail({reviewState,stage:'BEFORE_LOADING'}))
wrapper.getComponent({name:'DispatchWorkflowDialog'}).vm.$emit('saved');await flushPromises()
expect(listDispatches).toHaveBeenLastCalledWith(expect.objectContaining({page:1,stage:'BEFORE_LOADING',beforeLoadingSection:'VEHICLE_INFO'}))
expect(wrapper.get('[aria-label="装车前审核分区"]').findAll('button')[0]!.attributes('aria-selected')).toBe('true')
wrapper.unmount()
})
it('splits driver and phone without fallback and shows authoritative appointment and shipment fields', async () => {
const row = listRow(); row.resources.driver!.name = null; row.resources.driver!.phone = '555-REAL'
row.fbaAppointments = [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', batchNo: 'BATCH-1', appointmentNo: 'CONF-1', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers: ['SHIP-1', 'SHIP-2'] }]
vi.mocked(listDispatches).mockResolvedValue(page([row]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const columns = wrapper.getComponent(DataTable).props('columns')
expect(columns.map(column => column.label)).toContain('司机'); expect(columns.map(column => column.label)).toContain('联系电话')
expect(columns[0]!.key).toBe('actions')
expect(wrapper.text()).toContain('555-REAL'); expect(wrapper.text()).toContain('CONF-1'); expect(wrapper.text()).toContain('SHIP-1、SHIP-2')
wrapper.unmount()
})
it.each([
['BEFORE_LOADING', 'VEHICLE_INFO'], ['BEFORE_LOADING', 'DISPATCH_REVIEW'], ['BEFORE_LOADING', 'LOADING_REVIEW'],
['LOADING', 'VEHICLE_INFO'], ['AFTER_LOADING', 'VEHICLE_INFO'], ['DELIVERING', 'VEHICLE_INFO'], ['DELIVERED', 'VEHICLE_INFO'],
] as const)('keeps actual creation time visible in %s / %s for every transport type', async (stage, section) => {
const createdAt = '2026-10-22T01:23:45Z'
const rows = (['DELIVERY', 'PICKUP', 'LOCAL_TRUCK'] as const).map(taskType => listRow({ dispatchNo: 'PC-' + taskType, taskType, stage, createdAt, plannedDepartureAt: '2026-11-01T11:00:00Z' }))
vi.mocked(listDispatches).mockResolvedValue(page(rows))
const wrapper = mount(DispatchesView, options); await flushPromises()
const state = wrapper.vm as unknown as { stage: DispatchStage; section: BeforeLoadingSection }
state.stage = stage; state.section = section; await flushPromises()
const table = wrapper.getComponent(DataTable), columns = table.props('columns')
expect(columns[0]!.key).toBe('actions')
expect(columns.find(column => column.key === 'createdAt')).toMatchObject({ label: '创建时间', slotName: 'createdAt' })
const cells = wrapper.findAll('tbody td').filter(cell => cell.text() === formatOverseasDateTime(createdAt))
expect(cells).toHaveLength(3)
expect(getDispatch).not.toHaveBeenCalled()
wrapper.unmount()
})
it.each(['PICKUP', 'LOCAL_TRUCK'] as const)('shows only details after %s delivery completes', async taskType => {
vi.mocked(listDispatches).mockResolvedValue(page([listRow({ taskType, stage: 'DELIVERED', reviewState: 'APPROVED', actions: {} })]))
const wrapper = mount(DispatchesView, options); await flushPromises()
const row = wrapper.findAllComponents(RowActions).find(entry => entry.props('moreLabel') === '更多操作 · PC/1')!
expect(row.props('actions').map((item: { key: string }) => item.key)).toEqual(['detail'])
row.vm.$emit('action', 'transport'); await flushPromises()
expect(wrapper.findComponent({ name: 'TransportExecutionPanel' }).exists()).toBe(false)
wrapper.unmount()
})