288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
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 l’overview 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);
|
||
}
|
||
}
|