This commit is contained in:
2026-08-13 11:57:44 +08:00
parent 7beca3f985
commit 835ac0ad12
87 changed files with 3721 additions and 295 deletions
+3 -3
View File
@@ -57,8 +57,8 @@ describe('finance api', () => {
.mockResolvedValueOnce({ code: '200', message: 'ok', data: { customerId: 10001, totalReceivable: 300, totalReceived: 100, outstandingAmount: 200, entries: [] } })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ poolItemId: 9, customerId: 10001, direction: 'PAYABLE', sourceType: 'CONTAINER_COST', sourceId: 81, sourceVersion: 2, containerId: 81, feeItemName: '拖车费', amount: 680.5, status: 'OPEN' }] })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [] })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ id: 81, customerId: 10001, settlementNo: 'AR-81', receivableAmount: 300, receivedAmount: 100, outstandingAmount: 200, status: 'PARTIALLY_PAID', version: 2 }] })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ id: 71, customerId: 10001, settlementNo: 'AP-71', costType: 'CONTAINER_SHARED', containerId: 81, feeItemName: '拖车费', payableAmount: 680.5, paidAmount: 0, outstandingAmount: 680.5, status: 'UNPAID', version: 1 }] })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ settlementId: 81, customerId: 10001, settlementNo: 'AR-81', direction: 'RECEIVABLE', totalAmount: 300, paidAmount: 100, outstandingAmount: 200, generatedDate: '2026-08-11', status: 'PARTIALLY_PAID', version: 2 }] })
.mockResolvedValueOnce({ code: '200', message: 'ok', data: [{ settlementId: 71, customerId: 10001, settlementNo: 'AP-71', direction: 'PAYABLE', supplierName: '供应商A', totalAmount: 680.5, paidAmount: 0, outstandingAmount: 680.5, generatedDate: '2026-08-11', status: 'ISSUED', version: 1 }] })
const account = await getFinanceCustomerAccount('10001')
const pool = await listFinanceFeePool('10001', 'PAYABLE')
@@ -76,7 +76,7 @@ describe('finance api', () => {
expect(account).toMatchObject({ customerId: '10001', currency: 'CNY', accountMode: 'MANUAL_RECEIVABLE_LEDGER', outstandingAmount: 200 })
expect(pool[0]).toMatchObject({ poolItemId: '9', customerId: '10001', sourceId: '81', sourceVersion: 2, containerId: '81', feeItemName: '拖车费', amount: 680.5 })
expect(receivables[0]).toMatchObject({ id: '81', settlementNo: 'AR-81', currency: 'CNY' })
expect(payables[0]).toMatchObject({ id: '71', settlementNo: 'AP-71', feeItemName: '拖车费', payableAmount: 680.5, currency: 'CNY' })
expect(payables[0]).toMatchObject({ id: '71', settlementNo: 'AP-71', supplierName: '供应商A', payableAmount: 680.5, currency: 'CNY' })
})
it('derives the numeric fee source id without treating manual costs as reviewable sources', async () => {
+22 -6
View File
@@ -69,7 +69,7 @@ export async function listFinanceFeePool(customerId?: string, direction?: 'RECEI
return unwrapApiResponse(response).map((item) => {
const poolItemId = stringValue(item.poolItemId)
const feeSourceId = stringValue(item.sourceType) === 'MANUAL_COST' ? null : (poolItemId.match(/^[A-Z_]+:(\d+)$/)?.[1] ?? null)
return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
return { poolItemId, feeSourceId, customerId: stringValue(item.customerId), customerName: stringValue(item.customerName), direction: stringValue(item.direction) as FinanceFeePoolItem['direction'], sourceType: stringValue(item.sourceType), sourceId: stringValue(item.sourceId), sourceVersion: numberValue(item.sourceVersion), orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), feeItemName: stringValue(item.feeItemName), amount: numberValue(item.amount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt) }
})
}
@@ -133,7 +133,7 @@ function normalizeReconciliationDetail(item: RawResource): FinanceReconciliation
return {
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), lines,
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), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), 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), 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), occurredAt: stringValue(row.occurredAt) } }),
}
@@ -142,7 +142,7 @@ 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), supplierId: summary.supplierId == null ? undefined : stringValue(summary.supplierId), reconciliationStatementId: summary.reconciliationStatementId == null ? undefined : stringValue(summary.reconciliationStatementId), direction: stringValue(summary.direction) as FinanceSettlementDetail['direction'],
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)),
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), voucherFileIds: Array.isArray(row.voucherFileIds) ? row.voucherFileIds.map(stringValue) : [], remark: stringValue(row.remark) || undefined } }),
@@ -178,20 +178,36 @@ export async function createFinanceSettlementPayment(settlementId: string, paylo
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/settlements/${settlementId}/transactions`, payload, mutationHeaders('settlement-payment'))
return normalizeSettlementDetail(unwrapApiResponse(response))
}
export async function actionFinanceSettlement(settlementId: string, payload: import('../types').FinanceSettlementActionPayload) {
const response = await http.post<ApiEnvelope<RawResource>, ApiEnvelope<RawResource>>(`${basePath}/settlements/${settlementId}/actions`, payload, mutationHeaders('settlement-action'))
return normalizeSettlementDetail(unwrapApiResponse(response))
}
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) }))
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), createdAt: stringValue(item.createdAt), updatedBy: stringValue(item.updatedBy), 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.id), customerId: stringValue(item.customerId), settlementNo: stringValue(item.settlementNo), billDate: stringValue(item.billDate), dueDate: stringValue(item.dueDate), receivableAmount: numberValue(item.receivableAmount), receivedAmount: numberValue(item.receivedAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status) as FinanceReceivableSettlement['status'], version: numberValue(item.version) }))
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),
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),
}))
}
export async function listFinancePayableSettlements(customerId?: string): Promise<FinancePayableSettlement[]> {
const response = await http.get<ApiEnvelope<RawResource[]>, ApiEnvelope<RawResource[]>>(`${basePath}/payable-settlements`, { params: { customerId: customerId?.trim() || undefined } })
return unwrapApiResponse(response).map((item) => ({ id: stringValue(item.id), customerId: stringValue(item.customerId), settlementNo: stringValue(item.settlementNo), costType: stringValue(item.costType) as FinancePayableSettlement['costType'], orderId: item.orderId == null ? null : stringValue(item.orderId), inboundId: item.inboundId == null ? null : stringValue(item.inboundId), containerId: item.containerId == null ? null : stringValue(item.containerId), supplierName: stringValue(item.supplierName), feeItemName: stringValue(item.feeItemName), payableAmount: numberValue(item.payableAmount), paidAmount: numberValue(item.paidAmount), outstandingAmount: numberValue(item.outstandingAmount), currency: 'CNY', status: stringValue(item.status), createdAt: stringValue(item.createdAt), version: numberValue(item.version) }))
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),
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),
}))
}
function normalizeCustomerTerms(item: RawResource, customerId: string): FinanceCustomerTerms {
@@ -0,0 +1,71 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { getReconciliationStatement, listFinanceStatements } from '../api/finance'
import CustomerStatementsPanel from './CustomerStatementsPanel.vue'
vi.mock('../api/finance', () => ({
getReconciliationStatement: vi.fn(),
listFinanceStatements: vi.fn(),
}))
const statement = {
id: 'statement-1', customerId: '8', billNo: 'ST202608001', customerName: '柏悦物流',
billDate: '2026-08-01', dueDate: '2026-08-31', totalAmount: 1200, receivedAmount: 200,
outstandingAmount: 1000, currency: 'CNY' as const, status: 'ISSUED' as const, version: 1,
}
function mountPanel(permissions: string[]) {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
auth.token = 'token'
auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
return mount(CustomerStatementsPanel, {
props: { customerId: '8', customerName: '柏悦物流' },
global: { plugins: [pinia, ElementPlus] },
})
}
describe('CustomerStatementsPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(listFinanceStatements).mockResolvedValue([statement])
})
it('loads only the selected customer statements', async () => {
const wrapper = mountPanel(['platform:finance:customer-statement:list'])
await flushPromises()
expect(listFinanceStatements).toHaveBeenCalledWith('8')
expect(wrapper.text()).toContain('ST202608001')
expect(wrapper.text()).toContain('1,200.00')
})
it('does not query finance without statement permission', async () => {
const wrapper = mountPanel([])
await flushPromises()
expect(listFinanceStatements).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('暂无财务账单查看权限')
})
it('loads the real reconciliation detail from the statement row', async () => {
vi.mocked(getReconciliationStatement).mockResolvedValue({
...statement, paidAmount: 200, remark: '', periodStart: '2026-07-01', periodEnd: '2026-07-31',
creditDays: 30, settled: false, lines: [{ statementLineId: 'line-1', sourceFeeId: 'fee-1', sourceFeeVersion: 1, orderId: null, inboundId: 'inbound-1', feeItemName: '入库操作费', amount: 1200 }],
paymentHistory: [], revisions: [],
})
const wrapper = mountPanel(['platform:finance:customer-statement:list'])
await flushPromises()
await wrapper.get('button[aria-label="查看对账单ST202608001详情"]').trigger('click')
await flushPromises()
expect(getReconciliationStatement).toHaveBeenCalledWith('statement-1')
expect(wrapper.text()).toContain('入库操作费')
})
})
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { Eye, FileText } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
const props = defineProps<{ customerId: string; customerName: string }>()
const access = useAccessPreviewStore()
const rows = ref<FinanceStatementReadModel[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref<FinanceReconciliationDetail | null>(null)
const detailError = ref<ApiErrorDetails | null>(null)
let requestSerial = 0
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 92, fixed: 'left', slotName: 'actions' },
{ key: 'billNo', prop: 'billNo', label: '对账单号', minWidth: 180 },
{ key: 'billDate', prop: 'billDate', label: '账单日期', width: 120 },
{ key: 'dueDate', prop: 'dueDate', label: '到期日', width: 120 },
{ key: 'totalAmount', label: '应收(CNY)', width: 130, align: 'right', slotName: 'totalAmount' },
{ key: 'receivedAmount', label: '已收(CNY)', width: 130, align: 'right', slotName: 'receivedAmount' },
{ key: 'outstandingAmount', label: '未收(CNY)', width: 130, align: 'right', slotName: 'outstandingAmount' },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
]
async function load() {
const customerId = props.customerId.trim()
const serial = ++requestSerial
rows.value = []
loadError.value = null
if (!customerId || !access.can('platform:finance:customer-statement:list')) return
loading.value = true
try {
const result = await listFinanceStatements(customerId)
if (serial === requestSerial && customerId === props.customerId.trim()) rows.value = result
} catch (error) {
if (serial === requestSerial) loadError.value = getApiErrorDetails(error, '客户财务账单加载失败,请稍后重试')
} finally {
if (serial === requestSerial) loading.value = false
}
}
async function openDetail(row: FinanceStatementReadModel) {
detailOpen.value = true
detailLoading.value = true
detail.value = null
detailError.value = null
try { detail.value = await getReconciliationStatement(row.id) }
catch (error) { detailError.value = getApiErrorDetails(error, '对账单详情加载失败,请稍后重试') }
finally { detailLoading.value = false }
}
function formatAmount(value: number) {
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
watch(() => props.customerId, () => { void load() }, { immediate: true })
</script>
<template>
<section class="customer-statements-panel">
<div class="panel-title"><div><h2>财务账单</h2><p>{{ customerName }}的客户对账单</p></div><FileText :size="19" /></div>
<div v-if="!access.can('platform:finance:customer-statement:list')" class="module-empty"><FileText :size="30" /><strong>暂无财务账单查看权限</strong><p>请联系管理员开通客户对账单查看权限。</p></div>
<LoadErrorState v-else-if="loadError" title="客户财务账单加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="id" aria-label="客户财务账单列表" empty-text="当前客户暂无财务账单" border stripe>
<template #actions="{ row }"><el-button link type="primary" :aria-label="`查看对账单${(row as FinanceStatementReadModel).billNo}详情`" @click="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button></template>
<template #totalAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).totalAmount) }}</template>
<template #receivedAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).receivedAmount) }}</template>
<template #outstandingAmount="{ row }">{{ formatAmount((row as FinanceStatementReadModel).outstandingAmount) }}</template>
<template #status="{ row }"><el-tag :type="financeStatusTone((row as FinanceStatementReadModel).status)">{{ financeStatusLabel((row as FinanceStatementReadModel).status) }}</el-tag></template>
<template #pagination-left>币种:CNY</template>
</DataTable>
<AppDrawer v-model="detailOpen" title="客户对账单详情" size="min(820px, 94vw)" :loading="detailLoading">
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail">
<el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item><el-descriptions-item label="账期">{{ detail.periodStart }} 至 {{ detail.periodEnd }}</el-descriptions-item><el-descriptions-item label="未收金额">{{ detail.outstandingAmount }} CNY</el-descriptions-item></el-descriptions>
<el-table :data="detail.lines" size="small" border stripe><el-table-column prop="feeItemName" label="费用项目" /><el-table-column prop="amount" label="账单金额(CNY)" /></el-table>
<el-table v-if="detail.revisions.length" :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column prop="action" label="动作" width="130" /><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /></el-table>
</template>
</AppDrawer>
</section>
</template>
<style scoped>
.customer-statements-panel { min-width:0; }
.panel-title { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid var(--cc-border); }
.panel-title h2 { margin:0; color:#344054; font-size:16px; }.panel-title p { margin:5px 0 0; color:var(--cc-muted); font-size:11px; }.panel-title > svg { color:var(--cc-primary); }
.module-empty { display:flex; min-height:260px; align-items:center; justify-content:center; flex-direction:column; gap:7px; color:#98a2b3; text-align:center; }.module-empty strong { color:#475467; font-size:13px; }.module-empty p { max-width:520px; margin:0; color:var(--cc-muted); font-size:12px; line-height:1.7; }
:deep(.el-descriptions) { margin-bottom:18px; }:deep(.el-table + .el-table) { margin-top:18px; }
</style>
+5 -2
View File
@@ -19,15 +19,18 @@ describe('finance five-page delivery contract', () => {
['finance/settlement-payables', '结算单应付'],
] as const
it('registers exactly the five formal routes and menu entries', () => {
it('registers the five menu routes plus hidden detail routes', () => {
for (const [path, title] of pages) {
expect(routes).toContain(`path: '${path}'`)
expect(routes).toContain(`title: '${title}'`)
expect(navigation).toContain(`path: '/${path}'`)
expect(navigation).toContain(`title: '${title}'`)
}
expect(routes.match(/path: 'finance\//g)).toHaveLength(5)
expect(navigation.match(/path: '\/finance\//g)).toHaveLength(5)
expect(routes.match(/path: 'finance\/customer-accounts\/:customerId'/g)).toHaveLength(1)
expect(routes.match(/path: 'finance\/customer-arap\/:feeSourceId'/g)).toHaveLength(1)
expect(navigation).not.toContain('/finance/customer-accounts/:customerId')
expect(navigation).not.toContain('/finance/customer-arap/:feeSourceId')
})
it('does not expose the old three routes or out-of-scope reports', () => {
+1
View File
@@ -1 +1,2 @@
export { financeRoutes } from './routes'
export { default as CustomerStatementsPanel } from './components/CustomerStatementsPanel.vue'
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
describe('FinanceCustomerAccountsView delivery', () => {
const page = readFileSync(resolve(process.cwd(), 'src/modules/finance/pages/FinanceCustomerAccountsView.vue'), 'utf8')
const routes = readFileSync(resolve(process.cwd(), 'src/modules/finance/routes.ts'), 'utf8')
it('keeps the account detail as a full page with traceable ledger controls', () => {
expect(page).toContain('v-else-if="!detailOpen"')
expect(page).toContain('返回客户账户')
expect(page).not.toContain('<AppDrawer')
for (const marker of ['流水类型', '发生日期', '导出流水', 'listFinanceAccountLedger']) expect(page).toContain(marker)
})
it('shows only derived CNY summaries and no wallet actions', () => {
for (const marker of ['未收', '待出账', '本期已收', '应付', 'CNY']) expect(page).toContain(marker)
for (const forbidden of ['手动充值', '钱包', '外币', '发票', '利润']) expect(page).not.toContain(forbidden)
})
it('supports a refreshable customer detail route without inventing an API parameter', () => {
expect(routes).toContain("path: 'finance/customer-accounts/:customerId'")
expect(page).toContain("name: 'finance-customer-account-detail'")
expect(page).toContain('restoreAccountDetail(customerId)')
expect(page).toContain("listFinanceAccounts({ page: currentPage, size: 200 })")
expect(page).not.toContain('listFinanceAccounts({ customerId')
})
})
@@ -1,22 +1,24 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, Pencil, Save } from '@lucide/vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Download, Eye, Pencil, Save } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import FormSection from '@/shared/components/FormSection.vue'
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 { getFinanceCustomerTerms, listFinanceAccountLedger, listFinanceAccounts, updateFinanceCustomerTerms } from '../api/finance'
import { financeBusinessTypeLabel, financeDocumentTypeLabel } from '../status'
import { financeBusinessTypeLabel } from '../status'
import type { FinanceAccountSummary, FinanceCustomerTerms, FinanceLedgerEntry } from '../types'
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
const filters = ref<QueryModel>({ keyword: '', status: '' })
const rows = ref<FinanceAccountSummary[]>([])
const loading = ref(false)
@@ -24,7 +26,7 @@ const loadError = ref<ApiErrorDetails | null>(null)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const drawerOpen = ref(false)
const detailOpen = ref(false)
const editing = ref(false)
const saving = ref(false)
const selected = ref<FinanceAccountSummary | null>(null)
@@ -33,8 +35,12 @@ const ledgerLoading = ref(false)
const ledgerError = ref<ApiErrorDetails | null>(null)
const selectedTerms = ref<FinanceCustomerTerms | null>(null)
const creditDays = ref(30)
const ledgerFilters = ref<QueryModel>({ businessType: '', occurredRange: [] })
const ledgerPage = ref(1)
const ledgerPageSize = ref(20)
const ledgerTotal = ref(0)
const canView = computed(() => access.can('platform:finance:account:view'))
const canView = computed(() => access.can('platform:finance:customer-account:list') || access.can('platform:finance:account:view'))
const canViewTerms = computed(() => access.can('platform:finance:customer-terms:view'))
const canUpdateTerms = computed(() => canViewTerms.value && access.can('platform:finance:customer-terms:update'))
const queryFields: QueryField[] = [
@@ -54,6 +60,23 @@ const columns: DataTableColumn[] = [
{ key: 'status', label: '客户状态', width: 110, slotName: 'status' },
{ key: 'lastChangedAt', label: '最近变动', width: 170 },
]
const ledgerQueryFields: QueryField[] = [
{ key: 'businessType', type: 'select', label: '流水类型', width: 180, placeholder: '全部类型', options: [
{ label: '费用来源', value: 'FEE_SOURCE' }, { label: '对账单', value: 'RECONCILIATION' }, { label: '结算单', value: 'SETTLEMENT' }, { label: '收付款', value: 'PAYMENT' },
] },
{ key: 'occurredRange', type: 'daterange', label: '发生日期', width: 280 },
]
const ledgerColumns: DataTableColumn[] = [
{ key: 'ledgerId', label: '流水号', minWidth: 160 }, { key: 'occurredAt', label: '发生时间', width: 170 }, { key: 'businessType', label: '业务类型', width: 130, slotName: 'businessType' },
{ key: 'documentNo', label: '关联单据', minWidth: 170 }, { key: 'receivableChange', label: '应收变动(CNY)', width: 145 }, { key: 'payableChange', label: '应付变动(CNY)', width: 145 },
{ key: 'outstandingReceivableAfter', label: '变动后未收(CNY)', width: 160 }, { key: 'operatorName', label: '操作人', width: 120 }, { key: 'remark', label: '备注', minWidth: 180 },
]
const summary = computed(() => rows.value.reduce((result, row) => ({
receivable: result.receivable + row.outstandingReceivable,
pending: result.pending + row.pendingBilling,
received: result.received + row.receivedInPeriod,
payable: result.payable + row.outstandingPayable,
}), { receivable: 0, pending: 0, received: 0, payable: 0 }))
async function load(model: QueryModel = filters.value) {
Object.assign(filters.value, model)
@@ -71,7 +94,11 @@ function search(model: QueryModel) { page.value = 1; void load(model) }
async function openAccount(row: Record<string, unknown>, edit: boolean) {
selected.value = row as unknown as FinanceAccountSummary
editing.value = edit
drawerOpen.value = true
if (!edit) {
await router.push({ name: 'finance-customer-account-detail', params: { customerId: selected.value.customerId } })
return
}
detailOpen.value = true
ledger.value = []
ledgerError.value = null
selectedTerms.value = null
@@ -81,10 +108,17 @@ async function openAccount(row: Record<string, unknown>, edit: boolean) {
catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账期加载失败,请重试') }
return
}
ledgerLoading.value = true
await loadLedger()
}
async function loadLedger(model: QueryModel = ledgerFilters.value) {
if (!selected.value) return
Object.assign(ledgerFilters.value, model)
ledgerLoading.value = true; ledgerError.value = null
const range = Array.isArray(ledgerFilters.value.occurredRange) ? ledgerFilters.value.occurredRange.map(String) : []
try {
const result = await listFinanceAccountLedger(selected.value.customerId, { page: 1, size: 200 })
const result = await listFinanceAccountLedger(selected.value.customerId, { page: ledgerPage.value, size: ledgerPageSize.value, businessType: String(ledgerFilters.value.businessType || ''), startDate: range[0], endDate: range[1] })
ledger.value = result.records
ledgerTotal.value = result.total
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户流水加载失败,请重试') }
finally { ledgerLoading.value = false }
}
@@ -98,35 +132,71 @@ async function saveTerms() {
finally { saving.value = false }
}
function money(value: unknown) { return Number(value || 0).toFixed(2) }
onMounted(() => void load())
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
function downloadCsv(fileName: string, headers: string[], data: unknown[][]) {
const content = `\uFEFF${[headers, ...data].map((line) => line.map(csvCell).join(',')).join('\r\n')}`
const url = URL.createObjectURL(new Blob([content], { type: 'text/csv;charset=utf-8' }))
const link = document.createElement('a'); link.href = url; link.download = fileName; link.click(); URL.revokeObjectURL(url)
}
function exportAccounts() { downloadCsv('客户账户.csv', columns.slice(1).map((item) => String(item.label)), rows.value.map((row) => [row.customerName, row.customerCode, row.contactName, row.contactPhone, row.outstandingReceivable, row.pendingBilling, row.receivedInPeriod, row.outstandingPayable, row.status, row.lastChangedAt])) }
function exportLedger() { downloadCsv(`${selected.value?.customerName || '客户'}-账户流水.csv`, ledgerColumns.map((item) => String(item.label)), ledger.value.map((row) => [row.ledgerId, row.occurredAt, financeBusinessTypeLabel(row.businessType), row.documentNo, row.receivableChange, row.payableChange, row.outstandingReceivableAfter, row.operatorName, row.remark])) }
async function closeDetail() {
detailOpen.value = false; selected.value = null; editing.value = false; ledger.value = []
if (route.name === 'finance-customer-account-detail') await router.push({ name: 'finance-customer-accounts' })
}
async function restoreAccountDetail(customerId: string) {
detailOpen.value = true; editing.value = false; ledgerError.value = null
try {
let currentPage = 1; let pages = 1; selected.value = null
do {
const result = await listFinanceAccounts({ page: currentPage, size: 200 })
selected.value = result.records.find((row) => row.customerId === customerId) || null
pages = result.pages; currentPage += 1
} while (!selected.value && currentPage <= pages)
if (!selected.value) { ledgerError.value = { message: '未找到该客户账户,可能已停用或无权访问' }; return }
await loadLedger()
} catch (error) { ledgerError.value = getApiErrorDetails(error, '客户账户详情加载失败,请重试') }
}
onMounted(() => {
const customerId = String(route.params.customerId || '')
if (route.name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(customerId)
else void load()
})
watch(() => [route.name, String(route.params.customerId || '')], ([name, customerId], previous) => {
if (!previous) return
if (name === 'finance-customer-account-detail' && customerId) void restoreAccountDetail(customerId)
else { detailOpen.value = false; selected.value = null; ledger.value = []; void load() }
})
</script>
<template>
<AdminPage as="div" class="finance-account-page" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户账户" sub-title="请联系管理员开通客户账户查看权限" />
<ListPageLayout v-else aria-label="客户账户">
<ListPageLayout v-else-if="!detailOpen" aria-label="客户账户">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户账户筛选" :fields="queryFields" preference-key="finance-customer-accounts-query" @search="search" /></template>
<template v-if="loadError" #state><LoadErrorState title="客户账户加载失败" :message="loadError.message" :loading="loading" @retry="load()"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState></template>
<section v-if="!loadError" class="surface-panel table-panel">
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="finance-customer-accounts-table" aria-label="客户账户列表" row-key="customerId" empty-text="暂无客户账户" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-actions><el-button :disabled="loading || !rows.length" @click="exportAccounts"><Download :size="16" />导出</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 #status="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">{{ row.status === 'ACTIVE' ? '正常' : '停用' }}</el-tag></template>
<template #pagination-left>币种:CNY · 账户数据由对账单、收付款和费用来源派生</template>
<template #pagination-left><span>汇总:未收 {{ money(summary.receivable) }} · 待出账 {{ money(summary.pending) }} · 本期已收 {{ money(summary.received) }} · 应付 {{ money(summary.payable) }} CNY</span></template>
</DataTable>
</section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" :title="editing ? '维护客户账期' : '客户账户详情'" size="min(760px, 94vw)" :loading="ledgerLoading" :confirm-loading="saving">
<AdminPage v-else as="div" class="finance-account-detail" spacing="normal">
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户账户</el-button><div><h2>{{ editing ? '维护客户账期' : '客户账户详情' }}</h2><p v-if="selected">{{ selected.customerName }} · {{ selected.customerCode }}</p></div></div>
<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">
<el-descriptions :column="2" border class="account-detail"><el-descriptions-item label="客户">{{ selected.customerName }}</el-descriptions-item><el-descriptions-item label="客户编号">{{ selected.customerCode }}</el-descriptions-item><el-descriptions-item label="待收">{{ money(selected.outstandingReceivable) }} CNY</el-descriptions-item><el-descriptions-item label="待开账">{{ money(selected.pendingBilling) }} CNY</el-descriptions-item><el-descriptions-item label="本期已收">{{ money(selected.receivedInPeriod) }} CNY</el-descriptions-item><el-descriptions-item label="待付">{{ money(selected.outstandingPayable) }} CNY</el-descriptions-item></el-descriptions>
<FormSection v-if="!editing" title="账户流水" description="展示应收、应付及每次变动后的未收金额。"><div v-for="entry in ledger" :key="entry.ledgerId" class="ledger-entry"><div><strong>{{ entry.documentNo || `单据 ${entry.documentId}` }}</strong><span>{{ financeBusinessTypeLabel(entry.businessType) }} · {{ financeDocumentTypeLabel(entry.documentType) }}</span></div><span>{{ entry.occurredAt }}</span><div class="ledger-money"><b>应收 {{ money(entry.receivableChange) }}</b><b>应付 {{ money(entry.payableChange) }}</b><span>变动后未收 {{ money(entry.outstandingReceivableAfter) }} CNY</span></div><small>{{ entry.operatorName }}{{ entry.remark ? ` · ${entry.remark}` : '' }}</small></div><el-empty v-if="!ledger.length" description="暂无账户流水" /></FormSection>
<FormSection v-if="!editing" title="账户流水" description="展示应收、应付及每次变动后的未收金额。"><QueryToolbar v-model="ledgerFilters" :fields="ledgerQueryFields" ariaLabel="账户流水筛选" preference-key="finance-account-ledger-query" @search="ledgerPage = 1; loadLedger" /><div class="detail-toolbar"><span>共 {{ ledgerTotal }} 条</span><el-button :disabled="!ledger.length" @click="exportLedger"><Download :size="16" />导出流水</el-button></div><DataTable v-model:page="ledgerPage" v-model:page-size="ledgerPageSize" :data="ledger" :columns="ledgerColumns" :loading="ledgerLoading" :total="ledgerTotal" row-key="ledgerId" empty-text="暂无账户流水" @update:page="loadLedger()" @update:page-size="ledgerPage = 1; loadLedger()"><template #businessType="{ row }">{{ financeBusinessTypeLabel(row.businessType) }}</template></DataTable></FormSection>
<FormSection v-else title="账期设置" description="每个客户独立设置,默认 30 天。"><el-input-number v-model="creditDays" :min="0" :max="3650" :step="1" /><span class="term-unit">天</span></FormSection>
</template>
<template #footer><div class="drawer-footer"><el-button @click="drawerOpen = false">关闭</el-button><el-button v-if="editing && canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div></template>
</AppDrawer>
<div v-if="editing" class="detail-footer"><el-button @click="closeDetail">关闭</el-button><el-button v-if="canUpdateTerms" type="primary" :loading="saving" @click="saveTerms"><Save :size="16" />保存账期</el-button></div>
</AdminPage>
</AdminPage>
</template>
<style scoped>
.finance-account-page{min-width:0}.row-actions,.drawer-footer{display:flex;align-items:center;gap:8px}.drawer-footer{justify-content:flex-end}.row-actions .el-button{margin-left:0}.account-detail{margin-bottom:16px}.term-unit{margin-left:8px;color:var(--cc-muted)}.ledger-entry{display:grid;grid-template-columns:minmax(160px,1fr) auto minmax(210px,auto);gap:8px 16px;padding:12px 0;border-bottom:1px solid var(--el-border-color-lighter)}.ledger-entry>div:first-child,.ledger-money{display:grid;gap:3px}.ledger-entry span,.ledger-entry small{color:var(--cc-muted)}.ledger-entry small{grid-column:1/-1}.ledger-money{text-align:right}.ledger-money b{white-space:nowrap}@media(max-width:720px){.drawer-footer .el-button{min-height:44px}.ledger-entry{grid-template-columns:1fr 1fr}.ledger-money{grid-column:1/-1;text-align:left}:deep(.account-detail .el-descriptions__body .el-descriptions__table){table-layout:fixed}}@media(max-width:480px){.drawer-footer .el-button{flex:1 1 120px}.ledger-entry{grid-template-columns:1fr}:deep(.account-detail .el-descriptions__cell){display:block;width:100%}}
.finance-account-page,.finance-account-detail{min-width:0}.row-actions,.detail-footer,.detail-heading,.detail-toolbar{display:flex;align-items:center;gap:8px}.row-actions .el-button{margin-left:0}.detail-heading{align-items:flex-start;margin-bottom:16px}.detail-heading h2{margin:0;font-size:18px}.detail-heading p{margin:4px 0 0;color:var(--cc-muted)}.detail-toolbar,.detail-footer{justify-content:space-between;margin:12px 0}.detail-footer{justify-content:flex-end}.account-detail{margin-bottom:16px}.term-unit{margin-left:8px;color:var(--cc-muted)}@media(max-width:720px){.detail-heading{align-items:stretch;flex-direction:column}.detail-footer .el-button{min-height:44px}:deep(.account-detail .el-descriptions__body .el-descriptions__table){table-layout:fixed}}@media(max-width:480px){.detail-footer .el-button{flex:1 1 120px}:deep(.account-detail .el-descriptions__cell){display:block;width:100%}}
</style>
@@ -1,12 +1,13 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { createMemoryHistory, createRouter } from 'vue-router'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { ApiResponseError } from '@/shared/api/http'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import type { FinanceFeeSourceDetail } from '../types'
import FinanceCustomerArapView from './FinanceCustomerArapView.vue'
@@ -16,6 +17,7 @@ vi.mock('../api/finance', () => ({
getFinanceFeeSource: vi.fn(),
reviewFinanceFeeSource: vi.fn(),
updateFinanceFeeSourceSettlementInclusion: vi.fn(),
createReconciliationStatement: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver {
@@ -39,13 +41,18 @@ const detail: FinanceFeeSourceDetail = {
sourceStatus: 'AVAILABLE', status: 'AVAILABLE', statementGenerated: false, operationVersion: 3, createdAt: '2026-08-11', operationLogs: [],
}
async function mountView(permissions: string[]) {
async function mountView(permissions: string[], initialPath = '/finance/customer-arap') {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(FinanceCustomerArapView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
const router = createRouter({ history: createMemoryHistory(), routes: [
{ path: '/finance/customer-arap', name: 'finance-customer-arap', component: FinanceCustomerArapView },
{ path: '/finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: FinanceCustomerArapView },
] })
await router.push(initialPath); await router.isReady()
const wrapper = mount(FinanceCustomerArapView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus, router] } })
await flushPromises()
return wrapper
return Object.assign(wrapper, { router })
}
async function loadRows(wrapper: Awaited<ReturnType<typeof mountView>>) {
@@ -69,23 +76,25 @@ describe('FinanceCustomerArapView G2 operations', () => {
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 })
vi.mocked(createReconciliationStatement).mockReset().mockResolvedValue({} as never)
})
it('does not expose or call G2 operations without their permissions', async () => {
it('shows detail from the list permission but keeps G2 operations independent', async () => {
const wrapper = await mountView(['platform:finance:bill:view'])
await loadRows(wrapper)
const buttonLabels = wrapper.findAll('button').map((button) => button.text())
expect(buttonLabels).not.toContain('详情')
expect(buttonLabels).not.toContain('审核')
expect(buttonLabels).not.toContain('结算')
expect(getFinanceFeeSource).not.toHaveBeenCalled()
const vm = wrapper.vm as unknown as { openDetail(row: Record<string, unknown>): Promise<void> }
await vm.openDetail({ id: 'RECEIVABLE:42', feeSourceId: '42' })
expect(getFinanceFeeSource).toHaveBeenCalledWith('42')
expect(reviewFinanceFeeSource).not.toHaveBeenCalled()
expect(updateFinanceFeeSourceSettlementInclusion).not.toHaveBeenCalled()
})
it('loads all customers by default and keeps explicit customer filtering', async () => {
const wrapper = await mountView(['platform:finance:bill:view'])
const wrapper = await mountView(['platform:finance:bill:view', 'platform:customer:list'])
expect(listFinanceFeePool).toHaveBeenCalledWith(undefined, undefined)
const vm = wrapper.vm as unknown as { load(model: Record<string, string>): Promise<void> }
await vm.load({ customerId: '10001', direction: 'RECEIVABLE', status: '', keyword: '' })
@@ -146,4 +155,26 @@ describe('FinanceCustomerArapView G2 operations', () => {
expect(document.body.textContent).toContain('req-g2-error')
expect(document.body.textContent).toContain('trace-g2-error')
})
it('restores the independent detail route from its fee source id', async () => {
const wrapper = await mountView(['platform:finance:bill:view'], '/finance/customer-arap/42')
expect(getFinanceFeeSource).toHaveBeenCalledWith('42')
expect(wrapper.text()).toContain('费用来源详情')
expect(wrapper.text()).toContain('IB-91')
expect(wrapper.router.currentRoute.value.name).toBe('finance-customer-arap-detail')
})
it('creates a reconciliation draft from selected eligible receivables', async () => {
const wrapper = await mountView(['platform:finance:bill:view', 'platform:finance:reconciliation:create'])
await loadRows(wrapper)
const vm = wrapper.vm as unknown as {
selectedRows: Array<Record<string, unknown>>
generationForm: { mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; remark: string }
submitGeneration(): Promise<void>
}
vm.selectedRows = [{ id: 'RECEIVABLE:42', feeSourceId: '42', direction: 'RECEIVABLE', reviewStatus: 'APPROVED', settlementIncluded: true, statementGenerated: false, amount: 100 }]
Object.assign(vm.generationForm, { mode: 'SELECTED', customerId: '10001', period: ['2026-08-01', '2026-08-31'], remark: '八月费用' })
await vm.submitGeneration()
expect(createReconciliationStatement).toHaveBeenCalledWith({ customerId: '10001', currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: ['42'], periodStart: '2026-08-01', periodEnd: '2026-08-31', remark: '八月费用' })
})
})
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Eye, FileCheck2, Link2 } from '@lucide/vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Eye, FileCheck2, FilePlus2, Link2 } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
@@ -8,12 +9,11 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
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 { getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import { createReconciliationStatement, getFinanceFeeSource, listFinanceFeePool, reviewFinanceFeeSource, updateFinanceFeeSourceSettlementInclusion } from '../api/finance'
import { financeArapStatusOptions, financeDirectionLabel, financeOperationActionLabel, financeOperationResultLabel, financeSourceTypeLabel, financeStatusLabel } from '../status'
import type { FinanceFeeSourceDetail } from '../types'
@@ -32,17 +32,23 @@ interface ArapRow {
amount: number
status: string
occurredAt: string
businessDate: string
reviewStatus: string
settlementIncluded: boolean | null
statementGenerated: boolean | null
}
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
const customers = ref<PlatformTenantResource[]>([])
const customersLoading = ref(false)
const customersError = ref<ApiErrorDetails | null>(null)
const filters = ref<QueryModel>({ customerId: '', direction: '', status: '', keyword: '' })
const filters = ref<QueryModel>({ customerId: '', direction: '', reviewStatus: '', settlementIncluded: '', statementGenerated: '', businessRange: [], keyword: '' })
const allRows = ref<ArapRow[]>([])
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const drawerOpen = ref(false)
const detailOpen = ref(false)
const selected = ref<ArapRow | null>(null)
const detail = ref<FinanceFeeSourceDetail | null>(null)
const detailLoading = ref(false)
@@ -55,34 +61,47 @@ const inclusionOpen = ref(false)
const inclusionSaving = ref(false)
const inclusionError = ref<ApiErrorDetails | null>(null)
const inclusionForm = reactive({ included: true, reason: '' })
const generationOpen = ref(false)
const generationSaving = ref(false)
const generationError = ref<ApiErrorDetails | null>(null)
const generationForm = reactive<{ mode: 'BY_CUTOFF' | 'SELECTED'; customerId: string; period: string[]; cutoffDate: string; remark: string }>({ mode: 'SELECTED', customerId: '', period: [], cutoffDate: '', remark: '' })
const selectedRows = ref<ArapRow[]>([])
const page = ref(1)
const pageSize = ref(20)
const canView = computed(() => access.can('platform:finance:bill:view'))
const canViewSource = computed(() => access.can('platform:finance:fee-source:view'))
const canView = computed(() => access.can('platform:finance:customer-arap:list') || access.can('platform:finance:bill:view'))
const canListCustomers = computed(() => canView.value || access.can('platform:customer:list'))
const canViewSource = computed(() => access.can('platform:finance:fee-source:view') || access.can('platform:finance:bill:view'))
const canReviewSource = computed(() => access.can('platform:finance:fee-source:review'))
const canSettleSource = computed(() => access.can('platform:finance:fee-source:settlement'))
const canCreateStatement = computed(() => access.can('platform:finance:reconciliation:create'))
const stateError = computed(() => customersError.value || loadError.value)
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
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: financeArapStatusOptions },
{ 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': '关键词' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' }, { key: 'direction', label: '收付方向', width: 100, slotName: 'direction' },
{ 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 }, { key: 'documentNo', label: '费用单据', minWidth: 170 }, { key: 'sourceType', label: '来源类型', width: 120 },
{ key: 'sourceId', label: '来源追溯', minWidth: 200 }, { key: 'feeName', label: '费用项目', minWidth: 150 }, { key: 'amount', label: '金额(CNY)', width: 130 },
{ key: 'status', label: '状态', width: 120, slotName: 'status' }, { key: 'occurredAt', label: '发生日期', width: 130 },
{ 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: 130 },
]
const filteredRows = computed(() => {
const keyword = String(filters.value.keyword || '').trim().toLowerCase(); const direction = String(filters.value.direction || ''); const status = String(filters.value.status || '')
return allRows.value.filter((row) => (!direction || row.direction === direction) && (!status || row.status === status) && (!keyword || [row.documentNo, row.sourceId, row.feeName].some((value) => value.toLowerCase().includes(keyword))))
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 selectedAmount = computed(() => selectedRows.value.reduce((sum, row) => sum + row.amount, 0))
async function loadCustomers() {
if (!canListCustomers.value) return
customersLoading.value = true; customersError.value = null
try { const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1; do { const result = await listPlatformTenants({ page: currentPage, size: 200 }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages); customers.value = records }
catch (error) { customersError.value = getApiErrorDetails(error, '客户列表加载失败') }
@@ -95,7 +114,7 @@ async function load(model: QueryModel = filters.value) {
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, customerName: customers.value.find((customer) => String(customer.id) === item.customerId)?.tenantName || `客户 ${item.customerId}`, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt }))
allRows.value = items.map((item): ArapRow => ({ id: item.poolItemId, feeSourceId: item.feeSourceId, customerName: item.customerName || customers.value.find((customer) => String(customer.id) === item.customerId)?.tenantName || `客户 ${item.customerId}`, direction: item.direction, documentNo: item.poolItemId, sourceType: item.sourceType, sourceId: [item.inboundId ? `入库单 ${item.inboundId}` : '', item.orderId ? `订单 ${item.orderId}` : '', item.containerId ? `柜次 ${item.containerId}` : '', `来源 ${item.sourceId}/v${item.sourceVersion}`].filter(Boolean).join(' · '), orderId: item.orderId || '', inboundId: item.inboundId || '', containerId: item.containerId || '', feeName: item.feeItemName, amount: item.amount, status: item.status, occurredAt: item.createdAt, businessDate: item.createdAt, reviewStatus: item.status, settlementIncluded: null, statementGenerated: null }))
} catch (error) { loadError.value = getApiErrorDetails(error, '客户应收应付加载失败,请重试') }
finally { loading.value = false }
}
@@ -107,13 +126,14 @@ async function loadDetail(row: ArapRow) {
finally { detailLoading.value = false }
}
async function openDetail(row: Record<string, unknown>) {
selected.value = row as unknown as ArapRow; drawerOpen.value = true
await loadDetail(selected.value)
const target = row as unknown as ArapRow
if (!target.feeSourceId) return
await router.push({ name: 'finance-customer-arap-detail', params: { feeSourceId: target.feeSourceId } })
}
async function openReview(row: Record<string, unknown>) {
const target = row as unknown as ArapRow; selected.value = target
const loaded = await loadDetail(target)
if (!loaded) { drawerOpen.value = true; return }
if (!loaded) { detailOpen.value = true; return }
Object.assign(reviewForm, { action: 'APPROVE', reason: '' }); reviewError.value = null; reviewOpen.value = true
}
async function submitReview() {
@@ -129,7 +149,7 @@ async function submitReview() {
async function openInclusion(row: Record<string, unknown>) {
const target = row as unknown as ArapRow; selected.value = target
const loaded = await loadDetail(target)
if (!loaded) { drawerOpen.value = true; return }
if (!loaded) { detailOpen.value = true; return }
Object.assign(inclusionForm, { included: !loaded.settlementIncluded, reason: '' }); inclusionError.value = null; inclusionOpen.value = true
}
async function submitInclusion() {
@@ -144,32 +164,66 @@ async function submitInclusion() {
}
function statusLabel(status: string) { return financeStatusLabel(status) }
function money(value: unknown) { return Number(value || 0).toFixed(2) }
function onSelectionChange(rows: ArapRow[]) { selectedRows.value = rows }
function openGeneration(mode: 'BY_CUTOFF' | 'SELECTED') {
generationForm.mode = mode; generationForm.customerId = String(filters.value.customerId || ''); generationForm.period = Array.isArray(filters.value.businessRange) ? filters.value.businessRange.map(String) : []; generationForm.cutoffDate = generationForm.period[1] || ''; generationForm.remark = ''; generationError.value = null; generationOpen.value = true
}
async function submitGeneration() {
const eligible = selectedRows.value.filter((row) => row.feeSourceId && row.direction === 'RECEIVABLE' && row.reviewStatus === 'APPROVED' && row.settlementIncluded === true && row.statementGenerated === false)
const period = generationForm.period
if (!generationForm.customerId || !period[0] || !period[1]) { ElMessage.warning('请选择客户和业务期间'); return }
if (generationForm.mode === 'SELECTED' && !eligible.length) { ElMessage.warning('请选择审核通过、参与结算且未出账的应收费用'); return }
generationSaving.value = true; generationError.value = null
try { await createReconciliationStatement({ customerId: generationForm.customerId, currency: 'CNY', generationMode: generationForm.mode, feeSourceIds: generationForm.mode === 'SELECTED' ? eligible.map((row) => row.feeSourceId as string) : undefined, periodStart: period[0], periodEnd: period[1], remark: generationForm.remark.trim() }); generationOpen.value = false; selectedRows.value = []; ElMessage.success('对账单草稿已生成'); await load() } catch (error) { generationError.value = getApiErrorDetails(error, '对账单生成失败,请重试') } finally { generationSaving.value = false }
}
async function closeDetail() { detailOpen.value = false; selected.value = null; detail.value = null; await router.push({ name: 'finance-customer-arap' }) }
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, customerName: detail.value.customerName, direction: detail.value.direction, documentNo: detail.value.poolItemId, sourceType: detail.value.sourceType, sourceId: detail.value.sourceId, orderId: detail.value.orderId || '', inboundId: detail.value.inboundId || '', containerId: detail.value.containerId || '', feeName: 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 }
}
onMounted(async () => { await loadCustomers(); await load() })
onMounted(async () => {
const feeSourceId = String(route.params.feeSourceId || '')
if (route.name === 'finance-customer-arap-detail' && feeSourceId) { await restoreFeeSourceDetail(feeSourceId); return }
if (canListCustomers.value) await loadCustomers()
await load()
})
watch(() => [route.name, String(route.params.feeSourceId || '')], ([name, feeSourceId], previous) => {
if (!previous) return
if (name === 'finance-customer-arap-detail' && feeSourceId) void restoreFeeSourceDetail(feeSourceId)
else { detailOpen.value = false; selected.value = null; detail.value = null; void load() }
})
</script>
<template>
<AdminPage as="div" class="finance-arap-page" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户应收应付" sub-title="请联系管理员开通账单查看权限" />
<ListPageLayout v-else aria-label="客户应收应付">
<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 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="暂无客户应收应付数据">
<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">
<template #toolbar-actions><div v-if="canCreateStatement" class="generation-actions"><el-button :disabled="!filters.customerId" @click="openGeneration('BY_CUTOFF')"><FilePlus2 :size="16" />按截止日期生成</el-button><el-button type="primary" :disabled="!selectedRows.length" @click="openGeneration('SELECTED')"><FilePlus2 :size="16" />按勾选生成</el-button></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 #direction="{ row }"><el-tag :type="row.direction === 'RECEIVABLE' ? 'success' : row.direction === 'PAYABLE' ? 'warning' : 'info'">{{ financeDirectionLabel(row.direction) }}</el-tag></template>
<template #status="{ row }"><el-tag :type="row.status === 'PAID' ? 'success' : row.status === 'VOIDED' ? 'info' : 'warning'">{{ statusLabel(row.status) }}</el-tag></template>
<template #pagination-left>费用来源来自真实账单明细、入库费用快照和成本台账 · CNY</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 #pagination-left>已选 {{ selectedRows.length }} 笔 · 应收金额 {{ money(selectedAmount) }} CNY</template>
</DataTable></section>
</ListPageLayout>
<AppDrawer v-model="drawerOpen" title="费用来源详情" size="min(760px, 94vw)" :loading="detailLoading">
<AdminPage v-else as="div" class="finance-arap-detail" spacing="normal">
<div class="detail-heading"><el-button @click="closeDetail"><ArrowLeft :size="16" />返回客户应收应付</el-button><div><h2>费用来源详情</h2><p>{{ selected?.documentNo }} · {{ 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>
<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">
<el-descriptions :column="2" border class="arap-detail"><el-descriptions-item label="收付方向">{{ financeDirectionLabel(detail.direction) }}</el-descriptions-item><el-descriptions-item label="审核状态">{{ statusLabel(detail.reviewStatus) }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName || selected?.customerName }}</el-descriptions-item><el-descriptions-item label="来源状态">{{ statusLabel(detail.sourceStatus) }}</el-descriptions-item><el-descriptions-item label="来源类型">{{ financeSourceTypeLabel(detail.sourceType) }}</el-descriptions-item><el-descriptions-item label="来源版本">v{{ detail.sourceVersion }}</el-descriptions-item><el-descriptions-item label="入库单">{{ detail.inboundCode || detail.inboundId || '-' }}</el-descriptions-item><el-descriptions-item label="柜次">{{ detail.containerNo || detail.containerId || '-' }}</el-descriptions-item><el-descriptions-item label="费用项目">{{ detail.feeItemName }}</el-descriptions-item><el-descriptions-item label="金额">{{ money(detail.amount) }} CNY</el-descriptions-item><el-descriptions-item label="参与结算">{{ detail.settlementIncluded ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="已生成对账单">{{ detail.statementGenerated ? '是' : '否' }}</el-descriptions-item><el-descriptions-item label="审核原因" :span="2">{{ detail.reviewReason || '-' }}</el-descriptions-item><el-descriptions-item label="结算调整原因" :span="2">{{ detail.settlementReason || '-' }}</el-descriptions-item></el-descriptions>
<section class="operation-log"><h3>操作记录</h3><el-timeline v-if="detail.operationLogs?.length"><el-timeline-item v-for="(log, index) in detail.operationLogs" :key="`${log.occurredAt}-${index}`" :timestamp="log.occurredAt">{{ financeOperationActionLabel(log.action) }} · {{ log.operatorName }}<small>{{ log.remark || financeOperationResultLabel(log.result) }}</small></el-timeline-item></el-timeline><el-empty v-else description="暂无操作记录" /></section>
</template>
<template #footer><el-button @click="drawerOpen = false">关闭</el-button></template>
</AppDrawer>
</AdminPage>
<AppDialog v-model="reviewOpen" title="费用来源审核" confirm-text="提交审核" :confirm-loading="reviewSaving" @confirm="submitReview">
<el-alert v-if="reviewError" type="error" :closable="false"><ApiErrorMeta :details="reviewError" /></el-alert>
<el-form label-position="top"><el-form-item label="审核结论"><el-segmented v-model="reviewForm.action" :options="[{ label: '通过', value: 'APPROVE' }, { label: '驳回', value: 'REJECT' }]" /></el-form-item><el-form-item :label="reviewForm.action === 'REJECT' ? '驳回原因' : '审核说明(可选)'"><el-input v-model="reviewForm.reason" type="textarea" :rows="3" maxlength="500" :placeholder="reviewForm.action === 'REJECT' ? '请填写驳回原因' : '请输入审核说明(可选)'" show-word-limit /></el-form-item></el-form>
@@ -178,17 +232,26 @@ onMounted(async () => { await loadCustomers(); await load() })
<el-alert v-if="inclusionError" type="error" :closable="false"><ApiErrorMeta :details="inclusionError" /></el-alert>
<el-form label-position="top"><el-form-item label="结算状态"><el-switch v-model="inclusionForm.included" active-text="参与结算" inactive-text="排除结算" /></el-form-item><el-form-item label="调整原因"><el-input v-model="inclusionForm.reason" type="textarea" :rows="3" maxlength="500" placeholder="请说明参与或排除结算的原因" show-word-limit /></el-form-item></el-form>
</AppDialog>
<AppDialog v-model="generationOpen" :title="generationForm.mode === 'BY_CUTOFF' ? '按截止日期生成对账单' : '按勾选生成对账单'" confirm-text="生成草稿" :confirm-loading="generationSaving" @confirm="submitGeneration">
<el-alert v-if="generationError" type="error" :closable="false"><ApiErrorMeta :details="generationError" /></el-alert>
<el-form label-position="top"><el-form-item label="客户"><el-select v-model="generationForm.customerId" filterable placeholder="请选择客户" style="width:100%"><el-option v-for="customer in activeCustomers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></el-form-item><el-form-item label="业务期间"><el-date-picker v-model="generationForm.period" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="截止日期" style="width:100%" /></el-form-item><el-form-item v-if="generationForm.mode === 'SELECTED'" label="已选费用"><span>{{ selectedRows.length }} 笔,合计 {{ money(selectedAmount) }} CNY</span></el-form-item><el-form-item label="备注"><el-input v-model="generationForm.remark" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item></el-form>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.finance-arap-page { min-width: 0; }
.finance-arap-page,.finance-arap-detail { min-width: 0; }
.row-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.row-actions .el-button { margin-left: 0; }
.generation-actions,.detail-heading,.detail-actions { display: flex; align-items: center; gap: 8px; }
.detail-heading { align-items: flex-start; margin-bottom: 16px; }
.detail-heading h2 { margin: 0; font-size: 18px; }
.detail-heading p { margin: 4px 0 0; color: var(--cc-muted); }
.detail-actions { margin-left: auto; }
.muted-action { color: var(--cc-muted); }
.arap-detail { margin-bottom: 16px; }
.operation-log h3 { margin: 0 0 14px; font-size: 15px; }
.operation-log small { display: block; margin-top: 4px; color: var(--cc-muted); }
@media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 720px) { .detail-heading { align-items: stretch; flex-direction: column; } .detail-actions { margin-left: 0; } :deep(.arap-detail .el-descriptions__body .el-descriptions__table) { table-layout: fixed; } }
@media (max-width: 480px) { :deep(.arap-detail .el-descriptions__cell) { display: block; width: 100%; } }
</style>
@@ -0,0 +1,122 @@
import ElementPlus from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants } from '@/modules/iam'
import { getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import type { FinanceReconciliationDetail, FinanceStatementReadModel } from '../types'
import FinanceCustomerStatementsView from './FinanceCustomerStatementsView.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/finance', () => ({
actionReconciliationStatement: vi.fn(), createReconciliationStatement: vi.fn(),
getFinanceFeeSource: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceStatements: vi.fn(),
}))
class ResizeObserverStub implements ResizeObserver { observe() {} unobserve() {} disconnect() {} }
function memoryStorage(): Storage {
const values = new Map<string, string>()
return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value) }
}
const statement: FinanceStatementReadModel = {
id: '501', customerId: '10001', billNo: 'REC-501', customerName: '客户A', billDate: '2026-08-31', dueDate: '',
totalAmount: 300, receivedAmount: 100, outstandingAmount: 200, currency: 'CNY', status: 'ISSUED', version: 3,
createdBy: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人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: '创建人A', createdAt: '2026-08-01 09:00:00', updatedBy: '更新人B', updatedAt: '2026-08-20 11:00:00',
lines: [{ statementLineId: '601', sourceFeeId: '81', sourceFeeVersion: 2, orderId: '11', inboundId: '21', feeItemName: '运费', amount: 300 }],
paymentHistory: [{ transactionId: '701', transactionType: 'RECEIPT', transactionDate: '2026-08-20', amount: 100, method: 'BANK_TRANSFER', referenceNo: 'PAY-701', createdBy: '财务员', occurredAt: '2026-08-20 10:00:00' }],
revisions: [],
}
async function mountPage(permissions = ['platform:finance:bill:view']) {
const pinia = createPinia(); setActivePinia(pinia)
const auth = useAuthStore(); auth.token = 'token'; auth.permissions = permissions
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(FinanceCustomerStatementsView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
return wrapper
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('FinanceCustomerStatementsView', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('localStorage', memoryStorage())
vi.stubGlobal('sessionStorage', memoryStorage())
vi.stubGlobal('URL', { createObjectURL: vi.fn(() => 'blob:statement-export'), revokeObjectURL: vi.fn() })
document.body.innerHTML = ''
vi.mocked(listPlatformTenants).mockResolvedValue({ records: [{ id: '10001', tenantName: '客户A', status: 'ACTIVE' } as never], page: 1, size: 200, total: 1, pages: 1 })
vi.mocked(listFinanceStatements).mockResolvedValue([statement])
vi.mocked(getReconciliationStatement).mockResolvedValue(detail)
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('renders the main table and linked detail/payment tables on the same page', async () => {
const wrapper = await mountPage()
await (wrapper.vm as unknown as { openDetail(row: FinanceStatementReadModel): Promise<void> }).openDetail(statement)
await flushPromises()
expect(document.body.textContent).toContain('账单明细表')
expect(document.body.textContent).toContain('账单支付历史表')
expect(document.body.textContent).toContain('REC-501')
expect(document.body.textContent).toContain('CONT-31')
expect(document.body.textContent).toContain('IN-21')
expect(document.body.textContent).toContain('来源版本')
expect(document.body.textContent).toContain('创建人A')
expect(document.body.textContent).toContain('2026-08-20 11:00:00')
expect(document.querySelector('.el-drawer')).toBeNull()
})
it('filters hydrated statements by status, bill number, container, period, settlement and payment date', async () => {
const wrapper = await mountPage()
const vm = wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void>; filters: Record<string, unknown>; filteredRows: FinanceStatementReadModel[] }
await vm.load({
status: 'ISSUED', billNo: '501', customerId: '', containerNo: 'CONT-31',
periodStartRange: ['2026-08-01', '2026-08-02'], periodEndRange: ['2026-08-30', '2026-09-01'],
settled: 'false', lastPaymentRange: ['2026-08-20', '2026-08-20'],
})
await flushPromises()
expect(vm.filteredRows).toHaveLength(1)
vm.filters.containerNo = 'CONT-99'
await flushPromises()
expect(vm.filteredRows).toHaveLength(0)
})
it('exports the currently filtered real statement rows as CSV', async () => {
const clicked = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const wrapper = await mountPage()
;(wrapper.vm as unknown as { exportStatements(): void }).exportStatements()
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob))
expect(clicked).toHaveBeenCalledOnce()
})
it('disables statement generation when customer selection permission is missing', async () => {
vi.mocked(listPlatformTenants).mockClear()
await mountPage(['platform:finance:bill:view', 'platform:finance:reconciliation:create'])
const button = [...document.querySelectorAll('button')].find((item) => item.textContent?.includes('生成对账单'))
expect(button).toBeTruthy()
expect(button?.disabled).toBe(true)
expect(listPlatformTenants).not.toHaveBeenCalled()
})
it('surfaces detail hydration failures instead of silently returning an empty filtered list', async () => {
const wrapper = await mountPage()
vi.mocked(getReconciliationStatement).mockRejectedValueOnce(new Error('detail unavailable'))
await (wrapper.vm as unknown as { load(model: Record<string, unknown>): Promise<void> }).load({ status: 'ISSUED' })
await flushPromises()
expect(document.body.textContent).toContain('筛选字段加载失败')
})
})
@@ -1,19 +1,18 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
import { Download, Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
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 { actionReconciliationStatement, createReconciliationStatement, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { actionReconciliationStatement, createReconciliationStatement, getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
@@ -26,16 +25,41 @@ interface AdjustmentRow {
reason: string
}
interface StatementRow extends FinanceStatementReadModel {
periodStart: string
periodEnd: string
creditDays: number | null
settled: boolean | null
lastPaymentAt: string
remark: string
}
interface StatementLineSource {
containerNo: string
inboundCode: string
businessDate: string
quantity: number | null
unitPrice: number | null
sourceType: string
}
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const filters = ref<QueryModel>({ customerId: '' })
const rows = ref<FinanceStatementReadModel[]>([])
const filters = ref<QueryModel>({
status: '', billNo: '', customerId: '', containerNo: '', periodStartRange: [], periodEndRange: [],
settled: '', lastPaymentRange: [], createdRange: [],
})
const rows = ref<StatementRow[]>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const customerError = ref<ApiErrorDetails | null>(null)
const drawerOpen = ref(false)
const selectedId = ref('')
const detail = ref<FinanceReconciliationDetail | null>(null)
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 actionBusy = ref(false)
@@ -46,26 +70,101 @@ const adjustStatement = ref<FinanceStatementReadModel | null>(null)
const adjustReason = ref('')
const adjustLines = ref<AdjustmentRow[]>([])
const canView = computed(() => access.can('platform:finance:bill:view'))
const canView = computed(() => access.can('platform:finance:customer-statement:list') || access.can('platform:finance:bill:view'))
const canListCustomers = computed(() => access.can('platform:customer:list'))
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
const canReview = computed(() => access.can('platform:finance:reconciliation:review'))
const canIssue = computed(() => access.can('platform:finance:reconciliation:issue'))
const canAdjust = computed(() => access.can('platform:finance:reconciliation:adjust'))
const canVoid = computed(() => access.can('platform:finance:reconciliation:void'))
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '全部客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: '审核状态', width: 180, placeholder: '全部审核状态', options: [
{ label: '草稿', value: 'DRAFT' }, { label: '待审核', value: 'PENDING_REVIEW' }, { label: '审核通过', value: 'APPROVED' },
{ label: '审核拒绝', value: 'REJECTED' }, { label: '已出账', value: 'ISSUED' }, { label: '已作废', value: 'VOIDED' },
] },
{ key: 'billNo', type: 'input', label: '对账单号', width: 210, placeholder: '请输入对账单号' },
{ key: 'customerId', type: 'select', label: '客户', width: 240, placeholder: '全部客户', props: { filterable: true, clearable: true }, options: customers.value.filter((item) => item.status === 'ACTIVE').map((item) => ({ label: item.tenantName, value: String(item.id) })) },
{ key: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: '请输入柜次号' },
{ key: 'periodStartRange', type: 'daterange', label: '结算起始日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
{ key: 'periodEndRange', type: 'daterange', label: '结算结束日', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
{ key: 'settled', type: 'select', label: '是否结清', width: 160, placeholder: '全部', options: [{ label: '是', value: 'true' }, { label: '否', value: 'false' }] },
{ key: 'lastPaymentRange', type: 'daterange', label: '最后收款时间', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
{ key: 'createdRange', type: 'daterange', label: '创建日期', width: 280, props: { valueFormat: 'YYYY-MM-DD', startPlaceholder: '开始日期', endPlaceholder: '结束日期' } },
])
const columns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'status', label: '审核状态', width: 120, slotName: 'status' },
{ key: 'billNo', label: '对账单号', minWidth: 180 },
{ key: 'customerName', label: '客户', minWidth: 160 },
{ key: 'billDate', label: '账单日期', width: 130 },
{ key: 'dueDate', label: '到期日', width: 130 },
{ key: 'totalAmount', label: '应收(CNY)', width: 140 },
{ key: 'receivedAmount', label: '已收(CNY)', width: 140 },
{ key: 'outstandingAmount', label: '未收(CNY)', width: 140 },
{ key: 'status', label: '状态', width: 120, slotName: 'status' },
{ key: 'customerName', label: '客户', minWidth: 180 },
{ key: 'periodStart', label: '结算起始日', width: 130 },
{ key: 'periodEnd', label: '结算结束日', width: 130 },
{ key: 'creditDays', label: '账期(天)', width: 100 },
{ key: 'currency', label: '货币', width: 86 },
{ key: 'totalAmount', label: '总应收金额(CNY)', width: 155 },
{ key: 'receivedAmount', label: '已收金额(CNY)', width: 145 },
{ key: 'outstandingAmount', label: '未收金额(CNY)', width: 145 },
{ key: 'lastPaymentAt', label: '最后收款时间', minWidth: 170 },
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
{ key: 'remark', label: '备注', minWidth: 180 },
{ key: 'createdBy', label: '创建人', width: 110 },
{ key: 'createdAt', label: '创建时间', minWidth: 170 },
{ key: 'updatedBy', label: '更新人', width: 110 },
{ key: 'updatedAt', label: '更新时间', minWidth: 170 },
]
function detailToRow(row: FinanceStatementReadModel, current?: FinanceReconciliationDetail): StatementRow {
const latestPayment = current?.paymentHistory.map((item) => item.occurredAt || item.transactionDate).filter(Boolean).sort().at(-1) || ''
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 || '',
createdBy: current?.createdBy || row.createdBy, createdAt: current?.createdAt || row.createdAt,
updatedBy: current?.updatedBy || row.updatedBy, updatedAt: current?.updatedAt || row.updatedAt,
}
}
function stringFilter(key: string) { return String(filters.value[key] || '').trim().toLowerCase() }
function inDateRange(value: string, key: string) {
const range = filters.value[key]
if (!Array.isArray(range) || range.length < 2 || !value) return !Array.isArray(range) || range.length < 2
const date = value.slice(0, 10)
return date >= String(range[0]) && date <= String(range[1])
}
const filteredRows = computed(() => rows.value.filter((row) => {
const status = stringFilter('status')
const billNo = stringFilter('billNo')
const settled = stringFilter('settled')
const containerNo = stringFilter('containerNo')
const current = detailCache.get(row.id)
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))))
&& inDateRange(row.periodStart, 'periodStartRange')
&& inDateRange(row.periodEnd, 'periodEndRange')
&& inDateRange(row.lastPaymentAt, 'lastPaymentRange')
&& inDateRange(row.createdAt || '', 'createdRange')
}))
async function hydrateLineSources(current: FinanceReconciliationDetail) {
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
try {
const result = await listPlatformTenants({ page: 1, size: 200 })
@@ -75,26 +174,88 @@ async function loadCustomers() {
}
}
async function load(model: QueryModel = filters.value) {
filters.value = { ...model }
async function load(model?: QueryModel) {
const hydrateDetails = model !== undefined
if (model) filters.value = { ...model }
rows.value = []
selectedId.value = ''
detail.value = null
detailCache.clear()
lineSourceCache.clear()
error.value = null
loading.value = true
try { rows.value = await listFinanceStatements(String(filters.value.customerId || '').trim() || undefined) }
catch (cause) { error.value = getApiErrorDetails(cause, '客户对账单加载失败,请重试') }
finally { loading.value = false }
try {
const statements = await listFinanceStatements(String(filters.value.customerId || '').trim() || undefined)
const detailErrors: ApiErrorDetails[] = []
const hydrated = await Promise.all(statements.map(async (row) => {
if (!hydrateDetails) return detailToRow(row)
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} 的筛选字段加载失败`))
return detailToRow(row)
}
}))
if (detailErrors.length) {
error.value = detailErrors[0]!
return
}
rows.value = hydrated
} catch (cause) {
error.value = getApiErrorDetails(cause, '客户对账单加载失败,请重试')
} finally {
loading.value = false
}
}
async function fetchDetail(statementId: string) {
return getReconciliationStatement(statementId)
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
}
async function openDetail(row: FinanceStatementReadModel) {
drawerOpen.value = true
selectedId.value = row.id
detail.value = null
detailError.value = null
try { detail.value = await fetchDetail(row.id) }
catch (cause) { detailError.value = getApiErrorDetails(cause, '对账单详情加载失败,请重试') }
detailLoading.value = true
try {
detail.value = await fetchDetail(row.id, true)
const index = rows.value.findIndex((item) => item.id === row.id)
if (index >= 0) rows.value[index] = detailToRow(rows.value[index]!, detail.value)
} catch (cause) {
detailError.value = getApiErrorDetails(cause, '对账单明细加载失败,请重试')
} finally {
detailLoading.value = false
}
}
function selectedRowClass({ row }: { row: StatementRow }) {
return row.id === selectedId.value ? 'is-selected-statement' : ''
}
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
function exportStatements() {
const header = ['审核状态', '对账单号', '客户', '结算起始日', '结算结束日', '账期(天)', '货币', '总应收金额', '已收金额', '未收金额', '最后收款时间', '是否结清', '备注', '创建人', '创建时间', '更新人', '更新时间']
const body = filteredRows.value.map((row) => [financeStatusLabel(row.status), row.billNo, row.customerName, row.periodStart, row.periodEnd, row.creditDays ?? '', row.currency, row.totalAmount, row.receivedAmount, row.outstandingAmount, row.lastPaymentAt, row.settled === null ? '' : (row.settled ? '是' : '否'), row.remark, row.createdBy || '', row.createdAt || '', row.updatedBy || '', row.updatedAt || ''])
const blob = new Blob([`\uFEFF${[header, ...body].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `客户对账单-${new Date().toISOString().slice(0, 10)}.csv`
anchor.click()
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${body.length} 条客户对账单`)
}
function openCreate() {
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
createOpen.value = true
}
async function createStatement() {
@@ -104,7 +265,7 @@ async function createStatement() {
await createReconciliationStatement({ customerId: String(filters.value.customerId), currency: 'CNY', generationMode: 'BY_CUTOFF', periodStart: createForm.value.periodStart, periodEnd: createForm.value.periodEnd, remark: createForm.value.remark || undefined })
createOpen.value = false
ElMessage.success('对账单已生成')
await load()
await load(filters.value)
} catch (cause) { error.value = getApiErrorDetails(cause, '创建对账单失败') }
finally { createBusy.value = false }
}
@@ -118,7 +279,7 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
try {
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
ElMessage.success('对账单状态已更新')
await load()
await load(filters.value)
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
finally { actionBusy.value = false }
}
@@ -126,13 +287,10 @@ async function performAction(row: FinanceStatementReadModel, actionName: Reconci
async function openAdjustment(row: FinanceStatementReadModel) {
adjustBusy.value = true
try {
const current = await fetchDetail(row.id)
const current = await fetchDetail(row.id, true)
adjustStatement.value = { ...row, version: current.version }
adjustReason.value = ''
adjustLines.value = current.lines.flatMap((line) => {
const statementLineId = line.statementLineId
return statementLineId ? [{ selected: false, statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : []
})
adjustLines.value = current.lines.flatMap((line) => line.statementLineId ? [{ selected: false, statementLineId: line.statementLineId, feeItemName: line.feeItemName, originalAmount: line.amount, adjustedAmount: line.amount, reason: '' }] : [])
adjustOpen.value = true
} catch (cause) { error.value = getApiErrorDetails(cause, '调整明细加载失败') }
finally { adjustBusy.value = false }
@@ -146,20 +304,18 @@ async function submitAdjustment() {
adjustBusy.value = true
try {
await actionReconciliationStatement(statement.id, {
action: 'ADJUST',
reason: adjustReason.value.trim(),
expectedVersion: statement.version,
action: 'ADJUST', reason: adjustReason.value.trim(), expectedVersion: statement.version,
adjustmentLines: selected.map((line) => ({ statementLineId: line.statementLineId, adjustedAmount: line.adjustedAmount, reason: line.reason.trim() })),
})
adjustOpen.value = false
ElMessage.success('对账单调整已保存')
await load()
await load(filters.value)
await openDetail(statement)
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
finally { adjustBusy.value = false }
}
onMounted(() => { void loadCustomers(); void load() })
onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load() })
</script>
<template>
@@ -168,36 +324,79 @@ onMounted(() => { void loadCustomers(); void load() })
<ListPageLayout v-else aria-label="客户对账单">
<template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户对账单筛选" :fields="fields" preference-key="finance-statements-query" @search="load" /></template>
<template v-if="customerError || error" #state><LoadErrorState title="客户对账单加载失败" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState></template>
<section v-if="!customerError && !error" class="surface-panel table-panel">
<DataTable :data="rows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="createOpen = true"><Plus :size="15" />生成对账单</el-button></template>
<template #actions="{ row }">
<el-button link type="primary" @click="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button>
<el-button v-if="row.status === 'DRAFT' && canCreate" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'SUBMIT')"><Send :size="15" />提交</el-button>
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'APPROVE')"><ShieldCheck :size="15" />通过</el-button>
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && (canReview || canVoid)" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
<el-button link aria-label="更多对账单操作" title="更多对账单操作"><MoreHorizontal :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canReview" command="REJECT"><XCircle :size="15" />驳回</el-dropdown-item><el-dropdown-item v-if="canVoid" command="VOID" divided><XCircle :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
<el-button v-if="row.status === 'APPROVED' && canIssue" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'ISSUE')"><Send :size="15" />出账</el-button>
<el-button v-if="row.status === 'ISSUED' && canAdjust" link :loading="adjustBusy" @click="openAdjustment(row as FinanceStatementReadModel)"><RotateCcw :size="15" />调整</el-button>
<el-button v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" link :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" link type="danger" :loading="actionBusy" @click="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
</template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #pagination-left>币种:CNY</template>
</DataTable>
</section>
</ListPageLayout>
<template v-if="!customerError && !error">
<section class="surface-panel table-panel">
<DataTable :data="filteredRows" :columns="columns" :loading="loading" aria-label="客户对账单列表" row-key="id" empty-text="暂无客户对账单" preference-key="finance-customer-statements" :row-class-name="selectedRowClass" @row-click="openDetail">
<template #toolbar-actions>
<el-button :disabled="loading || !filteredRows.length" @click="exportStatements"><Download :size="15" />导出</el-button>
<el-button v-if="canCreate" type="primary" :disabled="!canGenerate" :title="canGenerate ? '生成对账单' : '缺少客户列表权限,无法选择客户'" @click="openCreate"><Plus :size="15" />生成对账单</el-button>
</template>
<template #actions="{ row }">
<el-button link type="primary" @click.stop="openDetail(row as FinanceStatementReadModel)"><Eye :size="15" />详情</el-button>
<el-button v-if="row.status === 'DRAFT' && canCreate" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'SUBMIT')"><Send :size="15" />提交</el-button>
<el-button v-if="row.status === 'PENDING_REVIEW' && canReview" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'APPROVE')"><ShieldCheck :size="15" />通过</el-button>
<el-dropdown v-if="row.status === 'PENDING_REVIEW' && (canReview || canVoid)" trigger="click" @command="(command: ReconciliationActionPayload['action']) => performAction(row as FinanceStatementReadModel, command)">
<el-button link aria-label="更多对账单操作" title="更多对账单操作" @click.stop><MoreHorizontal :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item v-if="canReview" command="REJECT"><XCircle :size="15" />驳回</el-dropdown-item><el-dropdown-item v-if="canVoid" command="VOID" divided><XCircle :size="15" />作废</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
<el-button v-if="row.status === 'APPROVED' && canIssue" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'ISSUE')"><Send :size="15" />出账</el-button>
<el-button v-if="row.status === 'ISSUED' && canAdjust" link :loading="adjustBusy" @click.stop="openAdjustment(row as FinanceStatementReadModel)"><RotateCcw :size="15" />调整</el-button>
<el-button v-if="['REJECTED','VOIDED'].includes(String(row.status)) && canVoid" link :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
<el-button v-if="['DRAFT','APPROVED','ISSUED'].includes(String(row.status)) && canVoid" link type="danger" :loading="actionBusy" @click.stop="performAction(row as FinanceStatementReadModel, 'VOID')"><XCircle :size="15" />作废</el-button>
</template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<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 #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable>
</section>
<AppDrawer v-model="drawerOpen" title="客户对账单详情" size="min(820px, 94vw)">
<LoadErrorState v-if="detailError" title="详情加载失败" :message="detailError.message" :loading="false"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<template v-else-if="detail">
<el-descriptions :column="2" border><el-descriptions-item label="对账单号">{{ detail.billNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ detail.customerName }}</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.totalAmount }} CNY</el-descriptions-item></el-descriptions>
<el-table :data="detail.lines" size="small" border stripe><el-table-column prop="feeItemName" label="费用项目" /><el-table-column prop="amount" label="账单金额(CNY)" /></el-table>
<el-table v-if="detail.revisions.length" :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column prop="action" label="动作" width="130" /><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /></el-table>
<section class="surface-panel statement-detail-panel" aria-label="所选对账单联动明细">
<header class="statement-detail-header">
<div><strong>已选择 {{ detail ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ detail.customerName }}</span></div>
<el-button v-if="detail" link @click="selectedId = ''; detail = null">清空</el-button>
</header>
<el-tabs v-model="activeDetailTab">
<el-tab-pane label="账单明细表" name="lines">
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="selectedId && openDetail(rows.find((row) => row.id === selectedId)!)"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="4" animated />
<template v-else-if="detail">
<el-table :data="detail.lines" size="small" border stripe empty-text="该对账单暂无账单明细">
<el-table-column label="应收/应付" width="110"><template #default>应收</template></el-table-column>
<el-table-column label="客户" min-width="170"><template #default>{{ detail.customerName }}</template></el-table-column>
<el-table-column label="入仓号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template></el-table-column>
<el-table-column label="柜次号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || '—' }}</template></el-table-column>
<el-table-column label="业务发生日" width="130"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template></el-table-column>
<el-table-column label="来源类型" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.sourceType || '—' }}</template></el-table-column>
<el-table-column prop="feeItemName" label="费用项目" min-width="150" />
<el-table-column label="计费量" width="110"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template></el-table-column>
<el-table-column label="单价(CNY)" width="120"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template></el-table-column>
<el-table-column prop="amount" label="账单金额(CNY)" width="150" />
<el-table-column prop="sourceFeeId" label="来源费用ID" min-width="140" />
<el-table-column prop="sourceFeeVersion" label="来源版本" width="110" />
<el-table-column label="对账单号" min-width="180"><template #default>{{ detail.billNo }}</template></el-table-column>
<el-table-column label="结算起始日" width="130"><template #default>{{ detail.periodStart || '—' }}</template></el-table-column>
<el-table-column label="结算结束日" width="130"><template #default>{{ detail.periodEnd || '—' }}</template></el-table-column>
</el-table>
<el-collapse v-if="detail.revisions.length" class="revision-collapse"><el-collapse-item title="调整记录" name="revisions"><el-table :data="detail.revisions" size="small" border stripe><el-table-column prop="revisionNo" label="修订" width="72" /><el-table-column prop="action" label="动作" width="130" /><el-table-column prop="beforeAmount" label="调整前" /><el-table-column prop="afterAmount" label="调整后" /><el-table-column prop="reason" label="原因" /><el-table-column prop="occurredAt" label="发生时间" min-width="170" /></el-table></el-collapse-item></el-collapse>
</template>
<el-empty v-else description="请在主表中选择一张对账单" />
</el-tab-pane>
<el-tab-pane label="账单支付历史表" name="payments">
<el-table v-if="detail" :data="detail.paymentHistory" size="small" border stripe empty-text="该对账单暂无有效收款记录">
<el-table-column prop="transactionDate" label="支付日期" min-width="130" />
<el-table-column prop="amount" label="支付金额(CNY)" min-width="150" />
<el-table-column prop="method" label="支付方式" min-width="130" />
<el-table-column prop="referenceNo" label="支付流水号" min-width="170" />
<el-table-column prop="createdBy" label="登记人" min-width="130" />
<el-table-column prop="occurredAt" label="登记时间" min-width="170" />
<el-table-column prop="remark" label="备注" min-width="180" />
</el-table>
<el-empty v-else description="请在主表中选择一张对账单" />
</el-tab-pane>
</el-tabs>
</section>
</template>
</AppDrawer>
</ListPageLayout>
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(520px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"><el-form label-position="top"><el-form-item label="账期开始"><el-date-picker v-model="createForm.periodStart" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="账期结束"><el-date-picker v-model="createForm.periodEnd" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="备注"><el-input v-model="createForm.remark" type="textarea" /></el-form-item></el-form></AppDialog>
@@ -211,4 +410,14 @@ onMounted(() => { void loadCustomers(); void load() })
<style scoped>
.finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.statement-detail-panel { min-width: 0; padding: 0 16px 16px; }
.statement-detail-header { min-height: 48px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--cc-border-subtle); }
.statement-detail-header div { display: flex; align-items: center; gap: 12px; min-width: 0; }
.statement-detail-header span { color: var(--cc-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.revision-collapse { margin-top: 12px; }
:deep(.is-selected-statement > td.el-table__cell) { background: var(--el-color-primary-light-9) !important; }
@media (max-width: 640px) {
.statement-detail-panel { padding-inline: 12px; }
.statement-detail-header div { align-items: flex-start; flex-direction: column; gap: 2px; padding-block: 8px; }
}
</style>
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage, type UploadFile } from 'element-plus'
import { CreditCard, Eye, HandCoins, Plus } from '@lucide/vue'
import { ElMessage, ElMessageBox, type UploadFile } from 'element-plus'
import { ArrowLeft, CreditCard, Download, Eye, HandCoins, Plus, RotateCcw, XCircle } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { listPlatformPartners, type PlatformPartnerResource } from '@/modules/master-data'
@@ -9,14 +9,14 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import AppDrawer from '@/shared/components/AppDrawer.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
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 {
createFinanceSettlementPayment,
actionFinanceSettlement,
createFinanceReceipt,
createFinanceSettlementPayment,
createPayableSettlement,
createReceivableSettlement,
getFinanceSettlement,
@@ -35,16 +35,19 @@ import type {
FinanceStatementReadModel,
} from '../types'
const props = defineProps<{ kind: 'receivable' | 'payable' }>()
type SettlementRow = FinanceReceivableSettlement | FinancePayableSettlement
const props = defineProps<{ kind: 'receivable' | 'payable'; settlementId?: string }>()
const emit = defineEmits<{ openDetail: [settlementId: string]; back: [] }>()
const access = useAccessPreviewStore()
const customers = ref<PlatformTenantResource[]>([])
const filters = ref<QueryModel>({ customerId: '' })
const rows = ref<Array<FinanceReceivableSettlement | FinancePayableSettlement>>([])
const filters = ref<QueryModel>({ customerId: '', supplierName: '', settlementNo: '', status: '' })
const rows = ref<SettlementRow[]>([])
const loading = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const customerError = ref<ApiErrorDetails | null>(null)
const detailOpen = ref(false)
const detail = ref<FinanceSettlementDetail | null>(null)
const selectedRow = ref<SettlementRow | null>(null)
const detailLoading = ref(false)
const detailError = ref<ApiErrorDetails | null>(null)
const createOpen = ref(false)
@@ -52,7 +55,7 @@ const createBusy = ref(false)
const statementOptions = ref<FinanceStatementReadModel[]>([])
const feeOptions = ref<FinanceFeePoolItem[]>([])
const supplierOptions = ref<PlatformPartnerResource[]>([])
const createForm = ref({ reconciliationStatementId: '', supplierId: '', feeSourceIds: [] as string[], dueDate: '', })
const createForm = ref({ reconciliationStatementId: '', supplierId: '', feeSourceIds: [] as string[], dueDate: '' })
const paymentOpen = ref(false)
const paymentBusy = ref(false)
const paymentFile = ref<File | null>(null)
@@ -61,22 +64,92 @@ const receiptOpen = ref(false)
const receiptBusy = ref(false)
const receiptFile = ref<File | null>(null)
const receiptForm = ref({ receivedDate: new Date().toISOString().slice(0, 10), amount: 0, paymentMethod: 'BANK_TRANSFER' as 'BANK_TRANSFER' | 'CASH' | 'OTHER', remark: '' })
const actionBusy = ref(false)
const viewPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:view' : 'platform:finance:settlement-ap:view')
const createPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:create' : 'platform:finance:settlement-ap:create')
const canView = computed(() => access.can(viewPermission.value))
const listPermission = computed(() => props.kind === 'receivable' ? 'platform:finance:settlement-ar:list' : 'platform:finance:settlement-ap:list')
const canView = computed(() => access.can(listPermission.value) || access.can(viewPermission.value))
const canListCustomers = computed(() => access.can('platform:customer:list'))
const canCreate = computed(() => access.can(createPermission.value))
const canPay = computed(() => props.kind === 'payable' && access.can('platform:finance:payment:create'))
const canReceive = computed(() => props.kind === 'receivable' && access.can('platform:finance:receipt:create'))
const canVoid = computed(() => access.can('platform:finance:settlement:void'))
const title = computed(() => props.kind === 'receivable' ? '结算单应收' : '结算单应付')
const detailMode = computed(() => Boolean(props.settlementId))
const createLabel = computed(() => props.kind === 'receivable' ? '生成应收结算单' : '生成应付结算单')
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
const fields = computed<QueryField[]>(() => [{ key: 'customerId', type: 'select', label: '客户', width: 280, placeholder: '全部客户', props: { filterable: true, clearable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }])
const supplierNames = computed(() => [...new Set(rows.value.map((item) => 'supplierName' in item ? item.supplierName : '').filter(Boolean))])
const statusOptions = computed(() => [...new Set(rows.value.map((item) => item.status).filter(Boolean))])
const fields = computed<QueryField[]>(() => [
{ key: 'status', type: 'select', label: props.kind === 'receivable' ? '收款状态' : '付款状态', width: 180, placeholder: '全部状态', props: { clearable: true }, options: statusOptions.value.map((status) => ({ label: financeStatusLabel(status), value: status })) },
...(props.kind === 'receivable'
? [{ key: 'customerId', type: 'select' as const, label: '客户', width: 240, placeholder: '全部客户', props: { filterable: true, clearable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) }]
: [
{ key: 'supplierName', type: 'select' as const, label: '供应商', width: 240, placeholder: '全部供应商', props: { filterable: true, clearable: true }, options: supplierNames.value.map((name) => ({ label: name, value: name })) },
{ key: 'customerId', type: 'select' as const, label: '客户', width: 220, placeholder: '全部客户', props: { filterable: true, clearable: true, loading: !customers.value.length && !customerError.value }, options: activeCustomers.value.map((item) => ({ label: item.tenantName, value: String(item.id) })) },
]),
{ key: 'settlementNo', type: 'input', label: '结算单号', width: 220, placeholder: '请输入结算单号' },
])
const filteredRows = computed(() => {
const status = String(filters.value.status || '')
const settlementNo = String(filters.value.settlementNo || '').trim().toLowerCase()
const supplierName = String(filters.value.supplierName || '')
return rows.value.filter((item) => (!status || item.status === status)
&& (!settlementNo || item.settlementNo.toLowerCase().includes(settlementNo))
&& (!supplierName || ('supplierName' in item && item.supplierName === supplierName)))
})
const totals = computed(() => filteredRows.value.reduce((result, row) => ({
amount: result.amount + totalAmount(row),
paid: result.paid + paidAmount(row),
outstanding: result.outstanding + row.outstandingAmount,
}), { amount: 0, paid: 0, outstanding: 0 }))
const columns = computed<DataTableColumn[]>(() => props.kind === 'receivable'
? [{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应收结算单号', minWidth: 180 }, { key: 'receivableAmount', label: '应收(CNY)', width: 140 }, { key: 'receivedAmount', label: '已收(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未收(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }]
: [{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' }, { key: 'customerId', label: '客户', minWidth: 160, slotName: 'customer' }, { key: 'settlementNo', label: '应付结算单号', minWidth: 180 }, { key: 'feeItemName', label: '费用项目', minWidth: 160 }, { key: 'payableAmount', label: '应付(CNY)', width: 140 }, { key: 'paidAmount', label: '已付(CNY)', width: 140 }, { key: 'outstandingAmount', label: '未付(CNY)', width: 140 }, { key: 'status', label: '状态', width: 120, slotName: 'status' }])
? [
{ key: 'actions', label: '操作', width: 190, fixed: 'left', slotName: 'actions' },
{ key: 'settlementNo', label: '结算单号', minWidth: 180 },
{ key: 'customerId', label: '客户', minWidth: 180, slotName: 'customer' },
{ 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: 'dueDate', label: '到期日', width: 130, slotName: 'dueDate' },
{ key: 'status', label: '收款状态', width: 120, slotName: 'status' },
]
: [
{ 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: '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' },
])
function money(value: number) { return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` }
function displayDate(value?: string) { return value || '—' }
function rowTotal(value: unknown) { return totalAmount(value as SettlementRow) }
function rowPaid(value: unknown) { return paidAmount(value as SettlementRow) }
function rowOutstanding(value: unknown) { return Number((value as SettlementRow).outstandingAmount || 0) }
function customerName(customerId: string, snapshot?: string) { return snapshot || customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || `客户 ${customerId}` }
function totalAmount(row: SettlementRow) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
function paidAmount(row: SettlementRow) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivedAmount : (row as FinancePayableSettlement).paidAmount }
function selectedSupplierName() { return selectedRow.value && 'supplierName' in selectedRow.value ? selectedRow.value.supplierName || '—' : '—' }
function detailPartyName() { return !detail.value ? '—' : props.kind === 'receivable' ? customerName(detail.value.customerId, detail.value.customerName) : detail.value.supplierName || selectedSupplierName() }
function detailActionRow(): SettlementRow {
const current = detail.value!
return props.kind === 'receivable'
? { id: current.id, customerId: current.customerId, customerName: current.customerName || '', settlementNo: current.settlementNo, billDate: current.generatedDate || '', dueDate: current.dueDate || '', receivableAmount: current.amount, receivedAmount: current.paidAmount, outstandingAmount: current.outstandingAmount, currency: 'CNY', status: current.status as FinanceReceivableSettlement['status'], version: current.version }
: { id: current.id, customerId: current.customerId, settlementNo: current.settlementNo, costType: 'SINGLE_ORDER', orderId: null, inboundId: null, containerId: null, supplierName: current.supplierName || '', feeItemName: '', payableAmount: current.amount, paidAmount: current.paidAmount, outstandingAmount: current.outstandingAmount, currency: 'CNY', status: current.status, createdAt: current.generatedDate || '', version: current.version }
}
function remainingAfter(amount: number) { return money(Math.max(0, Number(detail.value?.outstandingAmount || 0) - Number(amount || 0))) }
async function loadCustomers() {
if (!canListCustomers.value) return
customerError.value = null
try { customers.value = (await listPlatformTenants({ page: 1, size: 200 })).records }
catch (cause) { customerError.value = getApiErrorDetails(cause, '客户列表加载失败') }
@@ -97,22 +170,25 @@ async function load(model: QueryModel = filters.value) {
}
async function openDetail(row: unknown) {
const settlement = row as FinanceReceivableSettlement | FinancePayableSettlement
detailOpen.value = true
selectedRow.value = row as SettlementRow
emit('openDetail', selectedRow.value.id)
await loadDetail(selectedRow.value.id)
}
async function loadDetail(settlementId: string) {
detail.value = null
detailError.value = null
detailLoading.value = true
try { detail.value = await getFinanceSettlement(settlement.id) }
try { detail.value = await getFinanceSettlement(settlementId) }
catch (cause) { detailError.value = getApiErrorDetails(cause, `${title.value}详情加载失败`) }
finally { detailLoading.value = false }
}
function backToList() { emit('back') }
async function openCreate() {
const customerId = String(filters.value.customerId || '')
if (!customerId) {
ElMessage.warning('请先选择客户')
return
}
if (!customerId) return ElMessage.warning('请先选择客户')
createForm.value = { reconciliationStatementId: '', supplierId: '', feeSourceIds: [], dueDate: '' }
createOpen.value = true
error.value = null
@@ -136,11 +212,8 @@ async function submitCreate() {
if (props.kind === 'payable' && (!createForm.value.supplierId || !createForm.value.dueDate || !createForm.value.feeSourceIds.length)) return ElMessage.warning('请完整填写供应商、到期日和费用来源')
createBusy.value = true
try {
if (props.kind === 'receivable') {
await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' })
} else {
await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' })
}
if (props.kind === 'receivable') await createReceivableSettlement({ reconciliationStatementId: createForm.value.reconciliationStatementId, currency: 'CNY' })
else await createPayableSettlement({ customerId, supplierId: createForm.value.supplierId, feeSourceIds: createForm.value.feeSourceIds, dueDate: createForm.value.dueDate, currency: 'CNY' })
createOpen.value = false
ElMessage.success(`${title.value}已生成`)
await load()
@@ -150,16 +223,23 @@ async function submitCreate() {
function openPayment(row: unknown) {
const settlement = row as FinancePayableSettlement
selectedRow.value = settlement
detail.value = { id: settlement.id, settlementNo: settlement.settlementNo, customerId: settlement.customerId, direction: 'PAYABLE', amount: settlement.payableAmount, paidAmount: settlement.paidAmount, outstandingAmount: settlement.outstandingAmount, currency: 'CNY', status: settlement.status, version: settlement.version, lines: [], transactions: [], revisions: [] }
paymentFile.value = null
paymentForm.value = { transactionDate: new Date().toISOString().slice(0, 10), amount: settlement.outstandingAmount, method: 'BANK_TRANSFER', referenceNo: '', remark: '' }
paymentOpen.value = true
}
function openPaymentFromDetail() {
if (!detail.value) return
openPayment({ id: detail.value.id, settlementNo: detail.value.settlementNo, customerId: detail.value.customerId, supplierName: detail.value.supplierName || '', feeItemName: '', payableAmount: detail.value.amount, paidAmount: detail.value.paidAmount, outstandingAmount: detail.value.outstandingAmount, currency: 'CNY', status: detail.value.status, version: detail.value.version })
}
function selectPaymentFile(file: UploadFile) { paymentFile.value = file.raw ?? null }
async function openReceipt(row: unknown) {
const settlement = row as FinanceReceivableSettlement
selectedRow.value = settlement
receiptBusy.value = true
try {
const current = await getFinanceSettlement(settlement.id)
@@ -172,6 +252,11 @@ async function openReceipt(row: unknown) {
finally { receiptBusy.value = false }
}
async function openReceiptFromDetail() {
if (!detail.value) return
await openReceipt({ id: detail.value.id, customerId: detail.value.customerId, settlementNo: detail.value.settlementNo, receivableAmount: detail.value.amount, receivedAmount: detail.value.paidAmount, outstandingAmount: detail.value.outstandingAmount, currency: 'CNY', status: detail.value.status, version: detail.value.version })
}
function selectReceiptFile(file: UploadFile) { receiptFile.value = file.raw ?? null }
async function submitReceipt() {
@@ -180,17 +265,11 @@ async function submitReceipt() {
receiptBusy.value = true
try {
const voucher = await uploadFinanceVoucher(current.customerId, receiptFile.value)
await createFinanceReceipt({
customerId: current.customerId,
receivedDate: receiptForm.value.receivedDate,
amount: receiptForm.value.amount,
paymentMethod: receiptForm.value.paymentMethod,
remark: receiptForm.value.remark || undefined,
allocations: [{ billId: current.reconciliationStatementId, amount: receiptForm.value.amount }],
}, [voucher.fileId])
await createFinanceReceipt({ customerId: current.customerId, receivedDate: receiptForm.value.receivedDate, amount: receiptForm.value.amount, paymentMethod: receiptForm.value.paymentMethod, remark: receiptForm.value.remark || undefined, allocations: [{ billId: current.reconciliationStatementId, amount: receiptForm.value.amount }] }, [voucher.fileId])
receiptOpen.value = false
ElMessage.success('收款已登记')
await load()
if (detailMode.value) await loadDetail(current.id)
else await load()
} catch (cause) { error.value = getApiErrorDetails(cause, '收款登记失败') }
finally { receiptBusy.value = false }
}
@@ -203,14 +282,65 @@ async function submitPayment() {
await createFinanceSettlementPayment(detail.value.id, { ...paymentForm.value, currency: 'CNY', voucherFileIds: [voucher.fileId], expectedVersion: detail.value.version })
paymentOpen.value = false
ElMessage.success('付款已登记')
await load()
if (detailMode.value) await loadDetail(detail.value.id)
else await load()
} catch (cause) { error.value = getApiErrorDetails(cause, '付款登记失败') }
finally { paymentBusy.value = false }
}
function customerName(customerId: string) { return customers.value.find((item) => String(item.id) === String(customerId))?.tenantName || '客户信息暂不可用' }
function totalAmount(row: FinanceReceivableSettlement | FinancePayableSettlement) { return props.kind === 'receivable' ? (row as FinanceReceivableSettlement).receivableAmount : (row as FinancePayableSettlement).payableAmount }
onMounted(() => { void loadCustomers(); void load() })
async function performAction(row: SettlementRow, action: 'VOID' | 'REOPEN') {
const reason = await ElMessageBox.prompt('请输入操作原因', action === 'VOID' ? `作废${title.value}` : `重开${title.value}`, {
inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消',
}).then((result) => result.value.trim()).catch(() => null)
if (reason === null) return
actionBusy.value = true
try {
await actionFinanceSettlement(row.id, { action, reason, expectedVersion: row.version })
ElMessage.success(action === 'VOID' ? '结算单已作废' : '结算单已重开')
if (detailMode.value) await loadDetail(row.id)
else await load()
} catch (cause) { error.value = getApiErrorDetails(cause, `${action === 'VOID' ? '作废' : '重开'}结算单失败`) }
finally { actionBusy.value = false }
}
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
function exportRows() {
if (!filteredRows.value.length) return ElMessage.warning('当前没有可导出的结算单')
const header = props.kind === 'receivable'
? ['结算单号', '客户', '币种', '应收金额', '已收', '余额', '生成日期', '到期日', '收款状态']
: ['结算单号', '供应商', '费用类型', '币种', '应付金额', '已付', '余额', '生成日期', '付款状态']
const records = filteredRows.value.map((row) => props.kind === 'receivable'
? [row.settlementNo, customerName(row.customerId, (row as FinanceReceivableSettlement).customerName), row.currency, totalAmount(row), paidAmount(row), row.outstandingAmount, (row as FinanceReceivableSettlement).billDate, (row as FinanceReceivableSettlement).dueDate, financeStatusLabel(row.status)]
: [row.settlementNo, (row as FinancePayableSettlement).supplierName, (row as FinancePayableSettlement).feeItemName, row.currency, totalAmount(row), paidAmount(row), row.outstandingAmount, (row as FinancePayableSettlement).createdAt, financeStatusLabel(row.status)])
const blob = new Blob([`\ufeff${[header, ...records].map((record) => record.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${title.value}-${new Date().toISOString().slice(0, 10)}.csv`
link.click()
URL.revokeObjectURL(url)
}
function exportDetail() {
if (!detail.value) return
const current = detail.value
const header = ['结算单号', props.kind === 'receivable' ? '客户' : '供应商', '币种', props.kind === 'receivable' ? '应收金额' : '应付金额', props.kind === 'receivable' ? '已收' : '已付', '余额', '业务期间', '生成日期', '到期日', '状态']
const record = [current.settlementNo, props.kind === 'receivable' ? customerName(current.customerId, current.customerName) : current.supplierName, current.currency, current.amount, current.paidAmount, current.outstandingAmount, current.businessPeriod, current.generatedDate, current.dueDate, financeStatusLabel(current.status)]
const blob = new Blob([`\ufeff${[header, record].map((line) => line.map(csvCell).join(',')).join('\r\n')}`], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${current.settlementNo}.csv`
link.click()
URL.revokeObjectURL(url)
}
onMounted(() => {
if (canListCustomers.value) void loadCustomers()
const settlementId = String(props.settlementId || '')
if (settlementId) void loadDetail(settlementId)
else void load()
})
</script>
<template>
@@ -218,31 +348,102 @@ onMounted(() => { void loadCustomers(); void load() })
<el-result v-if="!canView" icon="warning" :title="`无权访问${title}`" sub-title="请联系管理员开通结算单查看权限" />
<ListPageLayout v-else :aria-label="title">
<template #toolbar>
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" preference-key="finance-settlement-query" @search="load" />
<QueryToolbar v-model="filters" class="surface-panel" :ariaLabel="`${title}筛选`" :fields="fields" :preference-key="`finance-settlement-${kind}-query`" @search="load" @reset="load" />
</template>
<template v-if="customerError || error" #state>
<LoadErrorState :title="`${title}加载失败`" :message="(customerError || error)?.message || ''" :loading="loading" @retry="customerError ? loadCustomers() : load()"><template #details><ApiErrorMeta :details="(customerError || error)!" /></template></LoadErrorState>
</template>
<section v-if="!customerError && !error" class="surface-panel table-panel">
<DataTable :data="rows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="暂无结算单数据">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button></template>
<template #actions="{ row }"><el-button link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button><el-button v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link :loading="receiptBusy" @click="openReceipt(row)"><HandCoins :size="15" />收款</el-button><el-button v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link @click="openPayment(row)"><CreditCard :size="15" />付款</el-button></template>
<template #customer="{ row }">{{ customerName(String(row.customerId)) }}</template>
<template #status="{ row }"><el-tag :type="financeStatusTone(row.status)">{{ financeStatusLabel(row.status) }}</el-tag></template>
<template #pagination-left>币种:CNY</template>
</DataTable>
</section>
<template v-if="!customerError && !error">
<section class="settlement-summary" aria-label="结算汇总">
<div class="summary-item"><span>{{ kind === 'receivable' ? '应收总额' : '应付总额' }}</span><strong>{{ money(totals.amount) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '已收' : '已付' }}</span><strong class="is-success">{{ money(totals.paid) }}</strong></div>
<div class="summary-item"><span>{{ kind === 'receivable' ? '待收' : '待付' }}</span><strong class="is-danger">{{ money(totals.outstanding) }}</strong></div>
</section>
<section class="surface-panel table-panel">
<DataTable :data="filteredRows" :columns="columns" :loading="loading" :aria-label="`${title}列表`" row-key="id" empty-text="暂无符合条件的结算单">
<template #toolbar-actions>
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="15" />{{ createLabel }}</el-button>
<el-button @click="exportRows"><Download :size="15" />导出</el-button>
</template>
<template #actions="{ row }">
<el-button link type="primary" @click="openDetail(row)"><Eye :size="15" />详情</el-button>
<el-button v-if="canReceive && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link :loading="receiptBusy" @click="openReceipt(row)"><HandCoins :size="15" />收款</el-button>
<el-button v-if="canPay && ['ISSUED','PARTIALLY_PAID'].includes(String(row.status))" link @click="openPayment(row)"><CreditCard :size="15" />付款</el-button>
<el-button v-if="canVoid && row.status === 'ISSUED'" link type="danger" :loading="actionBusy" @click="performAction(row as SettlementRow, 'VOID')"><XCircle :size="15" />作废</el-button>
<el-button v-if="canVoid && row.status === 'VOIDED'" link :loading="actionBusy" @click="performAction(row as SettlementRow, 'REOPEN')"><RotateCcw :size="15" />重开</el-button>
</template>
<template #customer="{ row }">{{ customerName(String(row.customerId), row.customerName) }}</template>
<template #supplier="{ row }">{{ row.supplierName || '—' }}</template>
<template #feeType="{ row }">{{ row.feeItemName || '详见费用明细' }}</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 #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 #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable>
</section>
</template>
</ListPageLayout>
<AppDrawer v-model="detailOpen" :title="`${title}详情`" size="min(760px, 94vw)">
<AppDrawer v-model="detailOpen" :title="`${title}详情`" size="min(900px, 96vw)">
<LoadErrorState v-if="detailError" :title="`${title}详情加载失败`" :message="detailError.message" :loading="detailLoading"><template #details><ApiErrorMeta :details="detailError" /></template></LoadErrorState>
<el-skeleton v-else-if="detailLoading" :rows="6" animated />
<template v-else-if="detail"><el-descriptions :column="2" border><el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item><el-descriptions-item label="客户">{{ customerName(detail.customerId) }}</el-descriptions-item><el-descriptions-item label="金额">{{ detail.amount }} CNY</el-descriptions-item><el-descriptions-item label="已收付">{{ detail.paidAmount }} CNY</el-descriptions-item><el-descriptions-item label="未收付">{{ detail.outstandingAmount }} CNY</el-descriptions-item><el-descriptions-item label="状态">{{ financeStatusLabel(detail.status) }}</el-descriptions-item></el-descriptions></template>
<el-skeleton v-else-if="detailLoading" :rows="8" animated />
<div v-else-if="detail" class="detail-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="结算单号">{{ detail.settlementNo }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '客户' : '供应商'">{{ kind === 'receivable' ? customerName(detail.customerId) : selectedSupplierName() }}</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="kind === 'receivable' ? '应收金额' : '应付金额'">{{ money(detail.amount) }}</el-descriptions-item>
<el-descriptions-item :label="kind === 'receivable' ? '已收' : '已付'">{{ money(detail.paidAmount) }}</el-descriptions-item>
<el-descriptions-item label="余额">{{ money(detail.outstandingAmount) }}</el-descriptions-item>
<el-descriptions-item v-if="detail.reconciliationStatementId" label="关联对账单">{{ detail.reconciliationStatementId }}</el-descriptions-item>
</el-descriptions>
<section class="detail-section">
<h3>费用明细</h3>
<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" />
<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>
<el-table-column prop="sourceVersion" label="来源版本" width="100" />
<el-table-column prop="amount" label="金额(CNY)" width="130" align="right"><template #default="scope">{{ money(scope.row.amount) }}</template></el-table-column>
</el-table>
</section>
<section class="detail-section">
<h3>{{ kind === 'receivable' ? '收款记录' : '付款记录' }}</h3>
<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 label="凭证" width="100"><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>
</section>
<section class="detail-section">
<h3>修订记录</h3>
<el-table :data="detail.revisions" size="small" border stripe empty-text="暂无修订记录">
<el-table-column prop="revisionNo" label="版本" width="90" />
<el-table-column prop="action" label="动作" min-width="120" />
<el-table-column prop="beforeAmount" label="调整前(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.beforeAmount) }}</template></el-table-column>
<el-table-column prop="afterAmount" label="调整后(CNY)" width="140" align="right"><template #default="scope">{{ money(scope.row.afterAmount) }}</template></el-table-column>
<el-table-column prop="reason" label="原因" min-width="180"><template #default="scope">{{ scope.row.reason || '—' }}</template></el-table-column>
<el-table-column prop="occurredAt" label="时间" min-width="160" />
</el-table>
</section>
</div>
</AppDrawer>
<AppDialog v-model="createOpen" :title="`生成${title}`" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="submitCreate">
<el-form label-position="top">
<el-form-item v-if="props.kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
<el-form-item v-if="kind === 'receivable'" label="已出账对账单"><el-select v-model="createForm.reconciliationStatementId" filterable><el-option v-for="item in statementOptions" :key="item.id" :label="`${item.billNo} · ${item.outstandingAmount} CNY`" :value="item.id" /></el-select></el-form-item>
<template v-else><el-form-item label="供应商"><el-select v-model="createForm.supplierId" filterable placeholder="请选择供应商"><el-option v-for="item in supplierOptions" :key="String(item.id)" :label="`${item.partnerName} · ${item.partnerCode}`" :value="String(item.id)" /></el-select></el-form-item><el-form-item label="到期日"><el-date-picker v-model="createForm.dueDate" type="date" value-format="YYYY-MM-DD" /></el-form-item><el-form-item label="应付费用来源"><el-select v-model="createForm.feeSourceIds" multiple filterable><el-option v-for="item in feeOptions" :key="item.feeSourceId!" :label="`${item.feeItemName} · ${item.amount} CNY`" :value="item.feeSourceId!" /></el-select></el-form-item></template>
</el-form>
</AppDialog>
@@ -259,6 +460,19 @@ onMounted(() => { void loadCustomers(); void load() })
<style scoped>
.finance-settlement-page { min-width: 0; }
.settlement-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.summary-item { min-width: 0; padding: 16px 18px; border: 1px solid var(--cc-border, #e5e7eb); border-radius: 8px; background: var(--cc-surface, #fff); }
.summary-item span { display: block; color: var(--cc-text-secondary, #64748b); font-size: 13px; }
.summary-item strong { display: block; margin-top: 8px; color: var(--cc-text-primary, #1f2937); font-size: 24px; line-height: 1.2; }
.summary-item strong.is-success { color: #16803c; }
.summary-item strong.is-danger { color: #c2413a; }
.detail-content { min-width: 0; }
.detail-section { margin-top: 22px; }
.detail-section h3 { margin: 0 0 10px; color: var(--cc-text-primary, #1f2937); font-size: 15px; font-weight: 600; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
@media (max-width: 720px) { :deep(.el-drawer__footer .el-button) { min-height: 44px; } }
@media (max-width: 720px) {
.settlement-summary { grid-template-columns: 1fr; }
.summary-item strong { font-size: 21px; }
:deep(.el-drawer__footer .el-button) { min-height: 44px; }
}
</style>
@@ -1,4 +1,4 @@
import ElementPlus from 'element-plus'
import ElementPlus, { ElMessageBox } from 'element-plus'
import { createPinia, setActivePinia } from 'pinia'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -6,6 +6,7 @@ import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { useAuthStore } from '@/core/auth/auth'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import {
actionFinanceSettlement,
actionReconciliationStatement,
createFinanceReceipt,
getFinanceSettlement,
@@ -20,7 +21,7 @@ import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
vi.mock('@/modules/iam', () => ({ listPlatformTenants: vi.fn() }))
vi.mock('../api/finance', () => ({
actionReconciliationStatement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(),
actionFinanceSettlement: vi.fn(), actionReconciliationStatement: vi.fn(), createFinanceReceipt: vi.fn(), createFinanceSettlementPayment: vi.fn(),
createPayableSettlement: vi.fn(), createReceivableSettlement: vi.fn(), createReconciliationStatement: vi.fn(),
getFinanceSettlement: vi.fn(), getReconciliationStatement: vi.fn(), listFinanceFeePool: vi.fn(),
listFinancePayableSettlements: vi.fn(), listFinanceReceivableSettlements: vi.fn(), listFinanceStatements: vi.fn(),
@@ -83,6 +84,7 @@ describe('finance G3/G4 page operations', () => {
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(actionReconciliationStatement).mockReset().mockResolvedValue(reconciliation)
vi.mocked(actionFinanceSettlement).mockReset().mockResolvedValue({ ...receivable, status: 'VOIDED', version: 1 })
vi.mocked(getReconciliationStatement).mockReset().mockResolvedValue(reconciliation)
vi.mocked(getFinanceSettlement).mockReset().mockResolvedValue(receivable)
vi.mocked(listFinanceReceivableSettlements).mockReset().mockResolvedValue([])
@@ -140,4 +142,17 @@ describe('finance G3/G4 page operations', () => {
allocations: [{ billId: '501', amount: 300 }],
}, ['9001'])
})
it('submits settlement void and reopen actions with the current version and reason', async () => {
const prompt = vi.spyOn(ElMessageBox, 'prompt').mockResolvedValue({ value: '账务冲销' } as never)
const pinia = piniaWithPermissions(['platform:finance:settlement-ar:view', 'platform:finance:settlement:void'])
const wrapper = mount(FinanceFormalSettlementPage, { props: { kind: 'receivable' }, attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
const vm = wrapper.vm as unknown as { performAction(row: Record<string, unknown>, action: 'VOID' | 'REOPEN'): Promise<void> }
await vm.performAction({ id: '701', version: 3 }, 'VOID')
expect(actionFinanceSettlement).toHaveBeenCalledWith('701', { action: 'VOID', reason: '账务冲销', expectedVersion: 3 })
prompt.mockRestore()
})
})
@@ -1,2 +1,12 @@
<script setup lang="ts">import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'</script>
<template><FinanceFormalSettlementPage kind="payable" /></template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
const route = useRoute()
const router = useRouter()
const settlementId = computed(() => String(route.params.settlementId || ''))
function openDetail(id: string) { void router.push({ name: 'finance-settlement-payable-detail', params: { settlementId: id } }) }
function back() { void router.push({ name: 'finance-settlement-payables' }) }
</script>
<template><FinanceFormalSettlementPage kind="payable" :settlement-id="settlementId" @open-detail="openDetail" @back="back" /></template>
@@ -1,2 +1,12 @@
<script setup lang="ts">import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'</script>
<template><FinanceFormalSettlementPage kind="receivable" /></template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import FinanceFormalSettlementPage from './FinanceFormalSettlementPage.vue'
const route = useRoute()
const router = useRouter()
const settlementId = computed(() => String(route.params.settlementId || ''))
function openDetail(id: string) { void router.push({ name: 'finance-settlement-receivable-detail', params: { settlementId: id } }) }
function back() { void router.push({ name: 'finance-settlement-receivables' }) }
</script>
<template><FinanceFormalSettlementPage kind="receivable" :settlement-id="settlementId" @open-detail="openDetail" @back="back" /></template>
+9 -5
View File
@@ -1,9 +1,13 @@
import type { RouteRecordRaw } from 'vue-router'
export const financeRoutes = [
{ path: 'finance/customer-accounts', name: 'finance-customer-accounts', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户', description: '查看客户 CNY 应收台账并维护独立账期', permission: 'platform:finance:account:view' } },
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:bill:view' } },
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:bill:view' } },
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:view' } },
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:view' } },
{ path: 'finance/customer-accounts', name: 'finance-customer-accounts', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户', description: '查看客户 CNY 应收台账并维护独立账期', permission: 'platform:finance:customer-account:list', permissionAnyOf: ['platform:finance:account:view'] } },
{ path: 'finance/customer-accounts/:customerId', name: 'finance-customer-account-detail', component: () => import('./pages/FinanceCustomerAccountsView.vue'), meta: { title: '客户账户详情', description: '查看客户派生财务汇总与账户流水', permission: 'platform:finance:customer-account:list', permissionAnyOf: ['platform:finance:account:view'], showPageHeading: false } },
{ path: 'finance/customer-arap', name: 'finance-customer-arap', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '客户应收应付', description: '按客户追溯入库单、订单和柜次费用来源', permission: 'platform:finance:customer-arap:list', permissionAnyOf: ['platform:finance:bill:view'] } },
{ path: 'finance/customer-arap/:feeSourceId', name: 'finance-customer-arap-detail', component: () => import('./pages/FinanceCustomerArapView.vue'), meta: { title: '费用来源详情', description: '查看费用来源、审核进度、关联业务与操作记录', permission: 'platform:finance:customer-arap:list', permissionAnyOf: ['platform:finance:bill:view'], showPageHeading: false } },
{ path: 'finance/customer-statements', name: 'finance-customer-statements', component: () => import('./pages/FinanceCustomerStatementsView.vue'), meta: { title: '客户对账单', description: '维护 CNY 客户对账单并在出账后冻结', permission: 'platform:finance:customer-statement:list', permissionAnyOf: ['platform:finance:bill:view'] } },
{ path: 'finance/settlement-receivables', name: 'finance-settlement-receivables', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '结算单应收', description: '登记客户收款、凭证及对应对账单金额', permission: 'platform:finance:settlement-ar:list', permissionAnyOf: ['platform:finance:settlement-ar:view'] } },
{ path: 'finance/settlement-receivables/:settlementId', name: 'finance-settlement-receivable-detail', component: () => import('./pages/FinanceSettlementReceivablesView.vue'), meta: { title: '应收结算单详情', description: '查看应收结算信息、费用明细和收款记录', permission: 'platform:finance:settlement-ar:list', permissionAnyOf: ['platform:finance:settlement-ar:view'], showPageHeading: false } },
{ path: 'finance/settlement-payables', name: 'finance-settlement-payables', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '结算单应付', description: '登记单票及整柜公共成本并追溯业务来源', permission: 'platform:finance:settlement-ap:list', permissionAnyOf: ['platform:finance:settlement-ap:view'] } },
{ path: 'finance/settlement-payables/:settlementId', name: 'finance-settlement-payable-detail', component: () => import('./pages/FinanceSettlementPayablesView.vue'), meta: { title: '应付结算单详情', description: '查看应付结算信息、费用明细和付款记录', permission: 'platform:finance:settlement-ap:list', permissionAnyOf: ['platform:finance:settlement-ap:view'], showPageHeading: false } },
] satisfies RouteRecordRaw[]
+33 -1
View File
@@ -4,7 +4,7 @@ export type FinancePaymentMethod = 'BANK_TRANSFER' | 'CASH' | 'OTHER'
export type FinanceFeeDirection = 'RECEIVABLE' | 'PAYABLE'
export type FinanceReviewStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
export type FinanceReconciliationStatus = 'DRAFT' | 'PENDING_REVIEW' | 'APPROVED' | 'REJECTED' | 'ISSUED' | 'VOIDED'
export type FinanceSettlementStatus = 'ISSUED' | 'VOIDED'
export type FinanceSettlementStatus = 'ISSUED' | 'PARTIALLY_PAID' | 'PAID' | 'VOIDED'
export type FinancePaymentStatus = 'UNPAID' | 'PARTIALLY_PAID' | 'PAID'
export interface FinanceVoucherResource {
@@ -47,6 +47,7 @@ export interface FinanceFeePoolItem {
poolItemId: string
feeSourceId: string | null
customerId: string
customerName?: string
direction: 'RECEIVABLE' | 'PAYABLE'
sourceType: string
sourceId: string
@@ -74,17 +75,26 @@ export interface FinanceStatementReadModel {
currency: 'CNY'
status: FinanceBillStatus
version: number
createdBy?: string
createdAt?: string
updatedBy?: string
updatedAt?: string
}
export interface FinanceReceivableSettlement {
id: string
customerId: string
settlementNo: string
customerName?: string
reconciliationStatementId?: string
businessPeriod?: string
generatedDate?: string
billDate: string
dueDate: string
receivableAmount: number
receivedAmount: number
outstandingAmount: number
paymentStatus?: FinancePaymentStatus
currency: 'CNY'
status: FinanceBillStatus
version: number
@@ -94,15 +104,21 @@ export interface FinancePayableSettlement {
id: string
customerId: string
settlementNo: string
customerName?: string
supplierId?: string
costType: FinanceCostType
orderId: string | null
inboundId: string | null
containerId: string | null
supplierName: string
feeItemName: string
businessPeriod?: string
generatedDate?: string
dueDate?: string
payableAmount: number
paidAmount: number
outstandingAmount: number
paymentStatus?: FinancePaymentStatus
currency: 'CNY'
status: string
createdAt: string
@@ -165,7 +181,13 @@ export interface FinanceSettlementDetail {
settlementNo: string
customerId: string
supplierId?: string
customerName?: string
supplierName?: string
reconciliationStatementId?: string
businessPeriod?: string
generatedDate?: string
dueDate?: string
paymentStatus?: FinancePaymentStatus
direction: 'RECEIVABLE' | 'PAYABLE'
amount: number
paidAmount: number
@@ -178,11 +200,21 @@ export interface FinanceSettlementDetail {
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; occurredAt: string }>
}
export interface FinanceSettlementActionPayload {
action: 'VOID' | 'REOPEN'
reason: string
expectedVersion: number
}
export interface FinanceReconciliationDetail extends FinanceBillDetail {
periodStart: string
periodEnd: string
creditDays: number
settled: boolean
createdBy?: string
createdAt?: string
updatedBy?: string
updatedAt?: string
paymentHistory: Array<{ transactionId: string; transactionType: string; transactionDate: string; amount: number; method: string; referenceNo: string; createdBy: string; occurredAt: string; remark?: string }>
revisions: Array<{ revisionNo: number; action: string; beforeAmount: number; afterAmount: number; reason?: string; operatorId: string; occurredAt: string }>
}