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 = {} 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 = {} 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 = {} 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 = {} 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 = {} // 按部门分布 const deptDist: Record = {} // 按周期分布 const periodDist: Record = {} 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