targo-backend/src/modules/pay-periods/services/pay-periods-query.service.ts
2025-08-11 14:58:03 -04:00

288 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
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, listPayYear, payYearOfDate } from "../utils/pay-year.util";
import { PayPeriodDto } from "../dtos/pay-period.dto";
import { mapPayPeriodToDto } from "../mappers/pay-periods.mapper";
@Injectable()
export class PayPeriodsQueryService {
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 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: 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 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: 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,
} },
} },
},
},
bank_code: { select: { categorie: true } },
},
});
// EXPENSES (filtrés par crew si besoin)
const expenses = await this.prisma.expenses.findMany({
where: {
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
} },
} },
} },
bank_code: { select: { categorie: true, modifier: true } },
},
});
// Agrégation
const byEmployee = new Map<number, EmployeePeriodOverviewDto>();
// seed pour employés sans données
if (opts?.seedNames) {
for (const [id, name] of opts.seedNames.entries()) {
byEmployee.set(id, {
employee_id: id,
employee_name: name,
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
expenses: 0,
mileage: 0,
is_approved: true,
});
}
}
const ensure = (id: number, name: string) => {
if (!byEmployee.has(id)) {
byEmployee.set(id, {
employee_id: id,
employee_name: name,
regular_hours: 0,
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
expenses: 0,
mileage: 0,
is_approved: true,
});
}
return byEmployee.get(id)!;
};
for (const s of shifts) {
const e = s.timesheet.employee;
const name = `${e.user.first_name} ${e.user.last_name}`.trim();
const rec = ensure(e.id, name);
const hours = computeHours(s.start_time, s.end_time);
const cat = (s.bank_code?.categorie || "REGULAR").toUpperCase();
switch (cat) {
case "EVENING": rec.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;
}
rec.is_approved = rec.is_approved && s.timesheet.is_approved;
}
for (const ex of expenses) {
const e = ex.timesheet.employee;
const name = `${e.user.first_name} ${e.user.last_name}`.trim();
const rec = ensure(e.id, name);
const amount = toMoney(ex.amount);
rec.expenses += amount;
const cat = (ex.bank_code?.categorie || "").toUpperCase();
const rate = ex.bank_code?.modifier ?? 0;
if (cat === "MILEAGE" && rate > 0) {
rec.mileage += amount / rate;
}
rec.is_approved = rec.is_approved && ex.timesheet.is_approved;
}
const employees_overview = Array.from(byEmployee.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),
label: period.label,
employees_overview,
};
}
async getCrewOverview(year: number, periodNumber: number, userId: string, includeSubtree: boolean): Promise<PayPeriodOverviewDto> {
// 1) Trouver la période
const period = await this.prisma.payPeriods.findFirst({ where: { year, period_number: periodNumber } });
if (!period) throw new NotFoundException(`Pay period ${year}-${periodNumber} not found`);
// 2) Résoudre l'employé superviseur depuis l'utilisateur courant (Users.id -> Employees)
const supervisor = await this.prisma.employees.findUnique({
where: { user_id: userId },
select: { id: true },
});
if (!supervisor) throw new ForbiddenException('No employee record linked to current user');
// 3) Récupérer la liste des employés du crew (directs ou sous-arbo complète)
const crew = await this.resolveCrew(supervisor.id, includeSubtree); // [{ id, first_name, last_name }]
const crewIds = crew.map(c => c.id);
// seed names map for employés sans données
const seedNames = new Map<number, string>(crew.map(c => [c.id, `${c.first_name} ${c.last_name}`.trim()]));
// 4) Construire loverview filtré par ce crew
return this.buildOverview(period, { restrictEmployeeIds: crewIds, seedNames });
}
private async resolveCrew(supervisorId: number, includeSubtree: boolean): Promise<Array<{ id: number; first_name: string; last_name: string }>> {
const result: Array<{ id: number; first_name: string; last_name: string }> = [];
// niveau 1 (directs)
let frontier = await this.prisma.employees.findMany({
where: { supervisor_id: supervisorId },
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 })));
if (!includeSubtree) return result;
// BFS pour les niveaux suivants
while (frontier.length) {
const parentIds = frontier.map(e => e.id);
const next = await this.prisma.employees.findMany({
where: { supervisor_id: { in: parentIds } },
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 })));
frontier = next;
}
return result;
}
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,
label: period.label,
}));
}
async findOne(periodNumber: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({
where: { period_number: periodNumber },
orderBy: { year: "desc" },
});
if (!row) throw new NotFoundException(`Pay period #${periodNumber} not found`);
return mapPayPeriodToDto(row);
}
async findOneByYearPeriod(year: number, periodNumber: number): Promise<PayPeriodDto> {
const row = await this.prisma.payPeriods.findFirst({
where: { year, period_number: periodNumber },
});
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
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 } },
});
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> {
const isoDay = date ?? new Date().toISOString().slice(0,10);
return this.findByDate(isoDay);
}
}