a2e9ba55c2
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除 P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量 P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
754 lines
26 KiB
TypeScript
754 lines
26 KiB
TypeScript
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 otConfig = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } }) ?? { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 }
|
||
const hourlyWage = data.monthlyWage / otConfig.monthlyDays / otConfig.dailyHours
|
||
const weekdayPay = hourlyWage * otConfig.weekdayRate * data.weekdayHours
|
||
const weekendPay = hourlyWage * otConfig.weekendRate * data.weekendHours
|
||
const holidayPay = hourlyWage * otConfig.holidayRate * 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 otConfig = await prisma.overtimeConfig.findUnique({ where: { orgId: req.user!.orgId } }) ?? { weekdayRate: 1.5, weekendRate: 2.0, holidayRate: 3.0, monthlyDays: 21.75, dailyHours: 8 }
|
||
const hourlyWage = monthlyWage / otConfig.monthlyDays / otConfig.dailyHours
|
||
const weekdayPay = hourlyWage * otConfig.weekdayRate * weekdayHours
|
||
const weekendPay = hourlyWage * otConfig.weekendRate * weekendHours
|
||
const holidayPay = hourlyWage * otConfig.holidayRate * 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)
|
||
}
|
||
})
|
||
|
||
// 从考勤记录同步加班工时
|
||
router.post('/overtime/sync-from-attendance', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const orgId = req.user!.orgId
|
||
const { month } = req.body as { month: string }
|
||
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
|
||
return res.status(400).json({ success: false, message: '请提供有效的月份(YYYY-MM)' })
|
||
}
|
||
|
||
const monthStart = new Date(month + '-01')
|
||
const monthEnd = new Date(monthStart)
|
||
monthEnd.setMonth(monthEnd.getMonth() + 1)
|
||
|
||
// 获取该月所有考勤记录(含加班工时)
|
||
const records = await prisma.attendanceRecord.findMany({
|
||
where: { orgId, date: { gte: monthStart, lt: monthEnd }, overtimeHours: { gt: 0 } },
|
||
})
|
||
|
||
if (records.length === 0) {
|
||
return res.json({ success: false, message: '该月考勤记录中无加班工时' })
|
||
}
|
||
|
||
// 按员工汇总加班工时,按日期类型分类
|
||
const empMap = new Map<string, { weekday: number; weekend: number; holiday: number }>()
|
||
for (const r of records) {
|
||
const day = new Date(r.date)
|
||
const dayOfWeek = day.getDay() // 0=周日, 6=周六
|
||
let type: 'weekday' | 'weekend' | 'holiday' = 'weekday'
|
||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||
type = 'weekend'
|
||
}
|
||
// 简单判断法定节假日:这里使用周末判断,实际法定节假日需要额外配置
|
||
// 如果有 holidayHours 字段在 attendanceRecord 中,优先使用
|
||
|
||
if (!empMap.has(r.employeeId)) {
|
||
empMap.set(r.employeeId, { weekday: 0, weekend: 0, holiday: 0 })
|
||
}
|
||
const entry = empMap.get(r.employeeId)!
|
||
entry[type] += r.overtimeHours || 0
|
||
}
|
||
|
||
// 获取员工月工资用于计算加班费
|
||
let config = await prisma.overtimeConfig.findUnique({ where: { orgId } })
|
||
if (!config) config = await prisma.overtimeConfig.create({ data: { orgId } })
|
||
|
||
let synced = 0
|
||
for (const [employeeId, hours] of empMap) {
|
||
const emp = await prisma.employee.findFirst({ where: { id: employeeId }, select: { monthlySalary: true } })
|
||
let monthlyWage = 0
|
||
try { monthlyWage = emp?.monthlySalary ? Number(decrypt(emp.monthlySalary)) : 0 } catch { monthlyWage = Number(emp?.monthlySalary) || 0 }
|
||
|
||
const hourlyWage = monthlyWage / config.monthlyDays / config.dailyHours
|
||
const weekdayPay = hourlyWage * config.weekdayRate * hours.weekday
|
||
const weekendPay = hourlyWage * config.weekendRate * hours.weekend
|
||
const holidayPay = hourlyWage * config.holidayRate * hours.holiday
|
||
const totalPay = weekdayPay + weekendPay + holidayPay
|
||
|
||
await prisma.overtimeRecord.upsert({
|
||
where: { employeeId_month: { employeeId, month } },
|
||
update: {
|
||
weekdayHours: hours.weekday,
|
||
weekendHours: hours.weekend,
|
||
holidayHours: hours.holiday,
|
||
weekdayPay, weekendPay, holidayPay, totalPay,
|
||
},
|
||
create: {
|
||
orgId, employeeId, month,
|
||
weekdayHours: hours.weekday,
|
||
weekendHours: hours.weekend,
|
||
holidayHours: hours.holiday,
|
||
weekdayPay, weekendPay, holidayPay, totalPay,
|
||
},
|
||
})
|
||
synced++
|
||
}
|
||
|
||
res.json({ success: true, data: { synced, totalEmployees: empMap.size } })
|
||
} 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)
|
||
}
|
||
})
|
||
|
||
// ========== 薪资汇总表 & 明细表 ==========
|
||
|
||
// 薪资汇总表(按部门维度统计)
|
||
router.get('/batch/:id/summary', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const batch = await prisma.payrollBatch.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
})
|
||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||
|
||
const entries = await prisma.batchEntry.findMany({
|
||
where: { batchId: req.params.id },
|
||
include: { employee: { select: { id: true, name: true, department: true } } },
|
||
})
|
||
|
||
// 按部门汇总
|
||
const deptMap = new Map<string, any>()
|
||
for (const e of entries) {
|
||
const dept = e.employee.department || '未分配'
|
||
if (!deptMap.has(dept)) {
|
||
deptMap.set(dept, { department: dept, headcount: 0, totalPay: 0, totalNetPay: 0, totalSocialEmp: 0, totalSocialOrg: 0, totalHousingEmp: 0, totalHousingOrg: 0, totalTax: 0 })
|
||
}
|
||
const d = deptMap.get(dept)
|
||
d.headcount++
|
||
d.totalPay += e.totalPay
|
||
d.totalNetPay += e.netPay
|
||
d.totalSocialEmp += e.socialEmp
|
||
d.totalSocialOrg += e.socialOrg
|
||
d.totalHousingEmp += e.housingEmp
|
||
d.totalHousingOrg += e.housingOrg
|
||
d.totalTax += e.tax
|
||
}
|
||
|
||
const departments = Array.from(deptMap.values())
|
||
const grandTotal = {
|
||
headcount: entries.length,
|
||
totalPay: entries.reduce((s, e) => s + e.totalPay, 0),
|
||
totalNetPay: entries.reduce((s, e) => s + e.netPay, 0),
|
||
totalSocialEmp: entries.reduce((s, e) => s + e.socialEmp, 0),
|
||
totalSocialOrg: entries.reduce((s, e) => s + e.socialOrg, 0),
|
||
totalHousingEmp: entries.reduce((s, e) => s + e.housingEmp, 0),
|
||
totalHousingOrg: entries.reduce((s, e) => s + e.housingOrg, 0),
|
||
totalTax: entries.reduce((s, e) => s + e.tax, 0),
|
||
}
|
||
|
||
res.json({ success: true, data: { batch, departments, grandTotal } })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
// 薪资明细表(全员明细)
|
||
router.get('/batch/:id/detail', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const batch = await prisma.payrollBatch.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
})
|
||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||
|
||
const entries = await prisma.batchEntry.findMany({
|
||
where: { batchId: req.params.id },
|
||
include: { employee: { select: { id: true, name: true, department: true, phone: true } } },
|
||
orderBy: { employee: { department: 'asc' } },
|
||
})
|
||
|
||
const details = entries.map(e => ({
|
||
employeeId: e.employeeId,
|
||
name: e.employee.name,
|
||
department: e.employee.department,
|
||
phone: e.employee.phone,
|
||
baseSalary: e.baseSalary,
|
||
positionSalary: e.positionSalary,
|
||
performanceSalary: e.performanceSalary,
|
||
senioritySalary: e.senioritySalary,
|
||
overtimePay: e.overtimePay,
|
||
transportAllowance: e.transportAllowance,
|
||
mealAllowance: e.mealAllowance,
|
||
housingAllowance: e.housingAllowance,
|
||
communicationAllowance: e.communicationAllowance,
|
||
allowance: e.allowance,
|
||
bonus: e.bonus,
|
||
deduction: e.deduction,
|
||
otherDeduction: e.otherDeduction,
|
||
socialEmp: e.socialEmp,
|
||
housingEmp: e.housingEmp,
|
||
tax: e.tax,
|
||
totalPay: e.totalPay,
|
||
netPay: e.netPay,
|
||
}))
|
||
|
||
res.json({ success: true, data: { batch, details } })
|
||
} catch (err) { next(err) }
|
||
})
|
||
|
||
export default router
|