feat(finance): manage customer wallet balances
This commit is contained in:
@@ -4,7 +4,12 @@
|
||||
|
||||
- 本文件适用于 `src/modules/finance/`。产品范围和状态读取 `../../../../../docs/财务管理/` 的产品确认与验收契约,后端读取 `../../../../../baiyue-firstleg/finance/AGENTS.md`。
|
||||
- 页面从 `pages/` 进入,统一 API 从 `api/finance.ts` 进入;费用来源和柜次成本快照联查 `firstleg/`,凭证与异步导出联查 `file/`。
|
||||
- 正式范围包括客户账户、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单;不要从历史原型自动扩大财务功能。
|
||||
- 正式范围包括客户资金账户、客户应收应付、应收款统计、客户对账单、应收结算单和应付结算单。资金账户当前只接充值申请审核和平台手工充值/扣款,冻结/解冻、消费及支付回调后续接入;不要从历史原型自动扩大其他财务功能。
|
||||
|
||||
## 客户资金账户
|
||||
|
||||
- `pages/FinanceCustomerAccountsView.vue` 的列表和详情展示实际余额、冻结金额、可用余额;操作列固定第一列。客户上传的充值申请在详情内按待审核优先展示,审核、驳回及凭证预览以服务端申请版本和客户归属为准。
|
||||
- 平台手工充值和扣款必须提交账户版本与幂等键;扣款不得超过服务端可用余额。页面只展示服务端余额和流水结果,不在浏览器补算或回退静态数据。
|
||||
|
||||
## 客户筛选与统计
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user