4d067e476e
原维度(合同管理/规章制度/社会保险/背景调查/工作时间/保密竞业)改为 标准维度(合同管理/薪酬社保/考勤加班/规章制度/解聘合规/证据链) - 合同管理:未签合同+到期预警 - 薪酬社保:社保配置+公积金+工资条确认率+社保缴纳异常 - 考勤加班:加班超时+考勤记录完整性 - 规章制度:制度公示+民主程序 - 解聘合规:解聘流程完成率+合规检查清单 - 证据链:违纪/考勤/培训记录+制度+解聘+工资条覆盖度
2164 lines
83 KiB
TypeScript
2164 lines
83 KiB
TypeScript
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 归并,不依赖 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<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(),
|
||
}))
|
||
}
|