e02e3dcd81
- 创建 salary.routes.ts,提供薪酬分析数据 - 基于 Payslip 模型计算部门对比、月度趋势、同比环比 - 在 app.ts 中挂载 /api/v1/salary 路由
89 lines
3.2 KiB
TypeScript
89 lines
3.2 KiB
TypeScript
import { Router } from 'express'
|
|
import prisma from '../lib/prisma'
|
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
|
|
|
const router = Router()
|
|
|
|
router.get('/dashboard', authMiddleware, async (req: AuthRequest, res) => {
|
|
try {
|
|
const orgId = req.user!.orgId
|
|
const year = Number(req.query.year) || new Date().getFullYear()
|
|
const yearPrefix = `${year}-`
|
|
|
|
// 获取该年度所有工资条
|
|
const payslips = await prisma.payslip.findMany({
|
|
where: {
|
|
orgId,
|
|
month: { startsWith: yearPrefix },
|
|
},
|
|
include: {
|
|
employee: { select: { department: true } },
|
|
},
|
|
})
|
|
|
|
// 概览
|
|
const totalEmployees = new Set(payslips.map(p => p.employeeId)).size
|
|
const allNetPays = payslips.map(p => p.netPay).sort((a, b) => a - b)
|
|
const avgSalary = payslips.length > 0 ? payslips.reduce((s, p) => s + p.netPay, 0) / payslips.length : 0
|
|
const medianSalary = allNetPays.length > 0
|
|
? allNetPays.length % 2 === 0
|
|
? (allNetPays[allNetPays.length / 2 - 1] + allNetPays[allNetPays.length / 2]) / 2
|
|
: allNetPays[Math.floor(allNetPays.length / 2)]
|
|
: 0
|
|
const totalAnnual = payslips.reduce((s, p) => s + p.netPay, 0)
|
|
|
|
// 同比:与上一年度对比
|
|
const prevYearPrefix = `${year - 1}-`
|
|
const prevPayslips = await prisma.payslip.findMany({
|
|
where: {
|
|
orgId,
|
|
month: { startsWith: prevYearPrefix },
|
|
},
|
|
})
|
|
const prevTotal = prevPayslips.reduce((s, p) => s + p.netPay, 0)
|
|
const yoy = prevTotal > 0 ? ((totalAnnual - prevTotal) / prevTotal) * 100 : undefined
|
|
|
|
// 环比:与上个月对比(12月 vs 11月)
|
|
const decPayslips = payslips.filter(p => p.month === `${year}-12`)
|
|
const novPayslips = payslips.filter(p => p.month === `${year}-11`)
|
|
const decTotal = decPayslips.reduce((s, p) => s + p.netPay, 0)
|
|
const novTotal = novPayslips.reduce((s, p) => s + p.netPay, 0)
|
|
const mom = novTotal > 0 ? ((decTotal - novTotal) / novTotal) * 100 : undefined
|
|
|
|
// 部门薪酬对比
|
|
const deptMap = new Map<string, { count: number; total: number }>()
|
|
for (const p of payslips) {
|
|
const dept = p.employee?.department || '未分配'
|
|
const cur = deptMap.get(dept) || { count: 0, total: 0 }
|
|
cur.count += 1
|
|
cur.total += p.netPay
|
|
deptMap.set(dept, cur)
|
|
}
|
|
const departments = Array.from(deptMap.entries())
|
|
.map(([name, v]) => ({ name, count: v.count, avgSalary: v.count > 0 ? v.total / v.count : 0 }))
|
|
.sort((a, b) => b.avgSalary - a.avgSalary)
|
|
|
|
// 月度趋势
|
|
const monthMap = new Map<string, number>()
|
|
for (const p of payslips) {
|
|
monthMap.set(p.month, (monthMap.get(p.month) || 0) + p.netPay)
|
|
}
|
|
const monthlyTrend = Array.from(monthMap.entries())
|
|
.map(([month, total]) => ({ month: month.slice(5), total }))
|
|
.sort((a, b) => a.month.localeCompare(b.month))
|
|
|
|
res.json({
|
|
success: true,
|
|
data: {
|
|
summary: { totalEmployees, avgSalary, medianSalary, totalAnnual, yoy, mom },
|
|
departments,
|
|
monthlyTrend,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
res.status(500).json({ success: false, error: { code: 'INTERNAL_ERROR', message: '获取薪酬分析数据失败' } })
|
|
}
|
|
})
|
|
|
|
export default router
|