This commit is contained in:
2026-08-13 12:22:41 +08:00
parent 835ac0ad12
commit 8a8c4fa190
26 changed files with 572 additions and 148 deletions
@@ -13,7 +13,7 @@ import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import { actionReconciliationStatement, createReconciliationStatement, getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { financeStatusLabel, financeStatusTone } from '../status'
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
@@ -62,6 +62,7 @@ const detailCache = new Map<string, FinanceReconciliationDetail>()
const lineSourceCache = new Map<string, StatementLineSource>()
const createOpen = ref(false)
const createBusy = ref(false)
const exportBusy = ref(false)
const actionBusy = ref(false)
const createForm = ref({ periodStart: new Date().toISOString().slice(0, 8) + '01', periodEnd: new Date().toISOString().slice(0, 10), remark: '' })
const adjustOpen = ref(false)
@@ -70,8 +71,9 @@ const adjustStatement = ref<FinanceStatementReadModel | null>(null)
const adjustReason = ref('')
const adjustLines = ref<AdjustmentRow[]>([])
const canView = computed(() => access.can('platform:finance:customer-statement:list') || access.can('platform:finance:bill:view'))
const canView = computed(() => access.can('platform:finance:customer-statement:list'))
const canListCustomers = computed(() => access.can('platform:customer:list'))
const canViewFeeSource = computed(() => access.can('platform:finance:customer-arap: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'))
@@ -85,7 +87,7 @@ const fields = computed<QueryField[]>(() => [
] },
{ 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: 'containerNo', type: 'input', label: '柜次号', width: 190, placeholder: canViewFeeSource.value ? '请输入柜次号' : '缺少费用来源查看权限', props: { disabled: !canViewFeeSource.value } },
{ 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' }] },
@@ -149,6 +151,7 @@ const filteredRows = computed(() => rows.value.filter((row) => {
}))
async function hydrateLineSources(current: FinanceReconciliationDetail) {
if (!canViewFeeSource.value) return
await Promise.all(current.lines.map(async (line) => {
if (!line.sourceFeeId || lineSourceCache.has(line.sourceFeeId)) return
try {
@@ -235,12 +238,43 @@ async function openDetail(row: FinanceStatementReadModel) {
}
}
function clearSelection() {
selectedId.value = ''
detail.value = null
detailError.value = null
}
function retrySelectedDetail() {
const row = rows.value.find((item) => item.id === selectedId.value)
if (row) void openDetail(row)
}
function selectedRowClass({ row }: { row: StatementRow }) {
return row.id === selectedId.value ? 'is-selected-statement' : ''
}
function paymentMethodLabel(method: string) {
return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' } as Record<string, string>)[method] || method || '—'
}
function revisionActionLabel(action: string) {
return ({ SUBMIT: '提交审核', APPROVE: '审核通过', REJECT: '审核驳回', ISSUE: '出账', ADJUST: '调整', VOID: '作废', REOPEN: '重开' } as Record<string, string>)[action] || action || '—'
}
function csvCell(value: unknown) { return `"${String(value ?? '').replaceAll('"', '""')}"` }
function exportStatements() {
async function exportStatements() {
exportBusy.value = true
try {
const hydrated = await Promise.all(rows.value.map(async (row) => {
const current = await fetchDetail(row.id)
return detailToRow(row, current)
}))
rows.value = hydrated
} catch (cause) {
error.value = getApiErrorDetails(cause, '导出前加载完整对账单字段失败')
exportBusy.value = false
return
}
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' })
@@ -250,6 +284,7 @@ function exportStatements() {
anchor.download = `客户对账单-${new Date().toISOString().slice(0, 10)}.csv`
anchor.click()
URL.revokeObjectURL(url)
exportBusy.value = false
ElMessage.success(`已导出 ${body.length} 条客户对账单`)
}
@@ -260,6 +295,8 @@ function openCreate() {
async function createStatement() {
if (!filters.value.customerId) return ElMessage.warning('请先选择客户')
if (!createForm.value.periodStart || !createForm.value.periodEnd) return ElMessage.warning('请选择完整账期')
if (createForm.value.periodStart > createForm.value.periodEnd) return ElMessage.warning('账期开始日期不能晚于结束日期')
createBusy.value = true
try {
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 })
@@ -328,7 +365,7 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<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 :loading="exportBusy" :disabled="loading || exportBusy || !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 }">
@@ -352,32 +389,35 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<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>
<div><strong>已选择 {{ selectedId ? 1 : 0 }} 项</strong><span v-if="detail">{{ detail.billNo }} · {{ detail.customerName }}</span></div>
<el-button v-if="selectedId" link @click="clearSelection">清空</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>
<LoadErrorState v-if="detailError" title="明细加载失败" :message="detailError.message" :loading="detailLoading" @retry="retrySelectedDetail"><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="柜次号" min-width="140"><template #default="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</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 label="来源类型" min-width="140"><template #default="{ row }">{{ financeSourceTypeLabel(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="sourceAmount" label="来源金额(CNY)" width="150" />
<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 prop="sourceHash" label="来源快照Hash" min-width="190" />
<el-table-column prop="adjustmentReason" label="调整说明" min-width="180" />
<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>
<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 label="动作" width="130"><template #default="{ row }">{{ revisionActionLabel(row.action) }}</template></el-table-column><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>
@@ -385,7 +425,7 @@ onMounted(() => { if (canListCustomers.value) void loadCustomers(); void load()
<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 label="支付方式" min-width="130"><template #default="{ row }">{{ paymentMethodLabel(row.method) }}</template></el-table-column>
<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" />