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) {
|
export async function runRiskDetection(orgId: string) {
|
||||||
|
// 非月度风险去重:检查所有状态(含 RESOLVED/IGNORED),避免已处理的风险被重新创建
|
||||||
|
// 按 employeeId:type 归并,不依赖 actionUrl(actionUrl 可能因天数变化而不同)
|
||||||
const existingRisks = await prisma.riskItem.findMany({
|
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),避免已完成的当月任务被重新创建
|
// 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建
|
||||||
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
|
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: {
|
include: {
|
||||||
employee: {
|
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' },
|
orderBy: { estimatedLoss: 'desc' },
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|
||||||
const lossAvoided = lossAvoidedAgg._sum.estimatedLoss || 0
|
|
||||||
|
|
||||||
// 按员工维度汇总规避损失
|
|
||||||
const RISK_AVOIDANCE_RATE = 0.3
|
const RISK_AVOIDANCE_RATE = 0.3
|
||||||
const employeeBreakdown: Record<string, {
|
|
||||||
employeeId: string
|
// 按身份证号去重(同一人可能有多条 Employee 记录),无身份证号时回退到 employeeId
|
||||||
|
// 同时按风险类型去重(同一风险被重复创建解决多次,只取 estimatedLoss 最大的一条)
|
||||||
|
const personBreakdown: Record<string, {
|
||||||
|
personKey: string
|
||||||
name: string
|
name: string
|
||||||
department: string
|
department: string
|
||||||
riskCount: number
|
riskCount: number
|
||||||
lossAvoided: number
|
lossAvoided: number
|
||||||
adjustedLoss: 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) {
|
for (const r of employeeRiskDetails) {
|
||||||
const empId = r.employeeId || '_unknown'
|
// 按身份证号分组,无身份证号时回退到 employeeId
|
||||||
if (!employeeBreakdown[empId]) {
|
const personKey = r.employee?.idCardHash || r.employeeId || '_unknown'
|
||||||
employeeBreakdown[empId] = {
|
if (!personBreakdown[personKey]) {
|
||||||
employeeId: empId,
|
personBreakdown[personKey] = {
|
||||||
|
personKey,
|
||||||
name: r.employee?.name || '未知员工',
|
name: r.employee?.name || '未知员工',
|
||||||
department: r.employee?.department || '-',
|
department: r.employee?.department || '-',
|
||||||
riskCount: 0,
|
riskCount: 0,
|
||||||
lossAvoided: 0,
|
lossAvoided: 0,
|
||||||
adjustedLoss: 0,
|
adjustedLoss: 0,
|
||||||
details: [],
|
details: new Map(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const emp = employeeBreakdown[empId]
|
const person = personBreakdown[personKey]
|
||||||
emp.riskCount++
|
|
||||||
emp.lossAvoided += r.estimatedLoss || 0
|
|
||||||
|
|
||||||
// 根据风险标题推断法律依据
|
// 按风险类别去重:同一类别只保留 estimatedLoss 最大的一条
|
||||||
let legalBasis = '—'
|
const catKey = getRiskCategoryKey(r.title || '')
|
||||||
const title = r.title || ''
|
const existing = person.details.get(catKey)
|
||||||
if (title.includes('未签合同')) legalBasis = '第82条 双倍工资≤11个月'
|
if (!existing || (r.estimatedLoss || 0) > existing.estimatedLoss) {
|
||||||
else if (title.includes('到期') && title.includes('未续签')) legalBasis = '第47条 经济补偿N≤12个月'
|
person.details.set(catKey, {
|
||||||
else if (title.includes('孕期') || title.includes('哺乳期') || title.includes('工伤') || title.includes('医疗期')) legalBasis = '第87条 违法解除2N≤24个月'
|
title: r.title,
|
||||||
else if (title.includes('试用期')) legalBasis = '第20条 试用期工资差额'
|
estimatedLoss: r.estimatedLoss || 0,
|
||||||
else if (title.includes('社保') || title.includes('公积金')) legalBasis = '滞纳金 每日0.05%'
|
legalBasis: getLegalBasis(r.title || ''),
|
||||||
else if (title.includes('工资') || title.includes('个税')) legalBasis = '拖欠工资/个税'
|
})
|
||||||
|
}
|
||||||
emp.details.push({
|
|
||||||
title: r.title,
|
|
||||||
estimatedLoss: r.estimatedLoss || 0,
|
|
||||||
legalBasis,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算每个员工的打折后规避损失
|
// 汇总去重后的员工明细
|
||||||
const employeeDetails = Object.values(employeeBreakdown)
|
const employeeDetails = Object.values(personBreakdown)
|
||||||
.map((e) => ({
|
.map((p) => {
|
||||||
...e,
|
const details = Array.from(p.details.values()).sort((a, b) => b.estimatedLoss - a.estimatedLoss)
|
||||||
adjustedLoss: Math.round(e.lossAvoided * RISK_AVOIDANCE_RATE),
|
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)
|
.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) => ({
|
const timeline = monthlyRisks.map((mr) => ({
|
||||||
month: mr.month,
|
month: mr.month,
|
||||||
|
|||||||
Reference in New Issue
Block a user