targo-backend/src/modules/shifts/services/shifts-query.service.ts

270 lines
9.9 KiB
TypeScript

import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateShiftDto } from "../dtos/create-shift.dto";
import { Shifts, ShiftsArchive } from "@prisma/client";
import { UpdateShiftsDto } from "../dtos/update-shift.dto";
import { buildPrismaWhere } from "src/common/shared/build-prisma-where";
import { SearchShiftsDto } from "../dtos/search-shift.dto";
import { NotificationsService } from "src/modules/notifications/services/notifications.service";
import { computeHours, hoursBetweenSameDay } from "src/common/utils/date-utils";
const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 8);
export interface OverviewRow {
fullName: string;
supervisor: string;
totalRegularHrs: number;
totalEveningHrs: number;
totalOvertimeHrs: number;
totalExpenses: number;
totalMileage: number;
isValidated: boolean;
}
@Injectable()
export class ShiftsQueryService {
constructor(
private readonly prisma: PrismaService,
private readonly notifs: NotificationsService,
) {}
async create(dto: CreateShiftDto): Promise<Shifts> {
const { timesheet_id, bank_code_id, date, start_time, end_time, description } = dto;
//shift creation
const shift = await this.prisma.shifts.create({
data: { timesheet_id, bank_code_id, date, start_time, end_time, description },
include: { timesheet: { include: { employee: { include: { user: true } } } },
bank_code: true,
},
});
//fetches all shifts of the same day to check for daily overtime
const sameDayShifts = await this.prisma.shifts.findMany({
where: { timesheet_id, date },
select: { id: true, date: true, start_time: true, end_time: true },
});
//sums hours of the day
const totalHours = sameDayShifts.reduce((sum, s) => {
return sum + hoursBetweenSameDay(s.date, s.start_time, s.end_time);
}, 0 );
//Notify if total hours > 8 for a single day
if(totalHours > DAILY_LIMIT_HOURS ) {
const userId = String(shift.timesheet.employee.user.id);
const dateLabel = new Date(date).toLocaleDateString('fr-CA');
this.notifs.notify(userId, {
type: 'shift.overtime.daily',
severity: 'warn',
message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${dateLabel} (total: ${totalHours.toFixed(2)}h).`,
ts: new Date().toISOString(),
meta: {
timesheet_id,
date: new Date(date).toISOString(),
totalHours,
threshold: DAILY_LIMIT_HOURS,
lastShiftId: shift.id
},
});
}
return shift;
}
async findAll(filters: SearchShiftsDto): Promise <Shifts[]> {
const where = buildPrismaWhere(filters);
const shifts = await this.prisma.shifts.findMany({ where })
return shifts;
}
async findOne(id: number): Promise<Shifts> {
const shift = await this.prisma.shifts.findUnique({
where: { id },
include: { timesheet: { include: { employee: { include: { user: true } } } },
bank_code: true,
},
});
if(!shift) {
throw new NotFoundException(`Shift #${id} not found`);
}
return shift;
}
async update(id: number, dto: UpdateShiftsDto): Promise<Shifts> {
await this.findOne(id);
const { timesheet_id, bank_code_id, date,start_time,end_time, description} = dto;
return this.prisma.shifts.update({
where: { id },
data: {
...(timesheet_id !== undefined && { timesheet_id }),
...(bank_code_id !== undefined && { bank_code_id }),
...(date !== undefined && { date }),
...(start_time !== undefined && { start_time }),
...(end_time !== undefined && { end_time }),
...(description !== undefined && { description }),
},
include: { timesheet: { include: { employee: { include: { user: true } } } },
bank_code: true,
},
});
}
async remove(id: number): Promise<Shifts> {
await this.findOne(id);
return this.prisma.shifts.delete({ where: { id } });
}
async getSummary(period_id: number): Promise<OverviewRow[]> {
//fetch pay-period to display
const period = await this.prisma.payPeriods.findFirst({
where: { pay_period_no: period_id },
});
if(!period) {
throw new NotFoundException(`pay-period ${period_id} not found`);
}
const { period_start, period_end } = period;
//prepare shifts and expenses for display
const shifts = await this.prisma.shifts.findMany({
where: { date: { gte: period_start, lte: period_end } },
include: {
bank_code: true,
timesheet: { include: {
employee: { include: {
user:true,
supervisor: { include: { user: true } },
} },
} },
},
});
const expenses = await this.prisma.expenses.findMany({
where: { date: { gte: period_start, lte: period_end } },
include: {
bank_code: true,
timesheet: { include: { employee: {
include: { user:true,
supervisor: { include: { user:true } },
} },
} },
},
});
const mapRow = new Map<string, OverviewRow>();
for(const s of shifts) {
const employeeId = s.timesheet.employee.user_id;
const user = s.timesheet.employee.user;
const sup = s.timesheet.employee.supervisor?.user;
let row = mapRow.get(employeeId);
if(!row) {
row = {
fullName: `${user.first_name} ${user.last_name}`,
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
totalRegularHrs: 0,
totalEveningHrs: 0,
totalOvertimeHrs: 0,
totalExpenses: 0,
totalMileage: 0,
isValidated: false,
};
}
const hours = computeHours(s.start_time, s.end_time);
switch(s.bank_code.type) {
case 'regular' : row.totalRegularHrs += hours;
break;
case 'evening' : row.totalEveningHrs += hours;
break;
case 'overtime' : row.totalOvertimeHrs += hours;
break;
default: row.totalRegularHrs += hours;
}
mapRow.set(employeeId, row);
}
for(const e of expenses) {
const employeeId = e.timesheet.employee.user_id;
const user = e.timesheet.employee.user;
const sup = e.timesheet.employee.supervisor?.user;
let row = mapRow.get(employeeId);
if(!row) {
row = {
fullName: `${user.first_name} ${user.last_name}`,
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
totalRegularHrs: 0,
totalEveningHrs: 0,
totalOvertimeHrs: 0,
totalExpenses: 0,
totalMileage: 0,
isValidated: false,
};
}
const amount = Number(e.amount);
row.totalExpenses += amount;
if(e.bank_code.type === 'mileage') {
row.totalMileage += amount;
}
mapRow.set(employeeId, row);
}
//return by default the list of employee in ascending alphabetical order
return Array.from(mapRow.values()).sort((a,b) => a.fullName.localeCompare(b.fullName));
}
//archivation functions ******************************************************
async archiveOld(): Promise<void> {
//fetches archived timesheet's Ids
const archivedTimesheets = await this.prisma.timesheetsArchive.findMany({
select: { timesheet_id: true },
});
const timesheetIds = archivedTimesheets.map(sheet => sheet.timesheet_id);
if(timesheetIds.length === 0) {
return;
}
// copy/delete transaction
await this.prisma.$transaction(async transaction => {
//fetches shifts to move to archive
const shiftsToArchive = await transaction.shifts.findMany({
where: { timesheet_id: { in: timesheetIds } },
});
if(shiftsToArchive.length === 0) {
return;
}
//copies sent to archive table
await transaction.shiftsArchive.createMany({
data: shiftsToArchive.map(shift => ({
shift_id: shift.id,
timesheet_id: shift.timesheet_id,
bank_code_id: shift.bank_code_id,
description: shift.description ?? undefined,
date: shift.date,
start_time: shift.start_time,
end_time: shift.end_time,
})),
});
//delete from shifts table
await transaction.shifts.deleteMany({
where: { id: { in: shiftsToArchive.map(shift => shift.id) } },
})
})
}
//fetches all archived timesheets
async findAllArchived(): Promise<ShiftsArchive[]> {
return this.prisma.shiftsArchive.findMany();
}
//fetches an archived timesheet
async findOneArchived(id: number): Promise<ShiftsArchive> {
return this.prisma.shiftsArchive.findUniqueOrThrow({ where: { id } });
}
}