优化
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, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReconciliationStatement, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance'
|
||||
import { actionReconciliationStatement, adjustFinanceBill, createFinanceCost, createFinanceReceipt, createFinanceSettlementPayment, createPayableSettlement, createReceivableSettlement, createReconciliationStatement, getFinanceBill, getFinanceCustomerAccount, getFinanceCustomerAccountSummary, getFinanceCustomerTerms, getFinanceFeeSource, getFinanceSettlement, getReconciliationStatement, issueFinanceBill, listFinanceAccountLedger, listFinanceAccounts, listFinanceBillRevisions, listFinanceFeePool, listFinanceFeeSources, listFinancePayableSettlements, listFinanceReceivableSettlements, listFinanceStatements, reviewFinanceFeeSource, updateFinanceBill, updateFinanceCustomerTerms, updateFinanceFeeSourceSettlementInclusion, uploadFinanceVoucher, voidFinanceBill } from './finance'
|
||||
|
||||
describe('finance api', () => {
|
||||
beforeEach(() => {
|
||||
@@ -18,13 +18,13 @@ describe('finance api', () => {
|
||||
|
||||
it('uses the customer statement read-model path', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({
|
||||
code: '200', message: 'ok', data: [{ id: 1, customerId: 10001, billNo: 'B-1', customerName: '客户A', billDate: '2026-08-11', dueDate: '2026-09-10', totalAmount: 100, receivedAmount: 0, outstandingAmount: 100, status: 'DRAFT', version: 2, createdBy: 7, createdByName: '创建人A', updatedBy: 8, updatedByName: '更新人B' }],
|
||||
code: '200', message: 'ok', data: [{ statementId: 1, customerId: 10001, statementNo: 'B-1', customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, lineCount: 3, sourceCount: 2, sourceAmount: 120, adjustmentAmount: -20, totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, submittedAt: '2026-08-02T09:00:00', reviewedAt: '2026-08-03T09:00:00', issuedAt: null, status: 'DRAFT', version: 2, createdBy: 7, createdByName: '创建人A', updatedBy: 8, updatedByName: '更新人B' }],
|
||||
})
|
||||
|
||||
const result = await listFinanceStatements('10001')
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/statements', { params: { customerId: '10001' } })
|
||||
expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', currency: 'CNY', status: 'DRAFT', version: 2, createdBy: '7', createdByName: '创建人A', updatedBy: '8', updatedByName: '更新人B' })
|
||||
expect(result[0]).toMatchObject({ id: '1', customerId: '10001', billNo: 'B-1', periodStart: '2026-08-01', periodEnd: '2026-08-31', lineCount: 3, sourceCount: 2, sourceAmount: 120, adjustmentAmount: -20, currency: 'CNY', status: 'DRAFT', version: 2, createdBy: '7', createdByName: '创建人A', updatedBy: '8', updatedByName: '更新人B' })
|
||||
})
|
||||
|
||||
it('never serializes an empty customerId for optional finance list filters', async () => {
|
||||
@@ -61,6 +61,21 @@ describe('finance api', () => {
|
||||
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: '人工收款' })
|
||||
})
|
||||
|
||||
it('uses the formal paged fee-source path and preserves independent business fields', async () => {
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({
|
||||
code: '200', message: 'success', requestId: 'req-fees', traceId: 'trace-fees',
|
||||
data: [{ feeSourceId: 42, sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IB-91', sourceFeeId: 81, sourceVersion: 3, direction: 'PAYABLE', customerId: 10001, customerName: '客户A', supplierId: 71, supplierName: '供应商A', orderId: 11, inboundId: 91, inboundCode: 'IB-91', containerId: 31, containerNo: 'CONT-31', businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', statementGenerated: false }],
|
||||
page: { page: 2, pageSize: 50, total: 51 },
|
||||
})
|
||||
|
||||
const result = await listFinanceFeeSources({ page: 2, size: 50, customerId: '10001', direction: 'PAYABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: ' IB-91 ' })
|
||||
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/fee-sources', { params: {
|
||||
page: 2, pageSize: 50, customerId: '10001', direction: 'PAYABLE', status: 'AVAILABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, businessDateFrom: '2026-08-01', businessDateTo: '2026-08-31', keyword: 'IB-91',
|
||||
} })
|
||||
expect(result).toMatchObject({ total: 51, pages: 2, records: [{ feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceBusinessId: 'IB-91', supplierName: '供应商A', inboundCode: 'IB-91', containerNo: 'CONT-31', businessDate: '2026-08-11', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false }] })
|
||||
})
|
||||
|
||||
it('uses fixed read-model paths for all five finance pages', async () => {
|
||||
const get = vi.spyOn(http, 'get')
|
||||
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { customerId: 10001, totalReceivable: 300, totalReceived: 100, outstandingAmount: 200, entries: [] } })
|
||||
@@ -116,7 +131,7 @@ describe('finance api', () => {
|
||||
})
|
||||
|
||||
it('uses the frozen G3 reconciliation create and action contracts', async () => {
|
||||
const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, createdBy: 7, createdByName: '创建人A', createdAt: '2026-08-01T09:00:00', updatedBy: 8, updatedByName: '更新人B', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, sourceHash: 'hash-42', containerId: 31, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [{ transactionId: 701, transactionType: 'RECEIPT', transactionDate: '2026-08-02', amount: 10, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: 9, createdByName: '收款登记人', createdAt: '2026-08-02T11:00:00' }], revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 120, afterAmount: 100, reason: '优惠', operatorId: 10, operatorName: '调整人', occurredAt: '2026-08-02T12:00:00' }] }
|
||||
const detail = { statementId: 501, statementNo: 'REC-501', customerId: 10001, customerName: '客户A', periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, currency: 'CNY', totalReceivable: 100, receivedAmount: 0, outstandingAmount: 100, settled: false, status: 'DRAFT', version: 0, createdBy: 7, createdByName: '创建人A', createdAt: '2026-08-01T09:00:00', updatedBy: 8, updatedByName: '更新人B', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: 601, feeSourceId: 42, sourceVersion: 3, sourceHash: 'hash-42', direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceNo: 'CONT-31', orderId: 11, inboundId: 21, inboundCode: 'IN-21', containerId: 31, containerNo: 'CONT-31', businessDate: '2026-08-15', quantity: 4, unitPrice: 30, feeItemName: '运费', sourceAmount: 120, statementAmount: 100, adjustmentReason: '优惠调整' }], paymentHistory: [{ transactionId: 701, transactionType: 'RECEIPT', transactionDate: '2026-08-02', amount: 10, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: [9001], createdBy: 9, createdByName: '收款登记人', createdAt: '2026-08-02T11:00:00' }], revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 120, afterAmount: 100, reason: '优惠', operatorId: 10, operatorName: '调整人', occurredAt: '2026-08-02T12:00:00' }] }
|
||||
const post = vi.spyOn(http, 'post').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
const get = vi.spyOn(http, 'get').mockResolvedValue({ code: '200', message: 'success', data: detail })
|
||||
|
||||
@@ -128,7 +143,7 @@ describe('finance api', () => {
|
||||
expect(post.mock.calls[1]).toEqual(['/platform/finance/v1/reconciliation-statements/501/actions', { action: 'SUBMIT', expectedVersion: 0 }, { headers: { 'Idempotency-Key': expect.any(String) } }])
|
||||
expect(get).toHaveBeenCalledWith('/platform/finance/v1/reconciliation-statements/501')
|
||||
expect(created).toMatchObject({ id: '501', billNo: 'REC-501', customerId: '10001', totalAmount: 100, createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01T09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-02T10:00:00', lines: [{ statementLineId: '601', sourceFeeId: '42', sourceHash: 'hash-42', containerId: '31', sourceAmount: 120, amount: 100, adjustmentReason: '优惠调整' }] })
|
||||
expect(loaded.paymentHistory[0]).toMatchObject({ createdBy: '9', createdByName: '收款登记人' })
|
||||
expect(loaded.paymentHistory[0]).toMatchObject({ voucherFileIds: ['9001'], createdBy: '9', createdByName: '收款登记人' })
|
||||
expect(loaded.revisions[0]).toMatchObject({ operatorId: '10', operatorName: '调整人' })
|
||||
})
|
||||
|
||||
|
||||
@@ -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, FinanceLedgerEntry, FinancePayableSettlement, FinanceReceivableSettlement, FinanceReceiptResource, FinanceReconciliationDetail, FinanceSettlementDetail, FinanceStatementReadModel, FinanceVoucherResource, ReconciliationActionPayload } from '../types'
|
||||
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, ReconciliationActionPayload } from '../types'
|
||||
|
||||
const basePath = '/platform/finance/v1'
|
||||
export interface FinanceCustomerAccountSummaryResource {
|
||||
@@ -116,17 +116,44 @@ export async function listFinanceAccountLedger(customerId: string, params: { pag
|
||||
return { records, total, pages: Math.max(1, Math.ceil(total / pageSize)) }
|
||||
}
|
||||
|
||||
export async function listFinanceFeeSources(params: {
|
||||
page?: number
|
||||
size?: number
|
||||
customerId?: string
|
||||
direction?: 'RECEIVABLE' | 'PAYABLE'
|
||||
status?: string
|
||||
reviewStatus?: 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
settlementIncluded?: boolean
|
||||
statementGenerated?: boolean
|
||||
businessDateFrom?: string
|
||||
businessDateTo?: string
|
||||
keyword?: string
|
||||
} = {}): Promise<{ records: FinanceFeeSourceSummary[]; total: number; pages: number }> {
|
||||
const query = {
|
||||
page: params.page ?? 1, pageSize: params.size ?? 20, customerId: params.customerId || undefined,
|
||||
direction: params.direction || undefined, status: params.status || undefined,
|
||||
reviewStatus: params.reviewStatus || undefined, settlementIncluded: params.settlementIncluded,
|
||||
statementGenerated: params.statementGenerated, businessDateFrom: params.businessDateFrom || undefined,
|
||||
businessDateTo: params.businessDateTo || undefined, keyword: params.keyword?.trim() || undefined,
|
||||
}
|
||||
const response = await http.get<ApiEnvelope<RawResource[]> & { page?: RawResource }, ApiEnvelope<RawResource[]> & { page?: RawResource }>(`${basePath}/fee-sources`, { params: query })
|
||||
const records = unwrapApiResponse(response).map(normalizeFeeSourceSummary)
|
||||
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 getFinanceFeeSource(feeSourceId: string): Promise<FinanceFeeSourceDetail> {
|
||||
const response = await http.get<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/fee-sources/${feeSourceId}`)
|
||||
return normalizeFeeSourceDetail(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail {
|
||||
function normalizeFeeSourceSummary(item: RawResource): FinanceFeeSourceSummary {
|
||||
const nullableString = (value: unknown) => value == null ? null : stringValue(value)
|
||||
const nullableNumber = (value: unknown) => value == null ? null : numberValue(value)
|
||||
return {
|
||||
poolItemId: stringValue(item.feeSourceId), feeSourceId: stringValue(item.feeSourceId),
|
||||
sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceFeeId), sourceFeeId: stringValue(item.sourceFeeId),
|
||||
poolItemId: stringValue(item.feeSourceId), feeSourceId: stringValue(item.feeSourceId), sourceSystem: stringValue(item.sourceSystem),
|
||||
sourceType: stringValue(item.sourceType), sourceBusinessId: stringValue(item.sourceBusinessId), sourceId: stringValue(item.sourceFeeId),
|
||||
sourceVersion: numberValue(item.sourceVersion), direction: stringValue(item.direction) as FinanceFeeSourceDetail['direction'],
|
||||
customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), supplierId: nullableString(item.supplierId), supplierName: stringValue(item.supplierName),
|
||||
orderId: nullableString(item.orderId), inboundId: nullableString(item.inboundId), inboundCode: stringValue(item.inboundCode),
|
||||
@@ -134,8 +161,15 @@ function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail {
|
||||
feeItemName: stringValue(item.feeItemName), quantity: nullableNumber(item.quantity), unitPrice: nullableNumber(item.unitPrice), amount: numberValue(item.amount), currency: 'CNY',
|
||||
reviewStatus: stringValue(item.reviewStatus) as FinanceFeeSourceDetail['reviewStatus'], settlementIncluded: Boolean(item.settlementIncluded),
|
||||
sourceStatus: stringValue(item.sourceStatus), status: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated),
|
||||
createdAt: stringValue(item.businessDate),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFeeSourceDetail(item: RawResource): FinanceFeeSourceDetail {
|
||||
return {
|
||||
...normalizeFeeSourceSummary(item), sourceFeeId: stringValue(item.sourceFeeId),
|
||||
reviewReason: stringValue(item.reviewReason) || undefined, settlementReason: stringValue(item.settlementReason) || undefined,
|
||||
sourceHash: stringValue(item.sourceHash) || undefined, operationVersion: numberValue(item.operationVersion), createdAt: stringValue(item.businessDate),
|
||||
sourceHash: stringValue(item.sourceHash) || undefined, operationVersion: numberValue(item.operationVersion),
|
||||
operationLogs: (Array.isArray(item.operationLogs) ? item.operationLogs : []).map((entry) => { const row = entry as RawResource; return { occurredAt: stringValue(row.occurredAt), action: stringValue(row.action), operatorName: stringValue(row.operatorName), result: stringValue(row.result), remark: stringValue(row.remark) || undefined } }),
|
||||
}
|
||||
}
|
||||
@@ -153,7 +187,10 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation
|
||||
const row = entry as RawResource
|
||||
return {
|
||||
statementLineId: stringValue(row.statementLineId), sourceFeeId: stringValue(row.feeSourceId), sourceFeeVersion: numberValue(row.sourceVersion), sourceHash: stringValue(row.sourceHash) || undefined,
|
||||
orderId: null, inboundId: row.inboundId == null ? null : stringValue(row.inboundId), containerId: row.containerId == null ? null : stringValue(row.containerId),
|
||||
direction: stringValue(row.direction) as 'RECEIVABLE' | 'PAYABLE', sourceType: stringValue(row.sourceType), sourceNo: stringValue(row.sourceNo),
|
||||
orderId: row.orderId == null ? null : stringValue(row.orderId), inboundId: row.inboundId == null ? null : stringValue(row.inboundId), containerId: row.containerId == null ? null : stringValue(row.containerId),
|
||||
inboundCode: stringValue(row.inboundCode), containerNo: stringValue(row.containerNo), businessDate: stringValue(row.businessDate),
|
||||
quantity: row.quantity == null ? null : numberValue(row.quantity), unitPrice: row.unitPrice == null ? null : numberValue(row.unitPrice),
|
||||
feeItemName: stringValue(row.feeItemName), sourceAmount: numberValue(row.sourceAmount), amount: numberValue(row.statementAmount), adjustmentReason: stringValue(row.adjustmentReason) || undefined,
|
||||
}
|
||||
})
|
||||
@@ -161,7 +198,7 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation
|
||||
id: stringValue(item.statementId), billNo: stringValue(item.statementNo), customerId: stringValue(item.customerId), customerName: stringValue(item.customerName),
|
||||
billDate: stringValue(item.periodEnd), dueDate: '', periodStart: stringValue(item.periodStart), periodEnd: stringValue(item.periodEnd), creditDays: numberValue(item.creditDays), currency: 'CNY',
|
||||
totalAmount: numberValue(item.totalReceivable), paidAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), settled: Boolean(item.settled), status: stringValue(item.status) as FinanceBillResource['status'], version: numberValue(item.version), remark: stringValue(item.remark), createdBy: stringValue(item.createdBy), createdByName: stringValue(item.createdByName), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedByName: stringValue(item.updatedByName), updatedAt: stringValue(item.updatedAt), lines,
|
||||
paymentHistory: (Array.isArray(item.paymentHistory) ? item.paymentHistory : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), createdBy: stringValue(row.createdBy), createdByName: stringValue(row.createdByName), occurredAt: stringValue(row.occurredAt ?? row.createdAt), remark: stringValue(row.remark) || undefined } }),
|
||||
paymentHistory: (Array.isArray(item.paymentHistory) ? item.paymentHistory : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], createdBy: stringValue(row.createdBy), createdByName: stringValue(row.createdByName), occurredAt: stringValue(row.occurredAt ?? row.createdAt), remark: stringValue(row.remark) || undefined } }),
|
||||
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), operatorName: stringValue(row.operatorName), occurredAt: stringValue(row.occurredAt) } }),
|
||||
}
|
||||
}
|
||||
@@ -169,14 +206,27 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation
|
||||
function normalizeSettlementDetail(item: RawResource): FinanceSettlementDetail {
|
||||
const summary = item.summary && typeof item.summary === 'object' ? item.summary as RawResource : item
|
||||
return {
|
||||
id: stringValue(summary.settlementId ?? summary.id), settlementNo: stringValue(summary.settlementNo), customerId: stringValue(summary.customerId), customerName: stringValue(summary.customerName), supplierId: summary.supplierId == null ? undefined : stringValue(summary.supplierId), supplierName: stringValue(summary.supplierName), reconciliationStatementId: summary.reconciliationStatementId == null ? undefined : stringValue(summary.reconciliationStatementId), businessPeriod: stringValue(summary.businessPeriod), generatedDate: stringValue(summary.generatedDate), dueDate: stringValue(summary.dueDate), paymentStatus: stringValue(summary.paymentStatus) as FinanceSettlementDetail['paymentStatus'], direction: stringValue(summary.direction) as FinanceSettlementDetail['direction'],
|
||||
amount: numberValue(summary.totalAmount ?? summary.amount), paidAmount: numberValue(summary.paidAmount), outstandingAmount: numberValue(summary.outstandingAmount), currency: 'CNY', status: stringValue(summary.status), version: numberValue(summary.version),
|
||||
lines: (Array.isArray(item.lines) ? item.lines : []).map((line) => normalizeFeeSourceDetail(line as RawResource)),
|
||||
id: stringValue(summary.settlementId ?? summary.id), settlementNo: stringValue(summary.settlementNo), customerId: stringValue(summary.customerId), customerName: stringValue(summary.customerName), supplierId: summary.supplierId == null ? undefined : stringValue(summary.supplierId), supplierName: stringValue(summary.supplierName), reconciliationStatementId: summary.reconciliationStatementId == null ? undefined : stringValue(summary.reconciliationStatementId), reconciliationStatementNo: stringValue(summary.reconciliationStatementNo) || undefined, businessPeriod: stringValue(summary.businessPeriod), feeItemSummary: stringValue(summary.feeItemSummary) || undefined, generatedDate: stringValue(summary.generatedDate), dueDate: stringValue(summary.dueDate), paymentStatus: stringValue(summary.paymentStatus) as FinanceSettlementDetail['paymentStatus'], direction: stringValue(summary.direction) as FinanceSettlementDetail['direction'],
|
||||
amount: numberValue(summary.totalAmount ?? summary.amount), paidAmount: numberValue(summary.paidAmount), outstandingAmount: numberValue(summary.outstandingAmount), currency: 'CNY', status: stringValue(summary.status), createdBy: stringValue(summary.createdBy) || undefined, createdByName: stringValue(summary.createdByName) || undefined, createdAt: stringValue(summary.createdAt) || undefined, updatedAt: stringValue(summary.updatedAt) || undefined, lastPaymentAt: stringValue(summary.lastPaymentAt) || undefined, version: numberValue(summary.version),
|
||||
lines: (Array.isArray(item.lines) ? item.lines : []).map((line) => normalizeSettlementLine(line as RawResource)),
|
||||
transactions: (Array.isArray(item.transactions) ? item.transactions : []).map((entry) => { const row = entry as RawResource; return { transactionId: stringValue(row.transactionId), transactionType: stringValue(row.transactionType), transactionDate: stringValue(row.transactionDate), amount: numberValue(row.amount), method: stringValue(row.method), referenceNo: stringValue(row.referenceNo), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], createdBy: stringValue(row.createdBy), createdByName: stringValue(row.createdByName), createdAt: stringValue(row.createdAt), remark: stringValue(row.remark) || undefined } }),
|
||||
revisions: (Array.isArray(item.revisions) ? item.revisions : []).map((entry) => { const row = entry as RawResource; return { revisionNo: numberValue(row.revisionNo), action: stringValue(row.action), beforeAmount: numberValue(row.beforeAmount), afterAmount: numberValue(row.afterAmount), reason: stringValue(row.reason) || undefined, operatorId: stringValue(row.operatorId), operatorName: stringValue(row.operatorName), occurredAt: stringValue(row.occurredAt) } }),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSettlementLine(item: RawResource): FinanceSettlementLine {
|
||||
const nullableString = (value: unknown) => value == null ? null : stringValue(value)
|
||||
const nullableNumber = (value: unknown) => value == null ? null : numberValue(value)
|
||||
return {
|
||||
feeSourceId: stringValue(item.feeSourceId), sourceType: stringValue(item.sourceType), sourceFeeId: stringValue(item.sourceFeeId), sourceVersion: numberValue(item.sourceVersion),
|
||||
direction: stringValue(item.direction) as FinanceSettlementLine['direction'], customerId: stringValue(item.customerId), customerName: stringValue(item.customerName),
|
||||
supplierId: nullableString(item.supplierId), supplierName: stringValue(item.supplierName), orderId: nullableString(item.orderId), orderNo: stringValue(item.orderNo),
|
||||
inboundId: nullableString(item.inboundId), inboundCode: stringValue(item.inboundCode), containerId: nullableString(item.containerId), containerNo: stringValue(item.containerNo),
|
||||
businessDate: stringValue(item.businessDate), feeItemName: stringValue(item.feeItemName), quantity: nullableNumber(item.quantity), unitPrice: nullableNumber(item.unitPrice),
|
||||
amount: numberValue(item.amount), currency: 'CNY', reviewStatus: stringValue(item.reviewStatus) as FinanceSettlementLine['reviewStatus'], settlementIncluded: Boolean(item.settlementIncluded), sourceStatus: stringValue(item.sourceStatus), statementGenerated: Boolean(item.statementGenerated),
|
||||
}
|
||||
}
|
||||
|
||||
export async function createReconciliationStatement(payload: CreateReconciliationPayload) {
|
||||
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/statements`, payload, mutationHeaders('reconciliation-create'))
|
||||
return normalizeReconciliationDetail(unwrapApiResponse(response))
|
||||
@@ -212,14 +262,14 @@ export async function actionFinanceSettlement(settlementId: string, payload: imp
|
||||
|
||||
export async function listFinanceStatements(customerId?: string): Promise<FinanceStatementReadModel[]> {
|
||||
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/statements`, { params: { customerId: customerId?.trim() || undefined } })
|
||||
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), totalAmount: numberValue(item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version), createdBy: stringValue(item.createdBy), createdByName: stringValue(item.createdByName), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedByName: stringValue(item.updatedByName), updatedAt: stringValue(item.updatedAt) }))
|
||||
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.statementId ?? item.id), customerId: stringValue(item.customerId), billNo: stringValue(item.statementNo ?? item.billNo), customerName: stringValue(item.customerName), billDate: stringValue(item.periodEnd ?? item.billDate), dueDate: stringValue(item.dueDate), periodStart: stringValue(item.periodStart), periodEnd: stringValue(item.periodEnd), creditDays: item.creditDays == null ? undefined : numberValue(item.creditDays), lineCount: item.lineCount == null ? undefined : numberValue(item.lineCount), sourceCount: item.sourceCount == null ? undefined : numberValue(item.sourceCount), sourceAmount: item.sourceAmount == null ? undefined : numberValue(item.sourceAmount), adjustmentAmount: item.adjustmentAmount == null ? undefined : numberValue(item.adjustmentAmount), totalAmount: numberValue(item.totalReceivable ?? item.totalAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', settled: item.settled == null ? undefined : Boolean(item.settled), lastReceiptAt: stringValue(item.lastReceiptAt), submittedAt: stringValue(item.submittedAt), reviewedAt: stringValue(item.reviewedAt), issuedAt: stringValue(item.issuedAt), status: stringValue(item.status) as FinanceStatementReadModel['status'], version: numberValue(item.version), remark: stringValue(item.remark), createdBy: stringValue(item.createdBy), createdByName: stringValue(item.createdByName), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), updatedByName: stringValue(item.updatedByName), updatedAt: stringValue(item.updatedAt) }))
|
||||
}
|
||||
|
||||
export async function listFinanceReceivableSettlements(customerId?: string): Promise<FinanceReceivableSettlement[]> {
|
||||
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/receivable-settlements`, { params: { customerId: customerId?.trim() || undefined } })
|
||||
return unwrapApiResponse(response).map((item) => ({
|
||||
id: stringValue(item.settlementId ?? item.id), customerId: stringValue(item.customerId),
|
||||
customerName: stringValue(item.customerName), settlementNo: stringValue(item.settlementNo), reconciliationStatementId: item.reconciliationStatementId == null ? undefined : stringValue(item.reconciliationStatementId), businessPeriod: stringValue(item.businessPeriod), generatedDate: stringValue(item.generatedDate), billDate: stringValue(item.generatedDate ?? item.billDate),
|
||||
customerName: stringValue(item.customerName), settlementNo: stringValue(item.settlementNo), reconciliationStatementId: item.reconciliationStatementId == null ? undefined : stringValue(item.reconciliationStatementId), reconciliationStatementNo: stringValue(item.reconciliationStatementNo) || undefined, businessPeriod: stringValue(item.businessPeriod), feeItemSummary: stringValue(item.feeItemSummary) || undefined, generatedDate: stringValue(item.generatedDate), createdBy: stringValue(item.createdBy) || undefined, createdByName: stringValue(item.createdByName) || undefined, createdAt: stringValue(item.createdAt) || undefined, updatedAt: stringValue(item.updatedAt) || undefined, lastPaymentAt: stringValue(item.lastPaymentAt) || undefined, billDate: stringValue(item.generatedDate ?? item.billDate),
|
||||
dueDate: stringValue(item.dueDate), receivableAmount: numberValue(item.totalAmount ?? item.receivableAmount),
|
||||
receivedAmount: numberValue(item.paidAmount ?? item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount),
|
||||
paymentStatus: stringValue(item.paymentStatus) as FinanceReceivableSettlement['paymentStatus'], currency: 'CNY', status: stringValue(item.status) as FinanceReceivableSettlement['status'], version: numberValue(item.version),
|
||||
@@ -231,9 +281,9 @@ export async function listFinancePayableSettlements(customerId?: string): Promis
|
||||
return unwrapApiResponse(response).map((item) => ({
|
||||
id: stringValue(item.settlementId ?? item.id), customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), settlementNo: stringValue(item.settlementNo), supplierId: item.supplierId == null ? undefined : stringValue(item.supplierId),
|
||||
costType: 'SINGLE_ORDER', orderId: null, inboundId: null, containerId: null,
|
||||
supplierName: stringValue(item.supplierName), feeItemName: '', businessPeriod: stringValue(item.businessPeriod), generatedDate: stringValue(item.generatedDate), dueDate: stringValue(item.dueDate), payableAmount: numberValue(item.totalAmount ?? item.payableAmount),
|
||||
supplierName: stringValue(item.supplierName), feeItemName: stringValue(item.feeItemSummary), feeItemSummary: stringValue(item.feeItemSummary) || undefined, businessPeriod: stringValue(item.businessPeriod), generatedDate: stringValue(item.generatedDate), dueDate: stringValue(item.dueDate), payableAmount: numberValue(item.totalAmount ?? item.payableAmount),
|
||||
paidAmount: numberValue(item.paidAmount), outstandingAmount: numberValue(item.outstandingAmount), paymentStatus: stringValue(item.paymentStatus) as FinancePayableSettlement['paymentStatus'], currency: 'CNY',
|
||||
status: stringValue(item.status), createdAt: stringValue(item.generatedDate ?? item.createdAt), version: numberValue(item.version),
|
||||
status: stringValue(item.status), createdAt: stringValue(item.createdAt), createdBy: stringValue(item.createdBy) || undefined, createdByName: stringValue(item.createdByName) || undefined, updatedAt: stringValue(item.updatedAt) || undefined, lastPaymentAt: stringValue(item.lastPaymentAt) || undefined, version: numberValue(item.version),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -136,12 +136,25 @@ describe('FinanceCustomerAccountsView delivery', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('客户A')
|
||||
expect(wrapper.text()).not.toContain('C-001')
|
||||
expect(wrapper.text()).toContain('C-001')
|
||||
expect(wrapper.text()).toContain('张三')
|
||||
expect(wrapper.text()).toContain('13800000000')
|
||||
expect(listFinanceAccounts).toHaveBeenCalledWith({ page: 1, size: 200 })
|
||||
expect(listFinanceAccountLedger).toHaveBeenCalledWith('10001', expect.objectContaining({ page: 1, size: 20 }))
|
||||
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, outstandingPayable: 0, status: 'ACTIVE', lastChangedAt: '' }], total: 1, pages: 1 })
|
||||
const wrapper = await mountAccountsView(['platform:finance:customer-account:list'])
|
||||
expect(wrapper.text()).toContain('客户编号')
|
||||
expect(wrapper.text()).toContain('联系人')
|
||||
expect(wrapper.text()).toContain('联系电话')
|
||||
expect(wrapper.text().match(/未维护/g)?.length).toBeGreaterThanOrEqual(3)
|
||||
expect(wrapper.text()).not.toContain('客户10001')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('formats known ledger codes, operator ids and timestamps for display', async () => {
|
||||
vi.mocked(listFinanceAccountLedger).mockResolvedValue({ records: [{ ledgerId: '2087867025978863617', occurredAt: '2026-08-13T10:00:00', businessType: 'SETTLEMENT_ISSUED', documentType: 'BILL', documentId: '2', documentNo: 'REC-00000000000000000001', receivableChange: 10, payableChange: 0, outstandingReceivableAfter: 90, operatorName: 'operator-1000001', remark: '结算单生效' }], total: 1, pages: 1 })
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
|
||||
@@ -62,6 +62,9 @@ const queryFields = computed<QueryField[]>(() => [
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, 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: 130 },
|
||||
{ key: 'pendingBilling', label: '待开账(CNY)', width: 140 },
|
||||
{ key: 'receivedInPeriod', label: '本期已收(CNY)', width: 140 },
|
||||
@@ -176,6 +179,7 @@ function formatDateTime(value: unknown) {
|
||||
return Number.isNaN(date.getTime()) ? '时间数据缺失' : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function formatLastChangedAt(value: unknown) { return String(value || '').trim() ? formatDateTime(value) : '暂无变动' }
|
||||
function customerField(value: unknown) { return String(value || '').trim() || '未维护' }
|
||||
function displayDocumentNo(value: unknown) { return String(value || '').trim() || '关联单据缺失' }
|
||||
function displayLedgerId(value: unknown) { return String(value || '').trim() || '流水号缺失' }
|
||||
async function finishExport(task: Awaited<ReturnType<typeof createPlatformExportTask>>) {
|
||||
@@ -257,6 +261,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
<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 link type="primary" @click="openAccount(row, false)"><Eye :size="15" />查看</el-button><el-button v-if="canUpdateTerms" link 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 #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
|
||||
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 应付 {{ money(summary.payable) }} CNY</span></template>
|
||||
@@ -270,6 +275,7 @@ watch(() => [route.name, String(route.params.customerId || '')], ([name, custome
|
||||
<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-pending"><span>待开账金额</span><strong>{{ money(selected.pendingBilling) }}</strong><small>CNY</small></div><div class="account-metric is-received"><span>本期已收</span><strong>{{ money(selected.receivedInPeriod) }}</strong><small>CNY</small></div><div class="account-metric is-payable"><span>待付金额</span><strong>{{ money(selected.outstandingPayable) }}</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="ledger-section" title="账户流水" :bordered="false">
|
||||
<QueryToolbar v-model="ledgerFilters" class="ledger-query" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" />
|
||||
<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>
|
||||
|
||||
@@ -11,15 +11,15 @@ import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { ApiResponseError } from '@/shared/api/http'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
|
||||
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import type { FinanceFeeSourceDetail } from '../types'
|
||||
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import type { FinanceFeeSourceDetail, FinanceFeeSourceSummary } from '../types'
|
||||
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
|
||||
|
||||
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
|
||||
vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
listFinanceFeePool: vi.fn(),
|
||||
listFinanceFeeSources: vi.fn(),
|
||||
getFinanceFeeSource: vi.fn(),
|
||||
reviewFinanceFeeSource: vi.fn(),
|
||||
updateFinanceFeeSourceSettlementInclusion: vi.fn(),
|
||||
@@ -42,10 +42,18 @@ function createMemoryStorage(): Storage {
|
||||
|
||||
const detail: FinanceFeeSourceDetail = {
|
||||
poolItemId: '42', feeSourceId: '42', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceFeeId: '81', sourceVersion: 3,
|
||||
sourceSystem: 'FIRSTLEG', sourceBusinessId: 'IB-91',
|
||||
customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '', orderId: null, inboundId: '91', inboundCode: 'IB-91', containerId: null, containerNo: '',
|
||||
businessDate: '2026-08-11', feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', settlementIncluded: false,
|
||||
sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: false, operationVersion: 3, createdAt: '2026-08-11', operationLogs: [],
|
||||
}
|
||||
const feeSummary: FinanceFeeSourceSummary = {
|
||||
poolItemId: '42', feeSourceId: '42', sourceSystem: 'FIRSTLEG', sourceType: 'INBOUND_FEE', sourceBusinessId: 'IB-91',
|
||||
sourceId: '81', sourceVersion: 3, customerId: '10001', customerName: '客户A', direction: 'RECEIVABLE', supplierId: null,
|
||||
supplierName: '', orderId: null, inboundId: '91', inboundCode: 'IB-91', containerId: null, containerNo: '', businessDate: '2026-08-11',
|
||||
feeItemName: '运费', quantity: 2, unitPrice: 50, amount: 100, currency: 'CNY', reviewStatus: 'PENDING', settlementIncluded: false,
|
||||
sourceStatus: 'AVAILABLE', statementGenerated: false, status: 'AVAILABLE', createdAt: '2026-08-11',
|
||||
}
|
||||
|
||||
async function mountView(permissions: string[], initialPath = '/finance/customer-arap') {
|
||||
const pinia = createPinia(); setActivePinia(pinia)
|
||||
@@ -81,7 +89,7 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
document.body.innerHTML = ''
|
||||
const customer = { id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource
|
||||
vi.mocked(listPlatformTenants).mockReset().mockResolvedValue({ records: [customer], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(listFinanceFeePool).mockReset().mockResolvedValue([{ poolItemId: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 3, orderId: null, inboundId: '91', containerId: null, feeItemName: '运费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' }])
|
||||
vi.mocked(listFinanceFeeSources).mockReset().mockResolvedValue({ records: [feeSummary], total: 1, pages: 1 })
|
||||
vi.mocked(getFinanceFeeSource).mockReset().mockResolvedValue(detail)
|
||||
vi.mocked(reviewFinanceFeeSource).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', operationVersion: 4 })
|
||||
vi.mocked(updateFinanceFeeSourceSettlementInclusion).mockReset().mockResolvedValue({ ...detail, reviewStatus: 'APPROVED', settlementIncluded: true, operationVersion: 4 })
|
||||
@@ -106,20 +114,20 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
|
||||
it('loads all customers by default and keeps explicit customer filtering', async () => {
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list', 'platform:customer:list'])
|
||||
expect(listFinanceFeePool).toHaveBeenCalledWith(undefined, undefined)
|
||||
expect(listFinanceFeeSources).toHaveBeenCalledWith(expect.objectContaining({ page: 1, size: 20, customerId: undefined, direction: undefined }))
|
||||
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }
|
||||
await vm.load({ customerId: '10001', direction: 'RECEIVABLE', status: '', keyword: '' })
|
||||
expect(listFinanceFeePool).toHaveBeenLastCalledWith('10001', 'RECEIVABLE')
|
||||
expect(listFinanceFeeSources).toHaveBeenLastCalledWith(expect.objectContaining({ customerId: '10001', direction: 'RECEIVABLE' }))
|
||||
expect(wrapper.text()).not.toContain('全客户查询暂不可用')
|
||||
})
|
||||
|
||||
it('maps each all-customer fee row with its backend customerId', async () => {
|
||||
const customerB = { id: '10002', tenantName: '客户B', status: 'ACTIVE' } as PlatformTenantResource
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as PlatformTenantResource, customerB], page: 1, size: 200, total: 2, pages: 1 })
|
||||
vi.mocked(listFinanceFeePool).mockResolvedValue([
|
||||
{ poolItemId: 'RECEIVABLE:42', feeSourceId: '42', customerId: '10001', direction: 'RECEIVABLE', sourceType: 'INBOUND_FEE', sourceId: '81', sourceVersion: 3, orderId: null, inboundId: '91', containerId: null, feeItemName: '运费', amount: 100, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-11' },
|
||||
{ poolItemId: 'PAYABLE:43', feeSourceId: null, customerId: '10002', direction: 'PAYABLE', sourceType: 'MANUAL_COST', sourceId: '43', sourceVersion: 1, orderId: '11', inboundId: null, containerId: null, feeItemName: '拖车费', amount: 200, currency: 'CNY', status: 'POSTED', createdAt: '2026-08-10' },
|
||||
])
|
||||
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [
|
||||
feeSummary,
|
||||
{ ...feeSummary, poolItemId: '43', feeSourceId: '43', sourceBusinessId: 'CONT-43', customerId: '10002', customerName: '客户B', direction: 'PAYABLE', sourceType: 'CONTAINER_FEE', sourceId: '82', orderId: '11', inboundId: null, inboundCode: '', containerId: '31', containerNo: 'CONT-43', supplierId: '71', supplierName: '供应商A', feeItemName: '拖车费', amount: 200 },
|
||||
], total: 2, pages: 1 })
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list'])
|
||||
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }
|
||||
await vm.load({ customerId: '', direction: '', status: '', keyword: '' })
|
||||
@@ -211,15 +219,18 @@ describe('FinanceCustomerArapView G2 operations', () => {
|
||||
})
|
||||
|
||||
it('hides technical source identifiers and versions from list rows', async () => {
|
||||
vi.mocked(listFinanceFeePool).mockResolvedValue([
|
||||
{ poolItemId: 'PAYABLE:358', feeSourceId: '358', customerId: '10001', direction: 'PAYABLE', sourceType: 'CONTAINER_FEE', sourceId: '2088285842638942210', sourceVersion: 1, orderId: null, inboundId: null, containerId: '2088285834627821569', feeItemName: 'ADMIN-API-20260814152244615-整柜成本', amount: 300, currency: 'CNY', status: 'AVAILABLE', createdAt: '2026-08-14T23:25:19.684' },
|
||||
])
|
||||
vi.mocked(listFinanceFeeSources).mockResolvedValue({ records: [{
|
||||
...feeSummary, poolItemId: '358', feeSourceId: '358', sourceType: 'CONTAINER_FEE', sourceBusinessId: 'CONT-20260814', sourceId: '2088285842638942210', sourceVersion: 1,
|
||||
direction: 'PAYABLE', orderId: null, inboundId: null, inboundCode: '', containerId: '2088285834627821569', containerNo: 'CONT-20260814', supplierId: '71', supplierName: '供应商A',
|
||||
feeItemName: 'ADMIN-API-20260814152244615-整柜成本', amount: 300, businessDate: '2026-08-14', createdAt: '2026-08-14',
|
||||
}], total: 1, pages: 1 })
|
||||
const wrapper = await mountView(['platform:finance:customer-arap:list'])
|
||||
await (wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }).load({ customerId: '', direction: '', status: '', keyword: '' })
|
||||
const text = wrapper.text()
|
||||
expect(text).toContain('应付费用 · 整柜成本')
|
||||
expect(text).toContain('柜次费用 · 关联柜次')
|
||||
expect(text).toContain('2026/8/14 23:25:19')
|
||||
expect(text).toContain('整柜成本')
|
||||
expect(text).toContain('柜次 CONT-20260814')
|
||||
expect(text).toContain('供应商A')
|
||||
expect(text).toContain('2026/8/14')
|
||||
expect(text).not.toContain('PAYABLE:358')
|
||||
expect(text).not.toContain('2088285842638942210')
|
||||
expect(text).not.toContain('/v1')
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeeSources, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
|
||||
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
|
||||
import type { FinanceFeeSourceDetail } from '../types'
|
||||
|
||||
@@ -28,11 +28,15 @@ interface ArapRow {
|
||||
customerName: string
|
||||
direction: 'RECEIVABLE' | 'PAYABLE'
|
||||
documentNo: string
|
||||
sourceBusinessId: string
|
||||
sourceType: string
|
||||
sourceId: string
|
||||
orderId: string
|
||||
inboundId: string
|
||||
containerId: string
|
||||
inboundCode: string
|
||||
containerNo: string
|
||||
supplierName: string
|
||||
feeName: string
|
||||
amount: number
|
||||
status: string
|
||||
@@ -49,7 +53,7 @@ const router = useRouter()
|
||||
const customers = ref<PlatformTenantResource[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const customersError = ref<ApiErrorDetails | null>(null)
|
||||
const filters = ref<QueryModel>({ customerId: '', direction: '', reviewStatus: '', settlementIncluded: '', statementGenerated: '', businessRange: [], keyword: '' })
|
||||
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', reviewStatus: '', settlementIncluded: '', statementGenerated: '', businessRange: [], keyword: '' })
|
||||
const allRows = ref<ArapRow[]>([])
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
@@ -73,6 +77,7 @@ const generationForm = reactive<{ mode: 'BY_CUTOFF' | 'SELECTED'; customerId: st
|
||||
const selectedRows = ref<ArapRow[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const exportBusy = ref(false)
|
||||
|
||||
const canView = computed(() => access.can('platform:finance:customer-arap:list'))
|
||||
@@ -87,16 +92,18 @@ const activeCustomers = computed(() => customers.value.filter((item) => item.sta
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'customerId', type: 'select', label: '客户名称', width: 260, placeholder: '全部客户', props: { 'aria-label': '客户名称', filterable: true, clearable: true, loading: customersLoading.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
|
||||
{ key: 'direction', type: 'select', label: '收付方向', width: 140, placeholder: '全部', options: [{ label: '应收', value: 'RECEIVABLE' }, { label: '应付', value: 'PAYABLE' }] },
|
||||
{ key: 'status', type: 'select', label: '来源状态', width: 150, placeholder: '全部状态', options: [{ label: '可结算', value: 'AVAILABLE' }, { label: '已占用', value: 'LOCKED' }, { label: '已生成对账单', value: 'BILLED' }, { label: '已作废', value: 'VOIDED' }, { label: '已被调整替代', value: 'SUPERSEDED' }] },
|
||||
{ key: 'reviewStatus', type: 'select', label: '审核状态', width: 150, placeholder: '全部状态', options: financeArapStatusOptions },
|
||||
{ key: 'settlementIncluded', type: 'select', label: '参与结算', width: 140, placeholder: '全部', options: [{ label: '参与', value: 'true' }, { label: '不参与', value: 'false' }] },
|
||||
{ key: 'statementGenerated', type: 'select', label: '已生成对账单', width: 160, placeholder: '全部', options: [{ label: '已生成', value: 'true' }, { label: '未生成', value: 'false' }] },
|
||||
{ key: 'businessRange', type: 'daterange', label: '业务日期', width: 280 },
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 220, placeholder: '单号/费用/来源ID', props: { 'aria-label': '关键词' } },
|
||||
{ key: 'keyword', type: 'input', label: '关键词', width: 240, placeholder: '业务单号/费用/客户/供应商', props: { 'aria-label': '关键词' } },
|
||||
])
|
||||
const columns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'selection', type: 'selection', width: 48, fixed: 'left', selectable: (row: ArapRow) => row.direction === 'RECEIVABLE' && Boolean(row.feeSourceId) }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
|
||||
{ key: 'sourceId', label: '来源追溯', minWidth: 200 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 },
|
||||
{ key: 'customerName', label: '客户', minWidth: 180, slotName: 'customerName' }, { key: 'businessReference', label: '关联业务', minWidth: 220, slotName: 'businessReference' }, { key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
|
||||
{ key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 }, { key: 'supplierName', label: '供应商', minWidth: 170, slotName: 'supplierName' },
|
||||
{ key: 'status', label: '来源状态', width: 130, slotName: 'sourceStatus' },
|
||||
{ key: 'reviewStatus', label: '审核状态', width: 120, slotName: 'status' }, { key: 'settlementIncluded', label: '参与结算', width: 110, slotName: 'included' }, { key: 'statementGenerated', label: '已生成对账单', width: 140, slotName: 'generated' }, { key: 'businessDate', label: '业务发生日', width: 170, slotName: 'businessDate' },
|
||||
]
|
||||
function customerName(customerId: string, snapshot?: string) {
|
||||
@@ -129,23 +136,35 @@ function sourceSummary(sourceType: unknown, orderId: unknown, inboundId: unknown
|
||||
const type = sourceTypeDisplay(sourceType)
|
||||
return relations.length ? `${type} · ${relations.join('、')}` : type
|
||||
}
|
||||
function businessReference(row: ArapRow) {
|
||||
const references = [row.inboundCode ? `入仓码 ${row.inboundCode}` : '', row.containerNo ? `柜次 ${row.containerNo}` : '']
|
||||
const businessId = row.sourceBusinessId.trim()
|
||||
if (businessId && ![row.inboundCode, row.containerNo].includes(businessId)) references.push(`业务单号 ${businessId}`)
|
||||
return references.filter(Boolean).join(' · ') || '业务关联待补充'
|
||||
}
|
||||
function supplierDisplay(row: ArapRow) { return row.direction === 'RECEIVABLE' ? '不适用' : row.supplierName.trim() || '供应商待补充' }
|
||||
function sourceStatusDisplay(value: unknown) {
|
||||
const labels: Record<string, string> = { AVAILABLE: '可结算', LOCKED: '已占用', BILLED: '已生成对账单', VOIDED: '已作废', SUPERSEDED: '已被调整替代' }
|
||||
return labels[String(value || '').toUpperCase()] || '状态待确认'
|
||||
}
|
||||
function formatDateTime(value: unknown) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) return '时间暂不可用'
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime()) ? '时间暂不可用' : date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
function formatBusinessDate(value: unknown) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return '业务日期待补充'
|
||||
const [year, month, day] = raw.split('-')
|
||||
return `${year}/${Number(month)}/${Number(day)}`
|
||||
}
|
||||
function displayOperator(value: unknown) {
|
||||
const operator = String(value || '').trim()
|
||||
if (!operator) return '名称数据缺失'
|
||||
return /^(?:operator-)?\d+$/i.test(operator) ? '名称数据缺失' : operator
|
||||
}
|
||||
const filteredRows = computed(() => {
|
||||
const keyword = String(filters.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const reviewStatus = String(filters.value.reviewStatus || '')
|
||||
const included = String(filters.value.settlementIncluded || ''); const generated = String(filters.value.statementGenerated || ''); const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
|
||||
return allRows.value.filter((row) => (!direction || row.direction === direction) && (!reviewStatus || row.reviewStatus === reviewStatus) && (!included || String(row.settlementIncluded) === included) && (!generated || String(row.statementGenerated) === generated) && (!range[0] || row.businessDate >= range[0]) && (!range[1] || row.businessDate <= range[1]) && (!keyword || [row.documentNo, row.sourceId, row.feeName].some((value) => value.toLowerCase().includes(keyword))))
|
||||
})
|
||||
const rows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
const rows = computed(() => allRows.value)
|
||||
const selectedAmount = computed(() => selectedRows.value.reduce((sum, row) => sum + row.amount, 0))
|
||||
|
||||
async function loadCustomers() {
|
||||
@@ -156,16 +175,26 @@ async function loadCustomers() {
|
||||
finally { customersLoading.value = false }
|
||||
}
|
||||
async function load(model: QueryModel = filters.value) {
|
||||
Object.assign(filters.value, model); page.value = 1; allRows.value = []; loadError.value = null
|
||||
Object.assign(filters.value, model); allRows.value = []; loadError.value = null
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
loading.value = true
|
||||
try {
|
||||
const direction = String(filters.value.direction || '') as 'RECEIVABLE' | 'PAYABLE' | ''
|
||||
const items = await listFinanceFeePool(customerId || undefined, direction || undefined)
|
||||
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.status, occurredAt: item.createdAt, businessDate: item.createdAt, reviewStatus: item.status, settlementIncluded: null, statementGenerated: null }))
|
||||
const range = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []
|
||||
const result = await listFinanceFeeSources({
|
||||
page: page.value, size: pageSize.value, customerId: customerId || undefined, direction: direction || undefined,
|
||||
status: String(filters.value.status || '') || undefined,
|
||||
reviewStatus: (String(filters.value.reviewStatus || '') || undefined) as 'PENDING' | 'APPROVED' | 'REJECTED' | undefined,
|
||||
settlementIncluded: String(filters.value.settlementIncluded || '') ? String(filters.value.settlementIncluded) === 'true' : undefined,
|
||||
statementGenerated: String(filters.value.statementGenerated || '') ? String(filters.value.statementGenerated) === 'true' : undefined,
|
||||
businessDateFrom: range[0], businessDateTo: range[1], keyword: String(filters.value.keyword || '').trim() || undefined,
|
||||
})
|
||||
allRows.value = result.records.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerId: item.customerId, customerName: customerName(item.customerId, item.customerName), direction: item.direction, documentNo: feeDocumentLabel(item.direction, item.feeItemName), sourceBusinessId: item.sourceBusinessId, sourceType: item.sourceType, sourceId: sourceSummary(item.sourceType, item.orderId, item.inboundId, item.containerId), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', inboundCode: item.inboundCode, containerNo: item.containerNo, supplierName: item.supplierName, feeName: displayFeeName(item.feeItemName), amount: item.amount, status: item.sourceStatus, occurredAt: item.businessDate, businessDate: item.businessDate, reviewStatus: item.reviewStatus, settlementIncluded: item.settlementIncluded, statementGenerated: item.statementGenerated }))
|
||||
total.value = result.total
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function search(model: QueryModel) { page.value = 1; void load(model) }
|
||||
async function loadDetail(row: ArapRow) {
|
||||
detailLoading.value = true; detailError.value = null; detail.value = null
|
||||
if (!row.feeSourceId) { detailError.value = { message: '该手工成本没有可审核的费用来源详情' }; detailLoading.value = false; return null }
|
||||
@@ -263,7 +292,7 @@ async function restoreFeeSourceDetail(feeSourceId: string) {
|
||||
detailOpen.value = true; detailLoading.value = true; detailError.value = null; detail.value = null
|
||||
try {
|
||||
detail.value = await getFinanceFeeSource(feeSourceId)
|
||||
selected.value = { id: detail.value.poolItemId, feeSourceId, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: feeDocumentLabel(detail.value.direction, detail.value.feeItemName), sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', feeName: displayFeeName(detail.value.feeItemName), amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, settlementIncluded: detail.value.settlementIncluded, statementGenerated: detail.value.statementGenerated }
|
||||
selected.value = { id: detail.value.poolItemId, feeSourceId, customerId: detail.value.customerId, customerName: customerName(detail.value.customerId, detail.value.customerName), direction: detail.value.direction, documentNo: feeDocumentLabel(detail.value.direction, detail.value.feeItemName), sourceBusinessId: detail.value.sourceBusinessId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', inboundCode: detail.value.inboundCode, containerNo: detail.value.containerNo, supplierName: detail.value.supplierName, feeName: displayFeeName(detail.value.feeItemName), amount: detail.value.amount, status: detail.value.status, occurredAt: detail.value.createdAt, businessDate: detail.value.businessDate, reviewStatus: detail.value.reviewStatus, settlementIncluded: detail.value.settlementIncluded, statementGenerated: detail.value.statementGenerated }
|
||||
} catch (error) { detailError.value = getApiErrorDetails(error, '费用来源详情加载失败,请重试') }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
@@ -285,18 +314,18 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<AdminPage as="div" class="finance-arap-page" spacing="normal">
|
||||
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
|
||||
<ListPageLayout v-else-if="!detailOpen" aria-label="客户应收应付">
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="load" /></template>
|
||||
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户应收应付筛选" :fields="queryFields" preference-key="finance-customer-arap-query" @search="search" /></template>
|
||||
<template v-if="stateError" #state><LoadErrorState title="客户应收应付加载失败" :message="stateError.message" :loading="customersLoading || loading" @retry="customersError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="stateError" /></template></LoadErrorState></template>
|
||||
<section v-if="!customersError && !loadError" class="surface-panel table-panel">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="filteredRows.length" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange">
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-arap-table" aria-label="客户应收应付列表" row-key="id" empty-text="暂无客户应收应付数据" @selection-change="onSelectionChange" @update:page="load()" @update:page-size="page = 1; load()">
|
||||
<template #toolbar-actions><div class="generation-actions"><el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @click="exportFeeSources"><Download :size="16" />导出Excel</el-button><template v-if="canCreateStatement"><el-button @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></template></div></template>
|
||||
<template #actions="{ row }"><div class="row-actions"><template v-if="row.feeSourceId"><el-button v-if="canViewSource" link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReviewSource" link type="primary" @click="openReview(row)"><FileCheck2 :size="15" />审核</el-button><el-button v-if="canSettleSource" link type="primary" @click="openInclusion(row)"><Link2 :size="15" />结算</el-button></template><span v-else class="muted-action">手工成本</span></div></template>
|
||||
<template #customerName="{ row }">{{ customerName(row.customerId, row.customerName) }}</template><template #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
|
||||
<template #documentNo="{ row }">{{ feeDocumentLabel(row.direction, row.feeName) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #sourceId="{ row }">{{ row.sourceId }}</template>
|
||||
<template #businessReference="{ row }">{{ businessReference(row as unknown as ArapRow) }}</template><template #sourceType="{ row }">{{ sourceTypeDisplay(row.sourceType) }}</template><template #supplierName="{ row }">{{ supplierDisplay(row as unknown as ArapRow) }}</template><template #sourceStatus="{ row }">{{ sourceStatusDisplay(row.status) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="row.reviewStatus === 'APPROVED' ? 'success' : row.reviewStatus === 'REJECTED' ? 'danger' : 'warning'">{{ statusLabel(row.reviewStatus) }}</el-tag></template>
|
||||
<template #included="{ row }">{{ row.settlementIncluded == null ? '待加载' : row.settlementIncluded ? '参与' : '不参与' }}</template>
|
||||
<template #generated="{ row }">{{ row.statementGenerated == null ? '待加载' : row.statementGenerated ? '已生成' : '未生成' }}</template>
|
||||
<template #businessDate="{ row }">{{ formatDateTime(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
|
||||
<template #businessDate="{ row }">{{ formatBusinessDate(row.businessDate) }}</template><template #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
|
||||
</DataTable></section>
|
||||
</ListPageLayout>
|
||||
<AdminPage v-else as="div" class="finance-arap-detail" spacing="normal">
|
||||
@@ -305,7 +334,7 @@ watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSou
|
||||
<template #header><div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div class="detail-title"><h2>费用来源详情</h2><p>{{ selected?.documentNo || '费用来源' }} · {{ selected ? customerName(selected.customerId, selected.customerName) : '客户名称暂不可用' }}</p></div><div class="detail-actions"><el-button v-if="canReviewSource && selected" @click="openReview(selected as unknown as Record<string, unknown>)"><FileCheck2 :size="16" />审核</el-button><el-button v-if="canSettleSource && selected" @click="openInclusion(selected as unknown as Record<string, unknown>)"><Link2 :size="16" />参与结算</el-button></div></div></template>
|
||||
<template v-if="detail" #summary><section class="arap-metrics" aria-label="费用来源概览"><div class="arap-metric is-amount"><span>费用金额</span><strong>{{ money(detail.amount) }}</strong><small>CNY</small></div><div class="arap-metric"><span>收付方向</span><strong>{{ financeDirectionLabel(detail.direction) }}</strong></div><div class="arap-metric"><span>参与结算</span><strong>{{ detail.settlementIncluded ? '是' : '否' }}</strong></div><div class="arap-metric"><span>审核状态</span><strong>{{ statusLabel(detail.reviewStatus) }}</strong></div></section></template>
|
||||
<LoadErrorState v-if="detailError" title="费用来源详情加载失败" :message="detailError.message" :loading="detailLoading" @retry="selected && loadDetail(selected)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
|
||||
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information" title="单据信息" :bordered="false"><el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联单据">{{ detail.inboundCode || detail.containerNo || '—' }}</el-descriptions-item><el-descriptions-item label="业务日期" :span="2">{{ formatDateTime(detail.businessDate) }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement" title="审核与结算" :bordered="false"><el-descriptions :column="1" class="arap-detail"><el-descriptions-item label="费用状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log" title="操作记录" :bordered="false"><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></FormSection></template>
|
||||
<template v-else-if="detail"><div class="arap-detail-grid"><FormSection class="arap-information" title="单据信息" :bordered="false"><el-descriptions :column="2" class="arap-detail"><el-descriptions-item label="客户">{{ customerName(detail.customerId, detail.customerName || selected?.customerName) }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ displayFeeName(detail.feeItemName) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ sourceTypeDisplay(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="关联业务">{{ detail.inboundCode || detail.containerNo || detail.sourceBusinessId || '业务关联待补充' }}</el-descriptions-item><el-descriptions-item v-if="detail.direction === 'PAYABLE'" label="供应商">{{ detail.supplierName || '供应商待补充' }}</el-descriptions-item><el-descriptions-item label="业务日期">{{ formatBusinessDate(detail.businessDate) }}</el-descriptions-item></el-descriptions></FormSection><FormSection class="arap-settlement" title="审核与结算" :bordered="false"><el-descriptions :column="1" class="arap-detail"><el-descriptions-item label="费用状态">{{ sourceStatusDisplay(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因">{{ detail.reviewReason || '未填写' }}</el-descriptions-item><el-descriptions-item label="结算调整原因">{{ detail.settlementReason || '未填写' }}</el-descriptions-item></el-descriptions></FormSection></div><FormSection class="operation-log" title="操作记录" :bordered="false"><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="formatDateTime(log.occurredAt)">{{ financeOperationActionLabel(log.action) }} · {{ displayOperator(log.operatorName) }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></FormSection></template>
|
||||
</DetailPageLayout>
|
||||
</section>
|
||||
</AdminPage>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useAuthStore } from '@/core/auth/auth'
|
||||
import { createPlatformExportTask } from '@/modules/downloads'
|
||||
import { listPlatformTenants } from '@/modules/iam'
|
||||
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
|
||||
import { getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import { getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
|
||||
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
|
||||
|
||||
@@ -19,7 +19,7 @@ vi.mock('@/modules/downloads', () => ({ createPlatformExportTask: vi.fn() }))
|
||||
vi.mock('@/shared/components/exportTaskCreatedDialog', () => ({ showExportTaskCreatedDialog: vi.fn() }))
|
||||
vi.mock('../api/finance', () => ({
|
||||
actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(),
|
||||
getFinanceFeeSource: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(),
|
||||
getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
|
||||
@@ -30,14 +30,17 @@ function memoryStorage(): Storage {
|
||||
|
||||
const statement: FinanceStatementReadModel = {
|
||||
id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31', dueDate: '',
|
||||
periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, lineCount: 1, sourceCount: 1,
|
||||
sourceAmount: 320, adjustmentAmount: -20, settled: false, lastReceiptAt: '2026-08-20 10:00:00',
|
||||
submittedAt: '2026-08-02 09:00:00', reviewedAt: '2026-08-03 09:00:00', issuedAt: '2026-08-04 09:00:00', remark: '八月账单',
|
||||
totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3,
|
||||
createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
}
|
||||
const detail: FinanceReconciliationDetail = {
|
||||
...statement, paidAmount: 100, periodStart: '2026-08-01', periodEnd: '2026-08-31', creditDays: 30, settled: false, remark: '八月账单',
|
||||
createdBy: '7', createdByName: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '8', updatedByName: '更新人B', updatedAt: '2026-08-20 11:00:00',
|
||||
lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, sourceHash: 'hash-81', orderId: '11', inboundId: '21', containerId: '31', feeItemName: '运费', sourceAmount: 320, amount: 300, adjustmentReason: '优惠调整' }],
|
||||
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }],
|
||||
lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, sourceHash: 'hash-81', direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceNo: 'CONT-31', orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: '31', containerNo: 'CONT-31', businessDate: '2026-08-15', quantity: 10, unitPrice: 30, feeItemName: '运费', sourceAmount: 320, amount: 300, adjustmentReason: '优惠调整' }],
|
||||
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', voucherFileIds: ['9001'], createdBy: '9', createdByName: '财务员', occurredAt: '2026-08-20 10:00:00' }],
|
||||
revisions: [{ revisionNo: 1, action: 'ADJUST', beforeAmount: 320, afterAmount: 300, reason: '优惠调整', operatorId: '10', operatorName: '调整人', occurredAt: '2026-08-20 09:00:00' }],
|
||||
}
|
||||
|
||||
@@ -71,13 +74,6 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
vi.mocked(getReconciliationStatement).mockResolvedValue(detail)
|
||||
vi.mocked(createPlatformExportTask).mockReset().mockResolvedValue({ taskName: '客户对账单', taskNo: 'EXP-STMT-1' } as never)
|
||||
vi.mocked(showExportTaskCreatedDialog).mockReset().mockResolvedValue(false)
|
||||
vi.mocked(getFinanceFeeSource).mockResolvedValue({
|
||||
poolItemId: '81', feeSourceId: '81', customerId: '10001', customerName: '客户A', supplierId: null, supplierName: '',
|
||||
direction: 'RECEIVABLE', sourceType: 'CONTAINER_FEE', sourceId: 'FEE-81', sourceFeeId: 'FEE-81', sourceVersion: 2,
|
||||
orderId: '11', inboundId: '21', inboundCode: 'IN-21', containerId: '31', containerNo: 'CONT-31', businessDate: '2026-08-15',
|
||||
feeItemName: '运费', quantity: 10, unitPrice: 30, amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true,
|
||||
sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: true, operationVersion: 2, createdAt: '2026-08-15',
|
||||
})
|
||||
})
|
||||
|
||||
it('opens statement details on a dedicated route instead of below the list', async () => {
|
||||
@@ -101,7 +97,7 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(document.querySelector('[aria-label="客户对账单列表"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="所选对账单联动明细"]')).toBeNull()
|
||||
expect(document.querySelector('[aria-label="对账单摘要"]')?.textContent).toContain('200.00 CNY')
|
||||
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(11)
|
||||
expect(document.querySelectorAll('.statement-summary-grid .statement-summary-item')).toHaveLength(17)
|
||||
expect(document.querySelector('.statement-detail-head')?.textContent).toContain('REC-501')
|
||||
expect(document.querySelector('[aria-label="客户对账单账单明细"]')).toBeTruthy()
|
||||
expect(document.querySelector('.detail-table-scroll [aria-label="客户对账单账单明细"]')).toBeTruthy()
|
||||
@@ -216,12 +212,12 @@ describe('FinanceCustomerStatementsView', () => {
|
||||
expect(listPlatformTenants).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses statement list permission for fee-source details without exposing technical trace fields', async () => {
|
||||
it('uses statement detail snapshots without a second fee-source request or technical trace fields', async () => {
|
||||
const { wrapper } = await mountPage(['platform:finance:customer-statement:list'])
|
||||
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
|
||||
await flushPromises()
|
||||
expect(getFinanceFeeSource).toHaveBeenCalledWith('81')
|
||||
expect(document.body.textContent).toContain('CONT-31')
|
||||
expect(document.body.textContent).toContain('订单 11')
|
||||
expect(document.body.textContent).not.toContain('hash-81')
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ 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 { actionReconciliationStatement, createReconciliationStatement, getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceStatements } from '../api/finance'
|
||||
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
|
||||
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
|
||||
|
||||
@@ -30,7 +30,7 @@ interface AdjustmentRow {
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface StatementRow extends FinanceStatementReadModel {
|
||||
type StatementRow = Omit<FinanceStatementReadModel, 'creditDays' | 'settled'> & {
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
creditDays: number | null
|
||||
@@ -39,15 +39,6 @@ interface StatementRow extends FinanceStatementReadModel {
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface StatementLineSource {
|
||||
containerNo: string
|
||||
inboundCode: string
|
||||
businessDate: string
|
||||
quantity: number | null
|
||||
unitPrice: number | null
|
||||
sourceType: string
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -66,7 +57,6 @@ const detailLoading = ref(false)
|
||||
const detailError = ref<ApiErrorDetails | null>(null)
|
||||
const activeDetailTab = ref<'lines' | 'payments'>('lines')
|
||||
const detailCache = new Map<string, FinanceReconciliationDetail>()
|
||||
const lineSourceCache = new Map<string, StatementLineSource>()
|
||||
const createOpen = ref(false)
|
||||
const createBusy = ref(false)
|
||||
const exportBusy = ref(false)
|
||||
@@ -118,7 +108,11 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'periodStart', label: '结算起始日', width: 130 },
|
||||
{ key: 'periodEnd', label: '结算结束日', width: 130 },
|
||||
{ key: 'creditDays', label: '账期(天)', width: 100 },
|
||||
{ key: 'lineCount', label: '费用明细数', width: 110 },
|
||||
{ key: 'sourceCount', label: '来源数量', width: 100 },
|
||||
{ key: 'currency', label: '货币', width: 86 },
|
||||
{ key: 'sourceAmount', label: '调整前金额(CNY)', width: 150 },
|
||||
{ key: 'adjustmentAmount', label: '调整金额(CNY)', width: 145 },
|
||||
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
|
||||
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
|
||||
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
|
||||
@@ -127,10 +121,17 @@ const columns: DataTableColumn[] = [
|
||||
{ key: 'remark', label: '备注', minWidth: 180 },
|
||||
{ key: 'createdByName', label: '创建人', width: 130, slotName: 'createdByName' },
|
||||
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
|
||||
{ key: 'submittedAt', label: '提交时间', minWidth: 170, slotName: 'submittedAt' },
|
||||
{ key: 'reviewedAt', label: '审核时间', minWidth: 170, slotName: 'reviewedAt' },
|
||||
{ key: 'issuedAt', label: '出账时间', minWidth: 170, slotName: 'issuedAt' },
|
||||
{ key: 'updatedByName', label: '更新人', width: 130, slotName: 'updatedByName' },
|
||||
{ key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' },
|
||||
]
|
||||
const detailLineColumns: DataTableColumn[] = [
|
||||
{ key: 'direction', label: '方向', width: 90, slotName: 'direction' },
|
||||
{ key: 'sourceType', label: '来源类型', width: 120, slotName: 'sourceType' },
|
||||
{ key: 'sourceNo', label: '业务单号', minWidth: 160 },
|
||||
{ key: 'orderId', label: '订单', minWidth: 150, slotName: 'orderId' },
|
||||
{ key: 'inboundCode', label: '入仓号', minWidth: 150, slotName: 'inboundCode' },
|
||||
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
|
||||
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
|
||||
@@ -150,6 +151,7 @@ const paymentColumns: DataTableColumn[] = [
|
||||
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
|
||||
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
|
||||
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
|
||||
{ key: 'voucherFileIds', label: '收款凭证', minWidth: 130, slotName: 'voucherFileIds' },
|
||||
{ key: 'createdByName', label: '登记人', minWidth: 140, slotName: 'createdByName' },
|
||||
{ key: 'occurredAt', label: '登记时间', width: 170, slotName: 'occurredAt' },
|
||||
{ key: 'remark', label: '备注', minWidth: 200 },
|
||||
@@ -180,12 +182,12 @@ function formatDateTime(value?: string) {
|
||||
}
|
||||
|
||||
function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconciliationDetail): StatementRow {
|
||||
const latestPayment = current?.paymentHistory.map((item) => item.occurredAt || item.transactionDate).filter(Boolean).sort().at(-1) || ''
|
||||
const latestPayment = current?.paymentHistory.map((item) => item.occurredAt || item.transactionDate).filter(Boolean).sort().at(-1) || row.lastReceiptAt || ''
|
||||
return {
|
||||
...row,
|
||||
periodStart: current?.periodStart || '', periodEnd: current?.periodEnd || row.billDate,
|
||||
creditDays: current ? current.creditDays : null, settled: current ? current.settled : null,
|
||||
lastPaymentAt: latestPayment, remark: current?.remark || '',
|
||||
periodStart: current?.periodStart || row.periodStart || '', periodEnd: current?.periodEnd || row.periodEnd || row.billDate,
|
||||
creditDays: current ? current.creditDays : row.creditDays ?? null, settled: current ? current.settled : row.settled ?? null,
|
||||
lastPaymentAt: latestPayment, remark: current?.remark || row.remark || '',
|
||||
createdBy: current?.createdBy || row.createdBy, createdByName: current?.createdByName || row.createdByName, createdAt: current?.createdAt || row.createdAt,
|
||||
updatedBy: current?.updatedBy || row.updatedBy, updatedByName: current?.updatedByName || row.updatedByName, updatedAt: current?.updatedAt || row.updatedAt,
|
||||
}
|
||||
@@ -207,29 +209,13 @@ const filteredRows = computed(() => rows.value.filter((row) => {
|
||||
return (!status || String(row.status).toLowerCase() === status)
|
||||
&& (!billNo || row.billNo.toLowerCase().includes(billNo))
|
||||
&& (!settled || String(row.settled) === settled)
|
||||
&& (!containerNo || Boolean(current?.lines.some((line) => lineSourceCache.get(line.sourceFeeId)?.containerNo.toLowerCase().includes(containerNo))))
|
||||
&& (!containerNo || Boolean(current?.lines.some((line) => line.containerNo?.toLowerCase().includes(containerNo))))
|
||||
&& inDateRange(row.periodStart, 'periodStartRange')
|
||||
&& inDateRange(row.periodEnd, 'periodEndRange')
|
||||
&& inDateRange(row.lastPaymentAt, 'lastPaymentRange')
|
||||
&& inDateRange(row.createdAt || '', 'createdRange')
|
||||
}))
|
||||
|
||||
async function hydrateLineSources(current: FinanceReconciliationDetail) {
|
||||
if (!canViewFeeSource.value) return
|
||||
await Promise.all(current.lines.map(async (line) => {
|
||||
if (!line.sourceFeeId || lineSourceCache.has(line.sourceFeeId)) return
|
||||
try {
|
||||
const source = await getFinanceFeeSource(line.sourceFeeId)
|
||||
lineSourceCache.set(line.sourceFeeId, {
|
||||
containerNo: source.containerNo, inboundCode: source.inboundCode, businessDate: source.businessDate,
|
||||
quantity: source.quantity, unitPrice: source.unitPrice, sourceType: source.sourceType,
|
||||
})
|
||||
} catch {
|
||||
lineSourceCache.set(line.sourceFeeId, { containerNo: '', inboundCode: '', businessDate: '', quantity: null, unitPrice: null, sourceType: '' })
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
if (!canListCustomers.value) return
|
||||
customerError.value = null
|
||||
@@ -249,7 +235,6 @@ async function load(model?: QueryModel) {
|
||||
selectedId.value = ''
|
||||
detail.value = null
|
||||
detailCache.clear()
|
||||
lineSourceCache.clear()
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -260,7 +245,6 @@ async function load(model?: QueryModel) {
|
||||
try {
|
||||
const current = await getReconciliationStatement(row.id)
|
||||
detailCache.set(row.id, current)
|
||||
await hydrateLineSources(current)
|
||||
return detailToRow(row, current)
|
||||
} catch (cause) {
|
||||
detailErrors.push(getApiErrorDetails(cause, `对账单 ${row.billNo || row.id} 的筛选字段加载失败`))
|
||||
@@ -283,7 +267,6 @@ async function fetchDetail(statementId: string, force = false) {
|
||||
if (!force && detailCache.has(statementId)) return detailCache.get(statementId)!
|
||||
const current = await getReconciliationStatement(statementId)
|
||||
detailCache.set(statementId, current)
|
||||
await hydrateLineSources(current)
|
||||
return current
|
||||
}
|
||||
|
||||
@@ -321,6 +304,14 @@ function revisionActionLabel(action: string) {
|
||||
return ({ SUBMIT: '提交审核', APPROVE: '审核通过', REJECT: '审核驳回', ISSUE: '出账', ADJUST: '调整', VOID: '作废', REOPEN: '重开' } as Record<string, string>)[action] || action || '—'
|
||||
}
|
||||
|
||||
function revisionTime(actions: string[]) {
|
||||
return detail.value?.revisions.filter((item) => actions.includes(item.action)).map((item) => item.occurredAt).sort().at(-1) || ''
|
||||
}
|
||||
|
||||
function detailSourceAmount() {
|
||||
return detail.value?.lines.reduce((total, line) => total + (line.sourceAmount ?? line.amount), 0) ?? 0
|
||||
}
|
||||
|
||||
async function exportStatements() {
|
||||
const customerId = String(filters.value.customerId || '').trim()
|
||||
if (customerId && !/^[1-9]\d*$/.test(customerId)) return ElMessage.error('客户业务标识无效,无法创建导出任务')
|
||||
@@ -461,10 +452,16 @@ watch(detailRouteId, (statementId) => {
|
||||
<div class="statement-summary-item"><span>已收金额</span><strong>{{ detail.paidAmount.toFixed(2) }} CNY</strong></div>
|
||||
<div class="statement-summary-item"><span>未收金额</span><strong class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</strong></div>
|
||||
<div class="statement-summary-item"><span>账期</span><strong>{{ detail.creditDays }} 天</strong></div>
|
||||
<div class="statement-summary-item"><span>费用明细 / 来源</span><strong>{{ detail.lines.length }} / {{ new Set(detail.lines.map((line) => line.sourceFeeId)).size }}</strong></div>
|
||||
<div class="statement-summary-item"><span>调整前金额</span><strong>{{ detailSourceAmount().toFixed(2) }} CNY</strong></div>
|
||||
<div class="statement-summary-item"><span>调整金额</span><strong>{{ (detail.totalAmount - detailSourceAmount()).toFixed(2) }} CNY</strong></div>
|
||||
<div class="statement-summary-item"><span>货币</span><strong>{{ detail.currency }}</strong></div>
|
||||
<div class="statement-summary-item"><span>是否结清</span><strong>{{ detail.settled ? '是' : '否' }}</strong></div>
|
||||
<div class="statement-summary-item"><span>创建人</span><strong>{{ operatorDisplayName(detail.createdByName) }}</strong></div>
|
||||
<div class="statement-summary-item"><span>创建时间</span><strong>{{ formatDateTime(detail.createdAt) }}</strong></div>
|
||||
<div class="statement-summary-item"><span>提交时间</span><strong>{{ formatDateTime(revisionTime(['SUBMIT'])) }}</strong></div>
|
||||
<div class="statement-summary-item"><span>审核时间</span><strong>{{ formatDateTime(revisionTime(['APPROVE', 'REJECT'])) }}</strong></div>
|
||||
<div class="statement-summary-item"><span>出账时间</span><strong>{{ formatDateTime(revisionTime(['ISSUE'])) }}</strong></div>
|
||||
<div class="statement-summary-item"><span>更新人</span><strong>{{ operatorDisplayName(detail.updatedByName) }}</strong></div>
|
||||
<div class="statement-summary-item"><span>更新时间</span><strong>{{ formatDateTime(detail.updatedAt) }}</strong></div>
|
||||
<div class="statement-summary-item statement-summary-item--remark"><span>备注</span><strong>{{ detail.remark || '—' }}</strong></div>
|
||||
@@ -478,11 +475,14 @@ watch(detailRouteId, (statementId) => {
|
||||
<FormSection v-else-if="detail" title="账单明细" :bordered="false">
|
||||
<div class="detail-table-scroll">
|
||||
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
|
||||
<template #inboundCode="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template>
|
||||
<template #containerNo="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template>
|
||||
<template #businessDate="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template>
|
||||
<template #quantity="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template>
|
||||
<template #unitPrice="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template>
|
||||
<template #direction="{ row }">{{ row.direction === 'PAYABLE' ? '应付' : '应收' }}</template>
|
||||
<template #sourceType="{ row }">{{ financeSourceTypeLabel(row.sourceType) }}</template>
|
||||
<template #orderId="{ row }">{{ row.orderId ? `订单 ${row.orderId}` : '—' }}</template>
|
||||
<template #inboundCode="{ row }">{{ row.inboundCode || '—' }}</template>
|
||||
<template #containerNo="{ row }">{{ row.containerNo || '—' }}</template>
|
||||
<template #businessDate="{ row }">{{ row.businessDate || '—' }}</template>
|
||||
<template #quantity="{ row }">{{ row.quantity ?? '—' }}</template>
|
||||
<template #unitPrice="{ row }">{{ row.unitPrice ?? '—' }}</template>
|
||||
<template #billNo>{{ detail.billNo }}</template>
|
||||
<template #periodStart>{{ detail.periodStart || '—' }}</template>
|
||||
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
|
||||
@@ -498,6 +498,7 @@ watch(detailRouteId, (statementId) => {
|
||||
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
|
||||
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
|
||||
<template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template>
|
||||
<template #voucherFileIds="{ row }">{{ row.voucherFileIds.length ? `${row.voucherFileIds.length} 个附件` : (row.referenceNo || '—') }}</template>
|
||||
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
|
||||
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
|
||||
</DataTable>
|
||||
@@ -535,6 +536,9 @@ watch(detailRouteId, (statementId) => {
|
||||
<template #settled="{ row }"><el-tag v-if="row.settled !== null" :type="row.settled ? 'success' : 'warning'">{{ row.settled ? '是' : '否' }}</el-tag><span v-else>—</span></template>
|
||||
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
|
||||
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
<template #submittedAt="{ row }">{{ formatDateTime(row.submittedAt) }}</template>
|
||||
<template #reviewedAt="{ row }">{{ formatDateTime(row.reviewedAt) }}</template>
|
||||
<template #issuedAt="{ row }">{{ formatDateTime(row.issuedAt) }}</template>
|
||||
<template #updatedByName="{ row }">{{ operatorDisplayName(row.updatedByName) }}</template>
|
||||
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
|
||||
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { getFinanceSettlement, listFinancePayableSettlements, listFinanceReceivableSettlements } from '../api/finance'
|
||||
|
||||
const pageSource = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceFormalSettlementPage.vue'), 'utf8')
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('formal settlement field contract', () => {
|
||||
it('maps list business fields without exposing internal versions', async () => {
|
||||
const get = vi.spyOn(http, 'get')
|
||||
.mockResolvedValueOnce({ code: '200', data: [{
|
||||
settlementId: 701, settlementNo: 'AR-701', direction: 'RECEIVABLE', customerId: 10001,
|
||||
customerName: '客户A', reconciliationStatementId: 501, reconciliationStatementNo: 'REC-501',
|
||||
businessPeriod: '2026-08', feeItemSummary: '海运费、拖车费', currency: 'CNY', totalAmount: 300,
|
||||
paidAmount: 100, outstandingAmount: 200, generatedDate: '2026-08-17', dueDate: '2026-09-16',
|
||||
paymentStatus: 'PARTIALLY_PAID', status: 'PARTIALLY_PAID', createdBy: 7, createdByName: '财务员',
|
||||
createdAt: '2026-08-17T09:00:00', updatedAt: '2026-08-17T10:00:00', lastPaymentAt: '2026-08-17T10:00:00', version: 1,
|
||||
}] } as never)
|
||||
.mockResolvedValueOnce({ code: '200', data: [{
|
||||
settlementId: 801, settlementNo: 'AP-801', direction: 'PAYABLE', customerId: 10001,
|
||||
customerName: '客户A', supplierId: 81, supplierName: '承运供应商', businessPeriod: '2026-08',
|
||||
feeItemSummary: '拖车费', currency: 'CNY', totalAmount: 300, paidAmount: 0, outstandingAmount: 300,
|
||||
generatedDate: '2026-08-17', dueDate: '2026-09-16', paymentStatus: 'UNPAID', status: 'ISSUED',
|
||||
createdBy: 7, createdByName: '财务员', createdAt: '2026-08-17T09:00:00', updatedAt: '2026-08-17T09:00:00', version: 0,
|
||||
}] } as never)
|
||||
|
||||
const [receivable] = await listFinanceReceivableSettlements('10001')
|
||||
const [payable] = await listFinancePayableSettlements('10001')
|
||||
|
||||
expect(receivable).toMatchObject({ reconciliationStatementNo: 'REC-501', feeItemSummary: '海运费、拖车费', createdByName: '财务员', lastPaymentAt: '2026-08-17T10:00:00' })
|
||||
expect(payable).toMatchObject({ customerName: '客户A', supplierName: '承运供应商', feeItemSummary: '拖车费', createdAt: '2026-08-17T09:00:00' })
|
||||
expect(get).toHaveBeenNthCalledWith(1, '/platform/finance/v1/receivable-settlements', { params: { customerId: '10001' } })
|
||||
})
|
||||
|
||||
it('maps immutable source identity and payment evidence in detail', async () => {
|
||||
vi.spyOn(http, 'get').mockResolvedValue({ code: '200', data: {
|
||||
settlementId: 801, settlementNo: 'AP-801', direction: 'PAYABLE', customerId: 10001, customerName: '客户A',
|
||||
supplierId: 81, supplierName: '承运供应商', feeItemSummary: '拖车费', totalAmount: 300, paidAmount: 100,
|
||||
outstandingAmount: 200, currency: 'CNY', generatedDate: '2026-08-17', dueDate: '2026-09-16',
|
||||
paymentStatus: 'PARTIALLY_PAID', status: 'PARTIALLY_PAID', createdBy: 7, createdByName: '财务员',
|
||||
createdAt: '2026-08-17T09:00:00', updatedAt: '2026-08-17T10:00:00', lastPaymentAt: '2026-08-17T10:00:00', version: 1,
|
||||
lines: [{ feeSourceId: 21, sourceType: 'CONTAINER_FEE', sourceFeeId: 9001, sourceVersion: 1, direction: 'PAYABLE', customerId: 10001, supplierId: 81, supplierName: '承运供应商', orderId: 11, orderNo: 'FL-11', inboundId: 12, inboundCode: 'IN-12', containerId: 13, containerNo: 'CONT-13', businessDate: '2026-08-15', feeItemName: '拖车费', amount: 300, currency: 'CNY', reviewStatus: 'APPROVED', settlementIncluded: true, sourceStatus: 'AVAILABLE', statementGenerated: true }],
|
||||
transactions: [{ transactionId: 31, transactionType: 'PAYMENT', transactionDate: '2026-08-17', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'BANK-31', voucherFileIds: [41], createdBy: 7, createdByName: '财务员', createdAt: '2026-08-17T10:00:00' }],
|
||||
revisions: [{ revisionNo: 1, action: 'CREATE', beforeAmount: 0, afterAmount: 300, operatorId: 7, operatorName: '财务员', occurredAt: '2026-08-17T09:00:00' }],
|
||||
} } as never)
|
||||
|
||||
const detail = await getFinanceSettlement('801')
|
||||
|
||||
expect(detail.lines[0]).toMatchObject({ orderId: '11', orderNo: 'FL-11', inboundCode: 'IN-12', containerNo: 'CONT-13', feeItemName: '拖车费', amount: 300 })
|
||||
expect(detail.transactions[0]).toMatchObject({ method: 'BANK_TRANSFER', referenceNo: 'BANK-31', voucherFileIds: ['41'], createdByName: '财务员' })
|
||||
})
|
||||
|
||||
it('renders the confirmed list and detail fields as business labels', () => {
|
||||
for (const label of ['关联对账单', '结算周期', '创建时间', '最后收款时间', '最后付款时间', '订单号', '银行流水号/凭证号', '修订记录']) {
|
||||
expect(pageSource).toContain(label)
|
||||
}
|
||||
expect(pageSource).toContain("BANK_TRANSFER: '银行转账'")
|
||||
expect(pageSource).not.toContain('prop="sourceVersion" label="来源版本"')
|
||||
expect(pageSource).not.toContain('{{ detail.version }}')
|
||||
})
|
||||
})
|
||||
@@ -127,30 +127,53 @@ const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'settlementNo', label: '结算单号', minWidth: 180 },
|
||||
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
|
||||
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
|
||||
{ key: 'currency', label: '币种', width: 80 },
|
||||
{ key: 'receivableAmount', label: '应收金额', width: 130, slotName: 'money' },
|
||||
{ key: 'receivedAmount', label: '已收', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'billDate', label: '生成日期', width: 130, slotName: 'billDate' },
|
||||
{ key: 'reconciliationStatementNo', label: '关联对账单', minWidth: 180, slotName: 'reconciliationStatementNo' },
|
||||
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
|
||||
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
|
||||
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
|
||||
{ key: 'status', label: '收款状态', width: 120, slotName: 'status' },
|
||||
{ key: 'paymentStatus', label: '收款状态', width: 120, slotName: 'paymentStatus' },
|
||||
{ key: 'createdByName', label: '制单人', minWidth: 130, slotName: 'createdByName' },
|
||||
{ key: 'createdAt', label: '创建时间', width: 170, slotName: 'createdAt' },
|
||||
{ key: 'lastPaymentAt', label: '最后收款时间', width: 170, slotName: 'lastPaymentAt' },
|
||||
{ key: 'status', label: '结算状态', width: 120, slotName: 'settlementStatus' },
|
||||
]
|
||||
: [
|
||||
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'settlementNo', label: '结算单号', minWidth: 180 },
|
||||
{ key: 'supplierName', label: '供应商', minWidth: 180, slotName: 'supplier' },
|
||||
{ key: 'feeItemName', label: '费用类型', minWidth: 150, slotName: 'feeType' },
|
||||
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
|
||||
{ key: 'feeItemSummary', label: '费用类型', minWidth: 150, slotName: 'feeItemSummary' },
|
||||
{ key: 'currency', label: '币种', width: 80 },
|
||||
{ key: 'payableAmount', label: '应付金额', width: 130, slotName: 'money' },
|
||||
{ key: 'paidAmount', label: '已付', width: 120, slotName: 'paidMoney' },
|
||||
{ key: 'outstandingAmount', label: '余额', width: 120, slotName: 'outstandingMoney' },
|
||||
{ key: 'createdAt', label: '生成日期', width: 130, slotName: 'createdAt' },
|
||||
{ key: 'status', label: '付款状态', width: 120, slotName: 'status' },
|
||||
{ key: 'businessPeriod', label: '结算周期', width: 120, slotName: 'businessPeriod' },
|
||||
{ key: 'generatedDate', label: '生成日期', width: 130, slotName: 'generatedDate' },
|
||||
{ key: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
|
||||
{ key: 'paymentStatus', label: '付款状态', width: 120, slotName: 'paymentStatus' },
|
||||
{ key: 'createdByName', label: '制单人', minWidth: 130, slotName: 'createdByName' },
|
||||
{ key: 'createdAt', label: '创建时间', width: 170, slotName: 'createdAt' },
|
||||
{ key: 'lastPaymentAt', label: '最后付款时间', width: 170, slotName: 'lastPaymentAt' },
|
||||
{ key: 'status', label: '结算状态', width: 120, slotName: 'settlementStatus' },
|
||||
])
|
||||
|
||||
function money(value: number) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
|
||||
function displayDate(value?: string) { return value || '—' }
|
||||
function displayDateTime(value?: string) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function settlementPaymentStatusLabel(status?: string) {
|
||||
if (status === 'UNPAID') return props.kind === 'receivable' ? '待收款' : '待付款'
|
||||
if (status === 'PARTIALLY_PAID') return props.kind === 'receivable' ? '部分收款' : '部分付款'
|
||||
if (status === 'PAID') return '已结清'
|
||||
return '—'
|
||||
}
|
||||
function paymentMethodLabel(method?: string) {
|
||||
return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[String(method || '')] || '—'
|
||||
}
|
||||
function operatorDisplayName(value?: string) {
|
||||
const candidate = String(value || '').trim()
|
||||
return candidate && !/^(?:operator-)?\d+$/i.test(candidate) ? candidate : '名称数据缺失'
|
||||
@@ -488,12 +511,18 @@ onMounted(() => {
|
||||
<el-descriptions :column="4" class="settlement-detail-info">
|
||||
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ detailPartyName() }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="kind === 'payable'" label="客户">{{ customerName(detail.customerId, detail.customerName) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="币种">CNY</el-descriptions-item>
|
||||
<el-descriptions-item label="收付款状态"><el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="关联账单">{{ detail.reconciliationStatementId || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务期间">{{ detail.businessPeriod || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="kind === 'receivable' ? '收款状态' : '付款状态'"><el-tag :type="financeStatusTone(detail.paymentStatus)">{{ settlementPaymentStatusLabel(detail.paymentStatus) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="结算状态"><el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="关联对账单">{{ detail.reconciliationStatementNo || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结算周期">{{ detail.businessPeriod || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="费用类型">{{ detail.feeItemSummary || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生成日期">{{ displayDate(detail.generatedDate) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="到期日">{{ displayDate(detail.dueDate) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="制单人">{{ operatorDisplayName(detail.createdByName) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ displayDateTime(detail.createdAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="kind === 'receivable' ? '最后收款时间' : '最后付款时间'">{{ displayDateTime(detail.lastPaymentAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</FormSection>
|
||||
<FormSection title="费用明细">
|
||||
@@ -501,6 +530,7 @@ onMounted(() => {
|
||||
<el-table :data="detail.lines" size="small" border stripe empty-text="暂无费用明细">
|
||||
<el-table-column prop="feeItemName" label="费用类型" min-width="150" />
|
||||
<el-table-column prop="sourceType" label="来源类型" min-width="140"><template #default="scope">{{ financeSourceTypeLabel(scope.row.sourceType) }}</template></el-table-column>
|
||||
<el-table-column prop="orderNo" label="订单号" min-width="160"><template #default="scope">{{ scope.row.orderNo || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="inboundCode" label="入仓码" min-width="130"><template #default="scope">{{ scope.row.inboundCode || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="containerNo" label="柜次号" min-width="130"><template #default="scope">{{ scope.row.containerNo || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="businessDate" label="业务发生日" width="120"><template #default="scope">{{ displayDate(scope.row.businessDate) }}</template></el-table-column>
|
||||
@@ -513,11 +543,11 @@ onMounted(() => {
|
||||
<el-table :data="detail.transactions" size="small" border stripe :empty-text="kind === 'receivable' ? '暂无收款记录' : '暂无付款记录'">
|
||||
<el-table-column prop="transactionDate" :label="kind === 'receivable' ? '收款日期' : '付款日期'" width="130" />
|
||||
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
|
||||
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130" />
|
||||
<el-table-column prop="referenceNo" label="支付流水号" min-width="160"><template #default="scope">{{ scope.row.referenceNo || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="method" :label="kind === 'receivable' ? '收款方式' : '付款方式'" min-width="130"><template #default="scope">{{ paymentMethodLabel(scope.row.method) }}</template></el-table-column>
|
||||
<el-table-column prop="referenceNo" label="银行流水号/凭证号" min-width="180"><template #default="scope">{{ scope.row.referenceNo || '—' }}</template></el-table-column>
|
||||
<el-table-column label="登记人" min-width="140"><template #default="scope">{{ operatorDisplayName(scope.row.createdByName) }}</template></el-table-column>
|
||||
<el-table-column label="登记时间" min-width="170"><template #default="scope">{{ displayDateTime(scope.row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="凭证" width="100"><template #default="scope">{{ scope.row.voucherFileIds.length ? `${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
|
||||
<el-table-column label="凭证" width="130"><template #default="scope">{{ scope.row.voucherFileIds.length ? `已上传 ${scope.row.voucherFileIds.length} 个` : '—' }}</template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180"><template #default="scope">{{ scope.row.remark || '—' }}</template></el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
@@ -566,14 +596,19 @@ onMounted(() => {
|
||||
</template>
|
||||
<template #customer="{ row }">{{ customerName(String(row.customerId), row.customerName) }}</template>
|
||||
<template #supplier="{ row }">{{ row.supplierName || '—' }}</template>
|
||||
<template #feeType="{ row }">{{ row.feeItemName || '详见费用明细' }}</template>
|
||||
<template #feeItemSummary="{ row }">{{ row.feeItemSummary || '—' }}</template>
|
||||
<template #money="{ row }">{{ money(rowTotal(row)) }}</template>
|
||||
<template #paidMoney="{ row }">{{ money(rowPaid(row)) }}</template>
|
||||
<template #outstandingMoney="{ row }">{{ money(rowOutstanding(row)) }}</template>
|
||||
<template #billDate="{ row }">{{ displayDate((row as FinanceReceivableSettlement).billDate) }}</template>
|
||||
<template #reconciliationStatementNo="{ row }">{{ row.reconciliationStatementNo || '—' }}</template>
|
||||
<template #businessPeriod="{ row }">{{ row.businessPeriod || '—' }}</template>
|
||||
<template #generatedDate="{ row }">{{ displayDate(row.generatedDate) }}</template>
|
||||
<template #dueDate="{ row }">{{ displayDate((row as FinanceReceivableSettlement).dueDate) }}</template>
|
||||
<template #createdAt="{ row }">{{ displayDate((row as FinancePayableSettlement).createdAt) }}</template>
|
||||
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
||||
<template #paymentStatus="{ row }"><el-tag :type="financeStatusTone(row.paymentStatus)">{{ settlementPaymentStatusLabel(row.paymentStatus) }}</el-tag></template>
|
||||
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
|
||||
<template #createdAt="{ row }">{{ displayDateTime(row.createdAt) }}</template>
|
||||
<template #lastPaymentAt="{ row }">{{ displayDateTime(row.lastPaymentAt) }}</template>
|
||||
<template #settlementStatus="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
|
||||
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
@@ -62,6 +62,24 @@ export interface FinanceFeePoolItem {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface FinanceFeeSourceSummary extends FinanceFeePoolItem {
|
||||
feeSourceId: string
|
||||
sourceSystem: string
|
||||
sourceBusinessId: string
|
||||
customerName: string
|
||||
supplierId: string | null
|
||||
supplierName: string
|
||||
inboundCode: string
|
||||
containerNo: string
|
||||
businessDate: string
|
||||
quantity: number | null
|
||||
unitPrice: number | null
|
||||
reviewStatus: FinanceReviewStatus
|
||||
settlementIncluded: boolean
|
||||
sourceStatus: string
|
||||
statementGenerated: boolean
|
||||
}
|
||||
|
||||
export interface FinanceStatementReadModel {
|
||||
id: string
|
||||
customerId: string
|
||||
@@ -69,12 +87,25 @@ export interface FinanceStatementReadModel {
|
||||
customerName: string
|
||||
billDate: string
|
||||
dueDate: string
|
||||
periodStart?: string
|
||||
periodEnd?: string
|
||||
creditDays?: number
|
||||
lineCount?: number
|
||||
sourceCount?: number
|
||||
sourceAmount?: number
|
||||
adjustmentAmount?: number
|
||||
totalAmount: number
|
||||
receivedAmount: number
|
||||
outstandingAmount: number
|
||||
currency: 'CNY'
|
||||
status: FinanceBillStatus
|
||||
version: number
|
||||
settled?: boolean
|
||||
lastReceiptAt?: string
|
||||
submittedAt?: string
|
||||
reviewedAt?: string
|
||||
issuedAt?: string
|
||||
remark?: string
|
||||
createdBy?: string
|
||||
createdByName?: string
|
||||
createdAt?: string
|
||||
@@ -89,8 +120,15 @@ export interface FinanceReceivableSettlement {
|
||||
settlementNo: string
|
||||
customerName?: string
|
||||
reconciliationStatementId?: string
|
||||
reconciliationStatementNo?: string
|
||||
businessPeriod?: string
|
||||
feeItemSummary?: string
|
||||
generatedDate?: string
|
||||
createdBy?: string
|
||||
createdByName?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
lastPaymentAt?: string
|
||||
billDate: string
|
||||
dueDate: string
|
||||
receivableAmount: number
|
||||
@@ -114,6 +152,7 @@ export interface FinancePayableSettlement {
|
||||
containerId: string | null
|
||||
supplierName: string
|
||||
feeItemName: string
|
||||
feeItemSummary?: string
|
||||
businessPeriod?: string
|
||||
generatedDate?: string
|
||||
dueDate?: string
|
||||
@@ -124,6 +163,10 @@ export interface FinancePayableSettlement {
|
||||
currency: 'CNY'
|
||||
status: string
|
||||
createdAt: string
|
||||
createdBy?: string
|
||||
createdByName?: string
|
||||
updatedAt?: string
|
||||
lastPaymentAt?: string
|
||||
version: number
|
||||
}
|
||||
|
||||
@@ -155,22 +198,8 @@ export interface FinanceLedgerEntry {
|
||||
remark: string
|
||||
}
|
||||
|
||||
export interface FinanceFeeSourceDetail extends FinanceFeePoolItem {
|
||||
feeSourceId: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
supplierId: string | null
|
||||
supplierName: string
|
||||
export interface FinanceFeeSourceDetail extends FinanceFeeSourceSummary {
|
||||
sourceFeeId: string
|
||||
inboundCode: string
|
||||
containerNo: string
|
||||
businessDate: string
|
||||
quantity: number | null
|
||||
unitPrice: number | null
|
||||
reviewStatus: FinanceReviewStatus
|
||||
settlementIncluded: boolean
|
||||
sourceStatus: string
|
||||
statementGenerated: boolean
|
||||
reviewReason?: string
|
||||
settlementReason?: string
|
||||
sourceHash?: string
|
||||
@@ -178,6 +207,34 @@ export interface FinanceFeeSourceDetail extends FinanceFeePoolItem {
|
||||
operationVersion: number
|
||||
}
|
||||
|
||||
export interface FinanceSettlementLine {
|
||||
feeSourceId: string
|
||||
sourceType: string
|
||||
sourceFeeId: string
|
||||
sourceVersion: number
|
||||
direction: 'RECEIVABLE' | 'PAYABLE'
|
||||
customerId: string
|
||||
customerName: string
|
||||
supplierId: string | null
|
||||
supplierName: string
|
||||
orderId: string | null
|
||||
orderNo: string
|
||||
inboundId: string | null
|
||||
inboundCode: string
|
||||
containerId: string | null
|
||||
containerNo: string
|
||||
businessDate: string
|
||||
feeItemName: string
|
||||
quantity: number | null
|
||||
unitPrice: number | null
|
||||
amount: number
|
||||
currency: 'CNY'
|
||||
reviewStatus: FinanceReviewStatus
|
||||
settlementIncluded: boolean
|
||||
sourceStatus: string
|
||||
statementGenerated: boolean
|
||||
}
|
||||
|
||||
export interface FinanceSettlementDetail {
|
||||
id: string
|
||||
settlementNo: string
|
||||
@@ -186,7 +243,9 @@ export interface FinanceSettlementDetail {
|
||||
customerName?: string
|
||||
supplierName?: string
|
||||
reconciliationStatementId?: string
|
||||
reconciliationStatementNo?: string
|
||||
businessPeriod?: string
|
||||
feeItemSummary?: string
|
||||
generatedDate?: string
|
||||
dueDate?: string
|
||||
paymentStatus?: FinancePaymentStatus
|
||||
@@ -196,8 +255,13 @@ export interface FinanceSettlementDetail {
|
||||
outstandingAmount: number
|
||||
currency: 'CNY'
|
||||
status: string
|
||||
createdBy?: string
|
||||
createdByName?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
lastPaymentAt?: string
|
||||
version: number
|
||||
lines: FinanceFeeSourceDetail[]
|
||||
lines: FinanceSettlementLine[]
|
||||
transactions: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; voucherFileIds: string[]; createdBy: string; createdByName: string; createdAt: string; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; operatorName: string; occurredAt: string }>
|
||||
}
|
||||
@@ -219,7 +283,7 @@ export interface FinanceReconciliationDetail extends FinanceBillDetail {
|
||||
updatedBy?: string
|
||||
updatedByName?: string
|
||||
updatedAt?: string
|
||||
paymentHistory: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; createdBy: string; createdByName: string; occurredAt: string; remark?: string }>
|
||||
paymentHistory: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; voucherFileIds: string[]; createdBy: string; createdByName: string; occurredAt: string; remark?: string }>
|
||||
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; operatorName: string; occurredAt: string }>
|
||||
}
|
||||
|
||||
@@ -287,6 +351,14 @@ export interface FinanceBillLine {
|
||||
orderId: string | null
|
||||
inboundId: string | null
|
||||
containerId?: string | null
|
||||
direction?: 'RECEIVABLE' | 'PAYABLE'
|
||||
sourceType?: string
|
||||
sourceNo?: string
|
||||
inboundCode?: string
|
||||
containerNo?: string
|
||||
businessDate?: string
|
||||
quantity?: number | null
|
||||
unitPrice?: number | null
|
||||
feeItemName: string
|
||||
sourceAmount?: number
|
||||
amount: number
|
||||
|
||||
Reference in New Issue
Block a user