fix(pay-period): commented bulk approval and command service

This commit is contained in:
Matthieu Haineault 2025-10-22 10:16:38 -04:00
parent 0ce1191437
commit 9f27c83981
4 changed files with 584 additions and 207 deletions

View File

@ -429,6 +429,359 @@
] ]
} }
}, },
"/pay-periods/current-and-all": {
"get": {
"operationId": "PayPeriodsController_getCurrentAndAll",
"parameters": [
{
"name": "date",
"required": false,
"in": "query",
"description": "Override for resolving the current period",
"schema": {
"example": "2025-08-11",
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Find current and all pay periods",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodBundleDto"
}
}
}
}
},
"summary": "Return current pay period and the full list",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/date/{date}": {
"get": {
"operationId": "PayPeriodsController_findByDate",
"parameters": [
{
"name": "date",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Pay period found for the selected date",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodDto"
}
}
}
},
"404": {
"description": "Pay period not found for the selected date"
}
},
"summary": "Resolve a period by a date within it",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/{year}/{periodNumber}": {
"get": {
"operationId": "PayPeriodsController_findOneByYear",
"parameters": [
{
"name": "year",
"required": true,
"in": "path",
"schema": {
"example": 2024,
"type": "number"
}
},
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
}
}
],
"responses": {
"200": {
"description": "Pay period found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodDto"
}
}
}
},
"404": {
"description": "Pay period not found"
}
},
"summary": "Find pay period by year and period number",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/{year}/{periodNumber}/{email}": {
"get": {
"operationId": "PayPeriodsController_getCrewOverview",
"parameters": [
{
"name": "year",
"required": true,
"in": "path",
"schema": {
"example": 2024,
"type": "number"
}
},
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
}
},
{
"name": "email",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "includeSubtree",
"required": false,
"in": "query",
"description": "Include indirect reports",
"schema": {
"example": false,
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "Crew overview",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodOverviewDto"
}
}
}
},
"404": {
"description": "Pay period not found"
}
},
"summary": "Supervisor crew overview for a given pay period",
"tags": [
"pay-periods"
]
}
},
"/pay-periods/overview/{year}/{periodNumber}": {
"get": {
"operationId": "PayPeriodsController_getOverviewByYear",
"parameters": [
{
"name": "year",
"required": true,
"in": "path",
"schema": {
"example": 2024,
"type": "number"
}
},
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
}
}
],
"responses": {
"200": {
"description": "Pay period overview found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PayPeriodOverviewDto"
}
}
}
},
"404": {
"description": "Pay period not found"
}
},
"summary": "Detailed view of a pay period by year + number",
"tags": [
"pay-periods"
]
}
},
"/timesheets": {
"get": {
"operationId": "TimesheetController_getTimesheetByIds",
"parameters": [
{
"name": "employee_email",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "year",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "period_number",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Timesheet"
]
}
},
"/shift": {
"get": {
"operationId": "ShiftController_getShiftsByIds",
"parameters": [
{
"name": "shift_ids",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Shift"
]
},
"patch": {
"operationId": "ShiftController_updateBatch",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Shift"
]
}
},
"/shift/{timesheet_id}": {
"post": {
"operationId": "ShiftController_createBatch",
"parameters": [
{
"name": "timesheet_id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"responses": {
"201": {
"description": ""
}
},
"tags": [
"Shift"
]
}
},
"/shift/{shift_id}": {
"delete": {
"operationId": "ShiftController_remove",
"parameters": [
{
"name": "shift_id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Shift"
]
}
},
"/preferences/{email}": { "/preferences/{email}": {
"patch": { "patch": {
"operationId": "PreferencesController_updatePreferences", "operationId": "PreferencesController_updatePreferences",
@ -562,139 +915,6 @@
"SchedulePresets" "SchedulePresets"
] ]
} }
},
"/shift": {
"get": {
"operationId": "ShiftController_getShiftsByIds",
"parameters": [
{
"name": "shift_ids",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Shift"
]
},
"patch": {
"operationId": "ShiftController_updateBatch",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Shift"
]
}
},
"/shift/{timesheet_id}": {
"post": {
"operationId": "ShiftController_createBatch",
"parameters": [
{
"name": "timesheet_id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
},
"responses": {
"201": {
"description": ""
}
},
"tags": [
"Shift"
]
}
},
"/shift/{shift_id}": {
"delete": {
"operationId": "ShiftController_remove",
"parameters": [
{
"name": "shift_id",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Shift"
]
}
},
"/timesheets": {
"get": {
"operationId": "TimesheetController_getTimesheetByIds",
"parameters": [
{
"name": "employee_email",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "year",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "period_number",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Timesheet"
]
}
} }
}, },
"info": { "info": {
@ -1018,6 +1238,168 @@
} }
} }
}, },
"PayPeriodDto": {
"type": "object",
"properties": {
"pay_period_no": {
"type": "number",
"example": 1,
"description": "numéro cyclique de la période entre 1 et 26"
},
"period_start": {
"type": "string",
"example": "2023-12-17",
"format": "date"
},
"period_end": {
"type": "string",
"example": "2023-12-30",
"format": "date"
},
"payday": {
"type": "string",
"example": "2023-01-04",
"format": "date"
},
"pay_year": {
"type": "number",
"example": 2023
},
"label": {
"type": "string",
"example": "2023-12-17 → 2023-12-30"
}
},
"required": [
"pay_period_no",
"period_start",
"period_end",
"payday",
"pay_year",
"label"
]
},
"PayPeriodBundleDto": {
"type": "object",
"properties": {
"current": {
"description": "Current pay period (resolved from date)",
"allOf": [
{
"$ref": "#/components/schemas/PayPeriodDto"
}
]
},
"periods": {
"description": "All pay periods",
"type": "array",
"items": {
"$ref": "#/components/schemas/PayPeriodDto"
}
}
},
"required": [
"current",
"periods"
]
},
"EmployeePeriodOverviewDto": {
"type": "object",
"properties": {
"employee_name": {
"type": "string",
"example": "Alex Dupont",
"description": "Nom complet de lemployé"
},
"regular_hours": {
"type": "number",
"example": 40,
"description": "pay-period`s regular hours"
},
"other_hours": {
"type": "object",
"example": 0,
"description": "pay-period`s other hours"
},
"expenses": {
"type": "number",
"example": 420.69,
"description": "pay-period`s total expenses ($)"
},
"mileage": {
"type": "number",
"example": 40,
"description": "pay-period total mileages (km)"
},
"is_approved": {
"type": "boolean",
"example": true,
"description": "Tous les timesheets de la période sont approuvés pour cet employé"
}
},
"required": [
"employee_name",
"regular_hours",
"other_hours",
"expenses",
"mileage",
"is_approved"
]
},
"PayPeriodOverviewDto": {
"type": "object",
"properties": {
"pay_period_no": {
"type": "number",
"example": 1,
"description": "Period number (126)"
},
"pay_year": {
"type": "number",
"example": 2023,
"description": "Calendar year of the period"
},
"period_start": {
"type": "string",
"example": "2023-12-17",
"format": "date",
"description": "Period start date (YYYY-MM-DD)"
},
"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",
"description": "Human-readable label"
},
"employees_overview": {
"description": "Per-employee overview for the period",
"type": "array",
"items": {
"$ref": "#/components/schemas/EmployeePeriodOverviewDto"
}
}
},
"required": [
"pay_period_no",
"pay_year",
"period_start",
"period_end",
"payday",
"label",
"employees_overview"
]
},
"PreferencesDto": { "PreferencesDto": {
"type": "object", "type": "object",
"properties": {} "properties": {}

View File

@ -5,7 +5,7 @@ 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 { 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";
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto"; import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
@ -15,7 +15,7 @@ export class PayPeriodsController {
constructor( constructor(
private readonly queryService: PayPeriodsQueryService, private readonly queryService: PayPeriodsQueryService,
private readonly commandService: PayPeriodsCommandService, // private readonly commandService: PayPeriodsCommandService,
) {} ) {}
@Get('current-and-all') @Get('current-and-all')
@ -51,13 +51,13 @@ export class PayPeriodsController {
return this.queryService.findOneByYearPeriod(year, period_no); return this.queryService.findOneByYearPeriod(year, period_no);
} }
@Patch("crew/bulk-approval") // @Patch("crew/bulk-approval")
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: "Approve all selected timesheets in the period" }) // @ApiOperation({ summary: "Approve all selected timesheets in the period" })
@ApiResponse({ status: 200, description: "Pay period approved" }) // @ApiResponse({ status: 200, description: "Pay period approved" })
async bulkApproval(@Body() dto: BulkCrewApprovalDto) { // async bulkApproval(@Body() dto: BulkCrewApprovalDto) {
return this.commandService.bulkApproveCrew(dto); // return this.commandService.bulkApproveCrew(dto);
} // }
@Get(':year/:periodNumber/:email') @Get(':year/:periodNumber/:email')
//@RolesAllowed(RoleEnum.SUPERVISOR) //@RolesAllowed(RoleEnum.SUPERVISOR)

View File

@ -1,7 +1,6 @@
import { PrismaModule } from "src/prisma/prisma.module"; import { PrismaModule } from "src/prisma/prisma.module";
import { PayPeriodsController } from "./controllers/pay-periods.controller"; import { PayPeriodsController } from "./controllers/pay-periods.controller";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PayPeriodsCommandService } from "./services/pay-periods-command.service";
import { PayPeriodsQueryService } from "./services/pay-periods-query.service"; import { PayPeriodsQueryService } from "./services/pay-periods-query.service";
import { TimesheetsModule } from "../timesheets/timesheets.module"; import { TimesheetsModule } from "../timesheets/timesheets.module";
import { SharedModule } from "../shared/shared.module"; import { SharedModule } from "../shared/shared.module";
@ -12,14 +11,10 @@ import { BusinessLogicsModule } from "../business-logics/business-logics.module"
imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule], imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule],
providers: [ providers: [
PayPeriodsQueryService, PayPeriodsQueryService,
PayPeriodsCommandService,
PrismaService, PrismaService,
], ],
controllers: [PayPeriodsController], controllers: [PayPeriodsController],
exports: [ exports: [ PayPeriodsQueryService ],
PayPeriodsQueryService,
PayPeriodsCommandService,
]
}) })
export class PayperiodsModule {} export class PayperiodsModule {}

