fix(shifts): clean
This commit is contained in:
parent
0a2369d5a5
commit
4527b0e7f7
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ export class ShiftPayloadDto {
|
|||
@IsBoolean()
|
||||
is_remote!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
is_approved!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(COMMENT_MAX_LENGTH)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user