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 { downloadDriverLicense } from '@/modules/overseas-master-data' import { blankVehicleMaintenance } from '../dispatchVehicleMaintenance' 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, DispatchDetail, 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(), downloadDriverLicense: 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, overrides: Partial = {}) { const initial = detail({ actions, ...overrides }); 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('previews the selected master license without treating it as a trip upload and clears it on driver change', async () => { vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' })) const { dialog } = await render('EDIT') const fields = dialog.findComponent(DispatchResourceFields) const selected = { ...resources().driver, mode: 'MASTER' as const, driverId: '22', evidence: { ...resources().driver.evidence!, fileIds: [], photoFileIds: [], masterLicenseFileId: '33' } } fields.vm.$emit('update:driver', selected); await flushPromises() const license = () => dialog.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === 'DRIVER')! expect(license().find('img').attributes('alt')).toBe('驾驶证照片:驾驶证照片') expect(downloadDriverLicense).toHaveBeenCalledExactlyOnceWith('22', '33') expect(license().props('modelValue')).toEqual([]) expect(license().text()).not.toContain('移除引用') expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33') fields.vm.$emit('update:driver', { ...selected, driverId: '23', evidence: { ...selected.evidence, masterLicenseFileId: null } }); await flushPromises() expect(license().find('img').exists()).toBe(false) expect(license().text()).toContain('暂无照片') }) it('reads an already frozen master license through the dispatch permission without a new driver directory request', async () => { const saved = resources() saved.driver = { ...saved.driver, mode: 'MASTER', driverId: '22', evidence: { ...saved.driver.evidence!, fileIds: ['33'], masterLicenseFileId: '33' } } const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: saved }) const license = dialog.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === 'DRIVER')! expect(license.findAll('img')).toHaveLength(1) expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER', '33') expect(downloadDriverLicense).not.toHaveBeenCalled() }) it('brings ten orders at one destination into one appointment input with confirmed time and all shipments', async () => { const initial = detail(), shipmentNumbers = Array.from({ length: 10 }, (_, index) => `SHIP-${index}`) const appointment = { batchKey: null, batchNo: null, customerId: null, appointmentNo: 'ISA-ONE', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null, shipmentNumbers } const { dialog } = await render('EDIT', undefined, false, { orderDetails: shipmentNumbers.map((_, index) => ({ ...initial.orderDetails[0]!, orderNo: `LM-${index}`, customerId: `C-${index}` })), dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment] } }) const table = dialog.get('[aria-label="目的仓预约"]') expect(table.findAll('tbody tr')).toHaveLength(1) expect(table.findAll('input')).toHaveLength(1) expect((table.get('input').element as HTMLInputElement).value).toBe('ISA-ONE') expect(table.findAll('tbody td')[2]!.text()).not.toBe('—') for (const shipment of shipmentNumbers) expect(table.text()).toContain(shipment) await table.get('input').setValue('ISA-CHANGED') expect(table.findAll('tbody td')[2]!.text()).toBe('—') dialog.vm.$emit('confirm'); await flushPromises() expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: expect.objectContaining({ destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'ISA-CHANGED' }] }) })) }) it('restores saved maintenance facts and shows associated orders without editable departure facts', async () => { const saved = { ...blankVehicleMaintenance(), plannedDeliveryDate: '2026-11-02', fleetEmail: 'fleet@example.test', destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'CONF-REAL' }] } const { dialog } = await render('EDIT', undefined, false, { vehicleInfo: { ...resources(), maintenance: saved }, dispatchInfo: { ...detail().dispatchInfo, fbaAppointments: [{ batchKey: 'FBA_APPOINTMENT:1', customerId: 'A', shipmentNumbers: ['SHIP-REAL'], batchNo: 'AP-1', appointmentNo: 'CONF-REAL', destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'SUCCESS', confirmedAt: '2026-10-25T17:00:00Z', blockReason: null }] } }) expect((field(dialog, '车队邮箱').get('input').element as HTMLInputElement).value).toBe('fleet@example.test') expect((field(dialog, 'ONT8 预约号').get('input').element as HTMLInputElement).value).toBe('CONF-REAL') expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(1) expect(dialog.text()).not.toContain('关联正式预约') expect(dialog.text()).toContain('关联订单 (1)') const orders = dialog.findAll('[role="tab"]').find(tab => tab.text().includes('关联订单'))! await orders.trigger('click') expect(dialog.get('.maintenance-orders').text()).toContain('LM-1') expect(dialog.get('.maintenance-orders').findAll('button, input')).toHaveLength(0) dialog.vm.$emit('confirm'); await flushPromises() expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: saved })) expect(vi.mocked(api.updateDispatch).mock.calls[0]![1]).not.toHaveProperty('departedAt') }) it('edits one number per destination and can save an incomplete second destination', async () => { const destinations = ['ONT8', 'LGB8'].map(code => ({ batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: `FBA:${code}`, destinationWarehouseCode: code, status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] })) const initial = detail() const { dialog } = await render('EDIT', undefined, false, { orderDetails: [...initial.orderDetails, { ...initial.orderDetails[0]!, customerId: 'OTHER', orderNo: 'LM-OTHER' }], dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: destinations } }) expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(2) await field(dialog, 'ONT8 预约号').get('input').setValue(' ISA-ONT8 ') dialog.vm.$emit('confirm'); await flushPromises() expect(api.updateDispatch).toHaveBeenCalledWith('PC/1', expect.objectContaining({ maintenance: expect.objectContaining({ destinationAppointments: [{ destinationKey: 'FBA:ONT8', appointmentNo: 'ISA-ONT8' }, { destinationKey: 'FBA:LGB8', appointmentNo: null }] }) })) }) it('keeps input editable after rejection and adds concurrent destinations when current facts are refreshed', async () => { const appointment = { batchKey: null, batchNo: null, customerId: null, appointmentNo: null, destinationKey: 'FBA:ONT8', destinationWarehouseCode: 'ONT8', status: 'PENDING', confirmedAt: null, blockReason: null, shipmentNumbers: [] } const initial = detail() const { dialog } = await render('EDIT', undefined, false, { dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment] } }) await field(dialog, 'ONT8 预约号').get('input').setValue('ISA-KEEP') vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('排车版本已变化', 'A0409', 200)) vi.mocked(api.getDispatch).mockResolvedValue(detail({ recordVersion: 2, actions, dispatchInfo: { ...initial.dispatchInfo, fbaAppointments: [appointment, { ...appointment, destinationKey: 'FBA:LGB8', destinationWarehouseCode: 'LGB8' }] } })) dialog.vm.$emit('confirm'); await flushPromises() expect(field(dialog, 'ONT8 预约号').get('input').attributes('disabled')).toBeUndefined() dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises() expect((field(dialog, 'ONT8 预约号').get('input').element as HTMLInputElement).value).toBe('ISA-KEEP') expect(field(dialog, 'LGB8 预约号')).toBeDefined() expect(dialog.findAll('[aria-label="目的仓预约"] tbody tr')).toHaveLength(2) }) it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('blocks %s approval for missing appointment but keeps rejection available', async action => { vi.mocked(api.getDispatch).mockResolvedValue(detail({ actions: { ...actions, [action]: { allowed: true, blockCode: 'APPROVAL_BLOCKED', blockReason: '预约收货时间尚未确认' } } })) const wrapper = mount(DispatchWorkflowDialog, { attachTo: document.body, props: { modelValue: true, dispatchNo: 'PC/1', action }, global: { plugins: [ElementPlus] } }); await flushPromises() const dialog = wrapper.getComponent(AppDialog) expect(dialog.text()).toContain('预约收货时间尚未确认') expect(dialog.text()).toContain('北京时间') expect(dialog.findAll('button').find(button => button.text() === '审核通过')!.attributes('disabled')).toBeDefined() await dialog.get('textarea').setValue('请客服补齐预约时间') await dialog.findAll('button').find(button => button.text() === '驳回')!.trigger('click'); await flushPromises() expect(action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading).toHaveBeenCalledWith('PC/1', expect.objectContaining({ decision: 'REJECTED', reason: '请客服补齐预约时间' })) }) 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') field(dialog, '预派送日期').getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-11-02') for (const [label, value] of [['派送方式', '外配'], ['提货方式', '一提多卸'], ['装车类型', '混装'], ['车辆类型', '厢式货车']]) field(dialog, label!).getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', value) await field(dialog, '车队邮箱').get('input').setValue(' fleet@example.test ') await field(dialog, '预约账号').get('input').setValue(' fleet-account ') expect(field(dialog, '发车时间').get('input').attributes('readonly')).toBeDefined() expect(dialog.find('textarea[aria-label="本次操作备注"]').exists()).toBe(false) 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: '新司机' }, maintenance: { plannedDeliveryDate: '2026-11-02', deliveryMethod: '外配', pickupMethod: '一提多卸', loadType: '混装', fleetEmail: 'fleet@example.test', vehicleType: '厢式货车', appointmentAccount: 'fleet-account', destinationAppointments: [] }, plannedDepartureAt: '2026-11-01T10:00:00Z', estimatedArrivalAt: null, remark: '真实备注' }) }) it('saves all three resource photos before submitting review with the refreshed version', async () => { const { dialog } = await render('EDIT') const fields = dialog.findAllComponents(DispatchEvidenceFiles) fields.forEach((field, index) => field.vm.$emit('update:modelValue', [String(401 + index)])) await flushPromises() await dialog.findAll('button').find(button => button.text() === '提交审核')!.trigger('click') await flushPromises() expect(api.updateDispatch).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 0, vehicle: expect.objectContaining({ evidenceFileIds: ['401'] }), driver: expect.objectContaining({ evidence: expect.objectContaining({ photoFileIds: ['402'], fileIds: ['403'] }) }) })) expect(api.submitDispatchReview).toHaveBeenCalledExactlyOnceWith('PC/1', expect.objectContaining({ recordVersion: 1, remark: null })) expect(vi.mocked(api.updateDispatch).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(api.submitDispatchReview).mock.invocationCallOrder[0]!) }) it('keeps failed input editable and permits a new request and another target',async()=>{ vi.mocked(api.updateDispatch).mockRejectedValueOnce(new ApiResponseError('结果未确认','S0503',200,null,'workflow-request')).mockImplementationOnce(async(_,body)=>command('EDIT',body.idempotencyKey)) const {wrapper,dialog}=await render('EDIT') await field(dialog,'车牌号').get('input').setValue('CA-PRESERVED') dialog.vm.$emit('confirm');await flushPromises() const original=structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1]) expect(dialog.text()).toContain('workflow-request') expect(field(dialog,'车牌号').get('input').attributes('disabled')).toBeUndefined() expect((field(dialog,'车牌号').get('input').element as HTMLInputElement).value).toBe('CA-PRESERVED') await field(dialog,'车牌号').get('input').setValue('CA-CHANGED') dialog.vm.$emit('confirm');await flushPromises() const next=vi.mocked(api.updateDispatch).mock.calls[1]![1] expect(next.vehicle?.plate).toBe('CA-CHANGED');expect(next.idempotencyKey).not.toBe(original.idempotencyKey) expect(api.getDispatchCommand).not.toHaveBeenCalled() await wrapper.setProps({dispatchNo:'PC/2',action:'CANCEL'});await flushPromises() expect(dialog.props('title')).toContain('PC/2') }) 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.props('width')).toBe('1100px') expect(dialog.get('[aria-label="本车审核依据"]').findAll('input')).toHaveLength(0) expect(dialog.get('[aria-label="本车审核依据"] h3').text()).toBe('车辆与货物') expect(dialog.get('[aria-label="本车审核依据"]').text()).toContain('联系电话') expect(dialog.get('[aria-label="排车与预约"]').findAll('input')).toHaveLength(0) const photos = dialog.findAllComponents(DispatchEvidenceFiles) expect(photos.map(photo => photo.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片']) expect(photos.every(photo => photo.props('readonly'))).toBe(true) expect(dialog.get('[aria-label="操作原因与意见"] h3').text()).toBe('审核意见') expect(dialog.text()).not.toContain('请核对本车资源与本趟证据') expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false) expect(dialog.findComponent({ name: 'ElRadioGroup' }).exists()).toBe(false) expect(dialog.findAll('button').some(button => button.text() === '确认提交')).toBe(false) const reject = dialog.findAll('button').find(button => button.text() === '驳回')! const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading await reject.trigger('click'); await flushPromises(); expect(review).not.toHaveBeenCalled() await vi.waitFor(() => expect(field(dialog, '驳回原因(必填)').text()).toContain('请填写驳回原因')) await dialog.get('textarea').setValue(' '); await reject.trigger('click'); await flushPromises(); expect(review).not.toHaveBeenCalled() await dialog.get('textarea').setValue(' 证据不完整 '); await reject.trigger('click'); await flushPromises() expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'REJECTED', reason: '证据不完整' }) expect(dialog.findAll('input[type="file"]')).toHaveLength(0) }) it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('approves %s directly and permits a new decision after failure', async action => { const review = action === 'DISPATCH_REVIEW' ? api.reviewDispatch : api.reviewDispatchLoading vi.mocked(review).mockRejectedValueOnce(new Error('结果未知')) const { dialog } = await render(action) await dialog.findAll('button').find(button => button.text() === '审核通过')!.trigger('click') await flushPromises() expect(review).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), decision: 'APPROVED', reason: null }) const original = structuredClone(vi.mocked(review).mock.calls[0]![1]) expect(dialog.props('showCancel')).toBe(false) expect(dialog.findAll('button').some(button => button.text() === '取消')).toBe(false) expect(dialog.findAll('button').some(button => button.text() === '驳回')).toBe(true) await dialog.findAll('button').find(button => button.text() === '审核通过')!.trigger('click') await flushPromises() expect(vi.mocked(review).mock.calls[1]![1].decision).toBe('APPROVED');expect(vi.mocked(review).mock.calls[1]![1].idempotencyKey).not.toBe(original.idempotencyKey) }) it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('disables both %s decisions when write permission is missing', async action => { can.mockReturnValue(false) const { dialog } = await render(action) for (const label of ['驳回', '审核通过']) { const button = dialog.findAll('button').find(item => item.text() === label)! expect(button.attributes('disabled')).toBeDefined() await button.trigger('click') } expect(api.reviewDispatch).not.toHaveBeenCalled() expect(api.reviewDispatchLoading).not.toHaveBeenCalled() }) it('retains all three uploaded photos and permits a fresh vehicle save after failure', async () => { can.mockImplementation(code => code === 'platform:overseas-warehouse:dispatch:edit') vi.mocked(api.updateDispatch).mockRejectedValueOnce(new Error('unknown edit')).mockImplementationOnce(async (_, body) => command('EDIT', body.idempotencyKey)) const { wrapper, dialog } = await render('EDIT', undefined, true) const fields = dialog.findAllComponents(DispatchEvidenceFiles) expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片']) expect(fields[0]!.props('modelValue')).toEqual(['301']) expect(dialog.get('.workflow-photo-grid').findAll('figure')).toHaveLength(3) 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(api.updateDispatch).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']) dialog.vm.$emit('confirm'); await flushPromises() const original = structuredClone(vi.mocked(api.updateDispatch).mock.calls[0]![1]) expect(original).toMatchObject({ vehicle: { evidenceFileIds: ['301', '401'] }, driver: { evidence: { photoFileIds: ['402'], fileIds: expect.arrayContaining(['403']) } } }) expect(dialog.props('confirmLoading')).toBe(false) dialog.getComponent(LoadErrorState).vm.$emit('retry'); await flushPromises() dialog.vm.$emit('confirm'); await flushPromises() const next=vi.mocked(api.updateDispatch).mock.calls[1]![1] expect(next).toMatchObject({...original,recordVersion:1,idempotencyKey:expect.any(String)}) expect(next.idempotencyKey).not.toBe(original.idempotencyKey) expect(api.reviewDispatch).not.toHaveBeenCalled() expect(api.reviewDispatchLoading).not.toHaveBeenCalled() }) it.each(['DISPATCH_REVIEW', 'LOADING_REVIEW'] as const)('shows saved photos and old audit evidence read-only in %s', async action => { const { wrapper, dialog } = await render(action) const value = detail({ actions }) value.vehicleInfo.driver!.evidence!.photoFileIds = ['402'] value.reviews.dispatchReview.attachments = { vehicleFileIds: [], driverPhotoFileIds: ['403'], licenseFileIds: [] } vi.mocked(api.getDispatch).mockResolvedValueOnce(value) await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises() const fields = dialog.findAllComponents(DispatchEvidenceFiles) expect(fields.map(item => item.props('label'))).toEqual(['车辆图片', '司机照片', '驾驶证照片']) expect(fields.every(item => item.props('readonly') && item.props('photoCard'))).toBe(true) expect(fields[1]!.props('modelValue')).toEqual(['402', '403']) expect(dialog.findAll('input[type="file"]')).toHaveLength(0) expect(api.uploadDispatchEvidence).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() } if (needsReason) await dialog.get('textarea').setValue(' 现场原因 ') else expect(dialog.find('textarea').exists()).toBe(false) dialog.vm.$emit('confirm'); await flushPromises() expect(send).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), ...(needsReason ? { reason: '现场原因' } : { remark: null }) }) }) 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)('keeps the retired %s editor unavailable', 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('confirms the scanned load in a compact dialog without measurement inputs', async () => { const { dialog } = await render('LOADING_COMPLETE') expect(dialog.get('[aria-label="确认装车完成"]').findAllComponents({ name: 'ElInputNumber' })).toHaveLength(0) expect(dialog.props('width')).toBe('560px') expect(dialog.text()).not.toContain('测量依据') dialog.vm.$emit('confirm'); await flushPromises() expect(api.completeDispatchLoading).toHaveBeenCalledExactlyOnceWith('PC/1', { recordVersion: 0, idempotencyKey: expect.any(String), manifestHash: 'manifest', 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.get('button.evidence-photo-preview').trigger('click'); await flushPromises() const image = files.findComponent(AppDialog) expect(image.props('modelValue')).toBe(true) const preview = new DOMWrapper(document.querySelector('.evidence-preview')!.closest('.el-dialog')!) expect(preview.get('img').attributes('src')).toBe('blob:owned-dispatch') Object.defineProperties(preview.get('img').element, { naturalWidth: { value: 1200 }, naturalHeight: { value: 800 } }) await preview.get('img').trigger('load'); await flushPromises() expect(preview.get('.evidence-preview').attributes('aria-busy')).toBe('false') 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() }) })