fix(dispatch): guard unsaved evidence without disabling uploads
This commit is contained in:
@@ -3,9 +3,10 @@ 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 DispatchEvidenceFiles from './DispatchEvidenceFiles.vue'
|
||||
import * as api from '../api/dispatches'
|
||||
import { command, detail, loadingPreview } from '../dispatchTestFixtures'
|
||||
import type { CommandResult, DispatchAction, DispatchDetail } from '../dispatchContracts'
|
||||
import type { CommandResult, DispatchAction, DispatchDetail, Driver, Vehicle } from '../dispatchContracts'
|
||||
import { useDispatchCommandRecovery } from '../dispatchCommandRecovery'
|
||||
const identity = vi.hoisted(() => ({ id: '' }))
|
||||
vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: identity }) }))
|
||||
@@ -280,3 +281,45 @@ it('saves a cleared unavailable master vehicle as an empty draft resource', asyn
|
||||
expect(api.submitDispatchReview).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['VEHICLE', 'DRIVER_PHOTO', 'DRIVER'] as const)('does not silently discard %s files when the master resource is missing', async kind => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
|
||||
editor.vehicle.mode = 'MASTER'; editor.vehicle.vehicleId = null
|
||||
editor.driver.mode = 'MASTER'; editor.driver.driverId = null
|
||||
await flushPromises()
|
||||
const card = wrapper.findAllComponents(DispatchEvidenceFiles).find(item => item.props('kind') === kind)!
|
||||
expect(card.get('.evidence-upload-button').attributes('disabled')).toBeUndefined()
|
||||
expect(wrapper.text()).not.toContain('请先在“排车信息”')
|
||||
// A pending reference must also be guarded at save time, rather than being converted to null.
|
||||
card.vm.$emit('update:modelValue', ['301'])
|
||||
await state.submit(); await flushPromises()
|
||||
expect(api.updateDispatch).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('saved')).toBeUndefined()
|
||||
expect(card.props('modelValue')).toEqual(['301'])
|
||||
expect(wrapper.text()).toContain(kind === 'VEHICLE' ? '请先选择车辆' : '请先选择司机')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it.each(['MASTER', 'EXTERNAL'] as const)('keeps all three %s photo references after saving, reopening and saving again', async mode => {
|
||||
const { wrapper, state } = await open('EDIT')
|
||||
const editor = wrapper.vm as unknown as { vehicle: Vehicle; driver: Driver }
|
||||
editor.vehicle.mode = mode; editor.vehicle.vehicleId = mode === 'MASTER' ? '11' : null
|
||||
editor.driver.mode = mode; editor.driver.driverId = mode === 'MASTER' ? '12' : null
|
||||
const expected = { VEHICLE: ['301'], DRIVER_PHOTO: ['302'], DRIVER: ['303'] }
|
||||
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) card.vm.$emit('update:modelValue', expected[card.props('kind') as keyof typeof expected])
|
||||
let saved: DispatchDetail | undefined
|
||||
vi.mocked(api.updateDispatch).mockImplementation(async (_, body) => {
|
||||
saved = detail({ recordVersion: 1, actions: { EDIT: { allowed: true, blockCode: null, blockReason: null } }, vehicleInfo: JSON.parse(JSON.stringify({ vehicle: body.vehicle, driver: body.driver, maintenance: body.maintenance })) })
|
||||
vi.mocked(api.getDispatch).mockImplementation(async () => JSON.parse(JSON.stringify(saved)))
|
||||
return command('EDIT', body.idempotencyKey)
|
||||
})
|
||||
await state.submit(); await flushPromises()
|
||||
expect(wrapper.emitted('saved')).toHaveLength(1)
|
||||
await wrapper.setProps({ modelValue: false }); await wrapper.setProps({ modelValue: true }); await flushPromises()
|
||||
for (const card of wrapper.findAllComponents(DispatchEvidenceFiles)) expect(card.props('modelValue')).toEqual(expected[card.props('kind') as keyof typeof expected])
|
||||
await state.submit()
|
||||
expect(api.updateDispatch).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(api.updateDispatch).mock.calls[1]![1]).toMatchObject({ vehicle: { evidenceFileIds: ['301'] }, driver: { evidence: { photoFileIds: ['302'], fileIds: ['303'] } } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -39,6 +39,8 @@ const measurements = ref<Array<{ warehousePalletId: string; palletNo: string; pa
|
||||
const blankVehicle = (): Vehicle => ({ mode: 'MASTER', vehicleId: null, plate: null, carrierName: null, capacity: { palletSpaces: null, maxLoadKg: null, maxVolumeCbm: null }, evidenceFileIds: [] })
|
||||
const blankDriver = (): Driver => ({ mode: 'MASTER', driverId: null, name: null, phone: null, evidence: { licenseNo: null, licenseExpiresOn: null, fileIds: [], expectedTaskEndAt: null } })
|
||||
const vehicle = ref<Vehicle>(blankVehicle()), driver = ref<Driver>(blankDriver())
|
||||
const missingVehicle = computed(() => vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId)
|
||||
const missingDriver = computed(() => driver.value.mode === 'MASTER' && !driver.value.driverId)
|
||||
const driverPhotos = computed({ get: () => driver.value.evidence?.photoFileIds || [], set: (ids: string[]) => { if (driver.value.evidence) driver.value.evidence.photoFileIds = ids } })
|
||||
// 新选司机的主档证照先按司机读取,已保存的冻结证照仍按排车读取。
|
||||
const masterLicenseReference = computed(() => driver.value.mode === 'MASTER' ? driver.value.evidence?.masterLicenseFileId : null)
|
||||
@@ -125,7 +127,10 @@ function payload(): object {
|
||||
case 'MARK_EXCEPTION':
|
||||
if (!exception.exceptionType) throw new DispatchInputError('请选择异常类型')
|
||||
return { ...common, exceptionType: exception.exceptionType, returnDirection: exception.returnDirection || null, description: exception.description.trim() || null, imageFileIds: [...exception.imageFileIds] } satisfies MarkDispatchException
|
||||
case 'EDIT': return { ...common, vehicle: vehicle.value.mode === 'MASTER' && !vehicle.value.vehicleId ? null : vehicle.value, driver: driver.value.mode === 'MASTER' && !driver.value.driverId ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'EDIT':
|
||||
if (missingVehicle.value && vehicle.value.evidenceFileIds.length) throw new DispatchInputError('请先选择车辆,再保存车辆图片;当前图片尚未保存')
|
||||
if (missingDriver.value && (driver.value.evidence?.fileIds.length || driverPhotos.value.length)) throw new DispatchInputError('请先选择司机,再保存司机照片和驾驶证照片;当前图片尚未保存')
|
||||
return { ...common, vehicle: missingVehicle.value ? null : vehicle.value, driver: missingDriver.value ? null : driver.value, maintenance: vehicleMaintenancePayload(form.maintenance), plannedDepartureAt: form.plannedDepartureAt || null, estimatedArrivalAt: form.estimatedArrivalAt || null, remark: form.remark || null } satisfies UpdateDispatch
|
||||
case 'DISPATCH_REVIEW': case 'LOADING_REVIEW':
|
||||
if (form.decision === 'APPROVED' && approvalBlock.value) throw new DispatchInputError(approvalBlock.value)
|
||||
if (form.decision === 'REJECTED' && !form.reason.trim()) throw new DispatchInputError('驳回时必须填写原因')
|
||||
|
||||
Reference in New Issue
Block a user