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 { ElMessage } from 'element-plus' import { ApiResponseError } from '@/shared/api/http' import { listPlatformTenants } from '@/modules/iam' import * as api from '../api/finance' import type { FinanceAccountSummary, FinanceRechargeRequest } from '../types' import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.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', () => ({ listFinanceAccounts: vi.fn(), listFinanceAccountLedger: vi.fn(), listFinanceRechargeRequests: vi.fn(), reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), updateFinanceCreditLimit: vi.fn(), downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(), })) const account: FinanceAccountSummary = { customerId: '9223372036854775001', customerCode: 'C-TEST', customerName: '测试客户', contactName: '', contactPhone: '', outstandingReceivable: 0, pendingBilling: 0, receivedInPeriod: 0, totalCost: 0, paidCost: 0, outstandingPayable: 0, actualBalance: '-20.00', frozenBalance: '0.00', availableBalance: '80.00', creditLimit: '100.00', creditUsed: '0.00', availableCredit: '100.00', creditExpiresOn: '2099-12-31', creditActive: true, accountVersion: 7, pendingRechargeCount: 1, status: 'ACTIVE', lastChangedAt: '', } const recharge: FinanceRechargeRequest = { requestId: '9223372036854775002', requestNo: 'RC202610200001', amount: '500.00', voucherFileId: '9223372036854775003', voucherFileName: '付款凭证.png', status: 'PENDING', version: 8, submittedByName: '测试客户', reviewedByName: '', reviewRemark: '', createdAt: '2026-10-20T01:00:00Z', reviewedAt: '', } 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 } } const failure = () => new ApiResponseError('测试操作未成功', 'TEST_REJECTED', 409, null, 'finance-test-request') async function mountPage(detail = true) { const router = createRouter({ history: createMemoryHistory(), routes: [ { path: '/finance/customer-accounts', name: 'finance-customer-accounts', component: FinanceCustomerAccountsView }, { path: '/finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: FinanceCustomerAccountsView }, ] }) await router.push(detail ? '/finance/customer-accounts/' + account.customerId : '/finance/customer-accounts') await router.isReady() const wrapper = mount(FinanceCustomerAccountsView, { 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 openAdjustment(type: 'RECHARGE' | 'DEDUCTION', detail = true) { const wrapper = await mountPage(detail) await click(wrapper, detail ? (type === 'RECHARGE' ? '手工充值' : '手工扣款') : (type === 'RECHARGE' ? '充值' : '扣款'), detail ? 'button' : '.el-table__body-wrapper button') return wrapper } async function openCredit() { const wrapper = await mountPage() await click(wrapper, '设置授信') return wrapper } async function openReview() { const wrapper = await mountPage() await click(wrapper, '审核', '.recharge-table button') return wrapper } enableAutoUnmount(afterEach) beforeEach(() => { vi.resetAllMocks() can.mockReturnValue(true) vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }) vi.stubGlobal('URL', class extends URL { static createObjectURL = vi.fn(() => 'blob:finance-current') static revokeObjectURL = vi.fn() }) vi.mocked(listPlatformTenants).mockResolvedValue({ records: [], page: 1, size: 200, total: 0, pages: 1 }) vi.mocked(api.listFinanceAccounts).mockResolvedValue({ records: [account], total: 1, pages: 1 }) vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [], total: 0, pages: 1 }) vi.mocked(api.listFinanceRechargeRequests).mockResolvedValue({ records: [recharge], total: 1, pages: 1 }) vi.mocked(api.adjustFinanceAccount).mockResolvedValue({}) vi.mocked(api.updateFinanceCreditLimit).mockResolvedValue({}) vi.mocked(api.reviewFinanceRechargeRequest).mockResolvedValue({ ...recharge, status: 'APPROVED', version: 9 }) vi.mocked(api.downloadFinanceRechargeVoucher).mockResolvedValue(new Blob(['image'], { type: 'image/png' })) }) afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); document.body.innerHTML = '' }) describe('customer wallet native dialogs', () => { it.each([true, false])('opens the same recharge dialog from detail=%s with plain authoritative balances', async detail => { const wrapper = await openAdjustment('RECHARGE', detail) const dialog = activeDialog(wrapper) expect((wrapper.vm as unknown as { adjustmentAccount: FinanceAccountSummary }).adjustmentAccount).toMatchObject(account) expect(dialog.props('width')).toContain('720px') expect(dialog.props('title')).toBe('手工充值 · 测试客户') expect(dialog.get('[data-tone="summary"]').text()).toContain('¥-20.00') expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false) expect(dialog.find('[data-tone="work"] .density-table--editable').exists()).toBe(true) }) it.each(['RECHARGE', 'DEDUCTION'] as const)('preserves %s fields, account version and refresh after fullscreen editing', async type => { const wrapper = await openAdjustment(type) const dialog = activeDialog(wrapper) if (type === 'DEDUCTION') { expect(dialog.get('[data-tone="summary"]').text()).toContain('¥80.00') expect(dialog.get('.el-alert').text()).toContain('不足部分再使用可用授信') } await dialog.get('input[placeholder="请输入大于 0 的金额"]').setValue('12.3') await dialog.get('input[placeholder="请输入关联业务单号(选填)"]').setValue(' RC-TEST ') await dialog.get('textarea').setValue(' 测试备注 ') dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2026-10-20') dialog.getComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'CASH') await dialog.get('button[aria-label="展开全屏"]').trigger('click') expect(dialog.props('fullscreen')).toBe(true) expect((dialog.get('input[placeholder="请输入大于 0 的金额"]').element as HTMLInputElement).value).toBe('12.3') await click(dialog, type === 'RECHARGE' ? '确认充值' : '确认扣款') expect(api.adjustFinanceAccount).toHaveBeenCalledWith(account.customerId, { type, amount: '12.30', occurredDate: '2026-10-20', paymentMethod: 'CASH', referenceNo: 'RC-TEST', remark: '测试备注', expectedAccountVersion: 7 }) expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(2) expect(api.listFinanceRechargeRequests).toHaveBeenCalledTimes(2) }) it.each(['', '0', '-1', 'invalid'])('keeps invalid adjustment %s from reaching the API', async amount => { const wrapper = await openAdjustment('RECHARGE') const dialog = activeDialog(wrapper) await dialog.get('input[placeholder="请输入大于 0 的金额"]').setValue(amount) await click(dialog, '确认充值') expect(api.adjustFinanceAccount).not.toHaveBeenCalled() }) it('locks adjustment controls and close paths, then keeps the rejected draft', async () => { const pending = deferred>>() vi.mocked(api.adjustFinanceAccount).mockReturnValue(pending.promise) const error = vi.spyOn(ElMessage, 'error') const wrapper = await openAdjustment('DEDUCTION') const dialog = activeDialog(wrapper) await dialog.get('input[placeholder="请输入大于 0 的金额"]').setValue('10') await dialog.get('textarea').setValue('保留备注') await click(dialog, '确认扣款') expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false, closeOnClickModal: false }) expect(dialog.get('textarea').attributes('disabled')).toBeDefined() await click(dialog, '取消') expect(dialog.props('modelValue')).toBe(true) pending.reject(failure()) await flushPromises() expect(error).toHaveBeenCalledWith('测试操作未成功') expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留备注') expect(dialog.get('textarea').attributes('disabled')).toBeUndefined() expect(dialog.props('modelValue')).toBe(true) }) it('submits zero credit with a far-future expiry and the original account version', async () => { const wrapper = await openCredit() const dialog = activeDialog(wrapper) expect(dialog.props('width')).toContain('560px') expect(dialog.get('[data-tone="summary"]').text()).toContain('¥0.00') expect(dialog.find('[data-tone="summary"] input').exists()).toBe(false) expect(dialog.get('.el-alert').text()).toContain('次日授信失效') const date = dialog.getComponent({ name: 'ElDatePicker' }) const disabledDate = date.props('disabledDate') as (value: Date) => boolean expect(disabledDate(new Date('2099-12-31T00:00:00'))).toBe(false) await dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').setValue('0') await dialog.get('textarea').setValue(' 清零额度 ') date.vm.$emit('update:modelValue', '2099-12-31') await dialog.get('button[aria-label="展开全屏"]').trigger('click') await click(dialog, '保存授信额度') expect(api.updateFinanceCreditLimit).toHaveBeenCalledWith(account.customerId, { creditLimit: '0.00', creditExpiresOn: '2099-12-31', remark: '清零额度', expectedAccountVersion: 7 }) }) it.each(['-1', '1.234', ''])('preserves credit amount validation for %s', async amount => { const wrapper = await openCredit() const dialog = activeDialog(wrapper) await dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').setValue(amount) await click(dialog, '保存授信额度') expect(api.updateFinanceCreditLimit).not.toHaveBeenCalled() }) it('rejects a past expiry without imposing a maximum future date', async () => { const wrapper = await openCredit() const dialog = activeDialog(wrapper) dialog.getComponent({ name: 'ElDatePicker' }).vm.$emit('update:modelValue', '2000-01-01') await flushPromises() await click(dialog, '保存授信额度') expect(api.updateFinanceCreditLimit).not.toHaveBeenCalled() }) it('retains credit input and restores close controls after a rejected request', async () => { const pending = deferred>>() vi.mocked(api.updateFinanceCreditLimit).mockReturnValue(pending.promise) const wrapper = await openCredit() const dialog = activeDialog(wrapper) await dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').setValue('321.09') await click(dialog, '保存授信额度') expect(dialog.props('showClose')).toBe(false) expect(dialog.get('textarea').attributes('disabled')).toBeDefined() pending.reject(failure()) await flushPromises() expect(dialog.props('showClose')).toBe(true) expect((dialog.get('input[placeholder="请输入大于或等于 0 的金额"]').element as HTMLInputElement).value).toBe('321.09') }) it.each(['APPROVE', 'REJECT'] as const)('reviews %s with plain request details and the original request version', async decision => { const wrapper = await openReview() const dialog = activeDialog(wrapper) expect(dialog.props('title')).toBe('审核充值申请 · ' + recharge.requestNo) expect(dialog.props('width')).toContain('620px') expect(dialog.get('[data-tone="source"]').text()).toContain('¥500.00') expect(dialog.find('[data-tone="source"] input').exists()).toBe(false) expect(dialog.text()).not.toContain(recharge.requestId) dialog.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', decision) await dialog.get('textarea').setValue(' 测试审核 ') await dialog.get('button[aria-label="展开全屏"]').trigger('click') await click(dialog, '确认审核') expect(api.reviewFinanceRechargeRequest).toHaveBeenCalledWith(recharge.requestId, { decision, remark: '测试审核', expectedVersion: 8 }) }) it('locks review controls during submission and keeps the failed decision and remark', async () => { const pending = deferred() vi.mocked(api.reviewFinanceRechargeRequest).mockReturnValue(pending.promise) const wrapper = await openReview() const dialog = activeDialog(wrapper) dialog.getComponent({ name: 'ElRadioGroup' }).vm.$emit('update:modelValue', 'REJECT') await dialog.get('textarea').setValue('保留审核意见') await click(dialog, '确认审核') expect(dialog.props()).toMatchObject({ showClose: false, closeOnPressEscape: false }) expect(dialog.get('textarea').attributes('disabled')).toBeDefined() pending.reject(failure()) await flushPromises() expect(dialog.getComponent({ name: 'ElRadioGroup' }).props('modelValue')).toBe('REJECT') expect((dialog.get('textarea').element as HTMLTextAreaElement).value).toBe('保留审核意见') expect(dialog.props('modelValue')).toBe(true) }) it('keeps independent adjustment and review permissions on the account detail', async () => { can.mockImplementation(permission => permission === 'platform:finance:customer-account:list') const wrapper = await mountPage() const labels = wrapper.findAll('button').map(button => button.text().trim()) for (const label of ['手工充值', '手工扣款', '设置授信', '审核']) expect(labels).not.toContain(label) expect(labels).toContain(recharge.voucherFileName) }) it('previews the authenticated voucher, preserves its URL through fullscreen and revokes on close', async () => { const wrapper = await mountPage() await click(wrapper, recharge.voucherFileName, '.recharge-table button') const dialog = activeDialog(wrapper) expect(api.downloadFinanceRechargeVoucher).toHaveBeenCalledWith(recharge.requestId) expect(dialog.props('width')).toContain('920px') expect(dialog.get('.voucher-preview__image').attributes('src')).toBe('blob:finance-current') await dialog.get('button[aria-label="展开全屏"]').trigger('click') expect(dialog.props('fullscreen')).toBe(true) expect(URL.createObjectURL).toHaveBeenCalledTimes(1) dialog.vm.$emit('update:modelValue', false) await flushPromises() expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:finance-current') }) it('does not create an image URL for a voucher response arriving after close', async () => { const pending = deferred() vi.mocked(api.downloadFinanceRechargeVoucher).mockReturnValue(pending.promise) const wrapper = await mountPage() await click(wrapper, recharge.voucherFileName, '.recharge-table button') activeDialog(wrapper).vm.$emit('update:modelValue', false) await flushPromises() pending.resolve(new Blob(['late'], { type: 'image/png' })) await flushPromises() expect(URL.createObjectURL).not.toHaveBeenCalled() }) it('keeps the current long-named voucher when an older request finishes after reopen', async () => { const pending = deferred() const filename = '第二份很长的付款凭证名称'.repeat(20) + '.png' vi.mocked(api.listFinanceRechargeRequests).mockResolvedValue({ records: [recharge, { ...recharge, requestId: '9223372036854775004', requestNo: 'RC202610200002', voucherFileName: filename }], total: 2, pages: 1 }) vi.mocked(api.downloadFinanceRechargeVoucher).mockReturnValueOnce(pending.promise) const wrapper = await mountPage() await click(wrapper, recharge.voucherFileName, '.recharge-table button') activeDialog(wrapper).vm.$emit('update:modelValue', false) await flushPromises() await click(wrapper, filename, '.recharge-table button') expect(activeDialog(wrapper).props('title')).toBe(filename) pending.resolve(new Blob(['old'], { type: 'image/png' })) await flushPromises() expect(activeDialog(wrapper).get('.voucher-preview__image').attributes('src')).toBe('blob:finance-current') expect(URL.createObjectURL).toHaveBeenCalledTimes(1) }) it('reports voucher download failure and allows reopening the same request', async () => { vi.mocked(api.downloadFinanceRechargeVoucher).mockRejectedValueOnce(failure()) const error = vi.spyOn(ElMessage, 'error') const wrapper = await mountPage() await click(wrapper, recharge.voucherFileName, '.recharge-table button') expect(error).toHaveBeenCalledWith('测试操作未成功') expect(wrapper.findAllComponents(ElDialog).some(item => item.props('modelValue'))).toBe(false) await click(wrapper, recharge.voucherFileName, '.recharge-table button') expect(activeDialog(wrapper).find('.voucher-preview__image').exists()).toBe(true) }) })