import ElementPlus, { ElMessage } from 'element-plus'
import { ApiResponseError } from '@/shared/api/http'
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'
import { command, detail, loadingPreview } from '../dispatchTestFixtures'
import type { CommandResult, DispatchAction, DispatchDetail } from '../dispatchContracts'
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
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: '
' } }))
vi.mock('../api/dispatches', () => ({ markDispatchException: vi.fn(), 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() }))
interface State { exception: { exceptionType: string; returnDirection: string; description: string; imageFileIds: string[] }; submit(): Promise; retryError(): Promise; measurements: Array<{ weightKg: number | null; volumeCbm: number | null; deliveryIndex: number | null; palletSpaces: number | null }>; form: { reason: string; decision: string; remark: string }; detail: DispatchDetail }
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: 'DELIVERING', 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.completeDispatchLoading).toHaveBeenCalledTimes(1); 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.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('requires a rejection reason and retains the original %s decision after an unknown result', async action => {
const success = vi.spyOn(ElMessage, 'success')
const reviewApi = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading
const { wrapper, state } = await open(action); state.form.decision = 'REJECTED'
await state.submit(); expect(reviewApi).not.toHaveBeenCalled()
state.form.reason = '预约未核实'; vi.mocked(reviewApi).mockRejectedValue(new Error('unknown result'))
await state.submit(); state.form.reason = 'later changed'; state.form.decision = 'APPROVED'
vi.mocked(api.getDispatchCommand).mockImplementation(async (_, key) => command(action, key))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 1 }))
await state.submit(); expect(reviewApi).toHaveBeenCalledTimes(1)
expect(vi.mocked(reviewApi).mock.calls[0]![1]).toMatchObject({ recordVersion: 0, decision: 'REJECTED', reason: '预约未核实' })
expect(success).toHaveBeenCalledWith('已退回客服处理(车辆信息),修改后需重新提交调度审核')
expect(wrapper.emitted('saved')).toHaveLength(1); wrapper.unmount()
})
it('shows the preserved rejection while customer service edits vehicle information', async () => {
const { wrapper, state } = await open('EDIT')
state.detail.reviews.loadingReview = { ...state.detail.reviews.loadingReview, status: 'REJECTED', reason: '车牌照片不清晰' }
await flushPromises()
expect(wrapper.getComponent({ name: 'ElDialogContent' }).text()).toContain('理货员审核退回:车牌照片不清晰')
wrapper.unmount()
})
it('blocks an action without a literal service allowance', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW', false); await state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('前置审核未通过'); wrapper.unmount()
})
it('does not reuse another dispatch detail when the target changes while its dialog remains open', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
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.submitDispatchReview).not.toHaveBeenCalled()
resolve(detail({ dispatchNo: 'PC/2', actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'NOT_READY', blockReason: '另一车尚未就绪' } } })); await flushPromises()
await state.submit(); expect(api.submitDispatchReview).not.toHaveBeenCalled(); expect(wrapper.text()).toContain('另一车尚未就绪'); 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 a route that drops an authoritative destination', async () => {
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()
})
it('refreshes current actions and notifies its parent after a fresh A0409, without presenting an unknown result or replay request', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('本轮调度审核已提交,请勿重复提交', 'A0409', 409, undefined, 'review-request', 'review-trace'))
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1, actions: { SUBMIT_REVIEW: { allowed: false, blockCode: 'ALREADY_SUBMITTED', blockReason: '审核已提交' } } }))
await state.submit(); await flushPromises()
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(api.getDispatch).toHaveBeenCalledTimes(2)
expect(api.getDispatchCommand).not.toHaveBeenCalled()
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'review-request', traceId: 'review-trace' })]])
expect(wrapper.emitted('update:modelValue')).toContainEqual([false])
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
wrapper.unmount()
})
it('retains edited values and the original validation response when the action is still allowed', async () => {
const { wrapper, state } = await open('EDIT'); state.form.remark = '用户未保存的本轮备注'
vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('预计到达时间须在发车之后', 'A0400', 400, undefined, 'validation-request'))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 7, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } } }))
await state.submit(); await flushPromises()
expect(state.form.remark).toBe('用户未保存的本轮备注')
expect(state.detail.recordVersion).toBe(7)
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
expect(wrapper.text()).toContain('A0400')
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
await state.retryError()
expect(state.form.remark).toBe('用户未保存的本轮备注')
expect(api.updateDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('预计到达时间须在发车之后')
wrapper.unmount()
})
it('reports a definite rejection to its parent even if the follow-up GET fails and blocks a new write until refreshed', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW'); state.form.remark = '保留本次备注'
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new ApiResponseError('审核已提交', 'A0409', 409, undefined, 'reject-request'))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('GET network failure'))
await state.submit(); await flushPromises()
expect(wrapper.emitted('rejected')).toEqual([[expect.objectContaining({ code: 'A0409', requestId: 'reject-request' })]])
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(state.form.remark).toBe('保留本次备注')
await state.submit()
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('keeps an unknown original command when its later replay is rejected instead of treating it as a fresh rejection', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockRejectedValueOnce(new Error('unknown transport outcome')).mockRejectedValueOnce(new ApiResponseError('当前已提交', 'A0409', 409))
await state.submit()
const original = structuredClone(vi.mocked(api.submitDispatchReview).mock.calls[0]![1])
expect(wrapper.text()).toContain('原操作结果尚待核对')
vi.mocked(api.getDispatchCommand).mockRejectedValueOnce(new ApiResponseError('未找到命令', 'A0404', 404))
await state.submit(); await flushPromises()
expect(api.submitDispatchReview).toHaveBeenNthCalledWith(2, 'PC/1', original)
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.body).toEqual(original)
expect(wrapper.emitted('rejected')).toBeUndefined()
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
expect(api.getDispatch).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('原操作结果尚待核对')
wrapper.unmount()
})
it('describes a proven success with a failed GET as completed and retries only its read', async () => {
const { wrapper, state } = await open('SUBMIT_REVIEW')
vi.mocked(api.submitDispatchReview).mockImplementationOnce(async (_, body) => command('SUBMIT_REVIEW', body.idempotencyKey))
vi.mocked(api.getDispatch).mockRejectedValueOnce(new Error('current detail unavailable'))
await state.submit(); await flushPromises()
expect(useDispatchCommandRecovery(() => identity.id).pending.value?.result).toMatchObject({ action: 'SUBMIT_REVIEW', recordVersion: 1 })
expect(wrapper.text()).toContain('操作已完成,页面资料待刷新')
expect(wrapper.text()).not.toContain('原操作结果尚待核对')
expect(wrapper.text()).not.toContain('核对并重试原请求')
vi.mocked(api.getDispatch).mockResolvedValueOnce(detail({ recordVersion: 1 }))
await state.submit()
expect(api.submitDispatchReview).toHaveBeenCalledTimes(1)
expect(api.getDispatchCommand).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toHaveLength(1)
wrapper.unmount()
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('blocks a fresh %s command and directs cargo changes to smart dispatch', async action => {
const { wrapper, state } = await open(action)
try {
expect(wrapper.text()).toContain('货物安排统一在智能排车中修改')
await state.submit()
expect(api.addDispatchCargo).not.toHaveBeenCalled()
expect(api.removeDispatchItem).not.toHaveBeenCalled()
expect(api.getDispatchItems).not.toHaveBeenCalled()
expect(api.listDispatchCandidates).not.toHaveBeenCalled()
expect(useDispatchCommandRecovery(() => identity.id).pending.value).toBeNull()
expect(wrapper.emitted('saved')).toBeUndefined()
} finally { wrapper.unmount() }
})
it.each(['ADD_CARGO', 'REMOVE_CARGO'] as const)('preserves the exact pending %s command for recovery in smart dispatch', async action => {
const recovery = useDispatchCommandRecovery(() => identity.id)
recovery.begin('PC/ORIGINAL', action, { recordVersion: '9007199254740993', idempotencyKey: 'original-key', reason: 'original reason', cargo: [{ sourceVersion: 'original-source' }] }, action === 'REMOVE_CARGO' ? { itemId: 'original-item' } : {})
recovery.pending.value!.attempted = true
const original = JSON.parse(JSON.stringify(recovery.pending.value))
const { wrapper, state } = await open('EDIT')
try {
expect(wrapper.text()).toContain('请从智能排车恢复原请求')
await state.submit(); await state.retryError()
expect(recovery.pending.value).toEqual(original)
expect(api.addDispatchCargo).not.toHaveBeenCalled()
expect(api.removeDispatchItem).not.toHaveBeenCalled()
expect(api.updateDispatch).not.toHaveBeenCalled()
expect(api.getDispatchCommand).not.toHaveBeenCalled()
expect(wrapper.emitted('saved')).toBeUndefined()
expect(wrapper.emitted('rejected')).toBeUndefined()
} finally { wrapper.unmount() }
})
it('requires an exception type and submits optional direction, description and images once', async () => {
const { wrapper, state } = await open('MARK_EXCEPTION')
await state.submit(); expect(api.markDispatchException).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请选择异常类型')
Object.assign(state.exception, { exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: ' 现场部分签收 ', imageFileIds: ['91'] })
vi.mocked(api.markDispatchException).mockImplementation(async (_, body) => ({ ...command('MARK_EXCEPTION', body.idempotencyKey), stage: 'DELIVERING' }))
vi.mocked(api.getDispatch).mockResolvedValue(detail({ stage: 'DELIVERING', recordVersion: 1 }))
await state.submit()
expect(api.markDispatchException).toHaveBeenCalledWith('PC/1', expect.objectContaining({ recordVersion: 0, exceptionType: 'PARTIAL_RECEIPT', returnDirection: 'OVERSEAS_WAREHOUSE', description: '现场部分签收', imageFileIds: ['91'] }))
expect(api.markDispatchException).toHaveBeenCalledTimes(1); expect(wrapper.emitted('saved')).toHaveLength(1)
expect(api.completeDispatchLoading).not.toHaveBeenCalled(); wrapper.unmount()
})
it('does not submit an exception when the server disallows the action', async () => {
const { wrapper, state } = await open('MARK_EXCEPTION', false)
state.exception.exceptionType = 'REFUSED'; await state.submit()
expect(api.markDispatchException).not.toHaveBeenCalled(); wrapper.unmount()
})