import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import ElementPlus, { ElMessageBox } from 'element-plus' import AppDialog from '@/shared/components/AppDialog.vue' import { ApiResponseError, http } from '@/shared/api/http' import type { ContainerCustomsStatus, ContainerCustomsView, DeclarationBatch, ClearanceResultType } from '../api/platformContainerCustoms' const api = vi.hoisted(() => ({ get: vi.fn(), start: vi.fn(), completeAllBuy: vi.fn(), sendToBroker: vi.fn(), getDeclarationBatches: vi.fn(), upload: vi.fn(), downloadSource: vi.fn(), downloadReceipt: vi.fn(), downloadFinal: vi.fn(), recordResult: vi.fn(), })) vi.mock('../api/platformContainerCustoms', () => ({ getContainerCustoms: api.get, startContainerCustoms: api.start, completeAllBuyContainerCustoms: api.completeAllBuy, uploadContainerCustomsReceipt: api.upload, downloadContainerCustomsDocument: api.downloadSource, downloadContainerCustomsReceipt: api.downloadReceipt, downloadContainerCustomsFinal: api.downloadFinal, submitContainerCustomsToBroker: vi.fn(), sendContainerCustomsToBroker: api.sendToBroker, getDeclarationBatches: api.getDeclarationBatches, recordClearanceResult: api.recordResult, })) vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() })) import ContainerCustomsPanel from './ContainerCustomsPanel.vue' function customs(status: ContainerCustomsStatus, hasCompletion = false): ContainerCustomsView { return { aggregateId: '2092438843630391401', loadingPlanId: '2092438843630391402', containerBookingId: '2092438843630391403', planNo: 'LP-1', cabinetNo: 'MCR08290001', containerNo: 'MSCU-1', status, hasActualCargo: true, version: 3, taxRefundCount: 1, coveredTaxRefundCount: 1, actualBoxCount: 12, inbounds: [{ inboundId: '2092438843630391404', orderId: '2092438843630391405', orderNo: 'O-1', inboundCode: 'RK-1', tenantId: '2092438843630391406', customerName: '客户一', taxMode: 'TAX_REFUND', covered: true }], documents: [{ documentId: '2092438843630391407', tenantId: '2092438843630391406', source: { fileId: '2092438843630391408', fileName: '客户退税资料.pdf', fileHash: 'source-hash', sizeBytes: 12, version: 2, uploadedAt: '2026-08-29T10:00:00' }, inboundIds: ['2092438843630391404'], inboundCodes: ['RK-1'], frozen: true, receipt: hasCompletion ? { fileId: '2092438843630391409', fileName: '报关行回执.pdf', fileHash: 'receipt-hash', sizeBytes: 13, version: 1, uploadedAt: '2026-08-29T11:00:00' } : null, finalDocument: hasCompletion ? { fileId: '2092438843630391410', fileName: '真实报关完税文件.pdf', fileHash: 'final-hash', sizeBytes: 14, version: 1, uploadedAt: '2026-08-29T12:00:00' } : null, confirmationHistory: hasCompletion ? [{ id: '2092438843630391411', receiptVersion: 1, action: 'CONFIRM', reason: null, operatorName: '客户一', createdAt: '2026-08-29T11:15:00' }] : [], }], brokerSubmission: hasCompletion ? { id: '2092438843630391412', customsContainerId: '2092438843630391401', loadingPlanId: '2092438843630391402', batchNo: 'BS-20260829-001', status: 'SENT', sourceVersion: 3, sentByName: '平台员', sentAt: '2026-08-29T10:45:00', version: 1 } : null, startedAt: status === 'DECLARING' || status === 'COMPLETED' ? '2026-08-29T10:30:00' : null, completedAt: status === 'COMPLETED' ? '2026-08-29T11:30:00' : null, } } async function render(status: ContainerCustomsStatus, canManage = true, hasCompletion = false) { api.get.mockResolvedValue(customs(status, hasCompletion)) const wrapper = mount(ContainerCustomsPanel, { props: { loadingPlanId: '2092438843630391402', canList: true, canManage, departureState: 'WAITING_OTHER_GATES' }, global: { plugins: [ElementPlus] }, }) await flushPromises() return wrapper } function buttonTexts(wrapper: Awaited>) { return wrapper.findAll('button').map((button) => button.text()) } describe('ContainerCustomsPanel', () => { beforeEach(() => { vi.clearAllMocks() api.getDeclarationBatches.mockResolvedValue([]) api.downloadSource.mockResolvedValue(new Blob(['source'])) api.downloadReceipt.mockResolvedValue(new Blob(['receipt'])) api.downloadFinal.mockResolvedValue(new Blob(['final'])) Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:customs-file') }) Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() }) vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) }) it('保留其他原因必填与批次版本,统一窗口确认后仍提交原始结果', async () => { const batch: DeclarationBatch = { id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402', batchNo: 'DEC-TEST-001', status: 'SUBMITTED', sourceVersion: 3, customsEntryNo: 'ENTRY-001', version: 7, submittedAt: null, submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null, results: [], } api.getDeclarationBatches.mockResolvedValue([batch]) api.get.mockResolvedValue({ ...customs('DECLARING', true), brokerSubmittedAt: '2026-10-19T09:00:00' }) const wrapper = mount(ContainerCustomsPanel, { props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' }, global: { plugins: [ElementPlus] }, }) await flushPromises() expect(wrapper.text()).toContain('ENTRY-001') expect(wrapper.text()).not.toContain('版本') await wrapper.findAll('button').find(button => button.text() === '标记海关异常/结果')!.trigger('click') const state = (wrapper.vm.$ as unknown as { setupState: { clearanceBatch: DeclarationBatch; clearanceResultType: ClearanceResultType; clearanceRemark: string; clearanceDialogVisible: boolean } }).setupState state.clearanceResultType = 'OTHER' await flushPromises() const dialog = wrapper.getComponent(AppDialog) expect(dialog.props('title')).toBe('标记海关异常/结果 · DEC-TEST-001') expect(dialog.props('lockWhileSubmitting')).toBe(false) dialog.vm.$emit('confirm') await flushPromises() expect(api.recordResult).not.toHaveBeenCalled() state.clearanceRemark = ' 已核实海关通知 ' dialog.vm.$emit('confirm') await flushPromises() expect(api.recordResult).toHaveBeenCalledWith(batch, 'OTHER', '已核实海关通知') expect(dialog.props('modelValue')).toBe(false) wrapper.unmount() }) it('开始报关确认使用简短文案,取消不会冻结资料或调用开始接口', async () => { const confirm = vi.spyOn(ElMessageBox, 'confirm').mockRejectedValueOnce('cancel') const wrapper = await render('READY_TO_START') await wrapper.findAll('button').find(button => button.text() === '开始报关')!.trigger('click') await flushPromises() expect(confirm).toHaveBeenCalledWith('确认开始整柜报送?', '整柜报关', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认' })) expect(api.start).not.toHaveBeenCalled() confirm.mockRestore() wrapper.unmount() }) it('renders all customs result reasons in Chinese with the recorded remarks', async () => { const reasons: Array<[ClearanceResultType, string]> = [ ['INSPECTION', '查验'], ['SUPPLEMENT_REQUIRED', '补资料'], ['DETAINED', '扣货'], ['RELEASED', '放行'], ['OTHER', '其他'], ] const batch: DeclarationBatch = { id: '101', customsContainerId: '102', loadingPlanId: '2092438843630391402', batchNo: 'DEC-TEST-001', status: 'RESULT_PENDING', sourceVersion: 1, customsEntryNo: 'ENTRY-TEST-001', version: 1, submittedAt: null, submittedByName: null, acknowledgedAt: null, acknowledgedByName: null, remark: null, results: reasons.map(([resultType], index) => ({ id: String(200 + index), declarationBatchId: '101', customsContainerId: '102', resultType, resultStatus: 'OPEN', customsEntryNo: 'ENTRY-TEST-001', occurredAt: '2026-09-29T10:00:00', resolution: `人工备注${index}`, evidenceFileId: null, recordedByName: '操作员', recordedAt: '2026-09-29T10:00:00', previousResultId: null, })), } api.getDeclarationBatches.mockResolvedValue([batch]) api.get.mockResolvedValue({ ...customs('COMPLETED', true), brokerSubmittedAt: '2026-09-29T09:00:00' }) const wrapper = mount(ContainerCustomsPanel, { props: { loadingPlanId: batch.loadingPlanId, canList: true, canManage: false, departureState: 'WAITING_OTHER_GATES' }, global: { plugins: [ElementPlus] }, }) await flushPromises() const rows = wrapper.findAll('.declaration-result-row') expect(rows).toHaveLength(reasons.length) for (const [index, [code, label]] of reasons.entries()) { expect(rows[index]!.text()).toContain(label) expect(rows[index]!.text()).toContain(`人工备注${index}`) expect(rows[index]!.text()).not.toContain(code) expect(rows[index]!.find('button').exists()).toBe(false) } wrapper.unmount() }) it('keeps READY_TO_START read-only while exposing source file name and download', async () => { const wrapper = await render('READY_TO_START') expect(wrapper.text()).toContain('开始报关') expect(wrapper.find('[aria-label="报关概况"]').exists()).toBe(false) expect(wrapper.text()).toContain('客户退税资料.pdf') expect(wrapper.text()).toContain('客户退税资料') expect(wrapper.text()).toContain('预录单') expect(wrapper.text()).not.toContain('客户报关资料') expect(wrapper.find('[aria-label="下载客户退税资料 客户退税资料.pdf"]').exists()).toBe(true) expect(buttonTexts(wrapper)).not.toContain('上传预录单') expect(buttonTexts(wrapper)).not.toContain('覆盖预录单') expect(buttonTexts(wrapper)).not.toContain('提交') }) it('shows a successful empty state and hides customs actions when the completed plan has no actual cargo', async () => { api.get.mockResolvedValue({ ...customs('READY_TO_START'), hasActualCargo: false, taxRefundCount: 0, coveredTaxRefundCount: 0, actualBoxCount: 0, inbounds: [], documents: [] }) const wrapper = mount(ContainerCustomsPanel, { props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' }, global: { plugins: [ElementPlus] }, }) await flushPromises() expect(wrapper.text()).toContain('当前装柜计划没有实际装柜货物,无需报关') expect(wrapper.text()).toContain('无需报关') expect(buttonTexts(wrapper)).not.toContain('开始报关') expect(buttonTexts(wrapper)).not.toContain('导出买单 XLSX') }) it('shows a pending state instead of an error before loading is completed', async () => { api.get.mockResolvedValue(null) const wrapper = mount(ContainerCustomsPanel, { props: { loadingPlanId: '2097922185010147329', canList: true, canManage: false, departureState: 'WAITING_OTHER_GATES' }, global: { plugins: [ElementPlus] }, }) await flushPromises() expect(wrapper.text()).toContain('当前装柜计划尚未完成,完成装柜后可处理报关') expect(wrapper.text()).not.toContain('整柜报关加载失败') }) it('allows completion upload and overwrite only while DECLARING with manage permission', async () => { let wrapper = await render('DECLARING', true, false) expect(wrapper.text()).toContain('首次发送报关行') expect(wrapper.text()).toContain('首次发送后上传预录单') expect(buttonTexts(wrapper)).not.toContain('上传预录单') wrapper.unmount() wrapper = await render('DECLARING', true, true) expect(wrapper.text()).toContain('发送报关行执行') expect(wrapper.text()).toContain('覆盖预录单') expect(wrapper.text()).toContain('报关行回执.pdf') expect(wrapper.text()).toContain('真实报关完税文件.pdf') expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一') wrapper.unmount() wrapper = await render('DECLARING', false, true) expect(wrapper.text()).toContain('只读') expect(buttonTexts(wrapper)).not.toContain('上传预录单') expect(buttonTexts(wrapper)).not.toContain('覆盖预录单') expect(buttonTexts(wrapper)).not.toContain('提交') }) it('loads declaration batches during DECLARING after broker execution was sent', async () => { const value = { ...customs('DECLARING', true), brokerSubmittedAt: '2026-08-29T11:20:00' } api.get.mockResolvedValue(value) const wrapper = mount(ContainerCustomsPanel, { props: { loadingPlanId: '2092438843630391402', canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' }, global: { plugins: [ElementPlus] }, }) await flushPromises() expect(api.getDeclarationBatches).toHaveBeenCalledWith('2092438843630391402') wrapper.unmount() }) it('隐藏资料版本但保留预录单历史、原文件名和上传并发版本', async () => { const value = customs('DECLARING', true) const document = value.documents[0]! document.receipt!.version = 7 document.receiptHistory = [{ version: 6, file: { ...document.receipt!, version: 6 }, operatorName: '报关员', createdAt: '2026-08-29T10:50:00' }] api.get.mockResolvedValue(value) const wrapper = mount(ContainerCustomsPanel, { props: { loadingPlanId: value.loadingPlanId, canList: true, canManage: true, departureState: 'WAITING_OTHER_GATES' }, global: { plugins: [ElementPlus] }, }) await flushPromises() expect(wrapper.findAll('th').map(header => header.text())).toEqual(['操作', '关联入仓码', '报关行状态', '客户退税资料', '预录单', '真实报关完税文件', '客户', '预录单情况']) expect(wrapper.text()).not.toMatch(/版本|退税资料 v|回执 v|完税 v/) expect(wrapper.text()).toContain('预录单历史:2026/8/29 10:50:00 · 报关员') expect(wrapper.text()).toContain('确认 · 2026/8/29 11:15:00 · 客户一') expect(wrapper.find('[aria-label="下载预录单 报关行回执.pdf"]').exists()).toBe(true) const selected = new File(['receipt'], '新预录单.pdf', { type: 'application/pdf' }) const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void onChange({ raw: selected }) await flushPromises() await wrapper.findAll('button').find(button => button.text() === '提交')!.trigger('click') await flushPromises() expect(api.upload).toHaveBeenCalledWith(document.documentId, selected, 7) wrapper.unmount() }) it('keeps COMPLETED strictly read-only with both files visible and downloadable', async () => { const wrapper = await render('COMPLETED', true, true) expect(wrapper.text()).toContain('客户退税资料.pdf') expect(wrapper.text()).toContain('报关行回执.pdf') expect(wrapper.text()).toContain('真实报关完税文件.pdf') expect(wrapper.text()).toContain('报关已完成,待其他离仓条件') expect(buttonTexts(wrapper)).not.toContain('上传预录单') expect(buttonTexts(wrapper)).not.toContain('覆盖预录单') expect(buttonTexts(wrapper)).not.toContain('提交') }) it('routes source and completion downloads independently by the long document id and original names', async () => { const wrapper = await render('COMPLETED', false, true) await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click') await flushPromises() expect(api.downloadSource).toHaveBeenCalledWith('2092438843630391407') expect(api.downloadReceipt).not.toHaveBeenCalled() await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click') await flushPromises() expect(api.downloadReceipt).toHaveBeenCalledWith('2092438843630391407') await wrapper.get('[aria-label="下载报关完税文件 真实报关完税文件.pdf"]').trigger('click') await flushPromises() expect(api.downloadFinal).toHaveBeenCalledWith('2092438843630391407') expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(3) expect(URL.createObjectURL).toHaveBeenCalledTimes(3) expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:customs-file') }) it('preserves backend error code, request id and trace id when one download fails', async () => { api.downloadSource.mockRejectedValueOnce(new ApiResponseError('文件不可用', 'CUSTOMS_FILE_404', 404, undefined, 'request-customs-download', 'trace-customs-download')) const wrapper = await render('COMPLETED', false, true) await wrapper.get('[aria-label="下载客户退税资料 客户退税资料.pdf"]').trigger('click') await flushPromises() expect(wrapper.text()).toContain('文件不可用') expect(wrapper.text()).toContain('CUSTOMS_FILE_404') expect(wrapper.text()).toContain('request-customs-download') expect(wrapper.text()).toContain('trace-customs-download') expect(api.downloadReceipt).not.toHaveBeenCalled() }) it('下载预录单失败时透传正式名称与诊断信息,保留原文件名', async () => { const get = vi.spyOn(http, 'get').mockResolvedValueOnce(new Blob([JSON.stringify({ code: 'A0404', message: '预录单不存在', data: null, requestId: 'request-pre-entry', traceId: 'trace-pre-entry' })], { type: 'application/json' })) api.downloadReceipt.mockImplementationOnce(async (documentId: string) => { const actual = await vi.importActual('../api/platformContainerCustoms') return actual.downloadContainerCustomsReceipt(documentId) }) const wrapper = await render('COMPLETED', false, true) await wrapper.get('[aria-label="下载预录单 报关行回执.pdf"]').trigger('click') await flushPromises() await vi.waitFor(() => expect(wrapper.text()).toContain('预录单不存在')) expect(wrapper.text()).toContain('request-pre-entry') expect(wrapper.text()).toContain('trace-pre-entry') expect(wrapper.text()).toContain('报关行回执.pdf') expect(wrapper.text()).not.toContain('报关行回执不存在') expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled() expect(get).toHaveBeenCalledWith('/platform/v1/customs/loading-plans/documents/2092438843630391407/completion/download', { responseType: 'blob' }) get.mockRestore() wrapper.unmount() }) it('上传冲突时展示服务端预录单提示并保留待上传文件', async () => { const message = '客户确认的预录单已发送给报关行,不可再修改预录单' api.upload.mockRejectedValueOnce(new ApiResponseError(message, 'A0409', 200, undefined, 'request-pre-entry-upload', 'trace-pre-entry-upload')) const wrapper = await render('DECLARING', true, true) const selected = new File(['receipt'], '预录单.pdf', { type: 'application/pdf' }) const onChange = wrapper.findAllComponents({ name: 'ElUpload' })[0]!.props('onChange') as (upload: { raw: File }) => void onChange({ raw: selected }) await flushPromises() const submit = wrapper.findAll('button').find(button => button.text() === '提交')! await submit.trigger('click') await flushPromises() expect(wrapper.text()).toContain(message) expect(wrapper.text()).toContain('A0409') expect(wrapper.text()).toContain('request-pre-entry-upload') expect(wrapper.text()).toContain('trace-pre-entry-upload') expect(submit.attributes('disabled')).toBeUndefined() wrapper.unmount() }) })