From 9bc5c41de803ed98890a009937ed8d48b818797e Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Thu, 28 Aug 2025 10:22:00 -0400 Subject: [PATCH] refactor(timesheets): refactored findAll to return more data --- .../services/holiday.service.ts | 25 +- .../controllers/csv-exports.controller.ts | 11 +- .../exports/dtos/export-csv-options.dto.ts | 61 ++-- .../exports/services/csv-exports.service.ts | 290 +++++++++++------- .../timesheets/dtos/timesheet-period.dto.ts | 7 + .../timesheets/utils/timesheet.helpers.ts | 3 +- 6 files changed, 248 insertions(+), 149 deletions(-) diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index daf6e96..4fce9e0 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from "@nestjs/common"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../../../prisma/prisma.service"; import { computeHours, getWeekStart } from "src/common/utils/date-utils"; @@ -8,7 +8,23 @@ export class HolidayService { constructor(private readonly prisma: PrismaService) {} - //switch employeeId for email + //fetch employee_id by email + private async resolveEmployeeByEmail(email: string): Promise { + const employee = await this.prisma.employees.findFirst({ + where: { + user: { email } + }, + select: { id: true }, + }); + if(!employee) throw new NotFoundException(`Employee with email : ${email} not found`); + return employee.id; + } + + private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise { + const employee_id = await this.resolveEmployeeByEmail(email); + return this.computeHoursPrevious4Weeks(employee_id, holiday_date) + } + private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise { //sets the end of the window to 1ms before the week with the holiday const holiday_week_start = getWeekStart(holiday_date); @@ -32,9 +48,8 @@ export class HolidayService { return daily_hours; } - //switch employeeId for email - async calculateHolidayPay( employee_id: number, holiday_date: Date, modifier: number): Promise { - const hours = await this.computeHoursPrevious4Weeks(employee_id, holiday_date); + async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise { + const hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date); const daily_rate = Math.min(hours, 8); this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`); return daily_rate * modifier; diff --git a/src/modules/exports/controllers/csv-exports.controller.ts b/src/modules/exports/controllers/csv-exports.controller.ts index f59e84c..7346bcb 100644 --- a/src/modules/exports/controllers/csv-exports.controller.ts +++ b/src/modules/exports/controllers/csv-exports.controller.ts @@ -11,13 +11,13 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators"; export class CsvExportController { constructor(private readonly csvService: CsvExportService) {} - @Get('csv') + @Get('csv/:year/:period_no') @Header('Content-Type', 'text/csv; charset=utf-8') - @Header('Content-Dispoition', 'attachment; filename="export.csv"') + @Header('Content-Disposition', 'attachment; filename="export.csv"') //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) async exportCsv(@Query() options: ExportCsvOptionsDto, @Query('period') periodId: string ): Promise { - + //modify to accept year and period_number //sets default values const companies = options.companies && options.companies.length ? options.companies : [ ExportCompany.TARGO, ExportCompany.SOLUCOM]; @@ -28,11 +28,10 @@ export class CsvExportController { const all = await this.csvService.collectTransaction(Number(periodId), companies); //filters by type - const filtered = all.filter(r => { - switch (r.bank_code.toLocaleLowerCase()) { + const filtered = all.filter(row => { + switch (row.bank_code.toLocaleLowerCase()) { case 'holiday' : return types.includes(ExportType.HOLIDAY); case 'vacation' : return types.includes(ExportType.VACATION); - case 'sick-leave': return types.includes(ExportType.SICK_LEAVE); case 'expenses' : return types.includes(ExportType.EXPENSES); default : return types.includes(ExportType.SHIFTS); } diff --git a/src/modules/exports/dtos/export-csv-options.dto.ts b/src/modules/exports/dtos/export-csv-options.dto.ts index dc969ad..f2a2b49 100644 --- a/src/modules/exports/dtos/export-csv-options.dto.ts +++ b/src/modules/exports/dtos/export-csv-options.dto.ts @@ -1,26 +1,47 @@ -import { IsArray, IsEnum, IsOptional } from "class-validator"; +import { Transform } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator"; -export enum ExportType { - SHIFTS = 'Quart de travail', - EXPENSES = 'Depenses', - HOLIDAY = 'Ferie', - VACATION = 'Vacance', - SICK_LEAVE = 'Absence' -} - -export enum ExportCompany { - TARGO = 'Targo', - SOLUCOM = 'Solucom', +function toBoolean(v: any) { + if(typeof v === 'boolean') return v; + if(typeof v === 'string') return ['true', '1', 'on','yes'].includes(v.toLowerCase()); + return false; } export class ExportCsvOptionsDto { - @IsOptional() - @IsArray() - @IsEnum(ExportCompany, { each: true }) - companies?: ExportCompany[]; - @IsOptional() - @IsArray() - @IsEnum(ExportType, { each: true }) - type?: ExportType[]; + @Transform(({ value }) => parseInt(value,10)) + @IsInt() @Min(2023) + year! : number; + + @Transform(({ value }) => parseInt(value,10)) + @IsInt() @Min(1) @Max(26) + period_no!: number; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + approved? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + shifts? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + expenses? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + holiday? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + vacation? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + targo? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + solucom? : boolean = true; } \ No newline at end of file diff --git a/src/modules/exports/services/csv-exports.service.ts b/src/modules/exports/services/csv-exports.service.ts index ed9fab2..00157db 100644 --- a/src/modules/exports/services/csv-exports.service.ts +++ b/src/modules/exports/services/csv-exports.service.ts @@ -1,6 +1,5 @@ import { PrismaService } from "src/prisma/prisma.service"; -import { ExportCompany } from "../dtos/export-csv-options.dto"; -import { Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; export interface CsvRow { company_code: number; @@ -14,135 +13,189 @@ export interface CsvRow { holiday_date?: string; } +type Filters = { + types: { + shifts: boolean; + expenses: boolean; + holiday: boolean; + vacation: boolean; + }; + companies: { + targo: boolean; + solucom: boolean; + }; + approved: boolean; +}; + @Injectable() export class CsvExportService { constructor(private readonly prisma: PrismaService) {} - async collectTransaction( period_id: number, companies: ExportCompany[], approved: boolean = true): - Promise { - - const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); - + async collectTransaction( + year: number, + period_no: number, + filters: Filters, + approved: boolean = true + ): Promise { + //fetch period const period = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no: period_id }, + where: { pay_year: year, pay_period_no: period_no }, + select: { period_start: true, period_end: true }, }); - if(!period) throw new NotFoundException(`Pay period ${period_id} not found`); + if(!period) throw new NotFoundException(`Pay period ${ year }-${ period_no } not found`); - const start_date = period.period_start; - const end_date = period.period_end; + const start = period.period_start; + const end = period.period_end; - const approved_filter = approved ? { is_approved: true } : {}; + //fetch company codes from .env + const comapany_codes = this.resolveCompanyCodes(filters.companies); + if(comapany_codes.length === 0) throw new BadRequestException('No company selected'); - //fetching shifts - const shifts = await this.prisma.shifts.findMany({ - where: { - date: { gte: start_date, lte: end_date }, - ...approved_filter, - timesheet: { - employee: { company_code: { in: company_codes} } }, - }, - include: { - bank_code: true, - timesheet: { include: { - employee: { include: { - user:true, - supervisor: { include: { - user:true } } } } } }, - }, - }); - - //fetching expenses - const expenses = await this.prisma.expenses.findMany({ - where: { - date: { gte: start_date, lte: end_date }, - ...approved_filter, - timesheet: { employee: { company_code: { in: company_codes} } }, - }, - include: { bank_code: true, - timesheet: { include: { - employee: { include: { - user: true, - supervisor: { include: { - user:true } } } } } }, - }, - }); - - //fetching leave requests - const leaves = await this.prisma.leaveRequests.findMany({ - where : { - start_date_time: { gte: start_date, lte: end_date }, - employee: { company_code: { in: company_codes } }, - }, - include: { - bank_code: true, - employee: { include: { - user: true, - supervisor: { include: { - user: true } } } }, - }, - }); - - const rows: CsvRow[] = []; - - //Shifts Mapping - for (const shift of shifts) { - const emp = shift.timesheet.employee; - const week_number = this.computeWeekNumber(start_date, shift.date); - const hours = this.computeHours(shift.start_time, shift.end_time); - - rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: shift.bank_code.bank_code, - quantity_hours: hours, - amount: undefined, - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); + //Flag types + const { shifts: want_shifts, expenses: want_expense, holiday: want_holiday, vacation: want_vacation } = filters.types; + if(!want_shifts && !want_expense && !want_holiday && !want_vacation) { + throw new BadRequestException(' No export type selected '); } - //Expenses Mapping - for (const e of expenses) { - const emp = e.timesheet.employee; - const week_number = this.computeWeekNumber(start_date, e.date); + const approved_filter = filters.approved? { is_approved: true } : {}; - rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: e.bank_code.bank_code, - quantity_hours: undefined, - amount: Number(e.amount), - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); - } + //Prisma queries + const [shifts, expenses] = await Promise.all([ + want_shifts || want_expense || want_holiday || want_vacation + ]) - //Leaves Mapping - for(const l of leaves) { - if(!l.bank_code) continue; - const emp = l.employee; - const start = l.start_date_time; - const end = l.end_date_time ?? start; - const week_number = this.computeWeekNumber(start_date, start); - const hours = this.computeHours(start, end); - rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: l.bank_code.bank_code, - quantity_hours: hours, - amount: undefined, - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); - } + + + + + // const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); + + // 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 start_date = period.period_start; + // const end_date = period.period_end; + + // const included_shifts = await this.prisma.shifts.findMany({ + // where: { } + // }) + + // const approved_filter = approved ? { is_approved: true } : {}; + + // //fetching shifts + // const shifts = await this.prisma.shifts.findMany({ + // where: { + // date: { gte: start_date, lte: end_date }, + // ...approved_filter, + // timesheet: { + // employee: { company_code: { in: company_codes} } }, + // }, + // include: { + // bank_code: true, + // timesheet: { include: { + // employee: { include: { + // user:true, + // supervisor: { include: { + // user:true } } } } } }, + // }, + // }); + + // //fetching expenses + // const expenses = await this.prisma.expenses.findMany({ + // where: { + // date: { gte: start_date, lte: end_date }, + // ...approved_filter, + // timesheet: { employee: { company_code: { in: company_codes} } }, + // }, + // include: { bank_code: true, + // timesheet: { include: { + // employee: { include: { + // user: true, + // supervisor: { include: { + // user:true } } } } } }, + // }, + // }); + + // //fetching leave requests + // const leaves = await this.prisma.leaveRequests.findMany({ + // where : { + // start_date_time: { gte: start_date, lte: end_date }, + // employee: { company_code: { in: company_codes } }, + // }, + // include: { + // bank_code: true, + // employee: { include: { + // user: true, + // supervisor: { include: { + // user: true } } } }, + // }, + // }); + + // const rows: CsvRow[] = []; + + // //Shifts Mapping + // for (const shift of shifts) { + // const emp = shift.timesheet.employee; + // const week_number = this.computeWeekNumber(start_date, shift.date); + // const hours = this.computeHours(shift.start_time, shift.end_time); + + // rows.push({ + // company_code: emp.company_code, + // external_payroll_id: emp.external_payroll_id, + // full_name: `${emp.user.first_name} ${emp.user.last_name}`, + // bank_code: shift.bank_code.bank_code, + // quantity_hours: hours, + // amount: undefined, + // week_number, + // pay_date: this.formatDate(end_date), + // holiday_date: undefined, + // }); + // } + + // //Expenses Mapping + // for (const e of expenses) { + // const emp = e.timesheet.employee; + // const week_number = this.computeWeekNumber(start_date, e.date); + + // rows.push({ + // company_code: emp.company_code, + // external_payroll_id: emp.external_payroll_id, + // full_name: `${emp.user.first_name} ${emp.user.last_name}`, + // bank_code: e.bank_code.bank_code, + // quantity_hours: undefined, + // amount: Number(e.amount), + // week_number, + // pay_date: this.formatDate(end_date), + // holiday_date: undefined, + // }); + // } + + // //Leaves Mapping + // for(const l of leaves) { + // if(!l.bank_code) continue; + // const emp = l.employee; + // const start = l.start_date_time; + // const end = l.end_date_time ?? start; + + // const week_number = this.computeWeekNumber(start_date, start); + // const hours = this.computeHours(start, end); + + // rows.push({ + // company_code: emp.company_code, + // external_payroll_id: emp.external_payroll_id, + // full_name: `${emp.user.first_name} ${emp.user.last_name}`, + // bank_code: l.bank_code.bank_code, + // quantity_hours: hours, + // amount: undefined, + // week_number, + // pay_date: this.formatDate(end_date), + // holiday_date: undefined, + // }); + // } //Final Mapping and sorts return rows.sort((a,b) => { @@ -155,6 +208,9 @@ export class CsvExportService { return a.week_number - b.week_number; }); } + resolveCompanyCodes(companies: { targo: boolean; solucom: boolean; }) { + throw new Error("Method not implemented."); + } generateCsv(rows: CsvRow[]): Buffer { const header = [ @@ -172,7 +228,7 @@ export class CsvExportService { const body = rows.map(r => [ r.company_code, r.external_payroll_id, - `${r.full_name.replace(/"/g, '""')}"`, + `${r.full_name.replace(/"/g, '""')}`, r.bank_code, r.quantity_hours?.toFixed(2) ?? '', r.week_number, diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index 71a8e2f..c15b3a5 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -11,6 +11,13 @@ export class ExpenseDto { export type DayShiftsDto = ShiftDto[]; +export class DetailedShifts { + shifts: DayShiftsDto; + total_hours: number; + short_date: string; + break_durations?: number; +} + export class DayExpensesDto { cash: ExpenseDto[] = []; km : ExpenseDto[] = []; diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 6dfef18..6ad14af 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -6,7 +6,7 @@ export type DayKey = 'sun'|'mon'|'tue'|'wed'|'thu'|'fri'|'sat'; //DB line types type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean }; -type ExpenseRow = {date: Date; amount: number; type: string; is_approved?: boolean }; +type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean }; export function dayKeyFromDate(date: Date, useUTC = true): DayKey { const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday @@ -98,6 +98,7 @@ export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], for (const shift of week_shifts) { const key = dayKeyFromDate(shift.date, true); week.shifts[key].push({ + shifts: [], start: toTimeString(shift.start_time), end : toTimeString(shift.end_time), is_approved: shift.is_approved ?? true,