From c510cc4e2ec1716fcc96ab6d439da876b119e43a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 16:58:38 +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 --- .../overseas-warehouse/PROTOTYPE_MATRIX.md | 6 + .../api/dispatchLoadingDocuments.ts | 27 +++++ .../components/DeliveryDocumentsDialog.vue | 3 +- .../DispatchDocumentsDialog.test.ts | 62 ++++++++++ .../components/DispatchDocumentsDialog.vue | 19 ++- .../components/DispatchLoadingDocuments.vue | 108 +++++++++++++++++ .../DispatchVehicleDocuments.test.ts | 63 ++++++++++ .../components/DispatchVehicleDocuments.vue | 26 +++- .../components/DispatchWorkflowDialog.vue | 2 +- .../dispatchCommandRecovery.ts | 2 +- .../overseas-warehouse/dispatchContracts.ts | 1 + .../pages/DispatchDetailView.test.ts | 22 ++++ .../pages/DispatchDetailView.vue | 4 +- .../pages/DispatchesView.test.ts | 114 +++++++++++++++++- .../pages/DispatchesView.vue | 45 +++++-- 15 files changed, 481 insertions(+), 23 deletions(-) create mode 100644 src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts create mode 100644 src/modules/overseas-warehouse/components/DispatchDocumentsDialog.test.ts create mode 100644 src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue create mode 100644 src/modules/overseas-warehouse/components/DispatchVehicleDocuments.test.ts diff --git a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md index b5569ede..831c4941 100644 --- a/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md +++ b/src/modules/overseas-warehouse/PROTOTYPE_MATRIX.md @@ -423,3 +423,9 @@ 本轮自动验证:Admin 5 个定向文件 96 项通过,`pnpm typecheck` 通过;Lastleg/File 8 个定向类 63 项通过,API 路由合同 5 项通过,AGENTS 索引 1232 项通过。未部署,未进行真实页面操作;上线本次变更须包含 Admin、lastleg-service 和 file(新增司机照片文件分类)。 - 2026-10-21 排车“派送中 → 标记异常”:按用户提供的三类异常、两类退货去向截图补齐选项,沿用居中作业弹框,说明与图片选填,保存后仍派送中;现有排车信息页签回看。自动回归单独报告,1920×1080/100%真实页面待人工截图确认。 + +## 排车管理资料统一入口(2026-10-21) + +| 页面 | 操作 | 原型/确认依据 | 承载与实现 | 验收 | +|---|---|---|---|---| +| 排车列表:派送中、配送完成 | 管理资料 | 用户管理资料截图及同车/派送单资料共用确认;签收完成即可,其他可补 | 1100px居中可全屏弹框,签收文件/油耗图片/装车资料三个页签;按关联派送单切换,共用原派送资料编辑器;油耗沿本车附加照片保存 | 自动回归单独报告;1920×1080、100%待人工截图确认 | diff --git a/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts b/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts new file mode 100644 index 00000000..4d5e1d67 --- /dev/null +++ b/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts @@ -0,0 +1,27 @@ +import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http' +import type { DispatchStage } from '../dispatchContracts' + +export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'] as const +export type LoadingPhotoSlot = typeof loadingPhotoSlots[number] +export interface LoadingPhoto { slot: LoadingPhotoSlot; fileId: string; fileName: string; contentType: string; savedAt: string | null } +export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] } +export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string } +const path = (no: string) => `/platform/v1/last-leg/dispatches/${encodeURIComponent(no)}/loading-documents` +export async function getLoadingDocuments(no: string) { + return unwrapApiResponse(await http.get, ApiEnvelope>(path(no))) +} +export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string) { + const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key) + return unwrapApiResponse(await http.post, ApiEnvelope>(`${path(no)}/${slot}/uploads`, form)) +} +export async function recoverLoadingPhoto(no: string, key: string) { + return unwrapApiResponse(await http.get, ApiEnvelope>(`${path(no)}/uploads/${encodeURIComponent(key)}`)) +} +export async function bindLoadingPhoto(no: string, slot: LoadingPhotoSlot, body: BindLoadingPhoto) { + return unwrapApiResponse(await http.put, ApiEnvelope>(`${path(no)}/${slot}`, body)) +} +export async function readLoadingPhoto(no: string, photo: LoadingPhoto) { + const blob = await http.get(`${path(no)}/${photo.slot}/files/${encodeURIComponent(photo.fileId)}/content`, { responseType: 'blob' }) + if (!(blob instanceof Blob) || !['image/jpeg', 'image/png'].includes(blob.type) || !blob.size) throw new Error('图片读取失败,请重新核对') + return blob +} diff --git a/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.vue b/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.vue index 53876535..9ed27a5a 100644 --- a/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.vue +++ b/src/modules/overseas-warehouse/components/DeliveryDocumentsDialog.vue @@ -1,7 +1,7 @@ + + diff --git a/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.test.ts b/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.test.ts new file mode 100644 index 00000000..b40e8ba5 --- /dev/null +++ b/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.test.ts @@ -0,0 +1,63 @@ +import ElementPlus from 'element-plus' +import { flushPromises, mount } from '@vue/test-utils' +import { beforeEach, expect, it, vi } from 'vitest' +import { ApiResponseError } from '@/shared/api/http' +import DispatchVehicleDocuments from './DispatchVehicleDocuments.vue' +import { bindVehiclePhoto, getVehicleDocuments, recoverVehiclePhoto, uploadVehiclePhoto, type VehicleDocuments, type VehiclePhoto } from '../api/dispatchVehicleDocuments' +const can = vi.hoisted(() => vi.fn(() => true)) +vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'operator' } }) })) +vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) +vi.mock('../api/dispatchVehicleDocuments', () => ({ bindVehiclePhoto: vi.fn(), getVehicleDocuments: vi.fn(), recoverVehiclePhoto: vi.fn(), uploadVehiclePhoto: vi.fn(), readVehiclePhoto: vi.fn() })) +const photo: VehiclePhoto = { slot: 'FUEL_START', fileId: '9007199254740995', fileName: 'fuel.png', contentType: 'image/png', savedAt: null } +function response(version = '3'): VehicleDocuments { return { dispatchNo: 'PC/1', recordVersion: version, editable: true, photos: [] } } +function setup() { return mount(DispatchVehicleDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) } +async function upload(wrapper: ReturnType) { const input = wrapper.get('input'); Object.defineProperty(input.element, 'files', { value: [new File(['image'], 'fuel.png', { type: 'image/png' })], configurable: true }); await input.trigger('change'); await flushPromises() } +beforeEach(() => { + vi.clearAllMocks(); sessionStorage.clear(); can.mockReturnValue(true) + vi.mocked(getVehicleDocuments).mockResolvedValue(response()) + vi.mocked(uploadVehiclePhoto).mockResolvedValue(photo) + vi.mocked(recoverVehiclePhoto).mockResolvedValue(photo) + vi.mocked(bindVehiclePhoto).mockResolvedValue({ ...response('4'), photos: [{ ...photo, savedAt: '2026-10-21T00:00:00Z' }] }) +}) +it('binds the uploaded file to the car and restores the original binding after reopening without uploading twice', async () => { + vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new Error('network timeout')) + const first = setup(); await flushPromises(); await upload(first) + const original = vi.mocked(bindVehiclePhoto).mock.calls[0]![2] + expect(original).toMatchObject({ recordVersion: '3', fileId: photo.fileId }) + first.unmount() + const reopened = setup(); await flushPromises() + await reopened.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises() + expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', original) + expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1) + expect(reopened.text()).toContain('fuel.png') + expect(sessionStorage.length).toBe(0) + reopened.unmount() +}) +it('keeps uncertain uploads for original-key recovery and never resubmits the file', async () => { + vi.mocked(uploadVehiclePhoto).mockRejectedValueOnce(new Error('connection lost')) + const wrapper = setup(); await flushPromises(); await upload(wrapper) + const originalKey = vi.mocked(uploadVehiclePhoto).mock.calls[0]![3] + await wrapper.findAll('button').find(button => button.text() === '核对原请求并继续保存')!.trigger('click'); await flushPromises() + expect(recoverVehiclePhoto).toHaveBeenCalledWith('PC/1', originalKey) + expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1) + expect(bindVehiclePhoto).toHaveBeenCalledTimes(1) + wrapper.unmount() +}) +it('refreshes only a known version conflict and reuses the uploaded file', async () => { + vi.mocked(bindVehiclePhoto).mockRejectedValueOnce(new ApiResponseError('运营记录版本已变化,请刷新后重试', 'A0409', 409)) + const wrapper = setup(); await flushPromises(); await upload(wrapper) + const originalKey = vi.mocked(bindVehiclePhoto).mock.calls[0]![2].idempotencyKey + vi.mocked(getVehicleDocuments).mockResolvedValue(response('8')) + await wrapper.findAll('button').find(button => button.text() === '刷新版本并保存已上传图片')!.trigger('click'); await flushPromises() + expect(bindVehiclePhoto).toHaveBeenLastCalledWith('PC/1', 'FUEL_START', { recordVersion: '8', fileId: photo.fileId, idempotencyKey: expect.any(String) }) + expect(vi.mocked(bindVehiclePhoto).mock.calls[1]![2].idempotencyKey).not.toBe(originalKey) + expect(uploadVehiclePhoto).toHaveBeenCalledTimes(1) + wrapper.unmount() +}) +it('does not offer upload with list permission alone', async () => { + can.mockReturnValue(false) + const wrapper = setup(); await flushPromises() + expect(wrapper.find('input[type="file"]').exists()).toBe(false) + expect(uploadVehiclePhoto).not.toHaveBeenCalled() + wrapper.unmount() +}) diff --git a/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.vue b/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.vue index d2cefd41..b460c24e 100644 --- a/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.vue +++ b/src/modules/overseas-warehouse/components/DispatchVehicleDocuments.vue @@ -13,7 +13,7 @@ const props = defineProps<{ dispatchNo: string }>() const auth = useAuthStore(), access = useAccessPreviewStore() const emit = defineEmits<{ saved: [] }>() const snapshot = ref(null), busy = ref(false), error = ref(null) -interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto } +interface Pending { slot: FuelSlot; key: string; photo?: VehiclePhoto; binding?: BindVehiclePhoto; versionConflict?: boolean } const pending = ref(null), preview = ref(''), corrupted = ref(false) const actor = auth.user?.id || '', number = props.dispatchNo const storageKey = `dispatch-vehicle-documents:${actor}:${number}` @@ -33,7 +33,13 @@ async function bind() { if (!item?.photo || !snapshot.value) return if (item.photo.slot !== item.slot) throw new Error('上传结果与图片分类不一致') if (!item.binding) { item.binding = { recordVersion: snapshot.value.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: item.photo.fileId }; persist() } - const result = await bindVehiclePhoto(number, item.slot, item.binding) + let result: VehicleDocuments + try { result = await bindVehiclePhoto(number, item.slot, item.binding) } + catch (cause) { + const details = getApiErrorDetails(cause, '图片保存失败') + if (details.code === 'A0409' && /记录版本已变化|记录版本竞争失败/.test(details.message)) { item.versionConflict = true; persist() } + throw cause + } ensureScope(); snapshot.value = result; pending.value = null; persist(); emit('saved'); ElMessage.success('油耗图片已保存') } async function recover() { @@ -43,6 +49,13 @@ async function recover() { catch (cause) { if (active) error.value = getApiErrorDetails(cause, '原上传或保存结果尚未确认') } finally { if (active) busy.value = false } } +async function refreshBinding() { + if (busy.value || !editable.value || !pending.value?.versionConflict) return + await load() + if (error.value || !pending.value) return + pending.value.binding = undefined; pending.value.versionConflict = false; persist() + await recover() +} async function upload(slot: FuelSlot, event: Event) { const input = event.target as HTMLInputElement, file = input.files?.[0]; input.value = '' if (!file || busy.value || pending.value || !editable.value) return @@ -51,7 +64,12 @@ async function upload(slot: FuelSlot, event: Event) { try { ensureScope(); pending.value = { slot, key: crypto.randomUUID() }; persist() pending.value.photo = await uploadVehiclePhoto(number, slot, file, pending.value.key); ensureScope(); persist(); await bind() - } catch (cause) { if (active) error.value = getApiErrorDetails(cause, '图片上传或保存失败,请核对原请求') } + } catch (cause) { + if (active) { + error.value = getApiErrorDetails(cause, '图片上传或保存失败,请核对原请求') + if (!pending.value?.photo && ['A0400', 'A0403'].includes(error.value.code || '')) { pending.value = null; persist() } + } + } finally { if (active) busy.value = false } } async function view(photo: VehiclePhoto) { @@ -74,7 +92,7 @@ defineExpose({ busy })