feat(banking_hours): added a function to add or take banked_hours

This commit is contained in:
Matthieu Haineault 2026-01-09 08:00:08 -05:00
parent 41efccac17
commit 70575e1772
11 changed files with 319 additions and 137 deletions

View File

@ -1,4 +1,4 @@
import { BankedHoursService } from "./services/banking-hours.service.service";
import { BankedHoursService } from "./services/banking-hours.service";
import { SickLeaveService } from "./services/sick-leave.service";
import { OvertimeService } from "./services/overtime.service";
import { VacationService } from "./services/vacation.service";

View File

@ -1,64 +0,0 @@
// import { Injectable } from "@nestjs/common";
// import { Result } from "src/common/errors/result-error.factory";
// import { PrismaService } from "src/prisma/prisma.service";
// @Injectable()
// export class BankedHoursService {
// constructor(private readonly prisma: PrismaService) { }
// //manage shifts with bank_code.type BANKING
// bankingHours = async (employee_id: number, hours: number): Promise<Result<number, string>> => {
// if (hours <= 0) return { success: false, error: 'INVALID_BANKING_HOURS' };
// try {
// const result = await this.prisma.$transaction(async (tx) => {
// const employee = await this.prisma.employees.findUnique({
// where: { id: employee_id },
// select: {
// id: true,
// paid_time_off: {
// select: {
// banked_hours: true
// },
// },
// },
// });
// if (!employee) {
// return { success: false, error: 'EMPLOYEE_NOT_FOUND' } as Result<number, string>
// }
// if (!employee.paid_time_off) {
// return { success: false, error: 'VACATION_HOURS_BANK_NOT_FOUND' } as Result<number, string>
// }
// const new_balance = await tx.paidTimeOff.update({
// where: { employee_id: employee.id },
// data: {
// banked_hours: { increment: hours },
// },
// select: {
// banked_hours: true,
// },
// });
// return { success: true, data: new_balance };
// });
// return result;
// } catch (error) {
// return { success: false, error: 'INVALID_BANKING_SHIFT' };
// }
// }
// //manage shifts with bank_code.type WITHDRAW_BANKED
// withdrawBankedHours = async (employee_id: number, asked_hours: number): Promise<Result<number, string>> => {
// if (asked_hours <= 0) return { success: false, error: 'INVALID_WITHDRAW_BANKED' };
// }
// }

View File

@ -0,0 +1,70 @@
import { Injectable } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class BankedHoursService {
constructor(private readonly prisma: PrismaService) { }
//manage shifts with bank_code.type BANKING
manageBankingHours = async (employee_id: number, asked_hours: number, type: string): Promise<Result<number, string>> => {
if (asked_hours <= 0) return { success: false, error: 'INVALID_BANKING_HOURS' };
try {
const result = await this.prisma.$transaction(async (tx) => {
const employee = await this.prisma.employees.findUnique({
where: { id: employee_id },
select: {
id: true,
paid_time_off: {
select: {
banked_hours: true
},
},
},
});
if (!employee) {
return { success: false, error: 'EMPLOYEE_NOT_FOUND' } as Result<number, string>;
}
if (!employee.paid_time_off) {
return { success: false, error: 'BANKING_HOURS_BANK_NOT_FOUND' } as Result<number, string>;
}
const banked_hours = (employee.paid_time_off.banked_hours).toNumber();
if (banked_hours <= 0) {
return { success: false, error: 'EMPTY_BANKED_HOURS' } as Result<number, string>;
}
if (type === 'BANKING') {
const new_balance = await tx.paidTimeOff.update({
where: { employee_id: employee.id },
data: { banked_hours: { increment: asked_hours } },
select: { banked_hours: true },
});
return { success: true, data: (new_balance.banked_hours).toNumber() } as Result<number, string>;
} else if (type === 'WITHDRAW_BANKED') {
if (asked_hours > banked_hours) {
return { success: true, data: banked_hours } as Result<number, string>;
} else {
}
await tx.paidTimeOff.update({
where: { employee_id: employee.id },
data: { banked_hours: { decrement: asked_hours } },
select: { banked_hours: true },
});
return { success: true, data: asked_hours } as Result<number, string>;
} else {
return { success: false, error: 'INVALID_SHIFT_TYPE' } as Result<number, string>;
}
});
return result;
} catch (error) {
return { success: false, error: 'INVALID_BANKING_SHIFT' };
}
}
}

