import { mount, flushPromises } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import ElementPlus, { ElFormItem, ElSelect } from 'element-plus' import AppDialog from '@/shared/components/AppDialog.vue' import ContainerLastLegFeesPanel from './ContainerLastLegFeesPanel.vue' const mocks = vi.hoisted(() => ({ upload: vi.fn(), metadata: vi.fn(), download: vi.fn(), create: vi.fn(), fees: vi.fn() })) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) })) vi.mock('@/modules/finance', () => ({ getFinancePaymentVoucher: mocks.metadata, downloadFinancePaymentVoucher: mocks.download })) vi.mock('@/modules/loading-lists', () => ({ uploadContainerFeePaymentVoucher: mocks.upload, listContainerFeeSupplierOptions: async () => [{ id: '21', supplierName: '供应商甲', category: 'TERMINAL_DRAYAGE' }] })) vi.mock('../api/overseasContainerFees', () => ({ listOverseasContainerFees: mocks.fees, listOverseasContainerCustomers: async () => [{ customerId: '11', customerName: '客户甲' }], createOverseasContainerFee: mocks.create, adjustOverseasContainerFee: vi.fn(), reviewOverseasContainerFee: vi.fn(), submitOverseasContainerFee: vi.fn(), voidOverseasContainerFee: vi.fn(), })) async function setup(readonly = false, realDialog = false, containerNo: string | null = 'CONT-99') { const wrapper = mount(ContainerLastLegFeesPanel, { props: { containerBookingId: '99', cabinetNo: 'CAB-99', containerNo, readonly }, global: { plugins: [ElementPlus], stubs: realDialog ? {} : { AppDialog: { props: ['modelValue', 'title', 'confirmDisabled'], template: '

{{ title }}

' }, } } }) await flushPromises() return wrapper } type FormState = { form: { customerId: string; settlementMode: string; direction: string; amount: number | null; businessDate: string; supplierId: string; paidAt: string; paymentReferenceNo: string }; dialogOpen: boolean } async function upload(wrapper: Awaited>, name: string) { const input = wrapper.get('input[type="file"]') Object.defineProperty(input.element, 'files', { configurable: true, value: [new File(['image'], name, { type: 'image/png' })] }) await input.trigger('change') await flushPromises() } describe('尾程付款凭证', () => { beforeEach(() => { vi.clearAllMocks() mocks.fees.mockResolvedValue([]) vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) URL.createObjectURL = vi.fn(() => 'blob:voucher') URL.revokeObjectURL = vi.fn() mocks.metadata.mockResolvedValue({ fileId: '91', fileName: '付款.png', contentType: 'image/png' }) mocks.download.mockResolvedValue(new Blob(['image'])) }) it('新增连续表切换付款字段和全屏后保留输入,保存仍使用真实集装箱号', async () => { const wrapper = await setup(false, true) await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click') await flushPromises() const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('新增尾程费用'))! expect(dialog.props('title')).toBe('新增尾程费用 · CAB-99') const labels = () => dialog.findAllComponents(ElFormItem).map(item => item.props('label')) expect(labels()).toHaveLength(7) state.form.settlementMode = 'DIRECT_PAID' state.form.amount = 120.25 state.form.supplierId = '21' state.form.businessDate = '2026-10-19' state.form.paidAt = '2026-10-19T09:00:00' state.form.paymentReferenceNo = ' PAY-001 ' await flushPromises() expect(labels()).toHaveLength(11) await dialog.get('[aria-label="展开全屏"]').trigger('click') expect(dialog.find('.el-dialog.is-fullscreen').exists()).toBe(true) expect(state.form.amount).toBe(120.25) await dialog.get('[aria-label="退出全屏"]').trigger('click') expect(state.form.paymentReferenceNo).toBe(' PAY-001 ') dialog.vm.$emit('confirm') await flushPromises() expect(mocks.create).toHaveBeenCalledWith(expect.objectContaining({ customerId: '11', containerBookingId: '99', containerNo: 'CONT-99', businessNo: 'CONT-99', amount: 120.25, supplierId: '21', settlementMode: 'DIRECT_PAID', paidAt: '2026-10-19T09:00:00', paymentReferenceNo: 'PAY-001', currency: 'CNY', }), expect.stringContaining('container-fee-create-')) wrapper.unmount() }) it('调整的所属客户显示只读名称,应收费用隐藏供应商和付款字段', async () => { mocks.fees.mockResolvedValue([{ sourceFeeId: '81', sourceVersion: 7, customerId: '11', customerName: '客户甲', feeItemCode: 'PORT_FEE', amount: 10, businessDate: '2026-10-19', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', paymentVoucherFileIds: [] }]) const wrapper = await setup(false, true) await wrapper.findAll('button').find(button => button.text() === '调整')!.trigger('click') await flushPromises() const dialog = wrapper.findAllComponents(AppDialog).find(item => item.props('title').startsWith('调整尾程费用'))! const fields = dialog.findAllComponents(ElFormItem) expect(fields.map(item => item.props('label'))).toEqual(['客户', '费用项目', '费用方向', '业务日期', '金额(CNY)']) const customer = fields[0]! expect(customer.text()).toContain('客户甲') expect(customer.findComponent(ElSelect).exists()).toBe(false) wrapper.unmount() }) it('只有柜次号仍不能维护费用,保留补录真实集装箱号门禁', async () => { const wrapper = await setup(false, true, null) expect(wrapper.findAll('button').find(button => button.text() === '新增费用')!.attributes('disabled')).toBeDefined() expect(wrapper.text()).toContain('尚未补录真实集装箱号') expect(mocks.create).not.toHaveBeenCalled() wrapper.unmount() }) it('作废待财务确认时保留有效来源,停止调整和重复作废', async () => { mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE', voidStatus: 'PENDING_FINANCE', paymentVoucherFileIds: [] }]) const wrapper = await setup() expect(wrapper.text()).toContain('待财务确认') expect(wrapper.text()).not.toContain('已作废') expect(wrapper.findAll('button').some(button => ['调整', '作废'].includes(button.text()))).toBe(false) wrapper.unmount() }) it('显示上传文件名,允许再次上传替换和移除', async () => { const wrapper = await setup() await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click') const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState state.form.settlementMode = 'DIRECT_PAID' await flushPromises() mocks.upload.mockResolvedValueOnce({ fileId: '91', fileName: '付款.png' }).mockResolvedValueOnce({ fileId: '92', fileName: '新凭证.png' }) await upload(wrapper, '付款.png') expect(mocks.upload).toHaveBeenCalledWith('11', expect.any(File)) expect(wrapper.text()).toContain('付款.png') await upload(wrapper, '新凭证.png') expect(wrapper.text()).toContain('新凭证.png') expect(wrapper.text()).not.toContain('付款.png') await wrapper.findAll('button').find(b => b.text() === '移除')!.trigger('click') expect(wrapper.text()).not.toContain('新凭证.png') wrapper.unmount() }) it('上传中禁止保存,客户切换后忽略旧客户上传结果', async () => { const wrapper = await setup() await wrapper.findAll('button').find(button => button.text() === '新增费用')!.trigger('click') const state = (wrapper.vm.$ as unknown as { setupState: FormState }).setupState state.form.settlementMode = 'DIRECT_PAID' await flushPromises() let complete!: (file: { fileId: string; fileName: string }) => void mocks.upload.mockReturnValue(new Promise(resolve => { complete = resolve })) await upload(wrapper, '旧客户.png') expect(wrapper.findAll('button').find(b => b.text() === '保存')!.attributes('disabled')).toBeDefined() state.form.customerId = '12' complete({ fileId: '91', fileName: '旧客户.png' }) await flushPromises() expect(wrapper.text()).not.toContain('旧客户.png') wrapper.unmount() }) it('只读列表能查看已保存的待审核凭证,并在关闭时释放预览资源', async () => { mocks.fees.mockResolvedValue([{ sourceFeeId: '81', customerId: '11', amount: 10, reviewStatus: 'PENDING', paymentVoucherFileIds: ['91'] }]) const wrapper = await setup(true) await wrapper.findAll('button').find(b => b.text() === '付款凭证 1')!.trigger('click') await flushPromises() expect(mocks.metadata).toHaveBeenCalledWith('11', '91') expect(mocks.download).toHaveBeenCalledWith('11', '91') expect(wrapper.get('img').attributes('src')).toBe('blob:voucher') expect(wrapper.get('a[download]').attributes('download')).toBe('付款.png') wrapper.unmount() expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:voucher') }) })