Files
TurboHR/backend/src/services/risk.service.ts
T
selfrelease 4d067e476e fix: 用工体检诊断6维度改为与评分标准说明一致
原维度(合同管理/规章制度/社会保险/背景调查/工作时间/保密竞业)改为
标准维度(合同管理/薪酬社保/考勤加班/规章制度/解聘合规/证据链)
- 合同管理:未签合同+到期预警
- 薪酬社保:社保配置+公积金+工资条确认率+社保缴纳异常
- 考勤加班:加班超时+考勤记录完整性
- 规章制度:制度公示+民主程序
- 解聘合规:解聘流程完成率+合规检查清单
- 证据链:违纪/考勤/培训记录+制度+解聘+工资条覆盖度
2026-08-01 15:01:15 +08:00

2164 lines
83 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>()
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 归并,不依赖 actionUrlactionUrl 可能因天数变化而不同)
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<string, typeof existingRisks[number]>()
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<string, any>()
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<string, typeof todosWithCost[number]>()
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 currentMonthStart = new Date(year, monthNum - 1, 1)
const currentMonthEnd = new Date(year, monthNum, 0, 23, 59, 59)
// 上月(环比)
const prevMonthStart = new Date(year, monthNum - 2, 1)
const prevMonthEnd = new Date(year, monthNum - 1, 0, 23, 59, 59)
const prevMonthStr = `${prevMonthStart.getFullYear()}-${String(prevMonthStart.getMonth() + 1).padStart(2, '0')}`
// 去年同月(同比)
const lastYearMonthStart = new Date(year - 1, monthNum - 1, 1)
const lastYearMonthEnd = new Date(year - 1, monthNum, 0, 23, 59, 59)
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<string, { totalPay: number; socialOrg: number; housingOrg: number; tax: number }>()
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<string, { dept: string; totalPay: number; socialOrg: number; housingOrg: number }>()
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<string, { totalPay: number; socialOrg: number; housingOrg: number; headcount: number }> = {}
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.laborContract.count({ where: { orgId, contractType: 'UNSIGNED', employee: { status: 'ACTIVE' } } }),
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.laborContract.count({ where: { orgId, contractType: 'UNSIGNED', employee: { status: 'ACTIVE' } } }),
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<string, {
personKey: string
name: string
department: string
riskCount: number
lossAvoided: number
adjustedLoss: number
details: Map<string, { title: string; estimatedLoss: number; legalBasis: string; resolutionMethod: string }>
}> = {}
// 辅助:根据风险标题推断规避方式
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
// ROI = 总价值 / 系统成本(年费 12000 元),上限 9999%
const systemCost = 12000
const rawRoi = systemCost > 0 ? Math.round((totalValue / systemCost) * 100) : 0
const roi = Math.min(rawRoi, 9999)
const metrics = {
risksResolved,
lossAvoided,
aiQueries: aiConversations,
contractsReviewed: aiReviews,
contractsSigned,
payrollProcessed: payrollBatches,
terminations,
employeesManaged,
auditActions,
policiesPublished,
}
// 未解决风险按员工去重+风险类别去重(同一类别只取 estimatedLoss 最大的一条)
const pendingBreakdown: Record<string, {
name: string
department: string
riskCount: number
totalLoss: number
details: Map<string, { title: string; estimatedLoss: number; legalBasis: string }>
}> = {}
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(),
}))
}