feat(business-logic): implementation of vacation.service.ts, sick-leave.service.ts and update leave-requests. service

This commit is contained in:
Matthieu Haineault 2025-07-31 16:37:57 -04:00
parent 2e6bafeb18
commit 5766715d77
4 changed files with 227 additions and 34 deletions

View File

@ -0,0 +1,61 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class SickLeaveService {
constructor(private readonly prisma: PrismaService) {}
private readonly logger = new Logger(SickLeaveService.name);
async calculateSickLeavePay(employeeId: number, startDate: Date, daysRequested: number, modifier: number): Promise<number> {
//sets the year to jan 1st to dec 31st
const periodStart = new Date(startDate.getFullYear(), 0, 1);
const periodEnd = startDate;
//fetches all shifts of a selected employee
const shifts = await this.prisma.shifts.findMany({
where: {
timesheet: { employee_id: employeeId },
date: { gte: periodStart, lte: periodEnd},
},
select: { date: true },
});
//count the amount of worked days
const workedDates = new Set(
shifts.map(shift => shift.date.toISOString().slice(0,10))
);
const daysWorked = workedDates.size;
this.logger.debug(`Sick leave: days worked= ${daysWorked} in ${periodStart.toDateString()} -> ${periodEnd.toDateString()}`);
//less than 30 worked days returns 0
if (daysWorked < 30) {
return 0;
}
//default 3 days allowed after 30 worked days
let acquiredDays = 3;
//identify the date of the 30th worked day
const orderedDates = Array.from(workedDates).sort();
const thresholdDate = new Date(orderedDates[29]); // index 29 is the 30th day
//calculate each completed month, starting the 1st of the next month
const firstBonusDate = new Date(thresholdDate.getFullYear(), thresholdDate.getMonth() +1, 1);
let months = (periodEnd.getFullYear() - firstBonusDate.getFullYear()) * 12 +
(periodEnd.getMonth() - firstBonusDate.getMonth()) + 1;
if(months < 0) months = 0;
acquiredDays += months;
//cap of 10 days
if (acquiredDays > 10) acquiredDays = 10;
this.logger.debug(`Sick leave: threshold Date = ${thresholdDate.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquiredDays}`);
const payableDays = Math.min(acquiredDays, daysRequested);
const rawHours = payableDays * 8 * modifier;
const rounded = Math.round(rawHours * 4) / 4;
this.logger.debug(`Sick leave pay: days= ${payableDays}, modifier= ${modifier}, hours= ${rounded}`);
return rounded;
}
}

View File

