feat: AIHR 智能人力资源管理系统初始提交

- 员工花名册管理(加密存储、导入导出)
- 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条)
- 社保公积金(多城市配置、版本管理、基数调整)
- 解聘管理(6步流程、证据链、工作交接)
- AI 助手(合同审查、风险预测、RAG 知识库)
- Dashboard 仪表盘
- 设置与通知
This commit is contained in:
selfrelease
2026-07-24 13:53:11 +08:00
commit 0df8aa77d9
109 changed files with 38190 additions and 0 deletions
+577
View File
@@ -0,0 +1,577 @@
import { Router, Response, NextFunction } from 'express'
import prisma from '../lib/prisma'
import { decrypt } from '../lib/crypto'
import { authMiddleware, AuthRequest } from '../middleware/auth'
import { z } from 'zod'
const router = Router()
router.use(authMiddleware)
// ========== 加班费记录 ==========
const overtimeSchema = z.object({
employeeId: z.string().min(1),
month: z.string().regex(/^\d{4}-\d{2}$/),
monthlyWage: z.number().positive(),
weekdayHours: z.number().min(0).default(0),
weekendHours: z.number().min(0).default(0),
holidayHours: z.number().min(0).default(0),
})
// 获取加班费记录列表
router.get('/overtime', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeId, month } = req.query
const records = await prisma.overtimeRecord.findMany({
where: {
orgId: req.user!.orgId,
...(employeeId ? { employeeId: String(employeeId) } : {}),
...(month ? { month: String(month) } : {}),
},
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: { createdAt: 'desc' },
})
res.json({ success: true, data: records })
} catch (err) {
next(err)
}
})
// 保存加班费记录
router.post('/overtime', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = overtimeSchema.parse(req.body)
const hourlyWage = data.monthlyWage / 21.75 / 8
const weekdayPay = hourlyWage * 1.5 * data.weekdayHours
const weekendPay = hourlyWage * 2.0 * data.weekendHours
const holidayPay = hourlyWage * 3.0 * data.holidayHours
const totalPay = weekdayPay + weekendPay + holidayPay
const record = await prisma.overtimeRecord.upsert({
where: {
employeeId_month: { employeeId: data.employeeId, month: data.month },
},
update: {
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
weekdayPay,
weekendPay,
holidayPay,
totalPay,
},
create: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
month: data.month,
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
weekdayPay,
weekendPay,
holidayPay,
totalPay,
},
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
// 更新加班记录(按ID
const overtimeUpdateSchema = z.object({
weekdayHours: z.number().min(0).optional(),
weekendHours: z.number().min(0).optional(),
holidayHours: z.number().min(0).optional(),
monthlyWage: z.number().positive().optional(),
})
router.put('/overtime/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { id } = req.params
const data = overtimeUpdateSchema.parse(req.body)
const existing = await prisma.overtimeRecord.findUnique({ where: { id } })
if (!existing) {
res.status(404).json({ success: false, message: '记录不存在' })
return
}
const monthlyWage = data.monthlyWage ?? 0
const weekdayHours = data.weekdayHours ?? existing.weekdayHours
const weekendHours = data.weekendHours ?? existing.weekendHours
const holidayHours = data.holidayHours ?? existing.holidayHours
const hourlyWage = monthlyWage / 21.75 / 8
const weekdayPay = hourlyWage * 1.5 * weekdayHours
const weekendPay = hourlyWage * 2.0 * weekendHours
const holidayPay = hourlyWage * 3.0 * holidayHours
const totalPay = weekdayPay + weekendPay + holidayPay
const record = await prisma.overtimeRecord.update({
where: { id },
data: {
weekdayHours,
weekendHours,
holidayHours,
weekdayPay,
weekendPay,
holidayPay,
totalPay,
},
})
res.json({ success: true, data: record })
} catch (err) {
next(err)
}
})
// ========== 工资条管理 ==========
const payslipSchema = z.object({
employeeId: z.string().min(1),
month: z.string().regex(/^\d{4}-\d{2}$/),
baseSalary: z.number().min(0).default(0),
overtimePay: z.number().min(0).default(0),
weekdayOvertimePay: z.number().min(0).default(0),
weekendOvertimePay: z.number().min(0).default(0),
holidayOvertimePay: z.number().min(0).default(0),
allowance: z.number().min(0).default(0),
deduction: z.number().min(0).default(0),
})
// 获取工资条列表
router.get('/payslip', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, employeeId } = req.query
const payslips = await prisma.payslip.findMany({
where: {
orgId: req.user!.orgId,
...(month ? { month: String(month) } : {}),
...(employeeId ? { employeeId: String(employeeId) } : {}),
},
include: { employee: { select: { id: true, name: true, department: true } } },
orderBy: [{ month: 'desc' }, { employee: { name: 'asc' } }],
})
res.json({ success: true, data: payslips })
} catch (err) {
next(err)
}
})
// 创建/更新工资条
router.post('/payslip', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = payslipSchema.parse(req.body)
const totalPay = data.baseSalary + data.overtimePay + data.allowance - data.deduction
const payslip = await prisma.payslip.upsert({
where: {
employeeId_month: { employeeId: data.employeeId, month: data.month },
},
update: {
baseSalary: data.baseSalary,
overtimePay: data.overtimePay,
weekdayOvertimePay: data.weekdayOvertimePay,
weekendOvertimePay: data.weekendOvertimePay,
holidayOvertimePay: data.holidayOvertimePay,
allowance: data.allowance,
deduction: data.deduction,
totalPay,
},
create: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
month: data.month,
baseSalary: data.baseSalary,
overtimePay: data.overtimePay,
weekdayOvertimePay: data.weekdayOvertimePay,
weekendOvertimePay: data.weekendOvertimePay,
holidayOvertimePay: data.holidayOvertimePay,
allowance: data.allowance,
deduction: data.deduction,
totalPay,
},
})
res.json({ success: true, data: payslip })
} catch (err) {
next(err)
}
})
// 从加班费记录自动生成工资条
router.post('/payslip/generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, employeeId, baseSalary, allowance, deduction } = req.body as {
month: string
employeeId: string
baseSalary: number
allowance?: number
deduction?: number
}
const overtime = await prisma.overtimeRecord.findUnique({
where: { employeeId_month: { employeeId, month } },
})
const overtimePay = overtime?.totalPay || 0
const totalPay = baseSalary + overtimePay + (allowance || 0) - (deduction || 0)
const payslip = await prisma.payslip.upsert({
where: { employeeId_month: { employeeId, month } },
update: {
baseSalary,
overtimePay,
weekdayOvertimePay: overtime?.weekdayPay || 0,
weekendOvertimePay: overtime?.weekendPay || 0,
holidayOvertimePay: overtime?.holidayPay || 0,
allowance: allowance || 0,
deduction: deduction || 0,
totalPay,
},
create: {
orgId: req.user!.orgId,
employeeId,
month,
baseSalary,
overtimePay,
weekdayOvertimePay: overtime?.weekdayPay || 0,
weekendOvertimePay: overtime?.weekendPay || 0,
holidayOvertimePay: overtime?.holidayPay || 0,
allowance: allowance || 0,
deduction: deduction || 0,
totalPay,
},
})
res.json({ success: true, data: payslip })
} catch (err) {
next(err)
}
})
// 删除工资条
router.delete('/payslip/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await prisma.payslip.delete({
where: { id: req.params.id, orgId: req.user!.orgId },
})
res.json({ success: true })
} catch (err) {
next(err)
}
})
// ========== 批量生成工资条 ==========
const batchGenerateSchema = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/),
allowances: z.record(z.string(), z.number().default(0)).optional(),
deductions: z.record(z.string(), z.number().default(0)).optional(),
})
// 批量生成全员工资条
router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { month, allowances = {}, deductions = {} } = batchGenerateSchema.parse(req.body)
const employees = await prisma.employee.findMany({
where: { orgId: req.user!.orgId, status: 'ACTIVE' },
include: {
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
},
})
const results: any[] = []
for (const emp of employees) {
const overtime = await prisma.overtimeRecord.findUnique({
where: { employeeId_month: { employeeId: emp.id, month } },
})
const overtimePay = overtime?.totalPay || 0
const allowance = allowances[emp.id] || 0
const deduction = deductions[emp.id] || 0
let baseSalary = 0
if (emp.contracts[0]?.probationSalary && new Date(emp.contracts[0].startDate) > new Date(Date.now() - 365 * 24 * 60 * 60 * 1000)) {
baseSalary = emp.contracts[0].probationSalary
} else if (emp.monthlySalary) {
try {
baseSalary = Number(decrypt(emp.monthlySalary)) || 0
} catch {
baseSalary = Number(emp.monthlySalary) || 0
}
}
const totalPay = baseSalary + overtimePay + allowance - deduction
const payslip = await prisma.payslip.upsert({
where: { employeeId_month: { employeeId: emp.id, month } },
update: { baseSalary, overtimePay, allowance, deduction, totalPay },
create: {
orgId: req.user!.orgId,
employeeId: emp.id,
month,
baseSalary,
overtimePay,
weekdayOvertimePay: overtime?.weekdayPay || 0,
weekendOvertimePay: overtime?.weekendPay || 0,
holidayOvertimePay: overtime?.holidayPay || 0,
allowance,
deduction,
totalPay,
},
})
results.push(payslip)
}
res.json({ success: true, data: { generated: results.length, payslips: results } })
} catch (err) {
next(err)
}
})
// ========== 加班费计算规则配置 ==========
const overtimeConfigSchema = z.object({
weekdayRate: z.number().min(1).default(1.5),
weekendRate: z.number().min(1).default(2.0),
holidayRate: z.number().min(1).default(3.0),
monthlyDays: z.number().min(1).default(21.75),
dailyHours: z.number().min(1).default(8),
})
// 获取加班费计算规则
router.get('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
let config = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } })
if (!config) {
config = await prisma.overtimeConfig.create({ data: { orgId: req.user!.orgId } })
}
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// 保存加班费计算规则
router.post('/overtime/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const data = overtimeConfigSchema.parse(req.body)
const config = await prisma.overtimeConfig.upsert({
where: { orgId: req.user!.orgId },
update: data,
create: { orgId: req.user!.orgId, ...data },
})
res.json({ success: true, data: config })
} catch (err) {
next(err)
}
})
// ========== 批量导入加班工时 ==========
const batchOvertimeSchema = z.array(
z.object({
employeeId: z.string().min(1),
month: z.string().regex(/^\d{4}-\d{2}$/),
weekdayHours: z.number().min(0).default(0),
weekendHours: z.number().min(0).default(0),
holidayHours: z.number().min(0).default(0),
}),
)
router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const items = batchOvertimeSchema.parse(req.body)
const results: any[] = []
for (const data of items) {
const record = await prisma.overtimeRecord.upsert({
where: { employeeId_month: { employeeId: data.employeeId, month: data.month } },
update: {
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
weekdayPay: 0, weekendPay: 0, holidayPay: 0, totalPay: 0,
},
create: {
orgId: req.user!.orgId,
employeeId: data.employeeId,
month: data.month,
weekdayHours: data.weekdayHours,
weekendHours: data.weekendHours,
holidayHours: data.holidayHours,
},
})
results.push(record)
}
res.json({ success: true, data: { imported: results.length } })
} catch (err) {
next(err)
}
})
// ========== 批次导入加班费 ==========
router.post('/overtime/import-to-batch/:batchId', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const orgId = req.user!.orgId
const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId } })
if (!batch) return res.status(404).json({ success: false, message: '批次不存在' })
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, message: '已归档批次不可操作' })
// 获取加班费计算规则
let config = await prisma.overtimeConfig.findUnique({ where: { orgId } })
if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } })
// 获取该月未关联批次的加班记录
const overtimeRecords = await prisma.overtimeRecord.findMany({
where: { orgId, month: batch.month, batchId: null },
include: { employee: { select: { id: true, name: true, monthlySalary: true } } },
})
if (overtimeRecords.length === 0) {
return res.json({ success: false, message: '没有可导入的加班记录(所有记录已关联批次或无数据)' })
}
const results: any[] = []
for (const ot of overtimeRecords) {
// 获取员工月工资
let monthlyWage = 0
try {
monthlyWage = ot.employee.monthlySalary ? Number(decrypt(ot.employee.monthlySalary)) : 0
} catch {
monthlyWage = Number(ot.employee.monthlySalary) || 0
}
if (!monthlyWage) continue
// 根据规则计算加班费
const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours
const weekdayPay = hourlyWage * config.weekdayRate * ot.weekdayHours
const weekendPay = hourlyWage * config.weekendRate * ot.weekendHours
const holidayPay = hourlyWage * config.holidayRate * ot.holidayHours
const totalPay = weekdayPay + weekendPay + holidayPay
// 更新加班记录:计算金额并锁定到批次
await prisma.overtimeRecord.update({
where: { id: ot.id },
data: { weekdayPay, weekendPay, holidayPay, totalPay, batchId },
})
// 更新批次条目的加班费
const entry = await prisma.batchEntry.findUnique({
where: { batchId_employeeId: { batchId, employeeId: ot.employeeId } },
})
if (entry) {
await prisma.batchEntry.update({
where: { id: entry.id },
data: { overtimePay: totalPay },
})
// 重新计算条目
const newTotalPay = entry.baseSalary + totalPay + entry.allowance + entry.bonus - entry.deduction
await prisma.batchEntry.update({
where: { id: entry.id },
data: { totalPay: newTotalPay },
})
}
results.push({ employeeId: ot.employeeId, employeeName: ot.employee.name, totalPay })
}
res.json({ success: true, data: { imported: results.length, details: results } })
} catch (err) {
next(err)
}
})
// ========== 税率试算 ==========
router.post('/tax-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeId, month, baseSalary, overtimePay, allowance, deduction, bonus, specialDeduction } = req.body
const orgId = req.user!.orgId
// 获取员工和配置
const [employee, socialConfig, housingConfig] = await Promise.all([
employeeId ? prisma.employee.findFirst({ where: { id: employeeId, orgId } }) : null,
prisma.socialInsuranceConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
prisma.housingFundConfig.findFirst({
where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] },
orderBy: { effectiveFrom: 'desc' },
}),
])
const emp = employee || { socialInsBase: baseSalary, housingFundBase: baseSalary }
const socialBase = emp.socialInsBase || baseSalary
const housingBase = emp.housingFundBase || baseSalary
// 计算社保公积金
let socialEmp = 0, housingEmp = 0
if (socialConfig) {
const { calcSocialInsurance } = await import('../services/payroll.service')
const social = calcSocialInsurance(socialBase, socialConfig)
socialEmp = social.socialEmp
}
if (housingConfig) {
const { calcHousingFund } = await import('../services/payroll.service')
const housing = calcHousingFund(housingBase, housingConfig)
housingEmp = housing.housingEmp
}
// 获取 YTD 数据计算累计个税
const year = month.slice(0, 4)
const ytdPayslips = employeeId
? await prisma.payslip.findMany({
where: { employeeId, month: { startsWith: year }, status: 'PUBLISHED' },
orderBy: { month: 'asc' },
})
: []
const ytdTaxableIncome = ytdPayslips.reduce((sum, p) => sum + (p.totalPay - p.deduction - socialEmp - housingEmp - (specialDeduction || 0)), 0)
const ytdTaxDeducted = ytdPayslips.reduce((sum, p) => sum + (p.tax || 0), 0)
const { calcCumulativeTax } = await import('../services/payroll.service')
const totalPay = (baseSalary || 0) + (overtimePay || 0) + (allowance || 0) - (deduction || 0) + (bonus || 0)
const taxableIncome = totalPay - socialEmp - housingEmp - (specialDeduction || 0)
const tax = calcCumulativeTax(ytdTaxableIncome + taxableIncome, ytdTaxDeducted)
const netPay = totalPay - socialEmp - housingEmp - tax
res.json({
success: true,
data: {
baseSalary: baseSalary || 0,
overtimePay: overtimePay || 0,
allowance: allowance || 0,
deduction: deduction || 0,
bonus: bonus || 0,
totalPay,
socialEmp,
housingEmp,
specialDeduction: specialDeduction || 0,
taxableIncome,
estimatedTax: tax,
netPay,
ytdPayslipCount: ytdPayslips.length,
breakdown: [
{ label: '应发合计', value: totalPay },
{ label: '个人社保', value: -socialEmp },
{ label: '个人公积金', value: -housingEmp },
{ label: '专项附加扣除', value: -(specialDeduction || 0) },
{ label: '应纳税所得额', value: taxableIncome },
{ label: '当月个税', value: -tax },
{ label: '实发工资', value: netPay },
],
},
})
} catch (err) {
next(err)
}
})
export default router