Files
TurboHR/backend/src/routes/payroll2.routes.ts
T
selfrelease e1b5ae9aab feat: 20260815 系统优化 - 全部31项问题修复(P0×6+P1×14+P2×9+P3×2)
P0紧急修复(6项):
- 草稿保存完整恢复所有字段(含socialAvgWage)
- 补偿金批次从compensationBreakdown读取
- 违法解除风险确认UI
- 合同结束日期前后校验(前后端双保险)

P1高优先级(14项):
- 离职日期联动社保/公积金截止月(15号规则)
- 合规检查+工作交接改为软阻断(生成待办)
- 补偿月数(N/N+1/2N/自定义)+计算基数(近12月/合同/自定义)
- 解聘并入花名册操作栏(类型选择跳转向导)
- 合同续签开始日期自动推导(原合同结束日+1天)
- 年龄合规筛查(童工阻断/未成年工/退休警告)
- 编辑入职日期后状态联动(待入职↔在职)
- 转正移植到花名册操作栏+薪资回写
- 男职工无法选择三期

P2体验优化(9项):
- "劳动合同"调整为"用工关系"
- 费用结算新增剩余年假折算(300%日工资)
- 身份证号全域改为"证件号码"(前后端18个文件)
- 手机号查重
- 开具证明+合同续签移植到花名册操作栏
- 批量转正+批量开具证明
- 去掉用工办理模块

P3规划(2项):
- 组织架构+审批流(Department/Position/ApprovalFlow/ApprovalInstance)
- 客服工作台(Ticket/ChatSession+SUPPORT角色)

新增模型: Department/Position/ApprovalFlow/ApprovalInstance/Ticket/TicketMessage/ChatSession/ChatMessage
新增字段: Employee.departmentId/supervisorId
新增角色: SUPPORT

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 12:37:27 +08:00

