diff --git a/src/modules/container-loading/pages/ContainerDetailView.test.ts b/src/modules/container-loading/pages/ContainerDetailView.test.ts index 29e2c4b2..b6dcdefe 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.test.ts +++ b/src/modules/container-loading/pages/ContainerDetailView.test.ts @@ -2,7 +2,7 @@ import ElementPlus, { ElMessageBox } from 'element-plus' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import ContainerDetailView from './ContainerDetailView.vue' -import { downloadContainerClearance, downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' +import { completeContainerClearance, downloadContainerClearance, downloadContainerDocument, getContainer, getContainerClearance, getContainerOverview, uploadContainerClearance, uploadContainerDocument } from '../api/platformContainerLoading' import { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists' import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types' @@ -47,6 +47,13 @@ function mountView(customs = true, outbound = true) { } function documentArticle(wrapper: VueWrapper, label: string) { return wrapper.findAll('.document-table tbody tr').find((item) => item.text().includes(label))! } function documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! } +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { resolve = resolvePromise }) + return { promise, resolve } +} +type ClearanceVm = { clearanceOpen: boolean; clearanceFile: File | null; clearanceError: { code?: string; requestId?: string } | null; documentRefreshWarning: unknown; selectClearanceFile: (file: { raw: File }) => void; uploadClearance: () => Promise; completeClearance: () => Promise } +function clearanceVm(wrapper: VueWrapper) { return wrapper.vm as unknown as ClearanceVm } describe('ContainerDetailView', () => { beforeEach(() => { @@ -163,18 +170,26 @@ describe('ContainerDetailView', () => { wrapper.unmount() }) - it('uses the independent destination document version and immediately replaces the table row after update', async () => { + it('closes immediately after a valid destination upload response, clears dialog state and refreshes after optimistic table update', async () => { const existing = { id: '301', documentType: 'DESTINATION_CLEARANCE', fileId: '302', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 } const updated = { ...existing, fileId: '303', fileName: 'clearance-v2.pdf', uploadedAt: '2026-08-26T13:00:00', version: 2 } + const refresh = deferred() + vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockReturnValueOnce(refresh.promise as never) vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never) vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never) const wrapper = mountView(); await flushPromises() await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' }) - ;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file }) - await (wrapper.vm as unknown as { uploadClearance: () => Promise }).uploadClearance(); await flushPromises() + clearanceVm(wrapper).selectClearanceFile({ raw: file }) + const request = clearanceVm(wrapper).uploadClearance(); await flushPromises() expect(uploadContainerClearance).toHaveBeenCalledWith('2092438843630391297', file, 1, '上传目的港清关资料') + expect(clearanceVm(wrapper).clearanceOpen).toBe(false) + expect(clearanceVm(wrapper).clearanceFile).toBeNull() + expect(clearanceVm(wrapper).clearanceError).toBeNull() expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('clearance-v2.pdf') + refresh.resolve(overview) + await request; await flushPromises() + expect(clearanceVm(wrapper).documentRefreshWarning).toBeNull() wrapper.unmount() }) @@ -196,17 +211,75 @@ describe('ContainerDetailView', () => { it('refreshes destination clearance after A0409 and preserves the server file and error metadata', async () => { const existing = { id: '401', documentType: 'DESTINATION_CLEARANCE', fileId: '402', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 } const server = { ...existing, fileId: '403', fileName: 'server-clearance-v2.pdf', uploadedByName: '服务端清关员', version: 2 } - vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never).mockResolvedValueOnce(server as never) + vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never).mockResolvedValueOnce(server as never).mockResolvedValueOnce(server as never) vi.mocked(uploadContainerClearance).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-clearance-409', 'trace-clearance-409')) const wrapper = mountView(); await flushPromises() await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' }) - ;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file }) - await (wrapper.vm as unknown as { uploadClearance: () => Promise }).uploadClearance(); await flushPromises() + clearanceVm(wrapper).selectClearanceFile({ raw: file }) + await clearanceVm(wrapper).uploadClearance(); await flushPromises() expect(uploadContainerClearance).toHaveBeenCalledOnce() + expect(clearanceVm(wrapper).clearanceOpen).toBe(true) + expect(clearanceVm(wrapper).clearanceFile).toBe(file) + expect(clearanceVm(wrapper).clearanceError).toMatchObject({ code: 'A0409', requestId: 'request-clearance-409' }) expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('server-clearance-v2.pdf') expect(wrapper.text()).toContain('A0409') expect(wrapper.text()).toContain('request-clearance-409') + clearanceVm(wrapper).clearanceOpen = false; await flushPromises() + await documentButton(wrapper, '目的港清关资料', '更新附件').trigger('click'); await flushPromises() + expect(clearanceVm(wrapper).clearanceOpen).toBe(true) + expect(clearanceVm(wrapper).clearanceFile).toBeNull() + expect(clearanceVm(wrapper).clearanceError).toBeNull() + expect(wrapper.text()).not.toContain('request-clearance-409') + wrapper.unmount() + }) + + it('keeps the destination dialog and selected file for network and empty-response upload failures', async () => { + const existing = { id: '501', documentType: 'DESTINATION_CLEARANCE', fileId: '502', fileName: 'clearance-v1.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 } + vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never) + vi.mocked(uploadContainerClearance) + .mockRejectedValueOnce(new ApiResponseError('网络请求失败', 'NETWORK_ERROR', undefined, undefined, 'request-network', 'trace-network')) + .mockResolvedValueOnce(null) + const wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() + const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' }) + clearanceVm(wrapper).selectClearanceFile({ raw: file }) + await clearanceVm(wrapper).uploadClearance(); await flushPromises() + expect(clearanceVm(wrapper).clearanceOpen).toBe(true) + expect(clearanceVm(wrapper).clearanceFile).toBe(file) + expect(wrapper.text()).toContain('request-network') + await clearanceVm(wrapper).uploadClearance(); await flushPromises() + expect(clearanceVm(wrapper).clearanceOpen).toBe(true) + expect(clearanceVm(wrapper).clearanceFile).toBe(file) + expect(uploadContainerClearance).toHaveBeenCalledTimes(2) + wrapper.unmount() + }) + + it('closes after successful destination clearance completion but keeps the dialog open on completion failure', async () => { + const existing = { id: '601', documentType: 'DESTINATION_CLEARANCE', fileId: '602', fileName: 'clearance.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', completedBy: null, completedByName: null, completedAt: null, version: 1 } + const completed = { ...existing, completedBy: '4', completedByName: '复核员', completedAt: '2026-08-27T12:00:00', version: 2 } + const refresh = deferred() + vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockReturnValueOnce(refresh.promise as never) + vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never) + vi.mocked(completeContainerClearance).mockResolvedValueOnce(completed as never) + let wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() + const request = clearanceVm(wrapper).completeClearance(); await flushPromises() + expect(clearanceVm(wrapper).clearanceOpen).toBe(false) + expect(clearanceVm(wrapper).clearanceError).toBeNull() + expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('已完成') + refresh.resolve(overview); await request; await flushPromises() + wrapper.unmount() + + vi.mocked(getContainerOverview).mockResolvedValue(overview as never) + vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never) + vi.mocked(completeContainerClearance).mockRejectedValueOnce(new ApiResponseError('完成失败', 'A0409', undefined, undefined, 'request-complete-409', 'trace-complete-409')) + wrapper = mountView(); await flushPromises() + await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() + await clearanceVm(wrapper).completeClearance(); await flushPromises() + expect(clearanceVm(wrapper).clearanceOpen).toBe(true) + expect(clearanceVm(wrapper).clearanceError).toMatchObject({ code: 'A0409', requestId: 'request-complete-409' }) + expect(wrapper.text()).toContain('trace-complete-409') wrapper.unmount() }) diff --git a/src/modules/container-loading/pages/ContainerDetailView.vue b/src/modules/container-loading/pages/ContainerDetailView.vue index 8f2386d3..5f2a9743 100644 --- a/src/modules/container-loading/pages/ContainerDetailView.vue +++ b/src/modules/container-loading/pages/ContainerDetailView.vue @@ -303,7 +303,7 @@ async function saveException() { } async function openClearance() { - clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; clearanceLoading.value = true + clearanceOpen.value = true; clearanceDocument.value = null; clearanceFile.value = null; clearanceError.value = null; documentRefreshWarning.value = null; clearanceLoading.value = true try { clearanceDocument.value = await getContainerClearance(containerId.value) } catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details } finally { clearanceLoading.value = false } @@ -312,17 +312,18 @@ function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw function removeClearanceFile() { clearanceFile.value = null } async function uploadClearance() { if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return } - clearanceSaving.value = true; clearanceError.value = null + clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null try { const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料') if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息') clearanceDocument.value = uploaded replaceOverviewDocument(toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE', overview.value?.documents.find((item) => item.documentType === 'DESTINATION_CLEARANCE'))) + clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null + ElMessage.success('目的港清关资料已上传') try { const refreshed = await getContainerOverview(containerId.value) overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE')] } } catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') } - clearanceFile.value = null; ElMessage.success('目的港清关资料已上传') } catch (cause) { const details = getApiErrorDetails(cause, '目的港清关资料上传失败') if (details.code === 'A0409') { @@ -334,8 +335,20 @@ async function uploadClearance() { } async function completeClearance() { if (!clearanceDocument.value) return - clearanceSaving.value = true; clearanceError.value = null - try { const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成'); if (!completed) throw new Error('确认清关完成但未返回附件信息'); clearanceDocument.value = completed; replaceOverviewDocument(toOverviewDocument(completed, 'DESTINATION_CLEARANCE')); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') } + clearanceSaving.value = true; clearanceError.value = null; documentRefreshWarning.value = null + try { + const completed = await completeContainerClearance(containerId.value, clearanceDocument.value.version, '确认目的港清关完成') + if (!completed) throw new Error('确认清关完成但未返回附件信息') + clearanceDocument.value = completed + const optimistic = toOverviewDocument(completed, 'DESTINATION_CLEARANCE') + replaceOverviewDocument(optimistic) + clearanceOpen.value = false; clearanceFile.value = null; clearanceError.value = null + ElMessage.success('已完成清关') + try { + const refreshed = await getContainerOverview(containerId.value) + overview.value = refreshed.documents.some((item) => item.documentType === 'DESTINATION_CLEARANCE') ? refreshed : { ...refreshed, documents: [...refreshed.documents, optimistic] } + } catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关已完成,但最新资料刷新失败;当前已保留完成事实') } + } catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') } finally { clearanceSaving.value = false } }