@ -0,0 +1,85 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class VacationService {
constructor(private readonly prisma: PrismaService) {}
private readonly logger = new Logger(VacationService.name);
/**
* Calculate the ammount allowed for vacation days.
*
* @param employeeId employee ID
* @param startDate first day of vacation
* @param daysRequested number of days requested
* @param modifier Coefficient of hours(1)
* @returns amount of payable hours
*/
async calculateVacationPay(employeeId: number, startDate: Date, daysRequested: number, modifier: number): Promise<number> {
//fetch hiring date
const employee = await this.prisma.employees.findUnique({
where: { id: employeeId },
select: { first_work_day: true },
});
if(!employee) {
throw new NotFoundException(`Employee #${employeeId} not found`);
}
const hireDate = employee.first_work_day;
//sets "year" to may 1st to april 30th
//check if hiring date is in may or later, we use hiring year, otherwise we use the year before
const yearOfRequest = startDate.getMonth() >= 4
? startDate.getFullYear() : startDate.getFullYear() -1;
const periodStart = new Date(yearOfRequest, 4, 1); //may = 4
const periodEnd = new Date(yearOfRequest + 1, 4, 0); //day 0 of may == april 30th
this.logger.debug(`Vacation period for request: ${periodStart.toDateString()} -> ${periodEnd.toDateString()}`);
//steps to reach to get more vacation weeks in years
const checkpoint = [5, 10, 15];
const anniversaries = checkpoint.map(years => {
const anniversaryDate = new Date(hireDate);
anniversaryDate.setFullYear(anniversaryDate.getFullYear() + years);
return anniversaryDate;
}).filter(d => d>= periodStart && d <= periodEnd).sort((a,b) => a.getTime() - b.getTime());
this.logger.debug(`anniversatries steps during the period: ${anniversaries.map(date => date.toDateString()).join(',') || 'aucun'}`);
const boundaries = [periodStart, ...anniversaries,periodEnd];
//calculate prorata per segment
let totalVacationDays = 0;
const msPerDay = 1000 * 60 * 60 * 24;
for (let i = 0; i < boundaries.length -1; i++) {
const segmentStart = boundaries[i];
const segmentEnd = boundaries[i+1];
//number of days in said segment
const daysInSegment = Math.round((segmentEnd.getTime() - segmentStart.getTime())/ msPerDay);
const yearsSinceHire = (segmentStart.getFullYear() - hireDate.getFullYear()) -
(segmentStart < new Date(segmentStart.getFullYear(), hireDate.getMonth()) ? 1 : 0);
let allocDays: number;
if(yearsSinceHire < 5) allocDays = 10;
else if(yearsSinceHire < 10) allocDays = 15;
else if(yearsSinceHire < 15) allocDays = 20;
else allocDays = 25;
//prorata for said segment
const prorata = (allocDays / 365) * daysInSegment;
totalVacationDays += prorata;
}
//compares allowed vacation pools with requested days
const payableDays = Math.min(totalVacationDays, daysRequested);
const rawHours = payableDays * 8 * modifier;
const roundedHours = Math.round(rawHours * 4) / 4;
this.logger.debug(`Vacation pay: entitledDays=${totalVacationDays.toFixed(2)}, requestedDays=${daysRequested},
payableDays=${payableDays.toFixed(2)}, hours=${roundedHours}`);
return roundedHours;
}
}

View File

@ -3,6 +3,8 @@ import { LeaveRequestController } from "./controllers/leave-requests.controller"
import { LeaveRequestsService } from "./services/leave-requests.service";
import { Module } from "@nestjs/common";
import { HolidayService } from "src/business-logic/holiday.service";
import { VacationService } from "src/business-logic/vacation.service";
import { SickLeaveService } from "src/business-logic/sick-leave.service";
@Module({
controllers: [LeaveRequestController],
@ -10,6 +12,8 @@ import { HolidayService } from "src/business-logic/holiday.service";
LeaveRequestsService,
PrismaService,
HolidayService,
VacationService,
SickLeaveService,
],
exports: [ LeaveRequestsService],
})

View File

