feat: 年度价值报告增加员工维度规避损失明细
- 后端按员工汇总已解决风险明细,含风险标题、法律依据、预估损失 - 前端新增员工规避损失明细表格,展示每员工的原始损失、规避率(30%)、打折后损失 - 表格含合计行,与总价值计算逻辑一致 - 修复RISK_AVOIDANCE_RATE重复声明
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user