feat: AIHR 智能人力资源管理系统初始提交
- 员工花名册管理(加密存储、导入导出) - 薪酬管理(发薪批次、薪酬模版、加班费计算、工资条) - 社保公积金(多城市配置、版本管理、基数调整) - 解聘管理(6步流程、证据链、工作交接) - AI 助手(合同审查、风险预测、RAG 知识库) - Dashboard 仪表盘 - 设置与通知
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import OpenAI from 'openai'
|
||||
import { searchKnowledge } from './rag.service'
|
||||
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY || ''
|
||||
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
|
||||
const client = new OpenAI({ apiKey, baseURL, timeout: 30 * 1000, maxRetries: 1 })
|
||||
|
||||
const SYSTEM_PROMPT = `你是一个专业的劳动用工合规顾问,精通中国劳动法、劳动合同法、社会保险法等相关法律法规。
|
||||
|
||||
你的职责:
|
||||
1. 回答用户关于劳动用工的合规问题
|
||||
2. 基于企业实际数据给出针对性建议
|
||||
3. 引用具体法律条文作为依据
|
||||
4. 用通俗易懂的语言解释法律问题
|
||||
|
||||
回答要求:
|
||||
- 先给出直接结论,再展开解释
|
||||
- 引用法律条文时标注具体法律名称和条款号
|
||||
- 涉及金额时给出计算过程
|
||||
- 如有关联的企业数据,在回答中提及
|
||||
- 回答简洁有力,避免冗长`
|
||||
|
||||
export async function chat(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
|
||||
const lastUserMsg = messages.filter(m => m.role === 'user').pop()
|
||||
let ragContext = ''
|
||||
if (lastUserMsg) {
|
||||
try {
|
||||
const knowledge = await searchKnowledge(lastUserMsg.content, 3)
|
||||
if (knowledge.length > 0) {
|
||||
ragContext = `\n\n相关法律条文(RAG检索结果):\n${knowledge.join('\n\n')}`
|
||||
}
|
||||
} catch { /* RAG not available, continue without */ }
|
||||
}
|
||||
|
||||
const systemMessage = orgContext
|
||||
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}${ragContext}`
|
||||
: `${SYSTEM_PROMPT}${ragContext}`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{ role: 'system', content: systemMessage },
|
||||
...messages,
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
export async function* chatStream(messages: { role: 'user' | 'assistant'; content: string }[], orgContext?: string) {
|
||||
const lastUserMsg = messages.filter(m => m.role === 'user').pop()
|
||||
let ragContext = ''
|
||||
if (lastUserMsg) {
|
||||
try {
|
||||
const knowledge = await searchKnowledge(lastUserMsg.content, 3)
|
||||
if (knowledge.length > 0) {
|
||||
ragContext = `\n\n相关法律条文(RAG检索结果):\n${knowledge.join('\n\n')}`
|
||||
}
|
||||
} catch { /* RAG not available, continue without */ }
|
||||
}
|
||||
|
||||
const systemMessage = orgContext
|
||||
? `${SYSTEM_PROMPT}\n\n当前企业数据概览:\n${orgContext}${ragContext}`
|
||||
: `${SYSTEM_PROMPT}${ragContext}`
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{ role: 'system', content: systemMessage },
|
||||
...messages,
|
||||
],
|
||||
temperature: 0.7,
|
||||
max_tokens: 2000,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta?.content
|
||||
if (delta) yield delta
|
||||
}
|
||||
}
|
||||
|
||||
export async function reviewContract(contractText: string): Promise<{ text: string; structured: { riskItems: { level: string; title: string; description: string; suggestion: string }[]; score: number; summary: string } }> {
|
||||
const prompt = `请审查以下劳动合同文本的合法性,逐条检查并标注风险等级(红/黄/绿),给出修改建议,最后给出合规评分(0-100分)。
|
||||
|
||||
合同文本:
|
||||
${contractText}
|
||||
|
||||
请按以下格式输出:
|
||||
【风险项】
|
||||
🔴/🟡/🟢 [问题标题] - [说明] - [修改建议]
|
||||
|
||||
【合规评分】XX/100
|
||||
|
||||
【总体建议】
|
||||
一段话总结`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-max',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是劳动法合同审查专家,精通劳动合同法。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 3000,
|
||||
})
|
||||
|
||||
const text = response.choices[0]?.message?.content || ''
|
||||
|
||||
// 解析结构化数据
|
||||
const riskItems: { level: string; title: string; description: string; suggestion: string }[] = []
|
||||
const riskRegex = /(🔴|🟡|🟢)\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]\s*-\s*\[([^\]]+)\]/g
|
||||
let match
|
||||
while ((match = riskRegex.exec(text)) !== null) {
|
||||
riskItems.push({
|
||||
level: match[1] === '🔴' ? 'RED' : match[1] === '🟡' ? 'YELLOW' : 'GREEN',
|
||||
title: match[2],
|
||||
description: match[3],
|
||||
suggestion: match[4],
|
||||
})
|
||||
}
|
||||
|
||||
const scoreMatch = text.match(/【合规评分】\s*(\d+)\s*\/\s*100/)
|
||||
const score = scoreMatch ? parseInt(scoreMatch[1]) : 0
|
||||
|
||||
const summaryMatch = text.match(/【总体建议】\s*([\s\S]*?)(?:$|$)/)
|
||||
const summary = summaryMatch ? summaryMatch[1].trim() : ''
|
||||
|
||||
return { text, structured: { riskItems, score, summary } }
|
||||
}
|
||||
|
||||
export async function matchCase(scenario: string) {
|
||||
const prompt = `作为一个劳动法案例匹配专家,请分析以下劳动争议情形,匹配相似的仲裁/诉讼案例,评估败诉风险。
|
||||
|
||||
争议情形:
|
||||
${scenario}
|
||||
|
||||
请按以下格式输出:
|
||||
【相似案例】
|
||||
案例1:[案例标题]
|
||||
- 情形:[简要描述]
|
||||
- 结果:[判决结果]
|
||||
- 赔偿金额:[金额]
|
||||
- 相似度:XX%
|
||||
|
||||
案例2:...
|
||||
|
||||
【败诉风险评估】
|
||||
风险等级:高/中/低(XX%)
|
||||
原因:[分析]
|
||||
|
||||
【建议】
|
||||
[降低风险的具体建议]`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-max',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是劳动法案例分析专家,熟悉劳动仲裁和诉讼案例。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 3000,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
|
||||
export async function predictRisks(orgContext: string) {
|
||||
const prompt = `基于以下企业用工数据,预测未来30天可能出现的合规风险,并给出优先级建议。
|
||||
|
||||
企业数据:
|
||||
${orgContext}
|
||||
|
||||
请按以下格式输出:
|
||||
【未来30天预计风险】
|
||||
- [员工姓名/风险描述] → [建议措施]
|
||||
|
||||
【优先级建议】
|
||||
[先处理什么,再处理什么]`
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: 'qwen-plus',
|
||||
messages: [
|
||||
{ role: 'system', content: '你是劳动用工风险预测专家,能基于企业数据分析未来风险趋势。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0.5,
|
||||
max_tokens: 1500,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message?.content || ''
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
import prisma from '../lib/prisma'
|
||||
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../lib/jwt'
|
||||
|
||||
export async function register(orgName: string, phone: string, password: string) {
|
||||
const existing = await prisma.user.findUnique({ where: { phone } })
|
||||
if (existing) {
|
||||
throw { code: 'DUPLICATE', message: '该手机号已注册' }
|
||||
}
|
||||
|
||||
const org = await prisma.organization.create({
|
||||
data: {
|
||||
name: orgName,
|
||||
plan: 'FREE',
|
||||
maxEmployees: 20,
|
||||
},
|
||||
})
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10)
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
orgId: org.id,
|
||||
phone,
|
||||
name: '管理员',
|
||||
passwordHash,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
})
|
||||
|
||||
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
const refreshToken = signRefreshToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
|
||||
return {
|
||||
user: { id: user.id, orgId: user.orgId, name: user.name, phone: user.phone, role: user.role },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(phone: string, password: string) {
|
||||
const user = await prisma.user.findUnique({ where: { phone } })
|
||||
if (!user) {
|
||||
throw { code: 'NOT_FOUND', message: '手机号或密码错误' }
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash)
|
||||
if (!valid) {
|
||||
throw { code: 'AUTH_FAILED', message: '手机号或密码错误' }
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
throw { code: 'ACCOUNT_DISABLED', message: '该账号已被禁用,请联系管理员' }
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
})
|
||||
|
||||
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
const refreshToken = signRefreshToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
|
||||
return {
|
||||
user: { id: user.id, orgId: user.orgId, name: user.name, phone: user.phone, role: user.role },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
}
|
||||
}
|
||||
|
||||
export async function refresh(refreshToken: string) {
|
||||
const payload = verifyRefreshToken(refreshToken)
|
||||
if (!payload) {
|
||||
throw { code: 'TOKEN_INVALID', message: 'Refresh Token 无效或已过期' }
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: payload.id } })
|
||||
if (!user) {
|
||||
throw { code: 'NOT_FOUND', message: '用户不存在' }
|
||||
}
|
||||
|
||||
const accessToken = signAccessToken({ id: user.id, orgId: user.orgId, role: user.role })
|
||||
return { accessToken }
|
||||
}
|
||||
|
||||
export async function resetPassword(phone: string, newPassword: string) {
|
||||
const user = await prisma.user.findUnique({ where: { phone } })
|
||||
if (!user) {
|
||||
throw { code: 'NOT_FOUND', message: '手机号未注册' }
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10)
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { passwordHash },
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { encrypt, decrypt, sha256 } from '../lib/crypto'
|
||||
import { runRiskDetection } from './risk.service'
|
||||
|
||||
function daysBetween(a: Date, b: Date): number {
|
||||
return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24))
|
||||
}
|
||||
|
||||
function dateToMonth(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
function prevMonth(month: string): string {
|
||||
const [y, m] = month.split('-').map(Number)
|
||||
const d = new Date(y, m - 2, 1)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function getContractStatus(contract: {
|
||||
signDate: Date | null
|
||||
startDate: Date
|
||||
endDate: Date | null
|
||||
contractType: string
|
||||
hireDate: Date
|
||||
}): { status: string; statusText: string; riskLevel: 'high' | 'medium' | 'low' | 'safe' } {
|
||||
const today = new Date()
|
||||
const typeLabel = contract.contractType === 'FIXED' ? '固定期限' : contract.contractType === 'UNFIXED' ? '无固定期限' : ''
|
||||
|
||||
if (!contract.signDate || contract.contractType === 'UNSIGNED') {
|
||||
const days = daysBetween(today, contract.hireDate)
|
||||
if (days > 365) {
|
||||
return { status: 'unsigned_over_year', statusText: '未签合同(已视为无固定期限)', riskLevel: 'high' }
|
||||
} else if (days > 30) {
|
||||
return { status: 'unsigned_over_30', statusText: `未签合同(${days}天)`, riskLevel: 'high' }
|
||||
}
|
||||
return { status: 'unsigned', statusText: `未签合同(${days}天)`, riskLevel: 'medium' }
|
||||
}
|
||||
|
||||
if (contract.endDate) {
|
||||
const daysToExpire = daysBetween(contract.endDate, today)
|
||||
if (daysToExpire < 0) {
|
||||
return { status: 'expired', statusText: `${typeLabel}·已到期未续签`, riskLevel: 'high' }
|
||||
} else if (daysToExpire <= 30) {
|
||||
return { status: 'expiring', statusText: `${typeLabel}·即将到期(${daysToExpire}天)`, riskLevel: 'medium' }
|
||||
}
|
||||
return { status: 'active', statusText: `${typeLabel}·正常`, riskLevel: 'safe' }
|
||||
}
|
||||
|
||||
return { status: 'unfixed', statusText: '无固定期限·正常', riskLevel: 'safe' }
|
||||
}
|
||||
|
||||
export function validateProbation(contractMonths: number, probationMonths: number): { valid: boolean; max: number; message?: string } {
|
||||
let max = 0
|
||||
if (contractMonths >= 36) max = 6
|
||||
else if (contractMonths >= 12) max = 2
|
||||
else if (contractMonths >= 3) max = 1
|
||||
|
||||
if (probationMonths > max) {
|
||||
return {
|
||||
valid: false,
|
||||
max,
|
||||
message: `${contractMonths}个月合同试用期最多${max}个月,当前${probationMonths}个月不合法`,
|
||||
}
|
||||
}
|
||||
return { valid: true, max }
|
||||
}
|
||||
|
||||
export async function getEmployees(orgId: string, params: { page?: number; pageSize?: number; search?: string; department?: string }) {
|
||||
const page = params.page || 1
|
||||
const pageSize = params.pageSize || 20
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const where: any = { orgId, status: 'ACTIVE' }
|
||||
if (params.search) {
|
||||
where.OR = [
|
||||
{ name: { contains: params.search } },
|
||||
{ phone: { contains: params.search } },
|
||||
]
|
||||
}
|
||||
if (params.department) {
|
||||
where.department = params.department
|
||||
}
|
||||
|
||||
const [total, employees] = await Promise.all([
|
||||
prisma.employee.count({ where }),
|
||||
prisma.employee.findMany({
|
||||
where,
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
])
|
||||
|
||||
const items = employees.map((emp) => {
|
||||
const latestContract = emp.contracts[0]
|
||||
const contractInfo = latestContract
|
||||
? getContractStatus({
|
||||
signDate: latestContract.signDate,
|
||||
startDate: latestContract.startDate,
|
||||
endDate: latestContract.endDate,
|
||||
contractType: latestContract.contractType,
|
||||
hireDate: emp.hireDate,
|
||||
})
|
||||
: getContractStatus({
|
||||
signDate: null,
|
||||
startDate: emp.hireDate,
|
||||
endDate: null,
|
||||
contractType: 'UNSIGNED',
|
||||
hireDate: emp.hireDate,
|
||||
})
|
||||
|
||||
let decryptedSalary = 0
|
||||
try {
|
||||
decryptedSalary = Number(decrypt(emp.monthlySalary)) || 0
|
||||
} catch {
|
||||
decryptedSalary = Number(emp.monthlySalary) || 0
|
||||
}
|
||||
|
||||
return {
|
||||
id: emp.id,
|
||||
name: emp.name,
|
||||
department: emp.department,
|
||||
hireDate: emp.hireDate.toISOString().slice(0, 10),
|
||||
status: emp.status,
|
||||
monthlySalary: decryptedSalary,
|
||||
contractStatus: contractInfo.status,
|
||||
contractStatusText: contractInfo.statusText,
|
||||
riskLevel: contractInfo.riskLevel,
|
||||
isPregnant: emp.isPregnant,
|
||||
isInMedicalPeriod: emp.isInMedicalPeriod,
|
||||
isWorkInjured: emp.isWorkInjured,
|
||||
}
|
||||
})
|
||||
|
||||
return { items, total, page, pageSize, totalPages: Math.ceil(total / pageSize) }
|
||||
}
|
||||
|
||||
export async function getEmployeeDetail(orgId: string, id: string) {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id, orgId },
|
||||
include: {
|
||||
contracts: { orderBy: { createdAt: 'desc' } },
|
||||
riskItems: { where: { status: 'PENDING' }, orderBy: { level: 'asc' } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
let decryptedSalary = 0
|
||||
try {
|
||||
decryptedSalary = Number(decrypt(employee.monthlySalary)) || 0
|
||||
} catch {
|
||||
decryptedSalary = Number(employee.monthlySalary) || 0
|
||||
}
|
||||
|
||||
return {
|
||||
...employee,
|
||||
monthlySalary: decryptedSalary,
|
||||
}
|
||||
}
|
||||
|
||||
export async function createEmployee(orgId: string, userId: string, data: any) {
|
||||
const org = await prisma.organization.findUnique({ where: { id: orgId } })
|
||||
if (org && org.maxEmployees > 0) {
|
||||
const activeCount = await prisma.employee.count({ where: { orgId, status: 'ACTIVE' } })
|
||||
if (activeCount >= org.maxEmployees) {
|
||||
throw { code: 'PLAN_LIMIT', message: `当前套餐人数上限为 ${org.maxEmployees} 人,已达上限,请升级套餐` }
|
||||
}
|
||||
}
|
||||
|
||||
const hireDate = new Date(data.hireDate)
|
||||
const hireMonth = dateToMonth(hireDate)
|
||||
const salaryNum = Number(data.monthlySalary) || 0
|
||||
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
||||
const socialInsStartMonth = data.socialInsStartMonth || hireMonth
|
||||
const housingFundStartMonth = data.housingFundStartMonth || hireMonth
|
||||
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
orgId,
|
||||
name: data.name,
|
||||
department: data.department,
|
||||
hireDate,
|
||||
monthlySalary: encrypt(data.monthlySalary),
|
||||
gender: data.gender,
|
||||
phone: data.phone,
|
||||
idCardNumber: data.idCardNumber ? encrypt(data.idCardNumber) : null,
|
||||
idCardHash: data.idCardNumber ? sha256(data.idCardNumber) : null,
|
||||
isPregnant: data.isPregnant || false,
|
||||
isInMedicalPeriod: data.isInMedicalPeriod || false,
|
||||
isWorkInjured: data.isWorkInjured || false,
|
||||
socialInsBase,
|
||||
housingFundBase,
|
||||
socialInsStartMonth,
|
||||
housingFundStartMonth,
|
||||
createdBy: userId,
|
||||
city: data.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
// 创建社保缴费记录
|
||||
await prisma.employeeSocialInsRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
startMonth: socialInsStartMonth,
|
||||
endMonth: null,
|
||||
base: socialInsBase,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
city: data.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
// 创建公积金缴费记录
|
||||
await prisma.employeeHousingFundRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
startMonth: housingFundStartMonth,
|
||||
endMonth: null,
|
||||
base: housingFundBase,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
city: data.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
// 创建初始薪资变更记录
|
||||
await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
oldSalary: 0,
|
||||
newSalary: salaryNum,
|
||||
effectiveDate: hireDate,
|
||||
effectiveMonth: hireMonth,
|
||||
endMonth: null,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建初始部门记录
|
||||
await prisma.employeeDepartmentRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
oldDepartment: '',
|
||||
newDepartment: data.department,
|
||||
effectiveMonth: hireMonth,
|
||||
endMonth: null,
|
||||
changeType: 'ONBOARDING',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
if (data.contract && data.contract.contractType !== 'UNSIGNED') {
|
||||
const contractMonths = data.contract.endDate
|
||||
? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44)
|
||||
: data.contract.contractYears * 12
|
||||
|
||||
const probationCheck = validateProbation(contractMonths, data.contract.probationMonths)
|
||||
if (!probationCheck.valid) {
|
||||
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
|
||||
}
|
||||
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: employee.id,
|
||||
signDate: data.contract.signDate ? new Date(data.contract.signDate) : null,
|
||||
startDate: new Date(data.contract.startDate),
|
||||
endDate: data.contract.endDate ? new Date(data.contract.endDate) : null,
|
||||
contractType: data.contract.contractType,
|
||||
signMethod: data.contract.signMethod || 'PAPER',
|
||||
contractYears: data.contract.contractYears || 3,
|
||||
probationMonths: data.contract.probationMonths || 0,
|
||||
probationSalary: data.contract.probationSalary || 0,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { id: employee.id }
|
||||
}
|
||||
|
||||
// 重新入职:复用已有员工基本信息,更新入职日期和状态,可选创建新合同
|
||||
export async function rehireEmployee(orgId: string, userId: string, id: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id, orgId },
|
||||
include: { terminations: { orderBy: { terminationDate: 'desc' }, take: 1 } },
|
||||
})
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const isResigned = employee.terminations.some((t) => t.terminationDate <= today)
|
||||
if (!isResigned) {
|
||||
throw { code: 'CONFLICT', message: '该员工当前在职,无需重新入职' }
|
||||
}
|
||||
|
||||
const newHireDate = new Date(data.hireDate)
|
||||
const latestTerm = employee.terminations[0]
|
||||
if (latestTerm && newHireDate <= latestTerm.terminationDate) {
|
||||
throw { code: 'VALIDATION_ERROR', message: '新入职日期必须晚于上次离职/解聘日期' }
|
||||
}
|
||||
|
||||
const newHireMonth = dateToMonth(newHireDate)
|
||||
const salaryNum = Number(decrypt(employee.monthlySalary)) || 0
|
||||
const socialInsBase = data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum
|
||||
const housingFundBase = data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum
|
||||
const socialInsStartMonth = data.socialInsStartMonth || newHireMonth
|
||||
const housingFundStartMonth = data.housingFundStartMonth || newHireMonth
|
||||
const prevHireMonth = prevMonth(newHireMonth)
|
||||
|
||||
// 关闭旧社保缴费记录
|
||||
await prisma.employeeSocialInsRecord.updateMany({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
data: { endMonth: prevHireMonth },
|
||||
})
|
||||
|
||||
// 关闭旧公积金缴费记录
|
||||
await prisma.employeeHousingFundRecord.updateMany({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
data: { endMonth: prevHireMonth },
|
||||
})
|
||||
|
||||
// 关闭旧薪资记录
|
||||
await prisma.salaryChangeRecord.updateMany({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
data: { endMonth: prevHireMonth },
|
||||
})
|
||||
|
||||
// 关闭旧部门记录
|
||||
await prisma.employeeDepartmentRecord.updateMany({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
data: { endMonth: prevHireMonth },
|
||||
})
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id },
|
||||
data: {
|
||||
hireDate: newHireDate,
|
||||
status: 'ACTIVE',
|
||||
department: data.department || employee.department,
|
||||
isPregnant: false,
|
||||
isInMedicalPeriod: false,
|
||||
isWorkInjured: false,
|
||||
socialInsBase,
|
||||
housingFundBase,
|
||||
socialInsStartMonth,
|
||||
socialInsEndMonth: null,
|
||||
housingFundStartMonth,
|
||||
housingFundEndMonth: null,
|
||||
city: data.city || employee.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
// 创建新社保缴费记录
|
||||
await prisma.employeeSocialInsRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
startMonth: socialInsStartMonth,
|
||||
endMonth: null,
|
||||
base: socialInsBase,
|
||||
changeType: 'REHIRE',
|
||||
createdBy: userId,
|
||||
city: data.city || employee.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
// 创建新公积金缴费记录
|
||||
await prisma.employeeHousingFundRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
startMonth: housingFundStartMonth,
|
||||
endMonth: null,
|
||||
base: housingFundBase,
|
||||
changeType: 'REHIRE',
|
||||
createdBy: userId,
|
||||
city: data.city || employee.city || '北京',
|
||||
},
|
||||
})
|
||||
|
||||
// 创建新薪资记录
|
||||
await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
oldSalary: salaryNum,
|
||||
newSalary: salaryNum,
|
||||
effectiveDate: newHireDate,
|
||||
effectiveMonth: newHireMonth,
|
||||
endMonth: null,
|
||||
changeType: 'REHIRE',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 创建新部门记录
|
||||
await prisma.employeeDepartmentRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
oldDepartment: employee.department,
|
||||
newDepartment: data.department || employee.department,
|
||||
effectiveMonth: newHireMonth,
|
||||
endMonth: null,
|
||||
changeType: 'REHIRE',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
if (data.contract && data.contract.contractType !== 'UNSIGNED') {
|
||||
const contractMonths = data.contract.endDate
|
||||
? Math.ceil(daysBetween(new Date(data.contract.endDate), new Date(data.contract.startDate)) / 30.44)
|
||||
: data.contract.contractYears * 12
|
||||
|
||||
const probationCheck = validateProbation(contractMonths, data.contract.probationMonths)
|
||||
if (!probationCheck.valid) {
|
||||
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
|
||||
}
|
||||
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
signDate: data.contract.signDate ? new Date(data.contract.signDate) : null,
|
||||
startDate: new Date(data.contract.startDate),
|
||||
endDate: data.contract.endDate ? new Date(data.contract.endDate) : null,
|
||||
contractType: data.contract.contractType,
|
||||
signMethod: data.contract.signMethod || 'PAPER',
|
||||
contractYears: data.contract.contractYears || 3,
|
||||
probationMonths: data.contract.probationMonths || 0,
|
||||
probationSalary: data.contract.probationSalary || 0,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { id }
|
||||
}
|
||||
|
||||
export async function updateEmployee(orgId: string, id: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
const updateData: any = {}
|
||||
if (data.name !== undefined) updateData.name = data.name
|
||||
if (data.department !== undefined) updateData.department = data.department
|
||||
if (data.hireDate !== undefined) updateData.hireDate = new Date(data.hireDate)
|
||||
if (data.monthlySalary !== undefined) {
|
||||
const oldSalary = Number(decrypt(employee.monthlySalary)) || 0
|
||||
const newSalary = Number(data.monthlySalary) || 0
|
||||
updateData.monthlySalary = encrypt(data.monthlySalary)
|
||||
// 记录薪资变更
|
||||
if (oldSalary !== newSalary) {
|
||||
const now = new Date()
|
||||
const nowMonth = dateToMonth(now)
|
||||
// 关闭之前有效记录
|
||||
await prisma.salaryChangeRecord.updateMany({
|
||||
where: { employeeId: id, endMonth: null },
|
||||
data: { endMonth: prevMonth(nowMonth) },
|
||||
})
|
||||
await prisma.salaryChangeRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: id,
|
||||
oldSalary,
|
||||
newSalary,
|
||||
effectiveDate: now,
|
||||
effectiveMonth: nowMonth,
|
||||
endMonth: null,
|
||||
changeType: 'SALARY_CHANGE',
|
||||
reason: data.salaryChangeReason || '手动调整',
|
||||
createdBy: '',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if (data.gender !== undefined) updateData.gender = data.gender
|
||||
if (data.phone !== undefined) updateData.phone = data.phone
|
||||
if (data.bankName !== undefined) updateData.bankName = data.bankName
|
||||
if (data.bankAccount !== undefined) updateData.bankAccount = encrypt(data.bankAccount)
|
||||
if (data.emergencyContact !== undefined) updateData.emergencyContact = data.emergencyContact
|
||||
if (data.emergencyPhone !== undefined) updateData.emergencyPhone = data.emergencyPhone
|
||||
if (data.address !== undefined) updateData.address = data.address
|
||||
if (data.isPregnant !== undefined) updateData.isPregnant = data.isPregnant
|
||||
if (data.isInMedicalPeriod !== undefined) updateData.isInMedicalPeriod = data.isInMedicalPeriod
|
||||
if (data.isWorkInjured !== undefined) updateData.isWorkInjured = data.isWorkInjured
|
||||
if (data.socialInsBase !== undefined) updateData.socialInsBase = data.socialInsBase
|
||||
if (data.housingFundBase !== undefined) updateData.housingFundBase = data.housingFundBase
|
||||
if (data.specialDeduction !== undefined) updateData.specialDeduction = data.specialDeduction
|
||||
if (data.city !== undefined) updateData.city = data.city
|
||||
|
||||
await prisma.employee.update({ where: { id }, data: updateData })
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { id }
|
||||
}
|
||||
|
||||
export async function deleteEmployee(orgId: string, id: string) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
await prisma.employee.update({ where: { id }, data: { status: 'RESIGNED' } })
|
||||
await prisma.riskItem.updateMany({
|
||||
where: { employeeId: id, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||||
})
|
||||
|
||||
return { id }
|
||||
}
|
||||
|
||||
export async function batchRenew(orgId: string, userId: string, contractIds: string[], years: number) {
|
||||
const contracts = await prisma.laborContract.findMany({
|
||||
where: { id: { in: contractIds }, orgId },
|
||||
})
|
||||
|
||||
if (contracts.length === 0) {
|
||||
throw { code: 'NOT_FOUND', message: '未找到符合条件的合同' }
|
||||
}
|
||||
|
||||
for (const contract of contracts) {
|
||||
const newStartDate = contract.endDate || new Date()
|
||||
const newEndDate = new Date(newStartDate)
|
||||
newEndDate.setFullYear(newEndDate.getFullYear() + years)
|
||||
|
||||
await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: contract.employeeId,
|
||||
signDate: new Date(),
|
||||
startDate: newStartDate,
|
||||
endDate: newEndDate,
|
||||
contractType: contract.contractType,
|
||||
signMethod: contract.signMethod,
|
||||
contractYears: years,
|
||||
probationMonths: 0,
|
||||
probationSalary: 0,
|
||||
renewalCount: contract.renewalCount + 1,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { renewed: contracts.length }
|
||||
}
|
||||
|
||||
export async function addContract(orgId: string, userId: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
const contractMonths = data.endDate
|
||||
? Math.ceil(daysBetween(new Date(data.endDate), new Date(data.startDate)) / 30.44)
|
||||
: data.contractYears * 12
|
||||
|
||||
const probationCheck = validateProbation(contractMonths, data.probationMonths)
|
||||
if (!probationCheck.valid) {
|
||||
throw { code: 'VALIDATION_ERROR', message: probationCheck.message }
|
||||
}
|
||||
|
||||
const contract = await prisma.laborContract.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
signDate: data.signDate ? new Date(data.signDate) : null,
|
||||
startDate: new Date(data.startDate),
|
||||
endDate: data.endDate ? new Date(data.endDate) : null,
|
||||
contractType: data.contractType,
|
||||
signMethod: data.signMethod || 'PAPER',
|
||||
contractYears: data.contractYears || 3,
|
||||
probationMonths: data.probationMonths || 0,
|
||||
probationSalary: data.probationSalary || 0,
|
||||
attachmentName: data.attachmentUrl ? '合同扫描件' : null,
|
||||
attachmentUrl: data.attachmentUrl || null,
|
||||
electronicContractNo: data.electronicContractNo || null,
|
||||
electronicContractUrl: data.electronicContractUrl || null,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
await runRiskDetection(orgId)
|
||||
|
||||
return { id: contract.id }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import OpenAI from 'openai'
|
||||
import prisma from '../lib/prisma'
|
||||
|
||||
const apiKey = process.env.DASHSCOPE_API_KEY || ''
|
||||
const baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
const client = new OpenAI({ apiKey, baseURL })
|
||||
|
||||
const EMBEDDING_MODEL = 'text-embedding-v2'
|
||||
|
||||
interface KnowledgeSeed {
|
||||
title: string
|
||||
content: string
|
||||
source: string
|
||||
category: string
|
||||
}
|
||||
|
||||
const SEED_DATA: KnowledgeSeed[] = [
|
||||
{ title: '劳动合同法 第十条 建立劳动关系应当订立书面合同', content: '建立劳动关系,应当订立书面劳动合同。已建立劳动关系,未同时订立书面劳动合同的,应当自用工之日起一个月内订立书面劳动合同。', source: '劳动合同法', category: '合同签订' },
|
||||
{ title: '劳动合同法 第八十二条 未签书面合同双倍工资', content: '用人单位自用工之日起超过一个月不满一年未与劳动者订立书面劳动合同的,应当向劳动者每月支付二倍的工资。', source: '劳动合同法', category: '合同签订' },
|
||||
{ title: '劳动合同法 第十四条 无固定期限劳动合同', content: '连续订立二次固定期限劳动合同续订的,应当订立无固定期限劳动合同。劳动者在该用人单位连续工作满十年的,应当订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
|
||||
{ title: '劳动合同法 第十九条 试用期期限', content: '三个月以上不满一年试用期不得超过一个月;一年以上不满三年不得超过二个月;三年以上不得超过六个月。同一用人单位与同一劳动者只能约定一次试用期。', source: '劳动合同法', category: '试用期' },
|
||||
{ title: '劳动合同法 第二十条 试用期工资', content: '试用期工资不得低于本单位相同岗位最低档工资或劳动合同约定工资的百分之八十,并不得低于最低工资标准。', source: '劳动合同法', category: '试用期' },
|
||||
{ title: '劳动合同法 第三十九条 过失性辞退', content: '严重违反规章制度、严重失职造成重大损害、被依法追究刑事责任等情形,用人单位可以解除劳动合同。', source: '劳动合同法', category: '解除终止' },
|
||||
{ title: '劳动合同法 第四十条 无过失性辞退', content: '提前三十日书面通知或额外支付一个月工资后可解除:医疗期满不能从事原工作、不能胜任经培训仍不胜任、客观情况重大变化未能协商一致。', source: '劳动合同法', category: '解除终止' },
|
||||
{ title: '劳动合同法 第四十一条 经济性裁员', content: '裁减二十人以上或占职工总数百分之十以上,需提前三十日向工会说明,方案报劳动行政部门。优先留用长期合同、无固定期限合同、家庭无其他就业人员。', source: '劳动合同法', category: '解除终止' },
|
||||
{ title: '劳动合同法 第四十二条 不得解除的情形', content: '职业病、因工负伤丧失劳动能力、医疗期内、孕期产期哺乳期、连续工作满十五年距退休不足五年等情形,不得依第四十条第四十一条解除。', source: '劳动合同法', category: '解除终止' },
|
||||
{ title: '劳动合同法 第四十七条 经济补偿计算', content: '每满一年支付一个月工资。六个月以上不满一年按一年计算;不满六个月支付半个月工资。月工资指解除前十二个月平均工资。高于社平工资三倍的按三倍计,年限最高十二年。', source: '劳动合同法', category: '经济补偿' },
|
||||
{ title: '劳动合同法 第八十七条 违法解除赔偿金', content: '用人单位违反本法规定解除或终止劳动合同的,应当依照第四十七条经济补偿标准的二倍向劳动者支付赔偿金。', source: '劳动合同法', category: '经济补偿' },
|
||||
{ title: '劳动法 第四十一条 加班时间上限', content: '一般每日不得超过一小时;特殊原因每日不得超过三小时,每月不得超过三十六小时。', source: '劳动法', category: '加班' },
|
||||
{ title: '劳动法 第四十四条 加班工资标准', content: '延长工作时间不低于工资150%;休息日加班不能补休的不低于200%;法定休假日不低于300%。', source: '劳动法', category: '加班' },
|
||||
{ title: '社会保险法 第五十八条 参保登记', content: '用人单位应当自用工之日起三十日内为其职工向社会保险经办机构申请办理社会保险登记。', source: '社会保险法', category: '社保' },
|
||||
{ title: '劳动合同法 第八十二条 二倍工资起算', content: '用人单位自用工之日起满一年不与劳动者订立书面劳动合同的,视为用人单位与劳动者已订立无固定期限劳动合同。', source: '劳动合同法', category: '合同签订' },
|
||||
]
|
||||
|
||||
let initialized = false
|
||||
|
||||
export async function ensureRAGTable() {
|
||||
if (initialized) return
|
||||
await prisma.$executeRaw`CREATE EXTENSION IF NOT EXISTS vector`
|
||||
await prisma.$executeRaw`
|
||||
CREATE TABLE IF NOT EXISTS rag_knowledge (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
embedding vector(1536),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
)
|
||||
`
|
||||
await prisma.$executeRaw`CREATE INDEX IF NOT EXISTS rag_knowledge_embedding_idx ON rag_knowledge USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)`
|
||||
initialized = true
|
||||
}
|
||||
|
||||
async function getEmbedding(text: string): Promise<number[]> {
|
||||
const res = await client.embeddings.create({ model: EMBEDDING_MODEL, input: text })
|
||||
return res.data[0]?.embedding || []
|
||||
}
|
||||
|
||||
export async function seedKnowledgeBase() {
|
||||
await ensureRAGTable()
|
||||
const count = await prisma.$queryRaw`SELECT count(*)::int as c FROM rag_knowledge` as any
|
||||
if (count[0]?.c > 0) return
|
||||
for (let i = 0; i < SEED_DATA.length; i++) {
|
||||
const item = SEED_DATA[i]
|
||||
const embedding = await getEmbedding(`${item.title} ${item.content}`)
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO rag_knowledge (id, title, content, source, category, embedding)
|
||||
VALUES (${`rag-${String(i).padStart(3, '0')}`}, ${item.title}, ${item.content}, ${item.source}, ${item.category}, ${embedding}::vector)
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchKnowledge(query: string, topK: number = 3): Promise<string[]> {
|
||||
await ensureRAGTable()
|
||||
const queryEmbedding = await getEmbedding(query)
|
||||
const results = await prisma.$queryRaw`
|
||||
SELECT title, content, source, 1 - (embedding <=> ${queryEmbedding}::vector) as similarity
|
||||
FROM rag_knowledge
|
||||
ORDER BY embedding <=> ${queryEmbedding}::vector
|
||||
LIMIT ${topK}
|
||||
` as any[]
|
||||
return results
|
||||
.filter((r) => r.similarity > 0.3)
|
||||
.map((r) => `【${r.title}】\n${r.content}\n(来源:${r.source},相似度:${(r.similarity * 100).toFixed(0)}%)`)
|
||||
}
|
||||
|
||||
export async function addKnowledge(title: string, content: string, source: string, category: string) {
|
||||
await ensureRAGTable()
|
||||
const embedding = await getEmbedding(`${title} ${content}`)
|
||||
const id = `rag-${Date.now()}`
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO rag_knowledge (id, title, content, source, category, embedding)
|
||||
VALUES (${id}, ${title}, ${content}, ${source}, ${category}, ${embedding}::vector)
|
||||
`
|
||||
return { id }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,827 @@
|
||||
import prisma from '../lib/prisma'
|
||||
import { RiskAssessment, TerminationReason } from '@prisma/client'
|
||||
|
||||
function dateToMonth(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
return `${y}-${m}`
|
||||
}
|
||||
|
||||
export interface ChecklistItem {
|
||||
key: string
|
||||
label: string
|
||||
autoChecked?: boolean | null // null=无法自动判断,true/false=系统判断结果
|
||||
autoSource?: string // 系统判断依据说明
|
||||
suggestion?: string // 系统建议说明
|
||||
suggestionType?: 'info' | 'warning' | 'required'
|
||||
}
|
||||
|
||||
export function getChecklistForReason(reason: string, employee?: any): ChecklistItem[] {
|
||||
switch (reason) {
|
||||
case 'NEGOTIATED':
|
||||
return [
|
||||
{
|
||||
key: 'compensation_paid', label: '是否已支付经济补偿金',
|
||||
autoChecked: null,
|
||||
suggestion: '协商解除需支付经济补偿金(N),建议在协商协议中明确金额',
|
||||
suggestionType: 'required',
|
||||
},
|
||||
{ key: 'agreement_signed', label: '是否签署协商解除协议', autoChecked: null },
|
||||
{ key: 'final_pay_ready', label: '是否结清最后工资', autoChecked: null },
|
||||
]
|
||||
case 'FAULT':
|
||||
return [
|
||||
{ key: 'has_rules', label: '是否有规章制度依据', autoChecked: null },
|
||||
{ key: 'has_evidence', label: '是否有违纪证据', autoChecked: null },
|
||||
{ key: 'notify_union', label: '是否事先通知工会', autoChecked: null },
|
||||
{ key: 'written_notice', label: '是否出具书面解除通知', autoChecked: null },
|
||||
]
|
||||
case 'NONFAULT': {
|
||||
const items: ChecklistItem[] = []
|
||||
|
||||
// 医疗期是否已届满 — 系统自动判断
|
||||
if (employee?.isInMedicalPeriod) {
|
||||
items.push({
|
||||
key: 'medical_period_end', label: '医疗期是否已届满',
|
||||
autoChecked: false,
|
||||
autoSource: '系统记录显示该员工正处于医疗期内,医疗期未届满',
|
||||
suggestion: '医疗期内不得以非过错理由解除,需等待医疗期届满',
|
||||
suggestionType: 'warning',
|
||||
})
|
||||
} else {
|
||||
items.push({
|
||||
key: 'medical_period_end', label: '医疗期是否已届满',
|
||||
autoChecked: null,
|
||||
autoSource: '系统未记录该员工处于医疗期,如实际已届满请勾选确认',
|
||||
})
|
||||
}
|
||||
|
||||
// 是否经过培训或调岗 — 系统自动判断
|
||||
const hasTraining = employee?.trainingRecords?.length > 0
|
||||
items.push({
|
||||
key: 'training_given', label: '是否经过培训或调岗',
|
||||
autoChecked: hasTraining ? true : null,
|
||||
autoSource: hasTraining
|
||||
? `系统记录显示该员工有${employee.trainingRecords.length}条培训记录`
|
||||
: '系统未找到培训或调岗记录,请人工确认',
|
||||
suggestion: hasTraining
|
||||
? '已有培训记录,满足"不胜任工作经培训或调岗"的前提条件'
|
||||
: '以不胜任工作为由解除前,必须先经过培训或调岗,否则违法解除风险极高',
|
||||
suggestionType: hasTraining ? 'info' : 'warning',
|
||||
})
|
||||
|
||||
// 是否支付经济补偿金 — 系统建议
|
||||
items.push({
|
||||
key: 'compensation_paid', label: '是否支付经济补偿金',
|
||||
autoChecked: null,
|
||||
suggestion: '非过错解除需支付经济补偿金(N),并在Step 4费用结算中确认金额',
|
||||
suggestionType: 'required',
|
||||
})
|
||||
|
||||
// 是否提前30天通知或支付代通知金 — 系统建议
|
||||
items.push({
|
||||
key: 'advance_notice', label: '是否提前30天通知或支付代通知金',
|
||||
autoChecked: null,
|
||||
suggestion: '非过错解除需提前30天书面通知,或额外支付1个月工资作为代通知金(N+1)',
|
||||
suggestionType: 'required',
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
case 'LAYOFF':
|
||||
return [
|
||||
{ key: 'advance_notice_30', label: '是否提前30天向工会或全体职工说明', autoChecked: null },
|
||||
{ key: 'listen_opinions', label: '是否听取工会或职工意见', autoChecked: null },
|
||||
{ key: 'report_labor_dept', label: '是否向劳动行政部门报告', autoChecked: null },
|
||||
{
|
||||
key: 'compensation_paid', label: '是否支付经济补偿金',
|
||||
autoChecked: null,
|
||||
suggestion: '裁员需支付经济补偿金(N)',
|
||||
suggestionType: 'required',
|
||||
},
|
||||
]
|
||||
case 'EXPIRED':
|
||||
return [
|
||||
{
|
||||
key: 'compensation_paid', label: '是否支付经济补偿金(如需)',
|
||||
autoChecked: null,
|
||||
suggestion: '公司提出不续签需支付经济补偿金(N);员工主动提出不续签则无需支付',
|
||||
suggestionType: 'info',
|
||||
},
|
||||
{ key: 'written_notice', label: '是否提前通知员工不续签', autoChecked: null },
|
||||
]
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function assessRisk(employee: any, reason: string): { level: RiskAssessment; warnings: string[] } {
|
||||
const warnings: string[] = []
|
||||
|
||||
if (employee.isPregnant) {
|
||||
warnings.push('该员工在孕期/哺乳期,法律禁止以非过错理由解除')
|
||||
}
|
||||
if (employee.isWorkInjured) {
|
||||
warnings.push('工伤期间不得解除劳动合同')
|
||||
}
|
||||
if (employee.isInMedicalPeriod && reason !== 'FAULT') {
|
||||
warnings.push('医疗期内不得解除劳动合同(非过错理由)')
|
||||
}
|
||||
|
||||
let level: RiskAssessment = 'SAFE'
|
||||
if (warnings.length > 0) {
|
||||
level = 'DANGER'
|
||||
}
|
||||
|
||||
return { level, warnings }
|
||||
}
|
||||
|
||||
export async function createTermination(orgId: string, userId: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
// 校验:已有离职/解聘记录且未重新雇佣则不允许再次解聘
|
||||
const latestTerm = await prisma.terminationRecord.findFirst({
|
||||
where: { employeeId: data.employeeId },
|
||||
orderBy: { terminationDate: 'desc' },
|
||||
})
|
||||
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
|
||||
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次解聘请先办理重新雇佣' }
|
||||
}
|
||||
|
||||
const { level } = assessRisk(employee, data.reason)
|
||||
|
||||
const termDate = new Date(data.terminationDate)
|
||||
const termMonth = dateToMonth(termDate)
|
||||
const socialInsEndMonth = data.socialInsEndMonth || termMonth
|
||||
const housingFundEndMonth = data.housingFundEndMonth || termMonth
|
||||
|
||||
const record = await prisma.terminationRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
type: 'TERMINATION',
|
||||
reason: data.reason,
|
||||
terminationDate: termDate,
|
||||
compensation: data.compensation || 0,
|
||||
socialInsEndMonth,
|
||||
housingFundEndMonth,
|
||||
riskLevel: level,
|
||||
checklist: data.checklist || {},
|
||||
remark: data.remark,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 关闭社保缴费记录(设置 endMonth)
|
||||
await prisma.employeeSocialInsRecord.updateMany({
|
||||
where: { employeeId: data.employeeId, endMonth: null },
|
||||
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
|
||||
})
|
||||
|
||||
// 关闭公积金缴费记录
|
||||
await prisma.employeeHousingFundRecord.updateMany({
|
||||
where: { employeeId: data.employeeId, endMonth: null },
|
||||
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
|
||||
})
|
||||
|
||||
// 根据解聘日期判断在职/离职状态
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const isResigned = termDate <= today
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: data.employeeId },
|
||||
data: {
|
||||
status: isResigned ? 'RESIGNED' : 'ACTIVE',
|
||||
socialInsEndMonth,
|
||||
housingFundEndMonth,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.riskItem.updateMany({
|
||||
where: { employeeId: data.employeeId, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||||
})
|
||||
|
||||
return { id: record.id }
|
||||
}
|
||||
|
||||
// 员工主动离职
|
||||
export async function createResignation(orgId: string, userId: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
// 校验:已有离职/解聘记录且未重新雇佣则不允许再次离职
|
||||
const latestTerm = await prisma.terminationRecord.findFirst({
|
||||
where: { employeeId: data.employeeId },
|
||||
orderBy: { terminationDate: 'desc' },
|
||||
})
|
||||
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
|
||||
throw { code: 'CONFLICT', message: '该员工已有离职/解聘记录,如需再次办理请先重新雇佣' }
|
||||
}
|
||||
|
||||
const termDate = new Date(data.terminationDate)
|
||||
const termMonth = dateToMonth(termDate)
|
||||
const socialInsEndMonth = data.socialInsEndMonth || termMonth
|
||||
const housingFundEndMonth = data.housingFundEndMonth || termMonth
|
||||
|
||||
const record = await prisma.terminationRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
type: 'RESIGNATION',
|
||||
reason: 'RESIGNATION',
|
||||
terminationDate: termDate,
|
||||
resignationReason: data.resignationReason || null,
|
||||
compensation: 0,
|
||||
socialInsEndMonth,
|
||||
housingFundEndMonth,
|
||||
riskLevel: 'SAFE',
|
||||
checklist: {},
|
||||
remark: data.remark || null,
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 关闭社保缴费记录
|
||||
await prisma.employeeSocialInsRecord.updateMany({
|
||||
where: { employeeId: data.employeeId, endMonth: null },
|
||||
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
|
||||
})
|
||||
|
||||
// 关闭公积金缴费记录
|
||||
await prisma.employeeHousingFundRecord.updateMany({
|
||||
where: { employeeId: data.employeeId, endMonth: null },
|
||||
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
|
||||
})
|
||||
|
||||
// 根据离职日期判断在职/离职状态
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const isResigned = termDate <= today
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: data.employeeId },
|
||||
data: {
|
||||
status: isResigned ? 'RESIGNED' : 'ACTIVE',
|
||||
socialInsEndMonth,
|
||||
housingFundEndMonth,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.riskItem.updateMany({
|
||||
where: { employeeId: data.employeeId, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||||
})
|
||||
|
||||
return { id: record.id }
|
||||
}
|
||||
|
||||
// 撤回离职/解聘(仅未到日期可撤回)
|
||||
export async function revokeTermination(orgId: string, recordId: string) {
|
||||
const record = await prisma.terminationRecord.findFirst({
|
||||
where: { id: recordId, orgId },
|
||||
})
|
||||
if (!record) {
|
||||
throw { code: 'NOT_FOUND', message: '离职/解聘记录不存在' }
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
if (record.terminationDate <= today) {
|
||||
throw { code: 'CONFLICT', message: '离职/解聘日期已到或已过,无法撤回' }
|
||||
}
|
||||
|
||||
await prisma.terminationRecord.delete({ where: { id: recordId } })
|
||||
|
||||
// 恢复员工状态为 ACTIVE
|
||||
await prisma.employee.update({
|
||||
where: { id: record.employeeId },
|
||||
data: { status: 'ACTIVE' },
|
||||
})
|
||||
|
||||
return { id: recordId }
|
||||
}
|
||||
|
||||
export async function getTerminations(orgId: string, page: number, pageSize: number) {
|
||||
const skip = (page - 1) * pageSize
|
||||
|
||||
const [total, records] = await Promise.all([
|
||||
prisma.terminationRecord.count({ where: { orgId } }),
|
||||
prisma.terminationRecord.findMany({
|
||||
where: { orgId },
|
||||
include: { employee: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
])
|
||||
|
||||
return {
|
||||
items: records.map((r) => ({
|
||||
id: r.id,
|
||||
employeeName: r.employee.name,
|
||||
department: r.employee.department,
|
||||
type: r.type,
|
||||
reason: r.reason,
|
||||
resignationReason: r.resignationReason,
|
||||
terminationDate: r.terminationDate.toISOString().slice(0, 10),
|
||||
compensation: r.compensation,
|
||||
riskLevel: r.riskLevel,
|
||||
remark: r.remark,
|
||||
createdAt: r.createdAt.toISOString().slice(0, 10),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateCompensation(hireDate: Date, leaveDate: Date, monthlyWage: number, socialAvgWage: number = 0): {
|
||||
years: number
|
||||
remainingMonths: number
|
||||
compMonths: number
|
||||
totalPay: number
|
||||
capped: boolean
|
||||
} {
|
||||
const totalMonths = (leaveDate.getFullYear() - hireDate.getFullYear()) * 12 + (leaveDate.getMonth() - hireDate.getMonth())
|
||||
const years = Math.floor(totalMonths / 12)
|
||||
const remainingMonths = totalMonths % 12
|
||||
|
||||
let compMonths: number
|
||||
if (remainingMonths >= 6) compMonths = years + 1
|
||||
else if (remainingMonths > 0) compMonths = years + 0.5
|
||||
else compMonths = years
|
||||
|
||||
if (compMonths <= 0) compMonths = 0.5
|
||||
|
||||
let wage = monthlyWage
|
||||
let capped = false
|
||||
if (socialAvgWage > 0 && monthlyWage > socialAvgWage * 3) {
|
||||
wage = socialAvgWage * 3
|
||||
compMonths = Math.min(compMonths, 12)
|
||||
capped = true
|
||||
}
|
||||
|
||||
return { years, remainingMonths, compMonths, totalPay: wage * compMonths, capped }
|
||||
}
|
||||
|
||||
// 批量解聘:支持合规预检和执行
|
||||
export interface BatchTerminatePreview {
|
||||
employeeId: string
|
||||
employeeName: string
|
||||
department: string
|
||||
reason: string
|
||||
terminationDate: string
|
||||
riskLevel: RiskAssessment | null
|
||||
warnings: string[]
|
||||
canTerminate: boolean
|
||||
}
|
||||
|
||||
export async function batchTerminatePreview(
|
||||
orgId: string,
|
||||
items: Array<{ employeeId: string; reason: string; terminationDate: string }>
|
||||
): Promise<BatchTerminatePreview[]> {
|
||||
const results: BatchTerminatePreview[] = []
|
||||
|
||||
for (const item of items) {
|
||||
const employee = await prisma.employee.findFirst({
|
||||
where: { id: item.employeeId, orgId },
|
||||
})
|
||||
|
||||
if (!employee) {
|
||||
results.push({
|
||||
employeeId: item.employeeId,
|
||||
employeeName: '(未找到)',
|
||||
department: '',
|
||||
reason: item.reason,
|
||||
terminationDate: item.terminationDate,
|
||||
riskLevel: null,
|
||||
warnings: ['员工不存在或无权操作'],
|
||||
canTerminate: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const { level, warnings } = assessRisk(employee, item.reason)
|
||||
results.push({
|
||||
employeeId: item.employeeId,
|
||||
employeeName: employee.name,
|
||||
department: employee.department,
|
||||
reason: item.reason,
|
||||
terminationDate: item.terminationDate,
|
||||
riskLevel: level,
|
||||
warnings,
|
||||
canTerminate: warnings.length === 0,
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export interface BatchTerminateResult {
|
||||
success: string[]
|
||||
failed: Array<{ employeeId: string; reason: string }>
|
||||
total: number
|
||||
}
|
||||
|
||||
export async function batchTerminate(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
items: Array<{ employeeId: string; reason: string; terminationDate: string; compensation?: number }>
|
||||
): Promise<BatchTerminateResult> {
|
||||
const success: string[] = []
|
||||
const failed: Array<{ employeeId: string; reason: string }> = []
|
||||
|
||||
for (const item of items) {
|
||||
try {
|
||||
const termDate = new Date(item.terminationDate)
|
||||
const termMonth = dateToMonth(termDate)
|
||||
|
||||
// 校验:已有离职/解聘记录
|
||||
const latestTerm = await prisma.terminationRecord.findFirst({
|
||||
where: { employeeId: item.employeeId },
|
||||
orderBy: { terminationDate: 'desc' },
|
||||
})
|
||||
const employee = await prisma.employee.findFirst({ where: { id: item.employeeId, orgId } })
|
||||
if (!employee) {
|
||||
failed.push({ employeeId: item.employeeId, reason: '员工不存在' })
|
||||
continue
|
||||
}
|
||||
if (latestTerm && latestTerm.terminationDate >= employee.hireDate) {
|
||||
failed.push({ employeeId: item.employeeId, reason: '该员工已有离职/解聘记录' })
|
||||
continue
|
||||
}
|
||||
|
||||
const { level } = assessRisk(employee, item.reason)
|
||||
|
||||
await prisma.terminationRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: item.employeeId,
|
||||
type: 'TERMINATION',
|
||||
reason: item.reason as TerminationReason,
|
||||
terminationDate: termDate,
|
||||
compensation: item.compensation || 0,
|
||||
socialInsEndMonth: termMonth,
|
||||
housingFundEndMonth: termMonth,
|
||||
riskLevel: level,
|
||||
checklist: {},
|
||||
remark: '批量解聘',
|
||||
createdBy: userId,
|
||||
},
|
||||
})
|
||||
|
||||
// 关闭社保和公积金
|
||||
await prisma.employeeSocialInsRecord.updateMany({
|
||||
where: { employeeId: item.employeeId, endMonth: null },
|
||||
data: { endMonth: termMonth },
|
||||
})
|
||||
await prisma.employeeHousingFundRecord.updateMany({
|
||||
where: { employeeId: item.employeeId, endMonth: null },
|
||||
data: { endMonth: termMonth },
|
||||
})
|
||||
|
||||
// 更新员工状态
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const isResigned = termDate <= today
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: item.employeeId },
|
||||
data: {
|
||||
status: isResigned ? 'RESIGNED' : 'ACTIVE',
|
||||
socialInsEndMonth: termMonth,
|
||||
housingFundEndMonth: termMonth,
|
||||
},
|
||||
})
|
||||
|
||||
// 关闭风险项
|
||||
await prisma.riskItem.updateMany({
|
||||
where: { employeeId: item.employeeId, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||||
})
|
||||
|
||||
success.push(item.employeeId)
|
||||
} catch (err: any) {
|
||||
failed.push({ employeeId: item.employeeId, reason: err.message || '未知错误' })
|
||||
}
|
||||
}
|
||||
|
||||
return { success, failed, total: items.length }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 解聘流程状态机:DRAFT → PENDING_APPROVAL → APPROVED → EXECUTING → COMPLETED
|
||||
// ↘ REJECTED → 可修改重新提交
|
||||
// 任意非 COMPLETED → CANCELLED
|
||||
// ============================================================
|
||||
|
||||
/** 标准工作交接清单模板 */
|
||||
export function getDefaultHandoverItems(): Array<{ key: string; label: string; done: boolean; remark: string }> {
|
||||
return [
|
||||
{ key: 'work_handover', label: '工作交接完成', done: false, remark: '' },
|
||||
{ key: 'equipment_return', label: '办公设备归还', done: false, remark: '' },
|
||||
{ key: 'access_revoke', label: '系统权限收回', done: false, remark: '' },
|
||||
{ key: 'docs_signed', label: '离职文件签署', done: false, remark: '' },
|
||||
{ key: 'finance_settled', label: '财务结算完成', done: false, remark: '' },
|
||||
{ key: 'contract_return', label: '劳动合同收回', done: false, remark: '' },
|
||||
]
|
||||
}
|
||||
|
||||
/** 创建草稿 */
|
||||
export async function createDraft(orgId: string, userId: string, data: any) {
|
||||
const employee = await prisma.employee.findFirst({ where: { id: data.employeeId, orgId } })
|
||||
if (!employee) {
|
||||
throw { code: 'NOT_FOUND', message: '员工不存在' }
|
||||
}
|
||||
|
||||
const { level } = assessRisk(employee, data.reason || 'NEGOTIATED')
|
||||
|
||||
const record = await prisma.terminationRecord.create({
|
||||
data: {
|
||||
orgId,
|
||||
employeeId: data.employeeId,
|
||||
type: data.type || 'TERMINATION',
|
||||
reason: data.reason || 'NEGOTIATED',
|
||||
terminationDate: data.terminationDate ? new Date(data.terminationDate) : new Date(),
|
||||
resignationReason: data.resignationReason || null,
|
||||
compensation: data.compensation || 0,
|
||||
socialInsEndMonth: data.socialInsEndMonth || null,
|
||||
housingFundEndMonth: data.housingFundEndMonth || null,
|
||||
riskLevel: level,
|
||||
checklist: data.checklist || {},
|
||||
remark: data.remark || null,
|
||||
createdBy: userId,
|
||||
status: 'DRAFT',
|
||||
currentStep: data.currentStep || 0,
|
||||
compensationBreakdown: data.compensationBreakdown || null,
|
||||
checklistOverrides: data.checklistOverrides || null,
|
||||
handoverItems: data.handoverItems || getDefaultHandoverItems(),
|
||||
},
|
||||
})
|
||||
|
||||
return { id: record.id }
|
||||
}
|
||||
|
||||
/** 更新草稿(仅 DRAFT/REJECTED 状态可编辑) */
|
||||
export async function updateDraft(orgId: string, recordId: string, userId: string, data: any) {
|
||||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||||
if (!record) {
|
||||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||||
}
|
||||
if (record.status !== 'DRAFT' && record.status !== 'REJECTED') {
|
||||
throw { code: 'CONFLICT', message: '当前状态不可编辑' }
|
||||
}
|
||||
|
||||
const updateData: any = { updatedBy: userId }
|
||||
if (data.reason !== undefined) {
|
||||
updateData.reason = data.reason
|
||||
const employee = await prisma.employee.findFirst({ where: { id: record.employeeId, orgId } })
|
||||
if (employee) {
|
||||
const { level } = assessRisk(employee, data.reason)
|
||||
updateData.riskLevel = level
|
||||
}
|
||||
}
|
||||
if (data.terminationDate !== undefined) updateData.terminationDate = new Date(data.terminationDate)
|
||||
if (data.compensation !== undefined) updateData.compensation = data.compensation
|
||||
if (data.socialInsEndMonth !== undefined) updateData.socialInsEndMonth = data.socialInsEndMonth
|
||||
if (data.housingFundEndMonth !== undefined) updateData.housingFundEndMonth = data.housingFundEndMonth
|
||||
if (data.checklist !== undefined) updateData.checklist = data.checklist
|
||||
if (data.remark !== undefined) updateData.remark = data.remark
|
||||
if (data.currentStep !== undefined) updateData.currentStep = data.currentStep
|
||||
if (data.compensationBreakdown !== undefined) updateData.compensationBreakdown = data.compensationBreakdown
|
||||
if (data.checklistOverrides !== undefined) updateData.checklistOverrides = data.checklistOverrides
|
||||
if (data.handoverItems !== undefined) updateData.handoverItems = data.handoverItems
|
||||
if (data.resignationReason !== undefined) updateData.resignationReason = data.resignationReason
|
||||
|
||||
await prisma.terminationRecord.update({ where: { id: recordId }, data: updateData })
|
||||
return { id: recordId }
|
||||
}
|
||||
|
||||
/** 提交审批 */
|
||||
export async function submitForApproval(orgId: string, recordId: string, userId: string) {
|
||||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||||
if (!record) {
|
||||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||||
}
|
||||
if (record.status !== 'DRAFT' && record.status !== 'REJECTED') {
|
||||
throw { code: 'CONFLICT', message: '仅草稿状态可提交审批' }
|
||||
}
|
||||
|
||||
await prisma.terminationRecord.update({
|
||||
where: { id: recordId },
|
||||
data: { status: 'PENDING_APPROVAL', updatedBy: userId },
|
||||
})
|
||||
return { id: recordId }
|
||||
}
|
||||
|
||||
/** 审批通过 */
|
||||
export async function approveTermination(orgId: string, recordId: string, userId: string, comment: string) {
|
||||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||||
if (!record) {
|
||||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||||
}
|
||||
if (record.status !== 'PENDING_APPROVAL') {
|
||||
throw { code: 'CONFLICT', message: '仅待审批状态可审批' }
|
||||
}
|
||||
|
||||
await prisma.terminationRecord.update({
|
||||
where: { id: recordId },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
approvedBy: userId,
|
||||
approvedAt: new Date(),
|
||||
approvalComment: comment || null,
|
||||
updatedBy: userId,
|
||||
},
|
||||
})
|
||||
return { id: recordId }
|
||||
}
|
||||
|
||||
/** 审批驳回 */
|
||||
export async function rejectTermination(orgId: string, recordId: string, userId: string, comment: string) {
|
||||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||||
if (!record) {
|
||||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||||
}
|
||||
if (record.status !== 'PENDING_APPROVAL') {
|
||||
throw { code: 'CONFLICT', message: '仅待审批状态可驳回' }
|
||||
}
|
||||
|
||||
await prisma.terminationRecord.update({
|
||||
where: { id: recordId },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
approvalComment: comment || '驳回',
|
||||
updatedBy: userId,
|
||||
},
|
||||
})
|
||||
return { id: recordId }
|
||||
}
|
||||
|
||||
/** 执行解聘(APPROVED → EXECUTING → COMPLETED) */
|
||||
export async function executeTermination(orgId: string, recordId: string, userId: string) {
|
||||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||||
if (!record) {
|
||||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||||
}
|
||||
if (record.status !== 'APPROVED' && record.status !== 'DRAFT') {
|
||||
throw { code: 'CONFLICT', message: '仅已审批或草稿状态可执行' }
|
||||
}
|
||||
|
||||
// 标记为执行中
|
||||
await prisma.terminationRecord.update({
|
||||
where: { id: recordId },
|
||||
data: { status: 'EXECUTING', updatedBy: userId },
|
||||
})
|
||||
|
||||
const termDate = record.terminationDate
|
||||
const termMonth = dateToMonth(termDate)
|
||||
const socialInsEndMonth = record.socialInsEndMonth || termMonth
|
||||
const housingFundEndMonth = record.housingFundEndMonth || termMonth
|
||||
|
||||
// 关闭社保缴费记录
|
||||
await prisma.employeeSocialInsRecord.updateMany({
|
||||
where: { employeeId: record.employeeId, endMonth: null },
|
||||
data: { endMonth: socialInsEndMonth, changeRefId: record.id },
|
||||
})
|
||||
|
||||
// 关闭公积金缴费记录
|
||||
await prisma.employeeHousingFundRecord.updateMany({
|
||||
where: { employeeId: record.employeeId, endMonth: null },
|
||||
data: { endMonth: housingFundEndMonth, changeRefId: record.id },
|
||||
})
|
||||
|
||||
// 更新员工状态
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
const isResigned = termDate <= today
|
||||
|
||||
await prisma.employee.update({
|
||||
where: { id: record.employeeId },
|
||||
data: {
|
||||
status: isResigned ? 'RESIGNED' : 'ACTIVE',
|
||||
socialInsEndMonth,
|
||||
housingFundEndMonth,
|
||||
},
|
||||
})
|
||||
|
||||
// 关闭风险项
|
||||
await prisma.riskItem.updateMany({
|
||||
where: { employeeId: record.employeeId, status: 'PENDING' },
|
||||
data: { status: 'RESOLVED', resolvedAt: new Date() },
|
||||
})
|
||||
|
||||
// 标记为已完成
|
||||
await prisma.terminationRecord.update({
|
||||
where: { id: recordId },
|
||||
data: { status: 'COMPLETED', updatedBy: userId },
|
||||
})
|
||||
|
||||
return { id: recordId }
|
||||
}
|
||||
|
||||
/** 撤销(状态→CANCELLED,不删除记录) */
|
||||
export async function cancelTermination(orgId: string, recordId: string, userId: string) {
|
||||
const record = await prisma.terminationRecord.findFirst({ where: { id: recordId, orgId } })
|
||||
if (!record) {
|
||||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||||
}
|
||||
if (record.status === 'COMPLETED') {
|
||||
throw { code: 'CONFLICT', message: '已完成的解聘不可撤销' }
|
||||
}
|
||||
|
||||
await prisma.terminationRecord.update({
|
||||
where: { id: recordId },
|
||||
data: { status: 'CANCELLED', updatedBy: userId },
|
||||
})
|
||||
|
||||
// 如果之前已执行(社保已关闭),恢复员工状态
|
||||
if (record.status === 'EXECUTING' || record.status === 'COMPLETED') {
|
||||
await prisma.employee.update({
|
||||
where: { id: record.employeeId },
|
||||
data: { status: 'ACTIVE' },
|
||||
})
|
||||
}
|
||||
|
||||
return { id: recordId }
|
||||
}
|
||||
|
||||
/** 获取草稿列表 */
|
||||
export async function getDrafts(orgId: string, status?: string) {
|
||||
const where: any = { orgId }
|
||||
if (status) {
|
||||
where.status = status
|
||||
} else {
|
||||
where.status = { in: ['DRAFT', 'PENDING_APPROVAL', 'APPROVED', 'REJECTED'] }
|
||||
}
|
||||
|
||||
const records = await prisma.terminationRecord.findMany({
|
||||
where,
|
||||
include: { employee: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})
|
||||
|
||||
return records.map((r) => ({
|
||||
id: r.id,
|
||||
employeeId: r.employeeId,
|
||||
employeeName: r.employee.name,
|
||||
department: r.employee.department,
|
||||
type: r.type,
|
||||
reason: r.reason,
|
||||
terminationDate: r.terminationDate.toISOString().slice(0, 10),
|
||||
compensation: r.compensation,
|
||||
riskLevel: r.riskLevel,
|
||||
status: r.status,
|
||||
currentStep: r.currentStep,
|
||||
remark: r.remark,
|
||||
createdAt: r.createdAt.toISOString().slice(0, 10),
|
||||
updatedAt: r.updatedAt.toISOString().slice(0, 10),
|
||||
}))
|
||||
}
|
||||
|
||||
/** 获取单条记录详情(含所有流程字段) */
|
||||
export async function getTerminationDetail(orgId: string, recordId: string) {
|
||||
const record = await prisma.terminationRecord.findFirst({
|
||||
where: { id: recordId, orgId },
|
||||
include: { employee: true },
|
||||
})
|
||||
if (!record) {
|
||||
throw { code: 'NOT_FOUND', message: '记录不存在' }
|
||||
}
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
employeeId: record.employeeId,
|
||||
employeeName: record.employee.name,
|
||||
department: record.employee.department,
|
||||
type: record.type,
|
||||
reason: record.reason,
|
||||
terminationDate: record.terminationDate.toISOString().slice(0, 10),
|
||||
resignationReason: record.resignationReason,
|
||||
compensation: record.compensation,
|
||||
socialInsEndMonth: record.socialInsEndMonth,
|
||||
housingFundEndMonth: record.housingFundEndMonth,
|
||||
riskLevel: record.riskLevel,
|
||||
checklist: record.checklist,
|
||||
remark: record.remark,
|
||||
status: record.status,
|
||||
currentStep: record.currentStep,
|
||||
compensationBreakdown: record.compensationBreakdown,
|
||||
checklistOverrides: record.checklistOverrides,
|
||||
handoverItems: record.handoverItems,
|
||||
approvedBy: record.approvedBy,
|
||||
approvedAt: record.approvedAt?.toISOString().slice(0, 10),
|
||||
approvalComment: record.approvalComment,
|
||||
createdBy: record.createdBy,
|
||||
createdAt: record.createdAt.toISOString().slice(0, 10),
|
||||
updatedAt: record.updatedAt.toISOString().slice(0, 10),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user