1042 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
// RFC 5987 编码中文文件名
function contentDisposition(filename: string): string {
const encoded = encodeURIComponent(filename)
return `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`
}
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, dateFrom, dateTo } = 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 } : {}),
...(dateFrom ? { createdAt: { gte: new Date(String(dateFrom)) } } : {}),
...(dateTo ? { createdAt: { lte: new Date(String(dateTo) + 'T23:59:59') } } : {}),
},
orderBy: [{ createdAt: 'desc' }, { 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', 'custom']).default('copy_last'),
sourceBatchId: z.string().optional(),
employeeIds: z.array(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, employeeIds, name, remark } = createBatchSchema.parse(req.body)
const orgId = req.user!.orgId
// 查询当月最大批次号,避免删除后 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 === 'custom' && employeeIds && employeeIds.length > 0) {
// 自定义选择:仅包含指定员工
employees = await prisma.employee.findMany({
where: { id: { in: employeeIds }, orgId },
include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 } },
})
} 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 } } } },
})
// SEVERANCE 批次:仅包含已审批通过(APPROVED/EXECUTING/COMPLETED)且有补偿金的离职记录
if (type === 'SEVERANCE') {
const eligibleTerms = terminations.filter(t =>
(t.status === 'APPROVED' || t.status === 'EXECUTING' || t.status === 'COMPLETED') &&
(t.compensation > 0 || (t.compensationBreakdown as any)?.total > 0)
)
employees = eligibleTerms.map(t => t.employee)
// 缓存 terminationRecord 以便后续 entry 创建时读取补偿金
;(req as any)._severanceTerms = new Map(eligibleTerms.map(t => [t.employeeId, t]))
} else {
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 }
}
// SEVERANCE 批次:从离职记录读取补偿金作为应发金额,不走工资/社保/个税计算
let severanceAmount = 0
let severanceBreakdown: any = null
if (type === 'SEVERANCE') {
const severanceTerms: Map<string, any> = (req as any)._severanceTerms || new Map()
const termRecord = severanceTerms.get(emp.id)
if (termRecord) {
severanceBreakdown = termRecord.compensationBreakdown
// 优先取 compensationBreakdown.total(含手动调整),否则取 compensation
severanceAmount = (severanceBreakdown as any)?.total || termRecord.compensation || 0
baseSalary = severanceAmount
}
}
// calcBatchEntry 内部会按员工检查当月已归档批次是否已扣社保,已扣则跳过
// SEVERANCE 批次:补偿金不走社保/个税计算,直接作为应发和实发金额
let calcResult: any
if (type === 'SEVERANCE' && severanceAmount > 0) {
calcResult = { socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: severanceAmount, netPay: severanceAmount }
} else {
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 { systemSocialEmp, systemSocialOrg, systemHousingEmp, systemHousingOrg, taxBreakdown, ...entryData } = calcResult
const updated = await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...inputs, ...entryData },
})
// 更新批次汇总
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, taxBreakdown: calcResult.taxBreakdown })
} catch (err) {
next(err)
}
})
// 获取条目个税计算明细
router.get('/batches/:batchId/entries/:employeeId/tax-detail', 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: '批次不存在' } })
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: entry.baseSalary,
overtimePay: entry.overtimePay,
allowance: entry.allowance,
deduction: entry.deduction,
bonus: entry.bonus,
}
const calcResult = await calcBatchEntry(orgId, employeeId, batch.month, inputs, batch.type)
const taxBreakdown = calcResult.taxBreakdown || {}
// 加入社保公积金系统计算值 vs 实际值对比
taxBreakdown.systemSocialEmp = calcResult.systemSocialEmp
taxBreakdown.systemSocialOrg = calcResult.systemSocialOrg
taxBreakdown.systemHousingEmp = calcResult.systemHousingEmp
taxBreakdown.systemHousingOrg = calcResult.systemHousingOrg
taxBreakdown.actualSocialEmp = entry.socialEmp
taxBreakdown.actualSocialOrg = entry.socialOrg
taxBreakdown.actualHousingEmp = entry.housingEmp
taxBreakdown.actualHousingOrg = entry.housingOrg
res.json({ success: true, data: taxBreakdown })
} 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 { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
const entry = await prisma.batchEntry.create({
data: {
batchId, orgId, employeeId,
baseSalary, overtimePay, allowance: 0, deduction: 0, bonus: 0,
...entryData, 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: '批次已归档' } })
// 1. 重算本批次所有条目(此时 calcBatchEntry 会包含所有已归档批次的累计数据)
const entries = await prisma.batchEntry.findMany({ where: { batchId } })
const recalcErrors: string[] = []
for (const entry of entries) {
try {
const inputs = {
baseSalary: entry.baseSalary,
overtimePay: entry.overtimePay,
allowance: entry.allowance,
deduction: entry.deduction,
bonus: entry.bonus,
positionSalary: entry.positionSalary || undefined,
performanceSalary: entry.performanceSalary || undefined,
senioritySalary: entry.senioritySalary || undefined,
transportAllowance: entry.transportAllowance || undefined,
mealAllowance: entry.mealAllowance || undefined,
housingAllowance: entry.housingAllowance || undefined,
communicationAllowance: entry.communicationAllowance || undefined,
otherDeduction: entry.otherDeduction || undefined,
}
// 社保如被手动覆盖,保留覆盖值
const overrideSocial: any = {}
if (entry.socialEmp !== undefined) overrideSocial.socialEmp = entry.socialEmp
if (entry.socialOrg !== undefined) overrideSocial.socialOrg = entry.socialOrg
if (entry.housingEmp !== undefined) overrideSocial.housingEmp = entry.housingEmp
if (entry.housingOrg !== undefined) overrideSocial.housingOrg = entry.housingOrg
const options = Object.keys(overrideSocial).length > 0 ? { overrideSocial } : undefined
const calcResult = await calcBatchEntry(orgId, entry.employeeId, batch.month, inputs, batch.type, options)
const { systemSocialEmp: _sse, systemSocialOrg: _sso, systemHousingEmp: _she, systemHousingOrg: _sho, taxBreakdown: _tb, ...entryData } = calcResult
await prisma.batchEntry.update({
where: { id: entry.id },
data: { ...entryData },
})
} catch (e: any) {
recalcErrors.push(`${entry.employeeId}: ${e?.message || '重算失败'}`)
}
}
// 2. 更新批次汇总
const recalcedEntries = await prisma.batchEntry.findMany({ where: { batchId } })
const totals = recalcedEntries.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 })
// 3. 标记为已归档
await prisma.payrollBatch.update({
where: { id: batchId },
data: {
status: 'ARCHIVED',
archivedAt: new Date(),
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: { archived: true, recalculated: entries.length, errors: recalcErrors.length > 0 ? recalcErrors : undefined } })
} 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', contentDisposition(`银行代发文件-${batch.month}-批次${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)
}
})
// ========== 工资条发布 ==========
// 发布工资条(将批次内所有工资条标记为 PUBLISHED)
router.post('/batches/:batchId/publish', 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: '批次不存在' } })
}
// 查找该批次关联的所有工资条(通过 BatchEntry 关联的 employeeId + month
const entries = await prisma.batchEntry.findMany({
where: { batchId, orgId },
select: { employeeId: true },
})
const employeeIds = entries.map(e => e.employeeId)
if (employeeIds.length === 0) {
return res.status(400).json({ success: false, error: { code: 'EMPTY', message: '批次内无员工' } })
}
// 更新对应月份的工资条
const result = await prisma.payslip.updateMany({
where: { orgId, employeeId: { in: employeeIds }, month: batch.month },
data: { publishStatus: 'PUBLISHED', publishedAt: new Date() },
})
res.json({ success: true, data: { published: result.count, month: batch.month } })
} catch (err) {
next(err)
}
})
// 定时发送工资条
router.post('/batches/:batchId/schedule', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { batchId } = req.params
const { scheduledAt } = req.body
if (!scheduledAt) {
return res.status(400).json({ success: false, error: { code: 'MISSING_DATE', message: '请选择发送时间' } })
}
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: '批次不存在' } })
}
const entries = await prisma.batchEntry.findMany({
where: { batchId, orgId },
select: { employeeId: true },
})
const employeeIds = entries.map(e => e.employeeId)
if (employeeIds.length === 0) {
return res.status(400).json({ success: false, error: { code: 'EMPTY', message: '批次内无员工' } })
}
const result = await prisma.payslip.updateMany({
where: { orgId, employeeId: { in: employeeIds }, month: batch.month },
data: { publishStatus: 'SCHEDULED', scheduledAt: new Date(scheduledAt) },
})
res.json({ success: true, data: { scheduled: result.count, scheduledAt } })
} catch (err) {
next(err)
}
})
// 定时发送记录
router.get('/schedule-records', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const records = await prisma.payslip.findMany({
where: { orgId: req.user!.orgId, publishStatus: 'SCHEDULED' },
include: { employee: { select: { name: true, department: true } } },
orderBy: { scheduledAt: 'asc' },
})
res.json({ success: true, data: records })
} catch (err) {
next(err)
}
})
// 取消定时发送
router.post('/schedule/:id/cancel', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const payslip = await prisma.payslip.findFirst({
where: { id: req.params.id, orgId: req.user!.orgId, publishStatus: 'SCHEDULED' },
})
if (!payslip) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '定时发送记录不存在' } })
}
const updated = await prisma.payslip.update({
where: { id: payslip.id },
data: { publishStatus: 'UNPUBLISHED', scheduledAt: null },
})
res.json({ success: true, data: updated })
} catch (err) {
next(err)
}
})
export default router