feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import type { RiskLevel, RiskType } from '@prisma/client'
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
export async function detectContractRisks(orgId: string) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE', hireDate: { lte: today } },
|
||||
include: { contracts: { orderBy: { createdAt: 'desc' } } },
|
||||
})
|
||||
|
||||
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||
|
||||
for (const emp of employees) {
|
||||
const latestContract = emp.contracts[0]
|
||||
|
||||
if (!latestContract || latestContract.contractType === 'UNSIGNED') {
|
||||
const days = daysBetween(new Date(), emp.hireDate)
|
||||
if (days > 365) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}入职${days}天未签合同,已视为无固定期限`,
|
||||
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},超过1年未签订书面合同,法律上已视为无固定期限劳动合同。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else if (days > 30) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}入职${days}天未签合同`,
|
||||
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},超过30天未签订书面合同,需尽快补签。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'LOW',
|
||||
title: `${emp.name}入职${days}天,尚未签合同`,
|
||||
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},30天内需签订书面合同。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (latestContract.endDate) {
|
||||
const daysToExpire = daysBetween(latestContract.endDate, new Date())
|
||||
if (daysToExpire < 0) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}的合同已到期${Math.abs(daysToExpire)}天未续签`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},已过期未续签。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
} else if (daysToExpire <= 30) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}的合同即将到期(${daysToExpire}天)`,
|
||||
description: `合同到期日 ${latestContract.endDate.toISOString().slice(0, 10)},需提前准备续签或终止。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (latestContract.probationMonths > 0) {
|
||||
const contractMonths = latestContract.endDate
|
||||
? Math.ceil(daysBetween(latestContract.endDate, latestContract.startDate) / 30.44)
|
||||
: 36
|
||||
let maxProbation = 0
|
||||
if (contractMonths >= 36) maxProbation = 6
|
||||
else if (contractMonths >= 12) maxProbation = 2
|
||||
else if (contractMonths >= 3) maxProbation = 1
|
||||
|
||||
if (latestContract.probationMonths > maxProbation) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'CONTRACT',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}试用期${latestContract.probationMonths}个月可能不合法`,
|
||||
description: `${contractMonths}个月合同试用期最多${maxProbation}个月,当前${latestContract.probationMonths}个月超出法定上限。`,
|
||||
actionUrl: `/contracts?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
// 预入职检查:入职日期已到但未签合同 → 待办
|
||||
export async function detectOnboardingRisks(orgId: string) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE', hireDate: { lte: today } },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
terminations: { where: { terminationDate: { lte: today } }, take: 1 },
|
||||
},
|
||||
})
|
||||
|
||||
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||
|
||||
for (const emp of employees) {
|
||||
// 已离职的跳过
|
||||
if (emp.terminations.length > 0) continue
|
||||
|
||||
const latestContract = emp.contracts[0]
|
||||
const hasSignedContract = latestContract && latestContract.contractType !== 'UNSIGNED'
|
||||
|
||||
if (!hasSignedContract) {
|
||||
const daysSinceHire = daysBetween(today, emp.hireDate)
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'ONBOARDING',
|
||||
level: daysSinceHire > 30 ? 'HIGH' : 'MEDIUM',
|
||||
title: `${emp.name}入职手续未完成${daysSinceHire > 30 ? `(已超${daysSinceHire}天)` : ''}`,
|
||||
description: `入职日期 ${emp.hireDate.toISOString().slice(0, 10)},尚未签订劳动合同,请尽快完成入职手续。`,
|
||||
actionUrl: `/roster?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
export async function detectTerminationRisks(orgId: string) {
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: { orgId, status: 'ACTIVE' },
|
||||
})
|
||||
|
||||
const risks: { employeeId: string; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||
|
||||
for (const emp of employees) {
|
||||
if (emp.isPregnant) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'TERMINATION',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}处于孕期/哺乳期,解聘受限`,
|
||||
description: '三期女职工不得依非过错理由解除劳动合同,否则面临违法解除赔偿金风险。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
if (emp.isInMedicalPeriod) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'TERMINATION',
|
||||
level: 'MEDIUM',
|
||||
title: `${emp.name}处于医疗期,解聘需谨慎`,
|
||||
description: '医疗期内不得解除劳动合同(非过错理由),需等待医疗期结束。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
if (emp.isWorkInjured) {
|
||||
risks.push({
|
||||
employeeId: emp.id,
|
||||
type: 'TERMINATION',
|
||||
level: 'HIGH',
|
||||
title: `${emp.name}工伤期间,解聘受限`,
|
||||
description: '工伤职工在停工留薪期内不得解除劳动合同。',
|
||||
actionUrl: `/termination?employee=${encodeURIComponent(emp.name)}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
export async function detectMonthlyTasks(orgId: string) {
|
||||
const setting = await prisma.notificationSetting.findUnique({ where: { orgId } })
|
||||
if (!setting) return []
|
||||
|
||||
const now = new Date()
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const today = now.getDate()
|
||||
|
||||
const tasks = [
|
||||
{ day: setting.payrollDay, title: `${currentMonth}月 发放工资`, desc: `每月${setting.payrollDay}日前完成工资发放`, url: '/money' },
|
||||
{ day: setting.socialInsDay, title: `${currentMonth}月 缴纳社保`, desc: `每月${setting.socialInsDay}日前完成社保缴纳`, url: '/money' },
|
||||
{ day: setting.housingFundDay, title: `${currentMonth}月 缴纳公积金`, desc: `每月${setting.housingFundDay}日前完成公积金缴纳`, url: '/money' },
|
||||
{ day: setting.taxDay, title: `${currentMonth}月 申报个税`, desc: `每月${setting.taxDay}日前完成个税申报`, url: '/money' },
|
||||
]
|
||||
|
||||
const risks: { employeeId: null; type: RiskType; level: RiskLevel; title: string; description: string; actionUrl: string }[] = []
|
||||
|
||||
for (const task of tasks) {
|
||||
// 当月已过截止日或正好到截止日时生成提醒
|
||||
if (today >= task.day) {
|
||||
risks.push({
|
||||
employeeId: null,
|
||||
type: 'MONTHLY',
|
||||
level: today > task.day + 3 ? 'HIGH' : 'MEDIUM',
|
||||
title: task.title,
|
||||
description: task.desc,
|
||||
actionUrl: task.url,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 工资条生成提醒:当月有已归档批次时提醒生成工资条
|
||||
const archivedBatches = await prisma.payrollBatch.count({
|
||||
where: { orgId, month: currentMonth, status: 'ARCHIVED' },
|
||||
})
|
||||
if (archivedBatches > 0) {
|
||||
risks.push({
|
||||
employeeId: null,
|
||||
type: 'SALARY',
|
||||
level: 'MEDIUM',
|
||||
title: `${currentMonth}月 生成工资条`,
|
||||
description: `本月有 ${archivedBatches} 个已归档工资批次,请前往工资条管理汇总生成工资条`,
|
||||
actionUrl: '/money',
|
||||
})
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
export async function runRiskDetection(orgId: string) {
|
||||
const existingRisks = await prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
})
|
||||
const existingKeys = new Set(existingRisks.map((r: typeof existingRisks[number]) => `${r.employeeId}:${r.type}:${r.actionUrl}`))
|
||||
|
||||
// 当月任务去重:检查所有状态(含 RESOLVED/IGNORED),避免已完成的当月任务被重新创建
|
||||
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`
|
||||
const monthlyExisting = await prisma.riskItem.findMany({
|
||||
where: { orgId, title: { startsWith: `${currentMonth}月` } },
|
||||
select: { employeeId: true, title: true },
|
||||
})
|
||||
const monthlyKeys = new Set(monthlyExisting.map((r: typeof monthlyExisting[number]) => `${r.employeeId}:${r.title}`))
|
||||
|
||||
const contractRisks = await detectContractRisks(orgId)
|
||||
const terminationRisks = await detectTerminationRisks(orgId)
|
||||
const onboardingRisks = await detectOnboardingRisks(orgId)
|
||||
const monthlyTasks = await detectMonthlyTasks(orgId)
|
||||
|
||||
// 月度任务用 monthlyKeys 去重,其他任务用 existingKeys 去重
|
||||
const nonMonthlyRisks = [...contractRisks, ...terminationRisks, ...onboardingRisks]
|
||||
const toCreate = [
|
||||
...nonMonthlyRisks.filter((r) => !existingKeys.has(`${r.employeeId}:${r.type}:${r.actionUrl}`)),
|
||||
...monthlyTasks.filter((r) => !monthlyKeys.has(`${r.employeeId}:${r.title}`)),
|
||||
]
|
||||
|
||||
if (toCreate.length > 0) {
|
||||
await prisma.riskItem.createMany({
|
||||
data: toCreate.map((r) => ({
|
||||
orgId,
|
||||
employeeId: r.employeeId,
|
||||
type: r.type,
|
||||
level: r.level,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
actionUrl: r.actionUrl,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
return toCreate.length
|
||||
}
|
||||
|
||||
export async function getDashboardData(orgId: string) {
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
const now = new Date()
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59)
|
||||
|
||||
const [
|
||||
employeeCount, highRisks, pendingRisks, riskItems, resolvedItems,
|
||||
overtimeRecords, payslips, batchEntries, socialConfig, housingConfig,
|
||||
monthContracts, monthTerminations, monthDisciplinary, monthAttendance,
|
||||
monthSeverancePay,
|
||||
] = await Promise.all([
|
||||
prisma.employee.count({ where: { orgId, status: 'ACTIVE' } }),
|
||||
prisma.riskItem.count({ where: { orgId, status: 'PENDING', level: 'HIGH', type: { in: ['CONTRACT', 'TERMINATION'] } } }),
|
||||
prisma.riskItem.count({ where: { orgId, status: 'PENDING' } }),
|
||||
prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'PENDING' },
|
||||
include: { employee: true },
|
||||
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
|
||||
take: 10,
|
||||
}),
|
||||
prisma.riskItem.findMany({
|
||||
where: { orgId, status: 'RESOLVED' },
|
||||
include: { employee: true },
|
||||
orderBy: { resolvedAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
prisma.overtimeRecord.findMany({
|
||||
where: { orgId, month: currentMonth },
|
||||
select: { totalPay: true, weekdayHours: true, weekendHours: true, holidayHours: true },
|
||||
}),
|
||||
prisma.payslip.findMany({
|
||||
where: { orgId, month: currentMonth },
|
||||
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, totalPay: true, confirmedAt: true },
|
||||
}),
|
||||
// 已归档批次的条目(用于总览汇总)
|
||||
prisma.batchEntry.findMany({
|
||||
where: { orgId, batch: { month: currentMonth, status: 'ARCHIVED' } },
|
||||
select: { baseSalary: true, overtimePay: true, allowance: true, deduction: true, bonus: true, totalPay: true, socialEmp: true, socialOrg: true, housingEmp: true, housingOrg: true, tax: true, netPay: true, employeeId: true },
|
||||
}),
|
||||
prisma.socialInsuranceConfig.findFirst({ where: { orgId, isCurrent: true } }),
|
||||
prisma.housingFundConfig.findFirst({ where: { orgId, isCurrent: true } }),
|
||||
prisma.laborContract.count({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.terminationRecord.count({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.disciplinaryRecord.count({
|
||||
where: { orgId, violationDate: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.attendanceRecord.count({
|
||||
where: { orgId, date: { gte: monthStart, lte: monthEnd } },
|
||||
}),
|
||||
prisma.terminationRecord.aggregate({
|
||||
where: { orgId, createdAt: { gte: monthStart, lte: monthEnd } },
|
||||
_sum: { compensation: true },
|
||||
}),
|
||||
])
|
||||
|
||||
const monthlyOvertimePay = overtimeRecords.reduce((sum: number, r: typeof overtimeRecords[number]) => sum + r.totalPay, 0)
|
||||
|
||||
// 本月薪税汇总:优先从已归档批次汇总,无归档批次则用工资条数据
|
||||
const archivedEntries = batchEntries
|
||||
const useArchivedData = archivedEntries.length > 0
|
||||
|
||||
let totalBaseSalary: number, totalOvertimePay: number, totalAllowance: number, totalDeduction: number, totalPay: number
|
||||
let totalSocialOrg: number, totalSocialEmp: number, totalHousingOrg: number, totalHousingEmp: number, totalTax: number, totalNetPay: number
|
||||
let payslipCount: number, confirmedPayslips: number
|
||||
|
||||
if (useArchivedData) {
|
||||
// 从已归档批次条目汇总(同一员工多批次的金额累加)
|
||||
const empMap = new Map<string, any>()
|
||||
for (const e of archivedEntries) {
|
||||
const ex = empMap.get(e.employeeId) || { baseSalary: 0, overtimePay: 0, allowance: 0, deduction: 0, bonus: 0, totalPay: 0, socialEmp: 0, socialOrg: 0, housingEmp: 0, housingOrg: 0, tax: 0, netPay: 0 }
|
||||
ex.baseSalary += e.baseSalary
|
||||
ex.overtimePay += e.overtimePay
|
||||
ex.allowance += e.allowance
|
||||
ex.deduction += e.deduction
|
||||
ex.bonus += e.bonus
|
||||
ex.totalPay += e.totalPay
|
||||
ex.socialEmp += e.socialEmp
|
||||
ex.socialOrg += e.socialOrg
|
||||
ex.housingEmp += e.housingEmp
|
||||
ex.housingOrg += e.housingOrg
|
||||
ex.tax += e.tax
|
||||
ex.netPay += e.netPay
|
||||
empMap.set(e.employeeId, ex)
|
||||
}
|
||||
const summary = Array.from(empMap.values())
|
||||
totalBaseSalary = summary.reduce((s, e) => s + e.baseSalary, 0)
|
||||
totalOvertimePay = summary.reduce((s, e) => s + e.overtimePay, 0)
|
||||
totalAllowance = summary.reduce((s, e) => s + e.allowance, 0)
|
||||
totalDeduction = summary.reduce((s, e) => s + e.deduction, 0)
|
||||
totalPay = summary.reduce((s, e) => s + e.totalPay, 0)
|
||||
totalSocialOrg = summary.reduce((s, e) => s + e.socialOrg, 0)
|
||||
totalSocialEmp = summary.reduce((s, e) => s + e.socialEmp, 0)
|
||||
totalHousingOrg = summary.reduce((s, e) => s + e.housingOrg, 0)
|
||||
totalHousingEmp = summary.reduce((s, e) => s + e.housingEmp, 0)
|
||||
totalTax = summary.reduce((s, e) => s + e.tax, 0)
|
||||
totalNetPay = summary.reduce((s, e) => s + e.netPay, 0)
|
||||
payslipCount = summary.length
|
||||
confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length
|
||||
} else {
|
||||
// fallback:从工资条表汇总
|
||||
totalBaseSalary = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.baseSalary, 0)
|
||||
totalOvertimePay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.overtimePay, 0)
|
||||
totalAllowance = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.allowance, 0)
|
||||
totalDeduction = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.deduction, 0)
|
||||
totalPay = payslips.reduce((s: number, p: typeof payslips[number]) => s + p.totalPay, 0)
|
||||
totalSocialOrg = 0
|
||||
totalSocialEmp = 0
|
||||
totalHousingOrg = 0
|
||||
totalHousingEmp = 0
|
||||
totalTax = 0
|
||||
totalNetPay = 0
|
||||
payslipCount = payslips.length
|
||||
confirmedPayslips = payslips.filter((p: typeof payslips[number]) => p.confirmedAt).length
|
||||
}
|
||||
|
||||
// 社保公积金:优先用归档批次的实际计算值,否则估算
|
||||
let socialOrgTotal = 0
|
||||
let socialEmpTotal = 0
|
||||
let housingOrgTotal = 0
|
||||
let housingEmpTotal = 0
|
||||
if (useArchivedData) {
|
||||
socialOrgTotal = totalSocialOrg
|
||||
socialEmpTotal = totalSocialEmp
|
||||
housingOrgTotal = totalHousingOrg
|
||||
housingEmpTotal = totalHousingEmp
|
||||
} else if (socialConfig && employeeCount > 0) {
|
||||
// 用平均工资作为估算基数
|
||||
const avgBase = employeeCount > 0 ? Math.max(socialConfig.baseMin, Math.min(socialConfig.baseMax, totalBaseSalary / Math.max(employeeCount, 1))) : socialConfig.baseMin
|
||||
socialOrgTotal = avgBase * (socialConfig.pensionOrg + socialConfig.medicalOrg + socialConfig.unemploymentOrg + socialConfig.injuryOrg + socialConfig.maternityOrg) / 100 * employeeCount
|
||||
socialEmpTotal = avgBase * (socialConfig.pensionEmp + socialConfig.medicalEmp + socialConfig.unemploymentEmp) / 100 * employeeCount
|
||||
housingOrgTotal = avgBase * (housingConfig?.housingOrg ?? 0) / 100 * employeeCount
|
||||
housingEmpTotal = avgBase * (housingConfig?.housingEmp ?? 0) / 100 * employeeCount
|
||||
}
|
||||
|
||||
// 个税:优先用归档批次的实际计算值,否则估算
|
||||
let estimatedTax = 0
|
||||
if (useArchivedData) {
|
||||
estimatedTax = totalTax
|
||||
} else {
|
||||
const taxableIncome = Math.max(0, totalPay - 5000 * payslips.length - socialEmpTotal - housingEmpTotal)
|
||||
if (taxableIncome <= 3000) estimatedTax = taxableIncome * 0.03
|
||||
else if (taxableIncome <= 12000) estimatedTax = 3000 * 0.03 + (taxableIncome - 3000) * 0.1
|
||||
else if (taxableIncome <= 25000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + (taxableIncome - 12000) * 0.2
|
||||
else if (taxableIncome <= 35000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + (taxableIncome - 25000) * 0.25
|
||||
else if (taxableIncome <= 55000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + (taxableIncome - 35000) * 0.3
|
||||
else if (taxableIncome <= 80000) estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + 20000 * 0.3 + (taxableIncome - 55000) * 0.35
|
||||
else estimatedTax = 3000 * 0.03 + 9000 * 0.1 + 13000 * 0.2 + 10000 * 0.25 + 20000 * 0.3 + 25000 * 0.35 + (taxableIncome - 80000) * 0.45
|
||||
}
|
||||
|
||||
const payrollSummary = {
|
||||
month: currentMonth,
|
||||
employeeCount,
|
||||
payslipCount,
|
||||
confirmedPayslips,
|
||||
unconfirmedPayslips: payslipCount - confirmedPayslips,
|
||||
baseSalary: totalBaseSalary,
|
||||
overtimePay: totalOvertimePay,
|
||||
allowance: totalAllowance,
|
||||
deduction: totalDeduction,
|
||||
totalPay,
|
||||
socialOrg: socialOrgTotal,
|
||||
socialEmp: socialEmpTotal,
|
||||
housingOrg: housingOrgTotal,
|
||||
housingEmp: housingEmpTotal,
|
||||
estimatedTax,
|
||||
severancePay: monthSeverancePay._sum.compensation || 0,
|
||||
// 企业总成本 = 工资总额 + 企业社保 + 企业公积金 + 经济补偿金
|
||||
orgTotalCost: totalPay + socialOrgTotal + housingOrgTotal + (monthSeverancePay._sum.compensation || 0),
|
||||
// 员工实发 = 工资总额 - 个人社保 - 个人公积金 - 个税
|
||||
empNetPay: useArchivedData ? totalNetPay : totalPay - socialEmpTotal - housingEmpTotal - estimatedTax,
|
||||
}
|
||||
|
||||
// 本月工作动态
|
||||
const monthlyActivities = {
|
||||
month: currentMonth,
|
||||
newContracts: monthContracts,
|
||||
terminations: monthTerminations,
|
||||
disciplinaryActions: monthDisciplinary,
|
||||
attendanceRecords: monthAttendance,
|
||||
overtimeHours: overtimeRecords.reduce((s: number, r: typeof overtimeRecords[number]) => s + r.weekdayHours + r.weekendHours + r.holidayHours, 0),
|
||||
overtimePay: monthlyOvertimePay,
|
||||
}
|
||||
|
||||
const riskDistribution = {
|
||||
contract: riskItems.filter((r: typeof riskItems[number]) => r.type === 'CONTRACT').length,
|
||||
salary: riskItems.filter((r: typeof riskItems[number]) => r.type === 'SALARY').length,
|
||||
termination: riskItems.filter((r: typeof riskItems[number]) => r.type === 'TERMINATION').length,
|
||||
}
|
||||
|
||||
const topRisks = riskItems
|
||||
.filter((r: typeof riskItems[number]) => r.level === 'HIGH')
|
||||
.slice(0, 5)
|
||||
.map((r: typeof riskItems[number]) => ({
|
||||
id: r.id,
|
||||
type: r.type as string,
|
||||
level: r.level.toLowerCase() as string,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
employeeName: r.employee?.name || null,
|
||||
actionUrl: r.actionUrl || '/',
|
||||
}))
|
||||
|
||||
const todos = riskItems.map((r: typeof riskItems[number]) => ({
|
||||
id: r.id,
|
||||
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
|
||||
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
actionUrl: r.actionUrl || '/',
|
||||
}))
|
||||
|
||||
const resolvedTodos = resolvedItems.map((r: typeof resolvedItems[number]) => ({
|
||||
id: r.id,
|
||||
type: r.type as 'CONTRACT' | 'SALARY' | 'TERMINATION' | 'MONTHLY',
|
||||
level: r.level.toLowerCase() as 'high' | 'medium' | 'low',
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
actionUrl: r.actionUrl || '/',
|
||||
resolvedAt: r.resolvedAt?.toISOString() || null,
|
||||
}))
|
||||
|
||||
const hour = new Date().getHours()
|
||||
const greeting = hour < 12
|
||||
? `早上好!今天有 ${pendingRisks} 件事需要处理`
|
||||
: hour < 18
|
||||
? `下午好!今天有 ${pendingRisks} 件事需要处理`
|
||||
: `晚上好!今天有 ${pendingRisks} 件事需要处理`
|
||||
|
||||
return {
|
||||
greeting,
|
||||
stats: {
|
||||
employeeCount,
|
||||
highRiskCount: highRisks,
|
||||
todoCount: pendingRisks,
|
||||
monthlyOvertimePay,
|
||||
},
|
||||
todos,
|
||||
resolvedTodos,
|
||||
riskDistribution,
|
||||
topRisks,
|
||||
aiPrediction: null,
|
||||
payrollSummary,
|
||||
monthlyActivities,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user