- 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次, 团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍 (有调整单时 + adjust_net_total),分列舍入差单列 - 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入, 团队与未分配不重复;按经理筛选只汇总展示团队 - 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称 - 月份校验抽 core/month,报表/月结/调整单非法月份统一 400; 岗位只读选项供财务/领导筛选 - 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、 summary 请求序号保护 Co-Authored-By: Claude Code <noreply@anthropic.com>
338 lines
11 KiB
Vue
338 lines
11 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],
|
||
}
|
||
}
|
||
|
||
interface DrillState {
|
||
rows: PaymentDetailRow[]
|
||
total: number
|
||
pageNum: number
|
||
pageSize: number
|
||
loading: boolean
|
||
seq: number
|
||
}
|
||
|
||
/** 每个桶独立的下钻状态:同时展开多个区间时互不覆盖 */
|
||
const drills = reactive<Record<string, DrillState>>({})
|
||
|
||
function drillOf(code: string): DrillState {
|
||
drills[code] ??= { rows: [], total: 0, pageNum: 1, pageSize: 10, loading: false, seq: 0 }
|
||
return drills[code]
|
||
}
|
||
|
||
let loadSeq = 0
|
||
|
||
async function load(): Promise<void> {
|
||
const seq = ++loadSeq
|
||
loading.value = true
|
||
try {
|
||
const result = await reportApi.getPaymentStats(getFilters())
|
||
if (seq !== loadSeq) return
|
||
data.value = result
|
||
// 筛选条件变化后旧桶明细不再对应当前口径;同时预建各桶状态,
|
||
// 避免模板渲染时才创建响应式属性
|
||
for (const code of Object.keys(drills)) delete drills[code]
|
||
for (const bucket of result.buckets) drillOf(bucket.code)
|
||
} catch (error) {
|
||
if (seq === loadSeq) handledError(error)
|
||
} finally {
|
||
if (seq === loadSeq) loading.value = false
|
||
}
|
||
}
|
||
|
||
function loadDrill(bucket: BucketRow, expandedRows: BucketRow[]): void {
|
||
// Element Plus 2.x 的第二参是当前展开行数组,不是 boolean;
|
||
// 收起时该桶不在数组里,自然不再请求
|
||
if (!expandedRows.some((row) => row.code === bucket.code)) return
|
||
drillOf(bucket.code).pageNum = 1
|
||
void fetchDrill(bucket.code)
|
||
}
|
||
|
||
async function fetchDrill(code: string): Promise<void> {
|
||
const state = drillOf(code)
|
||
const seq = ++state.seq
|
||
state.loading = true
|
||
try {
|
||
const page = await reportApi.listPaymentDetail({
|
||
...getFilters(),
|
||
bucket: code,
|
||
pageNum: state.pageNum,
|
||
pageSize: state.pageSize,
|
||
})
|
||
if (seq !== state.seq) return
|
||
state.rows = page.list
|
||
state.total = page.total
|
||
} catch (error) {
|
||
if (seq === state.seq) handledError(error)
|
||
} finally {
|
||
if (seq === state.seq) state.loading = false
|
||
}
|
||
}
|
||
|
||
function onDrillPage(code: string, page: number): void {
|
||
drillOf(code).pageNum = page
|
||
void fetchDrill(code)
|
||
}
|
||
|
||
function onDrillSize(code: string, size: number): void {
|
||
const state = drillOf(code)
|
||
state.pageSize = size
|
||
state.pageNum = 1 // 改每页条数回到第 1 页,否则可能停在空页
|
||
void fetchDrill(code)
|
||
}
|
||
|
||
void load()
|
||
|
||
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"
|
||
class="bucket-table"
|
||
data-testid="payment-bucket-table"
|
||
:data="data?.buckets ?? []"
|
||
row-key="code"
|
||
stripe
|
||
@expand-change="loadDrill"
|
||
>
|
||
<el-table-column type="expand">
|
||
<template #default="{ row }">
|
||
<div class="drill-wrap">
|
||
<el-table
|
||
v-loading="drillOf(row.code).loading"
|
||
:data="drillOf(row.code).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="drillOf(row.code).total"
|
||
:page-num="drillOf(row.code).pageNum"
|
||
:page-size="drillOf(row.code).pageSize"
|
||
@page-change="onDrillPage(row.code, $event)"
|
||
@size-change="onDrillSize(row.code, $event)"
|
||
/>
|
||
</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>
|