Files
admin-web/src/modules/finance/pages/FinanceAccountDialogs.test.ts
T

290 lines
18 KiB
TypeScript

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<T>() {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((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<Awaited<ReturnType<typeof api.adjustFinanceAccount>>>()
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<Awaited<ReturnType<typeof api.updateFinanceCreditLimit>>>()
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<FinanceRechargeRequest>()
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<Blob>()
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<Blob>()
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)
})
})