feat: 年度价值报告增加员工维度规避损失明细
- 后端按员工汇总已解决风险明细,含风险标题、法律依据、预估损失 - 前端新增员工规避损失明细表格,展示每员工的原始损失、规避率(30%)、打折后损失 - 表格含合计行,与总价值计算逻辑一致 - 修复RISK_AVOIDANCE_RATE重复声明
This commit is contained in:
@@ -1549,6 +1549,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
|||||||
auditActions,
|
auditActions,
|
||||||
policiesPublished,
|
policiesPublished,
|
||||||
monthlyRisks,
|
monthlyRisks,
|
||||||
|
employeeRiskDetails,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
prisma.riskItem.count({
|
prisma.riskItem.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -1611,10 +1612,77 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
|||||||
}).then((count) => ({ month: m + 1, count })),
|
}).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 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) => ({
|
const timeline = monthlyRisks.map((mr) => ({
|
||||||
month: mr.month,
|
month: mr.month,
|
||||||
@@ -1626,7 +1694,6 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
|||||||
// 1. 规避损失 = 已解决风险的预估损失之和 × 0.3(规避率30%,非每项风险都会演变为实际仲裁)
|
// 1. 规避损失 = 已解决风险的预估损失之和 × 0.3(规避率30%,非每项风险都会演变为实际仲裁)
|
||||||
// 2. 节约工时 = AI 查询 * 0.5h + 合同审查 * 1h + 算薪批次 * 2h + 制度公示 * 1h
|
// 2. 节约工时 = AI 查询 * 0.5h + 合同审查 * 1h + 算薪批次 * 2h + 制度公示 * 1h
|
||||||
// 3. 节约成本 = 节约工时 * 100 元/h(HR 平均时薪)
|
// 3. 节约成本 = 节约工时 * 100 元/h(HR 平均时薪)
|
||||||
const RISK_AVOIDANCE_RATE = 0.3 // 规避率:约30%的未处理风险会演变为实际劳动争议
|
|
||||||
const adjustedLossAvoided = Math.round(lossAvoided * RISK_AVOIDANCE_RATE)
|
const adjustedLossAvoided = Math.round(lossAvoided * RISK_AVOIDANCE_RATE)
|
||||||
const aiTimeSaved = aiConversations * 0.5 + aiReviews * 1.0
|
const aiTimeSaved = aiConversations * 0.5 + aiReviews * 1.0
|
||||||
const payrollTimeSaved = payrollBatches * 2.0
|
const payrollTimeSaved = payrollBatches * 2.0
|
||||||
@@ -1671,6 +1738,8 @@ export async function getAnnualValueReport(orgId: string, year: number) {
|
|||||||
costSaved,
|
costSaved,
|
||||||
timeSaved,
|
timeSaved,
|
||||||
lossAvoided,
|
lossAvoided,
|
||||||
|
adjustedLossAvoided,
|
||||||
|
employeeDetails,
|
||||||
summary,
|
summary,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,62 @@ export default function AnnualValueReport() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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">
|
<Card className="bg-gray-50">
|
||||||
<h2 className="text-sm font-medium mb-2">价值计算说明</h2>
|
<h2 className="text-sm font-medium mb-2">价值计算说明</h2>
|
||||||
|
|||||||
Reference in New Issue
Block a user