import ElementPlus from 'element-plus' import { DOMWrapper, enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' 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' import { command, detail, loadingPreview, resources } from '../dispatchTestFixtures' import type { DispatchAction, DispatchActions, DispatchEvidenceFile, UpdateDispatch } from '../dispatchContracts' 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' } })) 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() })) 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, 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') await dialog.get('textarea[aria-label="本次操作备注"]').setValue('维护备注') 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') await dialog.get('textarea[aria-label="本次操作备注"]').setValue('保留本次输入') 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') expect((dialog.get('textarea[aria-label="本次操作备注"]').element as HTMLTextAreaElement).value).toBe('保留本次输入') 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: '证据不完整' }) 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() }) it.each(['CANCEL', 'RETURN_TO_REVIEW', 'SUBMIT_REVIEW'] as const)('preserves the %s confirmation branch and its exact reason or remark', async action => { const { dialog } = await render(action) const needsReason = action === 'CANCEL' || action === 'RETURN_TO_REVIEW' const send = action === 'CANCEL' ? api.cancelDispatch : action === 'RETURN_TO_REVIEW' ? api.returnDispatchToReview : api.submitDispatchReview 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: '顺序调整' }) }) 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) dialog.vm.$emit('confirm'); await flushPromises() expect(api.addDispatchCargo).not.toHaveBeenCalled() expect(api.removeDispatchItem).not.toHaveBeenCalled() expect(api.getDispatchItems).not.toHaveBeenCalled() expect(api.listDispatchCandidates).not.toHaveBeenCalled() }) 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) 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() 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() }) })