fix: 年度价值报告按身份证号+风险类型双重去重

- 按 idCardHash 去重同一人的多条 Employee 记录(无身份证号回退到 employeeId)
- 按风险类别去重同一风险的重复创建解决,只取 estimatedLoss 最大值
- 风险扫描去重改为检查所有状态(含RESOLVED/IGNORED),按 employeeId:type 归并
- 修复规避损失用去重后的值替代原始聚合值
This commit is contained in:
selfrelease
2026-07-31 09:16:23 +08:00
parent a77a37a32e
commit 57587dd5ed
+77 -38
View File
@@ -443,10 +443,13 @@ export async function detectMonthlyTasks(orgId: string) {
}
export async function runRiskDetection(orgId: string) {
// 非月度风险去重:检查所有状态(含 RESOLVED/IGNORED),避免已处理的风险被重新创建
// 按 employeeId:type 归并,不依赖 actionUrlactionUrl 可能因天数变化而不同)
const existingRisks = await prisma.riskItem.findMany({
where: { orgId, status: 'PENDING' },
where: { orgId, status: { in: ['PENDING', 'RESOLVED', 'IGNORED'] } },
select: { employeeId: true, type: true },
})
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}:${r.actionUrl}`))
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}`))
// 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
@@ -1620,69 +1623,105 @@ export async function getAnnualValueReport(orgId: string, year: number) {
},
include: {
employee: {
select: { id: true, name: true, department: true, monthlySalary: true, hireDate: true },
select: { id: true, name: true, department: true, monthlySalary: true, hireDate: true, idCardHash: true },
},
},
orderBy: { estimatedLoss: 'desc' },
}),
])
const lossAvoided = lossAvoidedAgg._sum.estimatedLoss || 0
// 按员工维度汇总规避损失
const RISK_AVOIDANCE_RATE = 0.3
const employeeBreakdown: Record<string, {
employeeId: string
// 按身份证号去重(同一人可能有多条 Employee 记录),无身份证号时回退到 employeeId
// 同时按风险类型去重(同一风险被重复创建解决多次,只取 estimatedLoss 最大的一条)
const personBreakdown: Record<string, {
personKey: string
name: string
department: string
riskCount: number
lossAvoided: number
adjustedLoss: number
details: { title: string; estimatedLoss: number; legalBasis: string }[]
details: Map<string, { title: string; estimatedLoss: number; legalBasis: string }>
}> = {}
// 辅助:根据风险标题推断法律依据
function getLegalBasis(title: string): string {
if (title.includes('未签合同')) return '第82条 双倍工资≤11个月'
if (title.includes('到期') && title.includes('未续签')) return '第47条 经济补偿N≤12个月'
if (title.includes('孕期') || title.includes('哺乳期') || title.includes('工伤') || title.includes('医疗期')) return '第87条 违法解除2N≤24个月'
if (title.includes('试用期')) return '第20条 试用期工资差额'
if (title.includes('社保') || title.includes('公积金')) return '滞纳金 每日0.05%'
if (title.includes('工资') || title.includes('个税')) return '拖欠工资/个税'
return '—'
}
// 辅助:根据风险标题归并到风险类别 key(去掉天数等动态部分)
function getRiskCategoryKey(title: string): string {
if (title.includes('未签合同') && title.includes('无固定期限')) return 'CONTRACT_UNSIGNED_OVER_YEAR'
if (title.includes('未签合同') && title.includes('天')) return 'CONTRACT_UNSIGNED'
if (title.includes('到期') && title.includes('未续签')) return 'CONTRACT_EXPIRED'
if (title.includes('即将到期')) return 'CONTRACT_EXPIRING'
if (title.includes('试用期')) return 'CONTRACT_PROBATION'
if (title.includes('入职手续未完成')) return 'ONBOARDING_INCOMPLETE'
if (title.includes('孕期') || title.includes('哺乳期')) return 'SPECIAL_PREGNANCY'
if (title.includes('工伤')) return 'SPECIAL_INJURY'
if (title.includes('医疗期')) return 'SPECIAL_MEDICAL'
if (title.includes('社保')) return 'MONTHLY_SOCIAL'
if (title.includes('公积金')) return 'MONTHLY_HOUSING'
if (title.includes('工资')) return 'MONTHLY_PAYROLL'
if (title.includes('个税')) return 'MONTHLY_TAX'
return title.replace(/\d+/g, '').trim()
}
for (const r of employeeRiskDetails) {
const empId = r.employeeId || '_unknown'
if (!employeeBreakdown[empId]) {
employeeBreakdown[empId] = {
employeeId: empId,
// 按身份证号分组,无身份证号时回退到 employeeId
const personKey = r.employee?.idCardHash || r.employeeId || '_unknown'
if (!personBreakdown[personKey]) {
personBreakdown[personKey] = {
personKey,
name: r.employee?.name || '未知员工',
department: r.employee?.department || '-',
riskCount: 0,
lossAvoided: 0,
adjustedLoss: 0,
details: [],
details: new Map(),
}
}
const emp = employeeBreakdown[empId]
emp.riskCount++
emp.lossAvoided += r.estimatedLoss || 0
const person = personBreakdown[personKey]
// 根据风险标题推断法律依据
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,
})
// 按风险类别去重:同一类别只保留 estimatedLoss 最大的一条
const catKey = getRiskCategoryKey(r.title || '')
const existing = person.details.get(catKey)
if (!existing || (r.estimatedLoss || 0) > existing.estimatedLoss) {
person.details.set(catKey, {
title: r.title,
estimatedLoss: r.estimatedLoss || 0,
legalBasis: getLegalBasis(r.title || ''),
})
}
}
// 计算每个员工的打折后规避损失
const employeeDetails = Object.values(employeeBreakdown)
.map((e) => ({
...e,
adjustedLoss: Math.round(e.lossAvoided * RISK_AVOIDANCE_RATE),
}))
// 汇总去重后的员工明细
const employeeDetails = Object.values(personBreakdown)
.map((p) => {
const details = Array.from(p.details.values()).sort((a, b) => b.estimatedLoss - a.estimatedLoss)
const lossAvoided = details.reduce((sum, d) => sum + d.estimatedLoss, 0)
return {
employeeId: p.personKey,
name: p.name,
department: p.department,
riskCount: details.length,
lossAvoided,
adjustedLoss: Math.round(lossAvoided * RISK_AVOIDANCE_RATE),
details,
}
})
.sort((a, b) => b.adjustedLoss - a.adjustedLoss)
// 用去重后的 lossAvoided 替换原始汇总值
const dedupedLossAvoided = employeeDetails.reduce((sum, e) => sum + e.lossAvoided, 0)
const lossAvoided = dedupedLossAvoided
// 月度事件时间轴
const timeline = monthlyRisks.map((mr) => ({
month: mr.month,