808 lines
32 KiB
TypeScript
808 lines
32 KiB
TypeScript
import { Router, Response, NextFunction } from 'express'
|
||
import prisma from '../lib/prisma'
|
||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||
import { z } from 'zod'
|
||
import { decrypt } from '../lib/crypto'
|
||
import {
|
||
getTemplate,
|
||
calcBatchEntry,
|
||
getPayrollRiskWarnings,
|
||
generatePayslipFromBatches,
|
||
prePayrollCheck,
|
||
} from '../services/payroll.service'
|
||
|
||
const router = Router()
|
||
router.use(authMiddleware)
|
||
|
||
// ========== 薪酬模版 ==========
|
||
|
||
// 获取薪酬模版
|
||
router.get('/template', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const items = await getTemplate(req.user!.orgId)
|
||
res.json({ success: true, data: items })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 更新薪酬模版项
|
||
const updateTemplateItemSchema = z.object({
|
||
name: z.string().min(1).optional(),
|
||
formula: z.string().nullable().optional(),
|
||
order: z.number().int().optional(),
|
||
isEditable: z.boolean().optional(),
|
||
})
|
||
|
||
router.put('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const data = updateTemplateItemSchema.parse(req.body)
|
||
const item = await prisma.payslipItem.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
})
|
||
if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } })
|
||
|
||
const updateData: any = {}
|
||
if (data.name !== undefined && !item.isDefault) updateData.name = data.name
|
||
if (data.formula !== undefined) updateData.formula = data.formula
|
||
if (data.order !== undefined) updateData.order = data.order
|
||
if (data.isEditable !== undefined) updateData.isEditable = data.isEditable
|
||
|
||
const updated = await prisma.payslipItem.update({ where: { id: req.params.id }, data: updateData })
|
||
res.json({ success: true, data: updated })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 新增薪酬模版项
|
||
const createTemplateItemSchema = z.object({
|
||
name: z.string().min(1),
|
||
code: z.string().min(1),
|
||
type: z.enum(['INPUT', 'CALCULATED']),
|
||
formula: z.string().nullable().optional(),
|
||
order: z.number().int().default(99),
|
||
isEditable: z.boolean().default(true),
|
||
})
|
||
|
||
router.post('/template', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const data = createTemplateItemSchema.parse(req.body)
|
||
const item = await prisma.payslipItem.create({
|
||
data: { ...data, orgId: req.user!.orgId, isDefault: false },
|
||
})
|
||
res.json({ success: true, data: item })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 删除薪酬模版项(仅非预置项)
|
||
router.delete('/template/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const item = await prisma.payslipItem.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
})
|
||
if (!item) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '模版项不存在' } })
|
||
if (item.isDefault) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '预置项不可删除' } })
|
||
|
||
await prisma.payslipItem.delete({ where: { id: req.params.id } })
|
||
res.json({ success: true })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// ========== 发薪批次 ==========
|
||
|
||
// 检查本月是否已发薪
|
||
router.get('/batches/check', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const { month } = req.query
|
||
if (!month) return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 month 参数' } })
|
||
|
||
const archivedBatches = await prisma.payrollBatch.count({
|
||
where: { orgId: req.user!.orgId, month: String(month), status: 'ARCHIVED' },
|
||
})
|
||
const draftBatches = await prisma.payrollBatch.count({
|
||
where: { orgId: req.user!.orgId, month: String(month), status: 'DRAFT' },
|
||
})
|
||
const publishedPayslips = await prisma.payslip.count({
|
||
where: { orgId: req.user!.orgId, month: String(month), status: 'PUBLISHED' },
|
||
})
|
||
|
||
res.json({
|
||
success: true,
|
||
data: {
|
||
hasArchivedBatch: archivedBatches > 0,
|
||
archivedCount: archivedBatches,
|
||
draftCount: draftBatches,
|
||
payslipsPublished: publishedPayslips > 0,
|
||
},
|
||
})
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 获取可复制的归档批次列表
|
||
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 {
|
||
const { month, monthFrom, monthTo, status, type } = req.query
|
||
const batches = await prisma.payrollBatch.findMany({
|
||
where: {
|
||
orgId: req.user!.orgId,
|
||
...(month ? { month: String(month) } : {}),
|
||
...(monthFrom ? { month: { gte: String(monthFrom) } } : {}),
|
||
...(monthTo ? { month: { lte: String(monthTo) } } : {}),
|
||
...(status ? { status: String(status) as any } : {}),
|
||
...(type ? { type: String(type) as any } : {}),
|
||
},
|
||
orderBy: [{ month: 'desc' }, { batchNo: 'asc' }],
|
||
})
|
||
res.json({ success: true, data: batches })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 获取批次详情
|
||
router.get('/batches/:id', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const batch = await prisma.payrollBatch.findFirst({
|
||
where: { id: req.params.id, orgId: req.user!.orgId },
|
||
include: {
|
||
entries: {
|
||
include: {
|
||
employee: { select: { id: true, name: true, department: true, status: true, bankAccount: true, bankName: true } },
|
||
},
|
||
orderBy: { employee: { name: 'asc' } },
|
||
},
|
||
},
|
||
})
|
||
if (!batch) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '批次不存在' } })
|
||
res.json({ success: true, data: batch })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 重命名批次
|
||
router.put('/batches/:id/name', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const { name } = req.body
|
||
if (!name || typeof name !== 'string' || name.trim().length === 0) {
|
||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '批次名称不能为空' } })
|
||
}
|
||
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: '批次不存在' } })
|
||
if (batch.status === 'ARCHIVED') {
|
||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '已归档批次不可重命名' } })
|
||
}
|
||
const updated = await prisma.payrollBatch.update({
|
||
where: { id: req.params.id },
|
||
data: { name: name.trim() },
|
||
})
|
||
res.json({ success: true, data: { id: updated.id, name: updated.name } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 创建批次
|
||
const createBatchSchema = z.object({
|
||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||
type: z.enum(['REGULAR', 'TERMINATION', 'BONUS', 'SEVERANCE']).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, mode, sourceBatchId, name, remark } = createBatchSchema.parse(req.body)
|
||
const orgId = req.user!.orgId
|
||
|
||
// 检查当月是否有未归档批次,有则拒绝创建(确保个税按批次累计计算)
|
||
const draftBatches = await prisma.payrollBatch.count({
|
||
where: { orgId, month, status: 'DRAFT' },
|
||
})
|
||
if (draftBatches > 0) {
|
||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '当月存在未归档的批次,请先归档后再创建新批次' } })
|
||
}
|
||
|
||
// 查询当月最大批次号,避免删除后 count 不准导致唯一键冲突
|
||
const lastBatch = await prisma.payrollBatch.findFirst({
|
||
where: { orgId, month },
|
||
orderBy: { batchNo: 'desc' },
|
||
select: { batchNo: true },
|
||
})
|
||
const batchNo = (lastBatch?.batchNo || 0) + 1
|
||
|
||
// 获取在职员工 + 本月离职员工
|
||
const monthStart = new Date(`${month}-01`)
|
||
const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0, 23, 59, 59)
|
||
|
||
// 获取上月发薪数据
|
||
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' ? '离职结算' : type === 'SEVERANCE' ? '补偿金' : '发薪'}`
|
||
|
||
// 根据模式确定员工列表和数据来源
|
||
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' || type === 'SEVERANCE') {
|
||
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: {
|
||
orgId,
|
||
month,
|
||
batchNo,
|
||
name: batchName,
|
||
type,
|
||
remark,
|
||
createdBy: req.user!.id,
|
||
employeeCount: employees.length,
|
||
},
|
||
})
|
||
|
||
// 创建批次条目
|
||
const entries: any[] = []
|
||
const failedEmployees: { employeeId: string; name: string; error: string }[] = []
|
||
for (const emp of employees) {
|
||
let baseSalary = 0
|
||
let overtimePay = 0
|
||
let allowance = 0
|
||
let deduction = 0
|
||
let bonus = 0
|
||
|
||
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 } },
|
||
})
|
||
|
||
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
|
||
// blank_employees 模式下尝试从员工记录获取基本工资
|
||
if (mode === 'blank_employees' && emp.monthlySalary) {
|
||
try { baseSalary = Number(decrypt(emp.monthlySalary)) || 0 } catch { baseSalary = Number(emp.monthlySalary) || 0 }
|
||
}
|
||
|
||
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过
|
||
let calcResult: any
|
||
try {
|
||
calcResult = await calcBatchEntry(orgId, emp.id, month, { baseSalary, overtimePay, allowance, deduction, bonus }, type)
|
||
} catch (calcErr: any) {
|
||
// 单个员工计算失败不阻塞整个批次,记录错误并使用零值
|
||
failedEmployees.push({ employeeId: emp.id, name: emp.name, error: calcErr?.message || '计算失败' })
|
||
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: baseSalary + overtimePay + allowance + bonus - deduction, netPay: baseSalary + overtimePay + allowance + bonus - deduction }
|
||
}
|
||
|
||
// 风险提示
|
||
const riskWarnings = await getPayrollRiskWarnings(orgId, emp.id)
|
||
|
||
const entry = await prisma.batchEntry.create({
|
||
data: {
|
||
batchId: batch.id,
|
||
orgId,
|
||
employeeId: emp.id,
|
||
baseSalary,
|
||
overtimePay,
|
||
allowance,
|
||
deduction,
|
||
bonus,
|
||
socialEmp: calcResult.socialEmp,
|
||
socialOrg: calcResult.socialOrg,
|
||
housingEmp: calcResult.housingEmp,
|
||
housingOrg: calcResult.housingOrg,
|
||
tax: calcResult.tax,
|
||
totalPay: calcResult.totalPay,
|
||
netPay: calcResult.netPay,
|
||
riskWarnings,
|
||
},
|
||
})
|
||
entries.push(entry)
|
||
}
|
||
|
||
// 更新批次汇总
|
||
const totals = entries.reduce((acc, e) => ({
|
||
totalPay: acc.totalPay + e.totalPay,
|
||
totalNetPay: acc.totalNetPay + e.netPay,
|
||
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
|
||
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
|
||
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
|
||
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
|
||
totalTax: acc.totalTax + e.tax,
|
||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||
|
||
const updatedBatch = await prisma.payrollBatch.update({
|
||
where: { id: batch.id },
|
||
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,
|
||
},
|
||
include: { entries: { include: { employee: { select: { id: true, name: true, department: true, status: true } } } } },
|
||
})
|
||
|
||
res.json({ success: true, data: updatedBatch, failedEmployees: failedEmployees.length > 0 ? failedEmployees : undefined })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 编辑批次条目(计算依据项 + 社保公积金手动覆盖)
|
||
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) => {
|
||
try {
|
||
const { batchId, employeeId } = req.params
|
||
const data = updateEntrySchema.parse(req.body)
|
||
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: '已归档批次不可编辑' } })
|
||
|
||
const entry = await prisma.batchEntry.findUnique({
|
||
where: { batchId_employeeId: { batchId, employeeId } },
|
||
})
|
||
if (!entry) return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '条目不存在' } })
|
||
|
||
// 合并输入项
|
||
const inputs = {
|
||
baseSalary: data.baseSalary ?? entry.baseSalary,
|
||
overtimePay: data.overtimePay ?? entry.overtimePay,
|
||
allowance: data.allowance ?? entry.allowance,
|
||
deduction: data.deduction ?? entry.deduction,
|
||
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
|
||
|
||
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,手动覆盖优先
|
||
const options = Object.keys(overrideSocial).length > 0
|
||
? { overrideSocial }
|
||
: undefined
|
||
|
||
// 重新计算
|
||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type, options)
|
||
|
||
const updated = await prisma.batchEntry.update({
|
||
where: { id: entry.id },
|
||
data: { ...inputs, ...calcResult },
|
||
})
|
||
|
||
// 更新批次汇总
|
||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||
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, 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,
|
||
},
|
||
})
|
||
|
||
res.json({ success: true, data: updated })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 批次增加人员
|
||
router.post('/batches/:batchId/employees', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const { batchId } = req.params
|
||
const { employeeIds } = req.body as { employeeIds: string[] }
|
||
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: '已归档批次不可编辑' } })
|
||
|
||
const results: any[] = []
|
||
for (const employeeId of employeeIds) {
|
||
// 检查是否已在批次中
|
||
const existing = await prisma.batchEntry.findUnique({
|
||
where: { batchId_employeeId: { batchId, employeeId } },
|
||
})
|
||
if (existing) continue
|
||
|
||
const emp = await prisma.employee.findFirst({
|
||
where: { id: employeeId, orgId },
|
||
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
|
||
})
|
||
if (!emp) continue
|
||
|
||
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 overtime = await prisma.overtimeRecord.findUnique({
|
||
where: { employeeId_month: { employeeId, month: batch.month } },
|
||
})
|
||
const overtimePay = overtime?.totalPay || 0
|
||
|
||
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过
|
||
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, { baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0 }, batch.type)
|
||
const riskWarnings = await getPayrollRiskWarnings(orgId, employeeId)
|
||
|
||
const entry = await prisma.batchEntry.create({
|
||
data: {
|
||
batchId, orgId, employeeId,
|
||
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
|
||
...calcResult, riskWarnings,
|
||
},
|
||
})
|
||
results.push(entry)
|
||
}
|
||
|
||
// 更新批次人数和汇总
|
||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||
const totals = allEntries.reduce((acc, e) => ({
|
||
totalPay: acc.totalPay + e.totalPay,
|
||
totalNetPay: acc.totalNetPay + e.netPay,
|
||
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
|
||
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
|
||
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
|
||
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
|
||
totalTax: acc.totalTax + e.tax,
|
||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||
await prisma.payrollBatch.update({
|
||
where: { id: batchId },
|
||
data: {
|
||
employeeCount: allEntries.length,
|
||
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,
|
||
},
|
||
})
|
||
|
||
res.json({ success: true, data: { added: results.length } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 批次移除人员
|
||
router.delete('/batches/:batchId/employees/:employeeId', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const { batchId, employeeId } = 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, employeeId } })
|
||
|
||
// 更新批次人数和汇总
|
||
const allEntries = await prisma.batchEntry.findMany({ where: { batchId } })
|
||
const totals = allEntries.reduce((acc, e) => ({
|
||
totalPay: acc.totalPay + e.totalPay,
|
||
totalNetPay: acc.totalNetPay + e.netPay,
|
||
totalSocialOrg: acc.totalSocialOrg + e.socialOrg,
|
||
totalSocialEmp: acc.totalSocialEmp + e.socialEmp,
|
||
totalHousingOrg: acc.totalHousingOrg + e.housingOrg,
|
||
totalHousingEmp: acc.totalHousingEmp + e.housingEmp,
|
||
totalTax: acc.totalTax + e.tax,
|
||
}), { totalPay: 0, totalNetPay: 0, totalSocialOrg: 0, totalSocialEmp: 0, totalHousingOrg: 0, totalHousingEmp: 0, totalTax: 0 })
|
||
await prisma.payrollBatch.update({
|
||
where: { id: batchId },
|
||
data: {
|
||
employeeCount: allEntries.length,
|
||
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,
|
||
},
|
||
})
|
||
|
||
res.json({ success: true })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 删除批次(仅限草稿状态)
|
||
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 {
|
||
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.payrollBatch.update({
|
||
where: { id: batchId },
|
||
data: { status: 'ARCHIVED', archivedAt: new Date() },
|
||
})
|
||
|
||
res.json({ success: true, data: { archived: true } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 取消归档(只能取消最后一个归档批次,依次取消)
|
||
router.post('/batches/:batchId/unarchive', 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: '仅已归档批次可取消归档' } })
|
||
|
||
// 检查是否是最后一个归档批次(按 batchNo 倒序)
|
||
const lastArchived = await prisma.payrollBatch.findFirst({
|
||
where: { orgId, month: batch.month, status: 'ARCHIVED' },
|
||
orderBy: { batchNo: 'desc' },
|
||
select: { id: true },
|
||
})
|
||
if (lastArchived?.id !== batchId) {
|
||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '只能依次取消归档,请先取消最后归档的批次' } })
|
||
}
|
||
|
||
// 取消归档
|
||
await prisma.payrollBatch.update({
|
||
where: { id: batchId },
|
||
data: { status: 'DRAFT', archivedAt: null },
|
||
})
|
||
|
||
// 如果当月已无归档批次,删除对应工资条(取消个税累计)
|
||
const remainingArchived = await prisma.payrollBatch.count({
|
||
where: { orgId, month: batch.month, status: 'ARCHIVED' },
|
||
})
|
||
if (remainingArchived === 0) {
|
||
// 查找该月所有批次的员工,删除其工资条
|
||
const entries = await prisma.batchEntry.findMany({
|
||
where: { orgId, batch: { month: batch.month } },
|
||
select: { employeeId: true },
|
||
distinct: ['employeeId'],
|
||
})
|
||
if (entries.length > 0) {
|
||
await prisma.payslip.deleteMany({
|
||
where: {
|
||
orgId,
|
||
employeeId: { in: entries.map(e => e.employeeId) },
|
||
month: batch.month,
|
||
},
|
||
})
|
||
}
|
||
} else {
|
||
// 还有归档批次,重新生成工资条(基于剩余归档批次汇总)
|
||
await generatePayslipFromBatches(orgId, batch.month)
|
||
}
|
||
|
||
res.json({ success: true, data: { unarchived: true } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 从已归档批次汇总生成工资条
|
||
router.post('/payslips/generate', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const { month } = req.body
|
||
const orgId = req.user!.orgId
|
||
|
||
if (!month || !/^\d{4}-\d{2}$/.test(month)) {
|
||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '请提供有效的月份(YYYY-MM)' } })
|
||
}
|
||
|
||
// 检查是否有已归档批次
|
||
const archivedBatches = await prisma.payrollBatch.count({
|
||
where: { orgId, month, status: 'ARCHIVED' },
|
||
})
|
||
if (archivedBatches === 0) {
|
||
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '当月无已归档批次,无法生成工资条' } })
|
||
}
|
||
|
||
const result = await generatePayslipFromBatches(orgId, month)
|
||
|
||
// 自动标记"生成工资条"待办为已完成
|
||
await prisma.riskItem.updateMany({
|
||
where: { orgId, status: 'PENDING', type: 'SALARY', title: { startsWith: `${month}月 生成工资条` } },
|
||
data: { status: 'RESOLVED', resolvedAt: new Date(), resolvedBy: req.user!.id },
|
||
})
|
||
|
||
res.json({ success: true, data: { generated: result.generated } })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 银行代发文件导出(接口预留)
|
||
router.get('/batches/:batchId/export', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const { batchId } = req.params
|
||
const orgId = req.user!.orgId
|
||
const { format = 'csv' } = req.query
|
||
|
||
const batch = await prisma.payrollBatch.findFirst({
|
||
where: { id: batchId, orgId },
|
||
include: {
|
||
entries: {
|
||
include: { employee: { select: { name: true, bankAccount: true, bankName: true } } },
|
||
},
|
||
},
|
||
})
|
||
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: '仅归档批次可导出' } })
|
||
|
||
if (format === 'csv') {
|
||
const header = '姓名,银行账号,开户行,实发金额\n'
|
||
const rows = batch.entries.map(e => `${e.employee.name},${e.employee.bankAccount || ''},${e.employee.bankName || ''},${e.netPay}`).join('\n')
|
||
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
||
res.setHeader('Content-Disposition', `attachment; filename="payroll-${batch.month}-batch${batch.batchNo}.csv"`)
|
||
return res.send('\ufeff' + header + rows)
|
||
}
|
||
|
||
res.json({ success: true, data: batch })
|
||
} catch (err) {
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
// 算薪前 AI 校验
|
||
router.get('/batches/:batchId/pre-check', async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const result = await prePayrollCheck(req.user!.orgId, req.params.batchId)
|
||
res.json({ success: true, data: result })
|
||
} catch (err: any) {
|
||
if (err?.code === 'NOT_FOUND') {
|
||
return res.status(404).json({ success: false, error: { code: err.code, message: err.message } })
|
||
}
|
||
next(err)
|
||
}
|
||
})
|
||
|
||
export default router
|