2968484d2d
- 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
516 lines
17 KiB
TypeScript
516 lines
17 KiB
TypeScript
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
|