Files
zerp/backend/src/modules/hr/hr.service.ts

553 lines
15 KiB
TypeScript

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<string, any> {
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<string, any> = {
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<string> {
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();