From a99f39bbf609692c8c30a34a321db140b97559db Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 11 Aug 2025 12:46:05 -0400 Subject: [PATCH] fix(pay-periods): added fallback for archive purposes. minor fix for findAll --- docs/swagger/swagger-spec.json | 167 ++++++++++++------ .../controllers/pay-periods.controller.ts | 59 +++---- .../pay-periods/mappers/pay-periods.mapper.ts | 7 +- .../services/pay-periods-query.service.ts | 70 +++++--- .../services/pay-periods.service.ts | 36 ++-- .../pay-periods/utils/pay-year.util.ts | 38 ++++ 6 files changed, 253 insertions(+), 124 deletions(-) create mode 100644 src/modules/pay-periods/utils/pay-year.util.ts diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 5b19dfa..8ebbed7 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -1864,46 +1864,68 @@ ] } }, - "/pay-periods/{year}/{periodNumber}/overview": { + "/pay-periods/bundle/current-and-all": { "get": { - "operationId": "PayPeriodsController_getOverviewByYear", + "operationId": "PayPeriodsController_getCurrentAndAll", "parameters": [ { - "name": "year", - "required": true, - "in": "path", + "name": "date", + "required": false, + "in": "query", + "description": "Override for resolving the current period", "schema": { - "example": 2024, - "type": "number" - } - }, - { - "name": "periodNumber", - "required": true, - "in": "path", - "description": "1..26", - "schema": { - "example": 1, - "type": "number" + "example": "2025-08-11", + "type": "string" } } ], "responses": { "200": { - "description": "Pay period overview found", + "description": "Find current and all pay periods", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/PayPeriodOverviewDto" + "$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" + "description": "Pay period not found for the selected date" } }, - "summary": "Detailed view of a pay period by year + number", + "summary": "Resolve a period by a date within it", "tags": [ "pay-periods" ] @@ -1954,40 +1976,6 @@ ] } }, - "/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}/approval": { "patch": { "operationId": "PayPeriodsController_approve", @@ -2077,6 +2065,51 @@ "pay-periods" ] } + }, + "/pay-periods/{year}/{periodNumber}/overview": { + "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" + ] + } } }, "info": { @@ -2953,6 +2986,30 @@ "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": { diff --git a/src/modules/pay-periods/controllers/pay-periods.controller.ts b/src/modules/pay-periods/controllers/pay-periods.controller.ts index d645638..14a8288 100644 --- a/src/modules/pay-periods/controllers/pay-periods.controller.ts +++ b/src/modules/pay-periods/controllers/pay-periods.controller.ts @@ -29,19 +29,25 @@ export class PayPeriodsController { return this.payPeriodsService.findAll(); } - @Get(':year/:periodNumber/overview') - @ApiOperation({ summary: 'Detailed view of a pay period by year + number' }) - @ApiParam({ name: 'year', type: Number, example: 2024 }) - @ApiParam({ name: 'periodNumber', type: Number, example: 1, description: '1..26' }) - @ApiResponse({ status: 200, description: 'Pay period overview found', type: PayPeriodOverviewDto }) - @ApiNotFoundResponse({ description: 'Pay period not found' }) - async getOverviewByYear( - @Param('year', ParseIntPipe) year: number, - @Param('periodNumber', ParseIntPipe) periodNumber: number, - ): Promise { - return this.queryService.getOverviewByYearPeriod(year, periodNumber); + @Get('bundle/current-and-all') + @ApiOperation({summary: 'Return current pay period and the full list'}) + @ApiQuery({name: 'date', required:false, example: '2025-08-11', description:'Override for resolving the current period'}) + @ApiResponse({status: 200, description:'Find current and all pay periods', type: PayPeriodBundleDto}) + async getCurrentAndAll(@Query('date') date?: string): Promise { + const [current, periods] = await Promise.all([ + this.payPeriodsService.findCurrent(date), + this.payPeriodsService.findAll(), + ]); + return { current, periods }; } + @Get("date/:date") + @ApiOperation({ summary: "Resolve a period by a date within it" }) + @ApiResponse({ status: 200, description: "Pay period found for the selected date", type: PayPeriodDto }) + @ApiNotFoundResponse({ description: "Pay period not found for the selected date" }) + async findByDate(@Param("date") date: string) { + return this.payPeriodsService.findByDate(date); + } @Get(":year/:periodNumber") @ApiOperation({ summary: "Find pay period by year and period number" }) @@ -56,14 +62,6 @@ export class PayPeriodsController { return this.payPeriodsService.findOneByYearPeriod(year, periodNumber); } - @Get("date/:date") - @ApiOperation({ summary: "Resolve a period by a date within it" }) - @ApiResponse({ status: 200, description: "Pay period found for the selected date", type: PayPeriodDto }) - @ApiNotFoundResponse({ description: "Pay period not found for the selected date" }) - async findByDate(@Param("date") date: string) { - return this.payPeriodsService.findByDate(date); - } - @Patch(":year/:periodNumber/approval") @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) @ApiOperation({ summary: "Approve all timesheets with activity in the period" }) @@ -102,17 +100,16 @@ export class PayPeriodsController { return this.queryService.getCrewOverview(year, periodNumber, userId, includeSubtree); } - - @Get('bundle/current-and-all') - @ApiOperation({summary: 'Return current pay period and the full list'}) - @ApiQuery({name: 'date', required:false, example: '2025-01-01', description:'Override for resolving the current period'}) - @ApiResponse({status: 200, description:'Find current and all pay periods', type: PayPeriodBundleDto}) - async getCurrentAndAll(@Query('date') date?: string): Promise { - const [current, periods] = await Promise.all([ - this.payPeriodsService.findCurrent(date), - this.payPeriodsService.findAll(), - ]); - return { current, periods }; + @Get(':year/:periodNumber/overview') + @ApiOperation({ summary: 'Detailed view of a pay period by year + number' }) + @ApiParam({ name: 'year', type: Number, example: 2024 }) + @ApiParam({ name: 'periodNumber', type: Number, example: 1, description: '1..26' }) + @ApiResponse({ status: 200, description: 'Pay period overview found', type: PayPeriodOverviewDto }) + @ApiNotFoundResponse({ description: 'Pay period not found' }) + async getOverviewByYear( + @Param('year', ParseIntPipe) year: number, + @Param('periodNumber', ParseIntPipe) periodNumber: number, + ): Promise { + return this.queryService.getOverviewByYearPeriod(year, periodNumber); } - } diff --git a/src/modules/pay-periods/mappers/pay-periods.mapper.ts b/src/modules/pay-periods/mappers/pay-periods.mapper.ts index 5ff1ecf..0ce3d12 100644 --- a/src/modules/pay-periods/mappers/pay-periods.mapper.ts +++ b/src/modules/pay-periods/mappers/pay-periods.mapper.ts @@ -1,15 +1,18 @@ 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" export function mapPayPeriodToDto(row: PayPeriods): PayPeriodDto { + const s = toDateString(row.start_date); + const e = toDateString(row.end_date); return { period_number: row.period_number, start_date: toDateString(row.start_date), end_date: toDateString(row.end_date), - year: row.year, - label: row.label, + year: payYearOfDate(s), + label: `${s} => ${e}`, }; } diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index 5dda386..7d81f83 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from "src/prisma/prisma.service"; import { computeHours } from "src/common/utils/date-utils"; import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto"; import { EmployeePeriodOverviewDto } from "../dtos/overview-employee-period.dto"; +import { computePeriod } from "../utils/pay-year.util"; @Injectable() export class PayPeriodsQueryService { @@ -18,37 +19,52 @@ export class PayPeriodsQueryService { } async getOverviewByYearPeriod(year: number, periodNumber: number): Promise { - const period = await this.prisma.payPeriods.findFirst({ - where: { year, period_number: periodNumber }, - }); - if (!period) throw new NotFoundException(`Period ${year}-${periodNumber} not found`); - return this.buildOverview(period); + const p = computePeriod(year, periodNumber); + return this.buildOverview({ + start_date: p.start_date, + end_date : p.end_date, + period_number: p.period_number, + year: p.year, + label:p.label, + } as any); } - private async buildOverview( - period: { start_date: Date; end_date: Date; period_number: number; year: number; label: string; }, + private async buildOverview( + period: { start_date: string | Date; end_date: string | Date; period_number: number; year: number; label: string; }, opts?: { restrictEmployeeIds?: number[]; seedNames?: Map }, ): Promise { 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 whereEmployee = opts?.restrictEmployeeIds?.length - ? { employee_id: { in: opts.restrictEmployeeIds } } - : {}; + + const start = period.start_date instanceof Date + ? period.start_date + : new Date(`${period.start_date}T00:00:00.000Z`); + + const end = period.end_date instanceof Date + ? period.end_date + : new Date(`${period.end_date}T00:00:00.000Z`); + + const whereEmployee = opts?.restrictEmployeeIds?.length ? { employee_id: { in: opts.restrictEmployeeIds } }: {}; // SHIFTS (filtrés par crew si besoin) const shifts = await this.prisma.shifts.findMany({ where: { - date: { gte: period.start_date, lte: period.end_date }, + date: { gte: start, lte: end }, timesheet: whereEmployee, }, select: { start_time: true, end_time: true, - timesheet: { - select: { - is_approved: true, - employee: { select: { id: true, user: { select: { first_name: true, last_name: true } } } }, - }, + timesheet: { select: { + is_approved: true, + employee: { select: { + id: true, + user: { select: { + first_name: true, + last_name: true, + } }, + } }, + }, }, bank_code: { select: { categorie: true } }, }, @@ -57,17 +73,21 @@ export class PayPeriodsQueryService { // EXPENSES (filtrés par crew si besoin) const expenses = await this.prisma.expenses.findMany({ where: { - date: { gte: period.start_date, lte: period.end_date }, + date: { gte: start, lte: end }, timesheet: whereEmployee, }, select: { amount: true, - timesheet: { - select: { - is_approved: true, - employee: { select: { id: true, user: { select: { first_name: true, last_name: true } } } }, - }, - }, + timesheet: { select: { + is_approved: true, + employee: { select: { + id: true, + user: { select: { + first_name: true, + last_name: true + } }, + } }, + } }, bank_code: { select: { categorie: true, modifier: true } }, }, }); @@ -149,8 +169,8 @@ export class PayPeriodsQueryService { return { period_number: period.period_number, year: period.year, - start_date: toDateString(period.start_date), - end_date: toDateString(period.end_date), + start_date: toDateString(start), + end_date: toDateString(end), label: period.label, employees_overview, }; diff --git a/src/modules/pay-periods/services/pay-periods.service.ts b/src/modules/pay-periods/services/pay-periods.service.ts index a6627fe..120f7b4 100644 --- a/src/modules/pay-periods/services/pay-periods.service.ts +++ b/src/modules/pay-periods/services/pay-periods.service.ts @@ -3,19 +3,22 @@ import { PrismaService } from "src/prisma/prisma.service"; import { PayPeriodsCommandService } from "./pay-periods-command.service"; import { mapMany, mapPayPeriodToDto } from "../mappers/pay-periods.mapper"; import { PayPeriodDto } from "../dtos/pay-period.dto"; +import { computePeriod, listPayYear, payYearOfDate } from "../utils/pay-year.util"; @Injectable() export class PayPeriodsService { - constructor(private readonly prisma: PrismaService, - private readonly payperiodsApprovalService: PayPeriodsCommandService - ) {} + constructor(private readonly prisma: PrismaService) {} async findAll(): Promise { - const rows = await this.prisma.payPeriods.findMany({ - orderBy: [{ year: 'desc'}, { period_number: "asc"}], - }); - return mapMany(rows); + 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, + label: period.label, + })); } async findOne(periodNumber: number): Promise { @@ -31,8 +34,11 @@ export class PayPeriodsService { const row = await this.prisma.payPeriods.findFirst({ where: { year, period_number: periodNumber }, }); - if (!row) throw new NotFoundException(`Pay period ${year}-${periodNumber} not found`); - return mapPayPeriodToDto(row); + 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} } //function to cherry pick a Date to find a period @@ -41,8 +47,16 @@ export class PayPeriodsService { const row = await this.prisma.payPeriods.findFirst({ where: { start_date: { lte: dt }, end_date: { gte: dt } }, }); - if (!row) throw new NotFoundException(`No period found for this date: ${date}`); - return mapPayPeriodToDto(row); + 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); + 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} + } async findCurrent(date?: string): Promise { diff --git a/src/modules/pay-periods/utils/pay-year.util.ts b/src/modules/pay-periods/utils/pay-year.util.ts new file mode 100644 index 0000000..57cc675 --- /dev/null +++ b/src/modules/pay-periods/utils/pay-year.util.ts @@ -0,0 +1,38 @@ +export const ANCHOR_ISO = '2023-12-17'; // ancre date +const PERIOD_DAYS = 14; +const PERIODS_PER_YEAR = 26; + +const toUTCDate = (iso: string | Date) => { + const d = typeof iso === 'string' ? new Date(iso + 'T00:00:00.000Z') : iso; + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate())); +}; +export const toDateString = (d: Date) => d.toISOString().slice(0, 10); + +const ANCHOR = toUTCDate(ANCHOR_ISO); + +export function payYearOfDate(date: string | Date): number { + const d = toUTCDate(date); + const days = Math.floor((+d - +ANCHOR) / 86400000); + const cycles = Math.floor(days / (PERIODS_PER_YEAR * PERIOD_DAYS)); + return ANCHOR.getUTCFullYear() + 1 + cycles; +} +//compute labels for periods +export function computePeriod(payYear: number, periodNumber: number) { + const cycles = payYear - (ANCHOR.getUTCFullYear() + 1); + const offsetPeriods = cycles * PERIODS_PER_YEAR + (periodNumber - 1); + const start = new Date(+ANCHOR + offsetPeriods * PERIOD_DAYS * 86400000); + const end = new Date(+start + (PERIOD_DAYS - 1) * 86400000); + return { + period_number: periodNumber, + year: payYear, + start_date: toDateString(start), + end_date: toDateString(end), + label: `${toDateString(start)} → ${toDateString(end)}`, + start, end, + }; +} + +//list of all 26 periods for a full year +export function listPayYear(payYear: number) { + return Array.from({ length: PERIODS_PER_YEAR }, (_, i) => computePeriod(payYear, i + 1)); +}