Files
TurboHR/backend/src/routes/dashboard.routes.ts
T
freedakgmail 2968484d2d 优化: 大文件拆分+代码分割+按需加载+console清理+any类型替换
- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割
- AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割
- xlsx改为动态导入, OvertimeTab从345KB降至12.7KB
- api-services.ts: 请求参数 any→Record<string,unknown>
- 移除前端3处console.log残留
- 后端console替换为pino logger
- 前后端未使用import/变量清理
- Zod schema验证: termination/platform/special-status/work-process
- 新增 leave.routes.ts, acceptance-test.routes.ts
- UI组件: PageGuide, QueryError, Stepper
2026-08-04 07:53:37 +08:00

516 lines
17 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,
})
// 按优先级分组
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',
})),
},
]
// 过滤空分类
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