View File

@ -111,68 +111,114 @@ export class SickLeaveService {
}
};
// switch employeeId for email
async calculateSickLeavePay(
employee_id: number,
reference_date: Date,
days_requested: number,
hours_per_day: number,
modifier: number,
): Promise<Result<number, string>> {
if (days_requested <= 0 || hours_per_day <= 0 || modifier <= 0) {
return { success: true, data: 0 };
}
//sets the year to jan 1st to dec 31st
const period_start = getYearStart(reference_date);
const period_end = reference_date;
//fetches all shifts of a selected employee
const shifts = await this.prisma.shifts.findMany({
where: {
timesheet: { employee_id: employee_id },
date: { gte: period_start, lte: period_end },
takeSickLeaveHours = async (employee_id: number, asked_hours: number): Promise<Result<number, string>> => {
if (asked_hours <= 0) return { success: false, error: 'INVALID_BANKING_HOURS' };
console.log('also got here')
try {
const result = await this.prisma.$transaction(async (tx) => {
const employee = await this.prisma.employees.findUnique({
where: { id: employee_id },
select: {
id: true,
paid_time_off: {
select: {
banked_hours: true
},
},
},
select: { date: true },
});
//count the amount of worked days
const worked_dates = new Set(
shifts.map((shift) => shift.date.toISOString().slice(0, 10)),
);
const days_worked = worked_dates.size;
if (!employee) {
return { success: false, error: 'EMPLOYEE_NOT_FOUND' } as Result<number, string>;
}
if (!employee.paid_time_off) {
return { success: false, error: 'SICK_HOURS_BANK_NOT_FOUND' } as Result<number, string>;
}
const sick_bank = (employee.paid_time_off.banked_hours).toNumber();
if (sick_bank <= 0) return { success: false, error: 'EMPTY_SICK_HOURS_BANK' } as Result<number, string>;
//less than 30 worked days returns 0
if (days_worked < 30) {
return { success: true, data: 0 };
if (asked_hours > sick_bank) {
return { success: true, data: sick_bank } as Result<number, string>;
} else {
await tx.paidTimeOff.update({
where: { employee_id: employee.id },
data: { banked_hours: { decrement: asked_hours } },
select: { banked_hours: true },
});
return { success: true, data: asked_hours } as Result<number, string>;
}
});
return result;
} catch (error) {
return { success: false, error: 'INVALID_BANKING_SHIFT' };
}
//default 3 days allowed after 30 worked days
let acquired_days = 3;
//identify the date of the 30th worked day
const ordered_dates = Array.from(worked_dates).sort();
const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day
//calculate each completed month, starting the 1st of the next month
const first_bonus_date = new Date(
threshold_date.getFullYear(),
threshold_date.getMonth() + 1,
1,
);
let months =
(period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
(period_end.getMonth() - first_bonus_date.getMonth()) +
1;
if (months < 0) months = 0;
acquired_days += months;
//cap of 10 days
if (acquired_days > 10) acquired_days = 10;
const payable_days = Math.min(acquired_days, days_requested);
const raw_hours = payable_days * hours_per_day * modifier;
const rounded = roundToQuarterHour(raw_hours);
return { success: true, data: rounded };
}
//LEAVE REQUEST FUNCTION - DEPRECATED
// async calculateSickLeavePay(
// employee_id: number,
// reference_date: Date,
// days_requested: number,
// hours_per_day: number,
// modifier: number,
// ): Promise<Result<number, string>> {
// if (days_requested <= 0 || hours_per_day <= 0 || modifier <= 0) {
// return { success: true, data: 0 };
// }
// //sets the year to jan 1st to dec 31st
// const period_start = getYearStart(reference_date);
// const period_end = reference_date;
// //fetches all shifts of a selected employee
// const shifts = await this.prisma.shifts.findMany({
// where: {
// timesheet: { employee_id: employee_id },
// date: { gte: period_start, lte: period_end },
// },
// select: { date: true },
// });
// //count the amount of worked days
// const worked_dates = new Set(
// shifts.map((shift) => shift.date.toISOString().slice(0, 10)),
// );
// const days_worked = worked_dates.size;
// //less than 30 worked days returns 0
// if (days_worked < 30) {
// return { success: true, data: 0 };
// }
// //default 3 days allowed after 30 worked days
// let acquired_days = 3;
// //identify the date of the 30th worked day
// const ordered_dates = Array.from(worked_dates).sort();
// const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day
// //calculate each completed month, starting the 1st of the next month
// const first_bonus_date = new Date(
// threshold_date.getFullYear(),
// threshold_date.getMonth() + 1,
// 1,
// );
// let months =
// (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
// (period_end.getMonth() - first_bonus_date.getMonth()) +
// 1;
// if (months < 0) months = 0;
// acquired_days += months;
// //cap of 10 days
// if (acquired_days > 10) acquired_days = 10;
// const payable_days = Math.min(acquired_days, days_requested);
// const raw_hours = payable_days * hours_per_day * modifier;
// const rounded = roundToQuarterHour(raw_hours);
// return { success: true, data: rounded };
// }
}

View File

@ -84,16 +84,17 @@ export class VacationService {
select: { paid_time_off: { select: { vacation_hours: true } } },
});
if (!employee) {
return { success: false, error: 'EMPLOYEE_NOT_FOUND' } as Result<number, string>
return { success: false, error: 'EMPLOYEE_NOT_FOUND' } as Result<number, string>;
}
if (!employee.paid_time_off) {
return { success: false, error: 'VACATION_HOURS_BANK_NOT_FOUND' } as Result<number, string>
return { success: false, error: 'VACATION_HOURS_BANK_NOT_FOUND' } as Result<number, string>;
}
const vacation_bank = employee.paid_time_off.vacation_hours.toNumber()
if (vacation_bank <= 0) return { success: false, error: 'EMPTY_VACATION_BANK' } as Result<number, string>;
//check if remaining hours are less than asked and return maximum available hours
if (asked_hours > vacation_bank) {
return { success: true, data: vacation_bank } as Result<number, string>
return { success: true, data: vacation_bank } as Result<number, string>;
} else {
//update vacation_bank
await tx.paidTimeOff.update({

View File

@ -11,6 +11,8 @@ import { SchedulePresetsApplyService } from "src/time-and-attendance/schedule-pr
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
import { ShiftsCreateService } from "src/time-and-attendance/shifts/services/shifts-create.service";
import { VacationService } from "src/time-and-attendance/domains/services/vacation.service";
import { SickLeaveService } from "src/time-and-attendance/domains/services/sick-leave.service";
import { BankedHoursService } from "src/time-and-attendance/domains/services/banking-hours.service";
@ -26,6 +28,8 @@ import { VacationService } from "src/time-and-attendance/domains/services/vacati
ShiftsCreateService,
BankCodesResolver,
VacationService,
SickLeaveService,
BankedHoursService,
],
exports: [
SchedulePresetsGetService,

View File

@ -8,6 +8,8 @@ import { Result } from "src/common/errors/result-error.factory";
import { toStringFromHHmm, toStringFromDate, toDateFromString, overlaps, toDateFromHHmm, computeHours } from "src/common/utils/date-utils";
import { ShiftDto } from "src/time-and-attendance/shifts/shift.dto";
import { VacationService } from "src/time-and-attendance/domains/services/vacation.service";
import { BankedHoursService } from "src/time-and-attendance/domains/services/banking-hours.service";
import { SickLeaveService } from "src/time-and-attendance/domains/services/sick-leave.service";
@Injectable()
export class ShiftsCreateService {
@ -16,6 +18,8 @@ export class ShiftsCreateService {
private readonly emailResolver: EmailToIdResolver,
private readonly typeResolver: BankCodesResolver,
private readonly vacationService: VacationService,
private readonly bankingService: BankedHoursService,
private readonly sickService: SickLeaveService,
) { }
//_________________________________________________________________
@ -95,15 +99,33 @@ export class ShiftsCreateService {
return { success: false, error: `SHIFT_OVERLAP` };
}
}
//api call to validate available hours in vacation_bank and ajust end_time accordingly
if (dto.type === 'VACATION') {
const asked_hours = computeHours(toDateFromString(dto.start_time), toDateFromString(dto.end_time));
const asked_hours = computeHours(toDateFromHHmm(dto.start_time), toDateFromHHmm(dto.end_time));
const vacation_shift = await this.vacationService.manageVacationHoursBank(employee_id, asked_hours)
if (!vacation_shift.success) return { success: false, error: vacation_shift.error };
dto.end_time = this.addHourstoDateString(dto.start_time, vacation_shift.data);
}
//ADD HERE THE LOGICS TO CHECK FOR AVAILABLE BANK TYPE "PAID_BANKED_HOUR" AND BANKING_HOUR
//api call to validate available hours in banked_hours and ajust end_time accordingly
const banking_types: string[] = ['BANKING', 'WITHDRAW_BANKED'];
if (banking_types.includes(dto.type)) {
const hours = computeHours(toDateFromHHmm(dto.start_time), toDateFromHHmm(dto.end_time));
const banking_shift = await this.bankingService.manageBankingHours(employee_id, hours, dto.type);
if (!banking_shift.success) return { success: false, error: banking_shift.error };
dto.end_time = this.addHourstoDateString(dto.start_time, banking_shift.data);
}
//api call to validate available hours in sick_hours and ajust end_time accordingly
if (dto.type === 'SICK') {
console.log('got here')
const hours = computeHours(toDateFromHHmm(dto.start_time), toDateFromHHmm(dto.end_time));
const sick_hours = await this.sickService.takeSickLeaveHours(employee_id, hours);
if (!sick_hours.success) return { success: false, error: sick_hours.error };
console.log(sick_hours.data)
dto.end_time = this.addHourstoDateString(dto.start_time, sick_hours.data);
}
//sends data for creation of a shift in db
const created_shift = await this.prisma.shifts.create({

View File

@ -1,5 +1,6 @@
import { Injectable } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
import { computeHours } from "src/common/utils/date-utils";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
@ -9,15 +10,64 @@ export class ShiftsDeleteService {
// DELETE
//_________________________________________________________________
//finds shifts using shit_ids
//ajust paid-time-off banks
//blocs deletion if approved
async deleteShift(shift_id: number): Promise<Result<number, string>> {
try {
return await this.prisma.$transaction(async (tx) => {
const paid_time_off_types: string[] = ['SICK', 'VACATION', 'BANKING', 'WITHDRAW_BANKED'];
const shift = await tx.shifts.findUnique({
where: { id: shift_id },
select: { id: true, date: true, timesheet_id: true },
select: {
id: true,
date: true,
start_time: true,
end_time: true,
timesheet: true,
bank_code: { select: { type: true } }
},
});
if (!shift) return { success: false, error: `SHIFT_NOT_FOUND` };
const ajusted_hours = computeHours(shift.start_time, shift.end_time);
//manage banked types, ensures update of amount of hours in bank is ajusted when a paid_time_off shift is deleted
if (paid_time_off_types.includes(shift.bank_code.type)) {
switch (shift.bank_code.type) {
case 'SICK':
await this.prisma.paidTimeOff.update({
where: { employee_id: shift.timesheet.employee_id },
data: {
sick_hours: { increment: ajusted_hours },
},
});
break;
case 'VACATION':
await this.prisma.paidTimeOff.update({
where: { employee_id: shift.timesheet.employee_id },
data: {
vacation_hours: { increment: ajusted_hours },
},
});
break;
case 'WITHDRAW_BANKED':
await this.prisma.paidTimeOff.update({
where: { employee_id: shift.timesheet.employee_id },
data: {
banked_hours: { decrement: ajusted_hours },
},
});
case 'BANKING':
await this.prisma.paidTimeOff.update({
where: { employee_id: shift.timesheet.employee_id },
data: {
banked_hours: { increment: ajusted_hours },
},
});
break;
default:
break;
}
}
await tx.shifts.delete({ where: { id: shift_id } });
return { success: true, data: shift.id };

View File

@ -1,4 +1,4 @@
import { toDateFromString, toStringFromHHmm, toStringFromDate, toDateFromHHmm, overlaps } from "src/common/utils/date-utils";
import { toDateFromString, toStringFromHHmm, toStringFromDate, toDateFromHHmm, overlaps, computeHours } from "src/common/utils/date-utils";
import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
@ -9,6 +9,7 @@ import { Result } from "src/common/errors/result-error.factory";
import { shift_select } from "src/time-and-attendance/utils/selects.utils";
import { Normalized } from "src/time-and-attendance/utils/type.utils";
import { ShiftDto } from "src/time-and-attendance/shifts/shift.dto";
import { EmailToIdResolver } from "src/common/mappers/email-id.mapper";
@Injectable()
export class ShiftsUpdateService {
@ -16,6 +17,7 @@ export class ShiftsUpdateService {
private readonly prisma: PrismaService,
private readonly typeResolver: BankCodesResolver,
private readonly timesheetResolver: EmployeeTimesheetResolver,
private readonly emailResolver: EmailToIdResolver,
) { }
async updateOneOrManyShifts(shifts: ShiftDto[], email: string): Promise<Result<boolean, string>> {
@ -60,8 +62,11 @@ export class ShiftsUpdateService {
//_________________________________________________________________
async updateShift(dto: ShiftDto, email: string): Promise<Result<ShiftDto, string>> {
try {
// const paid_time_off_types: string[] = ['SICK', 'VACATION', 'BANKING', 'WITHDRAW_BANKED'];
const timesheet = await this.timesheetResolver.findTimesheetIdByEmail(email, toDateFromString(dto.date));
if (!timesheet.success) return { success: false, error: timesheet.error }
if (!timesheet.success) return { success: false, error: timesheet.error };
const employee = await this.emailResolver.findIdByEmail(email);
if (!employee.success) return { success: false, error: employee.error };
//finds original shift
const original = await this.prisma.shifts.findFirst({
@ -78,6 +83,51 @@ export class ShiftsUpdateService {
//finds bank_code_id using the type
const bank_code = await this.typeResolver.findBankCodeIDByType(dto.type);
if (!bank_code.success) return { success: false, error: bank_code.error };
// const original_hours = computeHours(original.start_time, original.end_time);
// const ajusted_hours = computeHours(toDateFromHHmm(dto.start_time), toDateFromHHmm(dto.end_time));
// if (paid_time_off_types.includes(dto.type)) {
// switch (dto.type) {
// case 'SICK':
// if (ajusted_hours < original_hours){
// const diff_hours = original_hours - ajusted_hours;
// await this.prisma.paidTimeOff.update({
// where: { employee_id: employee.data },
// data: {
// sick_hours: { decrement: diff_hours },
// },
// });
// } else {
// }
// break;
// case 'VACATION':
// await this.prisma.paidTimeOff.update({
// where: { employee_id: shift.timesheet.employee_id },
// data: {
// vacation_hours: { increment: ajusted_hours },
// },
// });
// break;
// case 'WITHDRAW_BANKED':
// await this.prisma.paidTimeOff.update({
// where: { employee_id: shift.timesheet.employee_id },
// data: {
// banked_hours: { decrement: ajusted_hours },
// },
// });
// case 'BANKING':
// await this.prisma.paidTimeOff.update({
// where: { employee_id: shift.timesheet.employee_id },
// data: {
// banked_hours: { increment: ajusted_hours },
// },
// });
// break;
// default:
// break;
// }
// }
//updates sent to DB
const updated = await this.prisma.shifts.update({

View File

@ -6,10 +6,11 @@ import { ShiftsCreateService } from 'src/time-and-attendance/shifts/services/shi
import { ShiftsDeleteService } from 'src/time-and-attendance/shifts/services/shifts-delete.service';
import { ShiftsUpdateService } from 'src/time-and-attendance/shifts/services/shifts-update-delete.service';
import { VacationService } from 'src/time-and-attendance/domains/services/vacation.service';
import { BankedHoursService } from 'src/time-and-attendance/domains/services/banking-hours.service';
@Module({
controllers: [ShiftController],
providers: [ShiftsCreateService, ShiftsUpdateService, ShiftsDeleteService, VacationService],
providers: [ShiftsCreateService, ShiftsUpdateService, ShiftsDeleteService, VacationService, BankedHoursService],
exports: [ShiftsCreateService, ShiftsUpdateService, ShiftsDeleteService],
})
export class ShiftsModule { }

View File

@ -40,6 +40,7 @@ import { SchedulePresetsCreateService } from "src/time-and-attendance/schedule-p
import { SchedulePresetsApplyService } from "src/time-and-attendance/schedule-presets/services/schedule-presets-apply.service";
import { CsvGeneratorService } from "src/time-and-attendance/exports/services/csv-builder.service";
import { VacationService } from "src/time-and-attendance/domains/services/vacation.service";
import { BankedHoursService } from "src/time-and-attendance/domains/services/banking-hours.service";
@Module({
imports: [
@ -82,6 +83,7 @@ import { VacationService } from "src/time-and-attendance/domains/services/vacati
CsvExportService,
CsvGeneratorService,
VacationService,
BankedHoursService,
],
exports: [TimesheetApprovalService],
}) export class TimeAndAttendanceModule { };