feat: 年度价值报告增加员工维度规避损失明细

- 后端按员工汇总已解决风险明细,含风险标题、法律依据、预估损失
- 前端新增员工规避损失明细表格,展示每员工的原始损失、规避率(30%)、打折后损失
- 表格含合计行,与总价值计算逻辑一致
- 修复RISK_AVOIDANCE_RATE重复声明
This commit is contained in:
selfrelease
2026-07-30 19:30:23 +08:00
parent aced7929e1
commit a77a37a32e
2 changed files with 126 additions and 1 deletions
+70 -1
View File
@@ -1549,6 +1549,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
auditActions,
policiesPublished,
monthlyRisks,
employeeRiskDetails,
] = await Promise.all([
prisma.riskItem.count({
where: {
@@ -1611,10 +1612,77 @@ export async function getAnnualValueReport(orgId: string, year: number) {
}).then((count) => ({ month: m + 1, count })),
),
),
// 按员工维度的已解决风险明细
prisma.riskItem.findMany({
where: {
orgId, status: 'RESOLVED',
resolvedAt: { gte: yearStart, lte: yearEnd },
},
include: {
employee: {
select: { id: true, name: true, department: true, monthlySalary: true, hireDate: true },
},
},
orderBy: { estimatedLoss: 'desc' },
}),
])
const lossAvoided = lossAvoidedAgg._sum.estimatedLoss || 0
// 按员工维度汇总规避损失
const RISK_AVOIDANCE_RATE = 0.3
const employeeBreakdown: Record<string, {
employeeId: string
name: string
department: string
riskCount: number
lossAvoided: number
adjustedLoss: number
details: { title: string; estimatedLoss: number; legalBasis: string }[]
}> = {}
for (const r of employeeRiskDetails) {
const empId = r.employeeId || '_unknown'
if (!employeeBreakdown[empId]) {
employeeBreakdown[empId] = {
employeeId: empId,
name: r.employee?.name || '未知员工',
department: r.employee?.department || '-',
riskCount: 0,
lossAvoided: 0,
adjustedLoss: 0,
details: [],
}
}
const emp = employeeBreakdown[empId]
emp.riskCount++
emp.lossAvoided += r.estimatedLoss || 0
// 根据风险标题推断法律依据
let legalBasis = '—'
const title = r.title || ''
if (title.includes('未签合同')) legalBasis = '第82条 双倍工资≤11个月'
else if (title.includes('到期') && title.includes('未续签')) legalBasis = '第47条 经济补偿N≤12个月'
else if (title.includes('孕期') || title.includes('哺乳期') || title.includes('工伤') || title.includes('医疗期')) legalBasis = '第87条 违法解除2N≤24个月'
else if (title.includes('试用期')) legalBasis = '第20条 试用期工资差额'
else if (title.includes('社保') || title.includes('公积金')) legalBasis = '滞纳金 每日0.05%'
else if (title.includes('工资') || title.includes('个税')) legalBasis = '拖欠工资/个税'
emp.details.push({
title: r.title,
estimatedLoss: r.estimatedLoss || 0,
legalBasis,
})
}
// 计算每个员工的打折后规避损失
const employeeDetails = Object.values(employeeBreakdown)
.map((e) => ({
...e,
adjustedLoss: Math.round(e.lossAvoided * RISK_AVOIDANCE_RATE),
}))
.sort((a, b) => b.adjustedLoss - a.adjustedLoss)
// 月度事件时间轴
const timeline = monthlyRisks.map((mr) => ({
month: mr.month,
@@ -1626,7 +1694,6 @@ export async function getAnnualValueReport(orgId: string, year: number) {
// 1. 规避损失 = 已解决风险的预估损失之和 × 0.3(规避率30%,非每项风险都会演变为实际仲裁)
// 2. 节约工时 = AI 查询 * 0.5h + 合同审查 * 1h + 算薪批次 * 2h + 制度公示 * 1h
// 3. 节约成本 = 节约工时 * 100 元/h(HR 平均时薪)
const RISK_AVOIDANCE_RATE = 0.3 // 规避率:约30%的未处理风险会演变为实际劳动争议
const adjustedLossAvoided = Math.round(lossAvoided * RISK_AVOIDANCE_RATE)
const aiTimeSaved = aiConversations * 0.5 + aiReviews * 1.0
const payrollTimeSaved = payrollBatches * 2.0
@@ -1671,6 +1738,8 @@ export async function getAnnualValueReport(orgId: string, year: number) {
costSaved,
timeSaved,
lossAvoided,
adjustedLossAvoided,
employeeDetails,
summary,
}
}
@@ -220,6 +220,62 @@ export default function AnnualValueReport() {
</div>
</Card>
{/* 员工规避损失明细 */}
{report.employeeDetails && report.employeeDetails.length > 0 && (
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-200 text-gray-500">
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-center py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium">(×30%)</th>
<th className="text-left py-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody>
{report.employeeDetails.map((emp: any, idx: number) => (
<tr key={emp.employeeId || idx} className="border-b border-gray-50">
<td className="py-2 px-2 font-medium">{emp.name}</td>
<td className="py-2 px-2 text-gray-600">{emp.department}</td>
<td className="py-2 px-2 text-center">{emp.riskCount}</td>
<td className="py-2 px-2 text-right text-gray-600">¥{emp.lossAvoided.toLocaleString()}</td>
<td className="py-2 px-2 text-right text-gray-400">30%</td>
<td className="py-2 px-2 text-right font-bold text-safe">¥{emp.adjustedLoss.toLocaleString()}</td>
<td className="py-2 px-2">
<div className="space-y-0.5">
{emp.details.map((d: any, i: number) => (
<div key={i} className="flex items-center gap-1.5">
<span className="text-gray-700">{d.title}</span>
<span className="text-gray-400">·</span>
<span className="text-gray-500">{d.legalBasis}</span>
<span className="text-gray-400">·</span>
<span className="text-gray-600">¥{d.estimatedLoss.toLocaleString()}</span>
</div>
))}
</div>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-200 font-bold">
<td className="py-2 px-2" colSpan={3}></td>
<td className="py-2 px-2 text-right">¥{(report.lossAvoided || 0).toLocaleString()}</td>
<td className="py-2 px-2"></td>
<td className="py-2 px-2 text-right text-safe">¥{(report.adjustedLossAvoided || 0).toLocaleString()}</td>
<td className="py-2 px-2"></td>
</tr>
</tfoot>
</table>
</div>
</Card>
)}
{/* 价值计算说明 */}
<Card className="bg-gray-50">
<h2 className="text-sm font-medium mb-2"></h2>