import prisma from '../../config/database'; import { AppError } from '../../shared/middleware/errorHandler'; import { AuditLogger } from '../../shared/utils/auditLogger'; class HRService { // ========== EMPLOYEES ========== private normalizeEmployeeData(data: any): Record { const toStr = (v: any) => (v != null && String(v).trim()) ? String(v).trim() : undefined; const toDate = (v: any) => { if (!v || !String(v).trim()) return undefined; const d = new Date(v); return isNaN(d.getTime()) ? undefined : d; }; const toNum = (v: any) => (v != null && v !== '') ? Number(v) : undefined; const raw: Record = { firstName: toStr(data.firstName), lastName: toStr(data.lastName), firstNameAr: toStr(data.firstNameAr), lastNameAr: toStr(data.lastNameAr), email: toStr(data.email), phone: toStr(data.phone), mobile: toStr(data.mobile), dateOfBirth: toDate(data.dateOfBirth), gender: toStr(data.gender), nationality: toStr(data.nationality), nationalId: toStr(data.nationalId), employmentType: toStr(data.employmentType), contractType: toStr(data.contractType), hireDate: toDate(data.hireDate), departmentId: toStr(data.departmentId), positionId: toStr(data.positionId), reportingToId: toStr(data.reportingToId) || undefined, basicSalary: toNum(data.baseSalary ?? data.basicSalary) ?? 0, }; return Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== undefined)); } async createEmployee(data: any, userId: string) { const uniqueEmployeeId = await this.generateEmployeeId(); const payload = this.normalizeEmployeeData(data); if (!payload.firstName || !payload.lastName || !payload.email || !payload.mobile || !payload.hireDate || !payload.departmentId || !payload.positionId) { throw new AppError(400, 'بيانات غير مكتملة - Missing required fields: firstName, lastName, email, mobile, hireDate, departmentId, positionId'); } const employee = await prisma.employee.create({ data: { uniqueEmployeeId, ...payload, } as any, include: { department: true, position: true, }, }); await AuditLogger.log({ entityType: 'EMPLOYEE', entityId: employee.id, action: 'CREATE', userId, }); return employee; } async findAllEmployees(filters: any, page: number, pageSize: number) { const skip = (page - 1) * pageSize; const where: any = {}; if (filters.search) { where.OR = [ { firstName: { contains: filters.search, mode: 'insensitive' } }, { lastName: { contains: filters.search, mode: 'insensitive' } }, { email: { contains: filters.search, mode: 'insensitive' } }, { uniqueEmployeeId: { contains: filters.search } }, ]; } if (filters.departmentId) { where.departmentId = filters.departmentId; } if (filters.status) { where.status = filters.status; } const total = await prisma.employee.count({ where }); const employees = await prisma.employee.findMany({ where, skip, take: pageSize, include: { department: true, position: true, reportingTo: { select: { id: true, firstName: true, lastName: true, position: true, }, }, }, orderBy: { hireDate: 'desc', }, }); return { employees, total, page, pageSize }; } async findEmployeeById(id: string) { const employee = await prisma.employee.findUnique({ where: { id }, include: { department: true, position: { include: { permissions: true, }, }, reportingTo: true, directReports: true, user: { select: { id: true, email: true, username: true, isActive: true, }, }, attendances: { take: 30, orderBy: { date: 'desc', }, }, leaves: { take: 10, orderBy: { createdAt: 'desc', }, }, salaries: { take: 12, orderBy: { year: 'desc', month: 'desc', }, }, }, }); if (!employee) { throw new AppError(404, 'الموظف غير موجود - Employee not found'); } return employee; } async updateEmployee(id: string, data: any, userId: string) { const existing = await prisma.employee.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'الموظف غير موجود - Employee not found'); } const payload = this.normalizeEmployeeData(data); const employee = await prisma.employee.update({ where: { id }, data: payload, include: { department: true, position: true, }, }); await AuditLogger.log({ entityType: 'EMPLOYEE', entityId: employee.id, action: 'UPDATE', userId, changes: { before: existing, after: employee, }, }); return employee; } async terminateEmployee(id: string, terminationDate: Date, reason: string, userId: string) { const employee = await prisma.employee.update({ where: { id }, data: { status: 'TERMINATED', terminationDate, terminationReason: reason, }, }); // Disable user account if (employee.id) { await prisma.user.updateMany({ where: { employeeId: employee.id }, data: { isActive: false }, }); } await AuditLogger.log({ entityType: 'EMPLOYEE', entityId: employee.id, action: 'TERMINATE', userId, reason, }); return employee; } // ========== ATTENDANCE ========== async recordAttendance(data: any, userId: string) { const attendance = await prisma.attendance.create({ data, }); return attendance; } async getAttendance(employeeId: string, month: number, year: number) { return prisma.attendance.findMany({ where: { employeeId, date: { gte: new Date(year, month - 1, 1), lte: new Date(year, month, 0), }, }, orderBy: { date: 'asc', }, }); } // ========== LEAVES ========== async createLeaveRequest(data: any, userId: string) { const leave = await prisma.leave.create({ data: { ...data, days: this.calculateLeaveDays(data.startDate, data.endDate), }, include: { employee: true, }, }); await AuditLogger.log({ entityType: 'LEAVE', entityId: leave.id, action: 'CREATE', userId, }); return leave; } async approveLeave(id: string, approvedBy: string, userId: string) { const leave = await prisma.leave.update({ where: { id }, data: { status: 'APPROVED', approvedBy, approvedAt: new Date(), }, include: { employee: true, }, }); await AuditLogger.log({ entityType: 'LEAVE', entityId: leave.id, action: 'APPROVE', userId, }); return leave; } // ========== SALARIES ========== async processSalary(employeeId: string, month: number, year: number, userId: string) { const employee = await prisma.employee.findUnique({ where: { id: employeeId }, include: { allowances: { where: { OR: [ { isRecurring: true }, { startDate: { lte: new Date(year, month, 0), }, OR: [ { endDate: null }, { endDate: { gte: new Date(year, month - 1, 1), }, }, ], }, ], }, }, commissions: { where: { month, year, status: 'APPROVED', }, }, }, }); if (!employee) { throw new AppError(404, 'الموظف غير موجود - Employee not found'); } const basicSalary = employee.basicSalary; const allowances = employee.allowances.reduce((sum, a) => sum + Number(a.amount), 0); const commissions = employee.commissions.reduce((sum, c) => sum + Number(c.amount), 0); // Calculate overtime from attendance const attendance = await prisma.attendance.findMany({ where: { employeeId, date: { gte: new Date(year, month - 1, 1), lte: new Date(year, month, 0), }, }, }); const overtimeHours = attendance.reduce((sum, a) => sum + Number(a.overtimeHours || 0), 0); const overtimePay = overtimeHours * 50; // SAR 50 per hour const deductions = 0; // Calculate based on business rules const netSalary = Number(basicSalary) + allowances + commissions + overtimePay - deductions; const salary = await prisma.salary.create({ data: { employeeId, month, year, basicSalary, allowances, deductions, commissions, overtimePay, netSalary, }, }); await AuditLogger.log({ entityType: 'SALARY', entityId: salary.id, action: 'PROCESS', userId, }); return salary; } // ========== HELPERS ========== private async generateEmployeeId(): Promise { const year = new Date().getFullYear(); const prefix = `EMP-${year}-`; const lastEmployee = await prisma.employee.findFirst({ where: { uniqueEmployeeId: { startsWith: prefix, }, }, orderBy: { createdAt: 'desc', }, select: { uniqueEmployeeId: true, }, }); let nextNumber = 1; if (lastEmployee) { const lastNumber = parseInt(lastEmployee.uniqueEmployeeId.split('-')[2]); nextNumber = lastNumber + 1; } return `${prefix}${nextNumber.toString().padStart(4, '0')}`; } private calculateLeaveDays(startDate: Date, endDate: Date): number { const start = new Date(startDate); const end = new Date(endDate); const diffTime = Math.abs(end.getTime() - start.getTime()); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return diffDays + 1; } // ========== DEPARTMENTS ========== async findAllDepartments() { const departments = await prisma.department.findMany({ where: { isActive: true }, include: { parent: { select: { id: true, name: true, nameAr: true } }, _count: { select: { children: true, employees: true } } }, orderBy: { name: 'asc' } }); return departments; } async getDepartmentsHierarchy() { const departments = await prisma.department.findMany({ where: { isActive: true }, include: { parent: { select: { id: true, name: true, nameAr: true } }, employees: { where: { status: 'ACTIVE' }, select: { id: true, firstName: true, lastName: true, firstNameAr: true, lastNameAr: true, position: { select: { title: true, titleAr: true } } } }, positions: { select: { id: true, title: true, titleAr: true } }, _count: { select: { children: true, employees: true } } }, orderBy: { name: 'asc' } }); const buildTree = (parentId: string | null): any[] => departments .filter((d) => d.parentId === parentId) .map((d) => ({ id: d.id, name: d.name, nameAr: d.nameAr, code: d.code, parentId: d.parentId, description: d.description, employees: d.employees, positions: d.positions, _count: d._count, children: buildTree(d.id) })); return buildTree(null); } async createDepartment(data: { name: string; nameAr?: string; code: string; parentId?: string; description?: string }, userId: string) { const existing = await prisma.department.findUnique({ where: { code: data.code } }); if (existing) { throw new AppError(400, 'كود القسم مستخدم مسبقاً - Department code already exists'); } if (data.parentId) { const parent = await prisma.department.findUnique({ where: { id: data.parentId } }); if (!parent) { throw new AppError(400, 'القسم الأب غير موجود - Parent department not found'); } } const department = await prisma.department.create({ data: { name: data.name, nameAr: data.nameAr, code: data.code, parentId: data.parentId || null, description: data.description } }); await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: department.id, action: 'CREATE', userId, changes: { created: department } }); return department; } async updateDepartment(id: string, data: { name?: string; nameAr?: string; code?: string; parentId?: string; description?: string; isActive?: boolean }, userId: string) { const existing = await prisma.department.findUnique({ where: { id } }); if (!existing) { throw new AppError(404, 'القسم غير موجود - Department not found'); } if (data.code && data.code !== existing.code) { const duplicate = await prisma.department.findUnique({ where: { code: data.code } }); if (duplicate) { throw new AppError(400, 'كود القسم مستخدم مسبقاً - Department code already exists'); } } if (data.parentId === id) { throw new AppError(400, 'لا يمكن تعيين القسم كأب لنفسه - Department cannot be its own parent'); } const department = await prisma.department.update({ where: { id }, data: { name: data.name, nameAr: data.nameAr, code: data.code, parentId: data.parentId ?? undefined, description: data.description, isActive: data.isActive } }); await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: id, action: 'UPDATE', userId, changes: { before: existing, after: department } }); return department; } async deleteDepartment(id: string, userId: string) { const dept = await prisma.department.findUnique({ where: { id }, include: { _count: { select: { children: true, employees: true } } } }); if (!dept) { throw new AppError(404, 'القسم غير موجود - Department not found'); } if (dept._count.children > 0) { throw new AppError(400, 'لا يمكن حذف قسم يحتوي على أقسام فرعية - Cannot delete department with sub-departments'); } if (dept._count.employees > 0) { throw new AppError(400, 'لا يمكن حذف قسم فيه موظفون - Cannot delete department with employees'); } await prisma.department.delete({ where: { id } }); await AuditLogger.log({ entityType: 'DEPARTMENT', entityId: id, action: 'DELETE', userId }); return { success: true }; } // ========== POSITIONS ========== async findAllPositions() { const positions = await prisma.position.findMany({ where: { isActive: true }, include: { department: { select: { id: true, name: true, nameAr: true } } }, orderBy: { title: 'asc' } }); return positions; } } export const hrService = new HRService();