import prisma from '../lib/prisma' // ========== 薪酬模版 ========== const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [ { name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, order: 1, isDefault: true, isEditable: true }, { name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', order: 2, isDefault: true, isEditable: false }, { name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, order: 3, isDefault: true, isEditable: true }, { name: '奖金', code: 'bonus', type: 'INPUT', formula: null, order: 4, isDefault: true, isEditable: true }, { name: '扣款', code: 'deduction', type: 'INPUT', formula: null, order: 5, isDefault: true, isEditable: true }, { name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + overtimePay + allowance + bonus - deduction', order: 6, isDefault: true, isEditable: false }, { name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', order: 7, isDefault: true, isEditable: false }, { name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', order: 8, isDefault: true, isEditable: false }, { name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', order: 9, isDefault: true, isEditable: false }, { name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', order: 10, isDefault: true, isEditable: false }, ] export async function ensureDefaultTemplate(orgId: string) { const existing = await prisma.payslipItem.count({ where: { orgId } }) if (existing === 0) { await prisma.payslipItem.createMany({ data: DEFAULT_ITEMS.map(item => ({ ...item, orgId })), }) } } export async function getTemplate(orgId: string) { await ensureDefaultTemplate(orgId) return prisma.payslipItem.findMany({ where: { orgId }, orderBy: { order: 'asc' }, }) } // ========== 社保计算 ========== export function calcSocialInsurance(base: number, config: any) { const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) const socialEmp = actualBase * (config.pensionEmp + config.medicalEmp + config.unemploymentEmp) / 100 const socialOrg = actualBase * (config.pensionOrg + config.medicalOrg + config.unemploymentOrg + config.injuryOrg + config.maternityOrg) / 100 return { actualBase, socialEmp, socialOrg } } export function calcHousingFund(base: number, config: any) { const actualBase = Math.min(Math.max(base, config.baseMin), config.baseMax) const housingEmp = actualBase * config.housingEmp / 100 const housingOrg = actualBase * config.housingOrg / 100 return { actualBase, housingEmp, housingOrg } } // ========== 累计预扣个税 ========== export function calcTax(taxableIncome: number): number { if (taxableIncome <= 0) return 0 let tax = 0 if (taxableIncome <= 36000) tax = taxableIncome * 0.03 else if (taxableIncome <= 144000) tax = taxableIncome * 0.10 - 2520 else if (taxableIncome <= 300000) tax = taxableIncome * 0.20 - 16920 else if (taxableIncome <= 420000) tax = taxableIncome * 0.25 - 31920 else if (taxableIncome <= 660000) tax = taxableIncome * 0.30 - 52920 else if (taxableIncome <= 960000) tax = taxableIncome * 0.35 - 85920 else tax = taxableIncome * 0.45 - 181920 return Math.max(0, Math.round(tax * 100) / 100) } /** * 累计预扣法计算当月个税 * @param ytdTaxableIncome 当年累计应纳税所得额(含当月) * @param ytdTaxDeducted 当年累计已预扣税额 * @returns 当月应预扣税额 */ export function calcCumulativeTax(ytdTaxableIncome: number, ytdTaxDeducted: number): number { const ytdTax = calcTax(ytdTaxableIncome) const currentMonthTax = Math.max(0, ytdTax - ytdTaxDeducted) return Math.round(currentMonthTax * 100) / 100 } /** * 年终奖单独计税 * @param bonusAmount 奖金金额 * @returns 应纳税额 */ export function calcBonusTax(bonusAmount: number): number { if (bonusAmount <= 0) return 0 const monthlyBonus = bonusAmount / 12 let rate = 0.03 let quickDeduction = 0 if (monthlyBonus <= 3000) { rate = 0.03; quickDeduction = 0 } else if (monthlyBonus <= 12000) { rate = 0.10; quickDeduction = 210 } else if (monthlyBonus <= 25000) { rate = 0.20; quickDeduction = 1410 } else if (monthlyBonus <= 35000) { rate = 0.25; quickDeduction = 2660 } else if (monthlyBonus <= 55000) { rate = 0.30; quickDeduction = 4410 } else if (monthlyBonus <= 80000) { rate = 0.35; quickDeduction = 7160 } else { rate = 0.45; quickDeduction = 15160 } const tax = bonusAmount * rate - quickDeduction return Math.max(0, Math.round(tax * 100) / 100) } // ========== 批次计算 ========== export async function calcBatchEntry( orgId: string, employeeId: string, month: string, inputs: { baseSalary: number; overtimePay: number; allowance: number; deduction: number; bonus: number }, batchType: string = 'REGULAR', options?: { skipSocial?: boolean; overrideSocial?: { socialEmp?: number; socialOrg?: number; housingEmp?: number; housingOrg?: number } }, ) { const [employee, socialConfig, housingConfig] = await Promise.all([ prisma.employee.findFirst({ where: { id: employeeId, orgId } }), prisma.socialInsuranceConfig.findFirst({ where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }], }, orderBy: { effectiveFrom: 'desc' }, }), prisma.housingFundConfig.findFirst({ where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }], }, orderBy: { effectiveFrom: 'desc' }, }), ]) if (!employee) throw { code: 'NOT_FOUND', message: '员工不存在' } // 社保基数:优先用员工核定基数,否则用基本工资 const socialBase = employee.socialInsBase || inputs.baseSalary const housingBase = employee.housingFundBase || inputs.baseSalary let socialEmp = 0, socialOrg = 0, housingEmp = 0, housingOrg = 0 // 年终奖/奖金批次、补偿金批次:不扣社保公积金 if (batchType !== 'BONUS' && batchType !== 'SEVERANCE' && !options?.skipSocial) { if (socialConfig) { const social = calcSocialInsurance(socialBase, socialConfig) socialEmp = social.socialEmp socialOrg = social.socialOrg } if (housingConfig) { const housing = calcHousingFund(housingBase, housingConfig) housingEmp = housing.housingEmp housingOrg = housing.housingOrg } } // 手动覆盖社保值 if (options?.overrideSocial) { if (options.overrideSocial.socialEmp !== undefined) socialEmp = options.overrideSocial.socialEmp if (options.overrideSocial.socialOrg !== undefined) socialOrg = options.overrideSocial.socialOrg if (options.overrideSocial.housingEmp !== undefined) housingEmp = options.overrideSocial.housingEmp if (options.overrideSocial.housingOrg !== undefined) housingOrg = options.overrideSocial.housingOrg } const totalPay = inputs.baseSalary + inputs.overtimePay + inputs.allowance + inputs.bonus - inputs.deduction // 个税计算 let tax = 0 if (batchType === 'BONUS') { // 年终奖单独计税 tax = calcBonusTax(inputs.bonus) } else { // 累计预扣法(补偿金也走累计预扣,但无社保公积金扣除) const year = month.slice(0, 4) const prevPayslips = await prisma.payslip.findMany({ where: { orgId, employeeId, month: { startsWith: year, lt: month }, }, select: { totalPay: true, socialEmp: true, housingEmp: true, tax: true }, }) const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + totalPay const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + socialEmp const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + housingEmp const ytdSpecialDeduction = employee.specialDeduction * Number(month.slice(5, 7)) const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0) const ytdTaxableIncome = Math.max(0, ytdIncome - 5000 * Number(month.slice(5, 7)) - ytdSocialEmp - ytdHousingEmp - ytdSpecialDeduction) tax = calcCumulativeTax(ytdTaxableIncome, ytdTaxDeducted) } const netPay = totalPay - socialEmp - housingEmp - tax return { socialEmp: Math.round(socialEmp * 100) / 100, socialOrg: Math.round(socialOrg * 100) / 100, housingEmp: Math.round(housingEmp * 100) / 100, housingOrg: Math.round(housingOrg * 100) / 100, tax, totalPay: Math.round(totalPay * 100) / 100, netPay: Math.round(netPay * 100) / 100, } } // ========== 风险提示 ========== export async function getPayrollRiskWarnings(orgId: string, employeeId: string): Promise { const warnings: string[] = [] const employee = await prisma.employee.findFirst({ where: { id: employeeId, orgId }, include: { contracts: { orderBy: { createdAt: 'desc' }, take: 1 }, terminations: { orderBy: { createdAt: 'desc' }, take: 1 }, }, }) if (!employee) return warnings if (employee.status === 'RESIGNED') { warnings.push('该员工已离职,需进行离职结算') } if (!employee.contracts.length || employee.contracts[0].contractType === 'UNSIGNED') { warnings.push('未签订书面劳动合同') } if (employee.contracts.length) { const contract = employee.contracts[0] if (contract.endDate) { const daysToExpiry = Math.ceil((new Date(contract.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) if (daysToExpiry <= 30 && daysToExpiry > 0) { warnings.push(`合同将于 ${daysToExpiry} 天后到期`) } } if (contract.probationMonths > 0 && contract.startDate) { const probationEnd = new Date(contract.startDate) probationEnd.setMonth(probationEnd.getMonth() + contract.probationMonths) if (probationEnd > new Date()) { warnings.push('试用期员工,薪资可能不同') } } } if (!employee.socialInsBase) { warnings.push('未设置社保缴费基数') } if (!employee.housingFundBase) { warnings.push('未设置公积金缴费基数') } if (employee.terminations.some((t) => t.status !== 'CANCELLED')) { warnings.push('已有解聘记录,请注意结算') } return warnings } // ========== 工资条汇总生成 ========== export async function generatePayslipFromBatches(orgId: string, month: string) { // 获取当月所有已归档批次 const batches = await prisma.payrollBatch.findMany({ where: { orgId, month, status: 'ARCHIVED' }, include: { entries: true }, }) if (batches.length === 0) return { generated: 0 } // 按员工汇总 const employeeMap = new Map() for (const batch of batches) { for (const entry of batch.entries) { const existing = employeeMap.get(entry.employeeId) || { baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0, socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, totalPay: 0, netPay: 0, } existing.baseSalary += entry.baseSalary existing.overtimePay += entry.overtimePay existing.allowance += entry.allowance existing.deduction += entry.deduction existing.bonus += entry.bonus existing.socialEmp += entry.socialEmp existing.socialOrg += entry.socialOrg existing.housingEmp += entry.housingEmp existing.housingOrg += entry.housingOrg existing.tax += entry.tax existing.totalPay += entry.totalPay existing.netPay += entry.netPay employeeMap.set(entry.employeeId, existing) } } // 计算累计数据 const year = month.slice(0, 4) const employeeIds = Array.from(employeeMap.keys()) const allPrevPayslips = await prisma.payslip.findMany({ where: { orgId, employeeId: { in: employeeIds }, month: { startsWith: year, lt: month } }, select: { employeeId: true, totalPay: true, tax: true, socialEmp: true, housingEmp: true }, }) const prevMap = new Map() for (const p of allPrevPayslips) { const existing = prevMap.get(p.employeeId) || { totalPay: 0, tax: 0, socialEmp: 0, housingEmp: 0 } existing.totalPay += p.totalPay existing.tax += p.tax existing.socialEmp += p.socialEmp existing.housingEmp += p.housingEmp prevMap.set(p.employeeId, existing) } let generated = 0 for (const [employeeId, summary] of employeeMap) { const prev = prevMap.get(employeeId) || { totalPay: 0, tax: 0, socialEmp: 0, housingEmp: 0 } const ytdIncome = prev.totalPay + summary.totalPay const ytdTaxDeducted = prev.tax + summary.tax const ytdSocialEmp = prev.socialEmp + summary.socialEmp const ytdHousingEmp = prev.housingEmp + summary.housingEmp await prisma.payslip.upsert({ where: { employeeId_month: { employeeId, month } }, update: { baseSalary: Math.round(summary.baseSalary * 100) / 100, overtimePay: Math.round(summary.overtimePay * 100) / 100, allowance: Math.round(summary.allowance * 100) / 100, deduction: Math.round(summary.deduction * 100) / 100, bonus: Math.round(summary.bonus * 100) / 100, totalPay: Math.round(summary.totalPay * 100) / 100, socialEmp: Math.round(summary.socialEmp * 100) / 100, housingEmp: Math.round(summary.housingEmp * 100) / 100, tax: Math.round(summary.tax * 100) / 100, netPay: Math.round(summary.netPay * 100) / 100, ytdIncome: Math.round(ytdIncome * 100) / 100, ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100, ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100, ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100, status: 'PUBLISHED', publishedAt: new Date(), }, create: { orgId, employeeId, month, baseSalary: Math.round(summary.baseSalary * 100) / 100, overtimePay: Math.round(summary.overtimePay * 100) / 100, allowance: Math.round(summary.allowance * 100) / 100, deduction: Math.round(summary.deduction * 100) / 100, bonus: Math.round(summary.bonus * 100) / 100, totalPay: Math.round(summary.totalPay * 100) / 100, socialEmp: Math.round(summary.socialEmp * 100) / 100, housingEmp: Math.round(summary.housingEmp * 100) / 100, tax: Math.round(summary.tax * 100) / 100, netPay: Math.round(summary.netPay * 100) / 100, ytdIncome: Math.round(ytdIncome * 100) / 100, ytdTaxDeducted: Math.round(ytdTaxDeducted * 100) / 100, ytdSocialEmp: Math.round(ytdSocialEmp * 100) / 100, ytdHousingEmp: Math.round(ytdHousingEmp * 100) / 100, status: 'PUBLISHED', publishedAt: new Date(), }, }) generated++ } return { generated } } // ========== 算薪前 AI 校验 ========== export interface PayrollCheckItem { code: string name: string status: 'PASS' | 'FAIL' | 'WARNING' message: string employeeId?: string employeeName?: string detail?: any } export interface PayrollCheckResult { checks: PayrollCheckItem[] passedCount: number failedCount: number warningCount: number } /** * 算薪前校验:检查 10+ 项异常,把错误拦截在发放前 */ export async function prePayrollCheck(orgId: string, batchId: string): Promise { const batch = await prisma.payrollBatch.findFirst({ where: { id: batchId, orgId }, include: { entries: { include: { employee: true } } }, }) if (!batch) { throw { code: 'NOT_FOUND', message: '工资批次不存在' } } const month = batch.month const entries = batch.entries const checks: PayrollCheckItem[] = [] // 获取社保公积金配置 const [socialConfig, housingConfig] = await Promise.all([ prisma.socialInsuranceConfig.findFirst({ where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] }, orderBy: { effectiveFrom: 'desc' }, }), prisma.housingFundConfig.findFirst({ where: { orgId, effectiveFrom: { lte: month }, OR: [{ effectiveTo: null }, { effectiveTo: { gte: month } }] }, orderBy: { effectiveFrom: 'desc' }, }), ]) // 1. 社保基数是否在上下限范围内 if (socialConfig) { for (const entry of entries) { const base = entry.employee.socialInsBase || entry.baseSalary if (base < socialConfig.baseMin || base > socialConfig.baseMax) { checks.push({ code: 'SOCIAL_BASE_OUT_OF_RANGE', name: '社保基数超出范围', status: 'FAIL', message: `${entry.employee.name} 的社保基数 ¥${base} 不在范围内(${socialConfig.baseMin} ~ ${socialConfig.baseMax})`, employeeId: entry.employeeId, employeeName: entry.employee.name, detail: { base, min: socialConfig.baseMin, max: socialConfig.baseMax }, }) } } } // 2. 公积金基数是否在上下限范围内 if (housingConfig) { for (const entry of entries) { const base = entry.employee.housingFundBase || entry.baseSalary if (base < housingConfig.baseMin || base > housingConfig.baseMax) { checks.push({ code: 'HOUSING_BASE_OUT_OF_RANGE', name: '公积金基数超出范围', status: 'FAIL', message: `${entry.employee.name} 的公积金基数 ¥${base} 不在范围内(${housingConfig.baseMin} ~ ${housingConfig.baseMax})`, employeeId: entry.employeeId, employeeName: entry.employee.name, detail: { base, min: housingConfig.baseMin, max: housingConfig.baseMax }, }) } } } // 3. 加班时数是否超过法定上限(月 36 小时) for (const entry of entries) { const overtimeRecords = await prisma.overtimeRecord.findMany({ where: { orgId, employeeId: entry.employeeId, month }, }) for (const ot of overtimeRecords) { const totalOT = ot.weekdayHours + ot.weekendHours + ot.holidayHours if (totalOT > 36) { checks.push({ code: 'OVERTIME_EXCEED_LIMIT', name: '加班超法定上限', status: 'WARNING', message: `${entry.employee.name} 本月加班 ${totalOT} 小时,超过法定月上限 36 小时`, employeeId: entry.employeeId, employeeName: entry.employee.name, detail: { totalHours: totalOT, limit: 36 }, }) } } } // 4. 个税累计预扣跳档检测 const year = month.slice(0, 4) for (const entry of entries) { const prevPayslips = await prisma.payslip.findMany({ where: { orgId, employeeId: entry.employeeId, month: { startsWith: year, lt: month } }, select: { tax: true, totalPay: true }, }) if (prevPayslips.length >= 2) { const avgTax = prevPayslips.reduce((s, p) => s + p.tax, 0) / prevPayslips.length if (entry.tax > avgTax * 3 && entry.tax > 1000) { checks.push({ code: 'TAX_BRACKET_JUMP', name: '个税跳档警告', status: 'WARNING', message: `${entry.employee.name} 本月个税 ¥${entry.tax} 明显高于往月均值 ¥${avgTax.toFixed(0)},可能存在累计预扣跳档`, employeeId: entry.employeeId, employeeName: entry.employee.name, detail: { currentTax: entry.tax, avgTax }, }) } } } // 5. 试用期工资是否低于合同工资 80% for (const entry of entries) { const latestContract = await prisma.laborContract.findFirst({ where: { employeeId: entry.employeeId, orgId }, orderBy: { createdAt: 'desc' }, }) if (latestContract && latestContract.probationMonths > 0 && latestContract.probationSalary > 0) { const probationEnd = new Date(latestContract.startDate) probationEnd.setMonth(probationEnd.getMonth() + latestContract.probationMonths) if (probationEnd > new Date() && entry.baseSalary < latestContract.probationSalary * 0.8) { checks.push({ code: 'PROBATION_SALARY_TOO_LOW', name: '试用期工资低于法定下限', status: 'FAIL', message: `${entry.employee.name} 试用期工资 ¥${entry.baseSalary} 低于合同工资的 80%(¥${latestContract.probationSalary * 0.8})`, employeeId: entry.employeeId, employeeName: entry.employee.name, detail: { actualSalary: entry.baseSalary, minSalary: latestContract.probationSalary * 0.8 }, }) } } } // 6. 离职员工是否多算了一个月 for (const entry of entries) { if (entry.employee.status === 'RESIGNED') { const termination = await prisma.terminationRecord.findFirst({ where: { employeeId: entry.employeeId, status: { notIn: ['CANCELLED', 'DRAFT'] } }, orderBy: { terminationDate: 'desc' }, }) if (termination) { const termMonth = termination.terminationDate.toISOString().slice(0, 7) if (month > termMonth) { checks.push({ code: 'RESIGNED_OVERPAY', name: '离职员工多算工资', status: 'FAIL', message: `${entry.employee.name} 已于 ${termination.terminationDate.toISOString().slice(0, 10)} 离职,但 ${month} 仍有工资记录`, employeeId: entry.employeeId, employeeName: entry.employee.name, detail: { terminationDate: termination.terminationDate, payrollMonth: month }, }) } } } } // 7. 新入职员工是否按实际入职日折算 for (const entry of entries) { const hireDate = entry.employee.hireDate const hireMonth = hireDate.toISOString().slice(0, 7) if (hireMonth === month) { const daysInMonth = new Date(hireDate.getFullYear(), hireDate.getMonth() + 1, 0).getDate() const actualWorkDays = daysInMonth - hireDate.getDate() + 1 // 如果基本工资等于整月工资,提示可能未折算 const fullMonthSalary = Number(entry.employee.monthlySalary) || entry.baseSalary if (Math.abs(entry.baseSalary - fullMonthSalary) < 1 && actualWorkDays < daysInMonth) { checks.push({ code: 'NEW_HIRE_NO_PRORATE', name: '新入职未折算工资', status: 'WARNING', message: `${entry.employee.name} 本月 ${hireDate.getDate()} 日入职,工资可能未按实际天数折算(实际工作 ${actualWorkDays} 天)`, employeeId: entry.employeeId, employeeName: entry.employee.name, detail: { hireDate: hireDate.toISOString().slice(0, 10), actualWorkDays, daysInMonth }, }) } } } // 8. 社保是否在入职 30 天内参保 for (const entry of entries) { const hireDate = entry.employee.hireDate const socialStart = entry.employee.socialInsStartMonth if (socialStart) { const socialStartDate = new Date(socialStart + '-01') const daysDiff = Math.floor((socialStartDate.getTime() - hireDate.getTime()) / 86400000) if (daysDiff > 30) { checks.push({ code: 'SOCIAL_INS_LATE_ENROLL', name: '社保参保延迟', status: 'WARNING', message: `${entry.employee.name} 入职 ${daysDiff} 天后才参保社保,超过 30 天法定期限`, employeeId: entry.employeeId, employeeName: entry.employee.name, detail: { hireDate: hireDate.toISOString().slice(0, 10), socialStart, daysDiff }, }) } } } // 9. 离职当月社保是否已停保 for (const entry of entries) { if (entry.employee.status === 'RESIGNED' && entry.employee.socialInsEndMonth) { // 正常,已停保 } else if (entry.employee.status === 'RESIGNED' && !entry.employee.socialInsEndMonth) { checks.push({ code: 'SOCIAL_INS_NOT_STOPPED', name: '离职未停保', status: 'WARNING', message: `${entry.employee.name} 已离职但社保未停保,可能产生多缴费用`, employeeId: entry.employeeId, employeeName: entry.employee.name, }) } } // 10. 基本工资为 0 for (const entry of entries) { if (entry.baseSalary === 0 && batch.type === 'REGULAR') { checks.push({ code: 'ZERO_BASE_SALARY', name: '基本工资为 0', status: 'WARNING', message: `${entry.employee.name} 的基本工资为 0,请确认是否正确`, employeeId: entry.employeeId, employeeName: entry.employee.name, }) } } // 汇总 const passedCount = entries.length > 0 ? Math.max(0, entries.length - checks.filter(c => c.employeeId).length) : 0 const failedCount = checks.filter(c => c.status === 'FAIL').length const warningCount = checks.filter(c => c.status === 'WARNING').length return { checks, passedCount, failedCount, warningCount } }