diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index ab10340..878bc43 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -71,10 +71,10 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) { async function main() { // --- Bank codes (pondérés: surtout G1 = régulier) --- - const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305', 'G43'] as const; + const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305'] as const; const WEIGHTED_CODES = [ - 'G1','G1','G1','G1','G1','G1','G1','G1', // 8x régulier - 'G56','G48','G700','G105','G305','G43' + 'G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1', + 'G56','G48','G104','G105','G305','G1','G1','G1','G1','G1','G1' ] as const; const bcRows = await prisma.bankCodes.findMany({ diff --git a/src/modules/business-logics/services/overtime.service.ts b/src/modules/business-logics/services/overtime.service.ts index 79619b5..6c6d1b0 100644 --- a/src/modules/business-logics/services/overtime.service.ts +++ b/src/modules/business-logics/services/overtime.service.ts @@ -1,55 +1,152 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils'; +import { Prisma } from '@prisma/client'; @Injectable() export class OvertimeService { private logger = new Logger(OvertimeService.name); - private daily_max = 12; // maximum for regular hours per day - private weekly_max = 80; //maximum for regular hours per week + private daily_max = 8; // maximum for regular hours per day + private weekly_max = 40; //maximum for regular hours per week + private INCLUDED_TYPES = ['EMERGENCY', 'EVENING','OVERTIME','REGULAR'] as const; // included types for weekly overtime calculation constructor(private prisma: PrismaService) {} - //calculate Daily overtime - getDailyOvertimeHours(start: Date, end: Date): number { - const hours = computeHours(start, end, 5); - const overtime = Math.max(0, hours - this.daily_max); - this.logger.debug(`getDailyOvertimeHours : ${overtime.toFixed(2)}h (threshold ${this.daily_max})`); - return overtime; + //calculate daily overtime + async getDailyOvertimeHoursForDay(employee_id: number, date: Date): Promise { + const shifts = await this.prisma.shifts.findMany({ + where: { date: date, timesheet: { employee_id: employee_id } }, + select: { start_time: true, end_time: true }, + }); + const total = shifts.map((shift)=> + computeHours(shift.start_time, shift.end_time, 5)).reduce((sum, hours)=> sum + hours, 0); + const overtime = Math.max(0, total - this.daily_max); + + this.logger.debug(`[OVERTIME]-[DAILY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`); + return overtime; } //calculate Weekly overtime - //switch employeeId for email - async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise { - const week_start = getWeekStart(refDate); - const week_end = getWeekEnd(week_start); + async getWeeklyOvertimeHours(employee_id: number, ref_date: Date): Promise { + const week_start = getWeekStart(ref_date); + const week_end = getWeekEnd(week_start); - //fetches all shifts containing hours - const shifts = await this.prisma.shifts.findMany({ - where: { timesheet: { employee_id: employeeId, shift: { - every: {date: { gte: week_start, lte: week_end } } + //fetches all shifts from INCLUDED_TYPES array + const included_shifts = await this.prisma.shifts.findMany({ + where: { + date: { gte:week_start, lte: week_end }, + timesheet: { employee_id }, + bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } }, }, - }, - }, - select: { start_time: true, end_time: true }, + select: { start_time: true, end_time: true }, + orderBy: [{date: 'asc'}, {start_time:'asc'}], }); //calculate total hours of those shifts minus weekly Max to find total overtime hours - const total = shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5)) + const total = included_shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5)) .reduce((sum, hours)=> sum+hours, 0); const overtime = Math.max(0, total - this.weekly_max); - this.logger.debug(`weekly total = ${total.toFixed(2)}h, weekly Overtime= ${overtime.toFixed(2)}h`); + this.logger.debug(`[OVERTIME]-[WEEKLY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`); return overtime; } - //apply modifier to overtime hours - calculateOvertimePay(overtime_hours: number, modifier: number): number { - const pay = overtime_hours * modifier; - this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`); + //transform REGULAR shifts to OVERTIME when exceed 40hrs of included_types of shift + async transformRegularHoursToWeeklyOvertime( + employee_id: number, + ref_date: Date, + tx?: Prisma.TransactionClient, + ): Promise { + //ensures the use of the transaction if needed. fallback to this.prisma if no transaction is detected. + const db = tx ?? this.prisma; - return pay; + //calculate weekly overtime + const overtime_hours = await this.getWeeklyOvertimeHours(employee_id, ref_date); + if(overtime_hours <= 0) return; + + const convert_to_minutes = Math.round(overtime_hours * 60); + + const [regular, overtime] = await Promise.all([ + db.bankCodes.findFirst({where: { type: 'REGULAR' }, select: { id: true } }), + db.bankCodes.findFirst({where: { type: 'OVERTIME'}, select: { id: true } }), + ]); + if(!regular || !overtime) return; + + const week_start = getWeekStart(ref_date); + const week_end = getWeekEnd(week_start); + + //gets all regular shifts and order them by desc + const regular_shifts_desc = await db.shifts.findMany({ + where: { + date: { gte:week_start, lte: week_end }, + timesheet: { employee_id }, + bank_code_id: regular.id, + }, + select: { + id: true, + timesheet_id: true, + date: true, + start_time: true, + end_time: true, + is_remote: true, + comment: true, + }, + orderBy: [{date: 'desc'}, {start_time:'desc'}], + }); + + let remaining_minutes = convert_to_minutes; + + for(const shift of regular_shifts_desc) { + if(remaining_minutes <= 0) break; + + const start = shift.start_time; + const end = shift.end_time; + const duration_in_minutes = Math.max(0, Math.round((end.getTime() - start.getTime())/60000)); + if(duration_in_minutes === 0) continue; + + if(duration_in_minutes <= remaining_minutes) { + await db.shifts.update({ + where: { id: shift.id }, + data: { bank_code_id: overtime.id }, + }); + remaining_minutes -= duration_in_minutes; + continue; + } + //sets the start_time of the new overtime shift + const new_overtime_start = new Date(end.getTime() - remaining_minutes * 60000); + + //shorten the regular shift + await db.shifts.update({ + where: { id: shift.id }, + data: { end_time: new_overtime_start }, + }); + + //creates the new overtime shift to replace the shorten regular shift + await db.shifts.create({ + data: { + timesheet_id: shift.timesheet_id, + date: shift.date, + start_time: new_overtime_start, + end_time: end, + is_remote: shift.is_remote, + comment: shift.comment, + bank_code_id: overtime.id, + }, + }); + remaining_minutes = 0; + } + this.logger.debug(`[OVERTIME]-[WEEKLY]-[TRANSFORM] emp=${employee_id} + week: ${week_start.toISOString().slice(0,10)}..${week_end.toISOString().slice(0,10)} + converted= ${(convert_to_minutes-remaining_minutes)/60}h`); } + //apply modifier to overtime hours + // calculateOvertimePay(overtime_hours: number, modifier: number): number { + // const pay = overtime_hours * modifier; + // this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`); + + // return pay; + // } + } diff --git a/src/modules/shifts/dtos/upsert-shift.dto.ts b/src/modules/shifts/dtos/upsert-shift.dto.ts index b82fbb5..fc2e130 100644 --- a/src/modules/shifts/dtos/upsert-shift.dto.ts +++ b/src/modules/shifts/dtos/upsert-shift.dto.ts @@ -17,6 +17,9 @@ export class ShiftPayloadDto { @IsBoolean() is_remote!: boolean; + @IsBoolean() + is_approved!: boolean; + @IsOptional() @IsString() @MaxLength(COMMENT_MAX_LENGTH) diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index f7b59f3..a47e628 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; import { formatHHmm, toDateOnlyUTC, weekStartSundayUTC } from "../helpers/shifts-date-time-helpers"; import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils"; import { DayShiftResponse, UpsertAction } from "../types-and-interfaces/shifts-upsert.types"; @@ -8,13 +8,17 @@ import { Prisma, Shifts } from "@prisma/client"; import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { PrismaService } from "src/prisma/prisma.service"; +import { OvertimeService } from "src/modules/business-logics/services/overtime.service"; @Injectable() export class ShiftsCommandService extends BaseApprovalService { + private readonly logger = new Logger(ShiftsCommandService.name); + constructor( prisma: PrismaService, private readonly emailResolver: EmployeeIdEmailResolver, private readonly bankTypeResolver: BankCodesResolver, + private readonly overtimeService: OvertimeService, ) { super(prisma); } //_____________________________________________________________________________________________ @@ -61,16 +65,16 @@ export class ShiftsCommandService extends BaseApprovalService { //validation/sanitation //resolve bank_code_id using type const old_norm_shift = old_shift ? await normalizeShiftPayload(old_shift) : undefined; - if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) { - throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); - } + // if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) { + // throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); + // } const old_bank_code_id: number | undefined = old_norm_shift ? (await this.bankTypeResolver.findByType(old_norm_shift.type, tx))?.id : undefined; const new_norm_shift = new_shift ? await normalizeShiftPayload(new_shift) : undefined; - if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) { - throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); - } + // if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) { + // throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); + // } const new_bank_code_id: number | undefined = new_norm_shift ? (await this.bankTypeResolver.findByType(new_norm_shift.type, tx))?.id : undefined; @@ -93,6 +97,7 @@ export class ShiftsCommandService extends BaseApprovalService { start_time: old_norm_shift.start_time, end_time: old_norm_shift.end_time, is_remote: old_norm_shift.is_remote, + is_approved: old_norm_shift.is_approved, comment: old_comment, bank_code_id: old_bank_code_id, }, @@ -100,28 +105,28 @@ export class ShiftsCommandService extends BaseApprovalService { }); }; - //checks for overlaping shifts - const assertNoOverlap = (exclude_shift_id?: number)=> { - if (!new_norm_shift) return; - const overlap_with = day_shifts.filter((shift)=> { - if(exclude_shift_id && shift.id === exclude_shift_id) return false; - return overlaps( - new_norm_shift.start_time.getTime(), - new_norm_shift.end_time.getTime(), - shift.start_time.getTime(), - shift.end_time.getTime(), - ); - }); + // //checks for overlaping shifts + // const assertNoOverlap = (exclude_shift_id?: number)=> { + // if (!new_norm_shift) return; + // const overlap_with = day_shifts.filter((shift)=> { + // if(exclude_shift_id && shift.id === exclude_shift_id) return false; + // return overlaps( + // new_norm_shift.start_time.getTime(), + // new_norm_shift.end_time.getTime(), + // shift.start_time.getTime(), + // shift.end_time.getTime(), + // ); + // }); - if(overlap_with.length > 0) { - const conflicts = overlap_with.map((shift)=> ({ - start_time: formatHHmm(shift.start_time), - end_time: formatHHmm(shift.end_time), - type: shift.bank_code?.type ?? 'UNKNOWN', - })); - throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts}); - } - }; + // if(overlap_with.length > 0) { + // const conflicts = overlap_with.map((shift)=> ({ + // start_time: formatHHmm(shift.start_time), + // end_time: formatHHmm(shift.end_time), + // type: shift.bank_code?.type ?? 'UNKNOWN', + // })); + // throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts}); + // } + // }; let action: UpsertAction; //_____________________________________________________________________________________________ // DELETE @@ -143,7 +148,7 @@ export class ShiftsCommandService extends BaseApprovalService { //_____________________________________________________________________________________________ else if (!old_shift && new_shift) { if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); - assertNoOverlap(); + // assertNoOverlap(); await tx.shifts.create({ data: { timesheet_id: timesheet.id, @@ -165,7 +170,7 @@ export class ShiftsCommandService extends BaseApprovalService { if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); const existing = await findExactOldShift(); if(!existing) throw new NotFoundException({ error_code: 'SHIFT_STALE', message: 'The shift was modified or deleted by someone else'}); - assertNoOverlap(existing.id); + // assertNoOverlap(existing.id); await tx.shifts.update({ where: { @@ -182,23 +187,33 @@ export class ShiftsCommandService extends BaseApprovalService { action = 'updated'; } else throw new BadRequestException('At least one of old_shift or new_shift must be provided'); + //switches regular hours to overtime hours when exceeds 40hrs per week. + await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx); + //Reload the day (truth source) const fresh_day = await tx.shifts.findMany({ where: { date: date_only, timesheet_id: timesheet.id, }, - include: { - bank_code: true - }, - orderBy: { - start_time: 'asc' - }, + include: { bank_code: true }, + orderBy: { start_time: 'asc' }, }); + try { + const [ daily_overtime, weekly_overtime ] = await Promise.all([ + this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only), + this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only), + ]); + this.logger.debug(`[OVERTIME] employee_id= ${employee_id}, date=${date_only.toISOString().slice(0,10)} + | daily= ${daily_overtime.toFixed(2)}h, weekly: ${weekly_overtime.toFixed(2)}h, (action:${action})`); + } catch (error) { + this.logger.warn(`Failed to compute overtime after ${action} : ${(error as Error).message}`); + } + return { action, - day: fresh_day.map((shift)=> ({ + day: fresh_day.map((shift) => ({ start_time: formatHHmm(shift.start_time), end_time: formatHHmm(shift.end_time), type: shift.bank_code?.type ?? 'UNKNOWN', diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts index cec997f..7988388 100644 --- a/src/modules/shifts/utils/shifts.utils.ts +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -24,14 +24,15 @@ export function resolveBankCodeByType(type: string): Promise { export function normalizeShiftPayload(payload: ShiftPayloadDto) { //normalize shift's infos - const start_time = timeFromHHMMUTC(payload.start_time); - const end_time = timeFromHHMMUTC(payload.end_time ); + const start_time = payload.start_time; + const end_time = payload.end_time; const type = (payload.type || '').trim().toUpperCase(); const is_remote = payload.is_remote === true; + const is_approved = payload.is_approved === false; //normalize comment const raw_comment = payload.comment ?? null; const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null; const comment = trimmed && trimmed.length > 0 ? trimmed: null; - return { start_time, end_time, type, is_remote, comment }; + return { start_time, end_time, type, is_remote, comment, is_approved }; } \ No newline at end of file