feat: 社保公积金版本管理 + 员工基数调整 + 加班费计算优化 + UI组件改进
- SocialInsuranceConfig 版本化(effectiveFrom/effectiveTo/isCurrent/adjustmentDone) - 社保配置版本管理接口(列表/新建/按月获取/当前版本) - 员工基数调整:预览全部在职员工、上年月均工资计算建议基数、可编辑表格、确认后批量保存 - payroll.service 按批次月份匹配对应版本社保配置 - risk.service / seed.ts 同步更新 - 前端社保tab重构为版本管理+调整+试算 - 加班费计算三步流程、CSV导入、批次导入 - UI组件、Dashboard、合同、薪酬等页面优化
This commit is contained in:
@@ -283,13 +283,50 @@ router.post('/payslip/batch-generate', async (req: AuthRequest, res: Response, n
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批量导入加班数据 ==========
|
||||
// ========== 加班费计算规则配置 ==========
|
||||
|
||||
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}$/),
|
||||
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),
|
||||
@@ -302,19 +339,13 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex
|
||||
const results: any[] = []
|
||||
|
||||
for (const data of items) {
|
||||
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,
|
||||
weekdayPay: 0, weekendPay: 0, holidayPay: 0, totalPay: 0,
|
||||
},
|
||||
create: {
|
||||
orgId: req.user!.orgId,
|
||||
@@ -323,7 +354,6 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex
|
||||
weekdayHours: data.weekdayHours,
|
||||
weekendHours: data.weekendHours,
|
||||
holidayHours: data.holidayHours,
|
||||
weekdayPay, weekendPay, holidayPay, totalPay,
|
||||
},
|
||||
})
|
||||
results.push(record)
|
||||
@@ -335,4 +365,80 @@ router.post('/overtime/batch', async (req: AuthRequest, res: Response, next: Nex
|
||||
}
|
||||
})
|
||||
|
||||
// ========== 批次导入加班费 ==========
|
||||
|
||||
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 cfg = config
|
||||
|
||||
// 获取该月未关联批次的加班记录
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -125,6 +125,22 @@ router.get('/batches/check', async (req: AuthRequest, res: Response, next: NextF
|
||||
}
|
||||
})
|
||||
|
||||
// 获取可复制的归档批次列表
|
||||
router.get('/batches/archived/list', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const orgId = req.user!.orgId
|
||||
const batches = await prisma.payrollBatch.findMany({
|
||||
where: { orgId, status: 'ARCHIVED' },
|
||||
orderBy: [{ month: 'desc' }, { batchNo: 'desc' }],
|
||||
select: { id: true, name: true, month: true, type: true, employeeCount: true, totalPay: true, totalNetPay: true },
|
||||
take: 20,
|
||||
})
|
||||
res.json({ success: true, data: batches })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 获取批次列表
|
||||
router.get('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@@ -167,13 +183,15 @@ router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFun
|
||||
const createBatchSchema = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||||
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS']).default('REGULAR'),
|
||||
mode: z.enum(['copy_last', 'blank_employees', 'blank_all', 'copy_batch']).default('copy_last'),
|
||||
sourceBatchId: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
remark: z.string().optional(),
|
||||
})
|
||||
|
||||
router.post('/batches', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { month, type, name, remark } = createBatchSchema.parse(req.body)
|
||||
const { month, type, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
// 查询当月已有批次数
|
||||
@@ -189,34 +207,55 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
const monthStart = new Date(`${month}-01`)
|
||||
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0, 23, 59, 59)
|
||||
|
||||
let employees: any[]
|
||||
if (type === 'TERMINATION') {
|
||||
// 离职结算批次:本月离职员工
|
||||
const terminations = await prisma.terminationRecord.findMany({
|
||||
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
|
||||
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
|
||||
})
|
||||
employees = terminations.map(t => t.employee)
|
||||
} else {
|
||||
// 常规/奖金批次:在职 + 本月离职
|
||||
employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
OR: [
|
||||
{ status: 'ACTIVE' },
|
||||
{ status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } },
|
||||
],
|
||||
},
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
}
|
||||
|
||||
// 获取上月发薪数据作为默认值
|
||||
// 获取上月发薪数据
|
||||
const prevMonth = new Date(monthStart.getFullYear(), monthStart.getMonth() - 1, 1)
|
||||
const prevMonthStr = `${prevMonth.getFullYear()}-${String(prevMonth.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
const batchName = name || `${month} 第${batchNo}批 ${type === 'BONUS' ? '奖金' : type === 'TERMINATION' ? '离职结算' : '发薪'}`
|
||||
|
||||
// 根据模式确定员工列表和数据来源
|
||||
let employees: any[] = []
|
||||
let sourceEntries: any[] | null = null
|
||||
|
||||
if (mode === 'blank_all') {
|
||||
// 全空白:不拉入员工
|
||||
employees = []
|
||||
} else if (mode === 'copy_batch' && sourceBatchId) {
|
||||
// 复制指定批次:从源批次复制条目
|
||||
const sourceBatch = await prisma.payrollBatch.findFirst({
|
||||
where: { id: sourceBatchId, orgId, status: 'ARCHIVED' },
|
||||
include: { entries: true },
|
||||
})
|
||||
if (!sourceBatch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '源批次不存在或未归档' } })
|
||||
sourceEntries = sourceBatch.entries
|
||||
// 提取员工 ID,后续按此创建条目
|
||||
const employeeIds = sourceEntries.map(e => e.employeeId)
|
||||
employees = await prisma.employee.findMany({
|
||||
where: { id: { in: employeeIds }, orgId },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
} else {
|
||||
// copy_last 或 blank_employees:拉入员工
|
||||
if (type === 'TERMINATION') {
|
||||
const terminations = await prisma.terminationRecord.findMany({
|
||||
where: { orgId, terminationDate: { gte: monthStart, lte: monthEnd } },
|
||||
include: { employee: { include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } } } },
|
||||
})
|
||||
employees = terminations.map(t => t.employee)
|
||||
} else {
|
||||
employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
orgId,
|
||||
OR: [
|
||||
{ status: 'ACTIVE' },
|
||||
{ status: 'RESIGNED', updatedAt: { gte: monthStart, lte: monthEnd } },
|
||||
],
|
||||
},
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 创建批次
|
||||
const batch = await prisma.payrollBatch.create({
|
||||
data: {
|
||||
@@ -234,36 +273,52 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
// 创建批次条目
|
||||
const entries: any[] = []
|
||||
for (const emp of employees) {
|
||||
// 获取上次发薪数据
|
||||
const prevPayslip = await prisma.payslip.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month: prevMonthStr } },
|
||||
})
|
||||
|
||||
// 获取加班费
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
})
|
||||
|
||||
// 基本工资
|
||||
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 }
|
||||
}
|
||||
let overtimePay = 0
|
||||
let allowance = 0
|
||||
let deduction = 0
|
||||
let bonus = 0
|
||||
|
||||
// 如果有上次发薪数据,带入
|
||||
if (prevPayslip) {
|
||||
baseSalary = prevPayslip.baseSalary
|
||||
}
|
||||
if (mode === 'copy_batch' && sourceEntries) {
|
||||
// 复制指定批次:从源条目复制数据
|
||||
const srcEntry = sourceEntries.find(e => e.employeeId === emp.id)
|
||||
if (srcEntry) {
|
||||
baseSalary = srcEntry.baseSalary
|
||||
overtimePay = srcEntry.overtimePay
|
||||
allowance = srcEntry.allowance
|
||||
deduction = srcEntry.deduction
|
||||
bonus = srcEntry.bonus
|
||||
}
|
||||
} else if (mode === 'copy_last') {
|
||||
// 复制上月:从上月工资条复制
|
||||
const prevPayslip = await prisma.payslip.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month: prevMonthStr } },
|
||||
})
|
||||
const overtime = await prisma.overtimeRecord.findUnique({
|
||||
where: { employeeId_month: { employeeId: emp.id, month } },
|
||||
})
|
||||
|
||||
const overtimePay = overtime?.totalPay || 0
|
||||
const allowance = prevPayslip?.allowance || 0
|
||||
const deduction = prevPayslip?.deduction || 0
|
||||
const bonus = type === 'BONUS' ? 0 : 0 // 奖金批次默认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 }
|
||||
}
|
||||
if (prevPayslip) baseSalary = prevPayslip.baseSalary
|
||||
overtimePay = overtime?.totalPay || 0
|
||||
allowance = prevPayslip?.allowance || 0
|
||||
deduction = prevPayslip?.deduction || 0
|
||||
}
|
||||
// blank_employees 和 blank_all: 所有金额默认 0
|
||||
|
||||
// 判断同月是否已有归档的常规批次(用于决定是否跳过社保)
|
||||
const hasArchivedRegularBatch = await prisma.payrollBatch.count({
|
||||
where: { orgId, month, status: 'ARCHIVED', type: { in: ['REGULAR', 'TERMINATION'] } },
|
||||
})
|
||||
|
||||
// 计算社保、个税等
|
||||
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
|
||||
// 同月已有归档常规批次时,新批次跳过社保(避免重复扣缴),但用户可手动编辑覆盖
|
||||
const skipSocial = type !== 'BONUS' && hasArchivedRegularBatch > 0
|
||||
const calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type, { skipSocial })
|
||||
|
||||
// 风险提示
|
||||
const riskWarnings = await getPayrollRiskWarnings(orgId, emp.id)
|
||||
@@ -322,13 +377,17 @@ router.post('/batches', async (req: AuthRequest, res: Response, next: NextFuncti
|
||||
}
|
||||
})
|
||||
|
||||
// 编辑批次条目(计算依据项)
|
||||
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
|
||||
const updateEntrySchema = z.object({
|
||||
baseSalary: z.number().min(0).optional(),
|
||||
overtimePay: z.number().min(0).optional(),
|
||||
allowance: z.number().min(0).optional(),
|
||||
deduction: z.number().min(0).optional(),
|
||||
bonus: z.number().min(0).optional(),
|
||||
socialEmp: z.number().min(0).optional(),
|
||||
socialOrg: z.number().min(0).optional(),
|
||||
housingEmp: z.number().min(0).optional(),
|
||||
housingOrg: z.number().min(0).optional(),
|
||||
})
|
||||
|
||||
router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
@@ -355,8 +414,16 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
||||
bonus: data.bonus ?? entry.bonus,
|
||||
}
|
||||
|
||||
// 构建社保覆盖参数(如果请求中包含社保字段)
|
||||
const overrideSocial: any = {}
|
||||
if (data.socialEmp !== undefined) overrideSocial.socialEmp = data.socialEmp
|
||||
if (data.socialOrg !== undefined) overrideSocial.socialOrg = data.socialOrg
|
||||
if (data.housingEmp !== undefined) overrideSocial.housingEmp = data.housingEmp
|
||||
if (data.housingOrg !== undefined) overrideSocial.housingOrg = data.housingOrg
|
||||
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
|
||||
|
||||
// 重新计算
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type)
|
||||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
|
||||
|
||||
const updated = await prisma.batchEntry.update({
|
||||
where: { id: entry.id },
|
||||
@@ -368,14 +435,22 @@ router.put('/batches/:batchId/entries/:employeeId', async (req: AuthRequest, res
|
||||
const totals = allEntries.reduce((acc, e) => ({
|
||||
totalPay: acc.totalPay + (e.id === entry.id ? calcResult.totalPay : e.totalPay),
|
||||
totalNetPay: acc.totalNetPay + (e.id === entry.id ? calcResult.netPay : e.netPay),
|
||||
totalSocialOrg: acc.totalSocialOrg + (e.id === entry.id ? calcResult.socialOrg : e.socialOrg),
|
||||
totalSocialEmp: acc.totalSocialEmp + (e.id === entry.id ? calcResult.socialEmp : e.socialEmp),
|
||||
totalHousingOrg: acc.totalHousingOrg + (e.id === entry.id ? calcResult.housingOrg : e.housingOrg),
|
||||
totalHousingEmp: acc.totalHousingEmp + (e.id === entry.id ? calcResult.housingEmp : e.housingEmp),
|
||||
totalTax: acc.totalTax + (e.id === entry.id ? calcResult.tax : e.tax),
|
||||
}), { totalPay: 0, totalNetPay: 0, totalTax: 0 })
|
||||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||||
|
||||
await prisma.payrollBatch.update({
|
||||
where: { id: batchId },
|
||||
data: {
|
||||
totalPay: Math.round(totals.totalPay * 100) / 100,
|
||||
totalNetPay: Math.round(totals.totalNetPay * 100) / 100,
|
||||
totalSocialOrg: Math.round(totals.totalSocialOrg * 100) / 100,
|
||||
totalSocialEmp: Math.round(totals.totalSocialEmp * 100) / 100,
|
||||
totalHousingOrg: Math.round(totals.totalHousingOrg * 100) / 100,
|
||||
totalHousingEmp: Math.round(totals.totalHousingEmp * 100) / 100,
|
||||
totalTax: Math.round(totals.totalTax * 100) / 100,
|
||||
},
|
||||
})
|
||||
@@ -467,6 +542,25 @@ router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest
|
||||
}
|
||||
})
|
||||
|
||||
// 删除批次(仅限草稿状态)
|
||||
router.delete('/batches/: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, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||||
if (batch.status === 'ARCHIVED') return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可删除' } })
|
||||
|
||||
await prisma.batchEntry.deleteMany({ where: { batchId } })
|
||||
await prisma.payrollBatch.delete({ where: { id: batchId } })
|
||||
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 归档批次
|
||||
router.post('/batches/:batchId/archive', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||
import { auditLog } from '../middleware/auditLog'
|
||||
import prisma from '../lib/prisma'
|
||||
import { decrypt } from '../lib/crypto'
|
||||
import { getContractStatus } from '../services/contract.service'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@@ -37,18 +38,39 @@ router.get('/', authMiddleware, async (req: AuthRequest, res, next) => {
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = employees.map((e) => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
status: e.status,
|
||||
hireDate: e.hireDate,
|
||||
gender: e.gender,
|
||||
phone: e.phone,
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
latestContract: e.contracts[0] || null,
|
||||
counts: e._count,
|
||||
}))
|
||||
const result = employees.map((e) => {
|
||||
const latestContract = e.contracts[0] || null
|
||||
const contractInfo = latestContract
|
||||
? getContractStatus({
|
||||
signDate: latestContract.signDate,
|
||||
startDate: latestContract.startDate,
|
||||
endDate: latestContract.endDate,
|
||||
contractType: latestContract.contractType,
|
||||
hireDate: e.hireDate,
|
||||
})
|
||||
: getContractStatus({
|
||||
signDate: null,
|
||||
startDate: e.hireDate,
|
||||
endDate: null,
|
||||
contractType: 'UNSIGNED',
|
||||
hireDate: e.hireDate,
|
||||
})
|
||||
return {
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
department: e.department,
|
||||
status: e.status,
|
||||
hireDate: e.hireDate,
|
||||
gender: e.gender,
|
||||
phone: e.phone,
|
||||
monthlySalary: safeDecrypt(e.monthlySalary),
|
||||
latestContract,
|
||||
contractStatus: contractInfo.status,
|
||||
contractStatusText: contractInfo.statusText,
|
||||
riskLevel: contractInfo.riskLevel,
|
||||
counts: e._count,
|
||||
}
|
||||
})
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
@@ -75,10 +97,15 @@ router.get('/:id/profile', authMiddleware, async (req: AuthRequest, res, next) =
|
||||
if (!employee) {
|
||||
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||
}
|
||||
const { monthlySalary, ...rest } = employee
|
||||
const { monthlySalary, bankAccount, idCardNumber, ...rest } = employee
|
||||
res.json({
|
||||
success: true,
|
||||
data: { ...rest, monthlySalary: safeDecrypt(monthlySalary) },
|
||||
data: {
|
||||
...rest,
|
||||
monthlySalary: safeDecrypt(monthlySalary),
|
||||
bankAccount: bankAccount ? safeDecrypt(bankAccount).toString() : null,
|
||||
idCardNumber: idCardNumber ? safeDecrypt(idCardNumber).toString() : null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
next(err)
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
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)
|
||||
|
||||
// 获取社保配置
|
||||
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
let config = await prisma.socialInsuranceConfig.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: { orgId: req.user!.orgId },
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 更新社保配置
|
||||
const configSchema = z.object({
|
||||
const configFields = {
|
||||
city: z.string().optional(),
|
||||
pensionOrg: z.number().optional(),
|
||||
pensionEmp: z.number().optional(),
|
||||
@@ -38,35 +21,269 @@ const configSchema = z.object({
|
||||
housingEmp: z.number().optional(),
|
||||
baseMin: z.number().optional(),
|
||||
baseMax: z.number().optional(),
|
||||
})
|
||||
}
|
||||
|
||||
router.put('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
// 获取当前生效版本
|
||||
router.get('/config', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const data = configSchema.parse(req.body)
|
||||
const config = await prisma.socialInsuranceConfig.upsert({
|
||||
where: { orgId: req.user!.orgId },
|
||||
update: data,
|
||||
create: { orgId: req.user!.orgId, ...data },
|
||||
let config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId: req.user!.orgId, isCurrent: true },
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: {
|
||||
orgId: req.user!.orgId,
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
createdBy: req.user!.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
res.json({ success: true, data: config })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 社保计算
|
||||
// 获取所有版本列表
|
||||
router.get('/config/versions', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const versions = await prisma.socialInsuranceConfig.findMany({
|
||||
where: { orgId: req.user!.orgId },
|
||||
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({
|
||||
...configFields,
|
||||
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.findUnique({
|
||||
where: { orgId_effectiveFrom: { orgId, 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' },
|
||||
select: { id: true, name: true, department: true, socialInsBase: true, housingFundBase: 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 oldHousingBase = emp.housingFundBase ?? monthlyWage
|
||||
const avgSalary = avgSalaryMap.get(emp.id) ?? monthlyWage
|
||||
// 建议基数 = 上年月均工资按上下限裁剪
|
||||
const suggestedSocialBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
|
||||
const suggestedHousingBase = Math.min(Math.max(avgSalary, config.baseMin), config.baseMax)
|
||||
return {
|
||||
employeeId: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
oldSocialBase,
|
||||
oldHousingBase,
|
||||
avgSalary,
|
||||
monthlyWage,
|
||||
suggestedSocialBase,
|
||||
suggestedHousingBase,
|
||||
}
|
||||
})
|
||||
|
||||
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(),
|
||||
newSocialBase: z.number(),
|
||||
newHousingBase: 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)
|
||||
|
||||
let adjusted = 0
|
||||
for (const item of items) {
|
||||
// 裁剪到上下限范围内
|
||||
const socialBase = Math.min(Math.max(item.newSocialBase, config.baseMin), config.baseMax)
|
||||
const housingBase = Math.min(Math.max(item.newHousingBase, config.baseMin), config.baseMax)
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: item.employeeId },
|
||||
data: {
|
||||
socialInsBase: socialBase,
|
||||
housingFundBase: housingBase,
|
||||
},
|
||||
})
|
||||
adjusted++
|
||||
}
|
||||
|
||||
await prisma.socialInsuranceConfig.update({
|
||||
where: { id },
|
||||
data: { adjustmentDone: true },
|
||||
})
|
||||
|
||||
res.json({ success: true, data: { adjusted, total: items.length } })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
})
|
||||
|
||||
// 社保计算(使用当前版本或指定月份版本)
|
||||
const calcSchema = z.object({
|
||||
base: z.number().positive(),
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
})
|
||||
|
||||
router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { base } = calcSchema.parse(req.body)
|
||||
let config = await prisma.socialInsuranceConfig.findUnique({
|
||||
where: { orgId: req.user!.orgId },
|
||||
})
|
||||
const { base, month } = calcSchema.parse(req.body)
|
||||
const orgId = req.user!.orgId
|
||||
|
||||
let config
|
||||
if (month) {
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: {
|
||||
orgId,
|
||||
effectiveFrom: { lte: month },
|
||||
OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }],
|
||||
},
|
||||
orderBy: { effectiveFrom: 'desc' },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({ data: { orgId: req.user!.orgId } })
|
||||
config = await prisma.socialInsuranceConfig.findFirst({
|
||||
where: { orgId, isCurrent: true },
|
||||
})
|
||||
}
|
||||
if (!config) {
|
||||
config = await prisma.socialInsuranceConfig.create({
|
||||
data: { orgId, effectiveFrom: new Date().toISOString().slice(0, 7), createdBy: req.user!.id },
|
||||
})
|
||||
}
|
||||
|
||||
const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax)
|
||||
@@ -93,6 +310,7 @@ router.post('/calculate', async (req: AuthRequest, res: Response, next: NextFunc
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user