diff --git a/backend/src/services/risk.service.ts b/backend/src/services/risk.service.ts index 4a6da43..808a5c0 100644 --- a/backend/src/services/risk.service.ts +++ b/backend/src/services/risk.service.ts @@ -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 }> = {} + // 辅助:根据风险标题推断法律依据 + 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,