feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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<string[]> {
|
||||
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.length) {
|
||||
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<string, any>()
|
||||
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)
|
||||
|
||||
let generated = 0
|
||||
for (const [employeeId, summary] of employeeMap) {
|
||||
// 获取当年之前月份的累计数据
|
||||
const prevPayslips = await prisma.payslip.findMany({
|
||||
where: { orgId, employeeId, month: { startsWith: year, lt: month } },
|
||||
select: { totalPay: true, tax: true, socialEmp: true, housingEmp: true },
|
||||
})
|
||||
const ytdIncome = prevPayslips.reduce((s, p) => s + p.totalPay, 0) + summary.totalPay
|
||||
const ytdTaxDeducted = prevPayslips.reduce((s, p) => s + p.tax, 0) + summary.tax
|
||||
const ytdSocialEmp = prevPayslips.reduce((s, p) => s + p.socialEmp, 0) + summary.socialEmp
|
||||
const ytdHousingEmp = prevPayslips.reduce((s, p) => s + p.housingEmp, 0) + 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user