feat: TurboHR 14项优化与功能增强

- #1 Dashboard风险提醒增加立刻办理按钮
- #2 Calendar月份选择器改为input month
- #3 Termination增加7种解聘原因法律依据和操作步骤
- #5 合同审查支持PDF TXT格式
- #6 AI合同审查prompt优化为具体修改建议
- #7 知识库添加更新机制说明
- #9 SpecialStatus员工选择改用all-lite接口
- #10 Termination增加详细法律条款引用
- #11 Money发薪批次增加社保公积金合计列
- #12 EmployeeAttachment扩展文件类型
- #13 花名册增加女职工干部工人选项加退休提醒
- #14 新增公司备用文件上传模块
This commit is contained in:
selfrelease
2026-08-01 13:47:49 +08:00
parent d9e2c610cf
commit f1a02f0439
20 changed files with 542 additions and 32 deletions
+63 -5
View File
@@ -1,6 +1,7 @@
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))
@@ -119,6 +120,17 @@ function estimateRiskCost(
}
}
// 退休提醒:未及时办理退休可能导致多缴社保公积金
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,
@@ -452,6 +464,49 @@ export async function detectMonthlyTasks(orgId: string) {
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) {
// 非月度风险去重:检查所有状态(含 RESOLVED/IGNORED),避免已处理的风险被重新创建
// 按 employeeId:type 归并,不依赖 actionUrlactionUrl 可能因天数变化而不同)
@@ -481,9 +536,10 @@ export async function runRiskDetection(orgId: string) {
const onboardingRisks = await detectOnboardingRisks(orgId)
const monthlyTasks = await detectMonthlyTasks(orgId)
const specialStatusRisks = await detectSpecialStatusRisks(orgId)
const retirementRisks = await detectRetirementRisks(orgId)
// 获取所有相关员工数据用于风险量化
const allEmployeeIds = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks]
const allEmployeeIds = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks, ...retirementRisks]
.map(r => r.employeeId)
.filter(Boolean) as string[]
const employees = allEmployeeIds.length > 0
@@ -492,7 +548,7 @@ export async function runRiskDetection(orgId: string) {
const empMap = new Map(employees.map(e => [e.id, e]))
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks]
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks, ...specialStatusRisks, ...retirementRisks]
const toCreate = [
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}`)),
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
@@ -790,7 +846,7 @@ export async function getDashboardData(orgId: string) {
const daysUntilDeadline = r.deadline ? daysBetween(r.deadline, new Date()) : null
return {
id: r.id,
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' | 'RETIREMENT',
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
priority,
title: r.title,
@@ -821,6 +877,7 @@ export async function getDashboardData(orgId: string) {
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()
}
@@ -872,7 +929,7 @@ export async function getDashboardData(orgId: string) {
const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({
id: r.id,
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY' | 'ONBOARDING' | 'RETIREMENT',
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
title: r.title,
description: r.description,
@@ -906,7 +963,7 @@ export async function getDashboardData(orgId: string) {
greeting,
stats: {
employeeCount,
highRiskCount: dedupedTodos.filter((t) => t.level === 'high' && (t.type === 'CONTRACT' || t.type === 'TERMINATION')).length,
highRiskCount: dedupedTodos.filter((t) => t.level === 'high' && (t.type === 'CONTRACT' || t.type === 'TERMINATION' || t.type === 'RETIREMENT')).length,
todoCount: todos.length,
monthlyOvertimePay,
},
@@ -1818,6 +1875,7 @@ export async function getAnnualValueReport(orgId: string, year: number) {
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()
}