refactor(pay-period): ajusted logics services and controller of model pay-periods

This commit is contained in:
Matthieu Haineault 2025-08-19 14:49:47 -04:00
parent ae6ce4bf97
commit f765a99273
17 changed files with 325 additions and 257 deletions

View File

@ -3035,22 +3035,27 @@
"PayPeriodDto": { "PayPeriodDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"period_number": { "pay_period_no": {
"type": "number", "type": "number",
"example": 1, "example": 1,
"description": "numéro cyclique de la période entre 1 et 26" "description": "numéro cyclique de la période entre 1 et 26"
}, },
"start_date": { "period_start": {
"type": "string", "type": "string",
"example": "2023-12-17", "example": "2023-12-17",
"format": "date" "format": "date"
}, },
"end_date": { "period_end": {
"type": "string", "type": "string",
"example": "2023-12-30", "example": "2023-12-30",
"format": "date" "format": "date"
}, },
"year": { "payday": {
"type": "string",
"example": "2023-01-04",
"format": "date"
},
"pay_year": {
"type": "number", "type": "number",
"example": 2023 "example": 2023
}, },
@ -3060,10 +3065,11 @@
} }
}, },
"required": [ "required": [
"period_number", "pay_period_no",
"start_date", "period_start",
"end_date", "period_end",
"year", "payday",
"pay_year",
"label" "label"
] ]
}, },
@ -3155,28 +3161,34 @@
"PayPeriodOverviewDto": { "PayPeriodOverviewDto": {
"type": "object", "type": "object",
"properties": { "properties": {
"period_number": { "pay_period_no": {
"type": "number", "type": "number",
"example": 1, "example": 1,
"description": "Period number (126)" "description": "Period number (126)"
}, },
"year": { "pay_year": {
"type": "number", "type": "number",
"example": 2023, "example": 2023,
"description": "Calendar year of the period" "description": "Calendar year of the period"
}, },
"start_date": { "period_start": {
"type": "string", "type": "string",
"example": "2023-12-17", "example": "2023-12-17",
"format": "date", "format": "date",
"description": "Period start date (YYYY-MM-DD)" "description": "Period start date (YYYY-MM-DD)"
}, },
"end_date": { "period_end": {
"type": "string", "type": "string",
"example": "2023-12-30", "example": "2023-12-30",
"format": "date", "format": "date",
"description": "Period end date (YYYY-MM-DD)" "description": "Period end date (YYYY-MM-DD)"
}, },
"payday": {
"type": "string",
"example": "2023-12-30",
"format": "date",
"description": "Period pay day(YYYY-MM-DD)"
},
"label": { "label": {
"type": "string", "type": "string",
"example": "2023-12-17 → 2023-12-30", "example": "2023-12-17 → 2023-12-30",
@ -3191,10 +3203,11 @@
} }
}, },
"required": [ "required": [
"period_number", "pay_period_no",
"year", "pay_year",
"start_date", "period_start",
"end_date", "period_end",
"payday",
"label", "label",
"employees_overview" "employees_overview"
] ]

View File

@ -5,39 +5,41 @@
CREATE OR REPLACE VIEW pay_period AS CREATE OR REPLACE VIEW pay_period AS
WITH WITH
anchor AS ( anchor AS (
SELECT '2023-12-17'::date AS anchor_date SELECT '2023-12-17'::date AS anchor_sunday
),
current_pay_period AS(
SELECT
((now()::date - anchor_date) % 14) +1 AS current_day_in_pay_period
FROM anchor
),
bounds AS (
SELECT
(now()::date
- INTERVAL '6 months'
- (current_day_in_pay_period || ' days')::INTERVAL
)::date AS start_bound,
(now()::date + INTERVAL '1 month'
- (current_day_in_pay_period || ' days')::INTERVAL
)::date AS end_bound,
anchor.anchor_date
FROM anchor
CROSS JOIN current_pay_period
), ),
series AS ( series AS (
SELECT SELECT
generate_series(bounds.start_bound, bounds.end_bound, '14 days') AS period_start, gs::date AS period_start, -- Dimanche
bounds.anchor_date (gs + INTERVAL '13 days')::date AS period_end, -- Samedi
FROM bounds (gs + INTERVAL '18 days')::date AS payday -- Jeudi suivant pour viser l'année fiscale
FROM generate_series(
(SELECT anchor_sunday FROM anchor),
(CURRENT_DATE + INTERVAL '1 month')::date,
INTERVAL '14 days'
) AS gs
),
numbered AS (
SELECT
period_start,
period_end,
payday,
EXTRACT(YEAR FROM payday)::int AS pay_year,
ROW_NUMBER() OVER (
PARTITION BY EXTRACT(YEAR FROM payday)
ORDER BY payday
) AS pay_period_no
FROM series
) )
SELECT SELECT
((row_number() OVER (ORDER BY period_start) - 1) % 26) + 1 AS period_number, pay_year,
period_start AS start_date, pay_period_no,
period_start + INTERVAL '13 days' AS end_date, period_start,
EXTRACT(YEAR FROM period_start)::int AS year, period_end,
period_start || ' -> ' || payday,
to_char(period_start + INTERVAL '13 days', 'YYYY-MM-DD') to_char(period_start, 'YYYY-MM-DD') || '->' ||
AS label to_char(period_end, 'YYYY-MM-DD') AS label
FROM series FROM numbered
ORDER BY period_start;
WHERE payday BETWEEN (CURRENT_DATE - INTERVAL '6 months')::date
AND (CURRENT_DATE + INTERVAL '1 month')::date
ORDER BY period_start;

View File

@ -135,11 +135,12 @@ model LeaveRequestsArchive {
//pay-period vue //pay-period vue
view PayPeriods { view PayPeriods {
period_number Int pay_year Int
start_date DateTime @db.Date pay_period_no Int
end_date DateTime @db.Date payday DateTime @db.Date
year Int period_start DateTime @db.Date
label String period_end DateTime @db.Date
label String
@@map("pay_period") @@map("pay_period")
} }

View File

@ -8,6 +8,7 @@ export class HolidayService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
//switch employeeId for email
private async computeHoursPrevious4Weeks(employeeId: number, holidayDate: Date): Promise<number> { private async computeHoursPrevious4Weeks(employeeId: number, holidayDate: Date): Promise<number> {
//sets the end of the window to 1ms before the week with the holiday //sets the end of the window to 1ms before the week with the holiday
const holidayWeekStart = getWeekStart(holidayDate); const holidayWeekStart = getWeekStart(holidayDate);
@ -31,6 +32,7 @@ export class HolidayService {
return dailyHours; return dailyHours;
} }
//switch employeeId for email
async calculateHolidayPay( employeeId: number, holidayDate: Date, modifier: number): Promise<number> { async calculateHolidayPay( employeeId: number, holidayDate: Date, modifier: number): Promise<number> {
const hours = await this.computeHoursPrevious4Weeks(employeeId, holidayDate); const hours = await this.computeHoursPrevious4Weeks(employeeId, holidayDate);
const dailyRate = Math.min(hours, 8); const dailyRate = Math.min(hours, 8);

View File

@ -20,6 +20,7 @@ export class OvertimeService {
} }
//calculate Weekly overtime //calculate Weekly overtime
//switch employeeId for email
async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise<number> { async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise<number> {
const weekStart = getWeekStart(refDate); const weekStart = getWeekStart(refDate);
const weekEnd = getWeekEnd(weekStart); const weekEnd = getWeekEnd(weekStart);

View File

@ -8,6 +8,7 @@ export class SickLeaveService {
private readonly logger = new Logger(SickLeaveService.name); private readonly logger = new Logger(SickLeaveService.name);
//switch employeeId for email
async calculateSickLeavePay(employeeId: number, referenceDate: Date, daysRequested: number, modifier: number): Promise<number> { async calculateSickLeavePay(employeeId: number, referenceDate: Date, daysRequested: number, modifier: number): Promise<number> {
//sets the year to jan 1st to dec 31st //sets the year to jan 1st to dec 31st
const periodStart = getYearStart(referenceDate); const periodStart = getYearStart(referenceDate);

View File

@ -15,6 +15,7 @@ export class VacationService {
* @param modifier Coefficient of hours(1) * @param modifier Coefficient of hours(1)
* @returns amount of payable hours * @returns amount of payable hours
*/ */
//switch employeeId for email
async calculateVacationPay(employeeId: number, startDate: Date, daysRequested: number, modifier: number): Promise<number> { async calculateVacationPay(employeeId: number, startDate: Date, daysRequested: number, modifier: number): Promise<number> {
//fetch hiring date //fetch hiring date
const employee = await this.prisma.employees.findUnique({ const employee = await this.prisma.employees.findUnique({

View File

@ -29,7 +29,7 @@ export class CsvExportController {
//filters by type //filters by type
const filtered = all.filter(r => { const filtered = all.filter(r => {
switch (r.bankCode.toLocaleLowerCase()) { switch (r.bank_code.toLocaleLowerCase()) {
case 'holiday' : return types.includes(ExportType.HOLIDAY); case 'holiday' : return types.includes(ExportType.HOLIDAY);
case 'vacation' : return types.includes(ExportType.VACATION); case 'vacation' : return types.includes(ExportType.VACATION);
case 'sick-leave': return types.includes(ExportType.SICK_LEAVE); case 'sick-leave': return types.includes(ExportType.SICK_LEAVE);

View File

@ -3,33 +3,33 @@ import { ExportCompany } from "../dtos/export-csv-options.dto";
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
export interface CsvRow { export interface CsvRow {
companyCode: number; company_code: number;
externalPayrollId: number; external_payroll_id: number;
fullName: string; full_name: string;
bankCode: string; bank_code: string;
quantityHours?: number; quantity_hours?: number;
amount?: number; amount?: number;
weekNumber: number; week_number: number;
payDate: string; pay_date: string;
holidayDate?: string; holiday_date?: string;
} }
@Injectable() @Injectable()
export class CsvExportService { export class CsvExportService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async collectTransaction(periodId: number, companies: ExportCompany[]): Promise<CsvRow[]> { async collectTransaction(period_id: number, companies: ExportCompany[]): Promise<CsvRow[]> {
const companyCodes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); const companyCodes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2);
const period = await this.prisma.payPeriods.findFirst({ const period = await this.prisma.payPeriods.findFirst({
where: { period_number: periodId }, where: { pay_period_no: period_id },
}); });
if(!period) { if(!period) {
throw new NotFoundException(`Pay period ${periodId} not found`); throw new NotFoundException(`Pay period ${period_id} not found`);
} }
const startDate = period.start_date; const startDate = period.period_start;
const endDate = period.end_date; const endDate = period.period_end;
//fetching shifts //fetching shifts
const shifts = await this.prisma.shifts.findMany({ const shifts = await this.prisma.shifts.findMany({
@ -72,39 +72,39 @@ export class CsvExportService {
const rows: CsvRow[] = []; const rows: CsvRow[] = [];
//Shifts Mapping //Shifts Mapping
for (const s of shifts) { for (const shift of shifts) {
const emp = s.timesheet.employee; const emp = shift.timesheet.employee;
const weekNumber = this.computeWeekNumber(startDate, s.date); const week_number = this.computeWeekNumber(startDate, shift.date);
const hours = this.computeHours(s.start_time, s.end_time); const hours = this.computeHours(shift.start_time, shift.end_time);
rows.push({ rows.push({
companyCode: emp.company_code, company_code: emp.company_code,
externalPayrollId: emp.external_payroll_id, external_payroll_id: emp.external_payroll_id,
fullName: `${emp.user.first_name} ${emp.user.last_name}`, full_name: `${emp.user.first_name} ${emp.user.last_name}`,
bankCode: s.bank_code.bank_code, bank_code: shift.bank_code.bank_code,
quantityHours: hours, quantity_hours: hours,
amount: undefined, amount: undefined,
weekNumber, week_number,
payDate: this.formatDate(endDate), pay_date: this.formatDate(endDate),
holidayDate: undefined, holiday_date: undefined,
}); });
} }
//Expenses Mapping //Expenses Mapping
for (const e of expenses) { for (const e of expenses) {
const emp = e.timesheet.employee; const emp = e.timesheet.employee;
const weekNumber = this.computeWeekNumber(startDate, e.date); const week_number = this.computeWeekNumber(startDate, e.date);
rows.push({ rows.push({
companyCode: emp.company_code, company_code: emp.company_code,
externalPayrollId: emp.external_payroll_id, external_payroll_id: emp.external_payroll_id,
fullName: `${emp.user.first_name} ${emp.user.last_name}`, full_name: `${emp.user.first_name} ${emp.user.last_name}`,
bankCode: e.bank_code.bank_code, bank_code: e.bank_code.bank_code,
quantityHours: undefined, quantity_hours: undefined,
amount: Number(e.amount), amount: Number(e.amount),
weekNumber, week_number,
payDate: this.formatDate(endDate), pay_date: this.formatDate(endDate),
holidayDate: undefined, holiday_date: undefined,
}); });
} }
@ -115,56 +115,56 @@ export class CsvExportService {
const start = l.start_date_time; const start = l.start_date_time;
const end = l.end_date_time ?? start; const end = l.end_date_time ?? start;
const weekNumber = this.computeWeekNumber(startDate, start); const week_number = this.computeWeekNumber(startDate, start);
const hours = this.computeHours(start, end); const hours = this.computeHours(start, end);
rows.push({ rows.push({
companyCode: emp.company_code, company_code: emp.company_code,
externalPayrollId: emp.external_payroll_id, external_payroll_id: emp.external_payroll_id,
fullName: `${emp.user.first_name} ${emp.user.last_name}`, full_name: `${emp.user.first_name} ${emp.user.last_name}`,
bankCode: l.bank_code.bank_code, bank_code: l.bank_code.bank_code,
quantityHours: hours, quantity_hours: hours,
amount: undefined, amount: undefined,
weekNumber, week_number,
payDate: this.formatDate(endDate), pay_date: this.formatDate(endDate),
holidayDate: undefined, holiday_date: undefined,
}); });
} }
//Final Mapping and sorts //Final Mapping and sorts
return rows.sort((a,b) => { return rows.sort((a,b) => {
if(a.externalPayrollId !== b.externalPayrollId) { if(a.external_payroll_id !== b.external_payroll_id) {
return a.externalPayrollId - b.externalPayrollId; return a.external_payroll_id - b.external_payroll_id;
} }
if(a.bankCode !== b.bankCode) { if(a.bank_code !== b.bank_code) {
return a.bankCode.localeCompare(b.bankCode); return a.bank_code.localeCompare(b.bank_code);
} }
return a.weekNumber - b.weekNumber; return a.week_number - b.week_number;
}); });
} }
generateCsv(rows: CsvRow[]): Buffer { generateCsv(rows: CsvRow[]): Buffer {
const header = [ const header = [
'companyCode', 'company_code',
'externalPayrolId', 'external_payrol_id',
'fullName', 'full_name',
'bankCode', 'bank_code',
'quantityHours', 'quantity_hours',
'amount', 'amount',
'weekNumber', 'week_number',
'payDate', 'pay_date',
'holidayDate', 'holiday_date',
].join(',') + '\n'; ].join(',') + '\n';
const body = rows.map(r => [ const body = rows.map(r => [
r.companyCode, r.company_code,
r.externalPayrollId, r.external_payroll_id,
`${r.fullName.replace(/"/g, '""')}"`, `${r.full_name.replace(/"/g, '""')}"`,
r.bankCode, r.bank_code,
r.quantityHours?.toFixed(2) ?? '', r.quantity_hours?.toFixed(2) ?? '',
r.weekNumber, r.week_number,
r.payDate, r.pay_date,
r.holidayDate ?? '', r.holiday_date ?? '',
].join(',')).join('\n'); ].join(',')).join('\n');
return Buffer.from('\uFEFF' + header + body, 'utf8'); return Buffer.from('\uFEFF' + header + body, 'utf8');

View File

@ -1,12 +1,10 @@
import { Controller, ForbiddenException, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query } from "@nestjs/common"; import { Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query } from "@nestjs/common";
import { ApiNotFoundResponse, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ApiNotFoundResponse, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "@nestjs/swagger";
import { PayPeriodDto } from "../dtos/pay-period.dto"; import { PayPeriodDto } from "../dtos/pay-period.dto";
import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto"; import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto";
import { PayPeriodsQueryService } from "../services/pay-periods-query.service"; import { PayPeriodsQueryService } from "../services/pay-periods-query.service";
import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { RolesAllowed } from "src/common/decorators/roles.decorators";
import { Roles as RoleEnum } from '.prisma/client'; import { Roles as RoleEnum } from '.prisma/client';
import { Req } from '@nestjs/common';
import { Request } from 'express';
import { PayPeriodsCommandService } from "../services/pay-periods-command.service"; import { PayPeriodsCommandService } from "../services/pay-periods-command.service";
import { PayPeriodBundleDto } from "../dtos/bundle-pay-period.dto"; import { PayPeriodBundleDto } from "../dtos/bundle-pay-period.dto";
@ -54,9 +52,9 @@ export class PayPeriodsController {
@ApiNotFoundResponse({ description: "Pay period not found" }) @ApiNotFoundResponse({ description: "Pay period not found" })
async findOneByYear( async findOneByYear(
@Param("year", ParseIntPipe) year: number, @Param("year", ParseIntPipe) year: number,
@Param("periodNumber", ParseIntPipe) periodNumber: number, @Param("periodNumber", ParseIntPipe) period_no: number,
) { ) {
return this.queryService.findOneByYearPeriod(year, periodNumber); return this.queryService.findOneByYearPeriod(year, period_no);
} }
@Patch("approval/:year/:periodNumber") @Patch("approval/:year/:periodNumber")
@ -67,10 +65,10 @@ export class PayPeriodsController {
@ApiResponse({ status: 200, description: "Pay period approved" }) @ApiResponse({ status: 200, description: "Pay period approved" })
async approve( async approve(
@Param("year", ParseIntPipe) year: number, @Param("year", ParseIntPipe) year: number,
@Param("periodNumber", ParseIntPipe) periodNumber: number, @Param("periodNumber", ParseIntPipe) period_no: number,
) { ) {
await this.commandService.approvalPayPeriod(year, periodNumber); await this.commandService.approvalPayPeriod(year, period_no);
return { message: `Pay-period ${year}-${periodNumber} approved` }; return { message: `Pay-period ${year}-${period_no} approved` };
} }
@Get(':year/:periodNumber/:email') @Get(':year/:periodNumber/:email')
@ -83,12 +81,11 @@ export class PayPeriodsController {
@ApiNotFoundResponse({ description: 'Pay period not found' }) @ApiNotFoundResponse({ description: 'Pay period not found' })
async getCrewOverview( async getCrewOverview(
@Param('year', ParseIntPipe) year: number, @Param('year', ParseIntPipe) year: number,
@Param('periodNumber', ParseIntPipe) periodNumber: number, @Param('periodNumber', ParseIntPipe) period_no: number,
@Param('email') email: string, @Param('email') email: string,
@Query('includeSubtree', new ParseBoolPipe({ optional: true })) includeSubtree = false, @Query('includeSubtree', new ParseBoolPipe({ optional: true })) include_subtree = false,
@Req() req: Request,
): Promise<PayPeriodOverviewDto> { ): Promise<PayPeriodOverviewDto> {
return this.queryService.getCrewOverview(year, periodNumber, email, includeSubtree); return this.queryService.getCrewOverview(year, period_no, email, include_subtree);
} }
@Get('overview/:year/:periodNumber') @Get('overview/:year/:periodNumber')
@ -99,8 +96,8 @@ export class PayPeriodsController {
@ApiNotFoundResponse({ description: 'Pay period not found' }) @ApiNotFoundResponse({ description: 'Pay period not found' })
async getOverviewByYear( async getOverviewByYear(
@Param('year', ParseIntPipe) year: number, @Param('year', ParseIntPipe) year: number,
@Param('periodNumber', ParseIntPipe) periodNumber: number, @Param('periodNumber', ParseIntPipe) period_no: number,
): Promise<PayPeriodOverviewDto> { ): Promise<PayPeriodOverviewDto> {
return this.queryService.getOverviewByYearPeriod(year, periodNumber); return this.queryService.getOverviewByYearPeriod(year, period_no);
} }
} }

View File

@ -3,10 +3,10 @@ import { EmployeePeriodOverviewDto } from './overview-employee-period.dto';
export class PayPeriodOverviewDto { export class PayPeriodOverviewDto {
@ApiProperty({ example: 1, description: 'Period number (126)' }) @ApiProperty({ example: 1, description: 'Period number (126)' })
period_number: number; pay_period_no: number;
@ApiProperty({ example: 2023, description: 'Calendar year of the period' }) @ApiProperty({ example: 2023, description: 'Calendar year of the period' })
year: number; pay_year: number;
@ApiProperty({ @ApiProperty({
example: '2023-12-17', example: '2023-12-17',
@ -14,7 +14,7 @@ export class PayPeriodOverviewDto {
format: 'date', format: 'date',
description: "Period start date (YYYY-MM-DD)", description: "Period start date (YYYY-MM-DD)",
}) })
start_date: string; period_start: string;
@ApiProperty({ @ApiProperty({
example: '2023-12-30', example: '2023-12-30',
@ -22,7 +22,15 @@ export class PayPeriodOverviewDto {
format: 'date', format: 'date',
description: "Period end date (YYYY-MM-DD)", description: "Period end date (YYYY-MM-DD)",
}) })
end_date: string; period_end: string;
@ApiProperty({
example: '2023-12-30',
type: String,
format: 'date',
description: "Period pay day(YYYY-MM-DD)",
})
payday: string;
@ApiProperty({ @ApiProperty({
example: '2023-12-17 → 2023-12-30', example: '2023-12-17 → 2023-12-30',

View File

@ -3,18 +3,22 @@ import { ApiProperty } from "@nestjs/swagger";
export class PayPeriodDto { export class PayPeriodDto {
@ApiProperty({ example: 1, @ApiProperty({ example: 1,
description: 'numéro cyclique de la période entre 1 et 26' }) description: 'numéro cyclique de la période entre 1 et 26' })
period_number: number; pay_period_no: number;
@ApiProperty({ example: '2023-12-17', @ApiProperty({ example: '2023-12-17',
type: String, format: 'date' }) type: String, format: 'date' })
start_date: String; period_start: string;
@ApiProperty({ example: '2023-12-30', @ApiProperty({ example: '2023-12-30',
type: String, format: 'date' }) type: String, format: 'date' })
end_date: String; period_end: string;
@ApiProperty({ example: '2023-01-04',
type: String, format: 'date' })
payday: string;
@ApiProperty({ example: 2023 }) @ApiProperty({ example: 2023 })
year: number; pay_year: number;
@ApiProperty({ example: '2023-12-17 → 2023-12-30' }) @ApiProperty({ example: '2023-12-17 → 2023-12-30' })
label: string; label: string;

View File

@ -1,18 +1,19 @@
import { PayPeriods } from "@prisma/client"; import { PayPeriods } from "@prisma/client";
import { PayPeriodDto } from "../dtos/pay-period.dto"; import { PayPeriodDto } from "../dtos/pay-period.dto";
import { payYearOfDate } from "../utils/pay-year.util";
const toDateString = (d: Date) => d.toISOString().slice(0, 10); // "YYYY-MM-DD" const toDateString = (date: Date) => date.toISOString().slice(0, 10); // "YYYY-MM-DD"
export function mapPayPeriodToDto(row: PayPeriods): PayPeriodDto { export function mapPayPeriodToDto(row: PayPeriods): PayPeriodDto {
const s = toDateString(row.start_date); const start = toDateString(row.period_start);
const e = toDateString(row.end_date); const end = toDateString(row.period_end);
const pay = toDateString(row.payday);
return { return {
period_number: row.period_number, pay_period_no: row.pay_period_no,
start_date: toDateString(row.start_date), period_start: toDateString(row.period_start),
end_date: toDateString(row.end_date), period_end: toDateString(row.period_end),
year: payYearOfDate(s), payday:pay,
label: `${s} => ${e}`, pay_year: new Date(pay).getFullYear(),
label: `${start} => ${end}`,
}; };
} }

View File

@ -6,26 +6,26 @@ import { PrismaService } from "src/prisma/prisma.service";
export class PayPeriodsCommandService { export class PayPeriodsCommandService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly timesheetsApproval: TimesheetsCommandService, private readonly timesheets_approval: TimesheetsCommandService,
) {} ) {}
async approvalPayPeriod(year: number , periodNumber: number): Promise<void> { async approvalPayPeriod(pay_year: number , period_no: number): Promise<void> {
const period = await this.prisma.payPeriods.findFirst({ const period = await this.prisma.payPeriods.findFirst({
where: { year, period_number: periodNumber}, where: { pay_year, pay_period_no: period_no},
}); });
if (!period) throw new NotFoundException(`PayPeriod #${year}-${periodNumber} not found`); if (!period) throw new NotFoundException(`PayPeriod #${pay_year}-${period_no} not found`);
//fetches timesheet of selected period if the timesheet has atleast 1 shift or 1 expense //fetches timesheet of selected period if the timesheet has atleast 1 shift or 1 expense
const timesheetList = await this.prisma.timesheets.findMany({ const timesheet_ist = await this.prisma.timesheets.findMany({
where: { where: {
OR: [ OR: [
{ shift: {some: { date: { gte: period.start_date, { shift: {some: { date: { gte: period.period_start,
lte: period.end_date, lte: period.period_end,
}, },
}}, }},
}, },
{ expense: { some: { date: { gte: period.start_date, { expense: { some: { date: { gte: period.period_start,
lte: period.end_date, lte: period.period_end,
}, },
}}, }},
}, },
@ -36,8 +36,8 @@ export class PayPeriodsCommandService {
//approval of both timesheet (cascading to the approval of related shifts and expenses) //approval of both timesheet (cascading to the approval of related shifts and expenses)
await this.prisma.$transaction(async (transaction)=> { await this.prisma.$transaction(async (transaction)=> {
for(const {id} of timesheetList) { for(const {id} of timesheet_ist) {
await this.timesheetsApproval.updateApprovalWithTx(transaction,id, true); await this.timesheets_approval.updateApprovalWithTx(transaction,id, true);
} }
}) })
} }

View File

@ -9,53 +9,65 @@ import { mapPayPeriodToDto } from "../mappers/pay-periods.mapper";
@Injectable() @Injectable()
export class PayPeriodsQueryService { export class PayPeriodsQueryService {
constructor( constructor( private readonly prisma: PrismaService) {}
private readonly prisma: PrismaService,
) {}
async getOverview(periodNumber: number): Promise<PayPeriodOverviewDto> { async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodOverviewDto> {
const period = await this.prisma.payPeriods.findFirst({ const period = computePeriod(pay_year, period_no);
where: { period_number: periodNumber },
orderBy: { year: "desc" },
});
if (!period) throw new NotFoundException(`Period #${periodNumber} not found`);
return this.buildOverview(period);
}
async getOverviewByYearPeriod(year: number, periodNumber: number): Promise<PayPeriodOverviewDto> {
const period = computePeriod(year, periodNumber);
return this.buildOverview({ return this.buildOverview({
start_date: period.start_date, period_start: period.period_start,
end_date : period.end_date, period_end : period.period_end,
period_number: period.period_number, period_no : period.period_no,
year: period.year, pay_year : period.pay_year,
label:period.label, payday : period.payday,
label :period.label,
} as any); } as any);
} }
async getOverview(pay_period_no: number): Promise<PayPeriodOverviewDto> {
const period = await this.prisma.payPeriods.findFirst({
where: { pay_period_no },
orderBy: { pay_year: "desc" },
});
if (!period) throw new NotFoundException(`Period #${pay_period_no} not found`);
return this.buildOverview({
period_start: period.period_start,
period_end : period.period_end,
payday : period.payday,
period_no : period.pay_period_no,
pay_year : period.pay_year,
label : period.label,
});
}
private async buildOverview( private async buildOverview(
period: { start_date: string | Date; end_date: string | Date; period_number: number; year: number; label: string; }, period: { period_start: string | Date; period_end: string | Date; payday: string | Date;
options?: { filteredEmployeeIds?: number[]; seedNames?: Map<number, string> }, period_no: number; pay_year: number; label: string; },
options?: { filtered_employee_ids?: number[]; seed_names?: Map<number, string> },
): Promise<PayPeriodOverviewDto> { ): Promise<PayPeriodOverviewDto> {
const toDateString = (d: Date) => d.toISOString().slice(0, 10); const toDateString = (d: Date) => d.toISOString().slice(0, 10);
const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number)); const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number));
const start = period.start_date instanceof Date const start = period.period_start instanceof Date
? period.start_date ? period.period_start
: new Date(`${period.start_date}T00:00:00.000Z`); : new Date(`${period.period_start}T00:00:00.000Z`);
const end = period.end_date instanceof Date const end = period.period_end instanceof Date
? period.end_date ? period.period_end
: new Date(`${period.end_date}T00:00:00.000Z`); : new Date(`${period.period_end}T00:00:00.000Z`);
const payd = period.payday instanceof Date
? period.payday
: new Date (`${period.payday}T00:00:00.000Z`);
//restrictEmployeeIds = filter for shifts and expenses by employees //restrictEmployeeIds = filter for shifts and expenses by employees
const whereEmployee = options?.filteredEmployeeIds?.length ? { employee_id: { in: options.filteredEmployeeIds } }: {}; const where_employee = options?.filtered_employee_ids?.length ? { employee_id: { in: options.filtered_employee_ids } }: {};
// SHIFTS (filtered by crew) // SHIFTS (filtered by crew)
const shifts = await this.prisma.shifts.findMany({ const shifts = await this.prisma.shifts.findMany({
where: { where: {
date: { gte: start, lte: end }, date: { gte: start, lte: end },
timesheet: whereEmployee, timesheet: where_employee,
}, },
select: { select: {
start_time: true, start_time: true,
@ -79,7 +91,7 @@ export class PayPeriodsQueryService {
const expenses = await this.prisma.expenses.findMany({ const expenses = await this.prisma.expenses.findMany({
where: { where: {
date: { gte: start, lte: end }, date: { gte: start, lte: end },
timesheet: whereEmployee, timesheet: where_employee,
}, },
select: { select: {
amount: true, amount: true,
@ -97,12 +109,12 @@ export class PayPeriodsQueryService {
}, },
}); });
const byEmployee = new Map<number, EmployeePeriodOverviewDto>(); const by_employee = new Map<number, EmployeePeriodOverviewDto>();
// seed for employee without data // seed for employee without data
if (options?.seedNames) { if (options?.seed_names) {
for (const [id, name] of options.seedNames.entries()) { for (const [id, name] of options.seed_names.entries()) {
byEmployee.set(id, { by_employee.set(id, {
employee_id: id, employee_id: id,
employee_name: name, employee_name: name,
regular_hours: 0, regular_hours: 0,
@ -117,8 +129,8 @@ export class PayPeriodsQueryService {
} }
const ensure = (id: number, name: string) => { const ensure = (id: number, name: string) => {
if (!byEmployee.has(id)) { if (!by_employee.has(id)) {
byEmployee.set(id, { by_employee.set(id, {
employee_id: id, employee_id: id,
employee_name: name, employee_name: name,
regular_hours: 0, regular_hours: 0,
@ -130,24 +142,24 @@ export class PayPeriodsQueryService {
is_approved: true, is_approved: true,
}); });
} }
return byEmployee.get(id)!; return by_employee.get(id)!;
}; };
for (const shift of shifts) { for (const shift of shifts) {
const employee = shift.timesheet.employee; const employee = shift.timesheet.employee;
const name = `${employee.user.first_name} ${employee.user.last_name}`.trim(); const name = `${employee.user.first_name} ${employee.user.last_name}`.trim();
const rec = ensure(employee.id, name); const record = ensure(employee.id, name);
const hours = computeHours(shift.start_time, shift.end_time); const hours = computeHours(shift.start_time, shift.end_time);
const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase(); const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase();
switch (categorie) { switch (categorie) {
case "EVENING": rec.evening_hours += hours; break; case "EVENING": record.evening_hours += hours; break;
case "EMERGENCY": case "EMERGENCY":
case "URGENT": rec.emergency_hours += hours; break; case "URGENT": record.emergency_hours += hours; break;
case "OVERTIME": rec.overtime_hours += hours; break; case "OVERTIME": record.overtime_hours += hours; break;
default: rec.regular_hours += hours; break; default: record.regular_hours += hours; break;
} }
rec.is_approved = rec.is_approved && shift.timesheet.is_approved; record.is_approved = record.is_approved && shift.timesheet.is_approved;
} }
for (const expense of expenses) { for (const expense of expenses) {
@ -166,25 +178,27 @@ export class PayPeriodsQueryService {
record.is_approved = record.is_approved && expense.timesheet.is_approved; record.is_approved = record.is_approved && expense.timesheet.is_approved;
} }
const employees_overview = Array.from(byEmployee.values()).sort((a, b) => const employees_overview = Array.from(by_employee.values()).sort((a, b) =>
a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }),
); );
return { return {
period_number: period.period_number, pay_period_no: period.period_no,
year: period.year, pay_year: period.pay_year,
start_date: toDateString(start), payday: toDateString(payd),
end_date: toDateString(end), period_start: toDateString(start),
period_end: toDateString(end),
label: period.label, label: period.label,
employees_overview, employees_overview,
}; };
} }
async getCrewOverview(year: number, periodNumber: number, email: string, includeSubtree: boolean): Promise<PayPeriodOverviewDto> { async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean):
Promise<PayPeriodOverviewDto> {
// 1) Search for the period // 1) Search for the period
const period = await this.prisma.payPeriods.findFirst({ where: { year, period_number: periodNumber } }); const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } });
if (!period) throw new NotFoundException(`Pay period ${year}-${periodNumber} not found`); if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`);
// 2) fetch supervisor // 2) fetch supervisor
const supervisor = await this.prisma.employees.findFirst({ const supervisor = await this.prisma.employees.findFirst({
@ -199,34 +213,42 @@ export class PayPeriodsQueryService {
if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor'); if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor');
// 3)fetchs crew members // 3)fetchs crew members
const crew = await this.resolveCrew(supervisor.id, includeSubtree); // [{ id, first_name, last_name }] const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }]
const crewIds = crew.map(c => c.id); const crew_ids = crew.map(c => c.id);
// seed names map for employee without data // seed names map for employee without data
const seedNames = new Map<number, string>(crew.map(c => [c.id, `${c.first_name} ${c.last_name}`.trim()])); const seed_names = new Map<number, string>(crew.map(crew => [crew.id, `${crew.first_name} ${crew.last_name}`.trim()]));
// 4) overview build // 4) overview build
return this.buildOverview(period, { filteredEmployeeIds: crewIds, seedNames }); return this.buildOverview({
period_no : period.pay_period_no,
period_start: period.period_start,
period_end : period.period_end,
payday : period.payday,
pay_year : period.pay_year,
label : period.label,
}, { filtered_employee_ids: crew_ids, seed_names });
} }
private async resolveCrew(supervisorId: number, includeSubtree: boolean): Promise<Array<{ id: number; first_name: string; last_name: string }>> { private async resolveCrew(supervisor_id: number, include_subtree: boolean):
Promise<Array<{ id: number; first_name: string; last_name: string }>> {
const result: Array<{ id: number; first_name: string; last_name: string }> = []; const result: Array<{ id: number; first_name: string; last_name: string }> = [];
let frontier = await this.prisma.employees.findMany({ let frontier = await this.prisma.employees.findMany({
where: { supervisor_id: supervisorId }, where: { supervisor_id: supervisor_id },
select: { id: true, user: { select: { first_name: true, last_name: true } } }, select: { id: true, user: { select: { first_name: true, last_name: true } } },
}); });
result.push(...frontier.map(e => ({ id: e.id, first_name: e.user.first_name, last_name: e.user.last_name }))); result.push(...frontier.map(emp => ({ id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name })));
if (!includeSubtree) return result; if (!include_subtree) return result;
while (frontier.length) { while (frontier.length) {
const parentIds = frontier.map(e => e.id); const parent_ids = frontier.map(emp => emp.id);
const next = await this.prisma.employees.findMany({ const next = await this.prisma.employees.findMany({
where: { supervisor_id: { in: parentIds } }, where: { supervisor_id: { in: parent_ids } },
select: { id: true, user: { select: { first_name: true, last_name: true } } }, select: { id: true, user: { select: { first_name: true, last_name: true } } },
}); });
if (next.length === 0) break; if (next.length === 0) break;
result.push(...next.map(e => ({ id: e.id, first_name: e.user.first_name, last_name: e.user.last_name }))); result.push(...next.map(emp => ({ id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name })));
frontier = next; frontier = next;
} }
@ -237,54 +259,69 @@ private async resolveCrew(supervisorId: number, includeSubtree: boolean): Promis
async findAll(): Promise<PayPeriodDto[]> { async findAll(): Promise<PayPeriodDto[]> {
const currentPayYear = payYearOfDate(new Date()); const currentPayYear = payYearOfDate(new Date());
return listPayYear(currentPayYear).map(period =>({ return listPayYear(currentPayYear).map(period =>({
period_number: period.period_number, pay_period_no: period.period_no,
year: period.year, pay_year: period.pay_year,
start_date: period.start_date, payday: period.payday,
end_date: period.end_date, period_start: period.period_start,
period_end: period.period_end,
label: period.label, label: period.label,
})); }));
} }
async findOne(periodNumber: number): Promise<PayPeriodDto> { async findOne(period_no: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({ const row = await this.prisma.payPeriods.findFirst({
where: { period_number: periodNumber }, where: { pay_period_no: period_no },
orderBy: { year: "desc" }, orderBy: { pay_year: "desc" },
}); });
if (!row) throw new NotFoundException(`Pay period #${periodNumber} not found`); if (!row) throw new NotFoundException(`Pay period #${period_no} not found`);
return mapPayPeriodToDto(row); return mapPayPeriodToDto(row);
} }
async findOneByYearPeriod(year: number, periodNumber: number): Promise<PayPeriodDto> { async findOneByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({ const row = await this.prisma.payPeriods.findFirst({
where: { year, period_number: periodNumber }, where: { pay_year, pay_period_no: period_no },
}); });
if(row) return mapPayPeriodToDto(row); if(row) return mapPayPeriodToDto(row);
// fallback for outside of view periods // fallback for outside of view periods
const p = computePeriod(year, periodNumber); const period = computePeriod(pay_year, period_no);
return {period_number: p.period_number, year: p.year, start_date: p.start_date, end_date: p.end_date, label: p.label} return {
pay_period_no: period.period_no,
pay_year: period.pay_year,
period_start: period.period_start,
payday: period.payday,
period_end: period.period_end,
label: period.label
}
} }
//function to cherry pick a Date to find a period //function to cherry pick a Date to find a period
async findByDate(date: string): Promise<PayPeriodDto> { async findByDate(date: string): Promise<PayPeriodDto> {
const dt = new Date(date); const dt = new Date(date);
const row = await this.prisma.payPeriods.findFirst({ const row = await this.prisma.payPeriods.findFirst({
where: { start_date: { lte: dt }, end_date: { gte: dt } }, where: { period_start: { lte: dt }, period_end: { gte: dt } },
}); });
if(row) return mapPayPeriodToDto(row); if(row) return mapPayPeriodToDto(row);
//fallback for outwside view periods //fallback for outwside view periods
const payYear = payYearOfDate(date); const pay_year = payYearOfDate(date);
const periods = listPayYear(payYear); const periods = listPayYear(pay_year);
const hit = periods.find(p => date >= p.start_date && date <= p.end_date); const hit = periods.find(period => date >= period.period_start && date <= period.period_end);
if(!hit) throw new NotFoundException(`No period found for ${date}`); if(!hit) throw new NotFoundException(`No period found for ${date}`);
return { period_number: hit.period_number, year: hit.year, start_date: hit.start_date, end_date:hit.end_date, label: hit.label} return {
pay_period_no: hit.period_no,
pay_year : hit.pay_year,
period_start : hit.period_start,
period_end : hit.period_end,
payday : hit.payday,
label : hit.label
}
} }
async findCurrent(date?: string): Promise<PayPeriodDto> { async findCurrent(date?: string): Promise<PayPeriodDto> {
const isoDay = date ?? new Date().toISOString().slice(0,10); const iso_day = date ?? new Date().toISOString().slice(0,10);
return this.findByDate(isoDay); return this.findByDate(iso_day);
} }
} }

View File

@ -9,8 +9,6 @@ const toUTCDate = (iso: string | Date) => {
}; };
export const toDateString = (d: Date) => d.toISOString().slice(0, 10); export const toDateString = (d: Date) => d.toISOString().slice(0, 10);
const ANCHOR = toUTCDate(ANCHOR_ISO);
export function payYearOfDate(date: string | Date, anchorISO = ANCHOR_ISO): number { export function payYearOfDate(date: string | Date, anchorISO = ANCHOR_ISO): number {
const ANCHOR = toUTCDate(anchorISO); const ANCHOR = toUTCDate(anchorISO);
const d = toUTCDate(date); const d = toUTCDate(date);
@ -19,23 +17,25 @@ export function payYearOfDate(date: string | Date, anchorISO = ANCHOR_ISO): numb
return ANCHOR.getUTCFullYear() + 1 + cycles; return ANCHOR.getUTCFullYear() + 1 + cycles;
} }
//compute labels for periods //compute labels for periods
export function computePeriod(payYear: number, periodNumber: number, anchorISO = ANCHOR_ISO) { export function computePeriod(pay_year: number, period_no: number, anchorISO = ANCHOR_ISO) {
const ANCHOR = toUTCDate(anchorISO); const ANCHOR = toUTCDate(anchorISO);
const cycles = payYear - (ANCHOR.getUTCFullYear() + 1); const cycles = pay_year - (ANCHOR.getUTCFullYear() + 1);
const offsetPeriods = cycles * PERIODS_PER_YEAR + (periodNumber - 1); const offsetPeriods = cycles * PERIODS_PER_YEAR + (period_no - 1);
const start = new Date(+ANCHOR + offsetPeriods * PERIOD_DAYS * MS_PER_DAY); const start = new Date(+ANCHOR + offsetPeriods * PERIOD_DAYS * MS_PER_DAY);
const end = new Date(+start + (PERIOD_DAYS - 1) * MS_PER_DAY); const end = new Date(+start + (PERIOD_DAYS - 1) * MS_PER_DAY);
const pay = new Date(end.getTime() + 6 * MS_PER_DAY);
return { return {
period_number: periodNumber, period_no: period_no,
year: payYear, pay_year: pay_year,
start_date: toDateString(start), payday: toDateString(pay),
end_date: toDateString(end), period_start: toDateString(start),
period_end: toDateString(end),
label: `${toDateString(start)}${toDateString(end)}`, label: `${toDateString(start)}${toDateString(end)}`,
start, end, start, end,
}; };
} }
//list of all 26 periods for a full year //list of all 26 periods for a full year
export function listPayYear(payYear: number, anchorISO = ANCHOR_ISO) { export function listPayYear(pay_year: number, anchorISO = ANCHOR_ISO) {
return Array.from({ length: PERIODS_PER_YEAR }, (_, i) => computePeriod(payYear, i + 1, anchorISO)); return Array.from({ length: PERIODS_PER_YEAR }, (_, i) => computePeriod(pay_year, i + 1, anchorISO));
} }

View File

@ -117,16 +117,16 @@ export class ShiftsQueryService {
async getSummary(period_id: number): Promise<OverviewRow[]> { async getSummary(period_id: number): Promise<OverviewRow[]> {
//fetch pay-period to display //fetch pay-period to display
const period = await this.prisma.payPeriods.findFirst({ const period = await this.prisma.payPeriods.findFirst({
where: { period_number: period_id }, where: { pay_period_no: period_id },
}); });
if(!period) { if(!period) {
throw new NotFoundException(`pay-period ${period_id} not found`); throw new NotFoundException(`pay-period ${period_id} not found`);
} }
const { start_date, end_date } = period; const { period_start, period_end } = period;
//prepare shifts and expenses for display //prepare shifts and expenses for display
const shifts = await this.prisma.shifts.findMany({ const shifts = await this.prisma.shifts.findMany({
where: { date: { gte: start_date, lte: end_date } }, where: { date: { gte: period_start, lte: period_end } },
include: { include: {
bank_code: true, bank_code: true,
timesheet: { include: { timesheet: { include: {
@ -139,7 +139,7 @@ export class ShiftsQueryService {
}); });
const expenses = await this.prisma.expenses.findMany({ const expenses = await this.prisma.expenses.findMany({
where: { date: { gte: start_date, lte: end_date } }, where: { date: { gte: period_start, lte: period_end } },
include: { include: {
bank_code: true, bank_code: true,
timesheet: { include: { employee: { timesheet: { include: { employee: {