366 lines
32 KiB
TypeScript
366 lines
32 KiB
TypeScript
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<DispatchDetail> = {}) {
|
|
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<VueWrapper, 'findAllComponents'>, 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, publishLooseboardDemand: true, 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()
|
|
})
|
|
})
|