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' import { isInProbation } from '../services/contract.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 } }, }) // 按批次月份判定是否仍在试用期(试用期结束日 = 合同开始日 + 试用期月数) // 试用期且 probationSalary > 0 → 用试用期工资;否则用转正工资 const batchMonthEnd = new Date(`${month}-28T23:59:59`) // 月末近似 const latestContract = emp.contracts?.[0] if (isInProbation(latestContract, batchMonthEnd) && latestContract.probationSalary > 0) { baseSalary = latestContract.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 = (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 // 按批次月份判定是否仍在试用期 const batchMonthEnd = new Date(`${batch.month}-28T23:59:59`) const latestContract = emp.contracts?.[0] if (isInProbation(latestContract, batchMonthEnd) && latestContract.probationSalary > 0) { baseSalary = latestContract.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