This commit is contained in:
2026-08-27 15:02:41 +08:00
parent 24575223a0
commit 00045a9764
2 changed files with 98 additions and 12 deletions
@@ -2,7 +2,7 @@ import ElementPlus, { ElMessageBox } from 'element-plus'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import ContainerDetailView from './ContainerDetailView.vue' 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 { completeLoadingDeclaration, downloadLoadingDeclaration, getLoadingDeclaration, uploadLoadingDeclaration } from '@/modules/loading-lists'
import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch' import { confirmContainerOutboundDispatch, getContainerOutboundDispatch } from '@/modules/outbound-dispatch/api/platformOutboundDispatch'
import type { ContainerOutboundDispatchDetail } from '@/modules/outbound-dispatch/types' 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 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 documentButton(wrapper: VueWrapper, label: string, buttonText: string) { return documentArticle(wrapper, label).findAll('button').find((button) => button.text().includes(buttonText))! }
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<void>; completeClearance: () => Promise<void> }
function clearanceVm(wrapper: VueWrapper) { return wrapper.vm as unknown as ClearanceVm }
describe('ContainerDetailView', () => { describe('ContainerDetailView', () => {
beforeEach(() => { beforeEach(() => {
@@ -163,18 +170,26 @@ describe('ContainerDetailView', () => {
wrapper.unmount() 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 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 updated = { ...existing, fileId: '303', fileName: 'clearance-v2.pdf', uploadedAt: '2026-08-26T13:00:00', version: 2 }
const refresh = deferred<typeof overview>()
vi.mocked(getContainerOverview).mockResolvedValueOnce(overview as never).mockReturnValueOnce(refresh.promise as never)
vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never) vi.mocked(getContainerClearance).mockResolvedValueOnce(existing as never)
vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never) vi.mocked(uploadContainerClearance).mockResolvedValueOnce(updated as never)
const wrapper = mountView(); await flushPromises() const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' }) const file = new File(['clearance'], 'clearance-v2.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file }) clearanceVm(wrapper).selectClearanceFile({ raw: file })
await (wrapper.vm as unknown as { uploadClearance: () => Promise<void> }).uploadClearance(); await flushPromises() const request = clearanceVm(wrapper).uploadClearance(); await flushPromises()
expect(uploadContainerClearance).toHaveBeenCalledWith('2092438843630391297', file, 1, '上传目的港清关资料') 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') expect(documentArticle(wrapper, '目的港清关资料').text()).toContain('clearance-v2.pdf')
refresh.resolve(overview)
await request; await flushPromises()
expect(clearanceVm(wrapper).documentRefreshWarning).toBeNull()
wrapper.unmount() wrapper.unmount()
}) })
@@ -196,17 +211,75 @@ describe('ContainerDetailView', () => {
it('refreshes destination clearance after A0409 and preserves the server file and error metadata', async () => { 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 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 } 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')) vi.mocked(uploadContainerClearance).mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-clearance-409', 'trace-clearance-409'))
const wrapper = mountView(); await flushPromises() const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises() await documentButton(wrapper, '目的港清关资料', '上传附件').trigger('click'); await flushPromises()
const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' }) const file = new File(['clearance'], 'clearance-next.pdf', { type: 'application/pdf' })
;(wrapper.vm as unknown as { selectClearanceFile: (file: { raw: File }) => void }).selectClearanceFile({ raw: file }) clearanceVm(wrapper).selectClearanceFile({ raw: file })
await (wrapper.vm as unknown as { uploadClearance: () => Promise<void> }).uploadClearance(); await flushPromises() await clearanceVm(wrapper).uploadClearance(); await flushPromises()
expect(uploadContainerClearance).toHaveBeenCalledOnce() 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(documentArticle(wrapper, '目的港清关资料').text()).toContain('server-clearance-v2.pdf')
expect(wrapper.text()).toContain('A0409') expect(wrapper.text()).toContain('A0409')
expect(wrapper.text()).toContain('request-clearance-409') 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<typeof overview>()
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() wrapper.unmount()
}) })
@@ -303,7 +303,7 @@ async function saveException() {
} }
async function openClearance() { 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) } try { clearanceDocument.value = await getContainerClearance(containerId.value) }
catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details } catch (cause) { const details = getApiErrorDetails(cause); if (details.code !== 'A0404') clearanceError.value = details }
finally { clearanceLoading.value = false } finally { clearanceLoading.value = false }
@@ -312,17 +312,18 @@ function selectClearanceFile(file: UploadFile) { clearanceFile.value = file.raw
function removeClearanceFile() { clearanceFile.value = null } function removeClearanceFile() { clearanceFile.value = null }
async function uploadClearance() { async function uploadClearance() {
if (!clearanceFile.value) { clearanceError.value = { code: 'FILE_REQUIRED', message: '请选择目的港清关资料文件' }; return } 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 { try {
const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料') const uploaded = await uploadContainerClearance(containerId.value, clearanceFile.value, clearanceDocument.value?.version || 0, '上传目的港清关资料')
if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息') if (!uploaded) throw new Error('目的港清关资料上传成功但未返回附件信息')
clearanceDocument.value = uploaded clearanceDocument.value = uploaded
replaceOverviewDocument(toOverviewDocument(uploaded, 'DESTINATION_CLEARANCE', overview.value?.documents.find((item) => item.documentType === 'DESTINATION_CLEARANCE'))) 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 { try {
const refreshed = await getContainerOverview(containerId.value) 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')] } 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, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') } } catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '目的港清关资料已上传,但最新资料刷新失败;当前已保留本次上传结果') }
clearanceFile.value = null; ElMessage.success('目的港清关资料已上传')
} catch (cause) { } catch (cause) {
const details = getApiErrorDetails(cause, '目的港清关资料上传失败') const details = getApiErrorDetails(cause, '目的港清关资料上传失败')
if (details.code === 'A0409') { if (details.code === 'A0409') {
@@ -334,8 +335,20 @@ async function uploadClearance() {
} }
async function completeClearance() { async function completeClearance() {
if (!clearanceDocument.value) return if (!clearanceDocument.value) return
clearanceSaving.value = true; clearanceError.value = null 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; replaceOverviewDocument(toOverviewDocument(completed, 'DESTINATION_CLEARANCE')); overview.value = await getContainerOverview(containerId.value); ElMessage.success('已完成清关') } 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, '清关完成失败') } catch (cause) { clearanceError.value = getApiErrorDetails(cause, '清关完成失败') }
finally { clearanceSaving.value = false } finally { clearanceSaving.value = false }
} }