fix(shifts): clean

This commit is contained in:
Matthieu Haineault 2025-10-09 15:22:34 -04:00
parent 0a2369d5a5
commit 4527b0e7f7
5 changed files with 185 additions and 69 deletions

View File

@ -71,10 +71,10 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) {
async function main() { async function main() {
// --- Bank codes (pondérés: surtout G1 = régulier) --- // --- 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 = [ const WEIGHTED_CODES = [
'G1','G1','G1','G1','G1','G1','G1','G1', // 8x régulier 'G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1',
'G56','G48','G700','G105','G305','G43' 'G56','G48','G104','G105','G305','G1','G1','G1','G1','G1','G1'
] as const; ] as const;
const bcRows = await prisma.bankCodes.findMany({ const bcRows = await prisma.bankCodes.findMany({

View File

@ -1,55 +1,152 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils'; import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils';
import { Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class OvertimeService { export class OvertimeService {
private logger = new Logger(OvertimeService.name); private logger = new Logger(OvertimeService.name);
private daily_max = 12; // maximum for regular hours per day private daily_max = 8; // maximum for regular hours per day
private weekly_max = 80; //maximum for regular hours per week 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) {} constructor(private prisma: PrismaService) {}
//calculate Daily overtime //calculate daily overtime
getDailyOvertimeHours(start: Date, end: Date): number { async getDailyOvertimeHoursForDay(employee_id: number, date: Date): Promise<number> {
const hours = computeHours(start, end, 5); const shifts = await this.prisma.shifts.findMany({
const overtime = Math.max(0, hours - this.daily_max); where: { date: date, timesheet: { employee_id: employee_id } },
this.logger.debug(`getDailyOvertimeHours : ${overtime.toFixed(2)}h (threshold ${this.daily_max})`); 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; return overtime;
} }
//calculate Weekly overtime //calculate Weekly overtime
//switch employeeId for email async getWeeklyOvertimeHours(employee_id: number, ref_date: Date): Promise<number> {
async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise<number> { const week_start = getWeekStart(ref_date);
const week_start = getWeekStart(refDate);
const week_end = getWeekEnd(week_start); const week_end = getWeekEnd(week_start);
//fetches all shifts containing hours //fetches all shifts from INCLUDED_TYPES array
const shifts = await this.prisma.shifts.findMany({ const included_shifts = await this.prisma.shifts.findMany({
where: { timesheet: { employee_id: employeeId, shift: { where: {
every: {date: { gte: week_start, lte: week_end } } 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 //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); .reduce((sum, hours)=> sum+hours, 0);
const overtime = Math.max(0, total - this.weekly_max); 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; return overtime;
} }
//apply modifier to overtime hours //transform REGULAR shifts to OVERTIME when exceed 40hrs of included_types of shift
calculateOvertimePay(overtime_hours: number, modifier: number): number { async transformRegularHoursToWeeklyOvertime(
const pay = overtime_hours * modifier; employee_id: number,
this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`); ref_date: Date,
tx?: Prisma.TransactionClient,
): Promise<void> {
//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;
// }
} }

View File

@ -17,6 +17,9 @@ export class ShiftPayloadDto {
@IsBoolean() @IsBoolean()
is_remote!: boolean; is_remote!: boolean;
@IsBoolean()
is_approved!: boolean;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MaxLength(COMMENT_MAX_LENGTH) @MaxLength(COMMENT_MAX_LENGTH)

View File

@ -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 { formatHHmm, toDateOnlyUTC, weekStartSundayUTC } from "../helpers/shifts-date-time-helpers";
import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils"; import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils";
import { DayShiftResponse, UpsertAction } from "../types-and-interfaces/shifts-upsert.types"; 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 { UpsertShiftDto } from "../dtos/upsert-shift.dto";
import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { BaseApprovalService } from "src/common/shared/base-approval.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { OvertimeService } from "src/modules/business-logics/services/overtime.service";
@Injectable() @Injectable()
export class ShiftsCommandService extends BaseApprovalService<Shifts> { export class ShiftsCommandService extends BaseApprovalService<Shifts> {
private readonly logger = new Logger(ShiftsCommandService.name);
constructor( constructor(
prisma: PrismaService, prisma: PrismaService,
private readonly emailResolver: EmployeeIdEmailResolver, private readonly emailResolver: EmployeeIdEmailResolver,
private readonly bankTypeResolver: BankCodesResolver, private readonly bankTypeResolver: BankCodesResolver,
private readonly overtimeService: OvertimeService,
) { super(prisma); } ) { super(prisma); }
//_____________________________________________________________________________________________ //_____________________________________________________________________________________________
@ -61,16 +65,16 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
//validation/sanitation //validation/sanitation
//resolve bank_code_id using type //resolve bank_code_id using type
const old_norm_shift = old_shift ? await normalizeShiftPayload(old_shift) : undefined; 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()) { // 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'); // 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 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; 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()) { // 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'); // 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; 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<Shifts> {
start_time: old_norm_shift.start_time, start_time: old_norm_shift.start_time,
end_time: old_norm_shift.end_time, end_time: old_norm_shift.end_time,
is_remote: old_norm_shift.is_remote, is_remote: old_norm_shift.is_remote,
is_approved: old_norm_shift.is_approved,
comment: old_comment, comment: old_comment,
bank_code_id: old_bank_code_id, bank_code_id: old_bank_code_id,
}, },
@ -100,28 +105,28 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
}); });
}; };
//checks for overlaping shifts // //checks for overlaping shifts
const assertNoOverlap = (exclude_shift_id?: number)=> { // const assertNoOverlap = (exclude_shift_id?: number)=> {
if (!new_norm_shift) return; // if (!new_norm_shift) return;
const overlap_with = day_shifts.filter((shift)=> { // const overlap_with = day_shifts.filter((shift)=> {
if(exclude_shift_id && shift.id === exclude_shift_id) return false; // if(exclude_shift_id && shift.id === exclude_shift_id) return false;
return overlaps( // return overlaps(
new_norm_shift.start_time.getTime(), // new_norm_shift.start_time.getTime(),
new_norm_shift.end_time.getTime(), // new_norm_shift.end_time.getTime(),
shift.start_time.getTime(), // shift.start_time.getTime(),
shift.end_time.getTime(), // shift.end_time.getTime(),
); // );
}); // });
if(overlap_with.length > 0) { // if(overlap_with.length > 0) {
const conflicts = overlap_with.map((shift)=> ({ // const conflicts = overlap_with.map((shift)=> ({
start_time: formatHHmm(shift.start_time), // start_time: formatHHmm(shift.start_time),
end_time: formatHHmm(shift.end_time), // end_time: formatHHmm(shift.end_time),
type: shift.bank_code?.type ?? 'UNKNOWN', // type: shift.bank_code?.type ?? 'UNKNOWN',
})); // }));
throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts}); // throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts});
} // }
}; // };
let action: UpsertAction; let action: UpsertAction;
//_____________________________________________________________________________________________ //_____________________________________________________________________________________________
// DELETE // DELETE
@ -143,7 +148,7 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
//_____________________________________________________________________________________________ //_____________________________________________________________________________________________
else if (!old_shift && new_shift) { 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 ?? ''}`); 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({ await tx.shifts.create({
data: { data: {
timesheet_id: timesheet.id, timesheet_id: timesheet.id,
@ -165,7 +170,7 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); 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(); const existing = await findExactOldShift();
if(!existing) throw new NotFoundException({ error_code: 'SHIFT_STALE', message: 'The shift was modified or deleted by someone else'}); 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({ await tx.shifts.update({
where: { where: {
@ -182,23 +187,33 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
action = 'updated'; action = 'updated';
} else throw new BadRequestException('At least one of old_shift or new_shift must be provided'); } 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) //Reload the day (truth source)
const fresh_day = await tx.shifts.findMany({ const fresh_day = await tx.shifts.findMany({
where: { where: {
date: date_only, date: date_only,
timesheet_id: timesheet.id, timesheet_id: timesheet.id,
}, },
include: { include: { bank_code: true },
bank_code: true orderBy: { start_time: 'asc' },
},
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 { return {
action, action,
day: fresh_day.map<DayShiftResponse>((shift)=> ({ day: fresh_day.map<DayShiftResponse>((shift) => ({
start_time: formatHHmm(shift.start_time), start_time: formatHHmm(shift.start_time),
end_time: formatHHmm(shift.end_time), end_time: formatHHmm(shift.end_time),
type: shift.bank_code?.type ?? 'UNKNOWN', type: shift.bank_code?.type ?? 'UNKNOWN',

View File

@ -24,14 +24,15 @@ export function resolveBankCodeByType(type: string): Promise<number> {
export function normalizeShiftPayload(payload: ShiftPayloadDto) { export function normalizeShiftPayload(payload: ShiftPayloadDto) {
//normalize shift's infos //normalize shift's infos
const start_time = timeFromHHMMUTC(payload.start_time); const start_time = payload.start_time;
const end_time = timeFromHHMMUTC(payload.end_time ); const end_time = payload.end_time;
const type = (payload.type || '').trim().toUpperCase(); const type = (payload.type || '').trim().toUpperCase();
const is_remote = payload.is_remote === true; const is_remote = payload.is_remote === true;
const is_approved = payload.is_approved === false;
//normalize comment //normalize comment
const raw_comment = payload.comment ?? null; const raw_comment = payload.comment ?? null;
const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null; const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null;
const comment = trimmed && trimmed.length > 0 ? trimmed: 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 };
} }