feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
This commit is contained in:
@@ -0,0 +1,956 @@
|
||||
import { Router, Response, NextFunction } from 'express'
|
||||
import prisma from '../lib/prisma'
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { z } from 'zod'
|
||||
|
||||
const router = Router()
|
||||
router.use(authMiddleware)
|
||||
|
||||
const socialConfigFields = {
|
||||
city: z.string().optional(),
|
||||
pensionOrg: z.number().optional(),
|
||||
pensionEmp: z.number().optional(),
|
||||
medicalOrg: z.number().optional(),
|
||||
medicalEmp: z.number().optional(),
|
||||
unemploymentOrg: z.number().optional(),
|
||||
unemploymentEmp: z.number().optional(),
|
||||
injuryOrg: z.number().optional(),
|
||||
maternityOrg: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
}
|
||||
|
||||
const housingConfigFields = {
|
||||
city: z.string().optional(),
|
||||
housingOrg: z.number().optional(),
|
||||
housingEmp: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
}
|
||||
|
||||
// 获取当前生效版本(支持按城市筛选)
|
||||
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const city = req.query.city as string | undefined
|
||||
const where: any = { orgId: req.user!.orgId, isCurrent: true }
|
||||
if (city) where.city = city
|
||||
let config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where,
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
// 未指定城市时,返回任意当前配置
|
||||
if (!config && !city) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
try {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: city || '北京',
|
||||
isCurrent: true,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// 唯一约束冲突,查询同城市任意配置
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, city: city || '北京' },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!config) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到社保配置' } })
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有城市列表(从配置中提取)
|
||||
router.get('/config/cities', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const configs = await prisma.socialInsuranceConfig.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
select: { city: true },
|
||||
distinct: ['city'],
|
||||
})
|
||||
const cities = configs.map(c => c.city).filter(Boolean)
|
||||
if (!cities.includes('北京')) cities.unshift('北京')
|
||||
res.json({ success: true, data: cities })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取所有版本列表(支持按城市筛选)
|
||||
router.get('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const city = req.query.city as string | undefined
|
||||
const where: any = { orgId: req.user!.orgId }
|
||||
if (city) where.city = city
|
||||
const versions = await prisma.socialInsuranceConfig.findMany({
|
||||
where,
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: versions })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 按月份获取适用版本
|
||||
router.get('/config/by-month/:month', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month } = req.params
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (!config) {
|
||||
// 回退到当前版本
|
||||
const current = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
})
|
||||
return res.json({ success: true, data: current })
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 新建版本(年度调基/比例变更)
|
||||
const createVersionSchema = z.object({
|
||||
...socialConfigFields,
|
||||
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
})
|
||||
|
||||
router.post('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createVersionSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 检查同一城市同一生效月份是否已有版本
|
||||
const existing = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有配置版本` })
|
||||
}
|
||||
|
||||
// 将之前当前版本标记为失效
|
||||
const prevCurrent = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, isCurrent: true },
|
||||
})
|
||||
if (prevCurrent) {
|
||||
// 计算上个版本的失效月份 = 新版本生效月份的前一个月
|
||||
const [year, mon] = data.effectiveFrom.split('-').map(Number)
|
||||
const prevMonth = mon === 1
|
||||
? `${year - 1}-12`
|
||||
: `${year}-${String(mon - 1).padStart(2, '0')}`
|
||||
await prisma.socialInsuranceConfig.update({
|
||||
where: { id: prevCurrent.id },
|
||||
data: { isCurrent: false, effectiveTo: prevMonth },
|
||||
})
|
||||
}
|
||||
|
||||
// 创建新版本
|
||||
const version = await prisma.socialInsuranceConfig.create({
|
||||
data: {
|
||||
orgId,
|
||||
...data,
|
||||
isCurrent: true,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: version })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 预览员工基数调整(返回全部在职员工,含当前基数和建议基数)
|
||||
router.get('/config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE', city: config.city },
|
||||
select: { id: true, name: true, department: true, socialInsBase: true, monthlySalary: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
// 计算上年平均工资:查询过去12个月的Payslip的totalPay平均值
|
||||
const now = new Date()
|
||||
const lastYearStart = `${now.getFullYear() - 1}-01`
|
||||
const lastYearEnd = `${now.getFullYear() - 1}-12`
|
||||
|
||||
const lastYearPayslips = await prisma.payslip.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
month: { gte: lastYearStart, lte: lastYearEnd },
|
||||
},
|
||||
select: { employeeId: true, totalPay: true },
|
||||
})
|
||||
|
||||
// 按员工汇总上年月均工资
|
||||
const avgSalaryMap = new Map<string, number>()
|
||||
const empPayslipMap = new Map<string, number[]>()
|
||||
for (const p of lastYearPayslips) {
|
||||
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
|
||||
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
|
||||
}
|
||||
for (const [empId, pays] of empPayslipMap) {
|
||||
const avg = pays.reduce((s, v) => s + v, 0) / pays.length
|
||||
avgSalaryMap.set(empId, avg)
|
||||
}
|
||||
|
||||
const items = employees.map((emp) => {
|
||||
let monthlyWage = 0
|
||||
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
|
||||
const oldSocialBase = emp.socialInsBase ?? monthlyWage
|
||||
const avgSalary = avgSalaryMap.get(emp.id) ?? monthlyWage
|
||||
const suggestedSocialBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
|
||||
return {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
oldBase: oldSocialBase,
|
||||
avgSalary,
|
||||
monthlyWage,
|
||||
suggestedBase: suggestedSocialBase,
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 执行员工基数调整(接收用户编辑后的数据)
|
||||
const adjustApplySchema = z.object({
|
||||
items: z.array(z.object({
|
||||
employeeId: z.string(),
|
||||
newBase: z.number(),
|
||||
})),
|
||||
})
|
||||
|
||||
router.post('/config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过基数调整' })
|
||||
|
||||
const { items } = adjustApplySchema.parse(req.body)
|
||||
const adjustMonth = config.effectiveFrom
|
||||
const prevAdjustMonth = (() => {
|
||||
const [y, m] = adjustMonth.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
})()
|
||||
|
||||
let adjusted = 0
|
||||
for (const item of items) {
|
||||
const socialBase = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
|
||||
|
||||
// 关闭旧社保记录
|
||||
await prisma.employeeSocialInsRecord.updateMany({
|
||||
where: { employeeId: item.employeeId, endMonth: null },
|
||||
data: { endMonth: prevAdjustMonth },
|
||||
})
|
||||
|
||||
// 创建新社保记录
|
||||
await prisma.employeeSocialInsRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
city: config.city,
|
||||
startMonth: adjustMonth,
|
||||
endMonth: null,
|
||||
base: socialBase,
|
||||
changeType: 'ADJUST',
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: item.employeeId },
|
||||
data: { socialInsBase: socialBase, socialInsStartMonth: adjustMonth },
|
||||
})
|
||||
adjusted++
|
||||
}
|
||||
|
||||
await prisma.socialInsuranceConfig.update({
|
||||
where: { id },
|
||||
data: { adjustmentDone: true },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { adjusted, total: items.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 重置社保基数调整(撤销本次调整,重新来过)
|
||||
router.post('/config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '配置版本不存在' })
|
||||
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
|
||||
|
||||
// 恢复 adjustmentDone 标志
|
||||
await prisma.socialInsuranceConfig.update({
|
||||
where: { id },
|
||||
data: { adjustmentDone: false },
|
||||
})
|
||||
|
||||
// 删除该版本创建的所有社保记录变更(按城市筛选)
|
||||
await prisma.employeeSocialInsRecord.deleteMany({
|
||||
where: {
|
||||
orgId,
|
||||
city: config.city,
|
||||
changeType: 'ADJUST',
|
||||
startMonth: config.effectiveFrom,
|
||||
},
|
||||
})
|
||||
|
||||
// 恢复员工社保基数为调整前(找到 adjustment 前的最后一条记录,按城市)
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE', city: config.city },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
for (const emp of employees) {
|
||||
const prevRecord = await prisma.employeeSocialInsRecord.findFirst({
|
||||
where: { orgId, employeeId: emp.id, city: config.city, startMonth: { lt: config.effectiveFrom } },
|
||||
orderBy: { startMonth: 'desc' },
|
||||
})
|
||||
await prisma.employee.update({
|
||||
where: { id: emp.id },
|
||||
data: {
|
||||
socialInsBase: prevRecord?.base ?? null,
|
||||
socialInsStartMonth: prevRecord?.startMonth ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ success: true, message: '社保基数调整已重置,可以重新调整' })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 社保计算(使用当前版本或指定月份版本)
|
||||
const calcSchema = z.object({
|
||||
base: z.number().positive(),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
city: z.string().optional(),
|
||||
})
|
||||
|
||||
router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { base, month, city } = calcSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
let config
|
||||
const whereBase: any = { orgId }
|
||||
if (city) whereBase.city = city
|
||||
if (month) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: {
|
||||
...whereBase,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { ...whereBase, isCurrent: true },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
|
||||
})
|
||||
}
|
||||
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
|
||||
const pensionOrg = actualBase * config.pensionOrg / 100
|
||||
const pensionEmp = actualBase * config.pensionEmp / 100
|
||||
const medicalOrg = actualBase * config.medicalOrg / 100
|
||||
const medicalEmp = actualBase * config.medicalEmp / 100
|
||||
const unemploymentOrg = actualBase * config.unemploymentOrg / 100
|
||||
const unemploymentEmp = actualBase * config.unemploymentEmp / 100
|
||||
const injuryOrg = actualBase * config.injuryOrg / 100
|
||||
const maternityOrg = actualBase * config.maternityOrg / 100
|
||||
const totalOrg = pensionOrg + medicalOrg + unemploymentOrg + injuryOrg + maternityOrg
|
||||
const totalEmp = pensionEmp + medicalEmp + unemploymentEmp
|
||||
const total = totalOrg + totalEmp
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
actualBase,
|
||||
originalBase: base,
|
||||
capped: base > config.baseMax,
|
||||
floored: base < config.baseMin,
|
||||
configVersion: config.effectiveFrom,
|
||||
items: [
|
||||
{ name: '养老保险', orgRate: config.pensionOrg, empRate: config.pensionEmp, orgAmount: pensionOrg, empAmount: pensionEmp },
|
||||
{ name: '医疗保险', orgRate: config.medicalOrg, empRate: config.medicalEmp, orgAmount: medicalOrg, empAmount: medicalEmp },
|
||||
{ name: '失业保险', orgRate: config.unemploymentOrg, empRate: config.unemploymentEmp, orgAmount: unemploymentOrg, empAmount: unemploymentEmp },
|
||||
{ name: '工伤保险', orgRate: config.injuryOrg, empRate: 0, orgAmount: injuryOrg, empAmount: 0 },
|
||||
{ name: '生育保险', orgRate: config.maternityOrg, empRate: 0, orgAmount: maternityOrg, empAmount: 0 },
|
||||
],
|
||||
totalOrg,
|
||||
totalEmp,
|
||||
total,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 公积金配置 ==========
|
||||
|
||||
// 获取当前公积金配置(支持按城市筛选)
|
||||
router.get('/housing-config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const city = req.query.city as string | undefined
|
||||
const where: any = { orgId: req.user!.orgId, isCurrent: true }
|
||||
if (city) where.city = city
|
||||
let config = await prisma.housingFundConfig.findFirst({
|
||||
where,
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
// 未指定城市时,返回任意当前配置
|
||||
if (!config && !city) {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
try {
|
||||
config = await prisma.housingFundConfig.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
city: city || '北京',
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, city: city || '北京' },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!config) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '未找到公积金配置' } })
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金配置版本列表(支持按城市筛选)
|
||||
router.get('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const city = req.query.city as string | undefined
|
||||
const where: any = { orgId: req.user!.orgId }
|
||||
if (city) where.city = city
|
||||
const versions = await prisma.housingFundConfig.findMany({
|
||||
where,
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: versions })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 新建公积金配置版本
|
||||
const createHousingVersionSchema = z.object({
|
||||
...housingConfigFields,
|
||||
effectiveFrom: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
})
|
||||
|
||||
router.post('/housing-config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = createHousingVersionSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const existing = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, city: data.city, effectiveFrom: data.effectiveFrom },
|
||||
})
|
||||
if (existing) {
|
||||
return res.status(400).json({ success: false, message: `${data.effectiveFrom} 已有公积金配置版本` })
|
||||
}
|
||||
|
||||
const prevCurrent = await prisma.housingFundConfig.findFirst({
|
||||
where: { orgId, isCurrent: true },
|
||||
})
|
||||
if (prevCurrent) {
|
||||
const [year, mon] = data.effectiveFrom.split('-').map(Number)
|
||||
const prevMonth = mon === 1
|
||||
? `${year - 1}-12`
|
||||
: `${year}-${String(mon - 1).padStart(2, '0')}`
|
||||
await prisma.housingFundConfig.update({
|
||||
where: { id: prevCurrent.id },
|
||||
data: { isCurrent: false, effectiveTo: prevMonth },
|
||||
})
|
||||
}
|
||||
|
||||
const version = await prisma.housingFundConfig.create({
|
||||
data: {
|
||||
orgId,
|
||||
...data,
|
||||
isCurrent: true,
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
res.json({ success: true, data: version })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金计算
|
||||
router.post('/housing-calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { base, month, city } = calcSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
let config
|
||||
const whereBase: any = { orgId }
|
||||
if (city) whereBase.city = city
|
||||
if (month) {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: {
|
||||
...whereBase,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.housingFundConfig.findFirst({
|
||||
where: { ...whereBase, isCurrent: true },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.housingFundConfig.create({
|
||||
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), city: city || '北京', createdBy: req.user!.id },
|
||||
})
|
||||
}
|
||||
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
const housingOrg = actualBase * config.housingOrg / 100
|
||||
const housingEmp = actualBase * config.housingEmp / 100
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
actualBase,
|
||||
originalBase: base,
|
||||
capped: base > config.baseMax,
|
||||
floored: base < config.baseMin,
|
||||
configVersion: config.effectiveFrom,
|
||||
housingOrg,
|
||||
housingEmp,
|
||||
total: housingOrg + housingEmp,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金调基预览
|
||||
router.get('/housing-config/:id/adjust-preview', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.housingFundConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
|
||||
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE', city: config.city },
|
||||
select: { id: true, name: true, department: true, housingFundBase: true, monthlySalary: true },
|
||||
orderBy: { name: 'asc' },
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const lastYearStart = `${now.getFullYear() - 1}-01`
|
||||
const lastYearEnd = `${now.getFullYear() - 1}-12`
|
||||
|
||||
const lastYearPayslips = await prisma.payslip.findMany({
|
||||
where: { orgId, month: { gte: lastYearStart, lte: lastYearEnd } },
|
||||
select: { employeeId: true, totalPay: true },
|
||||
})
|
||||
|
||||
const empPayslipMap = new Map<string, number[]>()
|
||||
for (const p of lastYearPayslips) {
|
||||
if (!empPayslipMap.has(p.employeeId)) empPayslipMap.set(p.employeeId, [])
|
||||
empPayslipMap.get(p.employeeId)!.push(p.totalPay)
|
||||
}
|
||||
|
||||
const items = employees.map((emp) => {
|
||||
let monthlyWage = 0
|
||||
try { monthlyWage = Number(decrypt(emp.monthlySalary)) } catch { monthlyWage = Number(emp.monthlySalary) || 0 }
|
||||
const oldBase = emp.housingFundBase ?? monthlyWage
|
||||
const payslips = empPayslipMap.get(emp.id)
|
||||
const avgSalary = payslips && payslips.length > 0 ? payslips.reduce((s, v) => s + v, 0) / payslips.length : monthlyWage
|
||||
const suggestedBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
|
||||
return {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
oldBase,
|
||||
avgSalary,
|
||||
monthlyWage,
|
||||
suggestedBase,
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { items, total: items.length, baseMin: config.baseMin, baseMax: config.baseMax } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 执行公积金调基
|
||||
const adjustHousingSchema = z.object({
|
||||
items: z.array(z.object({
|
||||
employeeId: z.string(),
|
||||
newBase: z.number(),
|
||||
})),
|
||||
})
|
||||
|
||||
router.post('/housing-config/:id/adjust-apply', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.housingFundConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
|
||||
if (config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本已执行过公积金基数调整' })
|
||||
|
||||
const { items } = adjustHousingSchema.parse(req.body)
|
||||
const adjustMonth = config.effectiveFrom
|
||||
const prevAdjustMonth = (() => {
|
||||
const [y, m] = adjustMonth.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
})()
|
||||
|
||||
let adjusted = 0
|
||||
for (const item of items) {
|
||||
const base = Math.min(Math.max(item.newBase, config.baseMin), config.baseMax)
|
||||
|
||||
// 关闭旧记录
|
||||
await prisma.employeeHousingFundRecord.updateMany({
|
||||
where: { employeeId: item.employeeId, endMonth: null },
|
||||
data: { endMonth: prevAdjustMonth },
|
||||
})
|
||||
|
||||
// 创建新记录
|
||||
await prisma.employeeHousingFundRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
city: config.city,
|
||||
startMonth: adjustMonth,
|
||||
endMonth: null,
|
||||
base,
|
||||
changeType: 'ADJUST',
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
|
||||
// 同步 Employee 便捷字段
|
||||
await prisma.employee.update({
|
||||
where: { id: item.employeeId },
|
||||
data: { housingFundBase: base, housingFundStartMonth: adjustMonth },
|
||||
})
|
||||
adjusted++
|
||||
}
|
||||
|
||||
await prisma.housingFundConfig.update({
|
||||
where: { id },
|
||||
data: { adjustmentDone: true },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { adjusted, total: items.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 重置公积金基数调整(撤销本次调整,重新来过)
|
||||
router.post('/housing-config/:id/reset-adjustment', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const config = await prisma.housingFundConfig.findFirst({
|
||||
where: { id, orgId },
|
||||
})
|
||||
if (!config) return res.status(404).json({ success: false, message: '公积金配置版本不存在' })
|
||||
if (!config.adjustmentDone) return res.status(400).json({ success: false, message: '该版本尚未执行过基数调整,无需重置' })
|
||||
|
||||
// 恢复 adjustmentDone 标志
|
||||
await prisma.housingFundConfig.update({
|
||||
where: { id },
|
||||
data: { adjustmentDone: false },
|
||||
})
|
||||
|
||||
// 删除该版本创建的所有公积金记录变更
|
||||
await prisma.employeeHousingFundRecord.deleteMany({
|
||||
where: {
|
||||
orgId,
|
||||
changeType: 'ADJUST',
|
||||
startMonth: config.effectiveFrom,
|
||||
},
|
||||
})
|
||||
|
||||
// 恢复员工公积金基数为调整前
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
for (const emp of employees) {
|
||||
const prevRecord = await prisma.employeeHousingFundRecord.findFirst({
|
||||
where: { orgId, employeeId: emp.id, startMonth: { lt: config.effectiveFrom } },
|
||||
orderBy: { startMonth: 'desc' },
|
||||
})
|
||||
await prisma.employee.update({
|
||||
where: { id: emp.id },
|
||||
data: {
|
||||
housingFundBase: prevRecord?.base ?? null,
|
||||
housingFundStartMonth: prevRecord?.startMonth ?? null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ success: true, message: '公积金基数调整已重置,可以重新调整' })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 月度增减员 ==========
|
||||
|
||||
// 社保月度增减员
|
||||
router.get('/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 增员:startMonth == month
|
||||
const additions = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: { orgId, startMonth: month },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
// 减员:endMonth == month 且 changeType == TERMINATION
|
||||
const reductions = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
additions: additions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
reductions: reductions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金月度增减员
|
||||
router.get('/housing/monthly-changes', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const additions = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: { orgId, startMonth: month },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
const reductions = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: { orgId, endMonth: month, changeType: 'TERMINATION' },
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
additions: additions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
reductions: reductions.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 在职申报 ==========
|
||||
|
||||
// 社保在保人员
|
||||
router.get('/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const records = await prisma.employeeSocialInsRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
startMonth: { lte: month },
|
||||
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
|
||||
},
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
items: records.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 公积金在保人员
|
||||
router.get('/housing/active-declaration', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const month = (req.query.month as string) || new Date().toISOString().slice(0, 7)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
const records = await prisma.employeeHousingFundRecord.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
startMonth: { lte: month },
|
||||
OR: [{ endMonth: null }, { endMonth: { gte: month } }],
|
||||
},
|
||||
include: { employee: { select: { name: true, department: true, idCardNumber: true, hireDate: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
month,
|
||||
items: records.map((r) => ({
|
||||
employeeId: r.employeeId,
|
||||
name: r.employee.name,
|
||||
department: r.employee.department,
|
||||
base: r.base,
|
||||
startMonth: r.startMonth,
|
||||
endMonth: r.endMonth,
|
||||
changeType: r.changeType,
|
||||
})),
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
Reference in New Issue
Block a user