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 { 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 { 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 }, ): 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 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(); // 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 { // 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(crew.map(c => [c.id, `${c.first_name} ${c.last_name}`.trim()])); // 4) Construire l’overview filtré par ce crew return this.buildOverview(period, { restrictEmployeeIds: crewIds, seedNames }); } private async resolveCrew(supervisorId: number, includeSubtree: boolean): Promise> { 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 { 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 { 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 { 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 { 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 { const isoDay = date ?? new Date().toISOString().slice(0,10); return this.findByDate(isoDay); } }