feat(finance): manage customer wallet balances

This commit is contained in:
2026-09-05 08:34:40 +08:00
parent 48731b227b
commit 68fca1bd9f
7 changed files with 487 additions and 71 deletions
@@ -10,12 +10,16 @@ import { useAuthStore } from '@/core/auth/auth'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { listFinanceAccountLedger, listFinanceAccounts } from '../api/finance'
import { adjustFinanceAccount, downloadFinanceRechargeVoucher, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest } from '../api/finance'
import FinanceCustomerAccountsView from './FinanceCustomerAccountsView.vue'
vi.mock('../api/finance', () => ({
listFinanceAccounts: vi.fn(),
listFinanceAccountLedger: vi.fn(),
listFinanceRechargeRequests: vi.fn(),
reviewFinanceRechargeRequest: vi.fn(),
adjustFinanceAccount: vi.fn(),
downloadFinanceRechargeVoucher: vi.fn(),
getFinanceCustomerTerms: vi.fn(),
updateFinanceCustomerTerms: vi.fn(),
}))
@@ -30,6 +34,13 @@ function memoryStorage(): Storage {
return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) }
}
const accountFixture = {
customerId: '10001', customerCode: 'C-001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000',
outstandingReceivable: 100, pendingBilling: 20, receivedInPeriod: 30, totalCost: 90, paidCost: 50, outstandingPayable: 40,
actualBalance: '126580.50', frozenBalance: '8500.00', availableBalance: '118080.50', accountVersion: 7, pendingRechargeCount: 1,
status: 'ACTIVE', lastChangedAt: '2026-08-13 10:00:00',
}
async function mountAccountsView(permissions: string[], initialPath = '/finance/customer-accounts') {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
@@ -54,7 +65,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).toContain('返回客户账户')
expect(page).toContain('<DetailPageLayout')
expect(page).toContain('class="account-metrics"')
for (const metric of ['待收客户款', '待付供应商款', '已线下支付供应商款']) expect(page).toContain(metric)
for (const metric of ['实际余额', '冻结金额', '可用余额']) expect(page).toContain(metric)
expect(page.match(/class="account-metric /g)).toHaveLength(3)
expect(page).toContain('class="ledger-query"')
expect(page).toContain('@search="ledgerPage = 1; void loadLedger($event)"')
@@ -63,7 +74,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(page).toContain('<template #pagination-left>共 {{ ledgerTotal }} 条</template>')
expect(page).not.toContain('class="detail-toolbar"')
expect(page).not.toContain('<AppDrawer')
for (const marker of ['流水类型', '发生日期', '导出流水', 'listFinanceAccountLedger']) expect(page).toContain(marker)
for (const marker of ['充值申请', '待审核', '审核', '流水类型', '发生日期', '导出流水', 'listFinanceAccountLedger', 'listFinanceRechargeRequests']) expect(page).toContain(marker)
for (const code of ['RECONCILIATION_CREATED', 'SETTLEMENT_ISSUED', 'RECEIPT',
'SUPPLIER_DIRECT_PAID', 'SUPPLIER_ON_ACCOUNT_PAYMENT', 'ADJUSTMENT', 'VOID']) expect(page).toContain(code)
for (const removed of ['FEE_INCLUDED', 'FEE_EXCLUDED', '纳入结算', '排除结算']) expect(page).not.toContain(removed)
@@ -73,8 +84,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
it('keeps term editing aligned with the account summary and nearby actions', () => {
for (const marker of ['class="term-section"', 'class="term-editor"', '账期天数', '#footer', '保存账期']) expect(page).toContain(marker)
expect(page).toContain('grid-template-columns: repeat(3, minmax(0, 1fr))')
for (const column of ['总成本(CNY)', '待收客户款(CNY)', '待付供应商款(CNY)', '已线下支付供应商款(CNY)']) expect(page).toContain(column)
expect(page).not.toContain('已付成本')
for (const column of ['实际余额(CNY)', '冻结金额(CNY)', '可用余额(CNY)', '待审核充值']) expect(page).toContain(column)
expect(page).toContain('grid-template-columns: 120px minmax(0, 220px)')
expect(page).toContain('justify-content: start')
expect(page).toContain('class="surface-panel finance-account-detail__surface"')
@@ -96,17 +106,20 @@ describe('FinanceCustomerAccountsView delivery', () => {
})
it('renders every account row operation as a small plain primary button with an icon', () => {
expect(page).toContain("{ key: 'actions', label: '操作', width: 160, fixed: 'left', slotName: 'actions' }")
expect(page).toContain('<el-button size="small" plain type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button>')
expect(page).toContain("{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' }")
expect(page).toContain('<el-button size="small" plain type="primary" @click="openAccount(row, false)"><Eye :size="15" />详情</el-button>')
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'RECHARGE')")
expect(page).toContain("openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')")
expect(page).toContain('<el-button v-if="canUpdateTerms" size="small" plain type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button>')
expect(page).not.toMatch(/<template #actions[\s\S]*?<el-button[^>]*\blink\b/)
expect(page).not.toContain('<el-button link type="primary" @click="openAccount(row, false)">')
expect(page).toContain('.row-actions {')
expect(page).toContain('white-space: nowrap')
})
it('shows only derived CNY summaries and no wallet actions', () => {
for (const marker of ['运营资金:待收客户款', '待付供应商款', '已线下支付供应商款', 'CNY']) expect(page).toContain(marker)
for (const forbidden of ['客户线下支付', '已收客户款', '手动充值', '钱包', '外币', '发票', '利润']) expect(page).not.toContain(forbidden)
it('shows authoritative CNY wallet balances and independently permitted actions', () => {
for (const marker of ['实际余额', '冻结金额', '可用余额', '手工充值', '手工扣款', 'platform:finance:customer-account:adjust', 'platform:finance:recharge:review']) expect(page).toContain(marker)
expect(page).not.toContain('operatingFundsSummary')
expect(page).not.toContain('adjustmentAccount?.actualBalance -')
})
it('selects an active customer by name from master data', () => {
@@ -122,7 +135,7 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(routes).toContain("path: 'finance/customer-accounts/:customerId'")
expect(page).toContain("name: 'finance-customer-account-detail'")
expect(page).toContain('restoreAccountDetail(customerId)')
expect(page).toContain("listFinanceAccounts({ page: currentPage, size: 200 })")
expect(page).toContain("listFinanceAccounts({ page: 1, size: 1, customerId })")
expect(page).toContain('customerId: String(filters.value.customerId ||')
})
@@ -130,8 +143,13 @@ describe('FinanceCustomerAccountsView delivery', () => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.stubGlobal('sessionStorage', memoryStorage())
vi.mocked(listFinanceAccounts).mockReset().mockResolvedValue({ records: [{ customerId: '10001', customerCode: 'C-001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 100, pendingBilling: 20, receivedInPeriod: 30, totalCost: 90, paidCost: 50, outstandingPayable: 40, status: 'ACTIVE', lastChangedAt: '2026-08-13 10:00:00' }], total: 1, pages: 1 })
vi.mocked(listFinanceAccountLedger).mockReset().mockResolvedValue({ records: [{ ledgerId: '1', occurredAt: '2026-08-13T10:00:00', businessType: 'REC', documentType: 'RECEIPT', documentId: '2', documentNo: 'REC-2', receivableChange: -10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '收款' }], total: 1, pages: 1 })
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:finance-voucher'), revokeObjectURL: vi.fn() })
vi.mocked(listFinanceAccounts).mockReset().mockResolvedValue({ records: [accountFixture], total: 1, pages: 1 })
vi.mocked(listFinanceAccountLedger).mockReset().mockResolvedValue({ records: [{ ledgerId: '1', occurredAt: '2026-08-13T10:00:00', businessType: 'REC', documentType: 'RECEIPT', documentId: '2', documentNo: 'REC-2', receivableChange: -10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '收款', businessNo: 'AC-1', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '126580.50', frozenBalanceAfter: '8500.00', availableBalanceAfter: '118080.50', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-2', createdAt: '2026-08-13T10:00:00' }], total: 1, pages: 1 })
vi.mocked(listFinanceRechargeRequests).mockReset().mockResolvedValue({ records: [{ requestId: '91', requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', reviewedByName: '', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '' }], total: 1, pages: 1 })
vi.mocked(reviewFinanceRechargeRequest).mockReset().mockResolvedValue({ requestId: '91', requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'APPROVED', version: 4, submittedByName: '客户A', reviewedByName: '财务A', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '2026-09-05T10:00:00' })
vi.mocked(adjustFinanceAccount).mockReset().mockResolvedValue({ ...accountFixture, actualBalance: '127080.50', availableBalance: '118580.50', accountVersion: 8 })
vi.mocked(downloadFinanceRechargeVoucher).mockReset().mockResolvedValue(new Blob(['image'], { type: 'image/png' }))
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ id: 'exp-1', taskNo: 'EXP-1', taskName: '客户账户', status: 'PENDING' } as never)
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
@@ -155,17 +173,18 @@ describe('FinanceCustomerAccountsView delivery', () => {
expect(wrapper.text()).toContain('13800000000')
const metrics = wrapper.find('.account-metrics')
expect(metrics.findAll('.account-metric')).toHaveLength(3)
expect(metrics.text()).toContain('待收客户款100.00CNY')
expect(metrics.text()).toContain('待付供应商款40.00CNY')
expect(metrics.text()).toContain('已线下支付供应商款50.00CNY')
expect(metrics.text()).toContain('实际余额¥126580.50CNY')
expect(metrics.text()).toContain('冻结金额¥8500.00CNY')
expect(metrics.text()).toContain('可用余额¥118080.50CNY')
for (const auxiliary of ['待开账金额', '本期已收', '总成本', '已付成本', '已收客户款', '客户线下支付']) expect(metrics.text()).not.toContain(auxiliary)
expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 200 })
expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 1, customerId: '10001' })
expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
expect(listFinanceRechargeRequests).toHaveBeenCalledWith('10001', { page: 1, size: 200 })
wrapper.unmount()
})
it('shows explicit master-data empty states without exposing the customer id', async () => {
vi.mocked(listFinanceAccounts).mockResolvedValue({ records: [{ customerId: '10001', customerCode: '', customerName: '客户A', contactName: '', contactPhone: '', outstandingReceivable: 0, pendingBilling: 0, receivedInPeriod: 0, totalCost: 0, paidCost: 0, outstandingPayable: 0, status: 'ACTIVE', lastChangedAt: '' }], total: 1, pages: 1 })
vi.mocked(listFinanceAccounts).mockResolvedValue({ records: [{ ...accountFixture, customerCode: '', contactName: '', contactPhone: '', actualBalance: '0.00', frozenBalance: '0.00', availableBalance: '0.00', pendingRechargeCount: 0, lastChangedAt: '' }], total: 1, pages: 1 })
const wrapper = await mountAccountsView(['platform:finance:customer-account:list'])
expect(wrapper.text()).toContain('客户编号')
expect(wrapper.text()).toContain('联系人')
@@ -196,6 +215,57 @@ describe('FinanceCustomerAccountsView delivery', () => {
wrapper.unmount()
})
it('shows recharge and deduction only with the independent adjustment permission', async () => {
const readOnly = await mountAccountsView(['platform:finance:customer-account:list'])
expect(readOnly.findAll('button').map(button => button.text())).not.toEqual(expect.arrayContaining(['充值', '扣款']))
readOnly.unmount()
const writable = await mountAccountsView(['platform:finance:customer-account:list', 'platform:finance:customer-account:adjust'])
expect(writable.findAll('button').map(button => button.text())).toEqual(expect.arrayContaining(['充值', '扣款']))
writable.unmount()
})
it('submits a two-decimal adjustment with account version and refreshes detail data', async () => {
const wrapper = await mountAccountsView(['platform:finance:customer-account:list', 'platform:finance:customer-account:adjust'], '/finance/customer-accounts/10001')
const vm = wrapper.vm as unknown as {
adjustmentForm: Record<string, string>
openAdjustment(account: typeof accountFixture, type: 'RECHARGE' | 'DEDUCTION'): void
submitAdjustment(): Promise<void>
}
vm.openAdjustment(accountFixture, 'DEDUCTION')
Object.assign(vm.adjustmentForm, { amount: '100', occurredDate: '2026-09-05', paymentMethod: 'BANK_TRANSFER', referenceNo: 'OUT-1', remark: ' 贴牌费 ' })
await vm.submitAdjustment()
expect(adjustFinanceAccount).toHaveBeenCalledWith('10001', {
type: 'DEDUCTION', amount: '100.00', occurredDate: '2026-09-05', paymentMethod: 'BANK_TRANSFER',
referenceNo: 'OUT-1', remark: '贴牌费', expectedAccountVersion: 7,
})
expect(listFinanceAccounts).toHaveBeenLastCalledWith({ page: 1, size: 1, customerId: '10001' })
expect(listFinanceRechargeRequests).toHaveBeenCalledTimes(2)
expect(listFinanceAccountLedger).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('keeps pending recharge requests first and reviews with request version', async () => {
vi.mocked(listFinanceRechargeRequests).mockResolvedValue({ records: [
{ requestId: '92', requestNo: 'RC-92', amount: '20.00', paymentMethod: 'ALIPAY', voucherFileId: '82', voucherFileName: 'paid.png', status: 'APPROVED', version: 4, submittedByName: '客户A', reviewedByName: '财务A', reviewRemark: '', createdAt: '2026-09-03T10:00:00', reviewedAt: '2026-09-04T10:00:00' },
{ requestId: '91', requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', reviewedByName: '', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '' },
], total: 2, pages: 1 })
const wrapper = await mountAccountsView(['platform:finance:customer-account:list', 'platform:finance:recharge:review'], '/finance/customer-accounts/10001')
const requestText = wrapper.find('.recharge-table').text()
expect(requestText.indexOf('RC-91')).toBeLessThan(requestText.indexOf('RC-92'))
expect(wrapper.find('.recharge-table').findAll('button').map(button => button.text()).filter(text => text === '审核')).toEqual(['审核'])
const vm = wrapper.vm as unknown as { reviewForm: Record<string, string>; openReview(request: Record<string, unknown>): void; submitReview(): Promise<void> }
vm.openReview({ requestId: '91', requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: '81', voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', reviewedByName: '', reviewRemark: '', createdAt: '2026-09-04T10:00:00', reviewedAt: '' })
Object.assign(vm.reviewForm, { decision: 'REJECT', remark: ' 凭证不清晰 ' })
await vm.submitReview()
expect(reviewFinanceRechargeRequest).toHaveBeenCalledWith('91', { decision: 'REJECT', remark: '凭证不清晰', expectedVersion: 3 })
expect(listFinanceRechargeRequests).toHaveBeenCalledTimes(2)
expect(listFinanceAccountLedger).toHaveBeenCalledTimes(2)
wrapper.unmount()
})
it('creates account and ledger export tasks from the complete active filters without page parameters', async () => {
const permissions = ['platform:finance:customer-account:list', 'platform:finance:customer-account:export', 'platform:download:list']
const listWrapper = await mountAccountsView(permissions)
@@ -222,6 +292,19 @@ describe('FinanceCustomerAccountsView delivery', () => {
detailWrapper.unmount()
})
it('loads voucher images through the authenticated blob endpoint', async () => {
const wrapper = await mountAccountsView(['platform:finance:customer-account:list'], '/finance/customer-accounts/10001')
const voucherButton = wrapper.findAll('.recharge-table button').find(button => button.text() === 'receipt.png')
expect(voucherButton).toBeTruthy()
await voucherButton!.trigger('click')
await flushPromises()
expect(downloadFinanceRechargeVoucher).toHaveBeenCalledWith('91')
expect(URL.createObjectURL).toHaveBeenCalled()
expect(wrapper.find('.voucher-preview__image').attributes('src')).toBe('blob:finance-voucher')
wrapper.unmount()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:finance-voucher')
})
it('requires both the finance export permission and download-center access', async () => {
const wrapper = await mountAccountsView(['platform:finance:customer-account:list', 'platform:finance:customer-account:export'])
expect(wrapper.findAll('button').map((button) => button.text())).not.toContain('导出Excel')