View File

@ -1,71 +1,71 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"; // import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service"; // import { PrismaService } from "src/prisma/prisma.service";
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto"; // import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
import { PayPeriodsQueryService } from "./pay-periods-query.service"; // import { PayPeriodsQueryService } from "./pay-periods-query.service";
import { TimesheetApprovalService } from "src/modules/timesheets/services/timesheet-approval.service"; // import { TimesheetApprovalService } from "src/modules/timesheets/services/timesheet-approval.service";
@Injectable() // @Injectable()
export class PayPeriodsCommandService { // export class PayPeriodsCommandService {
constructor( // constructor(
private readonly prisma: PrismaService, // private readonly prisma: PrismaService,
private readonly timesheets_approval: TimesheetApprovalService, // private readonly timesheets_approval: TimesheetApprovalService,
private readonly query: PayPeriodsQueryService, // private readonly query: PayPeriodsQueryService,
) {} // ) {}
//function to approve pay-periods according to selected crew members // //function to approve pay-periods according to selected crew members
async bulkApproveCrew(dto:BulkCrewApprovalDto): Promise<{updated: number}> { // async bulkApproveCrew(dto:BulkCrewApprovalDto): Promise<{updated: number}> {
const { supervisor_email, include_subtree, items } = dto; // const { supervisor_email, include_subtree, items } = dto;
if(!items?.length) throw new BadRequestException('no items to process'); // if(!items?.length) throw new BadRequestException('no items to process');
//fetch and validate supervisor status // //fetch and validate supervisor status
const supervisor = await this.query.getSupervisor(supervisor_email); // const supervisor = await this.query.getSupervisor(supervisor_email);
if(!supervisor) throw new NotFoundException('No employee record linked to current user'); // if(!supervisor) throw new NotFoundException('No employee record linked to current user');
if(!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor'); // if(!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor');
//fetches emails of crew members linked to supervisor // //fetches emails of crew members linked to supervisor
const crew_emails = await this.query.resolveCrewEmails(supervisor.id, include_subtree); // const crew_emails = await this.query.resolveCrewEmails(supervisor.id, include_subtree);
for(const item of items) { // for(const item of items) {
if(!crew_emails.has(item.employee_email)) { // if(!crew_emails.has(item.employee_email)) {
throw new ForbiddenException(`Employee ${item.employee_email} not in supervisor crew`); // throw new ForbiddenException(`Employee ${item.employee_email} not in supervisor crew`);
} // }
} // }
const period_cache = new Map<string, {period_start: Date, period_end: Date}>(); // const period_cache = new Map<string, {period_start: Date, period_end: Date}>();
const getPeriod = async (y:number, no: number) => { // const getPeriod = async (y:number, no: number) => {
const key = `${y}-${no}`; // const key = `${y}-${no}`;
if(!period_cache.has(key)) return period_cache.get(key)!; // if(!period_cache.has(key)) return period_cache.get(key)!;
const period = await this.query.getPeriodWindow(y,no); // const period = await this.query.getPeriodWindow(y,no);
if(!period) throw new NotFoundException(`Pay period ${y}-${no} not found`); // if(!period) throw new NotFoundException(`Pay period ${y}-${no} not found`);
period_cache.set(key, period); // period_cache.set(key, period);
return period; // return period;
}; // };
let updated = 0; // let updated = 0;
await this.prisma.$transaction(async (transaction) => { // await this.prisma.$transaction(async (transaction) => {
for(const item of items) { // for(const item of items) {
const { period_start, period_end } = await getPeriod(item.pay_year, item.period_no); // const { period_start, period_end } = await getPeriod(item.pay_year, item.period_no);
const t_sheets = await transaction.timesheets.findMany({ // const t_sheets = await transaction.timesheets.findMany({
where: { // where: {
employee: { user: { email: item.employee_email } }, // employee: { user: { email: item.employee_email } },
OR: [ // OR: [
{shift : { some: { date: { gte: period_start, lte: period_end } } } }, // {shift : { some: { date: { gte: period_start, lte: period_end } } } },
{expense: { some: { date: { gte: period_start, lte: period_end } } } }, // {expense: { some: { date: { gte: period_start, lte: period_end } } } },
], // ],
}, // },
select: { id: true }, // select: { id: true },
}); // });
for(const { id } of t_sheets) { // for(const { id } of t_sheets) {
await this.timesheets_approval.updateApprovalWithTransaction(transaction, id, item.approve); // await this.timesheets_approval.updateApprovalWithTransaction(transaction, id, item.approve);
updated++; // updated++;
} // }
} // }
}); // });
return {updated}; // return {updated};
} // }
} // }