feat: Sprint 1 — 设计Token系统 + 新导航5分组 + AppShell/PageHeader/FilterBar/DataTable组件 + 花名册导出扩充19字段 + 社保/公积金不缴纳选项 + 个税申报表导出 + 入离职/绩效统计看板
This commit is contained in:
@@ -239,4 +239,142 @@ router.get('/workforce-stats', authMiddleware, async (req: AuthRequest, res: Res
|
||||
}
|
||||
})
|
||||
|
||||
// 入离职统计看板 — 按月聚合入职和离职人数
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
Reference in New Issue
Block a user