费用优化

This commit is contained in:
2026-10-26 13:43:08 +08:00
parent 090431b409
commit 18a78e5508
74 changed files with 3339 additions and 116 deletions
@@ -6,7 +6,7 @@ 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 type { FinanceAccountSummary, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
const { default: ElementPlus, ElDialog } = createRequire(import.meta.url)('element-plus') as typeof import('element-plus')
@@ -17,7 +17,7 @@ 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(),
reviewFinanceRechargeRequest: vi.fn(), adjustFinanceAccount: vi.fn(), refundFinanceConsumption: vi.fn(), updateFinanceCreditLimit: vi.fn(),
downloadFinanceRechargeVoucher: vi.fn(), getFinanceCustomerTerms: vi.fn(), updateFinanceCustomerTerms: vi.fn(),
}))
const account: FinanceAccountSummary = {
@@ -287,3 +287,105 @@ describe('customer wallet native dialogs', () => {
expect(activeDialog(wrapper).find('.voucher-preview__image').exists()).toBe(true)
})
})
const consumption: FinanceLedgerEntry = {
ledgerId: '61', businessNo: 'AC-CONSUME-61', changeType: 'CONSUMPTION', amount: '-80.00',
sourceNo: 'RELABEL-1', occurredAt: '', businessType: 'ADJUSTMENT', documentType: '', documentId: '', documentNo: '',
receivableChange: 0, payableChange: 0, outstandingReceivableAfter: 0, operatorName: '', remark: '',
}
async function openRefundDialog() {
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption], total: 1, pages: 1 })
const wrapper = await mountPage()
await click(wrapper, '退款', '.ledger-table button')
return { wrapper, dialog: activeDialog(wrapper) }
}
describe('linked original consumption refunds', () => {
it.each(['', '0', '-1', '80.01', '1.001', 'NaN'])('rejects invalid refund %s', async amount => {
const { dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue(amount)
await dialog.get('textarea').setValue('测试退款')
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
})
it('requires a reason and keeps the original consumption visible', async () => {
const { dialog } = await openRefundDialog()
expect(dialog.text()).toContain('AC-CONSUME-61')
expect(dialog.text()).toContain('RELABEL-1')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
})
it('locks a pending refund, preserves failures, and retries the identical payload with the same key', async () => {
const pending = deferred<Record<string, unknown>>()
vi.mocked(api.refundFinanceConsumption).mockReturnValueOnce(pending.promise).mockResolvedValueOnce({})
const { wrapper, dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue('12.3')
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(dialog.text()).toContain('测试操作未成功')
const first = vi.mocked(api.refundFinanceConsumption).mock.calls[0]!
expect(first).toEqual([account.customerId, '61', { amount: '12.30', reason: '原单多扣' }, expect.stringMatching(/^finance-refund-/)])
await click(dialog, '确认退款')
expect(vi.mocked(api.refundFinanceConsumption).mock.calls[1]).toEqual(first)
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('账户流水')
})
it('creates a new key for changed amount after a rejected request', async () => {
vi.mocked(api.refundFinanceConsumption).mockRejectedValue(failure())
const { dialog } = await openRefundDialog()
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
await dialog.get('textarea').setValue('退款')
await click(dialog, '确认退款')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('11')
await click(dialog, '确认退款')
const calls = vi.mocked(api.refundFinanceConsumption).mock.calls
expect(calls[0]![3]).not.toBe(calls[1]![3])
})
it('hides refunds without adjustment permission and labels refund original references', async () => {
can.mockImplementation(permission => permission !== 'platform:finance:customer-account:adjust')
vi.mocked(api.listFinanceAccountLedger).mockResolvedValue({ records: [consumption, { ...consumption, ledgerId: '62', changeType: 'REFUND', amount: '10.00', referenceNo: 'AC-CONSUME-61' }], total: 2, pages: 1 })
const wrapper = await mountPage()
expect(wrapper.findAll('.ledger-table button').some(button => button.text() === '退款')).toBe(false)
expect(wrapper.text()).toContain('消费退款')
expect(wrapper.text()).toContain('原消费:')
})
})
describe('customer wallet detail error recovery', () => {
it.each(['ACCOUNT', 'LEDGER'] as const)('reloads the current detail after %s fails without same-route navigation', async failurePoint => {
if (failurePoint === 'ACCOUNT') vi.mocked(api.listFinanceAccounts).mockRejectedValueOnce(failure())
else vi.mocked(api.listFinanceAccountLedger).mockRejectedValueOnce(failure())
const wrapper = await mountPage()
expect(wrapper.get('.load-error-state').text()).toContain('测试操作未成功')
const pending = deferred<Awaited<ReturnType<typeof api.listFinanceAccounts>>>()
vi.mocked(api.listFinanceAccounts).mockReturnValueOnce(pending.promise)
await click(wrapper, '重新加载', '.load-error-state button')
expect(api.listFinanceAccounts).toHaveBeenCalledTimes(2)
pending.resolve({ records: [{ ...account, actualBalance: '90.00' }], total: 1, pages: 1 })
await flushPromises()
expect(wrapper.find('.load-error-state').exists()).toBe(false)
expect(wrapper.get('[aria-label="客户资金账户余额"]').text()).toContain('¥90.00')
expect(wrapper.find('.ledger-table').exists()).toBe(true)
expect(api.listFinanceAccountLedger).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2)
expect(api.listFinanceRechargeRequests).toHaveBeenCalledTimes(failurePoint === 'ACCOUNT' ? 1 : 2)
})
})
it('enforces the refund contract reason limit before submitting', async () => {
const { wrapper, dialog } = await openRefundDialog()
expect(dialog.get('textarea').attributes('maxlength')).toBe('500')
await dialog.get('input[placeholder="请输入退款金额"]').setValue('10')
const vm = wrapper.vm as unknown as { refundForm: { reason: string } }
vm.refundForm.reason = '退'.repeat(501)
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).not.toHaveBeenCalled()
vm.refundForm.reason = '退'.repeat(500)
await click(dialog, '确认退款')
expect(api.refundFinanceConsumption).toHaveBeenCalledWith(account.customerId, consumption.ledgerId, { amount: '10.00', reason: '退'.repeat(500) }, expect.any(String))
})