From 872d197531c0292b8de6caecf03d26e1ca9b8e31 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 21 Oct 2026 18:11:48 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/overseas-master-data/index.ts | 2 +- .../api/transportTasks.test.ts | 17 ++++++ .../overseas-warehouse/api/transportTasks.ts | 9 ++- .../components/DispatchEvidenceFiles.test.ts | 10 ++++ .../components/DispatchEvidenceFiles.vue | 21 +++++-- .../TransportExecutionPanel.test.ts | 59 ++++++++++++++++++- .../components/TransportExecutionPanel.vue | 55 ++++++++++++++--- .../overseas-warehouse/dispatchContracts.ts | 2 +- .../pages/UnifiedOrdersView.vue | 32 ++++------ 9 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 src/modules/overseas-warehouse/api/transportTasks.test.ts diff --git a/src/modules/overseas-master-data/index.ts b/src/modules/overseas-master-data/index.ts index bc159f04..428cec2f 100644 --- a/src/modules/overseas-master-data/index.ts +++ b/src/modules/overseas-master-data/index.ts @@ -12,7 +12,7 @@ export { listTripDispatchCandidates, prepareTrip, type TripPreparation, type Dis export type { TripPreviousDispatch } from './tripTypes' export { listVehicles } from './api/overseasMasterData' -export { listDrivers } from './api/drivers' +export { listDrivers, getDriver, downloadDriverLicense } from './api/drivers' export type { DriverDetail } from './driverTypes' export { normalizeTripDetail } from './api/trips' export type { TripDetail, TripAssignment, TripResourceRelease } from './tripTypes' diff --git a/src/modules/overseas-warehouse/api/transportTasks.test.ts b/src/modules/overseas-warehouse/api/transportTasks.test.ts new file mode 100644 index 00000000..d4c714b2 --- /dev/null +++ b/src/modules/overseas-warehouse/api/transportTasks.test.ts @@ -0,0 +1,17 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import { http } from '@/shared/api/http' +import { assignTransport } from './transportTasks' +vi.mock('@/shared/api/http', () => ({ http: { post: vi.fn() }, unwrapApiResponse: (value: { data: unknown }) => value.data })) +vi.mock('@/modules/overseas-master-data', () => ({ normalizeTripDetail: (value: unknown) => value })) +beforeEach(() => vi.resetAllMocks()) +const body = { recordVersion: 4, vehicleId: '1', driverId: '2', reason: '保存照片', reviewPhotos: { vehicleFileIds: ['21'], driverPhotoFileIds: ['22'], licenseFileIds: [] }, masterLicenseFileId: '33' } +it('does not submit photo changes when the backend has not supplied the dispatch version', () => { + expect(() => assignTransport('PC1', body)).toThrow('照片保存版本') + expect(http.post).not.toHaveBeenCalled() +}) +it('preserves zero dispatch version and opaque photo IDs in the assignment', async () => { + vi.mocked(http.post).mockResolvedValue({ data: { dispatchNo: 'PC1', taskType: 'PICKUP', dispatchRecordVersion: '1', resources: { vehicle: { evidenceFileIds: ['21'] } }, execution: {}, pickups: [] } }) + const result = await assignTransport('PC1', { ...body, dispatchRecordVersion: 0 }) + expect(http.post).toHaveBeenCalledWith('/platform/v1/last-leg/dispatches/transport-tasks/PC1/assign', { ...body, dispatchRecordVersion: 0 }) + expect(result.resources?.vehicle?.evidenceFileIds).toEqual(['21']) +}) diff --git a/src/modules/overseas-warehouse/api/transportTasks.ts b/src/modules/overseas-warehouse/api/transportTasks.ts index 950bdb1f..2f08653e 100644 --- a/src/modules/overseas-warehouse/api/transportTasks.ts +++ b/src/modules/overseas-warehouse/api/transportTasks.ts @@ -1,9 +1,11 @@ import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' import { normalizeTripDetail, type TripDetail, type PickupTask, type PickupCandidate, type PickupEvidence, type PickupPlan, type TripAssignment, type TripResourceRelease } from '@/modules/overseas-master-data' +import type { Resources, ReviewAttachments, Version } from '../dispatchContracts' export type TransportType = 'PICKUP' | 'LOCAL_TRUCK' export const transportLabels = { DELIVERY: '仓库派送', PICKUP: '到仓取货', LOCAL_TRUCK: '本土卡车' } -export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[] } +export interface TransportDetail { dispatchNo: string; taskType: TransportType; execution: TripDetail; customerId: string | null; orderNo: string | null; pickups: PickupTask[]; completionEvidence: PickupEvidence[]; dispatchRecordVersion: Version; resources: Resources | null } +export type TransportAssignment = TripAssignment & { dispatchRecordVersion?: Version; reviewPhotos?: ReviewAttachments; masterLicenseFileId?: string | null } export interface TruckCandidate { customerId: string; orderNo: string; origin: string; originAddress: string; destination: string; destinationAddress: string; palletCount: number; packageCount: number } export interface TruckPlan { customerId: string; orderNo: string; warehouseId: string; plannedDepartureAt: string; expectedDriverEndAt: string; idempotencyKey: string } type WireDetail = Omit & { execution: Parameters[0] } @@ -17,7 +19,10 @@ export const pickupCandidates = () => get<{ records: PickupCandidate[]; total: n export const truckCandidates = () => get(`${base}/truck-candidates`) export const createTransportPickup = (body: PickupPlan) => post(`${base}/pickup`, body).then(normalize) export const createTransportTruck = (body: TruckPlan) => post(`${base}/truck`, body).then(normalize) -export const assignTransport = (number: string, body: TripAssignment) => post(`${path(number)}/assign`, body).then(normalize) +export const assignTransport = (number: string, body: TransportAssignment) => { + if (body.reviewPhotos && body.dispatchRecordVersion == null) throw new Error('未读取到照片保存版本,请更新本地后端并刷新后重试') + return post(`${path(number)}/assign`, body).then(normalize) +} export const transportOccupancy = (number: string, kind: 'VEHICLE' | 'DRIVER', resourceIds: string[]) => resourceIds.length ? post>(`${path(number)}/resource-occupancy`, { kind, resourceIds }) : Promise.resolve([]) export const cancelTransport = (number: string, body: object) => post(`${path(number)}/cancel`, body) export const releaseTransport = (number: string, body: TripResourceRelease) => post(`${path(number)}/release`, body) diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts index bef6dc4f..5e7076cc 100644 --- a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.test.ts @@ -6,10 +6,20 @@ import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import DispatchEvidenceFiles from './DispatchEvidenceFiles.vue' import * as api from '../api/dispatches' import type { DispatchEvidenceFile } from '../dispatchContracts' +import { downloadDriverLicense } from '@/modules/overseas-master-data' +vi.mock('@/modules/overseas-master-data', () => ({ downloadDriverLicense: vi.fn() })) vi.mock('../api/dispatches', () => ({ getDispatchEvidence: vi.fn(), getDispatchEvidenceContent: vi.fn(), uploadDispatchEvidence: vi.fn() })) const metadata = (fileId = '301', contentType = 'image/png'): DispatchEvidenceFile => ({ fileId, fileName: contentType === 'application/pdf' ? '本车证照.pdf' : '本车证据.png', contentType, sizeBytes: 3 }) const options = { props: { modelValue: ['301'], dispatchNo: 'PC/1', kind: 'VEHICLE' as const }, global: { stubs: { ElButton: { props: ['disabled'], template: '' }, AppDialog: { name: 'AppDialog', props: ['modelValue', 'title'], template: '
' } } } } const button = (wrapper: ReturnType, label: string) => wrapper.findAll('button').find(value => value.text() === label)! +it('previews an unsaved master license through its driver and keeps uploaded license files on the dispatch route', async () => { + vi.mocked(downloadDriverLicense).mockResolvedValue(new Blob(['png'], { type: 'image/png' })) + const wrapper = mount(DispatchEvidenceFiles, { ...options, props: { ...options.props, kind: 'DRIVER', photoCard: true, modelValue: ['301'], referenceIds: ['33'], masterLicense: { driverId: '2', fileId: '33' } } }); await flushPromises() + expect(downloadDriverLicense).toHaveBeenCalledWith('2', '33') + expect(api.getDispatchEvidence).toHaveBeenCalledWith('PC/1', 'DRIVER', '301') + expect(api.getDispatchEvidence).not.toHaveBeenCalledWith('PC/1', 'DRIVER', '33') + expect(wrapper.findAll('figure img')).toHaveLength(2); wrapper.unmount() +}) async function selectFile(wrapper: ReturnType, file: File) { const input = wrapper.get('input[type="file"]'); Object.defineProperty(input.element, 'files', { configurable: true, value: [file] }); await input.trigger('change'); await flushPromises() } beforeEach(() => { vi.resetAllMocks(); vi.mocked(api.getDispatchEvidence).mockResolvedValue(metadata()); vi.mocked(api.uploadDispatchEvidence).mockResolvedValue(metadata('302')); vi.mocked(api.getDispatchEvidenceContent).mockResolvedValue(new Blob(['png'], { type: 'image/png' })); URL.createObjectURL = vi.fn().mockReturnValue('blob:authenticated'); URL.revokeObjectURL = vi.fn() }) it('renders authenticated photos above their caption and releases thumbnails on unmount', async () => { diff --git a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue index af312a78..2a9ffbb9 100644 --- a/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue +++ b/src/modules/overseas-warehouse/components/DispatchEvidenceFiles.vue @@ -9,14 +9,16 @@ import LoadErrorState from '@/shared/components/LoadErrorState.vue' import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue' import { ApiResponseError, getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http' import { getDispatchEvidence, getDispatchEvidenceContent, uploadDispatchEvidence } from '../api/dispatches' +import { downloadDriverLicense } from '@/modules/overseas-master-data' import { DispatchInputError, type DispatchEvidenceFile, type DispatchEvidenceKind } from '../dispatchContracts' -const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[] }>() +const props = defineProps<{ dispatchNo: string; kind: DispatchEvidenceKind; disabled?: boolean; readonly?: boolean; photoCard?: boolean; label?: string; saveHint?: string; referenceIds?: string[]; masterLicense?: { driverId: string; fileId: string } | null }>() const ids = defineModel({ required: true }) const allIds = computed(() => [...new Set([...(props.referenceIds || []), ...ids.value])]) const emit = defineEmits<{ busy: [value: boolean] }>() const metadata = ref>({}), error = ref(null), uploading = ref(false), input = ref(null) const imageOpen = ref(false), imageUrl = ref(''), imageName = ref(''), previewLoading = ref(false) const thumbnails = ref>({}) +let masterBlob: { fileId: string; blob: Blob } | null = null const previewId = ref(''), imageDecoding = ref(false), imageFailure = ref('') const previewBusy = computed(() => previewLoading.value || imageDecoding.value) const label = computed(() => props.label || (props.kind === 'VEHICLE' ? '本趟车辆证据' : props.kind === 'DRIVER_PHOTO' ? '司机照片' : '本趟司机证照')) @@ -45,9 +47,18 @@ async function load() { const current = ++generation, no = props.dispatchNo, kind = props.kind, values = [...allIds.value] for (const url of Object.values(thumbnails.value)) { URL.revokeObjectURL(url); urls.delete(url) } thumbnails.value = {} - closePreview(); metadata.value = {}; error.value = null + closePreview(); metadata.value = {}; error.value = null; masterBlob = null if (!no) return - const results = await Promise.allSettled(values.map(async id => valid(await getDispatchEvidence(no, kind, id), id))) + const master = props.kind === 'DRIVER' ? props.masterLicense : null + const results = await Promise.allSettled(values.map(async id => { + // 未保存的司机主档证照按司机身份读取;本车补传仍走排车文件归属。 + if (master && master.fileId === id) { + const blob = await downloadDriverLicense(master.driverId, id) + if (current === generation) masterBlob = { fileId: id, blob } + return valid({ fileId: id, fileName: '驾驶证照片', contentType: blob.type, sizeBytes: blob.size }, id) + } + return valid(await getDispatchEvidence(no, kind, id), id) + })) if (current !== generation) return results.forEach(result => { if (result.status === 'fulfilled') metadata.value[result.value.fileId] = result.value; else fail(result.reason, '附件资料读取失败,请重试;原引用已保留') }) if (props.photoCard) await Promise.all(values.map(async id => { @@ -78,7 +89,7 @@ async function upload(event: Event) { } function remove(id: string) { if (!props.readonly && !props.disabled && !uploading.value) ids.value = ids.value.filter(value => value !== id) } async function readContent(no: string, kind: DispatchEvidenceKind, id: string, file: DispatchEvidenceFile) { - const blob = await getDispatchEvidenceContent(no, kind, id) + const blob = kind === 'DRIVER' && masterBlob?.fileId === id ? masterBlob.blob : await getDispatchEvidenceContent(no, kind, id) const type = blob.type.split(';')[0]?.toLowerCase() if (type?.includes('json')) { let body: Partial<{ code: string; message: string; requestId: string; traceId: string }> @@ -104,7 +115,7 @@ async function preview(id: string) { } catch (cause) { if (current === previewGeneration) { closePreview(); fail(cause, '本车附件预览失败,原引用已保留') } } finally { if (current === previewGeneration) previewLoading.value = false } } -watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value]), load, { immediate: true }) +watch(() => JSON.stringify([props.dispatchNo, props.kind, allIds.value, props.masterLicense]), load, { immediate: true }) onBeforeUnmount(() => { generation++; closePreview(); for (const url of urls) URL.revokeObjectURL(url); urls.clear(); emit('busy', false) })