diff --git a/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts b/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts index 028642e9..246af0a8 100644 --- a/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts +++ b/src/modules/overseas-warehouse/api/dispatchLoadingDocuments.ts @@ -5,14 +5,14 @@ export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTUR export type LoadingPhotoSlot = typeof loadingPhotoSlots[number] export const loadingPhotoLabels: Record = { LOADING_SCENE: '司机驾驶证', LOADING_LIST: '车牌照片', PRE_DEPARTURE_CHECK: '装车完成', DRIVER_LICENSE: '封车照片', RECEIPT: 'BOL司机签字' } 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; replaceFileId?: string } +export interface LoadingDocuments { dispatchNo: string; recordVersion: string; dispatchRecordVersion: string; reviewRound: string; stage: DispatchStage; editable: boolean; complete: boolean; photos: LoadingPhoto[] } +export interface BindLoadingPhoto { recordVersion: string; idempotencyKey: string; fileId: string; uploadKey: string; replaceFileId?: 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) +export async function uploadLoadingPhoto(no: string, slot: LoadingPhotoSlot, file: File, key: string, reviewRound: string) { + const form = new FormData(); form.append('file', file); form.append('idempotencyKey', key); form.append('reviewRound', reviewRound) return unwrapApiResponse(await http.post, ApiEnvelope>(`${path(no)}/${slot}/uploads`, form)) } export async function recoverLoadingPhoto(no: string, key: string) { diff --git a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts index 293e4c96..a64d18a4 100644 --- a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts +++ b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.test.ts @@ -10,7 +10,7 @@ vi.mock('@/core/auth/auth', () => ({ useAuthStore: () => ({ user: { id: 'operato vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('../api/dispatchLoadingDocuments', async importOriginal => ({ ...await importOriginal(), getLoadingDocuments: vi.fn(), uploadLoadingPhoto: vi.fn(), recoverLoadingPhoto: vi.fn(), bindLoadingPhoto: vi.fn(), readLoadingPhoto: vi.fn() })) const photo: api.LoadingPhoto = { slot: 'LOADING_SCENE', fileId: '101', fileName: 'loading.png', contentType: 'image/png', savedAt: null } -const data = (extra: Partial = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra }) +const data = (extra: Partial = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', reviewRound: '1', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra }) function setup() { return mount(DispatchLoadingDocuments, { props: { dispatchNo: 'PC/1' }, global: { plugins: [ElementPlus] } }) } async function upload(wrapper: ReturnType, files = [new File(['image'], 'loading.png', { type: 'image/png' })]) { const input = wrapper.get('input[aria-label="上传司机驾驶证"]') @@ -51,13 +51,28 @@ it('offers five whole-dispatch slots and only reports saved after binding succee expect(table.findAll('thead th').map(cell => cell.text())).toEqual(['操作', '照片类别', '文件名称', '图片预览', '保存时间']) expect(table.findAll('tbody tr').map(row => row.findAll('td')[1]!.text())).toEqual(Object.values(api.loadingPhotoLabels).map(label => `${label}0/5 张`)) await upload(wrapper) - expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String)) + expect(api.uploadLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.any(File), expect.any(String), '1') expect(api.bindLoadingPhoto).toHaveBeenCalledWith('PC/1', 'LOADING_SCENE', expect.objectContaining({ recordVersion: '0', fileId: '101' })) + expect(vi.mocked(api.bindLoadingPhoto).mock.calls[0]![2].uploadKey).toBe(vi.mocked(api.uploadLoadingPhoto).mock.calls[0]![3]) expect(wrapper.emitted('saved')).toHaveLength(1) expect(wrapper.text()).toContain('图片读取失败') expect(table.get('tbody tr').text()).toContain('loading.png') wrapper.unmount() }) +it('stops a batch when re-review changes the source during upload instead of rebasing old files', async () => { + const wrapper = setup(); await flushPromises() + vi.mocked(api.uploadLoadingPhoto).mockImplementationOnce(async () => { + vi.mocked(api.getLoadingDocuments).mockResolvedValue(data({ recordVersion: '8', dispatchRecordVersion: '12', reviewRound: '2' })) + return photo + }) + await upload(wrapper, [new File(['a'], 'old-driver.png', { type: 'image/png' }), new File(['b'], 'old-driver-2.png', { type: 'image/png' })]) + expect(api.bindLoadingPhoto).not.toHaveBeenCalled() + expect(api.uploadLoadingPhoto).toHaveBeenCalledTimes(1) + expect(vi.mocked(api.uploadLoadingPhoto).mock.calls[0]![4]).toBe('1') + expect(wrapper.text()).toContain('审核轮次已变化') + expect(wrapper.emitted('saved')).toHaveLength(1) + wrapper.unmount() +}) it('shows an imported PDF license as an authenticated document and accepts an exact PDF replacement', async () => { vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:license') vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) diff --git a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue index 84c47626..af7410b1 100644 --- a/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue +++ b/src/modules/overseas-warehouse/components/DispatchLoadingDocuments.vue @@ -44,20 +44,23 @@ function photosFor(slot: LoadingPhotoSlot) { return snapshot.value?.photos.filte async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: string) { const input = event.target as HTMLInputElement, files = Array.from(input.files || []); input.value = '' if (!files.length || busy.value || !editable.value) return + const reviewRound = snapshot.value!.reviewRound const capacity = replaceFileId ? 1 : 5 - photosFor(slot).length if (files.length > capacity) { ElMessage.warning(`本次最多可选择${capacity}张图片`); return } if (files.some(file => !(['image/jpeg','image/png'].includes(file.type) || slot === 'LOADING_SCENE' && file.type === 'application/pdf') || !file.size || file.size > 10 * 1024 * 1024)) { ElMessage.warning('请选择不超过10MiB的JPG或PNG,司机驾驶证也支持PDF'); return } busy.value = true; error.value = null - let saved = 0, reconciled = false, bindingFailed = false + let saved = 0, reconciled = false, bindingFailed = false, roundChanged = false try { ensureScope() for (const file of files) { - const photo = await uploadLoadingPhoto(number, slot, file, crypto.randomUUID()); ensureScope() + const uploadKey = crypto.randomUUID() + const photo = await uploadLoadingPhoto(number, slot, file, uploadKey, reviewRound); ensureScope() const latest = await getLoadingDocuments(number); accept(latest) + if (latest.reviewRound !== reviewRound) { reconciled = true; roundChanged = true; throw new Error('审核轮次已变化,请核对新车辆、司机后重新选择资料') } if (!latest.editable) throw new Error('当前状态不允许修改照片') if (photo.slot !== slot || !photo.fileId) throw new Error('上传结果与照片类别不一致') try { - accept(await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, ...(replaceFileId ? { replaceFileId } : {}) })) + accept(await bindLoadingPhoto(number, slot, { recordVersion: latest.recordVersion, idempotencyKey: crypto.randomUUID(), fileId: photo.fileId, uploadKey, ...(replaceFileId ? { replaceFileId } : {}) })) } catch (cause) { // 保存可能已经提交;先核对当前事实,不重放请求或继续后续图片。 bindingFailed = true @@ -73,7 +76,7 @@ async function upload(slot: LoadingPhotoSlot, event: Event, replaceFileId?: stri } ElMessage.success(`已保存${saved}张照片`) } catch (cause) { - if (active) error.value = getApiErrorDetails(cause, bindingFailed + if (active) error.value = getApiErrorDetails(cause, roundChanged ? '审核轮次已变化,请核对新车辆、司机后重新选择资料' : bindingFailed ? needsRefresh.value ? '保存结果尚未核实,请重新读取资料' : '保存响应未确认,已读取最新资料,请核对已保存图片后继续' : '图片上传失败,已保存照片保留,请重新选择图片') }