@ -1,15 +1,19 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateLeaveRequestsDto } from "../dtos/create-leave-requests.dto";
import { LeaveRequests, LeaveRequestsArchive } from "@prisma/client";
import { UpdateLeaveRequestsDto } from "../dtos/update-leave-requests.dto";
import { HolidayService } from "src/business-logic/holiday.service";
import { VacationService } from "src/business-logic/vacation.service";
import { SickLeaveService } from "src/business-logic/sick-leave.service";
@Injectable()
export class LeaveRequestsService {
constructor(
private readonly prisma: PrismaService,
private readonly holidayService: HolidayService,
private readonly vacationService: VacationService,
private readonly sickLeaveService: SickLeaveService
) {}
async create(dto: CreateLeaveRequestsDto): Promise<LeaveRequests> {
@ -20,7 +24,9 @@ export class LeaveRequestsService {
data: { employee_id, bank_code_id, leave_type, start_date_time,
end_date_time, comment, approval_status: approval_status ?? undefined
},
include: { employee: { include: { user: true } } },
include: { employee: { include: { user: true } },
bank_code: true
},
});
}
@ -31,18 +37,38 @@ export class LeaveRequestsService {
},
});
const msPerDay = 1000 * 60 * 60 * 24;
return Promise.all(
list.map(async request => {
if(request.bank_code?.type === 'holiday') {
const cost = await this.holidayService.calculateHolidayPay(
request.employee_id,
request.start_date_time,
request.bank_code.modifier
);
return { ...request, cost };
// end_date fallback
const endDate = request.end_date_time ?? request.start_date_time;
//Requested days
const diffDays = Math.round((endDate.getTime() - request.start_date_time.getTime()) / msPerDay) +1;
// modifier fallback/validation
if (!request.bank_code || request.bank_code.modifier == null) {
throw new BadRequestException(`Modifier manquant pour bank_code_id=${request.bank_code_id}`);
}
return request;
}),
const modifier = request.bank_code.modifier;
let cost: number;
switch (request.bank_code.type) {
case 'holiday' :
cost = await this.holidayService.calculateHolidayPay( request.employee_id, request.start_date_time, modifier );
break;
case 'vacation' :
cost = await this.vacationService.calculateVacationPay( request.employee_id, request.start_date_time,diffDays, modifier );
break;
case 'sick' :
cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diffDays, modifier );
break;
default:
cost = diffDays * modifier;
}
return {...request, daysRequested: diffDays, cost };
})
);
}
@ -56,17 +82,34 @@ export class LeaveRequestsService {
if(!request) {
throw new NotFoundException(`LeaveRequest #${id} not found`);
}
//search for leave type. if holiday
if (request.bank_code?.type === 'holiday') {
const cost = await this.holidayService.calculateHolidayPay(
request.employee_id,
request.start_date_time,
request.bank_code.modifier,
);
return { ...request, cost };
//validation and fallback for end_date_time
const endDate = request.end_date_time ?? request.start_date_time;
//calculate included days
const msPerDay = 1000 * 60 * 60 * 24;
const diffDays = Math.floor((endDate.getTime() - request.start_date_time.getTime())/ msPerDay) + 1;
if (!request.bank_code || request.bank_code.modifier == null) {
throw new BadRequestException(`Modifier missing for code ${request.bank_code_id}`);
}
return request;
const modifier = request.bank_code.modifier;
//calculate cost based on bank_code types
let cost = diffDays * modifier;
switch(request.bank_code.type) {
case 'holiday':
cost = await this.holidayService.calculateHolidayPay( request.employee_id, request.start_date_time, modifier );
break;
case 'vacation':
cost = await this.vacationService.calculateVacationPay( request.employee_id, request.start_date_time, diffDays, modifier );
break;
case 'sick':
cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diffDays, modifier );
break;
default:
cost = diffDays * modifier;
}
return {...request, daysRequested: diffDays, cost };
}
async update(
@ -78,12 +121,12 @@ export class LeaveRequestsService {
return this.prisma.leaveRequests.update({
where: { id },
data: {
...(employee_id !== undefined && { employee_id }),
...(leave_type !== undefined && { leave_type } ),
...(employee_id !== undefined && { employee_id }),
...(leave_type !== undefined && { leave_type } ),
...(start_date_time !== undefined && { start_date_time }),
...(end_date_time !== undefined && { end_date_time }),
...(comment !== undefined && { comment }),
...(approval_status == undefined && { approval_status }),
...(end_date_time !== undefined && { end_date_time }),
...(comment !== undefined && { comment }),
...(approval_status !== undefined && { approval_status }),
},
include: { employee: { include: { user:true } } },
});
@ -112,14 +155,14 @@ export class LeaveRequestsService {
//copy unto archive table
await transaction.leaveRequestsArchive.createMany({
data: expired.map(request => ({
leave_request_id: request.id,
employee_id: request.employee_id,
leave_type: request.leave_type,
start_date_time: request.start_date_time,
end_date_time: request.end_date_time,
comment: request.comment,
approval_status: request.approval_status,
})),
leave_request_id: request.id,
employee_id: request.employee_id,
leave_type: request.leave_type,
start_date_time: request.start_date_time,
end_date_time: request.end_date_time,
comment: request.comment,
approval_status: request.approval_status,
})),
});
//delete from leave_requests table
await transaction.leaveRequests.deleteMany({