fix(dispatch): reject stale loading document upload rounds

This commit is contained in:
2026-10-26 19:21:13 +08:00
parent a9dc19a86b
commit 0137fd3a4c
3 changed files with 28 additions and 10 deletions
@@ -5,14 +5,14 @@ export const loadingPhotoSlots = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTUR
export type LoadingPhotoSlot = typeof loadingPhotoSlots[number]
export const loadingPhotoLabels: Record<LoadingPhotoSlot, string> = { 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<LoadingDocuments>, ApiEnvelope<LoadingDocuments>>(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<LoadingPhoto>, ApiEnvelope<LoadingPhoto>>(`${path(no)}/${slot}/uploads`, form))
}
export async function recoverLoadingPhoto(no: string, key: string) {
@@ -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<typeof api>(), 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> = {}): api.LoadingDocuments => ({ dispatchNo: 'PC/1', recordVersion: '0', dispatchRecordVersion: '3', stage: 'AFTER_LOADING', editable: true, complete: false, photos: [], ...extra })
const data = (extra: Partial<api.LoadingDocuments> = {}): 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<typeof setup>, 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(() => {})
@@ -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 ? '保存结果尚未核实,请重新读取资料' : '保存响应未确认,已读取最新资料,请核对已保存图片后继续'
: '图片上传失败,已保存照片保留,请重新选择图片')
}