Files
TurboHR/backend/src/routes/dashboard.routes.ts
T
freedakgmail 6ec939dc7f feat: 发薪日期多选+提前N天提醒+电子签设置区域修复
- Schema: 去掉 payrollFrequency,新增 payrollDays (JSON数组) + payrollReminderDays (Int)
- 设置页: 发薪日期改为1-28号多选按钮,新增提前提醒天数设置
- 设置页: 恢复电子签署设置区域(3个开关:规章制度/工资条/入职文件)
- 工作日历: 发薪日期作为 PAYROLL_DAY 事件显示
- 工作台: 提前N天提醒发薪日期,N可配置
- TaskCenter: 新增发薪提醒分类图标
- seed文件: 更新为 payrollDays 格式
2026-08-05 08:04:39 +08:00

558 lines
19 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 { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { getDashboardData, getMonthlyCalendar, getCostAnalysis, getHealthCheck, saveHealthCheckReport, getHealthCheckHistory, getAnnualValueReport, saveAnnualValueReport, getAnnualValueReportHistory } from '../services/risk.service'
import { z } from 'zod'
const router = Router()
router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
try {
const data = await getDashboardData(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 标记待办为已完成
router.patch('/todos/:id/resolve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const item = await prisma.riskItem.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
if (item.count === 0) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '待办不存在或已处理' } })
}
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 忽略待办
router.patch('/todos/:id/ignore', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const item = await prisma.riskItem.updateMany({
where: { id: req.params.id, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'IGNORED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
if (item.count === 0) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '待办不存在或已处理' } })
}
res.json({ success: true })
} catch (err) {
next(err)
}
})
// 批量标记待办为已完成
router.patch('/todos/batch-resolve', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({ ids: z.array(z.string()) })
const { ids } = schema.parse(req.body)
const result = await prisma.riskItem.updateMany({
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
res.json({ success: true, data: { count: result.count } })
} catch (err) {
next(err)
}
})
// 批量忽略待办
router.patch('/todos/batch-ignore', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const schema = z.object({ ids: z.array(z.string()) })
const { ids } = schema.parse(req.body)
const result = await prisma.riskItem.updateMany({
where: { id: { in: ids }, orgId: req.user!.orgId, status: 'PENDING' },
data: { status: 'IGNORED', resolvedAt: new Date(), resolvedBy: req.user!.id },
})
res.json({ success: true, data: { count: result.count } })
} catch (err) {
next(err)
}
})
// HR 月度日历
router.get('/calendar', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const data = await getMonthlyCalendar(req.user!.orgId, month)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 人力成本深度分析
router.get('/cost-analysis', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
const data = await getCostAnalysis(req.user!.orgId, month)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 合规健康度评分 — 已统一为 getHealthCheck 口径
router.get('/compliance-score', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getHealthCheck(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 用工体检诊断 — 获取当前诊断
router.get('/health-check', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getHealthCheck(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 用工体检诊断 — 保存报告
router.post('/health-check/save', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const report = await saveHealthCheckReport(req.user!.orgId, req.user!.id)
res.json({ success: true, data: { id: (report as any).id } })
} catch (err) {
next(err)
}
})
// 用工体检诊断 — 历史报告
router.get('/health-check/history', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getHealthCheckHistory(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 年度价值报告 — 获取当年报告
router.get('/annual-value', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const year = parseInt(req.query.year as string) || new Date().getFullYear()
const data = await getAnnualValueReport(req.user!.orgId, year)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 年度价值报告 — 保存
router.post('/annual-value/save', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const year = parseInt(req.body.year) || new Date().getFullYear()
const report = await saveAnnualValueReport(req.user!.orgId, req.user!.id, year)
res.json({ success: true, data: { id: (report as any).id } })
} catch (err) {
next(err)
}
})
// 年度价值报告 — 历史
router.get('/annual-value/history', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getAnnualValueReportHistory(req.user!.orgId)
res.json({ success: true, data })
} catch (err) {
next(err)
}
})
// 人力信息总览 — 员工分布统计
router.get('/workforce-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const employees = await prisma.employee.findMany({
where: { orgId, status: 'ACTIVE' },
select: { gender: true, birthDate: true, hireDate: true, education: true },
})
const now = new Date()
// 性别分布
const genderDist: Record<string, number> = {}
for (const e of employees) {
const g = e.gender || '未知'
genderDist[g] = (genderDist[g] || 0) + 1
}
// 年龄段分布
const ageRanges = ['<25', '25-30', '31-35', '36-40', '41-50', '>50']
const ageDist: Record<string, number> = {}
for (const r of ageRanges) ageDist[r] = 0
for (const e of employees) {
if (!e.birthDate) continue
const age = now.getFullYear() - e.birthDate.getFullYear()
if (age < 25) ageDist['<25']++
else if (age <= 30) ageDist['25-30']++
else if (age <= 35) ageDist['31-35']++
else if (age <= 40) ageDist['36-40']++
else if (age <= 50) ageDist['41-50']++
else ageDist['>50']++
}
// 学历分布
const eduDist: Record<string, number> = {}
for (const e of employees) {
const edu = e.education || '未知'
eduDist[edu] = (eduDist[edu] || 0) + 1
}
// 司龄分布
const tenureRanges = ['<1年', '1-3年', '3-5年', '5-10年', '>10年']
const tenureDist: Record<string, number> = {}
for (const r of tenureRanges) tenureDist[r] = 0
for (const e of employees) {
const years = (now.getTime() - e.hireDate.getTime()) / (365.25 * 24 * 3600 * 1000)
if (years < 1) tenureDist['<1年']++
else if (years < 3) tenureDist['1-3年']++
else if (years < 5) tenureDist['3-5年']++
else if (years < 10) tenureDist['5-10年']++
else tenureDist['>10年']++
}
res.json({
success: true,
data: {
total: employees.length,
gender: Object.entries(genderDist).map(([name, value]) => ({ name, value })),
age: Object.entries(ageDist).map(([name, value]) => ({ name, value })),
education: Object.entries(eduDist).map(([name, value]) => ({ name, value })),
tenure: Object.entries(tenureDist).map(([name, value]) => ({ name, value })),
},
})
} catch (err) {
next(err)
}
})
// 工作台下一步行动 — 聚合待办任务、草稿批次、到期合同、特殊状态
router.get('/workspace/next-actions', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const now = new Date()
const in30Days = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
// 1. 待办风险项(使用 getDashboardData 的去重 todos,与概览一致)
const dashboardData = await getDashboardData(orgId)
const dedupedTodos = dashboardData.todos
// 2. 草稿发薪批次
const draftBatches = await prisma.payrollBatch.findMany({
where: { orgId, status: 'DRAFT' },
orderBy: { createdAt: 'desc' },
take: 5,
select: { id: true, name: true, month: true, type: true, employeeCount: true, totalPay: true },
})
// 3. 即将到期合同(30天内)
const expiringContracts = await prisma.laborContract.findMany({
where: {
orgId,
endDate: { gte: now, lte: in30Days },
employee: { status: 'ACTIVE' },
},
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { endDate: 'asc' },
take: 10,
})
// 4. 特殊状态员工
const specialStatusEmployees = await prisma.employee.findMany({
where: {
orgId,
status: 'ACTIVE',
OR: [
{ isPregnant: true },
{ isInMedicalPeriod: true },
{ isWorkInjured: true },
],
},
select: { id: true, name: true, department: true, isPregnant: true, isInMedicalPeriod: true, isWorkInjured: true },
take: 10,
})
// 5. 发薪日提前提醒
const org = await prisma.organization.findUnique({
where: { id: orgId },
select: { payrollDays: true, payrollReminderDays: true },
})
const payrollDays = Array.isArray(org?.payrollDays) ? org.payrollDays as number[] : []
const reminderDays = org?.payrollReminderDays ?? 3
const payrollReminderItems: any[] = []
const currentYear = now.getFullYear()
const currentMonth = now.getMonth() // 0-indexed
for (const day of payrollDays) {
// 本月发薪日
const thisMonthPayday = new Date(currentYear, currentMonth, day)
const diffDays = Math.floor((thisMonthPayday.getTime() - now.getTime()) / 86400000)
if (diffDays >= 0 && diffDays <= reminderDays) {
payrollReminderItems.push({
id: `payroll-${currentYear}-${currentMonth + 1}-${day}`,
title: `发薪日(每月${day}号)${diffDays === 0 ? '今天' : `${diffDays}天后`}`,
subtitle: diffDays === 0 ? '今天发薪' : `还有${diffDays}`,
link: '/money',
})
}
// 下月发薪日(如果当月已过,看下月)
if (diffDays < 0) {
const nextMonthPayday = new Date(currentYear, currentMonth + 1, day)
const nextDiffDays = Math.floor((nextMonthPayday.getTime() - now.getTime()) / 86400000)
if (nextDiffDays >= 0 && nextDiffDays <= reminderDays) {
payrollReminderItems.push({
id: `payroll-${currentYear}-${currentMonth + 2}-${day}`,
title: `发薪日(下月${day}号)${nextDiffDays === 0 ? '今天' : `${nextDiffDays}天后`}`,
subtitle: `还有${nextDiffDays}`,
link: '/money',
})
}
}
}
// 按优先级分组
const actions: Array<{ category: string; priority: 'high' | 'medium' | 'low'; items: any[] }> = [
{
category: '待办事项',
priority: 'high',
items: dedupedTodos.map(t => ({
id: t.id,
title: t.title,
type: t.type,
level: t.level,
dueDate: t.deadline,
link: t.actionUrl || '/',
})),
},
{
category: '发薪批次',
priority: 'high',
items: draftBatches.map(b => ({
id: b.id,
title: `${b.name}${b.month}`,
subtitle: `${b.employeeCount}人 · 应发 ¥${(b.totalPay || 0).toLocaleString()}`,
link: '/money',
})),
},
{
category: '合同到期',
priority: 'medium',
items: expiringContracts.map(c => ({
id: c.id,
title: `${c.employee?.name} 的合同将于 ${c.endDate?.toISOString().slice(0, 10)} 到期`,
subtitle: c.employee?.department || '',
link: '/roster',
})),
},
{
category: '特殊状态',
priority: 'medium',
items: specialStatusEmployees.map(e => ({
id: e.id,
title: e.name,
subtitle: [
e.isPregnant ? '孕期' : '',
e.isInMedicalPeriod ? '医疗期' : '',
e.isWorkInjured ? '工伤' : '',
].filter(Boolean).join('、'),
link: '/special-status',
})),
},
{
category: '发薪提醒',
priority: 'medium',
items: payrollReminderItems,
},
]
// 过滤空分类
const filteredActions = actions.filter(a => a.items.length > 0)
const totalCount = filteredActions.reduce((s, a) => s + a.items.length, 0)
res.json({
success: true,
data: {
actions: filteredActions,
totalCount,
},
})
} catch (err) {
next(err)
}
})
// 入离职统计看板 — 按月聚合入职和离职人数
router.get('/turnover-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const months = parseInt(req.query.months as string) || 12
// 计算最近 N 个月的月份列表
const now = new Date()
const monthList: string[] = []
for (let i = months - 1; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
monthList.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`)
}
// 查询入职数据(按 hireDate 月份分组)
const startDate = new Date(monthList[0] + '-01')
const employees = await prisma.employee.findMany({
where: {
orgId,
OR: [
{ hireDate: { gte: startDate } },
{ status: 'RESIGNED' },
],
},
select: { id: true, name: true, hireDate: true, status: true, department: true },
})
// 查询离职记录
const terminations = await prisma.terminationRecord.findMany({
where: {
orgId,
terminationDate: { gte: startDate },
},
select: { employeeId: true, terminationDate: true, type: true, reason: true },
})
// 按月聚合
const monthlyData = monthList.map(month => {
const monthStart = new Date(month + '-01')
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1)
const hired = employees.filter(e => e.hireDate >= monthStart && e.hireDate < monthEnd).length
const left = terminations.filter(t => {
const td = t.terminationDate
return td && td >= monthStart && td < monthEnd
}).length
return { month, hired, left, net: hired - left }
})
// 汇总
const totalHired = monthlyData.reduce((s, m) => s + m.hired, 0)
const totalLeft = monthlyData.reduce((s, m) => s + m.left, 0)
const currentHeadcount = employees.filter(e => e.status !== 'RESIGNED').length
const avgHeadcount = currentHeadcount // 简化:使用当前人数
const turnoverRate = avgHeadcount > 0 ? (totalLeft / avgHeadcount * 100).toFixed(1) : '0'
res.json({
success: true,
data: {
monthly: monthlyData,
summary: {
totalHired,
totalLeft,
currentHeadcount,
turnoverRate: parseFloat(turnoverRate),
},
},
})
} catch (err) {
next(err)
}
})
// 绩效统计看板 — 按周期聚合绩效分布
router.get('/performance-stats', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
const period = (req.query.period as string) || new Date().toISOString().slice(0, 4) // 默认当前年
const records = await prisma.performanceRecord.findMany({
where: {
orgId,
period: { startsWith: period },
},
include: { employee: { select: { name: true, department: true } } },
orderBy: { createdAt: 'desc' },
})
// 按等级分布
const gradeDist: Record<string, number> = {}
// 按部门分布
const deptDist: Record<string, { count: number; avgScore: number; scores: number[] }> = {}
// 按周期分布
const periodDist: Record<string, { count: number; avgScore: number; scores: number[] }> = {}
for (const r of records) {
// 等级分布
const grade = r.grade || '未评级'
gradeDist[grade] = (gradeDist[grade] || 0) + 1
// 部门分布
const dept = r.employee?.department || '未分配'
if (!deptDist[dept]) deptDist[dept] = { count: 0, avgScore: 0, scores: [] }
deptDist[dept].count++
if (r.score) deptDist[dept].scores.push(r.score)
// 周期分布
if (!periodDist[r.period]) periodDist[r.period] = { count: 0, avgScore: 0, scores: [] }
periodDist[r.period].count++
if (r.score) periodDist[r.period].scores.push(r.score)
}
// 计算平均分
const calcAvg = (d: typeof deptDist) => Object.entries(d).map(([name, v]) => ({
name,
count: v.count,
avgScore: v.scores.length > 0 ? Math.round(v.scores.reduce((s, x) => s + x, 0) / v.scores.length * 10) / 10 : 0,
}))
const allScores = records.map(r => r.score).filter(Boolean) as number[]
const overallAvg = allScores.length > 0 ? Math.round(allScores.reduce((s, x) => s + x, 0) / allScores.length * 10) / 10 : 0
res.json({
success: true,
data: {
total: records.length,
overallAvgScore: overallAvg,
gradeDistribution: Object.entries(gradeDist).map(([name, value]) => ({ name, value })),
departmentDistribution: calcAvg(deptDist),
periodDistribution: calcAvg(periodDist),
},
})
} catch (err) {
next(err)
}
})
// 风险中心 — 获取所有待处理风险项(与工作台同口径,使用去重后的 todos)
router.get('/risks', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = await getDashboardData(req.user!.orgId)
// todos 已按员工+类别去重,与工作台待办数量一致
const risks = (data.todos || []).map((t: any) => ({
id: t.id,
type: t.type,
level: t.level.toUpperCase(),
status: 'PENDING',
title: t.title,
description: t.description || '',
actionUrl: t.actionUrl || '',
estimatedLoss: t.estimatedLoss || 0,
deadline: t.deadline || null,
employee: t.employeeName ? { name: t.employeeName, department: t.employeeDepartment || '' } : null,
}))
res.json({ success: true, data: risks })
} catch (err) {
next(err)
}
})
export default router