- 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次, 团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍 (有调整单时 + adjust_net_total),分列舍入差单列 - 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入, 团队与未分配不重复;按经理筛选只汇总展示团队 - 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称 - 月份校验抽 core/month,报表/月结/调整单非法月份统一 400; 岗位只读选项供财务/领导筛选 - 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、 summary 请求序号保护 Co-Authored-By: Claude Code <noreply@anthropic.com>
332 lines
11 KiB
Vue
332 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, reactive, ref, watch } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
|
||
import * as calcApi from '@/api/calc'
|
||
import type { LedgerRow } from '@/api/calc'
|
||
import { CUSTOMER_TYPE_OPTIONS, customerTypeLabel } from '@/api/customers'
|
||
import { listPersonnel } from '@/api/personnel'
|
||
import * as reportApi from '@/api/reports'
|
||
import type { PersonalDetailRow, ReportFilterParams, ReportSummary } from '@/api/reports'
|
||
import { listBusinessRoleOptions } from '@/api/businessRoles'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||
import { usePagedList } from '@/composables/usePagedList'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
/**
|
||
* 个人绩效工资明细表(FR-029):按人员/月份/客户类型/产品类别/岗位查明细。
|
||
*
|
||
* 口径与台账一致(后端同源查询);页脚合计是**全筛选结果**之和,不是
|
||
* 当前页之和(页面已注明)。行展开看计算快照(按台账 id 懒加载)。
|
||
*/
|
||
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
|
||
const filters = reactive({
|
||
settlement_month: '',
|
||
personnel_id: undefined as number | undefined,
|
||
customer_type: '',
|
||
product_category: '',
|
||
role_code: '',
|
||
})
|
||
|
||
/**
|
||
* 部门汇总「查看明细」下钻:路由 query 必须真正落到筛选条件上。
|
||
*
|
||
* 无 query 时也要清空(不能只覆盖):从菜单再次进入同一页面时组件复用,
|
||
* 旧的 personnel_id/settlement_month 会残留,用户看到的仍是被下钻者的数据。
|
||
*/
|
||
function applyRouteQuery(): void {
|
||
const personnelId = Number(route.query.personnel_id)
|
||
filters.personnel_id =
|
||
Number.isInteger(personnelId) && personnelId > 0 ? personnelId : undefined
|
||
const month = route.query.settlement_month
|
||
filters.settlement_month =
|
||
typeof month === 'string' && /^\d{4}-\d{2}$/.test(month) ? month : ''
|
||
}
|
||
applyRouteQuery()
|
||
|
||
const { options: personnelOptions, load: loadPersonnel } = useDropdownOptions(() =>
|
||
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
|
||
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
|
||
),
|
||
)
|
||
const { options: roleOptions, load: loadRoles } = useDropdownOptions(() =>
|
||
listBusinessRoleOptions().then((data) =>
|
||
data.map((item) => ({ label: item.role_name, value: item.role_code })),
|
||
),
|
||
)
|
||
void loadPersonnel()
|
||
void loadRoles()
|
||
|
||
const summary = ref<ReportSummary | null>(null)
|
||
|
||
function getFilters(): ReportFilterParams {
|
||
return {
|
||
// axios 自动剔除 undefined;空串需显式转换,否则后端收到空字符串会精确匹配空值
|
||
settlement_month: filters.settlement_month || undefined,
|
||
personnel_id: filters.personnel_id,
|
||
customer_type: filters.customer_type || undefined,
|
||
product_category: filters.product_category || undefined,
|
||
role_code: filters.role_code || undefined,
|
||
}
|
||
}
|
||
|
||
function fetchPage(params: ReportFilterParams) {
|
||
return reportApi.listPersonalDetail(params)
|
||
}
|
||
|
||
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||
usePagedList(fetchPage, getFilters, (data) => {
|
||
summary.value = data.summary
|
||
})
|
||
|
||
// 进页即加载(筛选条件可能已由部门汇总下钻带入)
|
||
onMounted(search)
|
||
|
||
// 同一路由再次下钻(例如从部门汇总换成员)时重新应用 query
|
||
watch(
|
||
() => route.query,
|
||
() => {
|
||
applyRouteQuery()
|
||
search()
|
||
},
|
||
)
|
||
|
||
function resetFilters(): void {
|
||
filters.settlement_month = ''
|
||
filters.personnel_id = undefined
|
||
filters.customer_type = ''
|
||
filters.product_category = ''
|
||
filters.role_code = ''
|
||
search()
|
||
}
|
||
|
||
/** 快照懒加载缓存:同一台账行只取一次 */
|
||
const snapshots = ref<Record<number, LedgerRow>>({})
|
||
|
||
async function loadSnapshot(row: PersonalDetailRow): Promise<void> {
|
||
if (snapshots.value[row.ledger_id]) return
|
||
try {
|
||
const ledger = await calcApi.getLedger(row.ledger_id)
|
||
snapshots.value = { ...snapshots.value, [row.ledger_id]: ledger }
|
||
} catch (error) {
|
||
handledError(error)
|
||
}
|
||
}
|
||
|
||
const exporting = ref(false)
|
||
|
||
async function doExport(): Promise<void> {
|
||
if (total.value === 0) {
|
||
ElMessage.warning('当前筛选无数据,无法导出')
|
||
return
|
||
}
|
||
exporting.value = true
|
||
try {
|
||
const blob = await reportApi.exportReport('personal-detail', 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 route = router.resolve({
|
||
name: 'report-print',
|
||
params: { code: 'personal-detail' },
|
||
query: cleanQuery(getFilters()),
|
||
})
|
||
window.open(route.href, '_blank')
|
||
}
|
||
|
||
function cleanQuery(params: ReportFilterParams): Record<string, string> {
|
||
const query: Record<string, string> = {}
|
||
for (const [key, value] of Object.entries(params)) {
|
||
if (value !== undefined && value !== null && value !== '') query[key] = String(value)
|
||
}
|
||
return query
|
||
}
|
||
|
||
function today(): string {
|
||
const now = new Date()
|
||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||
const day = String(now.getDate()).padStart(2, '0')
|
||
return `${now.getFullYear()}${month}${day}`
|
||
}
|
||
</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-select
|
||
v-model="filters.personnel_id"
|
||
placeholder="全部人员"
|
||
clearable
|
||
filterable
|
||
style="width: 150px"
|
||
>
|
||
<el-option
|
||
v-for="option in personnelOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</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 label="岗位">
|
||
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 130px">
|
||
<el-option
|
||
v-for="option in roleOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="search">查询</el-button>
|
||
<el-button @click="resetFilters">重置</el-button>
|
||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||
<el-button @click="doPrint">打印</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<el-table v-loading="loading" :data="records" stripe @expand-change="loadSnapshot">
|
||
<el-table-column type="expand">
|
||
<template #default="{ row }">
|
||
<pre class="snapshot">{{
|
||
JSON.stringify(snapshots[row.ledger_id]?.snapshot_json ?? '加载中…', null, 2)
|
||
}}</pre>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="business_no" label="合同编号" min-width="160" />
|
||
<el-table-column prop="ship_date" label="发货日" width="105" />
|
||
<el-table-column prop="receipt_date" label="回款日" width="105" />
|
||
<el-table-column prop="payment_days" label="回款天数" width="90" align="right" />
|
||
<el-table-column label="产品类别" width="100">
|
||
<template #default="{ row }">{{ reportApi.reportCategoryLabel(row.product_category) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="客户类型" width="100">
|
||
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="role_name" label="岗位" width="100" />
|
||
<el-table-column prop="rate_text" label="计提比例" width="110" />
|
||
<el-table-column prop="weight_r" label="支付权重R" width="95" align="right" />
|
||
<el-table-column prop="performance_amount" label="绩效金额" width="110" align="right">
|
||
<template #default="{ row }">
|
||
<span :class="{ negative: Number(row.performance_amount) < 0 }">
|
||
{{ row.performance_amount ?? '—' }}
|
||
</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="payable_amount" label="应付绩效" width="110" align="right" />
|
||
<el-table-column label="状态" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
|
||
试用期
|
||
</el-tag>
|
||
<el-tag v-if="row.missing_cost === 1" size="small" type="info" disable-transitions>
|
||
缺成本
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<div v-if="summary" class="summary-bar">
|
||
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计 {{ summary.performance_total ?? '0.00' }}
|
||
| 应付合计 {{ summary.payable_total ?? '0.00' }}
|
||
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
|
||
</div>
|
||
|
||
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum),
|
||
不能用 v-model 或 @change——翻了页不会加载 -->
|
||
<AppPagination
|
||
:total="total"
|
||
:page-num="pageNum"
|
||
:page-size="pageSize"
|
||
@page-change="onPageChange"
|
||
@size-change="onSizeChange"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.filters {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.snapshot {
|
||
margin: 0;
|
||
padding: 10px;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
background: #f4f5f8;
|
||
border-radius: 6px;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.negative {
|
||
color: #c45656;
|
||
}
|
||
|
||
.summary-bar {
|
||
margin-top: 10px;
|
||
padding: 8px 12px;
|
||
font-size: 13px;
|
||
background: var(--ys-indigo-wash);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.summary-note {
|
||
color: var(--ys-ink-3);
|
||
font-size: 12px;
|
||
}
|
||
</style>
|