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 * as api from '../api/finance' import type { FinanceFeeSourceDetail, FinanceReconciliationDetail, FinanceStatementReadModel } from '../types' import FinanceCustomerArapView from './FinanceCustomerArapView.vue' import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.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/downloads', () => ({ createPlatformExportTask: vi.fn() })) vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() })) vi.mock('../api/finance', () => ({ listFinanceFeeSources: vi.fn(), getFinanceFeeSource: vi.fn(), reviewFinanceFeeSource: vi.fn(), editFinanceFeeSource: vi.fn(), adjustFinanceFeeSource: vi.fn(), submitFinanceFeeSource: vi.fn(), voidFinanceFeeSource: vi.fn(), listFinanceStatements: vi.fn(), getReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(), actionReconciliationStatement: 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 fee: FinanceFeeSourceDetail = { poolItemId: '9223372036854775002', feeSourceId: '9223372036854775003', direction: 'RECEIVABLE', sourceType: 'MANUAL', sourceId: '9223372036854775004', sourceFeeId: '9223372036854775004', sourceVersion: 3, sourceSystem: 'FINANCE', sourceBusinessId: 'IN-TEST-1', customerId: String(customer.id), customerName: customer.tenantName, supplierId: null, supplierName: '', orderId: null, inboundId: '9223372036854775005', inboundCode: 'IN-TEST-1', containerId: null, containerNo: '', businessDate: '2026-10-01', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'DRAFT', sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: false, operationVersion: 9, createdAt: '2026-10-01', operationLogs: [], } const statement: FinanceStatementReadModel = { id: '9223372036854775010', customerId: String(customer.id), billNo: 'REC202610200001', customerName: customer.tenantName, billDate: '2026-10-20', dueDate: '', periodStart: '2026-10-01', periodEnd: '2026-10-20', creditDays: 30, lineCount: 1, sourceCount: 1, totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, currency: 'CNY', status: 'ISSUED', version: 3, } const detail: FinanceReconciliationDetail = { ...statement, paidAmount: 0, periodStart: '2026-10-01', periodEnd: '2026-10-20', creditDays: 30, settled: false, remark: '', version: 8, lines: [{ statementLineId: '9223372036854775011', sourceFeeId: fee.sourceFeeId, sourceFeeVersion: 3, sourceHash: 'test-hash', direction: 'RECEIVABLE', sourceType: 'MANUAL', sourceNo: 'IN-TEST-1', orderId: null, inboundId: fee.inboundId, inboundCode: 'IN-TEST-1', containerId: null, containerNo: '', businessDate: '2026-10-01', quantity: 2, unitPrice: 50, feeItemName: '运费', sourceAmount: 100, amount: 100 }], paymentHistory: [], revisions: [], } const failure = (message = '测试拒绝操作', code = 'TEST_REJECTED') => new ApiResponseError(message, code, 409, null, 'finance-dialog-test') 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 } } function setFee(overrides: Partial = {}) { const current = { ...fee, ...overrides } vi.mocked(api.listFinanceFeeSources).mockResolvedValue({ records: [current], total: 1, pages: 1 }) vi.mocked(api.getFinanceFeeSource).mockResolvedValue(current) } async function mountPage(kind: 'arap' | 'statements') { const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView }, { path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView }, { path: '/finance/customer-statements', name: 'finance-customer-statements', component: FinanceCustomerStatementsView }, { path: '/finance/customer-statements/:statementId', name: 'finance-customer-statement-detail', component: FinanceCustomerStatementsView }, ] }) await router.push(kind === 'arap' ? '/finance/customer-arap' : '/finance/customer-statements') await router.isReady() const wrapper = mount(kind === 'arap' ? FinanceCustomerArapView : FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [ElementPlus, router] } }) await flushPromises() return { wrapper, router } } 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 rowClick(wrapper: VueWrapper, label: string) { await click(wrapper, label, '.el-table__body-wrapper button') } async function openCreate() { const { wrapper, router } = await mountPage('statements') await click(wrapper, '生成对账单') return { wrapper, router, dialog: activeDialog(wrapper) } } async function fillCreation(dialog: VueWrapper, selected = false) { dialog.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', selected ? 'SELECTED' : 'BY_CUTOFF') dialog.findAllComponents({ name: 'ElSelect' })[0]!.vm.$emit('update:modelValue', String(customer.id)) const dates = dialog.findAllComponents({ name: 'ElDatePicker' }) dates[0]!.vm.$emit('update:modelValue', '2026-10-01') dates[1]!.vm.$emit('update:modelValue', '2026-10-20') await flushPromises() await dialog.get('textarea').setValue('测试备注') } async function openAdjustment() { const { wrapper, router } = await mountPage('statements') await rowClick(wrapper, '调整') return { wrapper, router, dialog: activeDialog(wrapper) } } async function fillAdjustment(dialog: VueWrapper) { await dialog.get('textarea[placeholder="请输入整体调整原因"]').setValue(' 整体调整 ') await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true) dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 88.12) await dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').setValue(' 行说明 ') } 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 }) setFee() for (const method of [api.reviewFinanceFeeSource, api.editFinanceFeeSource, api.adjustFinanceFeeSource, api.submitFinanceFeeSource, api.voidFinanceFeeSource]) vi.mocked(method).mockResolvedValue(fee) vi.mocked(api.listFinanceStatements).mockResolvedValue([statement]) vi.mocked(api.getReconciliationStatement).mockResolvedValue(detail) vi.mocked(api.createReconciliationStatement).mockResolvedValue({ ...detail, id: '9223372036854775012', billNo: 'REC202610200002', status: 'DRAFT' }) vi.mocked(api.actionReconciliationStatement).mockResolvedValue(detail) }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' }) describe('fee source and statement native dialogs', () => { it.each(['APPROVE', 'REJECT'] as const)('reviews %s with plain fee facts and the loaded operation version', async action => { setFee({ reviewStatus: 'PENDING' }) const { wrapper } = await mountPage('arap') await rowClick(wrapper, '审核') const dialog = activeDialog(wrapper) expect(dialog.props('title')).toContain('应收费用 · 运费') expect(dialog.get('[data-tone="source"]').text()).toContain('IN-TEST-1') expect(dialog.find('[data-tone="source"] input').exists()).toBe(false) expect(dialog.text()).not.toContain(fee.feeSourceId) dialog.getComponent({ name: 'ElSegmented' }).vm.$emit('update:modelValue', action) await dialog.get('textarea').setValue(' 审核说明 ') await dialog.get('button[aria-label="展开全屏"]').trigger('click') expect(dialog.props('fullscreen')).toBe(true) await click(dialog, '提交审核') expect(api.reviewFinanceFeeSource).toHaveBeenCalledWith(fee.feeSourceId, { action, reason: '审核说明', expectedVersion: 9 }) }) it('retains required rejection reasons', async () => { setFee({ reviewStatus: 'PENDING' }) const { wrapper } = await mountPage('arap') await rowClick(wrapper, '审核') const dialog = activeDialog(wrapper) dialog.getComponent({ name: 'ElSegmented' }).vm.$emit('update:modelValue', 'REJECT') await flushPromises() await click(dialog, '提交审核') expect(api.reviewFinanceFeeSource).not.toHaveBeenCalled() expect(dialog.get('textarea').attributes('placeholder')).toBe('请填写驳回原因') }) it.each([ ['编辑', 'DRAFT', 'editFinanceFeeSource'], ['调整', 'APPROVED', 'adjustFinanceFeeSource'], ['提交审核', 'DRAFT', 'submitFinanceFeeSource'], ['作废', 'APPROVED', 'voidFinanceFeeSource'], ] as const)('keeps %s command, reason, amount applicability and version', async (label, status, method) => { setFee({ reviewStatus: status }) const { wrapper } = await mountPage('arap') await rowClick(wrapper, label) const dialog = activeDialog(wrapper) const hasAmount = label === '编辑' || label === '调整' expect(dialog.findComponent({ name: 'ElInputNumber' }).exists()).toBe(hasAmount) await dialog.get('textarea').setValue(' 操作说明 ') if (hasAmount) dialog.getComponent({ name: 'ElInputNumber' }).vm.$emit('update:modelValue', 123.45) await dialog.get('button[aria-label="展开全屏"]').trigger('click') await click(dialog, label === '作废' ? '确认作废' : '确认') expect(api[method]).toHaveBeenCalledWith(fee.feeSourceId, { reason: '操作说明', expectedVersion: 9, ...(hasAmount ? { amount: 123.45 } : {}) }) }) it('keeps operation reasons mandatory before editing', async () => { const { wrapper } = await mountPage('arap') await rowClick(wrapper, '编辑') await click(activeDialog(wrapper), '确认') expect(api.editFinanceFeeSource).not.toHaveBeenCalled() }) it.each(['review', 'edit'] as const)('locks %s forms and keeps failed drafts and diagnostics', async mode => { const pending = deferred() if (mode === 'review') { setFee({ reviewStatus: 'PENDING' }); vi.mocked(api.reviewFinanceFeeSource).mockReturnValue(pending.promise) } else vi.mocked(api.editFinanceFeeSource).mockReturnValue(pending.promise) const { wrapper } = await mountPage('arap') await rowClick(wrapper, mode === 'review' ? '审核' : '编辑') const dialog = activeDialog(wrapper) await dialog.get('textarea').setValue('保留操作说明') await click(dialog, mode === 'review' ? '提交审核' : '确认') expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false }) expect(dialog.get('textarea').attributes('disabled')).toBeDefined() pending.reject(failure()) await flushPromises() expect(wrapper.getComponent(OperationErrorToast).props('details')).toMatchObject({ requestId: 'finance-dialog-test' }) expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留操作说明') expect(dialog.props('modelValue')).toBe(true) }) it('shows the current mutation failure after a previous review failed', async () => { setFee({ reviewStatus: 'PENDING' }) vi.mocked(api.reviewFinanceFeeSource).mockRejectedValueOnce(failure('审核失败')) vi.mocked(api.voidFinanceFeeSource).mockRejectedValueOnce(failure('作废失败')) const { wrapper } = await mountPage('arap') await rowClick(wrapper, '审核') await click(activeDialog(wrapper), '提交审核') await click(activeDialog(wrapper), '取消') await rowClick(wrapper, '作废') await activeDialog(wrapper).get('textarea').setValue('测试作废') await click(activeDialog(wrapper), '确认作废') expect(wrapper.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '作废失败' }) }) it.each(['FIRSTLEG', 'LASTLEG'])('keeps %s upstream adjustment and void actions unavailable', async system => { setFee({ sourceSystem: system, reviewStatus: 'APPROVED' }) const { wrapper } = await mountPage('arap') expect(wrapper.get('.el-table__body-wrapper .density-actions').findAll('button').map(button => button.text())).toEqual(['详情']) }) it('creates a cutoff statement with the original customer, CNY and period payload', async () => { const { dialog, router } = await openCreate() expect(dialog.props('width')).toContain('680px') await fillCreation(dialog) await dialog.get('button[aria-label="展开全屏"]').trigger('click') await click(dialog, '确定') expect(api.createReconciliationStatement).toHaveBeenCalledWith({ customerId: String(customer.id), currency: 'CNY', generationMode: 'BY_CUTOFF', feeSourceIds: undefined, periodStart: '2026-10-01', periodEnd: '2026-10-20', remark: '测试备注' }) expect(router.currentRoute.value.name).toBe('finance-customer-statement-detail') }) it('keeps selected-source filtering, summary and complete long IDs through fullscreen', async () => { setFee({ reviewStatus: 'APPROVED' }) const { dialog } = await openCreate() await fillCreation(dialog, true) expect(api.listFinanceFeeSources).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: String(customer.id), direction: 'RECEIVABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', statementGenerated: false })) const select = dialog.findAllComponents({ name: 'ElSelect' }).find(item => item.props('multiple'))! select.vm.$emit('update:modelValue', [fee.feeSourceId]) await flushPromises() expect(dialog.get('.selected-source-summary').text()).toContain('已选 1 笔,合计 100.00 CNY') await dialog.get('button[aria-label="展开全屏"]').trigger('click') await click(dialog, '确定') expect(api.createReconciliationStatement).toHaveBeenCalledWith(expect.objectContaining({ generationMode: 'SELECTED', feeSourceIds: [fee.feeSourceId] })) }) it('preserves customer and period validation before creating', async () => { const { dialog } = await openCreate() await click(dialog, '确定') expect(api.createReconciliationStatement).not.toHaveBeenCalled() await fillCreation(dialog) dialog.findAllComponents({ name: 'ElDatePicker' })[0]!.vm.$emit('update:modelValue', '2026-11-01') await flushPromises() await click(dialog, '确定') expect(api.createReconciliationStatement).not.toHaveBeenCalled() }) it('keeps selected-source mode disabled without its independent read permission', async () => { can.mockImplementation(permission => permission !== 'platform:finance:customer-arap:list') const { dialog } = await openCreate() expect(dialog.findAllComponents({ name: 'ElRadioButton' })[1]!.props('disabled')).toBe(true) }) it('keeps eligible-source loading errors visible and retries the same selection context', async () => { vi.mocked(api.listFinanceFeeSources).mockRejectedValueOnce(failure('费用加载失败')) const { dialog } = await openCreate() await fillCreation(dialog, true) expect(dialog.get('.el-alert').text()).toContain('可生成费用加载失败') await click(dialog, '重试') expect(dialog.find('.el-alert').exists()).toBe(false) expect(api.listFinanceFeeSources).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: String(customer.id), businessDateFrom: '2026-10-01', businessDateTo: '2026-10-20' })) }) it('locks creation then retains the draft and no-eligible-source feedback after failure', async () => { const pending = deferred() vi.mocked(api.createReconciliationStatement).mockReturnValue(pending.promise) const { dialog } = await openCreate() await fillCreation(dialog) await click(dialog, '确定') expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false }) expect(dialog.get('textarea').attributes('disabled')).toBeDefined() pending.reject(failure('没有可生成的费用', 'FIN_409')) await flushPromises() expect(dialog.get('.el-alert').text()).toContain('所选账期内没有可生成的应收费用') expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('测试备注') expect(dialog.props('modelValue')).toBe(true) }) it('keeps all adjustment rows in the main dialog flow with plain statement facts', async () => { vi.mocked(api.getReconciliationStatement).mockResolvedValue({ ...detail, lines: Array.from({ length: 13 }, (_, index) => ({ ...detail.lines[0]!, statementLineId: 'line-' + index, feeItemName: '费用' + (index + 1) })) }) const { dialog } = await openAdjustment() expect(dialog.props('width')).toContain('900px') expect(dialog.props('title')).toContain(statement.billNo) expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false) expect(dialog.getComponent({ name: 'ElTable' }).props('maxHeight')).toBeUndefined() expect(dialog.findAll('.el-table__body-wrapper tr')).toHaveLength(13) expect(dialog.get('.el-table__body-wrapper').text()).toContain('费用13') }) it('saves only checked lines with full IDs and the freshly loaded version', async () => { const { dialog } = await openAdjustment() await fillAdjustment(dialog) await dialog.get('button[aria-label="展开全屏"]').trigger('click') await click(dialog, '确定') expect(api.actionReconciliationStatement).toHaveBeenCalledWith(statement.id, { action: 'ADJUST', reason: '整体调整', expectedVersion: 8, adjustmentLines: [{ statementLineId: '9223372036854775011', adjustedAmount: 88.12, reason: '行说明' }] }) }) it('requires an overall reason, selection and each selected line reason', async () => { const { dialog } = await openAdjustment() await click(dialog, '确定') expect(api.actionReconciliationStatement).not.toHaveBeenCalled() await dialog.get('textarea').setValue('整体调整') await click(dialog, '确定') expect(api.actionReconciliationStatement).not.toHaveBeenCalled() await dialog.get('.el-table__body-wrapper input[type="checkbox"]').setValue(true) await click(dialog, '确定') expect(api.actionReconciliationStatement).not.toHaveBeenCalled() }) it('locks adjustment rows and retains their state with visible failure diagnostics', async () => { const pending = deferred() vi.mocked(api.actionReconciliationStatement).mockReturnValue(pending.promise) const { dialog } = await openAdjustment() await fillAdjustment(dialog) await click(dialog, '确定') expect(dialog.props('showClose')).toBe(false) expect(dialog.get('.el-table__body-wrapper input[type="checkbox"]').attributes('disabled')).toBeDefined() pending.reject(failure()) await flushPromises() expect(dialog.getComponent(OperationErrorToast).props('details')).toMatchObject({ message: '测试拒绝操作', requestId: 'finance-dialog-test' }) expect((dialog.get('.el-table__body-wrapper input[type="checkbox"]').element as HTMLInputElement).checked).toBe(true) expect((dialog.get('.el-table__body-wrapper input[placeholder="请输入明细说明"]').element as HTMLInputElement).value).toBe(' 行说明 ') expect(dialog.props('modelValue')).toBe(true) }) it.each([ ['驳回', 'PENDING_REVIEW', 'REJECT'], ['作废', 'DRAFT', 'VOID'], ['重开', 'REJECTED', 'REOPEN'], ] as const)('identifies %s reason prompts without changing the state command', async (label, status, action) => { vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status }]) const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue(Object.assign('confirm', { value: '测试原因', action: 'confirm' as const })) const { wrapper } = await mountPage('statements') await rowClick(wrapper, label) expect(prompt).toHaveBeenCalledWith('请输入操作原因', expect.stringContaining(statement.billNo), expect.objectContaining({ customClass: 'admin-density-confirm', inputPattern: /\S+/ })) expect(api.actionReconciliationStatement).toHaveBeenCalledWith(statement.id, { action, reason: '测试原因', expectedVersion: 3 }) }) it('does not change statement state when its reason prompt is cancelled', async () => { vi.mocked(api.listFinanceStatements).mockResolvedValue([{ ...statement, status: 'REJECTED' }]) vi.spyOn(ElMessageBox, 'prompt').mockRejectedValue('cancel') const { wrapper } = await mountPage('statements') await rowClick(wrapper, '重开') expect(api.actionReconciliationStatement).not.toHaveBeenCalled() }) })