From e02e3dcd81ce2f929d87694c14e6a27bdc25f568 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Tue, 4 Aug 2026 07:58:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=96=B0=E5=A2=9E=E5=90=8E=E7=AB=AF=20/?= =?UTF-8?q?api/v1/salary/dashboard=20=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 salary.routes.ts,提供薪酬分析数据 - 基于 Payslip 模型计算部门对比、月度趋势、同比环比 - 在 app.ts 中挂载 /api/v1/salary 路由 --- backend/src/app.ts | 2 + backend/src/routes/salary.routes.ts | 88 +++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 backend/src/routes/salary.routes.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index eafd43e..9f9f393 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -65,6 +65,7 @@ import specialStatusRoutes from './routes/special-status.routes' import companyFileRoutes from './routes/company-file.routes' import acceptanceTestRoutes from './routes/acceptance-test.routes' import leaveRoutes from './routes/leave.routes' +import salaryRoutes from './routes/salary.routes' app.use('/api/v1/auth', authRoutes) app.use('/api/v1/dashboard', dashboardRoutes) app.use('/api/v1/employees', employeeRoutes) @@ -93,6 +94,7 @@ app.use('/api/v1/special-statuses', specialStatusRoutes) app.use('/api/v1/company-files', companyFileRoutes) app.use('/api/v1/acceptance-tests', acceptanceTestRoutes) app.use('/api/v1/leaves', leaveRoutes) +app.use('/api/v1/salary', salaryRoutes) app.use(errorHandler) diff --git a/backend/src/routes/salary.routes.ts b/backend/src/routes/salary.routes.ts new file mode 100644 index 0000000..583a444 --- /dev/null +++ b/backend/src/routes/salary.routes.ts @@ -0,0 +1,88 @@ +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() + 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() + 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