553 lines
15 KiB
TypeScript
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();
|
|
|