Files
PayrollManagementSystem/frontend/src/views/reports/department-summary/index.vue
T
liuwenxinandClaude Code 04a1a0d307 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>
2026-10-19 16:33:37 +08:00

297 lines
9.6 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 { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { listPersonnel } from '@/api/personnel'
import * as reportApi from '@/api/reports'
import type { DepartmentSummary } from '@/api/reports'
import { useDropdownOptions } from '@/composables/useDropdownOptions'
import { handledError } from '@/utils/error'
/**
* 部门绩效工资汇总表(FR-030):按经理团队/月份汇总。
*
* 部门口径 = personnel.manager_id 实时上溯的经理团队(无独立部门表);
* 团队基数与月结聚合行同源(manager_share 共享模块),因此可与月结
* 冻结额(frozen_amount)对账。全公司 5% 部分单列——各团队行展示的
* 5% 金额是同一基数的名义值,不能相加。
*/
const router = useRouter()
const filters = reactive({
settlement_month: currentMonth(),
manager_id: undefined as number | undefined,
})
const { options: managerOptions, load: loadManagers } = useDropdownOptions(() =>
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
),
)
void loadManagers()
const data = ref<DepartmentSummary | null>(null)
const loading = ref(false)
const exporting = ref(false)
function currentMonth(): string {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
}
async function load(): Promise<void> {
if (!filters.settlement_month) {
ElMessage.warning('请选择统计月份(部门汇总按单月聚合)')
return
}
loading.value = true
try {
data.value = await reportApi.getDepartmentSummary({
settlement_month: filters.settlement_month,
manager_id: filters.manager_id,
})
} catch (error) {
handledError(error)
} finally {
loading.value = false
}
}
void load()
async function doExport(): Promise<void> {
if (!data.value) {
ElMessage.warning('请先查询')
return
}
exporting.value = true
try {
const blob = await reportApi.exportReport('department-summary', {
settlement_month: filters.settlement_month,
manager_id: filters.manager_id,
})
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> = { settlement_month: filters.settlement_month }
if (filters.manager_id) query.manager_id = String(filters.manager_id)
const route = router.resolve({
name: 'report-print',
params: { code: 'department-summary' },
query,
})
window.open(route.href, '_blank')
}
function openPersonalDetail(member: { personnel_id: number }): void {
void router.push({
name: 'reports-personal-detail',
query: {
personnel_id: String(member.personnel_id),
settlement_month: filters.settlement_month,
},
})
}
function today(): string {
const now = new Date()
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
now.getDate(),
).padStart(2, '0')}`
}
function pct(value: string): string {
return `${(Number(value) * 100).toFixed(2)}%`
}
</script>
<template>
<div class="page">
<el-form inline class="filters" @submit.prevent>
<el-form-item label="统计月份" required>
<el-date-picker
v-model="filters.settlement_month"
type="month"
value-format="YYYY-MM"
placeholder="选择月份"
:clearable="false"
style="width: 140px"
@change="load"
/>
</el-form-item>
<el-form-item label="经理">
<el-select
v-model="filters.manager_id"
placeholder="全部经理"
clearable
filterable
style="width: 160px"
@change="load"
>
<el-option
v-for="option in managerOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</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 v-if="data?.params" class="params-hint">
分成参数:团队 {{ data.params.team_text }}% | 全公司 {{ data.params.company_text }}% |
经理个人户{{ data.params.manager_personal_included === 1 ? '计入' : '不计入' }}基数 |
取值时点 {{ data.params.params_as_of }}
</div>
<div v-loading="loading" class="company-card">
<span class="company-title">全公司 5% 部分(单列)</span>
<span>全公司基数:{{ data?.company?.company_base ?? '—' }}</span>
<span>5% 分成:{{ data?.company?.company_share_amount ?? '—' }}</span>
<span class="company-note">
各团队行的 5% 金额基于同一全公司基数,不可跨团队相加
</span>
</div>
<el-table v-loading="loading" :data="data?.teams ?? []" row-key="manager_id" stripe>
<el-table-column type="expand">
<template #default="{ row }">
<div class="member-wrap">
<el-table :data="row.members" size="small" stripe>
<el-table-column prop="personnel_name" label="成员" width="120" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right" />
<el-table-column prop="payable_sum" label="应付绩效" width="120" align="right" />
<el-table-column label="调整补发" width="110" align="right">
<template #default="{ row: member }">{{ member.adjust_extra }}</template>
</el-table-column>
<el-table-column label="调整扣减" width="110" align="right">
<template #default="{ row: member }">{{ member.adjust_deduct }}</template>
</el-table-column>
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row: member }">
<el-button link type="primary" @click="openPersonalDetail(member)">
查看明细
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="row.members.length === 0" description="该经理本月无团队成员业绩" />
</div>
</template>
</el-table-column>
<el-table-column prop="manager_name" label="经理团队" width="130">
<template #default="{ row }">
{{ row.manager_name }}
<el-tag v-if="row.manager_status === 0" size="small" type="info" disable-transitions>
离职
</el-tag>
</template>
</el-table-column>
<el-table-column prop="member_count" label="团队人数" width="90" align="right" />
<el-table-column prop="team_base" label="团队基数" width="120" align="right" />
<el-table-column prop="team_share_amount" label="12.5% 分成" width="120" align="right" />
<el-table-column prop="company_share_amount" label="5% 分成" width="110" align="right" />
<el-table-column label="占全公司比" width="110" align="right">
<template #default="{ row }">{{ pct(row.company_base_ratio) }}</template>
</el-table-column>
<el-table-column prop="adjust_net" label="调整净额" width="110" align="right" />
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
<el-table-column label="月结冻结额" width="130" align="right">
<template #default="{ row }">
<span v-if="row.frozen_amount !== null" class="frozen">{{ row.frozen_amount }}</span>
<span v-else class="frozen-none">未生成月结</span>
</template>
</el-table-column>
</el-table>
<div v-if="data && data.unassigned.length" class="unassigned">
<h4>未纳入团队基数(无经理 / 非营销经理上级的下属)</h4>
<el-table :data="data.unassigned" size="small" stripe>
<el-table-column prop="personnel_name" label="人员" width="130" />
<el-table-column prop="role_code" label="岗位" width="120" />
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right" />
<el-table-column prop="net_total" label="净合计" width="130" align="right" />
</el-table>
</div>
</div>
</template>
<style scoped>
.filters {
margin-bottom: 4px;
}
.params-hint {
margin-bottom: 10px;
padding: 8px 12px;
font-size: 12px;
color: var(--ys-ink-2);
background: var(--ys-mist);
border-radius: 6px;
}
.company-card {
display: flex;
align-items: center;
gap: 24px;
margin-bottom: 12px;
padding: 12px 16px;
background: var(--ys-indigo-wash);
border-radius: 8px;
font-size: 13px;
}
.company-title {
font-weight: 600;
}
.company-note {
color: var(--ys-ink-3);
font-size: 12px;
}
.member-wrap {
padding: 8px 16px;
}
.frozen {
color: var(--ys-ink-2);
}
.frozen-none {
color: var(--ys-ink-3);
font-size: 12px;
}
.unassigned {
margin-top: 20px;
}
.unassigned h4 {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
}
</style>