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
+6 -1
View File
@@ -4,7 +4,12 @@
- 本文件适用于 `src/modules/finance/`。产品范围和状态读取 `../../../../../docs/财务管理/` 的产品确认与验收契约,后端读取 `../../../../../baiyue-firstleg/finance/AGENTS.md`。
- 页面从 `pages/` 进入,统一 API 从 `api/finance.ts` 进入;费用来源和柜次成本快照联查 `firstleg/`,凭证与异步导出联查 `file/`。
- 正式范围包括客户账户、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单;不要从历史原型自动扩大财务功能。
- 正式范围包括客户资金账户、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单。资金账户当前只接充值申请审核和平台手工充值/扣款,冻结/解冻、消费及支付回调后续接入;不要从历史原型自动扩大其他财务功能。
## 客户资金账户
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、可用余额;操作列固定第一列。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
- 平台手工充值和扣款必须提交账户版本与幂等键;扣款不得超过服务端可用余额。页面只展示服务端余额和流水结果,不在浏览器补算或回退静态数据。
## 客户筛选与统计
+29 -5
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/shared/api/http'
import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, editFinanceFeeSource, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReceivableStatistics, getReconciliationStatement, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
import { actionReconciliationStatement, adjustFinanceAccount, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, downloadFinanceRechargeVoucher, editFinanceFeeSource, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReceivableStatistics, getReconciliationStatement, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceRechargeRequests, listFinanceStatements, reviewFinanceFeeSource, reviewFinanceRechargeRequest, updateFinanceBill, updateFinanceCustomerTerms, uploadFinanceVoucher, voidFinanceBill } from './finance'
describe('finance api', () => {
beforeEach(() => {
@@ -79,21 +79,45 @@ describe('finance api', () => {
})
it('uses the formal G1 account list path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], page: { page: 2, pageSize: 20, total: 21 } })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-g1', traceId: 'trace-g1', data: [{ customerId: 10001, customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00', actualBalance: '126580.50', frozenBalance: '8500.00', availableBalance: '118080.50', accountVersion: 7, pendingRechargeCount: 2 }], page: { page: 2, pageSize: 20, total: 21 } })
const result = await listFinanceAccounts({ page: 2, size: 20, customerId: '10001', status: 'ACTIVE' })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts', { params: { page: 2, pageSize: 20, customerId: '10001', status: 'ACTIVE' } })
expect(result).toEqual({ records: [{ customerId: '10001', customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00' }], total: 21, pages: 2 })
expect(result).toEqual({ records: [{ customerId: '10001', customerCode: 'C-10001', customerName: '客户A', contactName: '张三', contactPhone: '13800000000', outstandingReceivable: 300, pendingBilling: 50, receivedInPeriod: 100, totalCost: 260, paidCost: 180, outstandingPayable: 80, status: 'ACTIVE', lastChangedAt: '2026-08-11T10:00:00', actualBalance: '126580.50', frozenBalance: '8500.00', availableBalance: '118080.50', accountVersion: 7, pendingRechargeCount: 2 }], total: 21, pages: 2 })
})
it('uses the formal G1 account ledger path and fixed page DTO', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款' }], page: { page: 1, pageSize: 50, total: 1 } })
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', requestId: 'req-ledger', traceId: 'trace-ledger', data: [{ ledgerId: 81, occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: 91, documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceType: 'MANUAL_ADJUSTMENT', sourceNo: 'RC-91', createdAt: '2026-08-11T12:00:00' }], page: { page: 1, pageSize: 50, total: 1 } })
const result = await listFinanceAccountLedger('10001', { page: 1, size: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts/10001/ledger', { params: { page: 1, pageSize: 50, businessType: 'RECEIPT', startDate: '2026-08-01', endDate: '2026-08-31' } })
expect(result.records[0]).toEqual({ ledgerId: '81', occurredAt: '2026-08-11T12:00:00', businessType: 'RECEIPT', documentType: 'SETTLEMENT', documentId: '91', documentNo: 'AR-91', receivableChange: -100, payableChange: 0, outstandingReceivableAfter: 200, operatorName: '财务A', remark: '人工收款' })
expect(result.records[0]).toMatchObject({ ledgerId: '81', businessNo: 'AC202608110001', changeType: 'RECHARGE', amount: '500.00', actualBalanceAfter: '1500.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1400.00', sourceNo: 'RC-91' })
})
it('uses frozen recharge review and account adjustment contracts', async () => {
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'ok', data: [{ requestId: 91, requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: 81, voucherFileName: 'receipt.png', status: 'PENDING', version: 3, submittedByName: '客户A', createdAt: '2026-09-04T10:00:00' }], page: { page: 1, pageSize: 200, total: 1 } })
const post = vi.spyOn(http, 'post')
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { requestId: 91, requestNo: 'RC-91', amount: '500.00', paymentMethod: 'WECHAT', voucherFileId: 81, voucherFileName: 'receipt.png', status: 'APPROVED', version: 4 } })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { ledgerId: 101, businessNo: 'AC-101', changeType: 'DEDUCTION', amount: '-100.00', actualBalanceAfter: '1400.00', frozenBalanceAfter: '100.00', availableBalanceAfter: '1300.00' } })
const requests = await listFinanceRechargeRequests('10001')
await reviewFinanceRechargeRequest('91', { decision: 'APPROVE', remark: '凭证有效', expectedVersion: 3 })
const ledger = await adjustFinanceAccount('10001', { type: 'DEDUCTION', amount: '100.00', occurredDate: '2026-09-04', paymentMethod: 'BANK_TRANSFER', referenceNo: 'OUT-1', remark: '贴牌费', expectedAccountVersion: 7 })
expect(get).toHaveBeenCalledWith('/platform/finance/v1/accounts/10001/recharge-requests', { params: { page: 1, pageSize: 200 } })
expect(requests.records[0]).toMatchObject({ requestId: '91', voucherFileId: '81', amount: '500.00', version: 3 })
expect(post).toHaveBeenNthCalledWith(1, '/platform/finance/v1/recharge-requests/91/review', { decision: 'APPROVE', remark: '凭证有效', expectedVersion: 3 }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(post).toHaveBeenNthCalledWith(2, '/platform/finance/v1/accounts/10001/adjustments', { type: 'DEDUCTION', amount: '100.00', occurredDate: '2026-09-04', paymentMethod: 'BANK_TRANSFER', referenceNo: 'OUT-1', remark: '贴牌费', expectedAccountVersion: 7 }, { headers: { 'Idempotency-Key': expect.any(String) } })
expect(ledger).toMatchObject({ ledgerId: 101, businessNo: 'AC-101', changeType: 'DEDUCTION', amount: '-100.00' })
})
it('downloads a recharge voucher as an authenticated blob', async () => {
const blob = new Blob(['image'], { type: 'image/png' })
const get = vi.spyOn(http, 'get').mockResolvedValue(blob)
await expect(downloadFinanceRechargeVoucher('91/2')).resolves.toBe(blob)
expect(get).toHaveBeenCalledWith('/platform/finance/v1/recharge-requests/91%2F2/voucher', { responseType: 'blob' })
})
it('uses the formal paged fee-source path and preserves independent business fields', async () => {
+59 -3
View File
@@ -1,5 +1,5 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
import type { CreatePayableSettlementPayload, CreateReceivableSettlementPayload, CreateReconciliationPayload, CreateSettlementPaymentPayload, FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceFeeSourceSummary, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceReconciliationDetail, FinanceSettlementDetail, FinanceSettlementLine, FinanceStatementReadModel, FinanceVoucherResource, ReceivableStatisticsResult, ReconciliationActionPayload } from '../types'
import type { CreatePayableSettlementPayload, CreateReceivableSettlementPayload, CreateReconciliationPayload, CreateSettlementPaymentPayload, FinanceAccountAdjustmentPayload, FinanceAccountEntry, FinanceAccountSummary, FinanceBillDetail, FinanceBillLine, FinanceBillResource, FinanceBillRevision, FinanceCostResource, FinanceCustomerAccountReadModel, FinanceCustomerTerms, FinanceFeePoolItem, FinanceFeeSourceDetail, FinanceFeeSourceSummary, FinanceLedgerEntry, FinancePayableSettlement, FinanceRechargeRequest, FinanceRechargeReviewPayload, FinanceReceivableSettlement, FinanceReceiptResource, FinanceReconciliationDetail, FinanceSettlementDetail, FinanceSettlementLine, FinanceStatementReadModel, FinanceVoucherResource, ReceivableStatisticsResult, ReconciliationActionPayload } from '../types'
const basePath = '/platform/finance/v1'
export interface FinanceCustomerAccountSummaryResource {
@@ -53,6 +53,7 @@ function idempotencyKey(operation: string) { return `finance-${operation}-${Date
type RawResource = Record<string, unknown>
function stringValue(value: unknown) { return value == null ? '' : String(value) }
function numberValue(value: unknown) { return Number(value ?? 0) }
function nullableStringValue(value: unknown) { return value == null ? null : String(value) }
function jsonRecord(value: unknown): RawResource {
if (value && typeof value === 'object' && !Array.isArray(value)) return value as RawResource
if (typeof value !== 'string' || !value.trim()) return {}
@@ -126,22 +127,77 @@ export async function listFinanceAccounts(params: { page?: number; size?: number
const query = { page: params.page ?? 1, pageSize: params.size ?? 20, customerId: params.customerId || undefined, status: params.status || undefined }
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/accounts`, { params: query })
const data = unwrapApiResponse(response)
const records: FinanceAccountSummary[] = data.map((item) => ({ customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName), contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable), pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), totalCost: numberValue(item.totalCost), paidCost: numberValue(item.paidCost), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status), lastChangedAt: stringValue(item.lastChangedAt) }))
const records: FinanceAccountSummary[] = data.map(normalizeFinanceAccount)
const total = numberValue(response.page?.total ?? records.length)
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
}
function normalizeFinanceAccount(item: RawResource): FinanceAccountSummary {
return {
customerId: stringValue(item.customerId), customerCode: stringValue(item.customerCode), customerName: stringValue(item.customerName),
contactName: stringValue(item.contactName), contactPhone: stringValue(item.contactPhone), outstandingReceivable: numberValue(item.outstandingReceivable),
pendingBilling: numberValue(item.pendingBilling), receivedInPeriod: numberValue(item.receivedInPeriod), totalCost: numberValue(item.totalCost),
paidCost: numberValue(item.paidCost), outstandingPayable: numberValue(item.outstandingPayable), status: stringValue(item.status),
lastChangedAt: stringValue(item.lastChangedAt), actualBalance: stringValue(item.actualBalance), frozenBalance: stringValue(item.frozenBalance),
availableBalance: stringValue(item.availableBalance), accountVersion: numberValue(item.accountVersion), pendingRechargeCount: numberValue(item.pendingRechargeCount),
}
}
export async function listFinanceAccountLedger(customerId: string, params: { page?: number; size?: number; businessType?: string; startDate?: string; endDate?: string } = {}): Promise<{ records: FinanceLedgerEntry[]; total: number; pages: number }> {
const query = { page: params.page ?? 1, pageSize: params.size ?? 20, businessType: params.businessType || undefined, startDate: params.startDate || undefined, endDate: params.endDate || undefined }
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/accounts/${customerId}/ledger`, { params: query })
const data = unwrapApiResponse(response)
const records = data.map((item) => ({ ledgerId: stringValue(item.ledgerId), occurredAt: stringValue(item.occurredAt), businessType: stringValue(item.businessType), documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo), receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange), outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark) }))
const records: FinanceLedgerEntry[] = data.map((item) => ({
ledgerId: stringValue(item.ledgerId), occurredAt: stringValue(item.occurredAt), businessType: stringValue(item.businessType),
documentType: stringValue(item.documentType), documentId: stringValue(item.documentId), documentNo: stringValue(item.documentNo),
receivableChange: numberValue(item.receivableChange), payableChange: numberValue(item.payableChange),
outstandingReceivableAfter: numberValue(item.outstandingReceivableAfter), operatorName: stringValue(item.operatorName), remark: stringValue(item.remark),
businessNo: nullableStringValue(item.businessNo), changeType: nullableStringValue(item.changeType), amount: nullableStringValue(item.amount),
actualBalanceAfter: nullableStringValue(item.actualBalanceAfter), frozenBalanceAfter: nullableStringValue(item.frozenBalanceAfter),
availableBalanceAfter: nullableStringValue(item.availableBalanceAfter), sourceType: nullableStringValue(item.sourceType),
sourceNo: nullableStringValue(item.sourceNo), occurredDate: nullableStringValue(item.occurredDate),
paymentMethod: nullableStringValue(item.paymentMethod), referenceNo: nullableStringValue(item.referenceNo),
createdAt: nullableStringValue(item.createdAt),
}))
const total = numberValue(response.page?.total ?? records.length)
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
}
function normalizeRechargeRequest(item: RawResource): FinanceRechargeRequest {
return {
requestId: stringValue(item.requestId), requestNo: stringValue(item.requestNo), amount: stringValue(item.amount),
paymentMethod: stringValue(item.paymentMethod), voucherFileId: stringValue(item.voucherFileId), voucherFileName: stringValue(item.voucherFileName),
status: stringValue(item.status), version: numberValue(item.version), submittedByName: stringValue(item.submittedByName),
reviewedByName: stringValue(item.reviewedByName), reviewRemark: stringValue(item.reviewRemark), createdAt: stringValue(item.createdAt),
reviewedAt: stringValue(item.reviewedAt),
}
}
export async function listFinanceRechargeRequests(customerId: string, params: { page?: number; size?: number } = {}): Promise<{ records: FinanceRechargeRequest[]; total: number; pages: number }> {
const query = { page: params.page ?? 1, pageSize: params.size ?? 200 }
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/accounts/${customerId}/recharge-requests`, { params: query })
const records = unwrapApiResponse(response).map(normalizeRechargeRequest)
const total = numberValue(response.page?.total ?? records.length)
const pageSize = numberValue(response.page?.pageSize ?? query.pageSize) || query.pageSize
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
}
export async function reviewFinanceRechargeRequest(requestId: string, payload: FinanceRechargeReviewPayload) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/recharge-requests/${requestId}/review`, payload, mutationHeaders('recharge-review'))
return normalizeRechargeRequest(unwrapApiResponse(response))
}
export function downloadFinanceRechargeVoucher(requestId: string) {
return http.get<Blob, Blob>(`${basePath}/recharge-requests/${encodeURIComponent(requestId)}/voucher`, { responseType: 'blob' })
}
export async function adjustFinanceAccount(customerId: string, payload: FinanceAccountAdjustmentPayload) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/accounts/${customerId}/adjustments`, payload, mutationHeaders('account-adjustment'))
return unwrapApiResponse(response)
}
export async function listFinanceFeeSources(params: {
page?: number
size?: number
+6 -5
View File
@@ -63,11 +63,12 @@ describe('finance six-page delivery contract', () => {
expect(page).toContain('options: customers.value.filter')
expect(page).toContain('label: item.tenantName')
expect(page).not.toContain('tenantCode')
expect(types).toContain('export interface FinanceOperatingFundsSummary')
expect(types).toContain('export interface FinanceAccountSummary extends FinanceOperatingFundsSummary')
expect(api).toContain('const records: FinanceAccountSummary[]')
for (const metric of ['待收客户款', '待付供应商款', '已线下支付供应商款']) expect(page).toContain(metric)
for (const forbidden of ['客户线下支付', '已收客户款', '已付成本']) expect(page).not.toContain(forbidden)
expect(types).toContain('export interface FinanceAccountSummary')
expect(types).toContain('actualBalance: string')
expect(types).toContain('export interface FinanceRechargeRequest')
expect(api).toContain('data.map(normalizeFinanceAccount)')
for (const metric of ['实际余额', '冻结金额', '可用余额']) expect(page).toContain(metric)
for (const operation of ['listFinanceRechargeRequests', 'reviewFinanceRechargeRequest', 'adjustFinanceAccount']) expect(page).toContain(operation)
const columns = page.slice(page.indexOf('const columns: DataTableColumn[]'))
expect(columns.indexOf("key: 'actions'")).toBeLessThan(columns.indexOf("key: 'customerName'"))
for (const state of ['客户账户加载失败', '暂无客户账户', '无权访问客户账户', '账户流水加载失败']) expect(page).toContain(state)
@@ -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')
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Download, Eye, Pencil, Save } from '@lucide/vue'
import { ArrowLeft, Download, Eye, MinusCircle, Pencil, PlusCircle, Save } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
@@ -16,9 +17,9 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
import { adjustFinanceAccount, downloadFinanceRechargeVoucher, getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, listFinanceRechargeRequests, reviewFinanceRechargeRequest, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceOperatingFundsSummary } from '../types'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry, FinanceRechargeRequest } from '../types'
const access = useAccessPreviewStore()
const route = useRoute()
@@ -46,31 +47,46 @@ const ledgerFilters = ref<QueryModel>({ businessType: '', occurredRange: [] })
const ledgerPage = ref(1)
const ledgerPageSize = ref(20)
const ledgerTotal = ref(0)
const rechargeRequests = ref<FinanceRechargeRequest[]>([])
const rechargeLoading = ref(false)
const rechargeError = ref<ApiErrorDetails | null>(null)
const accountExportBusy = ref(false)
const ledgerExportBusy = ref(false)
const adjustmentOpen = ref(false)
const adjustmentSaving = ref(false)
const adjustmentAccount = ref<FinanceAccountSummary | null>(null)
const adjustmentForm = reactive({ type: 'RECHARGE' as 'RECHARGE' | 'DEDUCTION', amount: '', occurredDate: '', paymentMethod: 'BANK_TRANSFER', referenceNo: '', remark: '' })
const reviewOpen = ref(false)
const reviewSaving = ref(false)
const reviewRequest = ref<FinanceRechargeRequest | null>(null)
const reviewForm = reactive({ decision: 'APPROVE' as 'APPROVE' | 'REJECT', remark: '' })
const voucherPreviewOpen = ref(false)
const voucherPreviewLoading = ref(false)
const voucherPreviewUrl = ref('')
const voucherPreviewName = ref('付款凭证')
const canView = computed(() => access.can('platform:finance:customer-account:list'))
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
const canExport = computed(() => access.can('platform:finance:customer-account:export') && access.can('platform:download:list'))
const canAdjust = computed(() => access.can('platform:finance:customer-account:adjust'))
const canReviewRecharge = computed(() => access.can('platform:finance:recharge:review'))
const stateError = computed(() => customersError.value || loadError.value)
const queryFields = computed<QueryField[]>(() => [
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '全部客户', props: { 'aria-label': '客户名称', filterable: true, clearable: true, loading: customersLoading.value }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'status', type: 'select', label: '状态', width: 150, placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 160, fixed: 'left', slotName: 'actions' },
{ key: 'actions', label: '操作', width: 310, fixed: 'left', slotName: 'actions' },
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' },
{ key: 'customerCode', label: '客户编号', width: 150, slotName: 'customerCode' },
{ key: 'contactName', label: '联系人', width: 130, slotName: 'contactName' },
{ key: 'contactPhone', label: '联系电话', width: 150, slotName: 'contactPhone' },
{ key: 'outstandingReceivable', label: '待收客户款(CNY)', width: 155 },
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
{ key: 'totalCost', label: '总成本(CNY)', width: 140 },
{ key: 'paidCost', label: '已线下支付供应商款(CNY)', width: 210 },
{ key: 'outstandingPayable', label: '待付供应商款(CNY)', width: 165 },
{ key: 'actualBalance', label: '实际余额(CNY)', width: 155, slotName: 'actualBalance' },
{ key: 'frozenBalance', label: '冻结金额(CNY)', width: 150, slotName: 'frozenBalance' },
{ key: 'availableBalance', label: '可用余额(CNY)', width: 155, slotName: 'availableBalance' },
{ key: 'pendingRechargeCount', label: '待审核充值', width: 120, slotName: 'pendingRechargeCount' },
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170, slotName: 'lastChangedAt' },
]
@@ -86,14 +102,22 @@ const ledgerQueryFields: QueryField[] = [
]
const ledgerColumns: DataTableColumn[] = [
{ key: 'ledgerId', label: '流水号', width: 180, slotName: 'ledgerId' }, { key: 'occurredAt', label: '发生时间', width: 170, slotName: 'occurredAt' }, { key: 'businessType', label: '业务类型', width: 140, slotName: 'businessType' },
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
{ key: 'documentNo', label: '关联单据', width: 220, slotName: 'documentNo' }, { key: 'amount', label: '余额变动(CNY)', width: 145, slotName: 'amount' },
{ key: 'paymentMethod', label: '支付方式', width: 130, slotName: 'ledgerPaymentMethod' },
{ key: 'actualBalanceAfter', label: '变动后实际余额', width: 160, slotName: 'actualBalanceAfter' },
{ key: 'frozenBalanceAfter', label: '变动后冻结金额', width: 160, slotName: 'frozenBalanceAfter' },
{ key: 'availableBalanceAfter', label: '变动后可用余额', width: 160, slotName: 'availableBalanceAfter' },
{ key: 'operatorName', label: '操作人', width: 140, slotName: 'operatorName' }, { key: 'remark', label: '备注', minWidth: 180 },
]
const operatingFundsSummary = computed<FinanceOperatingFundsSummary>(() => rows.value.reduce((result, row) => ({
outstandingReceivable: result.outstandingReceivable + row.outstandingReceivable,
outstandingPayable: result.outstandingPayable + row.outstandingPayable,
paidCost: result.paidCost + row.paidCost,
}), { outstandingReceivable: 0, outstandingPayable: 0, paidCost: 0 }))
const rechargeColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
{ key: 'requestNo', label: '申请单号', minWidth: 190 }, { key: 'createdAt', label: '提交时间', width: 170, slotName: 'createdAt' },
{ key: 'amount', label: '充值金额(CNY)', width: 150, slotName: 'amount' }, { key: 'paymentMethod', label: '付款方式', width: 130, slotName: 'paymentMethod' },
{ key: 'voucherFileName', label: '付款凭证', minWidth: 170, slotName: 'voucherFileName' }, { key: 'submittedByName', label: '提交人', width: 130 },
{ key: 'status', label: '状态', width: 110, slotName: 'status' }, { key: 'reviewedByName', label: '审核人', width: 130 },
{ key: 'reviewRemark', label: '审核备注', minWidth: 180 }, { key: 'reviewedAt', label: '审核时间', width: 170, slotName: 'reviewedAt' },
]
const sortedRechargeRequests = computed(() => [...rechargeRequests.value].sort((left, right) => Number(right.status === 'PENDING') - Number(left.status === 'PENDING')))
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
@@ -148,6 +172,24 @@ async function loadLedger(model: QueryModel = ledgerFilters.value) {
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户流水加载失败,请重试') }
finally { ledgerLoading.value = false }
}
async function loadRechargeRequests() {
if (!selected.value) return
rechargeLoading.value = true; rechargeError.value = null
try { rechargeRequests.value = (await listFinanceRechargeRequests(selected.value.customerId, { page: 1, size: 200 })).records }
catch (error) { rechargeRequests.value = []; rechargeError.value = getApiErrorDetails(error, '充值申请加载失败,请重试') }
finally { rechargeLoading.value = false }
}
async function refreshSelectedAccount() {
if (!selected.value) return
const customerId = selected.value.customerId
const result = await listFinanceAccounts({ page: 1, size: 1, customerId })
selected.value = result.records.find(row => row.customerId === customerId) || null
}
async function refreshWalletDetail() {
await refreshSelectedAccount()
if (!selected.value) return
await Promise.all([loadRechargeRequests(), loadLedger()])
}
async function retryDrawer() { if (selected.value) await openAccount(selected.value as unknown as Record<string, unknown>, editing.value) }
async function saveTerms() {
if (!selected.value || !Number.isInteger(creditDays.value) || creditDays.value < 0 || creditDays.value > 3650) { ElMessage.warning('账期必须是 0 到 3650 的整数天'); return }
@@ -157,7 +199,81 @@ async function saveTerms() {
catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期保存失败,请重试'); ElMessage.error(ledgerError.value.message) }
finally { saving.value = false }
}
function money(value: unknown) { return Number(value || 0).toFixed(2) }
function money(value: unknown) { const amount = String(value ?? '').trim(); return amount ? `¥${amount}` : '—' }
function amountInput(value: string) {
const amount = Number(value)
return Number.isFinite(amount) && amount > 0 ? amount.toFixed(2) : null
}
function today() {
const date = new Date()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${date.getFullYear()}-${month}-${day}`
}
function paymentMethodLabel(value: unknown) {
return ({ BANK_TRANSFER: '银行转账', WECHAT: '微信', ALIPAY: '支付宝', CASH: '现金', OTHER: '其他' } as Record<string, string>)[String(value)] || '其他'
}
function walletBusinessType(value: unknown) {
return ({ RECHARGE: '充值', DEDUCTION: '扣款' } as Record<string, string>)[String(value)] || ledgerBusinessType(value)
}
function openAdjustment(account: FinanceAccountSummary, type: 'RECHARGE' | 'DEDUCTION') {
adjustmentAccount.value = account
Object.assign(adjustmentForm, { type, amount: '', occurredDate: today(), paymentMethod: 'BANK_TRANSFER', referenceNo: '', remark: '' })
adjustmentOpen.value = true
}
async function submitAdjustment() {
if (!adjustmentAccount.value) return
const amount = amountInput(adjustmentForm.amount)
if (!amount || !adjustmentForm.occurredDate || !adjustmentForm.paymentMethod) { ElMessage.warning('请填写有效金额、发生日期和付款方式'); return }
adjustmentSaving.value = true
try {
await adjustFinanceAccount(adjustmentAccount.value.customerId, {
type: adjustmentForm.type, amount, occurredDate: adjustmentForm.occurredDate, paymentMethod: adjustmentForm.paymentMethod,
referenceNo: adjustmentForm.referenceNo.trim() || undefined, remark: adjustmentForm.remark.trim() || undefined,
expectedAccountVersion: adjustmentAccount.value.accountVersion,
})
adjustmentOpen.value = false
ElMessage.success(adjustmentForm.type === 'RECHARGE' ? '手工充值已入账' : '手工扣款已入账')
if (selected.value?.customerId === adjustmentAccount.value.customerId) await refreshWalletDetail()
else await load()
} catch (error) { ElMessage.error(getApiErrorMessage(error, adjustmentForm.type === 'RECHARGE' ? '手工充值失败' : '手工扣款失败')) }
finally { adjustmentSaving.value = false }
}
function openReview(request: FinanceRechargeRequest) {
reviewRequest.value = request
Object.assign(reviewForm, { decision: 'APPROVE', remark: '' })
reviewOpen.value = true
}
async function submitReview() {
if (!reviewRequest.value) return
reviewSaving.value = true
try {
await reviewFinanceRechargeRequest(reviewRequest.value.requestId, {
decision: reviewForm.decision, remark: reviewForm.remark.trim() || undefined, expectedVersion: reviewRequest.value.version,
})
reviewOpen.value = false
ElMessage.success(reviewForm.decision === 'APPROVE' ? '充值申请已通过并入账' : '充值申请已驳回')
await refreshWalletDetail()
} catch (error) { ElMessage.error(getApiErrorMessage(error, '充值申请审核失败')) }
finally { reviewSaving.value = false }
}
function clearVoucherPreview() {
if (voucherPreviewUrl.value) URL.revokeObjectURL(voucherPreviewUrl.value)
voucherPreviewUrl.value = ''
}
async function previewVoucher(request: FinanceRechargeRequest) {
clearVoucherPreview()
voucherPreviewName.value = request.voucherFileName || '付款凭证'
voucherPreviewOpen.value = true
voucherPreviewLoading.value = true
try {
const blob = await downloadFinanceRechargeVoucher(request.requestId)
voucherPreviewUrl.value = URL.createObjectURL(blob)
} catch (error) {
voucherPreviewOpen.value = false
ElMessage.error(getApiErrorMessage(error, '付款凭证加载失败'))
} finally { voucherPreviewLoading.value = false }
}
function customerName(customerId: string, snapshot?: string) {
const masterName = customers.value.find((item) => String(item.id) === String(customerId))?.tenantName?.trim()
const candidate = String(snapshot || '').trim()
@@ -230,14 +346,9 @@ async function closeDetail() {
async function restoreAccountDetail(customerId: string) {
detailOpen.value = true; editing.value = false; ledgerError.value = null
try {
let currentPage = 1; let pages = 1; selected.value = null
do {
const result = await listFinanceAccounts({ page: currentPage, size: 200 })
selected.value = result.records.find((row) => row.customerId === customerId) || null
pages = result.pages; currentPage += 1
} while (!selected.value && currentPage <= pages)
selected.value = (await listFinanceAccounts({ page: 1, size: 1, customerId })).records.find(row => row.customerId === customerId) || null
if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return }
await loadLedger()
await Promise.all([loadLedger(), loadRechargeRequests()])
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') }
}
onMounted(() => {
@@ -245,6 +356,7 @@ onMounted(() => {
if (route.name === 'finance-customer-account-detail' && customerId) { if (canListCustomers.value) void loadCustomers(); void restoreAccountDetail(customerId) }
else { if (canListCustomers.value) void loadCustomers(); void load() }
})
onBeforeUnmount(clearVoucherPreview)
watch(() => [route.name, String(route.params.customerId || '')], ([name, customerId], previous) => {
if (!previous) return
if (name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(String(customerId))
@@ -261,26 +373,38 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
<section v-if="!stateError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button v-if="canExport" :loading="accountExportBusy" :disabled="loading || accountExportBusy" @click="exportAccounts"><Download :size="16" />导出Excel</el-button></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" size="small" plain type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
<template #actions="{ row }"><div class="row-actions"><el-button size="small" plain type="primary" @click="openAccount(row, false)"><Eye :size="15" />详情</el-button><el-button v-if="canAdjust" size="small" plain type="primary" @click="openAdjustment(row as FinanceAccountSummary, 'RECHARGE')"><PlusCircle :size="15" />充值</el-button><el-button v-if="canAdjust" size="small" plain type="danger" @click="openAdjustment(row as FinanceAccountSummary, 'DEDUCTION')"><MinusCircle :size="15" />扣款</el-button><el-button v-if="canUpdateTerms" size="small" plain type="primary" @click="openAccount(row, true)"><Pencil :size="15" />账期</el-button></div></template>
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template>
<template #customerCode="{ row }">{{ customerField(row.customerCode) }}</template><template #contactName="{ row }">{{ customerField(row.contactName) }}</template><template #contactPhone="{ row }">{{ customerField(row.contactPhone) }}</template>
<template #lastChangedAt="{ row }">{{ formatLastChangedAt(row.lastChangedAt) }}</template>
<template #actualBalance="{ row }"><strong class="balance-value is-actual">{{ money(row.actualBalance) }}</strong></template><template #frozenBalance="{ row }"><span class="balance-value is-frozen">{{ money(row.frozenBalance) }}</span></template><template #availableBalance="{ row }"><strong class="balance-value">{{ money(row.availableBalance) }}</strong></template>
<template #pendingRechargeCount="{ row }"><el-tag v-if="row.pendingRechargeCount" type="warning">{{ row.pendingRechargeCount }} 笔</el-tag><span v-else>—</span></template>
<template #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left><span>运营资金:待收客户款 {{ money(operatingFundsSummary.outstandingReceivable) }} · 待付供应商款 {{ money(operatingFundsSummary.outstandingPayable) }} · 已线下支付供应商款 {{ money(operatingFundsSummary.paidCost) }} CNY</span></template>
<template #pagination-left><span>共 {{ total }} 个客户账户</span></template>
</DataTable>
</section>
</ListPageLayout>
<AdminPage v-else as="div" class="finance-account-detail" spacing="normal">
<section class="surface-panel finance-account-detail__surface">
<DetailPageLayout class="finance-account-detail__layout" :aria-label="editing ? '维护客户账期' : '客户账户详情'">
<template #header><div class="detail-heading"><div class="detail-title"><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · 客户财务往来总览</p></div><el-button class="detail-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button></div></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户账户运营资金"><div class="account-metric is-receivable"><span>待收客户款</span><strong>{{ money(selected.outstandingReceivable) }}</strong><small>CNY</small></div><div class="account-metric is-payable"><span>待付供应商款</span><strong>{{ money(selected.outstandingPayable) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>已线下支付供应商款</span><strong>{{ money(selected.paidCost) }}</strong><small>CNY</small></div></section></template>
<template #header><div class="detail-heading"><div class="detail-title"><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ customerName(selected.customerId, selected.customerName) }} · CNY 资金账户</p></div><div v-if="selected && !editing" class="detail-actions"><el-button v-if="canAdjust" type="primary" plain @click="openAdjustment(selected, 'RECHARGE')"><PlusCircle :size="16" />手工充值</el-button><el-button v-if="canAdjust" type="danger" plain @click="openAdjustment(selected, 'DEDUCTION')"><MinusCircle :size="16" />手工扣款</el-button></div><el-button class="detail-back" @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button></div></template>
<template v-if="selected && !ledgerError" #summary><section class="account-metrics" aria-label="客户资金账户余额"><div class="account-metric is-actual"><span>实际余额</span><strong>{{ money(selected.actualBalance) }}</strong><small>CNY</small></div><div class="account-metric is-frozen"><span>冻结金额</span><strong>{{ money(selected.frozenBalance) }}</strong><small>CNY</small></div><div class="account-metric is-available"><span>可用余额</span><strong>{{ money(selected.availableBalance) }}</strong><small>CNY</small></div></section></template>
<LoadErrorState v-if="ledgerError" :title="editing ? '客户账期加载失败' : '账户流水加载失败'" :message="ledgerError.message" :loading="ledgerLoading" @retry="retryDrawer"><template #details><ApiErrorMeta :details="ledgerError" /></template></LoadErrorState>
<template v-else-if="selected">
<FormSection class="customer-profile-section" title="客户信息" :bordered="false"><el-descriptions :column="3"><el-descriptions-item label="客户编号">{{ customerField(selected.customerCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ customerField(selected.contactName) }}</el-descriptions-item><el-descriptions-item label="联系电话">{{ customerField(selected.contactPhone) }}</el-descriptions-item></el-descriptions></FormSection>
<FormSection v-if="!editing" class="recharge-section" title="充值申请" :bordered="false">
<LoadErrorState v-if="rechargeError" title="充值申请加载失败" :message="rechargeError.message" :loading="rechargeLoading" @retry="loadRechargeRequests" />
<div v-else class="detail-table-scroll"><DataTable class="recharge-table" :data="sortedRechargeRequests" :columns="rechargeColumns" :loading="rechargeLoading" row-key="requestId" empty-text="暂无充值申请" column-settings-mode="hide">
<template #actions="{ row }"><el-button v-if="canReviewRecharge && row.status === 'PENDING'" size="small" type="primary" plain @click="openReview(row as FinanceRechargeRequest)">审核</el-button></template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template><template #reviewedAt="{ row }">{{ row.reviewedAt ? formatDateTime(row.reviewedAt) : '—' }}</template>
<template #amount="{ row }"><strong class="balance-value is-actual">{{ money(row.amount) }}</strong></template><template #paymentMethod="{ row }">{{ paymentMethodLabel(row.paymentMethod) }}</template>
<template #voucherFileName="{ row }"><el-button v-if="row.voucherFileId" link type="primary" @click="previewVoucher(row as FinanceRechargeRequest)">{{ row.voucherFileName || '预览付款凭证' }}</el-button><span v-else>—</span></template>
<template #status="{ row }"><el-tag :type="row.status === 'PENDING' ? 'warning' : row.status === 'APPROVED' ? 'success' : 'danger'">{{ row.status === 'PENDING' ? '待审核' : row.status === 'APPROVED' ? '已通过' : '已驳回' }}</el-tag></template>
</DataTable></div>
</FormSection>
<FormSection v-if="!editing" class="ledger-section" title="账户流水" :bordered="false">
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; void loadLedger($event)" />
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.ledgerId)">{{ displayLedgerId(row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template><template #businessType="{ row }">{{ ledgerBusinessType(row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.documentNo)">{{ displayDocumentNo(row.documentNo) }}</span></template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
<div class="detail-table-scroll"><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" class="ledger-table" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #toolbar-actions><el-button v-if="canExport" :loading="ledgerExportBusy" :disabled="ledgerLoading || ledgerExportBusy" @click="exportLedger"><Download :size="16" />导出流水</el-button></template><template #pagination-left>共 {{ ledgerTotal }} 条</template><template #ledgerId="{ row }"><span class="ledger-identifier" :title="displayLedgerId(row.businessNo || row.ledgerId)">{{ displayLedgerId(row.businessNo || row.ledgerId) }}</span></template><template #occurredAt="{ row }">{{ row.occurredDate || formatDateTime(row.createdAt || row.occurredAt) }}</template><template #businessType="{ row }">{{ walletBusinessType(row.changeType || row.businessType) }}</template><template #documentNo="{ row }"><span class="ledger-document" :title="displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo)">{{ displayDocumentNo(row.referenceNo || row.sourceNo || row.documentNo) }}</span></template><template #amount="{ row }">{{ money(row.amount) }}</template><template #ledgerPaymentMethod="{ row }">{{ row.paymentMethod ? paymentMethodLabel(row.paymentMethod) : '—' }}</template><template #actualBalanceAfter="{ row }">{{ money(row.actualBalanceAfter) }}</template><template #frozenBalanceAfter="{ row }">{{ money(row.frozenBalanceAfter) }}</template><template #availableBalanceAfter="{ row }">{{ money(row.availableBalanceAfter) }}</template><template #operatorName="{ row }">{{ displayOperator(row.operatorName) }}</template></DataTable></div>
</FormSection>
<FormSection v-else class="term-section" title="账期设置" :bordered="false"><div class="term-editor"><span class="term-label">账期天数</span><div class="term-control"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" placeholder="请输入账期天数" /><span class="term-unit">天</span></div></div></FormSection>
</template>
@@ -288,6 +412,17 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
</DetailPageLayout>
</section>
</AdminPage>
<AppDialog v-model="adjustmentOpen" :title="adjustmentForm.type === 'RECHARGE' ? '手工充值' : '手工扣款'" width="min(720px, calc(100vw - 28px))" :confirm-text="adjustmentForm.type === 'RECHARGE' ? '确认充值' : '确认扣款'" :confirm-type="adjustmentForm.type === 'RECHARGE' ? 'primary' : 'danger'" :confirm-loading="adjustmentSaving" @confirm="submitAdjustment">
<el-alert :title="adjustmentForm.type === 'RECHARGE' ? '充值成功后立即计入客户实际余额并生成资金流水。' : '扣款不得超过服务端可用余额;冻结或停用账户由服务端拒绝。'" :type="adjustmentForm.type === 'RECHARGE' ? 'info' : 'warning'" :closable="false" />
<el-form label-position="top" class="wallet-form"><div class="wallet-form__grid"><el-form-item label="客户名称"><el-input :model-value="adjustmentAccount ? customerName(adjustmentAccount.customerId, adjustmentAccount.customerName) : ''" readonly /></el-form-item><el-form-item label="当前实际余额"><el-input :model-value="money(adjustmentAccount?.actualBalance)" readonly /></el-form-item><el-form-item v-if="adjustmentForm.type === 'DEDUCTION'" label="当前可用余额"><el-input :model-value="money(adjustmentAccount?.availableBalance)" readonly /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值金额' : '扣款金额'" required><el-input v-model="adjustmentForm.amount" inputmode="decimal" placeholder="请输入大于 0 的金额" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '到账日期' : '扣款日期'" required><el-date-picker v-model="adjustmentForm.occurredDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值方式' : '扣款方式'" required><el-select v-model="adjustmentForm.paymentMethod"><el-option label="银行转账" value="BANK_TRANSFER" /><el-option label="现金" value="CASH" /><el-option label="其他" value="OTHER" /></el-select></el-form-item><el-form-item label="关联单据"><el-input v-model="adjustmentForm.referenceNo" maxlength="128" placeholder="请输入关联业务单号(选填)" /></el-form-item></div><el-form-item :label="adjustmentForm.type === 'RECHARGE' ? '充值备注' : '扣款备注'"><el-input v-model="adjustmentForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入备注(选填)" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="reviewOpen" title="审核充值申请" width="min(620px, calc(100vw - 28px))" confirm-text="确认审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-descriptions v-if="reviewRequest" :column="2" border><el-descriptions-item label="申请单号">{{ reviewRequest.requestNo }}</el-descriptions-item><el-descriptions-item label="充值金额">{{ money(reviewRequest.amount) }}</el-descriptions-item><el-descriptions-item label="付款方式">{{ paymentMethodLabel(reviewRequest.paymentMethod) }}</el-descriptions-item><el-descriptions-item label="付款凭证">{{ reviewRequest.voucherFileName || '—' }}</el-descriptions-item></el-descriptions>
<el-form label-position="top" class="review-form"><el-form-item label="审核结果" required><el-radio-group v-model="reviewForm.decision"><el-radio value="APPROVE">通过</el-radio><el-radio value="REJECT">驳回</el-radio></el-radio-group></el-form-item><el-form-item label="审核备注"><el-input v-model="reviewForm.remark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="请输入审核备注(选填)" /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="voucherPreviewOpen" :title="voucherPreviewName" width="min(920px, calc(100vw - 28px))" :show-footer="false" @closed="clearVoucherPreview">
<div v-loading="voucherPreviewLoading" class="voucher-preview"><img v-if="voucherPreviewUrl" class="voucher-preview__image" :src="voucherPreviewUrl" :alt="voucherPreviewName" /></div>
</AppDialog>
</AdminPage>
</template>
@@ -338,6 +473,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
.row-actions,
.detail-footer,
.detail-heading,
.detail-actions,
.term-control {
display: flex;
align-items: center;
@@ -378,6 +514,40 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
margin-left: auto;
}
.detail-actions {
margin-left: auto;
}
.detail-actions + .detail-back {
margin-left: 0;
}
.balance-value {
color: var(--cc-text);
font-variant-numeric: tabular-nums;
}
.balance-value.is-actual {
color: var(--el-color-success);
}
.balance-value.is-frozen {
color: var(--el-color-warning);
}
.voucher-preview {
display: grid;
min-height: 320px;
place-items: center;
}
.voucher-preview__image {
display: block;
max-width: 100%;
max-height: 70vh;
object-fit: contain;
}
.account-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -428,6 +598,15 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
padding: 10px 0 0;
}
.recharge-section {
padding: 10px 0 0;
border-bottom: 1px solid var(--cc-border, #e3e8ef);
}
.recharge-table {
min-width: 0;
}
.ledger-query {
margin-bottom: 8px;
}
@@ -494,6 +673,22 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
color: var(--cc-muted);
}
.wallet-form,
.review-form {
margin-top: 18px;
}
.wallet-form__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 18px;
}
.wallet-form :deep(.el-date-editor),
.wallet-form :deep(.el-select) {
width: 100%;
}
.detail-footer {
width: 100%;
justify-content: flex-end;
@@ -561,6 +756,12 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
}
}
@media (max-width: 720px) {
.wallet-form__grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.detail-heading {
align-items: stretch;
+53 -7
View File
@@ -175,23 +175,25 @@ export interface FinancePayableSettlement {
version: number
}
export interface FinanceOperatingFundsSummary {
outstandingReceivable: number
outstandingPayable: number
paidCost: number
}
export interface FinanceAccountSummary extends FinanceOperatingFundsSummary {
export interface FinanceAccountSummary {
customerId: string
customerCode: string
customerName: string
contactName: string
contactPhone: string
outstandingReceivable: number
pendingBilling: number
receivedInPeriod: number
totalCost: number
paidCost: number
outstandingPayable: number
status: string
lastChangedAt: string
actualBalance: string
frozenBalance: string
availableBalance: string
accountVersion: number
pendingRechargeCount: number
}
export interface ReceivableStatisticsRow {
@@ -227,6 +229,50 @@ export interface FinanceLedgerEntry {
outstandingReceivableAfter: number
operatorName: string
remark: string
businessNo?: string | null
changeType?: string | null
amount?: string | null
actualBalanceAfter?: string | null
frozenBalanceAfter?: string | null
availableBalanceAfter?: string | null
sourceType?: string | null
sourceNo?: string | null
occurredDate?: string | null
paymentMethod?: string | null
referenceNo?: string | null
createdAt?: string | null
}
export interface FinanceRechargeRequest {
requestId: string
requestNo: string
amount: string
paymentMethod: string
voucherFileId: string
voucherFileName: string
status: string
version: number
submittedByName: string
reviewedByName: string
reviewRemark: string
createdAt: string
reviewedAt: string
}
export interface FinanceRechargeReviewPayload {
decision: 'APPROVE' | 'REJECT'
remark?: string
expectedVersion: number
}
export interface FinanceAccountAdjustmentPayload {
type: 'RECHARGE' | 'DEDUCTION'
amount: string
occurredDate: string
paymentMethod: string
referenceNo?: string
remark?: string
expectedAccountVersion: number
}
export interface FinanceFeeSourceDetail extends FinanceFeeSourceSummary {