优化
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user