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": {
"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": {

View File

@ -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<PayPeriodOverviewDto> {
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<PayPeriodBundleDto> {
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<PayPeriodBundleDto> {
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<PayPeriodOverviewDto> {
return this.queryService.getOverviewByYearPeriod(year, periodNumber);
}
}

View File

@ -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}`,
};
}

View File

@ -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,36 +19,51 @@ export class PayPeriodsQueryService {
}
async getOverviewByYearPeriod(year: number, periodNumber: number): Promise<PayPeriodOverviewDto> {
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; },
period: { start_date: string | Date; end_date: string | Date; period_number: number; year: number; label: string; },
opts?: { restrictEmployeeIds?: number[]; seedNames?: 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 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: {
timesheet: { select: {
is_approved: true,
employee: { select: { id: true, user: { select: { first_name: true, last_name: 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: {
timesheet: { select: {
is_approved: true,
employee: { select: { id: true, user: { select: { first_name: true, last_name: 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,
};

View File

@ -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<PayPeriodDto[]> {
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<PayPeriodDto> {
@ -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<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));
}