- 个人绩效明细/部门绩效汇总/业务统计/回款统计四页,路由组「报表统计」order19-22(allowedRoles admin/finance/leader) - 明细分页页脚全筛选合计;部门汇总成员内联与跨页下钻;业务统计展开回款/台账;回款统计 8 档区间(档位由后端下发)与桶下钻 - 打印视图独立顶层路由(不挂 BasicLayout):公司页眉走 public 端点、循环翻页取全量(2000 行截断提示)、@media print A4 横版每页表头、签字栏 - AppPagination 只派发 page-change/size-change,四页按事件绑定并补初始加载(@change/v-model 不生效的同类缺陷) - e2e report_flow.py:四页筛选与导出下载、打印视图 window.print 桩断言 Co-Authored-By: Claude Code <noreply@anthropic.com>
311 lines
9.8 KiB
Vue
311 lines
9.8 KiB
Vue
<script setup lang="ts">
|
||
import { reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useRouter } from 'vue-router'
|
||
|
||
import { CUSTOMER_TYPE_OPTIONS, customerTypeLabel } from '@/api/customers'
|
||
import * as reportApi from '@/api/reports'
|
||
import type { BucketRow, PaymentDetailRow, PaymentStats } from '@/api/reports'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
/**
|
||
* 回款情况统计表(FR-031 之二):按回款天数区间/客户类型/产品类别统计。
|
||
*
|
||
* 区间档位由后端下发(禁止前端硬编码);空桶恒在且显示 0。回款金额侧
|
||
* 按回款单去重(二次分摊拆行不会让金额翻倍)。进度登记回款无台账行,
|
||
* 不在本表口径内(页面已注明)。
|
||
*/
|
||
|
||
const router = useRouter()
|
||
|
||
const filters = reactive({
|
||
settlement_month: '',
|
||
customer_type: '',
|
||
product_category: '',
|
||
})
|
||
const dateRange = ref<[string, string] | null>(null)
|
||
|
||
const data = ref<PaymentStats | null>(null)
|
||
const loading = ref(false)
|
||
const exporting = ref(false)
|
||
|
||
function getFilters() {
|
||
return {
|
||
settlement_month: filters.settlement_month || undefined,
|
||
customer_type: filters.customer_type || undefined,
|
||
product_category: filters.product_category || undefined,
|
||
date_from: dateRange.value?.[0],
|
||
date_to: dateRange.value?.[1],
|
||
}
|
||
}
|
||
|
||
async function load(): Promise<void> {
|
||
loading.value = true
|
||
try {
|
||
data.value = await reportApi.getPaymentStats(getFilters())
|
||
} catch (error) {
|
||
handledError(error)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
void load()
|
||
|
||
/** 桶展开下钻:分页明细按 (桶编码) 缓存当前页 */
|
||
const drill = reactive({
|
||
bucket: '',
|
||
rows: [] as PaymentDetailRow[],
|
||
total: 0,
|
||
pageNum: 1,
|
||
pageSize: 10,
|
||
loading: false,
|
||
})
|
||
|
||
async function loadDrill(bucket: BucketRow, expanded: boolean): Promise<void> {
|
||
if (!expanded) return
|
||
drill.bucket = bucket.code
|
||
drill.pageNum = 1
|
||
await fetchDrill()
|
||
}
|
||
|
||
async function fetchDrill(): Promise<void> {
|
||
if (!drill.bucket) return
|
||
drill.loading = true
|
||
try {
|
||
const page = await reportApi.listPaymentDetail({
|
||
...getFilters(),
|
||
bucket: drill.bucket,
|
||
pageNum: drill.pageNum,
|
||
pageSize: drill.pageSize,
|
||
})
|
||
drill.rows = page.list
|
||
drill.total = page.total
|
||
} catch (error) {
|
||
handledError(error)
|
||
} finally {
|
||
drill.loading = false
|
||
}
|
||
}
|
||
|
||
function onDrillPage(page: number): void {
|
||
drill.pageNum = page
|
||
void fetchDrill()
|
||
}
|
||
|
||
function onDrillSize(size: number): void {
|
||
drill.pageSize = size
|
||
drill.pageNum = 1 // 改每页条数回到第 1 页,否则可能停在空页
|
||
void fetchDrill()
|
||
}
|
||
|
||
async function doExport(): Promise<void> {
|
||
exporting.value = true
|
||
try {
|
||
const blob = await reportApi.exportReport('payment-stats', getFilters())
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `回款情况统计表_${today()}.xlsx`
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
ElMessage.success('已导出')
|
||
} catch (error) {
|
||
handledError(error)
|
||
} finally {
|
||
exporting.value = false
|
||
}
|
||
}
|
||
|
||
function doPrint(): void {
|
||
const query: Record<string, string> = {}
|
||
for (const [key, value] of Object.entries(getFilters())) {
|
||
if (value !== undefined && value !== null && value !== '') query[key] = String(value)
|
||
}
|
||
const route = router.resolve({
|
||
name: 'report-print',
|
||
params: { code: 'payment-stats' },
|
||
query,
|
||
})
|
||
window.open(route.href, '_blank')
|
||
}
|
||
|
||
function today(): string {
|
||
const now = new Date()
|
||
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
|
||
now.getDate(),
|
||
).padStart(2, '0')}`
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page">
|
||
<el-form inline class="filters" @submit.prevent>
|
||
<el-form-item label="月份">
|
||
<el-date-picker
|
||
v-model="filters.settlement_month"
|
||
type="month"
|
||
value-format="YYYY-MM"
|
||
placeholder="全部月份"
|
||
clearable
|
||
style="width: 140px"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="回款日期">
|
||
<el-date-picker
|
||
v-model="dateRange"
|
||
type="daterange"
|
||
value-format="YYYY-MM-DD"
|
||
range-separator="至"
|
||
start-placeholder="开始"
|
||
end-placeholder="结束"
|
||
style="width: 240px"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="客户类型">
|
||
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
|
||
<el-option
|
||
v-for="option in CUSTOMER_TYPE_OPTIONS"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="产品类别">
|
||
<el-select
|
||
v-model="filters.product_category"
|
||
placeholder="全部"
|
||
clearable
|
||
style="width: 130px"
|
||
>
|
||
<el-option label="市政水利" value="municipal_water" />
|
||
<el-option label="轨道交通" value="rail_transit" />
|
||
<el-option label="混合业务" value="mixed" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
|
||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||
<el-button @click="doPrint">打印</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<div class="hint">
|
||
统计口径:已计提回款(有台账行)的金额与绩效;进度登记回款不计入。空区间显示 0。
|
||
</div>
|
||
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="data?.buckets ?? []"
|
||
row-key="code"
|
||
stripe
|
||
@expand-change="loadDrill"
|
||
>
|
||
<el-table-column type="expand">
|
||
<template #default>
|
||
<div class="drill-wrap">
|
||
<el-table v-loading="drill.loading" :data="drill.rows" size="small" stripe>
|
||
<el-table-column prop="business_no" label="合同编号" min-width="150" />
|
||
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
|
||
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
||
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
|
||
<el-table-column prop="personnel_name" label="归属人员" width="110" />
|
||
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
|
||
<el-table-column label="客户类型" width="100">
|
||
<template #default="{ row }">
|
||
{{ customerTypeLabel(row.customer_type) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="产品类别" width="100">
|
||
<template #default="{ row }">
|
||
{{ reportApi.reportCategoryLabel(row.product_category) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
|
||
</el-table>
|
||
<AppPagination
|
||
:total="drill.total"
|
||
:page-num="drill.pageNum"
|
||
:page-size="drill.pageSize"
|
||
@page-change="onDrillPage"
|
||
@size-change="onDrillSize"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="label" label="回款天数区间" min-width="150" />
|
||
<el-table-column prop="receipt_amount" label="回款金额" width="140" align="right" />
|
||
<el-table-column prop="receipt_count" label="回款笔数" width="100" align="right" />
|
||
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
|
||
<el-table-column prop="payable_amount" label="应付绩效" width="130" align="right" />
|
||
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
|
||
</el-table>
|
||
|
||
<div v-if="data" class="summary-bar">
|
||
合计:回款 {{ data.totals.receipt_amount ?? '0.00' }} | 笔数
|
||
{{ data.totals.receipt_count ?? 0 }} | 绩效 {{ data.totals.performance_amount ?? '0.00' }}
|
||
| 应付 {{ data.totals.payable_amount ?? '0.00' }}
|
||
</div>
|
||
|
||
<div v-if="data" class="dimension-grid">
|
||
<div>
|
||
<h4>客户类型对比</h4>
|
||
<el-table :data="data.by_customer_type" size="small" stripe>
|
||
<el-table-column prop="label" label="客户类型" width="120" />
|
||
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
|
||
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
|
||
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
|
||
</el-table>
|
||
</div>
|
||
<div>
|
||
<h4>产品类别对比</h4>
|
||
<el-table :data="data.by_product_category" size="small" stripe>
|
||
<el-table-column prop="label" label="产品类别" width="120" />
|
||
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
|
||
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
|
||
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
|
||
</el-table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.filters {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.hint {
|
||
margin-bottom: 8px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.drill-wrap {
|
||
padding: 8px 16px;
|
||
}
|
||
|
||
.summary-bar {
|
||
margin-top: 10px;
|
||
padding: 8px 12px;
|
||
font-size: 13px;
|
||
background: var(--ys-indigo-wash);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.dimension-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||
gap: 20px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.dimension-grid h4 {
|
||
margin: 0 0 8px;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
</style>
|