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

View File

@ -5,39 +5,41 @@
CREATE OR REPLACE VIEW pay_period AS
WITH
anchor AS (
SELECT '2023-12-17'::date AS anchor_date
),
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
SELECT '2023-12-17'::date AS anchor_sunday
),
series AS (
SELECT
generate_series(bounds.start_bound, bounds.end_bound, '14 days') AS period_start,
bounds.anchor_date
FROM bounds
gs::date AS period_start, -- Dimanche
(gs + INTERVAL '13 days')::date AS period_end, -- Samedi
(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
((row_number() OVER (ORDER BY period_start) - 1) % 26) + 1 AS period_number,
period_start AS start_date,
period_start + INTERVAL '13 days' AS end_date,
EXTRACT(YEAR FROM period_start)::int AS year,
period_start || ' -> ' ||
to_char(period_start + INTERVAL '13 days', 'YYYY-MM-DD')
AS label
FROM series
ORDER BY period_start;
SELECT
pay_year,
pay_period_no,
period_start,
period_end,
payday,
to_char(period_start, 'YYYY-MM-DD') || '->' ||
to_char(period_end, 'YYYY-MM-DD') AS label
FROM numbered
WHERE payday BETWEEN (CURRENT_DATE - INTERVAL '6 months')::date
AND (CURRENT_DATE + INTERVAL '1 month')::date
ORDER BY period_start;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -29,7 +29,7 @@ export class CsvExportController {
//filters by type
const filtered = all.filter(r => {
switch (r.bankCode.toLocaleLowerCase()) {
switch (r.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);

View File

@ -3,33 +3,33 @@ import { ExportCompany } from "../dtos/export-csv-options.dto";
import { Injectable, NotFoundException } from "@nestjs/common";
export interface CsvRow {
companyCode: number;
externalPayrollId: number;
fullName: string;
bankCode: string;
quantityHours?: number;
company_code: number;
external_payroll_id: number;
full_name: string;
bank_code: string;
quantity_hours?: number;
amount?: number;
weekNumber: number;
payDate: string;
holidayDate?: string;
week_number: number;
pay_date: string;
holiday_date?: string;
}
@Injectable()
export class CsvExportService {
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 period = await this.prisma.payPeriods.findFirst({
where: { period_number: periodId },
where: { pay_period_no: period_id },
});
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 endDate = period.end_date;
const startDate = period.period_start;
const endDate = period.period_end;
//fetching shifts
const shifts = await this.prisma.shifts.findMany({
@ -72,39 +72,39 @@ export class CsvExportService {
const rows: CsvRow[] = [];
//Shifts Mapping
for (const s of shifts) {
const emp = s.timesheet.employee;
const weekNumber = this.computeWeekNumber(startDate, s.date);
const hours = this.computeHours(s.start_time, s.end_time);
for (const shift of shifts) {
const emp = shift.timesheet.employee;
const week_number = this.computeWeekNumber(startDate, shift.date);
const hours = this.computeHours(shift.start_time, shift.end_time);
rows.push({
companyCode: emp.company_code,
externalPayrollId: emp.external_payroll_id,
fullName: `${emp.user.first_name} ${emp.user.last_name}`,
bankCode: s.bank_code.bank_code,
quantityHours: hours,
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,
weekNumber,
payDate: this.formatDate(endDate),
holidayDate: undefined,
week_number,
pay_date: this.formatDate(endDate),
holiday_date: undefined,
});
}
//Expenses Mapping
for (const e of expenses) {
const emp = e.timesheet.employee;
const weekNumber = this.computeWeekNumber(startDate, e.date);
const week_number = this.computeWeekNumber(startDate, e.date);
rows.push({
companyCode: emp.company_code,
externalPayrollId: emp.external_payroll_id,
fullName: `${emp.user.first_name} ${emp.user.last_name}`,
bankCode: e.bank_code.bank_code,
quantityHours: undefined,
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),
weekNumber,
payDate: this.formatDate(endDate),
holidayDate: undefined,
week_number,
pay_date: this.formatDate(endDate),
holiday_date: undefined,
});
}
@ -115,56 +115,56 @@ export class CsvExportService {
const start = l.start_date_time;
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);
rows.push({
companyCode: emp.company_code,
externalPayrollId: emp.external_payroll_id,
fullName: `${emp.user.first_name} ${emp.user.last_name}`,
bankCode: l.bank_code.bank_code,
quantityHours: hours,
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,
weekNumber,
payDate: this.formatDate(endDate),
holidayDate: undefined,
week_number,
pay_date: this.formatDate(endDate),
holiday_date: undefined,
});
}
//Final Mapping and sorts
return rows.sort((a,b) => {
if(a.externalPayrollId !== b.externalPayrollId) {
return a.externalPayrollId - b.externalPayrollId;
if(a.external_payroll_id !== b.external_payroll_id) {
return a.external_payroll_id - b.external_payroll_id;
}
if(a.bankCode !== b.bankCode) {
return a.bankCode.localeCompare(b.bankCode);
if(a.bank_code !== b.bank_code) {
return a.bank_code.localeCompare(b.bank_code);
}
return a.weekNumber - b.weekNumber;
return a.week_number - b.week_number;
});
}
generateCsv(rows: CsvRow[]): Buffer {
const header = [
'companyCode',
'externalPayrolId',
'fullName',
'bankCode',
'quantityHours',
'company_code',
'external_payrol_id',
'full_name',
'bank_code',
'quantity_hours',
'amount',
'weekNumber',
'payDate',
'holidayDate',
'week_number',
'pay_date',
'holiday_date',
].join(',') + '\n';
const body = rows.map(r => [
r.companyCode,
r.externalPayrollId,
`${r.fullName.replace(/"/g, '""')}"`,
r.bankCode,
r.quantityHours?.toFixed(2) ?? '',
r.weekNumber,
r.payDate,
r.holidayDate ?? '',
r.company_code,
r.external_payroll_id,
`${r.full_name.replace(/"/g, '""')}"`,
r.bank_code,
r.quantity_hours?.toFixed(2) ?? '',
r.week_number,
r.pay_date,
r.holiday_date ?? '',
].join(',')).join('\n');
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 { PayPeriodDto } from "../dtos/pay-period.dto";
import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto";
import { PayPeriodsQueryService } from "../services/pay-periods-query.service";
import { RolesAllowed } from "src/common/decorators/roles.decorators";
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 { PayPeriodBundleDto } from "../dtos/bundle-pay-period.dto";
@ -54,9 +52,9 @@ export class PayPeriodsController {
@ApiNotFoundResponse({ description: "Pay period not found" })
async findOneByYear(
@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")
@ -67,10 +65,10 @@ export class PayPeriodsController {
@ApiResponse({ status: 200, description: "Pay period approved" })
async approve(
@Param("year", ParseIntPipe) year: number,
@Param("periodNumber", ParseIntPipe) periodNumber: number,
@Param("periodNumber", ParseIntPipe) period_no: number,
) {
await this.commandService.approvalPayPeriod(year, periodNumber);
return { message: `Pay-period ${year}-${periodNumber} approved` };
await this.commandService.approvalPayPeriod(year, period_no);
return { message: `Pay-period ${year}-${period_no} approved` };
}
@Get(':year/:periodNumber/:email')
@ -83,12 +81,11 @@ export class PayPeriodsController {
@ApiNotFoundResponse({ description: 'Pay period not found' })
async getCrewOverview(
@Param('year', ParseIntPipe) year: number,
@Param('periodNumber', ParseIntPipe) periodNumber: number,
@Param('periodNumber', ParseIntPipe) period_no: number,
@Param('email') email: string,
@Query('includeSubtree', new ParseBoolPipe({ optional: true })) includeSubtree = false,
@Req() req: Request,
@Query('includeSubtree', new ParseBoolPipe({ optional: true })) include_subtree = false,
): Promise<PayPeriodOverviewDto> {
return this.queryService.getCrewOverview(year, periodNumber, email, includeSubtree);
return this.queryService.getCrewOverview(year, period_no, email, include_subtree);
}
@Get('overview/:year/:periodNumber')
@ -99,8 +96,8 @@ export class PayPeriodsController {
@ApiNotFoundResponse({ description: 'Pay period not found' })
async getOverviewByYear(
@Param('year', ParseIntPipe) year: number,
@Param('periodNumber', ParseIntPipe) periodNumber: number,
@Param('periodNumber', ParseIntPipe) period_no: number,
): 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 {
@ApiProperty({ example: 1, description: 'Period number (126)' })
period_number: number;
pay_period_no: number;
@ApiProperty({ example: 2023, description: 'Calendar year of the period' })
year: number;
pay_year: number;
@ApiProperty({
example: '2023-12-17',
@ -14,7 +14,7 @@ export class PayPeriodOverviewDto {
format: 'date',
description: "Period start date (YYYY-MM-DD)",
})
start_date: string;
period_start: string;
@ApiProperty({
example: '2023-12-30',
@ -22,7 +22,15 @@ export class PayPeriodOverviewDto {
format: 'date',
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({
example: '2023-12-17 → 2023-12-30',

View File

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

View File

@ -1,18 +1,19 @@
import { PayPeriods } from "@prisma/client";
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 {
const s = toDateString(row.start_date);
const e = toDateString(row.end_date);
const start = toDateString(row.period_start);
const end = toDateString(row.period_end);
const pay = toDateString(row.payday);
return {
period_number: row.period_number,
start_date: toDateString(row.start_date),
end_date: toDateString(row.end_date),
year: payYearOfDate(s),
label: `${s} => ${e}`,
pay_period_no: row.pay_period_no,
period_start: toDateString(row.period_start),
period_end: toDateString(row.period_end),
payday:pay,
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 {
constructor(
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({
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
const timesheetList = await this.prisma.timesheets.findMany({
const timesheet_ist = await this.prisma.timesheets.findMany({
where: {
OR: [
{ shift: {some: { date: { gte: period.start_date,
lte: period.end_date,
{ shift: {some: { date: { gte: period.period_start,
lte: period.period_end,
},
}},
},
{ expense: { some: { date: { gte: period.start_date,
lte: period.end_date,
{ expense: { some: { date: { gte: period.period_start,
lte: period.period_end,
},
}},
},
@ -36,8 +36,8 @@ export class PayPeriodsCommandService {
//approval of both timesheet (cascading to the approval of related shifts and expenses)
await this.prisma.$transaction(async (transaction)=> {
for(const {id} of timesheetList) {
await this.timesheetsApproval.updateApprovalWithTx(transaction,id, true);
for(const {id} of timesheet_ist) {
await this.timesheets_approval.updateApprovalWithTx(transaction,id, true);
}
})
}

View File

@ -9,53 +9,65 @@ import { mapPayPeriodToDto } from "../mappers/pay-periods.mapper";
@Injectable()
export class PayPeriodsQueryService {
constructor(
private readonly prisma: PrismaService,
) {}
constructor( private readonly prisma: PrismaService) {}
async getOverview(periodNumber: number): Promise<PayPeriodOverviewDto> {
const period = await this.prisma.payPeriods.findFirst({
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);
async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodOverviewDto> {
const period = computePeriod(pay_year, period_no);
return this.buildOverview({
start_date: period.start_date,
end_date : period.end_date,
period_number: period.period_number,
year: period.year,
label:period.label,
period_start: period.period_start,
period_end : period.period_end,
period_no : period.period_no,
pay_year : period.pay_year,
payday : period.payday,
label :period.label,
} 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(
period: { start_date: string | Date; end_date: string | Date; period_number: number; year: number; label: string; },
options?: { filteredEmployeeIds?: number[]; seedNames?: Map<number, string> },
period: { period_start: string | Date; period_end: string | Date; payday: string | Date;
period_no: number; pay_year: number; label: string; },
options?: { filtered_employee_ids?: number[]; seed_names?: Map<number, string> },
): Promise<PayPeriodOverviewDto> {
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 start = period.start_date instanceof Date
? period.start_date
: new Date(`${period.start_date}T00:00:00.000Z`);
const start = period.period_start instanceof Date
? period.period_start
: new Date(`${period.period_start}T00:00:00.000Z`);
const end = period.end_date instanceof Date
? period.end_date
: new Date(`${period.end_date}T00:00:00.000Z`);
const end = period.period_end instanceof Date
? period.period_end
: 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
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)
const shifts = await this.prisma.shifts.findMany({
where: {
date: { gte: start, lte: end },
timesheet: whereEmployee,
timesheet: where_employee,
},
select: {
start_time: true,
@ -79,7 +91,7 @@ export class PayPeriodsQueryService {
const expenses = await this.prisma.expenses.findMany({
where: {
date: { gte: start, lte: end },
timesheet: whereEmployee,
timesheet: where_employee,
},
select: {
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
if (options?.seedNames) {
for (const [id, name] of options.seedNames.entries()) {
byEmployee.set(id, {
if (options?.seed_names) {
for (const [id, name] of options.seed_names.entries()) {
by_employee.set(id, {
employee_id: id,
employee_name: name,
regular_hours: 0,
@ -117,8 +129,8 @@ export class PayPeriodsQueryService {
}
const ensure = (id: number, name: string) => {
if (!byEmployee.has(id)) {
byEmployee.set(id, {
if (!by_employee.has(id)) {
by_employee.set(id, {
employee_id: id,
employee_name: name,
regular_hours: 0,
@ -130,24 +142,24 @@ export class PayPeriodsQueryService {
is_approved: true,
});
}
return byEmployee.get(id)!;
return by_employee.get(id)!;
};
for (const shift of shifts) {
const employee = shift.timesheet.employee;
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 categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase();
switch (categorie) {
case "EVENING": rec.evening_hours += hours; break;
case "EVENING": record.evening_hours += hours; break;
case "EMERGENCY":
case "URGENT": rec.emergency_hours += hours; break;
case "OVERTIME": rec.overtime_hours += hours; break;
default: rec.regular_hours += hours; break;
case "URGENT": record.emergency_hours += hours; break;
case "OVERTIME": record.overtime_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) {
@ -166,25 +178,27 @@ export class PayPeriodsQueryService {
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" }),
);
return {
period_number: period.period_number,
year: period.year,
start_date: toDateString(start),
end_date: toDateString(end),
pay_period_no: period.period_no,
pay_year: period.pay_year,
payday: toDateString(payd),
period_start: toDateString(start),
period_end: toDateString(end),
label: period.label,
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
const period = await this.prisma.payPeriods.findFirst({ where: { year, period_number: periodNumber } });
if (!period) throw new NotFoundException(`Pay period ${year}-${periodNumber} not found`);
const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } });
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`);
// 2) fetch supervisor
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');
// 3)fetchs crew members
const crew = await this.resolveCrew(supervisor.id, includeSubtree); // [{ id, first_name, last_name }]
const crewIds = crew.map(c => c.id);
const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }]
const crew_ids = crew.map(c => c.id);
// 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
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 }> = [];
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 } } },
});
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) {
const parentIds = frontier.map(e => e.id);
const parent_ids = frontier.map(emp => emp.id);
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 } } },
});
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;
}
@ -237,54 +259,69 @@ private async resolveCrew(supervisorId: number, includeSubtree: boolean): Promis
async findAll(): Promise<PayPeriodDto[]> {
const currentPayYear = payYearOfDate(new Date());
return listPayYear(currentPayYear).map(period =>({
period_number: period.period_number,
year: period.year,
start_date: period.start_date,
end_date: period.end_date,
pay_period_no: period.period_no,
pay_year: period.pay_year,
payday: period.payday,
period_start: period.period_start,
period_end: period.period_end,
label: period.label,
}));
}
async findOne(periodNumber: number): Promise<PayPeriodDto> {
async findOne(period_no: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({
where: { period_number: periodNumber },
orderBy: { year: "desc" },
where: { pay_period_no: period_no },
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);
}
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({
where: { year, period_number: periodNumber },
where: { pay_year, pay_period_no: period_no },
});
if(row) return mapPayPeriodToDto(row);
// fallback for outside of view periods
const p = computePeriod(year, periodNumber);
return {period_number: p.period_number, year: p.year, start_date: p.start_date, end_date: p.end_date, label: p.label}
const period = computePeriod(pay_year, period_no);
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
async findByDate(date: string): Promise<PayPeriodDto> {
const dt = new Date(date);
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);
//fallback for outwside view periods
const payYear = payYearOfDate(date);
const periods = listPayYear(payYear);
const hit = periods.find(p => date >= p.start_date && date <= p.end_date);
const pay_year = payYearOfDate(date);
const periods = listPayYear(pay_year);
const hit = periods.find(period => date >= period.period_start && date <= period.period_end);
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> {
const isoDay = date ?? new Date().toISOString().slice(0,10);
return this.findByDate(isoDay);
const iso_day = date ?? new Date().toISOString().slice(0,10);
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);
const ANCHOR = toUTCDate(ANCHOR_ISO);
export function payYearOfDate(date: string | Date, anchorISO = ANCHOR_ISO): number {
const ANCHOR = toUTCDate(anchorISO);
const d = toUTCDate(date);
@ -19,23 +17,25 @@ export function payYearOfDate(date: string | Date, anchorISO = ANCHOR_ISO): numb
return ANCHOR.getUTCFullYear() + 1 + cycles;
}
//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 cycles = payYear - (ANCHOR.getUTCFullYear() + 1);
const offsetPeriods = cycles * PERIODS_PER_YEAR + (periodNumber - 1);
const cycles = pay_year - (ANCHOR.getUTCFullYear() + 1);
const offsetPeriods = cycles * PERIODS_PER_YEAR + (period_no - 1);
const start = new Date(+ANCHOR + offsetPeriods * PERIOD_DAYS * 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 {
period_number: periodNumber,
year: payYear,
start_date: toDateString(start),
end_date: toDateString(end),
period_no: period_no,
pay_year: pay_year,
payday: toDateString(pay),
period_start: toDateString(start),
period_end: toDateString(end),
label: `${toDateString(start)}${toDateString(end)}`,
start, end,
};
}
//list of all 26 periods for a full year
export function listPayYear(payYear: number, anchorISO = ANCHOR_ISO) {
return Array.from({ length: PERIODS_PER_YEAR }, (_, i) => computePeriod(payYear, i + 1, anchorISO));
export function listPayYear(pay_year: number, anchorISO = ANCHOR_ISO) {
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[]> {
//fetch pay-period to display
const period = await this.prisma.payPeriods.findFirst({
where: { period_number: period_id },
where: { pay_period_no: period_id },
});
if(!period) {
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
const shifts = await this.prisma.shifts.findMany({
where: { date: { gte: start_date, lte: end_date } },
where: { date: { gte: period_start, lte: period_end } },
include: {
bank_code: true,
timesheet: { include: {
@ -139,7 +139,7 @@ export class ShiftsQueryService {
});
const expenses = await this.prisma.expenses.findMany({
where: { date: { gte: start_date, lte: end_date } },
where: { date: { gte: period_start, lte: period_end } },
include: {
bank_code: true,
timesheet: { include: { employee: {