feat(finance): manage customer wallet balances
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user