From 392f94e95cc0119c6f5cd934336f31849bd63942 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 27 Oct 2026 10:17:05 +0800 Subject: [PATCH] fix(dispatch): guard unsaved evidence without disabling uploads --- .../components/DispatchWorkflowDialog.test.ts | 45 ++++++++++++++++++- .../components/DispatchWorkflowDialog.vue | 7 ++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts index 89433e9a..fa375cc0 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.test.ts @@ -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() +}) diff --git a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue index bdfbb6f2..fc99e282 100644 --- a/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue +++ b/src/modules/overseas-warehouse/components/DispatchWorkflowDialog.vue @@ -39,6 +39,8 @@ const measurements = ref ({ 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(blankVehicle()), driver = ref(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('驳回时必须填写原因')