feat(report): 报表四页与打印视图

- 个人绩效明细/部门绩效汇总/业务统计/回款统计四页,路由组「报表统计」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>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-19 16:33:37 +08:00
1 parent 2a1939d2e7
commit 04a1a0d307
12 files changed
+2763 -1

No files matched your search

@@ -0,0 +1,310 @@
<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>