fix: 年度价值报告按身份证号+风险类型双重去重
- 按 idCardHash 去重同一人的多条 Employee 记录(无身份证号回退到 employeeId) - 按风险类别去重同一风险的重复创建解决,只取 estimatedLoss 最大值 - 风险扫描去重改为检查所有状态(含RESOLVED/IGNORED),按 employeeId:type 归并 - 修复规避损失用去重后的值替代原始聚合值
This commit is contained in:
@@ -443,10 +443,13 @@ export async function detectMonthlyTasks(orgId: string) {
|
||||
}
|
||||
|
||||
export async function runRiskDetection(orgId: string) {
|
||||
// 非月度风险去重:检查所有状态(含 RESOLVED/IGNORED),避免已处理的风险被重新创建
|
||||
// 按 employeeId:type 归并,不依赖 actionUrl(actionUrl 可能因天数变化而不同)
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user