import { createRequire } from 'node:module' import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { createMemoryHistory, createRouter } from 'vue-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ElMessageBox } from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import OperationErrorToast from '@/shared/components/OperationErrorToast.vue' import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam' import { listSuppliers, type Supplier } from '@/modules/suppliers' import * as api from '../api/finance' import type { FinanceFeePoolItem, FinancePayableSettlement, FinanceReceivableSettlement, FinanceSettlementDetail, FinanceStatementReadModel } from '../types' import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue' const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus') const can = vi.hoisted(() => vi.fn((_permission: string) => true)) vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can }) })) vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() })) vi.mock('@/modules/suppliers', () => ({ listSuppliers: vi.fn(), categoryLabels: { CERTIFICATE: '证书服务' } })) vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() })) vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() })) vi.mock('../api/finance', () => ({ actionFinanceSettlement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(), createPayableSettlement: vi.fn(), createReceivableSettlement: vi.fn(), getFinanceSettlement: vi.fn(), listFinanceFeePool: vi.fn(), listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(), listFinanceStatements: vi.fn(), uploadFinanceVoucher: vi.fn(), })) const customer: PlatformTenantResource = { id: '9223372036854775001', tenantCode: 'C-TEST', tenantName: '测试客户', status: 'ACTIVE', legalName: null, legalRepresentative: null, unifiedSocialCreditCode: null, contactName: null, contactPhone: null, contactEmail: null, servicePackage: null, contactWechat: null, salesOwnerName: null, paymentTermDays: null, enterpriseType: null, establishedDate: null, registeredCapital: null, registeredCapitalCurrency: null, registeredAddress: null, internalRemark: null, createdAt: null, approvedAt: null, } const supplier: Supplier = { id: '9223372036854775002', tenantId: '0', name: '测试供应商', category: 'CERTIFICATE', truckingScope: null, carrierCodes: [], region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'PER_TRANSACTION', remark: null, createdBy: null, createdAt: '', updatedBy: null, updatedAt: '', deleted: false, version: 1, } const ar: FinanceReceivableSettlement = { id: '9223372036854775010', customerId: String(customer.id), customerName: customer.tenantName, settlementNo: 'AR202610200001', reconciliationStatementId: '9223372036854775020', reconciliationStatementNo: 'REC202610200001', billDate: '2026-10-20', dueDate: '2026-11-20', receivableAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3, } const ap: FinancePayableSettlement = { id: '9223372036854775011', customerId: String(customer.id), customerName: customer.tenantName, settlementNo: 'AP202610200001', supplierId: supplier.id, supplierName: supplier.name, costType: 'SINGLE_ORDER', orderId: null, inboundId: null, containerId: null, feeItemName: '操作费', payableAmount: 300, paidAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', createdAt: '', version: 4, } const statement: FinanceStatementReadModel = { id: ar.reconciliationStatementId!, customerId: String(customer.id), customerName: customer.tenantName, billNo: 'REC202610200001', billDate: '2026-10-20', dueDate: '', totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 6, } const fee: FinanceFeePoolItem = { poolItemId: '9223372036854775030', feeSourceId: '9223372036854775031', customerId: String(customer.id), customerName: customer.tenantName, direction: 'PAYABLE', sourceType: 'MANUAL', sourceId: '9223372036854775032', sourceVersion: 1, orderId: null, inboundId: null, containerId: null, feeItemName: '操作费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '', } function settlement(kind: 'receivable' | 'payable'): FinanceSettlementDetail { return { id: kind === 'receivable' ? ar.id : ap.id, settlementNo: kind === 'receivable' ? ar.settlementNo : ap.settlementNo, customerId: String(customer.id), customerName: customer.tenantName, supplierId: supplier.id, supplierName: supplier.name, reconciliationStatementId: statement.id, reconciliationStatementNo: statement.billNo, direction: kind === 'receivable' ? 'RECEIVABLE' : 'PAYABLE', amount: 300, paidAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 8, lines: [], transactions: [], revisions: [] } } const failure = () => new ApiResponseError('测试操作失败', 'TEST_REJECTED', 409, null, 'settlement-test-request') function deferred() { let resolve!: (value: T) => void let reject!: (reason: unknown) => void const promise = new Promise((yes, no) => { resolve = yes; reject = no }) return { promise, resolve, reject } } async function mountPage(kind: 'receivable' | 'payable', detail = false) { vi.mocked(api.getFinanceSettlement).mockResolvedValue(settlement(kind)) const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/', component: { template: '
' } }] }) await router.push('/'); await router.isReady() const wrapper = mount(FinanceFormalSettlementPage, { props: { kind, ...(detail ? { settlementId: kind === 'receivable' ? ar.id : ap.id } : {}) }, attachTo: document.body, global: { plugins: [ElementPlus, router] } }) await flushPromises() return wrapper } function activeDialog(wrapper: VueWrapper) { const dialog = wrapper.findAllComponents(ElDialog).find(item => item.props('modelValue')) expect(dialog).toBeDefined() return dialog! } async function click(wrapper: VueWrapper, text: string, selector = 'button') { const button = wrapper.findAll(selector).find(item => item.text().trim() === text) expect(button, text).toBeDefined() await button!.trigger('click') await flushPromises() } async function openCreate(kind: 'receivable' | 'payable') { const wrapper = await mountPage(kind) await click(wrapper, kind === 'receivable' ? '生成应收结算单' : '生成应付结算单') return { wrapper, dialog: activeDialog(wrapper) } } async function openCashier(kind: 'receivable' | 'payable', detail = false) { const wrapper = await mountPage(kind, detail) const label = kind === 'receivable' ? '收款' : '付款' await click(wrapper, detail ? '部分' + label : label, detail ? 'button' : '.el-table__body-wrapper button') return { wrapper, dialog: activeDialog(wrapper) } } async function chooseFile(dialog: VueWrapper, name = '测试凭证.png', type = 'image/png') { const file = Object.assign(new File(['test image'], name, { type }), { uid: 100 }) const input = dialog.get('input[type="file"]') Object.defineProperty(input.element, 'files', { value: [file], configurable: true }) await input.trigger('change') await flushPromises() return file } function errorDetails(wrapper: VueWrapper) { return wrapper.findAllComponents(OperationErrorToast).find(toast => toast.props('details'))?.props('details') } enableAutoUnmount(afterEach) beforeEach(() => { vi.resetAllMocks() can.mockReturnValue(true) vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) vi.mocked(listPlatformTenants).mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(listSuppliers).mockResolvedValue({ records: [supplier], page: 1, size: 200, total: 1, pages: 1 }) vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([ar]) vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([ap]) vi.mocked(api.listFinanceStatements).mockResolvedValue([statement, { ...statement, id: 'draft-statement', status: 'DRAFT' }]) vi.mocked(api.listFinanceFeePool).mockResolvedValue([fee, { ...fee, feeSourceId: 'direct-paid', settlementMode: 'DIRECT_PAID' }, { ...fee, feeSourceId: 'locked-fee', status: 'LOCKED' }]) vi.mocked(api.createReceivableSettlement).mockResolvedValue(settlement('receivable')) vi.mocked(api.createPayableSettlement).mockResolvedValue(settlement('payable')) vi.mocked(api.createFinanceSettlementPayment).mockResolvedValue(settlement('payable')) vi.mocked(api.actionFinanceSettlement).mockResolvedValue(settlement('receivable')) vi.mocked(api.uploadFinanceVoucher).mockResolvedValue({ fileId: '9223372036854775040', customerId: String(customer.id), fileName: '测试凭证.png', contentType: 'image/png', sizeBytes: 10 }) vi.mocked(api.createFinanceReceipt).mockResolvedValue({ id: '9223372036854775041', customerId: String(customer.id), customerName: customer.tenantName, receivedDate: '2026-10-20', amount: 50, currency: 'CNY', paymentMethod: 'CASH', voucherFileIds: ['9223372036854775040'], allocationCount: 1 }) }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' }) describe('formal settlement native dialogs', () => { it('creates receivables from issued statements with original long IDs and CNY', async () => { const { dialog } = await openCreate('receivable') expect(dialog.props('width')).toContain('600px') const selects = dialog.findAllComponents({ name: 'ElSelect' }) selects[0]!.vm.$emit('update:modelValue', String(customer.id)) await flushPromises() expect(api.listFinanceStatements).toHaveBeenCalledWith(String(customer.id)) expect(selects[1]!.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual([statement.id]) selects[1]!.vm.$emit('update:modelValue', statement.id) await dialog.get('button[aria-label="展开全屏"]').trigger('click') await click(dialog, '确定') expect(api.createReceivableSettlement).toHaveBeenCalledWith({ reconciliationStatementId: statement.id, currency: 'CNY' }) }) it('keeps active suppliers and excludes directly paid or locked payable sources', async () => { const { dialog } = await openCreate('payable') const selects = dialog.findAllComponents({ name: 'ElSelect' }) selects[0]!.vm.$emit('update:modelValue', String(customer.id)) selects[1]!.vm.$emit('update:modelValue', supplier.id) await flushPromises() expect(api.listFinanceFeePool).toHaveBeenLastCalledWith(String(customer.id), 'PAYABLE', supplier.id) expect(selects[2]!.findAllComponents({ name: 'ElOption' }).map(item => item.props('value'))).toEqual([fee.feeSourceId]) selects[2]!.vm.$emit('update:modelValue', [fee.feeSourceId]) dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2099-12-31') await click(dialog, '确定') expect(api.createPayableSettlement).toHaveBeenCalledWith({ customerId: String(customer.id), supplierId: supplier.id, feeSourceIds: [fee.feeSourceId], dueDate: '2099-12-31', currency: 'CNY' }) }) it.each(['receivable', 'payable'] as const)('keeps %s creation validation before mutation', async kind => { const { dialog } = await openCreate(kind) await click(dialog, '确定') expect(api.createReceivableSettlement).not.toHaveBeenCalled() expect(api.createPayableSettlement).not.toHaveBeenCalled() }) it('shows dependency load errors without an unhandled customer-selection request', async () => { vi.mocked(api.listFinanceStatements).mockRejectedValueOnce(failure()) const { wrapper, dialog } = await openCreate('receivable') dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', String(customer.id)) await flushPromises() expect(errorDetails(wrapper)).toMatchObject({ message: '测试操作失败', requestId: 'settlement-test-request' }) expect(dialog.props('modelValue')).toBe(true) }) it('locks creation controls and retains a rejected selection', async () => { const pending = deferred() vi.mocked(api.createReceivableSettlement).mockReturnValue(pending.promise) const { wrapper, dialog } = await openCreate('receivable') const selects = dialog.findAllComponents({ name: 'ElSelect' }) selects[0]!.vm.$emit('update:modelValue', String(customer.id)) await flushPromises() selects[1]!.vm.$emit('update:modelValue', statement.id) await click(dialog, '确定') expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false }) expect(dialog.findAll('input').every(input => input.attributes('disabled') !== undefined)).toBe(true) pending.reject(failure()) await flushPromises() expect(errorDetails(wrapper)).toMatchObject({ requestId: 'settlement-test-request' }) expect(selects[1]!.props('modelValue')).toBe(statement.id) }) it.each([false, true])('keeps payable balances plain and posts the expected version from detail=%s', async detail => { const { dialog } = await openCashier('payable', detail) expect(dialog.props('title')).toBe('登记付款 · ' + ap.settlementNo) expect(dialog.props('width')).toContain('760px') expect(dialog.get('[data-tone="summary"]').text()).toContain('¥100.00') expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false) const amount = dialog.getComponent({ name: 'ElInputNumber' }) expect(amount.props('max')).toBe(200) amount.vm.$emit('update:modelValue', 50.25) dialog.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CASH') dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-20') await dialog.get('input[placeholder="请输入付款流水号"]').setValue('BANK-TEST') await dialog.get('textarea').setValue('付款备注') const file = await chooseFile(dialog) await dialog.get('button[aria-label="展开全屏"]').trigger('click') expect(dialog.text()).toContain('¥149.75') await click(dialog, '确定') expect(api.uploadFinanceVoucher).toHaveBeenCalledWith(String(customer.id), file) expect(api.createFinanceSettlementPayment).toHaveBeenCalledWith(ap.id, { transactionDate: '2026-10-20', amount: 50.25, method: 'CASH', referenceNo: 'BANK-TEST', remark: '付款备注', currency: 'CNY', voucherFileIds: ['9223372036854775040'], expectedVersion: detail ? 8 : 4 }) }) it.each([false, true])('allocates a real receipt to its loaded statement from detail=%s', async detail => { const { dialog } = await openCashier('receivable', detail) expect(dialog.props('title')).toBe('登记收款 · ' + ar.settlementNo) expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false) dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 50) dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-20') dialog.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CASH') await dialog.get('textarea').setValue('收款备注') const file = await chooseFile(dialog) await dialog.get('button[aria-label="展开全屏"]').trigger('click') expect(dialog.text()).toContain('¥150.00') await click(dialog, '确定') expect(api.uploadFinanceVoucher).toHaveBeenCalledWith(String(customer.id), file) expect(api.createFinanceReceipt).toHaveBeenCalledWith({ customerId: String(customer.id), receivedDate: '2026-10-20', amount: 50, paymentMethod: 'CASH', remark: '收款备注', allocations: [{ billId: statement.id, amount: 50 }] }, ['9223372036854775040']) }) it.each(['receivable', 'payable'] as const)('requires a selected voucher for %s before uploading', async kind => { const { dialog } = await openCashier(kind) await click(dialog, '确定') expect(api.uploadFinanceVoucher).not.toHaveBeenCalled() expect(dialog.props('modelValue')).toBe(true) }) it('retains non-image receipt rejection and clears the invalid file view', async () => { const { dialog } = await openCashier('receivable') await chooseFile(dialog, 'invalid.pdf', 'application/pdf') await click(dialog, '确定') expect(api.uploadFinanceVoucher).not.toHaveBeenCalled() expect(dialog.text()).not.toContain('invalid.pdf') }) it.each(['receivable', 'payable'] as const)('locks %s upload and form controls then retains rejected input', async kind => { const pending = deferred>>() vi.mocked(api.uploadFinanceVoucher).mockReturnValue(pending.promise) const { wrapper, dialog } = await openCashier(kind) const filename = '很长的凭证名称'.repeat(12) + '.png' await chooseFile(dialog, filename) await dialog.get('textarea').setValue('保留备注') await click(dialog, '确定') expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false }) expect(dialog.getComponent({ name: 'ElUpload' }).props('disabled')).toBe(true) expect(dialog.get('textarea').attributes('disabled')).toBeDefined() pending.reject(failure()) await flushPromises() expect(errorDetails(wrapper)).toMatchObject({ requestId: 'settlement-test-request' }) expect(dialog.text()).toContain(filename) expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留备注') expect(dialog.props('modelValue')).toBe(true) }) it.each(['receivable', 'payable'] as const)('clears the previous %s voucher filename when reopened', async kind => { const { wrapper, dialog } = await openCashier(kind) await chooseFile(dialog, 'old-voucher.png') expect(dialog.text()).toContain('old-voucher.png') await click(dialog, '取消') await click(wrapper, kind === 'receivable' ? '收款' : '付款', '.el-table__body-wrapper button') expect(activeDialog(wrapper).text()).not.toContain('old-voucher.png') await click(activeDialog(wrapper), '确定') expect(api.uploadFinanceVoucher).not.toHaveBeenCalled() }) it.each(['receivable', 'payable'] as const)('keeps %s cashier actions behind their independent permission', async kind => { can.mockImplementation(permission => permission.endsWith(':list')) const wrapper = await mountPage(kind) const labels = wrapper.get('.el-table__body-wrapper').findAll('button').map(button => button.text().trim()) expect(labels).not.toContain(kind === 'receivable' ? '收款' : '付款') }) it.each([ ['receivable', 'VOID', 'ISSUED'], ['receivable', 'REOPEN', 'VOIDED'], ['payable', 'VOID', 'ISSUED'], ['payable', 'REOPEN', 'VOIDED'], ] as const)('identifies %s %s prompts with its settlement number and original version', async (kind, action, status) => { if (kind === 'receivable') vi.mocked(api.listFinanceReceivableSettlements).mockResolvedValue([{ ...ar, status }]) else vi.mocked(api.listFinancePayableSettlements).mockResolvedValue([{ ...ap, status }]) const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: ' 测试原因 ', action: 'confirm' as const })) const wrapper = await mountPage(kind) await click(wrapper, action === 'VOID' ? '作废' : '重开', '.el-table__body-wrapper button') const current = kind === 'receivable' ? ar : ap expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(current.settlementNo), expect.objectContaining({ customClass: 'admin-density-confirm', inputPattern: /\S+/ })) expect(api.actionFinanceSettlement).toHaveBeenCalledWith(current.id, { action, reason: '测试原因', expectedVersion: current.version }) }) })