This commit is contained in:
2026-08-28 09:19:24 +08:00
parent 44d05e77cb
commit 4687f17d09
2 changed files with 81 additions and 5 deletions
@@ -54,6 +54,8 @@ function deferred<T>() {
}
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 }
type DocumentVm = { documentOpen: boolean; documentFile: File | null; documentError: { code?: string; requestId?: string } | null; documentUploadKey: number; documentRefreshWarning: { code?: string } | null; selectDocumentFile: (file: { raw: File }) => void; saveDocument: () => Promise<void> }
function documentVm(wrapper: VueWrapper) { return wrapper.vm as unknown as DocumentVm }
describe('ContainerDetailView', () => {
beforeEach(() => {
@@ -193,6 +195,63 @@ describe('ContainerDetailView', () => {
wrapper.unmount()
})
it.each([
['DELIVERY_ORDER', 'DO 资料'],
['PICKUP_ORDER', 'PU 资料'],
] as const)('optimistically replaces %s, closes before overview refresh and preserves the returned file over a stale overview', async (documentType, label) => {
const existing = { id: `old-${documentType}`, documentType, fileId: `old-file-${documentType}`, fileName: `old-${documentType}.pdf`, fileHash: null, uploadedBy: '1', uploadedByName: '旧操作员', uploadedAt: '2026-08-26T10:00:00', version: 1, completed: false }
const initial = { ...overview, documents: [...overview.documents, existing] }
const uploaded = { id: `new-${documentType}`, documentType, fileId: `new-file-${documentType}`, fileName: `new-${documentType}.pdf`, fileHash: 'hash', uploadedBy: '2', uploadedByName: '新操作员', uploadedAt: '2026-08-27T13:00:00', completedBy: null, completedByName: null, completedAt: null, version: 2 }
const refresh = deferred<typeof initial>()
vi.mocked(getContainerOverview).mockResolvedValueOnce(initial as never).mockReturnValueOnce(refresh.promise as never)
vi.mocked(uploadContainerDocument).mockResolvedValueOnce(uploaded as never)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, label, '更新附件').trigger('click'); await flushPromises()
const file = new File([documentType], `new-${documentType}.pdf`, { type: 'application/pdf' })
documentVm(wrapper).selectDocumentFile({ raw: file })
const request = documentVm(wrapper).saveDocument(); await flushPromises()
expect(uploadContainerDocument).toHaveBeenCalledWith('2092438843630391297', documentType, file, 1, `上传${label}`)
expect(documentVm(wrapper).documentOpen).toBe(false)
expect(documentVm(wrapper).documentFile).toBeNull()
expect(documentArticle(wrapper, label).text()).toContain(`new-${documentType}.pdf`)
expect(documentArticle(wrapper, label).text()).toContain('新操作员')
refresh.resolve(initial); await request; await flushPromises()
expect(documentArticle(wrapper, label).text()).toContain(`new-${documentType}.pdf`)
expect(documentVm(wrapper).documentRefreshWarning).toMatchObject({ code: 'DOCUMENT_OVERVIEW_STALE' })
wrapper.unmount()
})
it('rebuilds the upload picker on reopen and keeps DO failures open with the selected file and API evidence', async () => {
const existing = { id: 'do-old', documentType: 'DELIVERY_ORDER', fileId: 'do-file-old', fileName: 'old-do.pdf', fileHash: null, uploadedBy: '1', uploadedByName: '旧操作员', uploadedAt: '2026-08-26T10:00:00', version: 1, completed: false }
vi.mocked(getContainerOverview).mockResolvedValue({ ...overview, documents: [...overview.documents, existing] } as never)
vi.mocked(uploadContainerDocument)
.mockRejectedValueOnce(new ApiResponseError('版本冲突', 'A0409', undefined, undefined, 'request-do-409', 'trace-do-409'))
.mockResolvedValueOnce(null)
const wrapper = mountView(); await flushPromises()
await documentButton(wrapper, 'DO 资料', '更新附件').trigger('click'); await flushPromises()
const firstKey = documentVm(wrapper).documentUploadKey
const file = new File(['do'], 'next-do.pdf', { type: 'application/pdf' })
documentVm(wrapper).selectDocumentFile({ raw: file })
await documentVm(wrapper).saveDocument(); await flushPromises()
expect(documentVm(wrapper).documentOpen).toBe(true)
expect(documentVm(wrapper).documentFile).toBe(file)
expect(documentVm(wrapper).documentError).toMatchObject({ code: 'A0409', requestId: 'request-do-409' })
expect(wrapper.text()).toContain('trace-do-409')
documentVm(wrapper).documentOpen = false; await flushPromises()
await documentButton(wrapper, 'PU 资料', '上传附件').trigger('click'); await flushPromises()
expect(documentVm(wrapper).documentUploadKey).toBeGreaterThan(firstKey)
expect(documentVm(wrapper).documentFile).toBeNull()
expect(documentVm(wrapper).documentError).toBeNull()
expect(wrapper.text()).not.toContain('request-do-409')
const puFile = new File(['pu'], 'next-pu.pdf', { type: 'application/pdf' })
documentVm(wrapper).selectDocumentFile({ raw: puFile })
await documentVm(wrapper).saveDocument(); await flushPromises()
expect(documentVm(wrapper).documentOpen).toBe(true)
expect(documentVm(wrapper).documentFile).toBe(puFile)
expect(uploadContainerDocument).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('downloads destination clearance through its dedicated endpoint and keeps completed rows download-only', async () => {
const completed = { id: '351', documentType: 'DESTINATION_CLEARANCE', fileId: '2092438843630393502', fileName: 'destination-clearance.pdf', fileHash: null, uploadedBy: '3', uploadedByName: '清关员', uploadedAt: '2026-08-26T11:00:00', version: 2, completed: true }
vi.mocked(getContainerOverview).mockResolvedValueOnce({ ...overview, documents: [...overview.documents, completed] } as never)
@@ -47,6 +47,7 @@ const documentOpen = ref(false)
type UploadDocumentType = 'DELIVERY_ORDER' | 'PICKUP_ORDER' | 'CUSTOMS_DECLARATION'
const documentType = ref<UploadDocumentType>('DELIVERY_ORDER')
const documentFile = ref<File | null>(null)
const documentUploadKey = ref(0)
const documentSaving = ref(false)
const documentDownloading = ref(false)
const documentError = ref<ApiErrorDetails | null>(null)
@@ -194,7 +195,7 @@ function openDocument(type: string) {
if (!['DELIVERY_ORDER', 'PICKUP_ORDER', 'CUSTOMS_DECLARATION'].includes(type)) return
if (type === 'CUSTOMS_DECLARATION' ? !canManageCustoms.value : !canEdit.value) return
documentType.value = type as UploadDocumentType
documentFile.value = null; documentError.value = null; documentOpen.value = true
documentFile.value = null; documentError.value = null; documentRefreshWarning.value = null; documentUploadKey.value += 1; documentOpen.value = true
if (type === 'CUSTOMS_DECLARATION' && !overview.value?.loadingPlanId) {
documentError.value = { code: 'LOADING_PLAN_REQUIRED', message: '当前货柜没有装柜清单,无法上传国内出口报关附件' }
}
@@ -222,10 +223,26 @@ async function saveDocument() {
}
catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, '附件已上传,但最新资料刷新失败;当前已保留本次上传结果') }
} else {
await uploadContainerDocument(containerId.value, documentType.value, documentFile.value, expectedVersion, `上传${documentTitle.value}`)
overview.value = await getContainerOverview(containerId.value)
const targetType = documentType.value
const targetTitle = documentTitle.value
const uploaded = await uploadContainerDocument(containerId.value, targetType, documentFile.value, expectedVersion, `上传${targetTitle}`)
if (!uploaded) throw new Error(`${targetTitle}上传成功但未返回附件信息`)
const optimistic = toOverviewDocument(uploaded, targetType, selectedDocument.value)
replaceOverviewDocument(optimistic)
documentOpen.value = false; documentFile.value = null; documentError.value = null; documentUploadKey.value += 1
ElMessage.success(`${targetTitle}已上传`)
try {
const refreshed = await getContainerOverview(containerId.value)
const current = refreshed.documents.find((item) => item.documentType === targetType)
if (current && (current.fileId === optimistic.fileId || current.version > optimistic.version)) overview.value = refreshed
else {
overview.value = { ...refreshed, documents: [...refreshed.documents.filter((item) => item.documentType !== targetType), optimistic] }
documentRefreshWarning.value = { code: 'DOCUMENT_OVERVIEW_STALE', message: `${targetTitle}已上传,但资料汇总尚未同步;当前已保留本次上传结果` }
}
} catch (cause) { documentRefreshWarning.value = getApiErrorDetails(cause, `${targetTitle}已上传,但最新资料刷新失败;当前已保留本次上传结果`) }
return
}
documentOpen.value = false; documentFile.value = null; ElMessage.success(`${documentTitle.value}已上传`)
documentOpen.value = false; documentFile.value = null; documentUploadKey.value += 1; ElMessage.success(`${documentTitle.value}已上传`)
} catch (cause) {
const details = getApiErrorDetails(cause, `${documentTitle.value}上传失败`)
if (details.code === 'A0409') {
@@ -388,7 +405,7 @@ onMounted(async () => { await load(); if (route.query.action === 'documents') op
<div class="surface-panel carrier-panel"><CarrierScheduleQueryPanel :key="carrierRefreshKey" :ocean-booking-id="detail.oceanBookingId" :container-booking-id="detail.id" :warehouse-timeline="overview?.timeline || []" /></div>
</template>
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="documentOpen" :title="documentTitle" width="min(520px, calc(100vw - 28px))" confirm-text="上传资料" :confirm-loading="documentSaving" :confirm-disabled="!documentFile" @confirm="saveDocument"><el-form label-position="top"><el-form-item :label="documentTitle"><el-upload :key="documentUploadKey" :auto-upload="false" :limit="1" :on-change="selectDocumentFile" :on-remove="removeDocumentFile"><el-button><FileUp :size="16" />选择文件</el-button><template #tip><div class="el-upload__tip">{{ selectedDocument ? '上传新文件将替换当前资料并保留历史。' : `请选择需要提交的${documentTitle}。` }}</div></template></el-upload></el-form-item></el-form><LoadErrorState v-if="documentError" compact title="资料上传失败" :message="documentError.message" :show-retry="false"><template #details><ApiErrorMeta :details="documentError" /></template></LoadErrorState></AppDialog>
<AppDialog v-model="exceptionOpen" :title="exceptionTitle" width="min(520px, calc(100vw - 28px))" :confirm-text="overview?.exception.active ? '确认取消' : '确认标记'" :confirm-loading="exceptionSaving" :confirm-disabled="!exceptionReason.trim()" @confirm="saveException"><el-alert :title="overview?.exception.active ? '取消备注为必填项,提交后将保留历史记录。' : '异常原因将同步记录在货柜详情和操作日志中。'" :type="overview?.exception.active ? 'warning' : 'error'" :closable="false" /><el-form label-position="top"><el-form-item :label="overview?.exception.active ? '取消备注' : '异常原因'" required><el-input v-model="exceptionReason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item></el-form><LoadErrorState v-if="exceptionError" compact title="异常处理失败" :message="exceptionError.message" :show-retry="false" /></AppDialog>
<AppDialog v-model="clearanceOpen" title="目的港清关资料" width="min(520px, calc(100vw - 28px))" :loading="clearanceLoading" :show-confirm="!clearanceDocument?.completedAt" confirm-text="上传资料" :confirm-loading="clearanceSaving" :confirm-disabled="!clearanceFile" @confirm="uploadClearance"><el-alert v-if="clearanceDocument?.completedAt" type="success" :closable="false" :title="`目的港清关已完成:${clearanceDocument.fileName}`" /><template v-else><el-form label-position="top"><el-form-item label="目的港清关资料"><el-upload :auto-upload="false" :limit="1" :on-change="selectClearanceFile" :on-remove="removeClearanceFile"><el-button><FileUp :size="16" />选择文件</el-button></el-upload></el-form-item></el-form><el-button v-if="clearanceDocument" type="success" plain :loading="clearanceSaving" @click="completeClearance">确认目的港清关完成</el-button></template><LoadErrorState v-if="clearanceError" compact title="目的港清关资料操作失败" :message="clearanceError.message" :show-retry="false"><template #details><ApiErrorMeta :details="clearanceError" /></template></LoadErrorState></AppDialog>
</AdminPage>