Files
admin-web/src/modules/finance/pages/FinanceReceivableStatisticsView.vue
T

174 lines
13 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 '@/styles/adminWorkbench.css'
import '@/styles/adminDensity.css'
import '../financeDensity.css'
import { computed, onMounted, ref } from 'vue'
import { CircleCheck, Clock3, Download, Printer, RotateCcw, Search, Wallet } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { useRoute, useRouter } from 'vue-router'
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 DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
import { getReceivableStatistics } from '../api/finance'
import type { ReceivableStatisticsResult, ReceivableStatisticsRow } from '../types'
const now = new Date()
const defaultMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
const access = useAccessPreviewStore()
const route = useRoute()
const router = useRouter()
const month = ref(defaultMonth)
const customerId = ref('')
const customers = ref<PlatformTenantResource[]>([])
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const exporting = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const result = ref<ReceivableStatisticsResult | null>(null)
const canView = computed(() => access.can('platform:finance:receivable-statistics:list'))
const canPrint = computed(() => route.name === 'finance-receivable-statistics' && canView.value && !loading.value && !error.value && Boolean(result.value))
const canExport = computed(() => access.can('platform:finance:receivable-statistics:export') && access.can('platform:download:list'))
const rows = computed<ReceivableStatisticsRow[]>(() => result.value?.records ?? [])
const customerNameById = computed(() => new Map(customers.value.map((item) => [String(item.id), item.tenantName])))
const columns: DataTableColumn[] = [
{ key: 'customerName', label: '应收单位', minWidth: 240, slotName: 'customerName' },
{ key: 'currency', label: '币种', width: 100 },
{ key: 'totalReceivable', label: '应收总额(CNY)', width: 180, align: 'right', slotName: 'totalReceivable' },
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, align: 'right', slotName: 'totalReceived' },
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, align: 'right', slotName: 'totalOutstanding' },
]
async function load() {
if (!canView.value || !month.value) return
loading.value = true
error.value = null
try {
result.value = await getReceivableStatistics({ month: month.value, customerId: customerId.value || undefined, page: page.value, pageSize: pageSize.value })
} catch (reason) { error.value = getApiErrorDetails(reason, '应收款统计加载失败,请重试') }
finally { loading.value = false }
}
async function loadCustomers() {
try {
const records: PlatformTenantResource[] = []
let current = 1; let pages = 1
do { const response = await listPlatformTenants({ page: current, size: 200, status: 'ACTIVE' }); records.push(...response.records); pages = response.pages; current += 1 } while (current <= pages)
customers.value = records
} catch { /* 客户筛选失败不阻断统计,仍可查询全部客户。 */ }
}
function search() { page.value = 1; void load() }
function reset() { month.value = defaultMonth; customerId.value = ''; page.value = 1; void load() }
function printCurrent() { if (canPrint.value) window.print() }
function statisticsRowKey(row: ReceivableStatisticsRow) { return `${row.customerId}:${row.customerName}` }
function displayCustomerName(value: unknown) {
const row = value as ReceivableStatisticsRow
const masterName = customerNameById.value.get(String(row.customerId))?.trim()
const snapshot = row.customerName?.trim()
if (!snapshot || /^客户\s*\d+$/.test(snapshot) || snapshot === String(row.customerId)) return masterName || '—'
return snapshot
}
async function exportCurrent() {
exporting.value = true
try {
const task = await createPlatformExportTask({ exportType: 'FINANCE_RECEIVABLE_STATISTICS', format: 'XLSX', taskName: `应收款统计表-${month.value}`,
parameters: { month: month.value, ...(customerId.value ? { customerId: customerId.value } : {}) } })
const open = await showExportTaskCreatedDialog({ taskName: task.taskName, taskNo: task.taskNo, dense: true })
if (open) await router.push({ name: 'download-center' })
} catch (reason) { ElMessage.error(getApiErrorMessage(reason, '应收款统计导出任务创建失败')) }
finally { exporting.value = false }
}
function money(value?: string) { return Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) }
onMounted(() => { void loadCustomers(); void load() })
</script>
<template>
<AdminPage class="receivable-statistics-page admin-density admin-workbench" spacing="compact">
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
<template v-else>
<section class="finance-metric-grid" aria-label="CNY应收汇总" v-if="!error" v-loading="loading" :aria-busy="loading">
<article class="workbench-metric workbench-tone is-compact" data-tone="blue">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Wallet aria-hidden="true" /></span><h2 class="workbench-metric-label">应收总额</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceivable) : '—' }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="green">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><CircleCheck aria-hidden="true" /></span><h2 class="workbench-metric-label">已收合计</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalReceived) : '—' }}</strong><span>CNY</span></div>
</article>
<article class="workbench-metric workbench-tone is-compact" data-tone="purple">
<div class="workbench-metric-heading"><span class="workbench-icon is-compact"><Clock3 aria-hidden="true" /></span><h2 class="workbench-metric-label">余额未收</h2></div>
<div class="workbench-metric-value"><strong>{{ result ? money(result.totalOutstanding) : '—' }}</strong><span>CNY</span></div>
</article>
</section>
<div class="workbench-table is-page-surface"><DataTable class="statistics-table" appearance="integrated" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="result?.totalCustomers ?? 0" :row-key="statisticsRowKey" preference-key="finance-receivable-statistics-table" aria-label="应收款统计列表" empty-text="当前月份暂无应收数据" @update:page="load()" @update:page-size="page = 1; load()">
<template #toolbar-start><h1>应收款统计表</h1></template>
<template #filters><section class="query-toolbar statistics-query" aria-label="应收款统计筛选">
<div class="query-field"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
<div class="query-field"><el-select v-model="customerId" clearable filterable placeholder="全部应收单位" aria-label="应收单位"><el-option v-for="customer in customers" :key="String(customer.id)" :label="customer.tenantName" :value="String(customer.id)" /></el-select></div>
<div class="query-actions"><el-button type="primary" :loading="loading" @click="search"><Search :size="16" />查询</el-button><el-button @click="reset"><RotateCcw :size="16" />重置</el-button></div>
</section></template>
<template #notices><LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<template #toolbar-actions><div class="report-actions"><el-button :disabled="!canPrint" @click="printCurrent"><Printer :size="16" />打印</el-button><el-button v-if="canExport" type="primary" :loading="exporting" :disabled="loading || exporting" @click="exportCurrent"><Download :size="16" />导出Excel</el-button></div></template>
<template #customerName="{ row }">{{ displayCustomerName(row) }}</template><template #totalReceivable="{ row }">{{ money(row.totalReceivable) }}</template><template #totalReceived="{ row }">{{ money(row.totalReceived) }}</template><template #totalOutstanding="{ row }"><span class="outstanding-cell">{{ money(row.totalOutstanding) }}</span></template>
<template #pagination-left>统计月份:{{ result?.statisticsMonth }} · 币种:CNY</template>
</DataTable></div>
</template>
</AdminPage>
<Teleport to="body">
<section v-if="canPrint && result" class="receivable-statistics-print" aria-label="应收款统计打印报表">
<header>
<h1>应收款统计表</h1>
<p>统计月份:{{ result.statisticsMonth }} · 币种:CNY</p>
</header>
<table>
<colgroup><col class="print-customer-column" /><col class="print-currency-column" /><col /><col /><col /></colgroup>
<thead><tr><th scope="col">应收单位</th><th scope="col">币种</th><th scope="col">应收总额(CNY)</th><th scope="col">已收合计(CNY)</th><th scope="col">余额未收(CNY)</th></tr></thead>
<tbody>
<tr v-for="row in rows" :key="statisticsRowKey(row)"><td>{{ displayCustomerName(row) }}</td><td>{{ row.currency }}</td><td>{{ money(row.totalReceivable) }}</td><td>{{ money(row.totalReceived) }}</td><td>{{ money(row.totalOutstanding) }}</td></tr>
<tr v-if="!rows.length"><td colspan="5">当前月份暂无应收数据</td></tr>
</tbody>
<tfoot><tr><th scope="row" colspan="2">统计汇总</th><td>{{ money(result.totalReceivable) }}</td><td>{{ money(result.totalReceived) }}</td><td>{{ money(result.totalOutstanding) }}</td></tr></tfoot>
</table>
<p>数据第 {{ result.page }} 页 · 共 {{ result.totalCustomers }} 家应收单位</p>
</section>
</Teleport>
</template>
<style scoped>
.statistics-query { display: flex; align-items: end; gap: var(--admin-space-5); padding: var(--admin-space-6); }
.query-field { min-width: 220px; }
.query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; }
.query-actions, .report-actions { display: flex; gap: var(--admin-space-3); }
.report-actions { margin-left: auto; }
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
.outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
.statistics-table { overflow: hidden; }
.receivable-statistics-print { display: none; }
@media print {
:global(body:has(> .receivable-statistics-print) > :not(.receivable-statistics-print)) { display: none !important; }
:global(body:has(> .receivable-statistics-print)) { min-width: 0; height: auto; margin: 0; overflow: visible; background: #fff; }
.receivable-statistics-print { display: block; width: 100%; color: #000; font: 10pt/1.5 Arial, 'Microsoft YaHei', sans-serif; }
.receivable-statistics-print h1 { margin: 0 0 8pt; text-align: center; font-size: 18pt; }
.receivable-statistics-print p { margin: 8pt 0; }
.receivable-statistics-print table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.receivable-statistics-print col { width: 18%; }
.receivable-statistics-print .print-customer-column { width: 38%; }
.receivable-statistics-print .print-currency-column { width: 8%; }
.receivable-statistics-print th, .receivable-statistics-print td { padding: 5pt; border: 1px solid #999; text-align: left; overflow-wrap: anywhere; }
.receivable-statistics-print tbody td:nth-child(n+3), .receivable-statistics-print thead th:nth-child(n+3), .receivable-statistics-print tfoot td { text-align: right; font-variant-numeric: tabular-nums; }
.receivable-statistics-print thead { display: table-header-group; }
.receivable-statistics-print tfoot { display: table-row-group; font-weight: 700; }
.receivable-statistics-print tr { break-inside: avoid; }
}
</style>