141 lines
9.5 KiB
Vue
141 lines
9.5 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { Download, Printer, RotateCcw, Search } from '@lucide/vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { 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 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 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, slotName: 'totalReceivable' },
|
||
{ key: 'totalReceived', label: '已收合计(CNY)', width: 180, slotName: 'totalReceived' },
|
||
{ key: 'totalOutstanding', label: '余额未收(CNY)', width: 180, 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() { 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 })
|
||
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" spacing="normal">
|
||
<el-result v-if="!canView" icon="warning" title="无权访问应收款统计表" sub-title="请联系管理员开通应收款统计查看权限" />
|
||
<template v-else>
|
||
<section class="surface-panel statistics-query" aria-label="应收款统计筛选">
|
||
<div class="query-field"><span>统计月份</span><el-date-picker v-model="month" type="month" value-format="YYYY-MM" format="YYYY年MM月" :clearable="false" aria-label="统计月份" /></div>
|
||
<div class="query-field"><span>应收单位</span><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>
|
||
<div class="report-actions"><el-button :disabled="loading || !result" @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>
|
||
</section>
|
||
<LoadErrorState v-if="error" title="应收款统计加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||
<template v-else>
|
||
<section class="statistics-summary" aria-label="CNY应收汇总">
|
||
<div><span>应收总额</span><strong>CNY {{ money(result?.totalReceivable) }}</strong></div>
|
||
<div><span>已收合计</span><strong class="is-received">CNY {{ money(result?.totalReceived) }}</strong></div>
|
||
<div><span>余额未收</span><strong class="is-outstanding">CNY {{ money(result?.totalOutstanding) }}</strong></div>
|
||
</section>
|
||
<section class="surface-panel statistics-table">
|
||
<header><h2>应收款明细</h2><span>共 {{ result?.totalCustomers ?? 0 }} 个应收单位 · 仅统计CNY正式应收结算单</span></header>
|
||
<DataTable 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 #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>
|
||
</section>
|
||
</template>
|
||
</template>
|
||
</AdminPage>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.statistics-query { display: flex; align-items: end; gap: 12px; padding: 16px; }
|
||
.query-field { display: grid; min-width: 220px; gap: 6px; color: var(--cc-muted); font-size: 13px; }
|
||
.query-field :deep(.el-select), .query-field :deep(.el-date-editor) { width: 100%; }
|
||
.query-actions, .report-actions { display: flex; gap: 8px; }
|
||
.report-actions { margin-left: auto; }
|
||
.query-actions :deep(.el-button), .report-actions :deep(.el-button) { margin-left: 0; }
|
||
.statistics-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
|
||
.statistics-summary > div { padding: 16px; border: 1px solid var(--cc-border, #e3e8ef); border-radius: 8px; background: var(--cc-surface, #fff); }
|
||
.statistics-summary span { display: block; margin-bottom: 8px; color: var(--cc-muted); font-size: 13px; }
|
||
.statistics-summary strong { font-size: 22px; font-variant-numeric: tabular-nums; }
|
||
.statistics-summary .is-received { color: var(--el-color-success); }
|
||
.statistics-summary .is-outstanding, .outstanding-cell { color: var(--el-color-danger); font-variant-numeric: tabular-nums; }
|
||
.statistics-table { overflow: hidden; }
|
||
.statistics-table > header { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--cc-border, #e3e8ef); }
|
||
.statistics-table h2 { margin: 0; font-size: 16px; }
|
||
.statistics-table header span { color: var(--cc-muted); font-size: 13px; }
|
||
@media print {
|
||
.statistics-query, :deep(.data-table__pagination) { display: none !important; }
|
||
.receivable-statistics-page { gap: 10px; color: #000; }
|
||
.statistics-summary > div, .statistics-table { border-color: #999; box-shadow: none; }
|
||
}
|
||
</style>
|