import prisma from '../lib/prisma' import type { RiskLevel, RiskType } from '@prisma/client' import { decrypt } from '../lib/crypto' import { calcIndividualRetireAge, calcRetirementDaysLeft } from './retirement.service' function daysBetween(a: Date, b: Date): number { return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24)) } /** 解密员工月薪 */ function safeDecryptSalary(encrypted: string): number { try { return Number(decrypt(encrypted)) || 0 } catch { return Number(encrypted) || 0 } } /** * 社平工资3倍封顶(参照上海2024年度社平工资约12307元/月,3倍≈36921元) * 《劳动合同法》第47条:月工资高于社平3倍的按3倍计,且补偿年限最高12年 */ const SOCIAL_AVG_SALARY_3X = 36921 /** * 风险量化:根据风险类型和员工数据估算预估损失金额 * 依据《劳动合同法》《劳动法》设定法定上限 * @returns { estimatedLoss, lossRange, deadline } */ function estimateRiskCost( risk: { type: RiskType; level: RiskLevel; title: string; description: string }, employee?: { hireDate: Date; monthlySalary?: string } | null, ): { estimatedLoss: number; lossRange: [number, number]; deadline?: Date } { const today = new Date() const rawSalary = employee ? safeDecryptSalary(employee.monthlySalary || '0') : 0 // 第47条:高收入者月薪封顶为社平3倍 const salary = Math.min(rawSalary, SOCIAL_AVG_SALARY_3X) const workYears = employee ? Math.max(0, (today.getFullYear() - employee.hireDate.getFullYear()) + (today.getMonth() - employee.hireDate.getMonth()) / 12) : 0 // 经济补偿月数:每满1年1个月,不满半年算0.5个月,第47条上限12年 const n = Math.min(12, Math.max(0.5, Math.round(workYears * 2) / 2)) // 根据风险标题关键词判断类型并估算 const title = risk.title // 未签合同(>30天):双倍工资 // 《劳动合同法》第82条:超过1个月不满1年未签书面合同,每月支付2倍工资 // 超过1年视为已订立无固定期限合同,不再适用双倍工资,故最多罚11个月 if (title.includes('未签合同') && title.includes('天')) { const daysMatch = title.match(/(\d+)天/) const days = daysMatch ? parseInt(daysMatch[1]) : 0 const months = Math.min(11, Math.ceil(days / 30)) // 法定上限11个月 const loss = salary * months return { estimatedLoss: loss, lossRange: [loss * 0.5, loss], deadline: new Date(today.getTime() + 7 * 86400000), // 7天内处理 } } // 合同到期未续签:经济补偿 N × 月薪 // 《劳动合同法》第47条:N≤12个月,月薪≤社平3倍 if (title.includes('到期') && title.includes('未续签')) { const loss = salary * n // n已封顶12 return { estimatedLoss: loss, lossRange: [loss, loss * 2], // 可能被认定为违法解除 2N deadline: new Date(today.getTime() + 3 * 86400000), // 3天内处理 } } // 合同即将到期:提前准备 if (title.includes('即将到期')) { const daysMatch = title.match(/(\d+)天/) const days = daysMatch ? parseInt(daysMatch[1]) : 30 return { estimatedLoss: 0, lossRange: [0, salary * n], deadline: new Date(today.getTime() + days * 86400000), } } // 试用期超限:工资差额 if (title.includes('试用期') && title.includes('不合法')) { const loss = salary * 0.2 * 1 // 试用期工资差额约20% return { estimatedLoss: loss, lossRange: [loss, loss * 3], deadline: new Date(today.getTime() + 14 * 86400000), // 14天内处理 } } // 三期/工伤/医疗期:违法解除风险 2N // 《劳动合同法》第87条:违法解除按第47条标准的2倍赔偿,2N≤24个月 if (title.includes('孕期') || title.includes('哺乳期') || title.includes('工伤') || title.includes('医疗期')) { const loss = salary * n * 2 // n已封顶12,故2N≤24 return { estimatedLoss: loss, lossRange: [loss, loss * 2], deadline: new Date(today.getTime() + 1 * 86400000), // 1天内处理 } } // 月度任务:无直接损失但可能有滞纳金 if (title.includes('社保') || title.includes('公积金')) { return { estimatedLoss: 500, lossRange: [100, 2000], deadline: new Date(today.getTime() + 3 * 86400000), } } if (title.includes('工资') || title.includes('个税')) { return { estimatedLoss: 1000, lossRange: [500, 5000], deadline: new Date(today.getTime() + 1 * 86400000), } } // 退休提醒:未及时办理退休可能导致多缴社保公积金 if (title.includes('退休')) { const daysMatch = title.match(/(\d+)天/) const days = daysMatch ? parseInt(daysMatch[1]) : 0 return { estimatedLoss: days > 0 ? salary * (days / 30) : salary, lossRange: [500, salary * 6], deadline: new Date(today.getTime() + (days > 0 ? days : 7) * 86400000), } } // 默认 return { estimatedLoss: 0, lossRange: [0, 0], } } /** * 计算风险优先级 */ function calcPriority( level: RiskLevel, estimatedLoss: number, deadline?: Date, ): 'URGENT' | 'HIGH' | 'MEDIUM' | 'LOW' { const today = new Date() today.setHours(0, 0, 0, 0) const daysLeft = deadline ? daysBetween(deadline, today) : 999 if (level === 'HIGH' && (daysLeft <= 3 || estimatedLoss >= 50000)) return 'URGENT' if (level === 'HIGH') return 'HIGH' if (level === 'MEDIUM' && estimatedLoss >= 10000) return 'HIGH' if (level === 'MEDIUM') return 'MEDIUM' return 'LOW' } /** * 格式化时长:不足30天用"X天",超过30天用"X个月Y天" */ function formatDuration(days: number): string { if (days < 30) return `${days}天` const months = Math.floor(days / 30) const remainDays = days % 30 return remainDays === 0 ? `${months}个月` : `${months}个月${remainDays}天` } export async function detectContractRisks(orgId: string) { const today = new Date() today.setHours(0, 0, 0, 0) const employees = await prisma.employee.findMany({ where: { orgId, status: 'ACTIVE', hireDate: { lte: today } }, include: { contracts: { orderBy: { createdAt: 'desc' } } }, }) const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = [] for (const emp of employees) { const latestContract = emp.contracts[0] if (!latestContract || latestContract.contractType === 'UNSIGNED') { const days = daysBetween(new Date(), emp.hireDate) if (days > 365) { risks.push({ employeeId: emp.id, type: 'CONTRACT', level: 'HIGH', title: `${emp.name}入职${formatDuration(days)}未签合同,已视为无固定期限`, description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},超过1年未签订书面合同,法律上已视为无固定期限劳动合同。`, actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, }) } else if (days > 30) { risks.push({ employeeId: emp.id, type: 'CONTRACT', level: 'HIGH', title: `${emp.name}入职${formatDuration(days)}未签合同`, description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},超过30天未签订书面合同,需尽快补签。`, actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, }) } else { risks.push({ employeeId: emp.id, type: 'CONTRACT', level: 'LOW', title: `${emp.name}入职${formatDuration(days)},尚未签合同`, description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},30天内需签订书面合同。`, actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, }) } continue } if (latestContract.endDate) { const daysToExpire = daysBetween(latestContract.endDate, new Date()) if (daysToExpire < 0) { risks.push({ employeeId: emp.id, type: 'CONTRACT', level: 'HIGH', title: `${emp.name}的合同已到期${Math.abs(daysToExpire)}天未续签`, description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},已过期未续签。`, actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, }) } else if (daysToExpire <= 30) { risks.push({ employeeId: emp.id, type: 'CONTRACT', level: 'MEDIUM', title: `${emp.name}的合同即将到期(${daysToExpire}天)`, description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},需提前准备续签或终止。`, actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, }) } } if (latestContract.probationMonths > 0) { const contractMonths = latestContract.endDate ? Math.ceil(daysBetween(latestContract.endDate, latestContract.startDate) / 30.44) : 36 let maxProbation = 0 if (contractMonths >= 36) maxProbation = 6 else if (contractMonths >= 12) maxProbation = 2 else if (contractMonths >= 3) maxProbation = 1 if (latestContract.probationMonths > maxProbation) { risks.push({ employeeId: emp.id, type: 'CONTRACT', level: 'MEDIUM', title: `${emp.name}试用期${latestContract.probationMonths}个月可能不合法`, description: `${contractMonths}个月合同试用期最多${maxProbation}个月,当前${latestContract.probationMonths}个月超出法定上限。`, actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, }) } } } return risks } // 预入职检查:入职日期已到但未签合同 → 待办 export async function detectOnboardingRisks(orgId: string) { const today = new Date() today.setHours(0, 0, 0, 0) const employees = await prisma.employee.findMany({ where: { orgId, status: 'ACTIVE', hireDate: { lte: today } }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 }, terminations: { where: { terminationDate: { lte: today }, status: 'COMPLETED' }, take: 1 }, }, }) const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = [] for (const emp of employees) { // 已离职的跳过 if (emp.terminations.length > 0) continue const latestContract = emp.contracts[0] const hasSignedContract = latestContract && latestContract.contractType !== 'UNSIGNED' if (!hasSignedContract) { const daysSinceHire = daysBetween(today, emp.hireDate) risks.push({ employeeId: emp.id, type: 'ONBOARDING', level: daysSinceHire > 30 ? 'HIGH' : 'MEDIUM', title: `${emp.name}入职手续未完成${daysSinceHire > 30 ? `(已超${formatDuration(Math.round(daysSinceHire))})` : ''}`, description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},尚未签订劳动合同,请尽快完成入职手续。`, actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, }) } } return risks } export async function detectTerminationRisks(orgId: string) { const employees = await prisma.employee.findMany({ where: { orgId, status: 'ACTIVE' }, }) const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = [] for (const emp of employees) { if (emp.isPregnant) { risks.push({ employeeId: emp.id, type: 'TERMINATION', level: 'HIGH', title: `${emp.name}处于孕期/哺乳期,解聘受限`, description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。', actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`, }) } if (emp.isInMedicalPeriod) { risks.push({ employeeId: emp.id, type: 'TERMINATION', level: 'MEDIUM', title: `${emp.name}处于医疗期,解聘需谨慎`, description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。', actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`, }) } if (emp.isWorkInjured) { risks.push({ employeeId: emp.id, type: 'TERMINATION', level: 'HIGH', title: `${emp.name}工伤期间,解聘受限`, description: '工伤职工在停工留薪期内不得解除劳动合同。', actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`, }) } } return risks } /** * 检测特殊状态相关风险(三期/工伤/医疗期员工合同到期不得终止) */ async function detectSpecialStatusRisks(orgId: string) { const today = new Date() today.setHours(0, 0, 0, 0) // 查找所有进行中的特殊状态记录 const specialStatuses = await (prisma as any).employeeSpecialStatus.findMany({ where: { orgId, status: 'ACTIVE' }, include: { employee: { select: { id: true, name: true, status: true, contracts: { orderBy: { createdAt: 'desc' }, take: 1 } }, }, }, }) const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = [] for (const ss of specialStatuses) { const emp = ss.employee if (!emp || emp.status !== 'ACTIVE') continue const latestContract = emp.contracts[0] if (!latestContract || !latestContract.endDate) continue const daysToExpire = daysBetween(latestContract.endDate, today) const typeLabel = ss.type === 'PREGNANCY' ? '三期' : ss.type === 'WORK_INJURY' ? '工伤' : ss.type === 'MEDICAL_PERIOD' ? '医疗期' : '特殊状态' // 合同即将到期但处于特殊状态期间,不得终止 if (daysToExpire >= 0 && daysToExpire <= 60) { risks.push({ employeeId: emp.id, type: 'CONTRACT' as RiskType, level: 'HIGH' as RiskLevel, title: `${emp.name}处于${typeLabel}期间,合同${daysToExpire}天后到期,依法不得终止`, description: `${typeLabel}员工在合同到期时,用人单位不得依照《劳动合同法》第四十条、第四十一条终止合同,需顺延至相应情形消失。`, actionUrl: `/special-status?type=${ss.type}`, }) } // 工伤认定超期提醒(受伤30天内未认定) if (ss.type === 'WORK_INJURY' && ss.injuryDate && !ss.certificationDate) { const daysSinceInjury = daysBetween(today, new Date(ss.injuryDate)) if (daysSinceInjury > 30) { risks.push({ employeeId: emp.id, type: 'CONTRACT' as RiskType, level: 'HIGH' as RiskLevel, title: `${emp.name}工伤已${daysSinceInjury}天未完成认定,超30天限期`, description: `用人单位应自事故伤害发生之日起30日内提出工伤认定申请,逾期将影响工伤待遇。`, actionUrl: `/special-status?type=WORK_INJURY`, }) } } // 医疗期即将到期 if (ss.type === 'MEDICAL_PERIOD' && ss.medicalPeriodEnd) { const daysToMedicalEnd = daysBetween(new Date(ss.medicalPeriodEnd), today) if (daysToMedicalEnd >= 0 && daysToMedicalEnd <= 30) { risks.push({ employeeId: emp.id, type: 'CONTRACT' as RiskType, level: daysToMedicalEnd <= 7 ? 'HIGH' as RiskLevel : 'MEDIUM' as RiskLevel, title: `${emp.name}医疗期${daysToMedicalEnd}天后到期,需提前处理`, description: `医疗期届满后,员工仍需治疗的,用人单位应按规定处理。医疗期满不能从事原工作的,可依法解除合同但需支付经济补偿。`, actionUrl: `/special-status?type=MEDICAL_PERIOD`, }) } } } return risks } export async function detectMonthlyTasks(orgId: string) { const setting = await prisma.notificationSetting.findUnique({ where: { orgId } }) if (!setting) return [] const now = new Date() const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` const today = now.getDate() const tasks = [ { day: setting.payrollDay, title: `${currentMonth}月 发放工资`, desc: `每月${setting.payrollDay}日前完成工资发放`, url: '/money' }, { day: setting.socialInsDay, title: `${currentMonth}月 缴纳社保`, desc: `每月${setting.socialInsDay}日前完成社保缴纳`, url: '/money' }, { day: setting.housingFundDay, title: `${currentMonth}月 缴纳公积金`, desc: `每月${setting.housingFundDay}日前完成公积金缴纳`, url: '/money' }, { day: setting.taxDay, title: `${currentMonth}月 申报个税`, desc: `每月${setting.taxDay}日前完成个税申报`, url: '/money' }, ] const risks: { employeeId: null; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = [] for (const task of tasks) { // 当月已过截止日或正好到截止日时生成提醒 if (today >= task.day) { risks.push({ employeeId: null, type: 'MONTHLY', level: today > task.day + 3 ? 'HIGH' : 'MEDIUM', title: task.title, description: task.desc, actionUrl: task.url, }) } } // 工资条生成提醒:当月有已归档批次时提醒生成工资条 const archivedBatches = await prisma.payrollBatch.count({ where: { orgId, month: currentMonth, status: 'ARCHIVED' }, }) if (archivedBatches > 0) { risks.push({ employeeId: null, type: 'SALARY', level: 'MEDIUM', title: `${currentMonth}月 生成工资条`, description: `本月有 ${archivedBatches} 个已归档工资批次,请前往工资条管理汇总生成工资条`, actionUrl: '/money', }) } return risks } /** * 退休提醒:检测即将退休的员工(距退休180天内) */ export async function detectRetirementRisks(orgId: string) { const employees = await prisma.employee.findMany({ where: { orgId, status: 'ACTIVE', birthDate: { not: null } }, select: { id: true, name: true, gender: true, birthDate: true, femaleWorkerType: true }, }) const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = [] for (const emp of employees) { if (!emp.birthDate) continue const gender = emp.gender || '男' const fwt = emp.femaleWorkerType const bd = new Date(emp.birthDate) const { retireDate } = calcIndividualRetireAge(bd, gender, fwt) const daysLeft = calcRetirementDaysLeft(bd, gender, fwt) ?? 0 if (daysLeft <= 180 && daysLeft > 0) { risks.push({ employeeId: emp.id, type: 'RETIREMENT', level: daysLeft <= 30 ? 'HIGH' : 'MEDIUM', title: `${emp.name}距退休仅剩${daysLeft}天`, description: `${gender === '女' ? (fwt === 'WORKER' ? '女工人' : '女干部') : '男'},出生日期 ${emp.birthDate.toISOString().slice(0, 10)},预计退休日期 ${retireDate.toISOString().slice(0, 10)}。请提前准备退休手续。`, actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, }) } else if (daysLeft <= 0) { risks.push({ employeeId: emp.id, type: 'RETIREMENT', level: 'HIGH', title: `${emp.name}已达退休年龄`, description: `${gender === '女' ? (fwt === 'WORKER' ? '女工人' : '女干部') : '男'},出生日期 ${emp.birthDate.toISOString().slice(0, 10)},已超过退休日期 ${retireDate.toISOString().slice(0, 10)}。请尽快办理退休手续。`, actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`, }) } } return risks } export async function runRiskDetection(orgId: string) { // 清理重复 PENDING 风险:同一 employeeId:type 只保留最新一条 PENDING const pendingRisks = await prisma.riskItem.findMany({ where: { orgId, status: 'PENDING', employeeId: { not: null } }, select: { id: true, employeeId: true, type: true, createdAt: true }, orderBy: { createdAt: 'desc' }, }) const seenKeys = new Set() const duplicateIds: string[] = [] for (const r of pendingRisks) { const key = `${r.employeeId}:${r.type}` if (seenKeys.has(key)) { duplicateIds.push(r.id) } else { seenKeys.add(key) } } if (duplicateIds.length > 0) { await prisma.riskItem.updateMany({ where: { id: { in: duplicateIds } }, data: { status: 'RESOLVED', resolvedAt: new Date() }, }) } // 非月度风险去重:检查所有状态(含 RESOLVED/IGNORED),避免已处理的风险被重新创建 // 按 employeeId:type 归并,不依赖 actionUrl(actionUrl 可能因天数变化而不同) const existingRisks = await prisma.riskItem.findMany({ where: { orgId, status: { in: ['PENDING', 'RESOLVED', 'IGNORED'] } }, select: { id: true, employeeId: true, type: true, title: true, status: true }, }) const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}`)) // PENDING 风险的 employeeId:type → record 映射,用于更新标题 const pendingRiskMap = new Map() for (const r of existingRisks) { if (r.status === 'PENDING' && r.employeeId) { pendingRiskMap.set(`${r.employeeId}:${r.type}`, r) } } // 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建 const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}` const monthlyExisting = await prisma.riskItem.findMany({ where: { orgId, title: { startsWith: `${currentMonth}月` } }, select: { employeeId: true, title: true }, }) const monthlyKeys = new Set(monthlyExisting.map((r: typeof monthlyExisting[number]) => `${r.employeeId}:${r.title}`)) const contractRisks = await detectContractRisks(orgId) const terminationRisks = await detectTerminationRisks(orgId) const onboardingRisks = await detectOnboardingRisks(orgId) const monthlyTasks = await detectMonthlyTasks(orgId) const specialStatusRisks = await detectSpecialStatusRisks(orgId) const retirementRisks = await detectRetirementRisks(orgId) // 去重:contractRisks 已覆盖"未签合同",onboardingRisks 中相同员工不再重复生成 const contractEmployeeIds = new Set(contractRisks.map(r => r.employeeId)) const filteredOnboardingRisks = onboardingRisks.filter(r => !contractEmployeeIds.has(r.employeeId)) // 获取所有相关员工数据用于风险量化 const allEmployeeIds = [...contractRisks, ...terminationRisks, ...filteredOnboardingRisks, ...specialStatusRisks, ...retirementRisks] .map(r => r.employeeId) .filter(Boolean) as string[] const employees = allEmployeeIds.length > 0 ? await prisma.employee.findMany({ where: { id: { in: allEmployeeIds } } }) : [] const empMap = new Map(employees.map(e => [e.id, e])) // 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重 const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...filteredOnboardingRisks, ...specialStatusRisks, ...retirementRisks] const toCreate = [ ...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}`)), ...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)), ] // 更新已存在的 PENDING 风险:标题/量化信息可能因天数变化而变化 const toUpdate: { id: string; title: string; description: string; estimatedLoss: number; lossRange: [number, number]; deadline?: Date }[] = [] for (const r of nonMonthlyRisks) { const key = `${r.employeeId}:${r.type}` const existing = pendingRiskMap.get(key) if (existing && existing.title !== r.title) { const emp = r.employeeId ? empMap.get(r.employeeId) : null const cost = estimateRiskCost(r, emp) toUpdate.push({ id: existing.id, title: r.title, description: r.description, estimatedLoss: cost.estimatedLoss, lossRange: cost.lossRange, deadline: cost.deadline, }) } } for (const u of toUpdate) { await prisma.riskItem.update({ where: { id: u.id }, data: { title: u.title, description: u.description, estimatedLoss: u.estimatedLoss, lossRange: u.lossRange, deadline: u.deadline, }, }) } if (toCreate.length > 0) { // 为每个风险计算量化信息 const createData = toCreate.map((r) => { const emp = r.employeeId ? empMap.get(r.employeeId) : null const cost = estimateRiskCost(r, emp) return { orgId, employeeId: r.employeeId, type: r.type, level: r.level, title: r.title, description: r.description, actionUrl: r.actionUrl, estimatedLoss: cost.estimatedLoss, lossRange: cost.lossRange, deadline: cost.deadline, } }) await prisma.riskItem.createMany({ data: createData }) } // 自动消除:PENDING 风险在最新检测结果中不再出现,说明问题已自然解决 // 例如:员工补签了合同,则旧的"未签合同"风险应自动标记为 RESOLVED const newRiskKeys = new Set(nonMonthlyRisks.map((r) => `${r.employeeId}:${r.type}`)) const toResolve: string[] = [] for (const r of existingRisks) { if (r.status === 'PENDING' && r.employeeId) { const key = `${r.employeeId}:${r.type}` if (!newRiskKeys.has(key)) { toResolve.push(r.id) } } } if (toResolve.length > 0) { await prisma.riskItem.updateMany({ where: { id: { in: toResolve } }, data: { status: 'RESOLVED', resolvedAt: new Date() }, }) } return toCreate.length } export async function getDashboardData(orgId: string) { await runRiskDetection(orgId) const now = new Date() const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59) const yearStart = new Date(now.getFullYear(), 0, 1) const yearEnd = new Date(now.getFullYear(), 11, 31, 23, 59, 59) const [ employeeCount, _highRisks, _pendingRisks, riskItems, resolvedItems, overtimeRecords, payslips, batchEntries, socialConfig, housingConfig, monthContracts, monthTerminations, monthDisciplinary, monthAttendance, monthSeverancePay, yearBatchEntries, yearOvertimeRecords, yearSeverancePay, ] = await Promise.all([ prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }), prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH', type: { in: ['CONTRACT', 'TERMINATION'] } } }), prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }), prisma.riskItem.findMany({ where: { orgId, status: 'PENDING' }, include: { employee: { select: { id: true, name: true, department: true, idCardHash: true } } }, orderBy: [{ level: 'asc' }, { estimatedLoss: 'desc' }], }), prisma.riskItem.findMany({ where: { orgId, status: 'RESOLVED' }, include: { employee: true }, orderBy: { resolvedAt: 'desc' }, take: 10, }), prisma.overtimeRecord.findMany({ where: { orgId, month: currentMonth }, select: { totalPay: true, weekdayHours: true, weekendHours: true, holidayHours: true }, }), prisma.payslip.findMany({ where: { orgId, month: currentMonth }, select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, totalPay: true, confirmedAt: true }, }), // 已归档批次的条目(用于总览汇总) prisma.batchEntry.findMany({ where: { orgId, batch: { month: currentMonth, status: 'ARCHIVED' } }, select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true }, }), prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }), prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }), prisma.laborContract.count({ where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } }, }), prisma.terminationRecord.count({ where: { orgId, createdAt: { gte: monthStart, lte: monthEnd }, status: { notIn: ['CANCELLED', 'DRAFT'] } }, }), prisma.disciplinaryRecord.count({ where: { orgId, violationDate: { gte: monthStart, lte: monthEnd } }, }), prisma.attendanceRecord.count({ where: { orgId, date: { gte: monthStart, lte: monthEnd } }, }), prisma.terminationRecord.aggregate({ where: { orgId, createdAt: { gte: monthStart, lte: monthEnd }, status: { notIn: ['CANCELLED', 'DRAFT'] } }, _sum: { compensation: true }, }), // 年度累计:已归档批次条目 prisma.batchEntry.findMany({ where: { orgId, batch: { month: { startsWith: `${now.getFullYear()}-` }, status: 'ARCHIVED' } }, select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true }, }), // 年度累计:加班记录 prisma.overtimeRecord.findMany({ where: { orgId, month: { startsWith: `${now.getFullYear()}-` } }, select: { totalPay: true, weekdayHours: true, weekendHours: true, holidayHours: true }, }), // 年度累计:补偿金 prisma.terminationRecord.aggregate({ where: { orgId, createdAt: { gte: yearStart, lte: yearEnd }, status: { notIn: ['CANCELLED', 'DRAFT'] } }, _sum: { compensation: true }, }), ]) const monthlyOvertimePay = overtimeRecords.reduce((sum: number, r: typeof overtimeRecords[number]) => sum + r.totalPay, 0) // 本月薪税汇总:优先从已归档批次汇总,无归档批次则用工资条数据 const archivedEntries = batchEntries const useArchivedData = archivedEntries.length > 0 let totalBaseSalary: number, totalOvertimePay: number, totalAllowance: number, totalDeduction: number, totalPay: number let totalSocialOrg: number, totalSocialEmp: number, totalHousingOrg: number, totalHousingEmp: number, totalTax: number, totalNetPay: number let payslipCount: number, confirmedPayslips: number if (useArchivedData) { // 从已归档批次条目汇总(同一员工多批次的金额累加) const empMap = new Map() for (const e of archivedEntries) { const ex = empMap.get(e.employeeId) || { baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0, totalPay: 0, socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, netPay: 0 } ex.baseSalary += e.baseSalary ex.overtimePay += e.overtimePay ex.allowance += e.allowance ex.deduction += e.deduction ex.bonus += e.bonus ex.totalPay += e.totalPay ex.socialEmp += e.socialEmp ex.socialOrg += e.socialOrg ex.housingEmp += e.housingEmp ex.housingOrg += e.housingOrg ex.tax += e.tax ex.netPay += e.netPay empMap.set(e.employeeId, ex) } const summary = Array.from(empMap.values()) totalBaseSalary = summary.reduce((s, e) => s + e.baseSalary, 0) totalOvertimePay = summary.reduce((s, e) => s + e.overtimePay, 0) totalAllowance = summary.reduce((s, e) => s + e.allowance, 0) totalDeduction = summary.reduce((s, e) => s + e.deduction, 0) totalPay = summary.reduce((s, e) => s + e.totalPay, 0) totalSocialOrg = summary.reduce((s, e) => s + e.socialOrg, 0) totalSocialEmp = summary.reduce((s, e) => s + e.socialEmp, 0) totalHousingOrg = summary.reduce((s, e) => s + e.housingOrg, 0) totalHousingEmp = summary.reduce((s, e) => s + e.housingEmp, 0) totalTax = summary.reduce((s, e) => s + e.tax, 0) totalNetPay = summary.reduce((s, e) => s + e.netPay, 0) payslipCount = summary.length confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length } else { // fallback:从工资条表汇总 totalBaseSalary = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.baseSalary, 0) totalOvertimePay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.overtimePay, 0) totalAllowance = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.allowance, 0) totalDeduction = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.deduction, 0) totalPay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.totalPay, 0) totalSocialOrg = 0 totalSocialEmp = 0 totalHousingOrg = 0 totalHousingEmp = 0 totalTax = 0 totalNetPay = 0 payslipCount = payslips.length confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length } // 社保公积金:优先用归档批次的实际计算值,否则估算 let socialOrgTotal = 0 let socialEmpTotal = 0 let housingOrgTotal = 0 let housingEmpTotal = 0 if (useArchivedData) { socialOrgTotal = totalSocialOrg socialEmpTotal = totalSocialEmp housingOrgTotal = totalHousingOrg housingEmpTotal = totalHousingEmp } else if (socialConfig && employeeCount > 0 && payslips.length > 0) { // 用平均工资作为估算基数(仅当有工资条数据时才估算) const avgBase = employeeCount > 0 ? Math.max(socialConfig.baseMin, Math.min(socialConfig.baseMax, totalBaseSalary / Math.max(employeeCount, 1))) : socialConfig.baseMin socialOrgTotal = avgBase * (socialConfig.pensionOrg + socialConfig.medicalOrg + socialConfig.unemploymentOrg + socialConfig.injuryOrg + socialConfig.maternityOrg) / 100 * employeeCount socialEmpTotal = avgBase * (socialConfig.pensionEmp + socialConfig.medicalEmp + socialConfig.unemploymentEmp) / 100 * employeeCount housingOrgTotal = avgBase * (housingConfig?.housingOrg ?? 0) / 100 * employeeCount housingEmpTotal = avgBase * (housingConfig?.housingEmp ?? 0) / 100 * employeeCount } // 个税:优先用归档批次的实际计算值,否则估算 let estimatedTax = 0 if (useArchivedData) { estimatedTax = totalTax } else { const taxableIncome = Math.max(0, totalPay - 5000 * payslips.length - socialEmpTotal - housingEmpTotal) if (taxableIncome <= 3000) estimatedTax = taxableIncome * 0.03 else if (taxableIncome <= 12000) estimatedTax = 3000 * 0.03 + (taxableIncome - 3000) * 0.1 else if (taxableIncome <= 25000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + (taxableIncome - 12000) * 0.2 else if (taxableIncome <= 35000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + (taxableIncome - 25000) * 0.25 else if (taxableIncome <= 55000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + (taxableIncome - 35000) * 0.3 else if (taxableIncome <= 80000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + 20000 * 0.3 + (taxableIncome - 55000) * 0.35 else estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + 20000 * 0.3 + 25000 * 0.35 + (taxableIncome - 80000) * 0.45 } const payrollSummary = { month: currentMonth, employeeCount, payslipCount, confirmedPayslips, unconfirmedPayslips: payslipCount - confirmedPayslips, baseSalary: totalBaseSalary, overtimePay: totalOvertimePay, allowance: totalAllowance, deduction: totalDeduction, totalPay, socialOrg: socialOrgTotal, socialEmp: socialEmpTotal, housingOrg: housingOrgTotal, housingEmp: housingEmpTotal, estimatedTax, severancePay: monthSeverancePay._sum.compensation || 0, // 企业总成本 = 工资总额 + 企业社保 + 企业公积金 + 加班费 + 经济补偿金 orgTotalCost: totalPay + socialOrgTotal + housingOrgTotal + monthlyOvertimePay + (monthSeverancePay._sum.compensation || 0), // 员工实发 = 工资总额 - 个人社保 - 个人公积金 - 个税 empNetPay: useArchivedData ? totalNetPay : totalPay - socialEmpTotal - housingEmpTotal - estimatedTax, } // 本月工作动态 const monthlyActivities = { month: currentMonth, newContracts: monthContracts, terminations: monthTerminations, disciplinaryActions: monthDisciplinary, attendanceRecords: monthAttendance, overtimeHours: overtimeRecords.reduce((s: number, r: typeof overtimeRecords[number]) => s + r.weekdayHours + r.weekendHours + r.holidayHours, 0), overtimePay: monthlyOvertimePay, } // 年度累计人力资源成本 = 已归档批次汇总 + 当前月数据(当前月可能未归档) const yearArchivedPay = yearBatchEntries.reduce((s: number, e: typeof yearBatchEntries[number]) => s + e.totalPay, 0) const yearArchivedSocialOrg = yearBatchEntries.reduce((s: number, e: typeof yearBatchEntries[number]) => s + e.socialOrg, 0) const yearArchivedHousingOrg = yearBatchEntries.reduce((s: number, e: typeof yearBatchEntries[number]) => s + e.housingOrg, 0) const yearOvertimePay = yearOvertimeRecords.reduce((s: number, r: typeof yearOvertimeRecords[number]) => s + r.totalPay, 0) const yearSeverance = yearSeverancePay._sum.compensation || 0 // 当前月是否已包含在归档批次中 const currentMonthArchived = batchEntries.length > 0 const yearTotalPay = yearArchivedPay + (currentMonthArchived ? 0 : totalPay) const yearSocialOrg = yearArchivedSocialOrg + (currentMonthArchived ? 0 : socialOrgTotal) const yearHousingOrg = yearArchivedHousingOrg + (currentMonthArchived ? 0 : housingOrgTotal) const yearCostSummary = { year: now.getFullYear().toString(), totalPay: yearTotalPay, socialOrg: yearSocialOrg, housingOrg: yearHousingOrg, overtimePay: yearOvertimePay, severancePay: yearSeverance, orgTotalCost: yearTotalPay + yearSocialOrg + yearHousingOrg + yearOvertimePay + yearSeverance, } // 为所有风险项计算优先级和量化信息 const todosWithCost = riskItems.map((r: typeof riskItems[number]) => { const estimatedLoss = r.estimatedLoss || 0 const priority = calcPriority(r.level, estimatedLoss, r.deadline || undefined) const daysUntilDeadline = r.deadline ? daysBetween(r.deadline, new Date()) : null return { id: r.id, type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' | 'RETIREMENT', level: r.level.toLowerCase() as 'high' | 'medium' | 'low', priority, title: r.title, description: r.description, actionUrl: r.actionUrl || '/', estimatedLoss, lossRange: r.lossRange as [number, number] | null, deadline: r.deadline?.toISOString().slice(0, 10) || null, daysUntilDeadline, employeeName: r.employee?.name || null, employeeDepartment: r.employee?.department || null, employeeIdCardHash: r.employee?.idCardHash || null, } }) // 按员工 + 风险类别去重:同一员工同一类别只保留 estimatedLoss 最大的一条 function getTodoRiskCategoryKey(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' if (title.includes('退休')) return 'RETIREMENT' return title.replace(/\d+/g, '').trim() } const dedupedTodoMap = new Map() for (const t of todosWithCost) { const personKey = t.employeeIdCardHash || t.employeeName || t.id const catKey = getTodoRiskCategoryKey(t.title || '') const dedupKey = `${personKey}:${catKey}` const existing = dedupedTodoMap.get(dedupKey) if (!existing || t.estimatedLoss > existing.estimatedLoss) { dedupedTodoMap.set(dedupKey, t) } } const dedupedTodos = Array.from(dedupedTodoMap.values()) // 风险分布:使用去重后的数据,与待办列表一致 const riskDistribution = { contract: dedupedTodos.filter((t) => t.type === 'CONTRACT').length, salary: dedupedTodos.filter((t) => t.type === 'SALARY').length, termination: dedupedTodos.filter((t) => t.type === 'TERMINATION').length, } // 按优先级排序:URGENT > HIGH > MEDIUM > LOW const priorityOrder = { URGENT: 0, HIGH: 1, MEDIUM: 2, LOW: 3 } todosWithCost.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]) const topRisks = todosWithCost .filter((t) => t.priority === 'URGENT' || t.priority === 'HIGH') .slice(0, 5) .map((t) => ({ id: t.id, type: t.type as string, level: t.level, priority: t.priority, title: t.title, description: t.description, estimatedLoss: t.estimatedLoss, deadline: t.deadline, daysUntilDeadline: t.daysUntilDeadline, actionUrl: t.actionUrl, })) // 紧急风险横幅:最高优先级且预估损失>0 const urgentRisk = dedupedTodos.find((t) => t.priority === 'URGENT' && t.estimatedLoss > 0) || null // 按优先级排序去重后的 todos dedupedTodos.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]) const todos = dedupedTodos const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({ id: r.id, type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' | 'RETIREMENT', level: r.level.toLowerCase() as 'high' | 'medium' | 'low', title: r.title, description: r.description, actionUrl: r.actionUrl || '/', resolvedAt: r.resolvedAt?.toISOString() || null, })) const hour = new Date().getHours() const greeting = hour < 12 ? `早上好!今天有 ${todos.length} 件事需要处理` : hour < 18 ? `下午好!今天有 ${todos.length} 件事需要处理` : `晚上好!今天有 ${todos.length} 件事需要处理` // 风险趋势:本月新增 vs 上月 const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1) const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59) const lastMonthRiskCount = await prisma.riskItem.count({ where: { orgId, createdAt: { gte: lastMonthStart, lte: lastMonthEnd } }, }) const thisMonthRiskCount = await prisma.riskItem.count({ where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } }, }) const riskTrend = { thisMonth: thisMonthRiskCount, lastMonth: lastMonthRiskCount, change: thisMonthRiskCount - lastMonthRiskCount, } return { greeting, stats: { employeeCount, highRiskCount: dedupedTodos.filter((t) => t.level === 'high' && (t.type === 'CONTRACT' || t.type === 'TERMINATION' || t.type === 'RETIREMENT')).length, todoCount: todos.length, monthlyOvertimePay, }, urgentRisk, todos, resolvedTodos, riskDistribution, riskTrend, topRisks, aiPrediction: null, payrollSummary, monthlyActivities, yearCostSummary, } } /** * HR 月度日历:聚合当月关键日期 */ export async function getMonthlyCalendar(orgId: string, month: string) { const year = month.slice(0, 4) const monthNum = parseInt(month.slice(5, 7)) const monthStart = new Date(parseInt(year), monthNum - 1, 1) const monthEnd = new Date(parseInt(year), monthNum, 0, 23, 59, 59) const events: Array<{ date: string type: string title: string employeeName?: string employeeId?: string actionUrl?: string priority: 'high' | 'medium' | 'low' }> = [] // 1. 合同到期 const expiringContracts = await prisma.laborContract.findMany({ where: { orgId, endDate: { gte: monthStart, lte: monthEnd }, }, include: { employee: true }, }) for (const c of expiringContracts) { events.push({ date: c.endDate!.toISOString().slice(0, 10), type: 'CONTRACT_EXPIRY', title: `${c.employee.name} 合同到期`, employeeName: c.employee.name, employeeId: c.employeeId, actionUrl: '/contracts', priority: 'high', }) } // 2. 试用期到期 const probationExpiring = await prisma.laborContract.findMany({ where: { orgId, probationMonths: { gt: 0 }, }, include: { employee: true }, }) for (const c of probationExpiring) { const probationEnd = new Date(c.startDate) probationEnd.setMonth(probationEnd.getMonth() + c.probationMonths) if (probationEnd >= monthStart && probationEnd <= monthEnd) { events.push({ date: probationEnd.toISOString().slice(0, 10), type: 'PROBATION_END', title: `${c.employee.name} 试用期到期`, employeeName: c.employee.name, employeeId: c.employeeId, actionUrl: '/roster', priority: 'medium', }) } } // 3. 离职/解聘日期 const terminations = await prisma.terminationRecord.findMany({ where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd }, status: { notIn: ['CANCELLED'] }, }, include: { employee: true }, }) for (const t of terminations) { events.push({ date: t.terminationDate.toISOString().slice(0, 10), type: 'TERMINATION', title: `${t.employee.name} 离职/解聘日`, employeeName: t.employee.name, employeeId: t.employeeId, actionUrl: '/termination', priority: 'high', }) } // 4. 入职周年 const employees = await prisma.employee.findMany({ where: { orgId, status: 'ACTIVE', hireDate: { gte: new Date(parseInt(year) - 50, monthNum - 1, 1), lte: new Date(parseInt(year) - 1, monthNum - 1, 0), }, }, }) for (const emp of employees) { const hireAnniversary = new Date(parseInt(year), emp.hireDate.getMonth(), emp.hireDate.getDate()) if (hireAnniversary >= monthStart && hireAnniversary <= monthEnd) { const years = parseInt(year) - emp.hireDate.getFullYear() events.push({ date: hireAnniversary.toISOString().slice(0, 10), type: 'ANNIVERSARY', title: `${emp.name} 入职${years}周年`, employeeName: emp.name, employeeId: emp.id, actionUrl: '/roster', priority: 'low', }) } } // 5. 风险截止日期 const riskDeadlines = await prisma.riskItem.findMany({ where: { orgId, status: 'PENDING', deadline: { gte: monthStart, lte: monthEnd }, }, include: { employee: true }, }) for (const r of riskDeadlines) { events.push({ date: r.deadline!.toISOString().slice(0, 10), type: 'RISK_DEADLINE', title: `${r.title} 处理截止日`, employeeName: r.employee?.name, employeeId: r.employeeId || undefined, actionUrl: r.actionUrl || '/', priority: r.level === 'HIGH' ? 'high' : 'medium', }) } // 6. 退休日期 const retiringEmployees = await prisma.employee.findMany({ where: { orgId, status: 'ACTIVE', retirementDaysLeft: { gte: 0, lte: 365 }, }, }) for (const emp of retiringEmployees) { if (emp.retirementDaysLeft !== null) { const retireDate = new Date() retireDate.setDate(retireDate.getDate() + emp.retirementDaysLeft) if (retireDate >= monthStart && retireDate <= monthEnd) { events.push({ date: retireDate.toISOString().slice(0, 10), type: 'RETIREMENT', title: `${emp.name} 达到法定退休年龄`, employeeName: emp.name, employeeId: emp.id, actionUrl: '/roster', priority: 'high', }) } } } // 7. 自定义日历事件 const customEvents = await prisma.calendarEvent.findMany({ where: { orgId, date: { gte: monthStart, lte: monthEnd }, }, include: { employee: { select: { name: true } } }, }) for (const ev of customEvents) { events.push({ date: ev.date.toISOString().slice(0, 10), type: ev.type, title: ev.title + (ev.employee ? ` — ${ev.employee.name}` : ''), employeeName: ev.employee?.name, actionUrl: '/dashboard', priority: ev.priority as 'high' | 'medium' | 'low', }) } // 按日期排序 events.sort((a, b) => a.date.localeCompare(b.date)) return { month, events } } /** * 人力成本深度分析:同比/环比 + 归因 */ export async function getCostAnalysis(orgId: string, month: string) { const year = parseInt(month.slice(0, 4)) const monthNum = parseInt(month.slice(5, 7)) // 当月数据 // 上月(环比) const prevMonthStart = new Date(year, monthNum - 2, 1) const prevMonthStr = `${prevMonthStart.getFullYear()}-${String(prevMonthStart.getMonth() + 1).padStart(2, '0')}` // 去年同月(同比) const lastYearMonthStart = new Date(year - 1, monthNum - 1, 1) const lastYearMonthStr = `${lastYearMonthStart.getFullYear()}-${String(lastYearMonthStart.getMonth() + 1).padStart(2, '0')}` // 获取各月归档批次汇总(按员工去重,与 getDashboardData 口径一致) const getMonthCost = async (m: string) => { const entries = await prisma.batchEntry.findMany({ where: { orgId, batch: { month: m, status: 'ARCHIVED' } }, select: { employeeId: true, totalPay: true, socialOrg: true, housingOrg: true, tax: true }, }) // 同一员工多批次只取一条(取最后一条,金额通常已合并) const empMap = new Map() for (const e of entries) { const ex = empMap.get(e.employeeId) if (!ex) { empMap.set(e.employeeId, { totalPay: e.totalPay, socialOrg: e.socialOrg, housingOrg: e.housingOrg, tax: e.tax }) } else { // 同一员工多批次:累加金额 ex.totalPay += e.totalPay ex.socialOrg += e.socialOrg ex.housingOrg += e.housingOrg ex.tax += e.tax } } const deduped = Array.from(empMap.values()) return { totalPay: deduped.reduce((s, e) => s + e.totalPay, 0), totalSocialOrg: deduped.reduce((s, e) => s + e.socialOrg, 0), totalHousingOrg: deduped.reduce((s, e) => s + e.housingOrg, 0), totalTax: deduped.reduce((s, e) => s + e.tax, 0), employeeCount: deduped.length, } } const current = await getMonthCost(month) const prev = await getMonthCost(prevMonthStr) const lastYear = await getMonthCost(lastYearMonthStr) const currentTotal = current.totalPay + current.totalSocialOrg + current.totalHousingOrg const prevTotal = prev.totalPay + prev.totalSocialOrg + prev.totalHousingOrg const lastYearTotal = lastYear.totalPay + lastYear.totalSocialOrg + lastYear.totalHousingOrg // 环比变化 const momChange = prevTotal > 0 ? ((currentTotal - prevTotal) / prevTotal) * 100 : 0 // 同比变化 const yoyChange = lastYearTotal > 0 ? ((currentTotal - lastYearTotal) / lastYearTotal) * 100 : 0 // 人均成本 const currentPerCapita = current.employeeCount > 0 ? currentTotal / current.employeeCount : 0 const prevPerCapita = prev.employeeCount > 0 ? prevTotal / prev.employeeCount : 0 const lastYearPerCapita = lastYear.employeeCount > 0 ? lastYearTotal / lastYear.employeeCount : 0 // 归因分析 const factors: Array<{ factor: string; impact: number; description: string }> = [] // 1. 人数变化影响 const headcountChange = current.employeeCount - prev.employeeCount if (headcountChange !== 0 && prevPerCapita > 0) { factors.push({ factor: '人数变化', impact: headcountChange * prevPerCapita, description: `员工人数${headcountChange > 0 ? '增加' : '减少'} ${Math.abs(headcountChange)} 人,影响成本 ¥${(headcountChange * prevPerCapita).toFixed(2)}`, }) } // 2. 薪酬水平变化 if (prevPerCapita > 0 && currentPerCapita > 0) { const perCapitaChange = currentPerCapita - prevPerCapita if (Math.abs(perCapitaChange) > 1) { factors.push({ factor: '人均薪酬变化', impact: perCapitaChange * current.employeeCount, description: `人均成本${perCapitaChange > 0 ? '上升' : '下降'} ¥${Math.abs(perCapitaChange).toFixed(2)},影响总成本 ¥${(perCapitaChange * current.employeeCount).toFixed(2)}`, }) } } // 3. 社保公积金变化 const socialChange = (current.totalSocialOrg + current.totalHousingOrg) - (prev.totalSocialOrg + prev.totalHousingOrg) if (Math.abs(socialChange) > 1) { factors.push({ factor: '社保公积金变化', impact: socialChange, description: `社保公积金${socialChange > 0 ? '增加' : '减少'} ¥${Math.abs(socialChange).toFixed(2)}`, }) } // 按部门拆分成本(按员工去重) const deptEntries = await prisma.batchEntry.findMany({ where: { orgId, batch: { month, status: 'ARCHIVED' }, }, include: { employee: { select: { department: true } } }, }) // 先按员工去重,同一员工多批次累加金额 const deptEmpMap = new Map() for (const e of deptEntries) { const dept = e.employee?.department || '未分配' const ex = deptEmpMap.get(e.employeeId) if (!ex) { deptEmpMap.set(e.employeeId, { dept, totalPay: e.totalPay, socialOrg: e.socialOrg, housingOrg: e.housingOrg }) } else { ex.totalPay += e.totalPay ex.socialOrg += e.socialOrg ex.housingOrg += e.housingOrg } } const deptMap: Record = {} for (const [, v] of deptEmpMap) { if (!deptMap[v.dept]) deptMap[v.dept] = { totalPay: 0, socialOrg: 0, housingOrg: 0, headcount: 0 } deptMap[v.dept].totalPay += v.totalPay deptMap[v.dept].socialOrg += v.socialOrg deptMap[v.dept].housingOrg += v.housingOrg deptMap[v.dept].headcount += 1 } const departmentCost = Object.entries(deptMap) .map(([dept, v]) => ({ department: dept, totalCost: v.totalPay + v.socialOrg + v.housingOrg, totalPay: v.totalPay, socialOrg: v.socialOrg, housingOrg: v.housingOrg, headcount: v.headcount, perCapita: v.headcount > 0 ? (v.totalPay + v.socialOrg + v.housingOrg) / v.headcount : 0, })) .sort((a, b) => b.totalCost - a.totalCost) return { month, current: { totalCost: currentTotal, totalPay: current.totalPay, socialOrg: current.totalSocialOrg, housingOrg: current.totalHousingOrg, tax: current.totalTax, employeeCount: current.employeeCount, perCapita: currentPerCapita, }, monthOnMonth: { prevMonth: prevMonthStr, prevTotal: prevTotal, prevPerCapita: prevPerCapita, change: currentTotal - prevTotal, changePercent: momChange, }, yearOnYear: { lastYearMonth: lastYearMonthStr, lastYearTotal: lastYearTotal, lastYearPerCapita: lastYearPerCapita, change: currentTotal - lastYearTotal, changePercent: yoyChange, }, factors, departmentCost, } } /** * 合规健康度评分 + AI 建议卡片流 * 5 维度:合同管理 / 规章制度 / 考勤工时 / 薪酬工资 / 社保公积金 * 评分 0-100,越高越健康 */ export async function getComplianceScore(orgId: string) { const now = new Date() const [ totalEmployees, unsignedContracts, expiringContracts, pendingRisks, highRisks, resolvedRisks, policiesWithoutPublish, attendanceUnconfirmed, unconfirmedPayslips, totalPayslips, socialConfig, housingConfig, lastMonthRisks, ] = await Promise.all([ prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }), // 未签合同:完全无合同记录的在职员工 prisma.employee.count({ where: { orgId, status: 'ACTIVE', contracts: { none: {} } } }), prisma.laborContract.count({ where: { orgId, employee: { status: 'ACTIVE' }, endDate: { gte: now, lte: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) }, }, }), prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }), prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH' } }), prisma.riskItem.count({ where: { orgId, status: 'RESOLVED' } }), prisma.policyDocument.count({ where: { orgId, status: { not: 'PUBLISHED' } } }), prisma.attendanceConfirmation.count({ where: { orgId, status: 'PENDING' } }), prisma.payslip.count({ where: { orgId, confirmedAt: null } }), prisma.payslip.count({ where: { orgId } }), prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }), prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }), prisma.riskItem.count({ where: { orgId, createdAt: { gte: new Date(now.getFullYear(), now.getMonth() - 1, 1), lte: new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59), }, }, }), ]) // 5 维度评分计算 const contractScore = Math.max(0, 100 - unsignedContracts * 15 - expiringContracts * 5) const policyScore = Math.max(0, 100 - policiesWithoutPublish * 12) const attendanceScore = Math.max(0, 100 - attendanceUnconfirmed * 8) const salaryScore = totalPayslips > 0 ? Math.max(0, 100 - Math.round((unconfirmedPayslips / totalPayslips) * 100)) : 100 const socialScore = (socialConfig ? 50 : 0) + (housingConfig ? 50 : 0) const dimensions = [ { key: 'contract', name: '合同管理', score: contractScore, todoCount: unsignedContracts + expiringContracts }, { key: 'policy', name: '规章制度', score: policyScore, todoCount: policiesWithoutPublish }, { key: 'attendance', name: '考勤工时', score: attendanceScore, todoCount: attendanceUnconfirmed }, { key: 'salary', name: '薪酬工资', score: salaryScore, todoCount: unconfirmedPayslips }, { key: 'social', name: '社保公积金', score: socialScore, todoCount: 0 }, ] const overallScore = Math.round( dimensions.reduce((sum, d) => sum + d.score, 0) / dimensions.length, ) const level = overallScore >= 85 ? 'safe' : overallScore >= 60 ? 'warning' : 'danger' const levelLabel = overallScore >= 85 ? '健康' : overallScore >= 60 ? '中等风险' : '高风险' // AI 建议卡片流:4 类分级 const suggestions: Array<{ type: 'danger' | 'warning' | 'value' | 'knowledge' title: string description: string actionUrl: string actionLabel: string estimatedLoss?: number }> = [] // 高风险卡片 if (highRisks > 0) { const topRisk = await prisma.riskItem.findFirst({ where: { orgId, status: 'PENDING', level: 'HIGH' }, include: { employee: true }, orderBy: { estimatedLoss: 'desc' }, }) if (topRisk) { suggestions.push({ type: 'danger', title: topRisk.title, description: topRisk.description, actionUrl: topRisk.actionUrl || '/roster', actionLabel: '立即处理', estimatedLoss: topRisk.estimatedLoss || 0, }) } } // 中风险卡片 if (policiesWithoutPublish > 0) { suggestions.push({ type: 'warning', title: `${policiesWithoutPublish} 项制度未完成民主程序`, description: '未公示的制度在仲裁中可能无效,建议尽快完成民主程序', actionUrl: '/policies', actionLabel: '去处理', }) } if (expiringContracts > 0) { suggestions.push({ type: 'warning', title: `${expiringContracts} 份合同即将到期`, description: '30 天内到期的合同需及时续签或终止,否则可能面临双倍工资风险', actionUrl: '/roster', actionLabel: '查看合同', }) } // 价值卡片 if (resolvedRisks > 0) { const totalSaved = await prisma.riskItem.aggregate({ where: { orgId, status: 'RESOLVED', estimatedLoss: { gt: 0 } }, _sum: { estimatedLoss: true }, }) suggestions.push({ type: 'value', title: `已规避 ${fmtMoney(totalSaved._sum.estimatedLoss || 0)} 潜在损失`, description: `累计处理 ${resolvedRisks} 项风险,有效降低仲裁败诉概率`, actionUrl: '/audit', actionLabel: '查看记录', }) } // 知识卡片 if (lastMonthRisks > 0) { suggestions.push({ type: 'knowledge', title: `上月新增 ${lastMonthRisks} 项风险`, description: '关注风险趋势变化,及时调整管理策略', actionUrl: '/', actionLabel: '查看趋势', }) } return { overallScore, level, levelLabel, dimensions, suggestions, stats: { totalEmployees, pendingRisks, highRisks, resolvedRisks, }, } } function fmtMoney(n: number): string { if (n >= 10000) return `¥${(n / 10000).toFixed(1)}万` return `¥${n.toLocaleString('zh-CN')}` } /** * 用工体检诊断:6 维度深度诊断 + 历史报告 * 维度:合同管理 / 薪酬社保 / 考勤加班 / 规章制度 / 解聘合规 / 证据链 */ export async function getHealthCheck(orgId: string) { const now = new Date() const year = now.getFullYear() const [ totalEmployees, unsignedContracts, expiringContracts, policiesWithoutPublish, totalPolicies, socialConfig, housingConfig, employeesNoSocial, overtimeExcessive, totalTerminations, completedTerminations, terminationsWithChecklist, disciplinaryRecords, attendanceRecords, trainingRecords, unconfirmedPayslips, totalPayslips, ] = await Promise.all([ prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }), // 未签合同:完全无合同记录的在职员工 prisma.employee.count({ where: { orgId, status: 'ACTIVE', contracts: { none: {} } } }), prisma.laborContract.count({ where: { orgId, employee: { status: 'ACTIVE' }, endDate: { gte: now, lte: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) }, }, }), // democracyProgress 是 JSONB,用 status 字段判断未完成 prisma.policyDocument.count({ where: { orgId, status: { not: 'PUBLISHED' } } }), prisma.policyDocument.count({ where: { orgId } }), prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }), prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }), // 社保记录按员工去重 prisma.employeeSocialInsRecord.groupBy({ by: ['employeeId'], where: { orgId, endMonth: null }, _count: true }).then(r => r.length), prisma.overtimeRecord.count({ where: { orgId, weekdayHours: { gt: 36 } } }), // 解聘合规 prisma.terminationRecord.count({ where: { orgId } }), prisma.terminationRecord.count({ where: { orgId, status: 'COMPLETED' } }), prisma.terminationRecord.count({ where: { orgId, status: 'COMPLETED', checklist: { not: {} } } }), // 证据链 prisma.disciplinaryRecord.count({ where: { orgId } }), prisma.attendanceRecord.count({ where: { orgId } }), prisma.trainingRecord.count({ where: { orgId } }), // 薪酬 prisma.payslip.count({ where: { orgId, confirmedAt: null } }), prisma.payslip.count({ where: { orgId } }), ]) // 6 维度诊断(与评分标准说明一致) const dimensions = [ { key: 'contract', name: '合同管理', score: Math.max(0, 100 - unsignedContracts * 15 - expiringContracts * 5), findings: [ unsignedContracts > 0 ? `${unsignedContracts} 名员工未签书面合同,存在双倍工资风险` : '全员已签书面合同', expiringContracts > 0 ? `${expiringContracts} 份合同 30 天内到期,需及时续签` : '无即将到期合同', ].filter(Boolean), recommendations: [ unsignedContracts > 0 ? '立即补签书面合同,超过1个月未签需支付双倍工资' : '', expiringContracts > 0 ? '提前30天发送续签意向书,保留协商证据' : '', ].filter(Boolean), }, { key: 'salary_social', name: '薪酬社保', score: Math.round( (socialConfig ? 40 : 0) + (housingConfig ? 20 : 0) + (totalPayslips > 0 ? Math.max(0, 20 - Math.round((unconfirmedPayslips / totalPayslips) * 20)) : 20) + (employeesNoSocial === 0 ? 20 : Math.max(0, 20 - employeesNoSocial * 2)) ), findings: [ socialConfig ? '社保配置已设置' : '未配置社保比例,建议尽快设置', housingConfig ? '公积金配置已设置' : '未配置公积金比例', totalPayslips > 0 ? `${unconfirmedPayslips}/${totalPayslips} 张工资条未确认` : '暂无工资条记录', employeesNoSocial > 0 ? `${employeesNoSocial} 名员工社保记录异常` : '社保缴纳记录正常', ].filter(Boolean), recommendations: [ !socialConfig ? '前往社保管理页面设置企业/个人缴纳比例' : '', !housingConfig ? '根据当地政策配置公积金比例' : '', unconfirmedPayslips > 0 ? '提醒员工及时确认工资条,保留薪酬合规证据' : '', ].filter(Boolean), }, { key: 'attendance', name: '考勤加班', score: Math.max(0, 100 - overtimeExcessive * 10), findings: [ overtimeExcessive > 0 ? `${overtimeExcessive} 条加班记录月超时36小时,存在违法风险` : '加班时长合规', attendanceRecords > 0 ? `已记录 ${attendanceRecords} 条考勤数据` : '暂无考勤记录,建议建立考勤制度', ].filter(Boolean), recommendations: [ overtimeExcessive > 0 ? '月加班不得超过36小时,建议调休或减少加班安排' : '', attendanceRecords === 0 ? '建立考勤记录制度,保留出勤证据' : '', ].filter(Boolean), }, { key: 'policy', name: '规章制度', score: totalPolicies > 0 ? Math.max(0, 100 - policiesWithoutPublish * 12) : 60, findings: [ totalPolicies === 0 ? '尚未上传任何规章制度,建议建立基础制度体系' : `已管理 ${totalPolicies} 项制度`, policiesWithoutPublish > 0 ? `${policiesWithoutPublish} 项制度未完成民主程序,仲裁中可能无效` : '所有制度已完成民主公示', ].filter(Boolean), recommendations: [ totalPolicies === 0 ? '建议至少建立员工手册、考勤制度、奖惩制度三项基础制度' : '', policiesWithoutPublish > 0 ? '尽快完成民主程序四步:起草→讨论→协商→公示' : '', ].filter(Boolean), }, { key: 'termination', name: '解聘合规', score: totalTerminations > 0 ? Math.round( (completedTerminations / totalTerminations) * 60 + (terminationsWithChecklist / Math.max(1, completedTerminations)) * 40 ) : 100, findings: [ totalTerminations > 0 ? `共 ${totalTerminations} 起解聘,${completedTerminations} 起已完成流程` : '暂无解聘记录', totalTerminations > 0 && completedTerminations < totalTerminations ? `${totalTerminations - completedTerminations} 起解聘流程未完成,存在程序瑕疵风险` : '', terminationsWithChecklist > 0 ? `${terminationsWithChecklist} 起解聘已保存合规检查清单` : totalTerminations > 0 ? '建议完善解聘合规检查清单' : '', ].filter(Boolean), recommendations: [ completedTerminations < totalTerminations ? '完成所有解聘流程审批,确保程序合法' : '', totalTerminations > 0 ? '保留解聘协议、补偿计算依据等关键证据' : '', ].filter(Boolean), }, { key: 'evidence', name: '证据链', score: totalEmployees > 0 ? Math.min(100, Math.round( ((disciplinaryRecords > 0 ? 1 : 0) * 20) + ((attendanceRecords > 0 ? 1 : 0) * 30) + ((trainingRecords > 0 ? 1 : 0) * 10) + (totalPolicies > 0 ? 20 : 0) + (totalTerminations > 0 ? 10 : 10) + (totalPayslips > 0 ? 10 : 0) )) : 100, findings: [ disciplinaryRecords > 0 ? `违纪记录 ${disciplinaryRecords} 条` : '无违纪记录', attendanceRecords > 0 ? `考勤记录 ${attendanceRecords} 条` : '无考勤记录', trainingRecords > 0 ? `培训记录 ${trainingRecords} 条` : '无培训记录', totalEmployees > 0 ? `覆盖 ${totalEmployees} 名在管员工的证据留存` : '暂无在管员工', ].filter(Boolean), recommendations: [ disciplinaryRecords === 0 ? '对违纪行为及时记录并保留证据' : '', attendanceRecords === 0 ? '建立考勤记录制度,保留出勤证据' : '', trainingRecords === 0 ? '记录员工培训情况,保留签到表' : '', ].filter(Boolean), }, ] const totalScore = Math.round(dimensions.reduce((sum, d) => sum + d.score, 0) / dimensions.length) const level = totalScore >= 85 ? 'safe' : totalScore >= 60 ? 'warning' : 'danger' const summary = level === 'safe' ? `整体用工合规状况良好(${totalScore}分),建议保持定期巡检` : level === 'warning' ? `整体用工存在中等风险(${totalScore}分),建议优先处理低分维度问题` : `整体用工存在较高风险(${totalScore}分),建议立即整改低分维度` return { year, totalScore, level, dimensions, summary, } } /** * 保存体检诊断报告 */ export async function saveHealthCheckReport(orgId: string, userId: string) { const result = await getHealthCheck(orgId) const report = await (prisma as any).healthCheckReport.create({ data: { orgId, year: result.year, totalScore: result.totalScore, level: result.level, dimensions: result.dimensions as any, summary: result.summary, createdBy: userId, }, }) return report } /** * 获取历史诊断报告列表 */ export async function getHealthCheckHistory(orgId: string) { const reports = await (prisma as any).healthCheckReport.findMany({ where: { orgId }, orderBy: { createdAt: 'desc' }, take: 10, }) return (reports as any[]).map((r: any) => ({ id: r.id, year: r.year, totalScore: r.totalScore, level: r.level, summary: r.summary, createdAt: r.createdAt.toISOString(), })) } /** * 年度价值报告:量化系统为企业创造的价值 */ export async function getAnnualValueReport(orgId: string, year: number) { const yearStart = new Date(year, 0, 1) const yearEnd = new Date(year, 11, 31, 23, 59, 59) const [ risksResolved, _lossAvoidedAgg, aiConversations, aiReviews, contractsSigned, payrollBatches, terminations, employeesManaged, auditActions, policiesPublished, monthlyRisks, employeeRiskDetails, pendingRisks, ] = await Promise.all([ prisma.riskItem.count({ where: { orgId, status: 'RESOLVED', resolvedBy: { not: null }, resolvedAt: { gte: yearStart, lte: yearEnd }, employeeId: { not: null }, }, }), prisma.riskItem.aggregate({ where: { orgId, status: 'RESOLVED', estimatedLoss: { gt: 0 }, resolvedBy: { not: null }, resolvedAt: { gte: yearStart, lte: yearEnd }, employeeId: { not: null }, }, _sum: { estimatedLoss: true }, }), prisma.aIConversation.count({ where: { orgId, createdAt: { gte: yearStart, lte: yearEnd } }, }), prisma.aIReviewRecord.count({ where: { orgId, createdAt: { gte: yearStart, lte: yearEnd } }, }), prisma.laborContract.count({ where: { orgId, signDate: { gte: yearStart, lte: yearEnd }, }, }), prisma.payrollBatch.count({ where: { orgId, createdAt: { gte: yearStart, lte: yearEnd } }, }), prisma.terminationRecord.count({ where: { orgId, createdAt: { gte: yearStart, lte: yearEnd } }, }), prisma.employee.count({ where: { orgId, OR: [ { hireDate: { gte: yearStart, lte: yearEnd } }, { status: 'ACTIVE' }, ], }, }), prisma.auditLog.count({ where: { orgId, createdAt: { gte: yearStart, lte: yearEnd } }, }), prisma.policyDocument.count({ where: { orgId, status: 'PUBLISHED', publishedAt: { gte: yearStart, lte: yearEnd }, }, }), Promise.all( Array.from({ length: 12 }, (_, m) => prisma.riskItem.count({ where: { orgId, createdAt: { gte: new Date(year, m, 1), lte: new Date(year, m + 1, 0, 23, 59, 59), }, }, }).then((count) => ({ month: m + 1, count })), ), ), // 按员工维度的人工解决风险明细(排除系统自动关闭的,只算用户真正处理过的) prisma.riskItem.findMany({ where: { orgId, status: 'RESOLVED', resolvedBy: { not: null }, resolvedAt: { gte: yearStart, lte: yearEnd }, employeeId: { not: null }, }, include: { employee: { select: { id: true, name: true, department: true, monthlySalary: true, hireDate: true, idCardHash: true }, }, }, orderBy: { estimatedLoss: 'desc' }, }), // 未解决的风险列表(PENDING状态),排除无关联员工的月度任务,按预估损失降序,最多100条 prisma.riskItem.findMany({ where: { orgId, status: 'PENDING', estimatedLoss: { gt: 0 }, employeeId: { not: null }, }, include: { employee: { select: { id: true, name: true, department: true, idCardHash: true }, }, }, orderBy: { estimatedLoss: 'desc' }, take: 100, }), ]) // 按身份证号去重(同一人可能有多条 Employee 记录),无身份证号时回退到 employeeId // 同时按风险类型去重(同一风险被重复创建解决多次,只取 estimatedLoss 最大的一条) const personBreakdown: Record }> = {} // 辅助:根据风险标题推断规避方式 function getResolutionMethod(title: string): string { if (title.includes('未签合同') && title.includes('无固定期限')) return '补签书面合同' if (title.includes('未签合同')) return '签订书面合同' if (title.includes('到期') && title.includes('未续签')) return '续签合同' if (title.includes('即将到期')) return '续签或终止合同' if (title.includes('试用期') && title.includes('不合法')) return '调整试用期条款' if (title.includes('入职手续未完成')) return '完成入职手续' if (title.includes('孕期') || title.includes('哺乳期')) return '调整岗位/安排哺乳期待遇' if (title.includes('工伤')) return '申报工伤/安排适当工作' if (title.includes('医疗期')) return '保障医疗期待遇' if (title.includes('社保')) return '补缴社保' if (title.includes('公积金')) return '补缴公积金' if (title.includes('工资') || title.includes('个税')) return '发放工资/申报个税' return '人工处理' } // 辅助:根据风险标题推断法律依据 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' if (title.includes('退休')) return 'RETIREMENT' return title.replace(/\d+/g, '').trim() } for (const r of employeeRiskDetails) { // 去重优先级:身份证号 > 姓名回退到姓名,避免同一人多条 Employee 记录被重复计算 const personKey = r.employee?.idCardHash || r.employee?.name || r.employeeId || '_unknown' if (!personBreakdown[personKey]) { personBreakdown[personKey] = { personKey, name: r.employee?.name || '未知员工', department: r.employee?.department || '-', riskCount: 0, lossAvoided: 0, adjustedLoss: 0, details: new Map(), } } const person = personBreakdown[personKey] // 按风险类别去重:同一类别只保留 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 || ''), resolutionMethod: getResolutionMethod(r.title || ''), }) } } // 汇总去重后的员工明细 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: lossAvoided, 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, event: mr.count > 0 ? `发现 ${mr.count} 项风险` : '平稳运行', value: mr.count, })) // 价值计算 // 1. 规避损失 = 已解决风险的预估损失之和(系统已发现并解决,全部潜在损失即为规避价值) // 2. 节约工时 = AI 查询 * 0.5h + 合同审查 * 1h + 算薪批次 * 2h + 制度公示 * 1h // 3. 节约成本 = 节约工时 * 100 元/h(HR 平均时薪) const adjustedLossAvoided = lossAvoided const aiTimeSaved = aiConversations * 0.5 + aiReviews * 1.0 const payrollTimeSaved = payrollBatches * 2.0 const policyTimeSaved = policiesPublished * 1.0 const contractTimeSaved = contractsSigned * 0.5 const timeSaved = Math.round(aiTimeSaved + payrollTimeSaved + policyTimeSaved + contractTimeSaved) const costSaved = timeSaved * 100 // 总价值 = 规避损失 + 节约成本 const totalValue = adjustedLossAvoided + costSaved const metrics = { risksResolved, lossAvoided, aiQueries: aiConversations, contractsReviewed: aiReviews, contractsSigned, payrollProcessed: payrollBatches, terminations, employeesManaged, auditActions, policiesPublished, } // 未解决风险按员工去重+风险类别去重(同一类别只取 estimatedLoss 最大的一条) const pendingBreakdown: Record }> = {} for (const r of pendingRisks) { const personKey = r.employee?.idCardHash || r.employee?.name || r.employeeId || '_unknown' if (!pendingBreakdown[personKey]) { pendingBreakdown[personKey] = { name: r.employee?.name || '未知员工', department: r.employee?.department || '-', riskCount: 0, totalLoss: 0, details: new Map(), } } const catKey = getRiskCategoryKey(r.title) const loss = r.estimatedLoss || 0 const existing = pendingBreakdown[personKey].details.get(catKey) if (!existing || loss > existing.estimatedLoss) { pendingBreakdown[personKey].details.set(catKey, { title: r.title, estimatedLoss: loss, legalBasis: getLegalBasis(r.title), }) } } const pendingRiskDetails = Object.values(pendingBreakdown) .map(p => { const details = Array.from(p.details.values()).sort((a, b) => b.estimatedLoss - a.estimatedLoss) return { name: p.name, department: p.department, riskCount: details.length, totalLoss: details.reduce((sum, d) => sum + d.estimatedLoss, 0), details, } }) .sort((a, b) => b.totalLoss - a.totalLoss) const summary = `${year} 年规避潜在损失 ${fmtMoney(lossAvoided)},节约 HR 工时 ${timeSaved} 小时,仍有 ${pendingRiskDetails.length} 人待处理风险` return { year, totalValue, metrics, timeline, costSaved, timeSaved, lossAvoided, adjustedLossAvoided, employeeDetails, pendingRiskDetails, summary, } } /** * 保存年度价值报告 */ export async function saveAnnualValueReport(orgId: string, userId: string, year: number) { const result = await getAnnualValueReport(orgId, year) const report = await (prisma as any).annualValueReport.create({ data: { orgId, year: result.year, totalValue: result.totalValue, metrics: result.metrics as any, timeline: result.timeline as any, costSaved: result.costSaved, timeSaved: result.timeSaved, summary: result.summary, createdBy: userId, }, }) return report } /** * 获取历史年度价值报告 */ export async function getAnnualValueReportHistory(orgId: string) { const reports = await (prisma as any).annualValueReport.findMany({ where: { orgId }, orderBy: { year: 'desc' }, take: 10, }) return (reports as any[]).map((r: any) => ({ id: r.id, year: r.year, totalValue: r.totalValue, roi: r.roi, summary: r.summary, createdAt: r.createdAt.toISOString(), })) }