targo-backend/src/time-and-attendance/pay-period/services/pay-periods-query.service.ts

489 lines
19 KiB
TypeScript

import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { computeHours, computePeriod, listPayYear, payYearOfDate, toStringFromDate } from "src/common/utils/date-utils";
import { EmployeePeriodOverviewDto, PayPeriodDto, PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto";
import { Result } from "src/common/errors/result-error.factory";
import { mapPayPeriodToDto } from "src/time-and-attendance/pay-period/pay-periods.mapper";
@Injectable()
export class PayPeriodsQueryService {
constructor(private readonly prisma: PrismaService) { }
async getOverview(pay_period_no: number): Promise<Result<PayPeriodOverviewDto, string>> {
const period = await this.prisma.payPeriods.findFirst({
where: { pay_period_no },
orderBy: { pay_year: "desc" },
});
if (!period) return { success: false, error: `PAY_PERIOD_NOT_FOUND` };
const overview = await this.buildOverview({
period_start: period.period_start,
period_end: period.period_end,
payday: period.payday,
period_no: period.pay_period_no,
pay_year: period.pay_year,
label: period.label,
});
if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' }
return { success: true, data: overview.data }
}
async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise<Result<PayPeriodOverviewDto, string>> {
const period = computePeriod(pay_year, period_no);
const overview = await this.buildOverview({
period_start: period.period_start,
period_end: period.period_end,
period_no: period.period_no,
pay_year: period.pay_year,
payday: period.payday,
label: period.label,
});
if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' }
return { success: true, data: overview.data }
}
//find crew member associated with supervisor
private async resolveCrew(supervisor_id: number, include_subtree: boolean):
Promise<Result<Array<{ id: number; first_name: string; last_name: string; email: string }>, string>> {
const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = [];
let frontier = await this.prisma.employees.findMany({
where: { supervisor_id: supervisor_id },
select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } },
});
result.push(...frontier.map(emp => ({
id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email
})));
if (!include_subtree) return { success: true, data: result };
while (frontier.length) {
const parent_ids = frontier.map(emp => emp.id);
const next = await this.prisma.employees.findMany({
where: { supervisor_id: { in: parent_ids } },
select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } },
});
if (next.length === 0) break;
result.push(...next.map(emp => ({
id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email
})));
frontier = next;
}
return { success: true, data: result };
}
//fetchs crew emails
async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise<Result<Set<string>, string>> {
const crew = await this.resolveCrew(supervisor_id, include_subtree);
if (!crew.success) return { success: false, error: crew.error }
return { success: true, data: new Set(crew.data.map(crew_member => crew_member.email).filter(Boolean)) }
}
async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean):
Promise<Result<PayPeriodOverviewDto, string>> {
// 1) Search for the period
const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } });
if (!period) return { success: false, error: 'PAY_PERIOD_NOT_FOUND' }
// 2) fetch supervisor
const supervisor = await this.prisma.employees.findFirst({
where: { user: { email: email } },
select: {
id: true,
is_supervisor: true,
},
});
if (!supervisor) return { success: false, error: 'EMPLOYEE_NOT_FOUND' }
if (!supervisor.is_supervisor) return { success: false, error: 'INVALID_EMPLOYEE' }
// 3)fetchs crew members
const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }]
if (!crew.success) return { success: false, error: crew.error }
const crew_ids = crew.data.map(c => c.id);
// seed names map for employee without data
const seed_names = new Map<number, { name: string; email: string }>(
crew.data.map(crew => [
crew.id,
{
name: `${crew.first_name} ${crew.last_name}`.trim(),
email: crew.email
}
]
)
);
const overview = await this.buildOverview({
period_no: period.pay_period_no,
period_start: period.period_start,
period_end: period.period_end,
payday: period.payday,
pay_year: period.pay_year,
label: period.label,
}, { filtered_employee_ids: crew_ids, seed_names })
if (!overview.success) return { success: false, error: 'INVALID_PAY_PERIOD' }
// 4) overview build
return { success: true, data: overview.data }
}
private async buildOverview(
period: {
period_start: string | Date; period_end: string | Date; payday: string | Date;
period_no: number; pay_year: number; label: string;
},
options?: { filtered_employee_ids?: number[]; seed_names?: Map<number, { name: string, email: string }> }
): Promise<Result<PayPeriodOverviewDto, string>> {
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.period_start instanceof Date
? period.period_start
: new Date(`${period.period_start}T00:00:00.000Z`);
const end = period.period_end instanceof Date
? period.period_end
: new Date(`${period.period_end}T00:00:00.000Z`);
const payd = period.payday instanceof Date
? period.payday
: new Date(`${period.payday}T00:00:00.000Z`);
//restrictEmployeeIds = filter for shifts and expenses by employees
const where_employee = options?.filtered_employee_ids?.length ?
{
date: { gte: start, lte: end },
timesheet: { employee_id: { in: options.filtered_employee_ids } },
}
:
{ date: { gte: start, lte: end } };
// SHIFTS (filtered by crew)
const shifts = await this.prisma.shifts.findMany({
where: where_employee,
select: {
start_time: true,
end_time: true,
is_remote: true,
timesheet: {
select: {
id: true,
is_approved: true,
employee: {
select: {
id: true,
user: {
select: {
first_name: true,
last_name: true,
email: true,
}
},
}
},
},
},
bank_code: { select: { categorie: true, type: true } },
},
});
// EXPENSES (filtered by crew)
const expenses = await this.prisma.expenses.findMany({
where: where_employee,
select: {
amount: true,
timesheet: {
select: {
id: true,
is_approved: true,
employee: {
select: {
id: true,
user: {
select: {
first_name: true,
last_name: true,
email: true,
}
},
}
},
}
},
bank_code: { select: { categorie: true, modifier: true, type: true } },
},
});
const timesheet_id = shifts[0].timesheet.id ? expenses[0].timesheet.id : null;
if (timesheet_id === null) return { success: false, error: 'INVALID_TIMESHEET' };
const by_employee = new Map<number, EmployeePeriodOverviewDto>();
const timesheet = await this.prisma.timesheets.findFirst({
where: { id: timesheet_id },
select: {
is_approved: true,
id: true,
},
});
if (!timesheet) return { success: false, error: 'INVALID_TIMESHEET' };
// seed for employee without data
if (options?.seed_names) {
for (const [id, { name, email }] of options.seed_names.entries()) {
by_employee.set(id, {
email,
employee_name: name,
is_active: true,
regular_hours: 0,
other_hours: {
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
sick_hours: 0,
holiday_hours: 0,
vacation_hours: 0,
},
total_hours: 0,
expenses: 0,
mileage: 0,
is_approved: false,
});
}
} else {
const all_employees = await this.prisma.employees.findMany({
include: {
user: {
select: {
first_name: true,
last_name: true,
email: true
},
},
},
});
for (const employee of all_employees) {
let is_active = true;
if (employee.last_work_day !== null) {
is_active = this.checkForInactiveDate(employee.last_work_day)
}
by_employee.set(employee.id, {
email: employee.user.email,
employee_name: employee.user.first_name + ' ' + employee.user.last_name,
is_active: is_active,
regular_hours: 0,
other_hours: {
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
sick_hours: 0,
holiday_hours: 0,
vacation_hours: 0,
},
total_hours: 0,
expenses: 0,
mileage: 0,
is_approved: timesheet.is_approved,
});
}
}
const ensure = (id: number, name: string, email: string) => {
if (!by_employee.has(id)) {
by_employee.set(id, {
email,
employee_name: name,
is_active: true,
regular_hours: 0,
other_hours: {
evening_hours: 0,
emergency_hours: 0,
overtime_hours: 0,
sick_hours: 0,
holiday_hours: 0,
vacation_hours: 0,
},
total_hours: 0,
expenses: 0,
mileage: 0,
is_approved: timesheet.is_approved,
});
}
return by_employee.get(id)!;
};
for (const shift of shifts) {
const employee = shift.timesheet.employee;
const name = `${employee.user.first_name} ${employee.user.last_name}`.trim();
const record = ensure(employee.id, name, employee.user.email);
const hours = computeHours(shift.start_time, shift.end_time);
const type = (shift.bank_code?.type ?? '').toUpperCase();
switch (type) {
case "EVENING": record.other_hours.evening_hours += hours;
record.total_hours += hours;
break;
case "EMERGENCY": record.other_hours.emergency_hours += hours;
record.total_hours += hours;
break;
case "OVERTIME": record.other_hours.overtime_hours += hours;
record.total_hours += hours;
break;
case "SICK": record.other_hours.sick_hours += hours;
record.total_hours += hours;
break;
case "HOLIDAY": record.other_hours.holiday_hours += hours;
record.total_hours += hours;
break;
case "VACATION": record.other_hours.vacation_hours += hours;
record.total_hours += hours;
break;
case "REGULAR": record.regular_hours = record.regular_hours += hours;
record.total_hours += hours;
break;
}
record.is_approved = record.is_approved && shift.timesheet.is_approved;
}
for (const expense of expenses) {
const exp = expense.timesheet.employee;
const name = `${exp.user.first_name} ${exp.user.last_name}`.trim();
const record = ensure(exp.id, name, exp.user.email);
const amount = toMoney(expense.amount);
record.expenses = Number((record.expenses += amount).toFixed(2));
const type = (expense.bank_code?.type || "").toUpperCase();
const rate = expense.bank_code?.modifier ?? 0;
if (type === "MILEAGE" && rate > 0) {
record.mileage = Number((record.mileage += Math.round((amount / rate) * 100) / 100).toFixed(2));
}
record.is_approved = record.is_approved && expense.timesheet.is_approved;
}
const employees_overview = Array.from(by_employee.values()).sort((a, b) =>
a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }),
);
return {
success: true,
data: {
pay_period_no: period.period_no,
pay_year: period.pay_year,
payday: toDateString(payd),
period_start: toDateString(start),
period_end: toDateString(end),
label: period.label,
employees_overview,
}
};
}
async getSupervisor(email: string) {
return this.prisma.employees.findFirst({
where: { user: { email } },
select: { id: true, is_supervisor: true },
});
}
async findAll(): Promise<Result<PayPeriodDto[], string>> {
const currentPayYear = payYearOfDate(new Date());
return {
success: true,
data: listPayYear(currentPayYear).map(period => ({
pay_period_no: period.period_no,
pay_year: period.pay_year,
payday: period.payday,
period_start: period.period_start,
period_end: period.period_end,
label: period.label,
}))
};
}
async findOne(period_no: number): Promise<Result<PayPeriodDto, string>> {
const row = await this.prisma.payPeriods.findFirst({
where: { pay_period_no: period_no },
orderBy: { pay_year: "desc" },
});
if (!row) return { success: false, error: `PAY_PERIOD_NOT_FOUND` }
return { success: true, data: mapPayPeriodToDto(row) };
}
async findCurrent(date?: string): Promise<Result<PayPeriodDto, string>> {
const iso_day = date ?? new Date().toISOString().slice(0, 10);
const pay_period = await this.findByDate(iso_day);
if (!pay_period.success) return { success: false, error: 'INVALID_PAY_PERIOD' }
return { success: true, data: pay_period.data }
}
async findOneByYearPeriod(pay_year: number, period_no: number): Promise<Result<PayPeriodDto, string>> {
const row = await this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no: period_no },
});
if (row) return { success: true, data: mapPayPeriodToDto(row) };
// fallback for outside of view periods
const period = computePeriod(pay_year, period_no);
return {
success: true,
data: {
pay_period_no: period.period_no,
pay_year: period.pay_year,
period_start: period.period_start,
payday: period.payday,
period_end: period.period_end,
label: period.label
}
}
}
//function to cherry pick a Date to find a period
async findByDate(date: string): Promise<Result<PayPeriodDto, string>> {
const dt = new Date(date);
const row = await this.prisma.payPeriods.findFirst({
where: { period_start: { lte: dt }, period_end: { gte: dt } },
});
if (row) return { success: true, data: mapPayPeriodToDto(row) };
//fallback for outwside view periods
const pay_year = payYearOfDate(date);
const periods = listPayYear(pay_year);
const hit = periods.find(period => date >= period.period_start && date <= period.period_end);
if (!hit) return { success: false, error: `PAY_PERIOD_NOT_FOUND` }
return {
success: true,
data: {
pay_period_no: hit.period_no,
pay_year: hit.pay_year,
period_start: hit.period_start,
period_end: hit.period_end,
payday: hit.payday,
label: hit.label
}
}
}
async getPeriodWindow(pay_year: number, period_no: number) {
return this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no: period_no },
select: { period_start: true, period_end: true },
});
}
private checkForInactiveDate = (last_work_day: Date) => {
const limit = new Date(last_work_day);
limit.setDate(limit.getDate() + 14);
if (limit >= new Date()) {
return true;
}
return false;
}
}