Files
admin-web/src/modules/finance/pages/FinanceCustomerStatementsView.vue
T
2026-08-17 19:13:25 +08:00

646 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Download, Eye, MoreHorizontal, Plus, RotateCcw, Send, ShieldCheck, XCircle } from '@lucide/vue'
import { useAccessPreviewStore } from '@/core/access/accessPreview'
import { createPlatformExportTask } from '@/modules/downloads'
import { listPlatformTenants, type PlatformTenantResource } from '@/modules/iam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import AdminPage from '@/shared/components/AdminPage.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import DetailPageLayout from '@/shared/components/DetailPageLayout.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 { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { actionReconciliationStatement, createReconciliationStatement, getFinanceFeeSource, getReconciliationStatement, listFinanceStatements } from '../api/finance'
import { financeSourceTypeLabel, financeStatusLabel, financeStatusTone } from '../status'
import type { FinanceReconciliationDetail, FinanceStatementReadModel, ReconciliationActionPayload } from '../types'
interface AdjustmentRow {
selected: boolean
statementLineId: string
feeItemName: string
originalAmount: number
adjustedAmount: number
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 route = useRoute()
const router = useRouter()
const customers = ref<PlatformTenantResource[]>([])
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 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 exportBusy = ref(false)
const actionBusy = ref(false)
const createForm = ref({
customerId: '',
periodStart: new Date().toISOString().slice(0, 8) + '01',
periodEnd: new Date().toISOString().slice(0, 10),
remark: '',
})
const adjustOpen = ref(false)
const adjustBusy = ref(false)
const adjustStatement = ref<FinanceStatementReadModel | null>(null)
const adjustReason = ref('')
const adjustLines = ref<AdjustmentRow[]>([])
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-statement:list'))
const canCreate = computed(() => access.can('platform:finance:reconciliation:create'))
const activeCustomers = computed(() => customers.value.filter((item) => item.status === 'ACTIVE'))
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 canExport = computed(() => access.can('platform:finance:customer-statement:export') && access.can('platform:download:list'))
const canGenerate = computed(() => canCreate.value && canListCustomers.value)
const detailRouteId = computed(() => route.name === 'finance-customer-statement-detail' ? String(route.params.statementId || '') : '')
const isDetailRoute = computed(() => Boolean(detailRouteId.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: 260, 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: 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' }] },
{ 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: 180, slotName: 'customerName' },
{ 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, slotName: 'lastPaymentAt' },
{ key: 'settled', label: '是否结清', width: 100, slotName: 'settled' },
{ key: 'remark', label: '备注', minWidth: 180 },
{ key: 'createdByName', label: '创建人', width: 130, slotName: 'createdByName' },
{ key: 'createdAt', label: '创建时间', minWidth: 170, slotName: 'createdAt' },
{ key: 'updatedByName', label: '更新人', width: 130, slotName: 'updatedByName' },
{ key: 'updatedAt', label: '更新时间', minWidth: 170, slotName: 'updatedAt' },
]
const detailLineColumns: DataTableColumn[] = [
{ key: 'inboundCode', label: '入仓号', minWidth: 150, slotName: 'inboundCode' },
{ key: 'containerNo', label: '柜次号', minWidth: 150, slotName: 'containerNo' },
{ key: 'businessDate', label: '业务发生日', width: 130, slotName: 'businessDate' },
{ key: 'feeItemName', label: '费用项目', minWidth: 160 },
{ key: 'quantity', label: '计费量', width: 110, slotName: 'quantity' },
{ key: 'unitPrice', label: '单价(CNY)', width: 120, slotName: 'unitPrice' },
{ key: 'sourceAmount', label: '来源金额(CNY)', width: 150 },
{ key: 'amount', label: '账单金额(CNY)', width: 150 },
{ key: 'adjustmentReason', label: '调整说明', minWidth: 190 },
{ key: 'billNo', label: '对账单号', minWidth: 190, slotName: 'billNo' },
{ key: 'periodStart', label: '结算起始日', width: 130, slotName: 'periodStart' },
{ key: 'periodEnd', label: '结算结束日', width: 130, slotName: 'periodEnd' },
]
const paymentColumns: DataTableColumn[] = [
{ key: 'customerName', label: '客户', minWidth: 200, slotName: 'customerName' },
{ key: 'transactionDate', label: '支付日期', width: 130, slotName: 'transactionDate' },
{ key: 'amount', label: '支付金额(CNY)', width: 150 },
{ key: 'method', label: '支付方式', width: 130, slotName: 'method' },
{ key: 'referenceNo', label: '支付流水号', minWidth: 180 },
{ key: 'createdByName', label: '登记人', minWidth: 140, slotName: 'createdByName' },
{ key: 'occurredAt', label: '登记时间', width: 170, slotName: 'occurredAt' },
{ key: 'remark', label: '备注', minWidth: 200 },
]
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName.trim()])))
function isInternalCustomerLabel(value: string, customerId: string) {
const normalized = value.replaceAll(' ', '')
return normalized === customerId || /^客户(?:ID)?\d+$/.test(normalized)
}
function customerDisplayName(customerId: string, value?: string) {
const resolved = customerNameById.value.get(String(customerId))
if (resolved) return resolved
const candidate = String(value || '').trim()
return candidate && !isInternalCustomerLabel(candidate, String(customerId)) ? candidate : '客户名称未提供'
}
function operatorDisplayName(value?: string) {
const candidate = String(value || '').trim()
if (!candidate || /^(?:operator-)?\d+$/i.test(candidate)) return '名称数据缺失'
return candidate
}
function formatDateTime(value?: string) {
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—'
}
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, createdByName: current?.createdByName || row.createdByName, createdAt: current?.createdAt || row.createdAt,
updatedBy: current?.updatedBy || row.updatedBy, updatedByName: current?.updatedByName || row.updatedByName, updatedAt: current?.updatedAt || row.updatedAt,
}
}
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) {
if (!canViewFeeSource.value) return
await Promise.all(current.lines.map(async (line) => {
if (!line.sourceFeeId || lineSourceCache.has(line.sourceFeeId)) return
try {
const source = await getFinanceFeeSource(line.sourceFeeId)
lineSourceCache.set(line.sourceFeeId, {
containerNo: source.containerNo, inboundCode: source.inboundCode, businessDate: source.businessDate,
quantity: source.quantity, unitPrice: source.unitPrice, sourceType: source.sourceType,
})
} catch {
lineSourceCache.set(line.sourceFeeId, { containerNo: '', inboundCode: '', businessDate: '', quantity: null, unitPrice: null, sourceType: '' })
}
}))
}
async function loadCustomers() {
if (!canListCustomers.value) return
customerError.value = null
try {
const records: PlatformTenantResource[] = []; let currentPage = 1; let pages = 1
do { const result = await listPlatformTenants({ page: currentPage, size: 200, status: 'ACTIVE' }); records.push(...result.records); pages = Math.max(1, result.pages); currentPage += 1 } while (currentPage <= pages)
customers.value = records
} catch (cause) {
customerError.value = getApiErrorDetails(cause, '客户列表加载失败')
}
}
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 {
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, 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 loadDetail(statementId: string) {
selectedId.value = statementId
detail.value = null
detailError.value = null
detailLoading.value = true
try {
detail.value = await fetchDetail(statementId, true)
} catch (cause) {
detailError.value = getApiErrorDetails(cause, '对账单明细加载失败,请重试')
} finally {
detailLoading.value = false
}
}
async function openDetail(row: FinanceStatementReadModel) {
await router.push({ name: 'finance-customer-statement-detail', params: { statementId: row.id } })
}
function retrySelectedDetail() {
if (selectedId.value) void loadDetail(selectedId.value)
}
async function returnToList() {
await router.push({ name: 'finance-customer-statements' })
}
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 || '—'
}
async function exportStatements() {
const customerId = String(filters.value.customerId || '').trim()
if (customerId && !/^[1-9]\d*$/.test(customerId)) return ElMessage.error('客户业务标识无效,无法创建导出任务')
const parameters: Record<string, string | boolean> = {}
const putText = (key: string) => {
const value = String(filters.value[key] || '').trim()
if (value) parameters[key] = value
}
const putRange = (sourceKey: string, fromKey: string, toKey: string) => {
const value = filters.value[sourceKey]
if (!Array.isArray(value)) return
if (value[0]) parameters[fromKey] = String(value[0])
if (value[1]) parameters[toKey] = String(value[1])
}
if (customerId) parameters.customerId = customerId
putText('status')
putText('billNo')
putText('containerNo')
putRange('periodStartRange', 'periodStartFrom', 'periodStartTo')
putRange('periodEndRange', 'periodEndFrom', 'periodEndTo')
putRange('lastPaymentRange', 'lastPaymentFrom', 'lastPaymentTo')
putRange('createdRange', 'createdFrom', 'createdTo')
const settled = String(filters.value.settled || '')
if (settled) parameters.settled = settled === 'true'
exportBusy.value = true
try {
const task = await createPlatformExportTask({
exportType: 'FINANCE_RECONCILIATION_STATEMENT_LIST',
format: 'XLSX',
taskName: '客户对账单',
parameters,
})
const openDownloadCenter = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo })
if (openDownloadCenter) await router.push({ name: 'download-center' })
} catch (cause) {
ElMessage.error(getApiErrorMessage(cause, '客户对账单导出任务创建失败'))
} finally {
exportBusy.value = false
}
}
function openCreate() {
if (!canListCustomers.value) return ElMessage.warning('缺少客户列表权限,无法选择客户生成对账单')
createForm.value = { ...createForm.value, customerId: '' }
createOpen.value = true
}
async function createStatement() {
if (!createForm.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(createForm.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(filters.value)
} catch (cause) { error.value = getApiErrorDetails(cause, '创建对账单失败') }
finally { createBusy.value = false }
}
async function performAction(row: FinanceStatementReadModel, actionName: ReconciliationActionPayload['action']) {
const reason = ['REJECT', 'VOID', 'REOPEN'].includes(actionName)
? await ElMessageBox.prompt('请输入操作原因', '对账单操作', { inputType: 'textarea', inputPattern: /\S+/, inputErrorMessage: '操作原因不能为空', confirmButtonText: '确认', cancelButtonText: '取消' }).then((result) => result.value).catch(() => null)
: undefined
if (reason === null) return
actionBusy.value = true
try {
await actionReconciliationStatement(row.id, { action: actionName, expectedVersion: row.version, ...(reason ? { reason } : {}) })
ElMessage.success('对账单状态已更新')
await load(filters.value)
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单操作失败') }
finally { actionBusy.value = false }
}
async function openAdjustment(row: FinanceStatementReadModel) {
adjustBusy.value = true
try {
const current = await fetchDetail(row.id, true)
adjustStatement.value = { ...row, version: current.version }
adjustReason.value = ''
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 }
}
async function submitAdjustment() {
const statement = adjustStatement.value
const selected = adjustLines.value.filter((line) => line.selected)
if (!statement || !adjustReason.value.trim() || !selected.length) return ElMessage.warning('请填写调整原因并至少选择一条明细')
if (selected.some((line) => line.adjustedAmount <= 0 || !line.reason.trim())) return ElMessage.warning('请填写所选明细的新金额和调整说明')
adjustBusy.value = true
try {
await actionReconciliationStatement(statement.id, {
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(filters.value)
await openDetail(statement)
} catch (cause) { error.value = getApiErrorDetails(cause, '对账单调整失败') }
finally { adjustBusy.value = false }
}
onMounted(() => { if (canListCustomers.value) void loadCustomers() })
watch(detailRouteId, (statementId) => {
if (statementId) void loadDetail(statementId)
else {
selectedId.value = ''
detail.value = null
detailError.value = null
void load()
}
}, { immediate: true })
</script>
<template>
<AdminPage as="div" class="finance-statements-page" spacing="normal">
<el-result v-if="!canView" icon="warning" title="无权访问客户对账单" sub-title="请联系管理员开通财务查看权限" />
<DetailPageLayout v-else-if="isDetailRoute" class="surface-panel statement-detail-page" aria-label="客户对账单详情">
<template #header>
<header class="statement-detail-page__header">
<div><h2>客户对账单详情</h2><p>{{ detail?.billNo || '正在加载对账单信息' }}</p></div>
<el-button @click="returnToList"><ArrowLeft :size="16" />返回客户对账单</el-button>
</header>
</template>
<template v-if="detail" #summary>
<div class="statement-detail-head">
<div><span>客户对账单</span><h2>{{ detail.billNo }}</h2><p>{{ customerDisplayName(detail.customerId, detail.customerName) }} · {{ detail.periodStart || '—' }} 至 {{ detail.periodEnd || '—' }}</p></div>
<el-tag :type="financeStatusTone(detail.status)">{{ financeStatusLabel(detail.status) }}</el-tag>
</div>
<FormSection class="statement-summary-section" title="对账单概要" aria-label="对账单摘要">
<div class="statement-summary-grid">
<div class="statement-summary-item"><span>总应收金额</span><strong>{{ detail.totalAmount.toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>已收金额</span><strong>{{ detail.paidAmount.toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>未收金额</span><strong class="statement-outstanding">{{ detail.outstandingAmount.toFixed(2) }} CNY</strong></div>
<div class="statement-summary-item"><span>账期</span><strong>{{ detail.creditDays }} 天</strong></div>
<div class="statement-summary-item"><span>货币</span><strong>{{ detail.currency }}</strong></div>
<div class="statement-summary-item"><span>是否结清</span><strong>{{ detail.settled ? '是' : '否' }}</strong></div>
<div class="statement-summary-item"><span>创建人</span><strong>{{ operatorDisplayName(detail.createdByName) }}</strong></div>
<div class="statement-summary-item"><span>创建时间</span><strong>{{ formatDateTime(detail.createdAt) }}</strong></div>
<div class="statement-summary-item"><span>更新人</span><strong>{{ operatorDisplayName(detail.updatedByName) }}</strong></div>
<div class="statement-summary-item"><span>更新时间</span><strong>{{ formatDateTime(detail.updatedAt) }}</strong></div>
<div class="statement-summary-item statement-summary-item--remark"><span>备注</span><strong>{{ detail.remark || '—' }}</strong></div>
</div>
</FormSection>
</template>
<el-tabs v-model="activeDetailTab" class="statement-detail-tabs">
<el-tab-pane label="账单明细表" name="lines">
<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 />
<FormSection v-else-if="detail" title="账单明细" :bordered="false">
<div class="detail-table-scroll">
<DataTable class="statement-detail-table statement-line-table" :data="detail.lines" size="small" border stripe :columns="detailLineColumns" column-settings-mode="hide" row-key="statementLineId" empty-text="该对账单暂无账单明细" aria-label="客户对账单账单明细">
<template #inboundCode="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.inboundCode || row.inboundId || '—' }}</template>
<template #containerNo="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.containerNo || (row.containerId ? `柜次ID ${row.containerId}` : '—') }}</template>
<template #businessDate="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.businessDate || '—' }}</template>
<template #quantity="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.quantity ?? '—' }}</template>
<template #unitPrice="{ row }">{{ lineSourceCache.get(row.sourceFeeId)?.unitPrice ?? '—' }}</template>
<template #billNo>{{ detail.billNo }}</template>
<template #periodStart>{{ detail.periodStart || '—' }}</template>
<template #periodEnd>{{ detail.periodEnd || '—' }}</template>
</DataTable>
</div>
<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 label="操作人" min-width="140"><template #default="{ row }">{{ operatorDisplayName(row.operatorName) }}</template></el-table-column><el-table-column label="发生时间" min-width="170"><template #default="{ row }">{{ formatDateTime(row.occurredAt) }}</template></el-table-column></el-table></el-collapse-item></el-collapse>
</FormSection>
</el-tab-pane>
<el-tab-pane label="账单支付历史表" name="payments">
<FormSection v-if="detail" title="支付历史" :bordered="false">
<div class="detail-table-scroll">
<DataTable class="statement-detail-table statement-payment-table" :data="detail.paymentHistory" size="small" border stripe :columns="paymentColumns" column-settings-mode="hide" row-key="transactionId" empty-text="该对账单暂无有效收款记录" aria-label="客户对账单支付历史">
<template #customerName>{{ customerDisplayName(detail.customerId, detail.customerName) }}</template>
<template #transactionDate="{ row }">{{ formatDateTime(row.transactionDate).slice(0, 10) }}</template>
<template #method="{ row }">{{ paymentMethodLabel(row.method) }}</template>
<template #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
<template #occurredAt="{ row }">{{ formatDateTime(row.occurredAt) }}</template>
</DataTable>
</div>
</FormSection>
</el-tab-pane>
</el-tabs>
</DetailPageLayout>
<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>
<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-click="openDetail">
<template #toolbar-actions>
<el-button v-if="canExport" :loading="exportBusy" :disabled="loading || exportBusy" @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 #customerName="{ row }">{{ customerDisplayName(row.customerId, row.customerName) }}</template>
<template #lastPaymentAt="{ row }">{{ formatDateTime(row.lastPaymentAt) }}</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 #createdByName="{ row }">{{ operatorDisplayName(row.createdByName) }}</template>
<template #createdAt="{ row }">{{ formatDateTime(row.createdAt) }}</template>
<template #updatedByName="{ row }">{{ operatorDisplayName(row.updatedByName) }}</template>
<template #updatedAt="{ row }">{{ formatDateTime(row.updatedAt) }}</template>
<template #pagination-left>共 {{ filteredRows.length }} 条 · 币种:CNY</template>
</DataTable>
</section>
</template>
</ListPageLayout>
<AppDialog v-model="createOpen" title="生成客户对账单" width="min(560px, 94vw)" :confirm-loading="createBusy" @confirm="createStatement"><el-form label-position="top"><el-form-item label="客户"><el-select v-model="createForm.customerId" filterable placeholder="请选择客户"><el-option v-for="item in activeCustomers" :key="String(item.id)" :label="item.tenantName" :value="String(item.id)" /></el-select></el-form-item><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>
<AppDialog v-model="adjustOpen" title="调整客户对账单" width="min(900px, 96vw)" :confirm-loading="adjustBusy" @confirm="submitAdjustment">
<el-form label-position="top"><el-form-item label="整体调整原因"><el-input v-model="adjustReason" type="textarea" maxlength="500" show-word-limit /></el-form-item></el-form>
<el-table :data="adjustLines" size="small" max-height="420" border stripe><el-table-column label="选择" width="64"><template #default="{ row }"><el-checkbox v-model="row.selected" /></template></el-table-column><el-table-column prop="feeItemName" label="费用项目" min-width="150" /><el-table-column prop="originalAmount" label="原金额(CNY)" width="130" /><el-table-column label="新金额(CNY)" width="180"><template #default="{ row }"><el-input-number v-model="row.adjustedAmount" :min="0.01" :precision="2" /></template></el-table-column><el-table-column label="明细说明" min-width="220"><template #default="{ row }"><el-input v-model="row.reason" maxlength="500" /></template></el-table-column></el-table>
</AppDialog>
</AdminPage>
</template>
<style scoped>
.finance-statements-page { min-width: 0; }
:deep(.el-select), :deep(.el-date-editor) { width: 100%; }
.statement-detail-page { min-width: 0; width: min(100%, 1920px); margin: 0 auto; overflow: hidden; }
.statement-detail-page :deep(.detail-page-layout) { gap: 0; }
.statement-detail-page__header { min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
.statement-detail-page__header h2 { margin: 0; color: var(--cc-text-primary); font-size: 20px; }
.statement-detail-page__header p { margin: 4px 0 0; color: var(--cc-text-secondary); font-size: 13px; }
.statement-detail-head { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: start; gap: 8px; padding: 10px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
.statement-detail-head > div > span { color: var(--cc-primary); font-size: 11px; font-weight: 700; }
.statement-detail-head h2 { margin: 2px 0 0; font-size: 16px; line-height: 1.4; overflow-wrap: anywhere; }
.statement-detail-head p { margin: 5px 0 0; color: var(--cc-text-secondary); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
.statement-summary-section { padding: 10px 16px; border-bottom: 1px solid var(--cc-border-subtle); }
.statement-summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px 24px; }
.statement-summary-item { min-width: 0; }
.statement-summary-item span, .statement-summary-item strong { display: block; overflow-wrap: anywhere; }
.statement-summary-item span { margin-bottom: 3px; color: var(--cc-text-secondary); font-size: 12px; }
.statement-summary-item strong { color: var(--cc-text-primary); font-size: 14px; line-height: 1.45; }
.statement-summary-item--remark { grid-column: span 2; }
.statement-outstanding { color: var(--el-color-warning-dark-2); }
.statement-detail-tabs { min-width: 0; }
.statement-detail-table { min-width: 0; }
.detail-table-scroll { min-width: 0; overflow-x: auto; }
.statement-detail-tabs :deep(.el-tabs__header) { margin: 0; padding: 0 16px; }
.statement-detail-tabs :deep(.el-tabs__content) { min-width: 0; }
.statement-detail-tabs :deep(.form-section) { padding: 12px 16px 12px; }
.statement-line-table { min-width: 1840px; }
.statement-payment-table { min-width: 1300px; }
.revision-collapse { margin-top: 12px; }
@media (min-width: 1920px) {
.statement-detail-page :deep(.detail-page-layout) {
gap: 8px;
}
.statement-detail-page :deep(.detail-page-layout__header) {
min-height: 56px;
padding: 10px 14px;
}
.statement-detail-page__header h2 {
font-size: 18px;
}
.statement-detail-head {
grid-template-columns: minmax(0, 1fr) auto;
gap: 6px;
padding: 8px 14px;
}
.statement-summary-section {
padding: 8px 14px;
}
.statement-summary-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 10px 24px;
}
.statement-summary-item--remark {
grid-column: span 2;
}
.statement-detail-tabs :deep(.el-tabs__header) {
padding: 0 14px;
}
.statement-detail-tabs :deep(.form-section) {
padding: 8px 14px;
}
.statement-detail-tabs :deep(.form-section__heading) {
margin-bottom: 8px;
}
.detail-table-scroll {
margin-bottom: 8px;
}
.revision-collapse {
margin-top: 8px;
}
}
@media (max-width: 640px) {
.statement-detail-page__header { align-items: stretch; flex-direction: column; }
.statement-summary-grid { grid-template-columns: 1fr; }
.statement-summary-item--remark { grid-column: auto; }
}
</style>