import prisma from '../lib/prisma' import { encrypt, decrypt } 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 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, isPregnant: data.isPregnant || false, isInMedicalPeriod: data.isInMedicalPeriod || false, isWorkInjured: data.isWorkInjured || false, socialInsBase, housingFundBase, socialInsStartMonth, housingFundStartMonth, createdBy: userId, }, }) // 创建社保缴费记录 await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: employee.id, startMonth: socialInsStartMonth, endMonth: null, base: socialInsBase, changeType: 'ONBOARDING', createdBy: userId, }, }) // 创建公积金缴费记录 await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: employee.id, startMonth: housingFundStartMonth, endMonth: null, base: housingFundBase, changeType: 'ONBOARDING', createdBy: userId, }, }) // 创建初始薪资变更记录 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, }, }) // 创建新社保缴费记录 await prisma.employeeSocialInsRecord.create({ data: { orgId, employeeId: id, startMonth: socialInsStartMonth, endMonth: null, base: socialInsBase, changeType: 'REHIRE', createdBy: userId, }, }) // 创建新公积金缴费记录 await prisma.employeeHousingFundRecord.create({ data: { orgId, employeeId: id, startMonth: housingFundStartMonth, endMonth: null, base: housingFundBase, changeType: 'REHIRE', createdBy: userId, }, }) // 创建新薪资记录 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 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 } }