fix(pay-periods): added fallback for archive purposes. minor fix for findAll

This commit is contained in:
Matthieu Haineault 2025-08-11 12:46:05 -04:00
parent 242e3179f4
commit a99f39bbf6
6 changed files with 253 additions and 124 deletions

View File

@ -1864,46 +1864,68 @@
] ]
} }
}, },
"/pay-periods/{year}/{periodNumber}/overview": { "/pay-periods/bundle/current-and-all": {
"get": { "get": {
"operationId": "PayPeriodsController_getOverviewByYear", "operationId": "PayPeriodsController_getCurrentAndAll",
"parameters": [ "parameters": [
{ {
"name": "year", "name": "date",
"required": true, "required": false,
"in": "path", "in": "query",
"description": "Override for resolving the current period",
"schema": { "schema": {
"example": 2024, "example": "2025-08-11",
"type": "number" "type": "string"
}
},
{
"name": "periodNumber",
"required": true,
"in": "path",
"description": "1..26",
"schema": {
"example": 1,
"type": "number"
} }
} }
], ],
"responses": { "responses": {
"200": { "200": {
"description": "Pay period overview found", "description": "Find current and all pay periods",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "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": [ "tags": [
"pay-periods" "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": { "/pay-periods/{year}/{periodNumber}/approval": {
"patch": { "patch": {
"operationId": "PayPeriodsController_approve", "operationId": "PayPeriodsController_approve",
@ -2077,6 +2065,51 @@
"pay-periods" "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": { "info": {
@ -2953,6 +2986,30 @@
"label" "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": { "EmployeePeriodOverviewDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@ -29,19 +29,25 @@ export class PayPeriodsController {
return this.payPeriodsService.findAll(); return this.payPeriodsService.findAll();
} }
@Get(':year/:periodNumber/overview') @Get('bundle/current-and-all')
@ApiOperation({ summary: 'Detailed view of a pay period by year + number' }) @ApiOperation({summary: 'Return current pay period and the full list'})
@ApiParam({ name: 'year', type: Number, example: 2024 }) @ApiQuery({name: 'date', required:false, example: '2025-08-11', description:'Override for resolving the current period'})
@ApiParam({ name: 'periodNumber', type: Number, example: 1, description: '1..26' }) @ApiResponse({status: 200, description:'Find current and all pay periods', type: PayPeriodBundleDto})
@ApiResponse({ status: 200, description: 'Pay period overview found', type: PayPeriodOverviewDto }) async getCurrentAndAll(@Query('date') date?: string): Promise<PayPeriodBundleDto> {
@ApiNotFoundResponse({ description: 'Pay period not found' }) const [current, periods] = await Promise.all([
async getOverviewByYear( this.payPeriodsService.findCurrent(date),
@Param('year', ParseIntPipe) year: number, this.payPeriodsService.findAll(),
@Param('periodNumber', ParseIntPipe) periodNumber: number, ]);
): Promise<PayPeriodOverviewDto> { return { current, periods };
return this.queryService.getOverviewByYearPeriod(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);
}
@Get(":year/:periodNumber") @Get(":year/:periodNumber")
@ApiOperation({ summary: "Find pay period by year and period number" }) @ApiOperation({ summary: "Find pay period by year and period number" })
@ -56,14 +62,6 @@ export class PayPeriodsController {
return this.payPeriodsService.findOneByYearPeriod(year, periodNumber); 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") @Patch(":year/:periodNumber/approval")
@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
@ApiOperation({ summary: "Approve all timesheets with activity in the period" }) @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); return this.queryService.getCrewOverview(year, periodNumber, userId, includeSubtree);
} }
@Get(':year/:periodNumber/overview')
@Get('bundle/current-and-all') @ApiOperation({ summary: 'Detailed view of a pay period by year + number' })
@ApiOperation({summary: 'Return current pay period and the full list'}) @ApiParam({ name: 'year', type: Number, example: 2024 })
@ApiQuery({name: 'date', required:false, example: '2025-01-01', description:'Override for resolving the current period'}) @ApiParam({ name: 'periodNumber', type: Number, example: 1, description: '1..26' })
@ApiResponse({status: 200, description:'Find current and all pay periods', type: PayPeriodBundleDto}) @ApiResponse({ status: 200, description: 'Pay period overview found', type: PayPeriodOverviewDto })
async getCurrentAndAll(@Query('date') date?: string): Promise<PayPeriodBundleDto> { @ApiNotFoundResponse({ description: 'Pay period not found' })
const [current, periods] = await Promise.all([ async getOverviewByYear(
this.payPeriodsService.findCurrent(date), @Param('year', ParseIntPipe) year: number,
this.payPeriodsService.findAll(), @Param('periodNumber', ParseIntPipe) periodNumber: number,
]); ): Promise<PayPeriodOverviewDto> {
return { current, periods }; return this.queryService.getOverviewByYearPeriod(year, periodNumber);
} }
} }

View File

@ -1,15 +1,18 @@
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 = (d: Date) => d.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 e = toDateString(row.end_date);
return { return {
period_number: row.period_number, period_number: row.period_number,
start_date: toDateString(row.start_date), start_date: toDateString(row.start_date),
end_date: toDateString(row.end_date), end_date: toDateString(row.end_date),
year: row.year, year: payYearOfDate(s),
label: row.label, label: `${s} => ${e}`,
}; };
} }

View File

@ -3,6 +3,7 @@ import { PrismaService } from "src/prisma/prisma.service";
import { computeHours } from "src/common/utils/date-utils"; import { computeHours } from "src/common/utils/date-utils";
import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto"; import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto";
import { EmployeePeriodOverviewDto } from "../dtos/overview-employee-period.dto"; import { EmployeePeriodOverviewDto } from "../dtos/overview-employee-period.dto";
import { computePeriod } from "../utils/pay-year.util";
@Injectable() @Injectable()
export class PayPeriodsQueryService { export class PayPeriodsQueryService {
@ -18,37 +19,52 @@ export class PayPeriodsQueryService {
} }
async getOverviewByYearPeriod(year: number, periodNumber: number): Promise<PayPeriodOverviewDto> { async getOverviewByYearPeriod(year: number, periodNumber: number): Promise<PayPeriodOverviewDto> {
const period = await this.prisma.payPeriods.findFirst({ const p = computePeriod(year, periodNumber);
where: { year, period_number: periodNumber }, return this.buildOverview({
}); start_date: p.start_date,
if (!period) throw new NotFoundException(`Period ${year}-${periodNumber} not found`); end_date : p.end_date,
return this.buildOverview(period); period_number: p.period_number,
year: p.year,
label:p.label,
} as any);
} }
private async buildOverview( private async buildOverview(
period: { start_date: Date; end_date: Date; period_number: number; year: number; label: string; }, period: { start_date: string | Date; end_date: string | Date; period_number: number; year: number; label: string; },
opts?: { restrictEmployeeIds?: number[]; seedNames?: Map<number, string> }, opts?: { restrictEmployeeIds?: number[]; seedNames?: 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 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) // SHIFTS (filtrés par crew si besoin)
const shifts = await this.prisma.shifts.findMany({ const shifts = await this.prisma.shifts.findMany({
where: { where: {
date: { gte: period.start_date, lte: period.end_date }, date: { gte: start, lte: end },
timesheet: whereEmployee, timesheet: whereEmployee,
}, },
select: { select: {
start_time: true, start_time: true,
end_time: true, end_time: true,
timesheet: { timesheet: { select: {
select: { is_approved: true,
is_approved: true, employee: { select: {
employee: { select: { id: true, user: { select: { first_name: true, last_name: true } } } }, id: true,
}, user: { select: {
first_name: true,
last_name: true,
} },
} },
},
}, },
bank_code: { select: { categorie: true } }, bank_code: { select: { categorie: true } },
}, },
@ -57,17 +73,21 @@ export class PayPeriodsQueryService {
// EXPENSES (filtrés par crew si besoin) // EXPENSES (filtrés par crew si besoin)
const expenses = await this.prisma.expenses.findMany({ const expenses = await this.prisma.expenses.findMany({
where: { where: {
date: { gte: period.start_date, lte: period.end_date }, date: { gte: start, lte: end },
timesheet: whereEmployee, timesheet: whereEmployee,
}, },
select: { select: {
amount: true, amount: true,
timesheet: { timesheet: { select: {
select: { is_approved: true,
is_approved: true, employee: { select: {
employee: { select: { id: true, user: { select: { first_name: true, last_name: true } } } }, id: true,
}, user: { select: {
}, first_name: true,
last_name: true
} },
} },
} },
bank_code: { select: { categorie: true, modifier: true } }, bank_code: { select: { categorie: true, modifier: true } },
}, },
}); });
@ -149,8 +169,8 @@ export class PayPeriodsQueryService {
return { return {
period_number: period.period_number, period_number: period.period_number,
year: period.year, year: period.year,
start_date: toDateString(period.start_date), start_date: toDateString(start),
end_date: toDateString(period.end_date), end_date: toDateString(end),
label: period.label, label: period.label,
employees_overview, employees_overview,
}; };

View File

@ -3,19 +3,22 @@ import { PrismaService } from "src/prisma/prisma.service";
import { PayPeriodsCommandService } from "./pay-periods-command.service"; import { PayPeriodsCommandService } from "./pay-periods-command.service";
import { mapMany, mapPayPeriodToDto } from "../mappers/pay-periods.mapper"; import { mapMany, mapPayPeriodToDto } from "../mappers/pay-periods.mapper";
import { PayPeriodDto } from "../dtos/pay-period.dto"; import { PayPeriodDto } from "../dtos/pay-period.dto";
import { computePeriod, listPayYear, payYearOfDate } from "../utils/pay-year.util";
@Injectable() @Injectable()
export class PayPeriodsService { export class PayPeriodsService {
constructor(private readonly prisma: PrismaService, constructor(private readonly prisma: PrismaService) {}
private readonly payperiodsApprovalService: PayPeriodsCommandService
) {}
async findAll(): Promise<PayPeriodDto[]> { async findAll(): Promise<PayPeriodDto[]> {
const rows = await this.prisma.payPeriods.findMany({ const currentPayYear = payYearOfDate(new Date());
orderBy: [{ year: 'desc'}, { period_number: "asc"}], return listPayYear(currentPayYear).map(period =>({
}); period_number: period.period_number,
return mapMany(rows); year: period.year,
start_date: period.start_date,
end_date: period.end_date,
label: period.label,
}));
} }
async findOne(periodNumber: number): Promise<PayPeriodDto> { async findOne(periodNumber: number): Promise<PayPeriodDto> {
@ -31,8 +34,11 @@ export class PayPeriodsService {
const row = await this.prisma.payPeriods.findFirst({ const row = await this.prisma.payPeriods.findFirst({
where: { year, period_number: periodNumber }, where: { year, period_number: periodNumber },
}); });
if (!row) throw new NotFoundException(`Pay period ${year}-${periodNumber} not found`); if(row) return mapPayPeriodToDto(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 //function to cherry pick a Date to find a period
@ -41,8 +47,16 @@ export class PayPeriodsService {
const row = await this.prisma.payPeriods.findFirst({ const row = await this.prisma.payPeriods.findFirst({
where: { start_date: { lte: dt }, end_date: { gte: dt } }, where: { start_date: { lte: dt }, end_date: { gte: dt } },
}); });
if (!row) throw new NotFoundException(`No period found for this date: ${date}`); if(row) return mapPayPeriodToDto(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<PayPeriodDto> { async findCurrent(date?: string): Promise<PayPeriodDto> {

View File

@ -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));
}