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() {
// --- 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({

View File

@ -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<number> {
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<number> {
const week_start = getWeekStart(refDate);
const week_end = getWeekEnd(week_start);
async getWeeklyOvertimeHours(employee_id: number, ref_date: Date): Promise<number> {
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<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()
is_remote!: boolean;
@IsBoolean()
is_approved!: boolean;
@IsOptional()
@IsString()
@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 { 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<Shifts> {
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<Shifts> {
//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<Shifts> {
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<Shifts> {
});
};
//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<Shifts> {
//_____________________________________________________________________________________________
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<Shifts> {
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<Shifts> {
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<DayShiftResponse>((shift)=> ({
day: fresh_day.map<DayShiftResponse>((shift) => ({
start_time: formatHHmm(shift.start_time),
end_time: formatHHmm(shift.end_time),
type: shift.bank_code?.type ?? 'UNKNOWN',

View File

@ -24,14 +24,15 @@ export function resolveBankCodeByType(type: string): Promise<number> {
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 };
}