targo-backend/src/time-and-attendance/pay-period/services/pay-periods-build-overview.service.ts
Nic D 5f4fb3594a fix(pay-period, timesheet): fix issue where overtime wasn't calculated properly.
Also fix issue where employee overviews would sometimes falsely return as approved if employee had no timesheets created yet. This is due to Array.prototype.every returning true on an empty array.
2026-03-13 15:21:04 -04:00

268 lines
10 KiB
TypeScript

import { Injectable } from "@nestjs/common";
import { Result } from "src/common/errors/result-error.factory";
import { computeHours, computePeriod, toDateFromString, toStringFromDate } from "src/common/utils/date-utils";
import { PrismaPostgresService } from "prisma/postgres/prisma-postgres.service";
import { EmployeePeriodOverviewDto, Overview, PayPeriodOverviewDto } from "src/time-and-attendance/pay-period/dtos/overview-pay-period.dto";
@Injectable()
export class GetOverviewService {
constructor(
private readonly prisma: PrismaPostgresService,
) { }
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 }
}
async buildOverview(
overview: Overview
): Promise<Result<PayPeriodOverviewDto, string>> {
const employee_overviews = await this.prisma.employees.findMany({
where: {
OR: [
{ last_work_day: { gte: toDateFromString(overview.period_start) } },
{ last_work_day: null },
]
},
select: {
id: true,
last_work_day: true,
user: {
select: {
first_name: true,
last_name: true,
email: true,
},
},
supervisor: {
select: {
user: {
select: {
first_name: true,
last_name: true,
email: true,
},
},
},
},
timesheet: {
where: {
start_date: { gte: toDateFromString(overview.period_start), lte: toDateFromString(overview.period_end) },
},
select: {
id: true,
is_approved: true,
start_date: true,
shift: {
select: {
start_time: true,
end_time: true,
date: true,
bank_code: { select: { type: true } },
},
orderBy: { date: 'asc' },
},
expense: {
select: {
amount: true,
mileage: true,
bank_code: { select: { type: true, modifier: true } },
},
},
},
},
},
orderBy: { user: { first_name: 'asc' } },
});
const by_employee = new Map<number, EmployeePeriodOverviewDto>();
// seed for employee without data
if (overview.options?.seed_names) {
for (const [id, { first_name, last_name, email }] of overview.options.seed_names.entries()) {
by_employee.set(id, this.createEmployeeSeeds(email, first_name, last_name));
}
} else {
for (const employee of employee_overviews) {
const record = this.createEmployeeSeeds(
employee.user.email,
employee.user.first_name,
employee.user.last_name,
employee.supervisor?.user ?? null,
);
by_employee.set(employee.id, record);
}
}
const ensure = (
id: number,
first_name: string,
last_name: string,
email: string
) => {
if (!by_employee.has(id)) {
by_employee.set(id, this.createEmployeeSeeds(email, first_name, last_name));
}
return by_employee.get(id)!;
};
for (const employee of employee_overviews) {
const record = ensure(
employee.id,
employee.user.first_name,
employee.user.last_name,
employee.user.email
);
for (const timesheet of employee.timesheet) {
let total_weekly_hours: number = 0;
let daily_hours: number = 0;
let previous_shift_date: string = '';
//totals by types for shifts
for (const shift of timesheet.shift) {
const hours = computeHours(shift.start_time, shift.end_time);
const type = (shift.bank_code?.type ?? '').toUpperCase();
if (previous_shift_date !== toStringFromDate(shift.date)) {
previous_shift_date = toStringFromDate(shift.date);
daily_hours = 0;
}
switch (type) {
case "EVENING":
if (total_weekly_hours + hours <= 40) {
record.other_hours.evening_hours += Math.min(hours, 8 - daily_hours);
record.other_hours.overtime_hours += Math.max(daily_hours + hours - 8, 0);
} else {
record.other_hours.evening_hours += Math.max(40 - total_weekly_hours, 0);
record.other_hours.overtime_hours += Math.min(total_weekly_hours + hours - 40, hours);
}
total_weekly_hours += hours;
record.total_hours += hours;
break;
case "EMERGENCY":
record.other_hours.emergency_hours += hours;
record.total_hours += hours;
total_weekly_hours += hours;
break;
case "SICK":
record.other_hours.sick_hours += hours;
break;
case "HOLIDAY":
record.other_hours.holiday_hours += hours;
record.total_hours += hours;
total_weekly_hours += hours;
break;
case "VACATION": record.other_hours.vacation_hours += hours;
break;
case "REGULAR":
if (total_weekly_hours + hours <= 40) {
record.regular_hours += Math.min(hours, 8 - daily_hours);
record.other_hours.overtime_hours += Math.max(daily_hours + hours - 8, 0);
} else {
record.regular_hours += Math.max(40 - total_weekly_hours, 0);
record.other_hours.overtime_hours += Math.min(total_weekly_hours + hours - 40, hours);
}
total_weekly_hours += hours;
record.total_hours += hours;
break;
}
daily_hours += hours;
}
//totals by type for expenses
for (const expense of timesheet.expense) {
const amount = Number(expense.amount)
record.expenses = Number((record.expenses + amount).toFixed(2));
const type = (expense.bank_code?.type || "").toUpperCase();
const rate = expense.bank_code?.modifier ?? 1;
const mileage = Number(expense.mileage) / rate;
if (type === "MILEAGE" && rate > 0) {
record.mileage = Number((record.mileage += Math.round(mileage)).toFixed(2));
}
}
record.weekly_hours.push(total_weekly_hours);
}
}
for (const employee of employee_overviews) {
const record = by_employee.get(employee.id);
if (!record) continue;
const timesheets = employee.timesheet;
const cutoff_date = new Date();
cutoff_date.setDate(cutoff_date.getDate() + 14);
const is_active = employee.last_work_day ? employee.last_work_day.getTime() >= cutoff_date.getTime() : true;
if (timesheets.length > 0)
record.is_approved = timesheets.every(timesheet => timesheet.is_approved);
record.is_active = is_active;
}
const employees_overview = Array.from(by_employee.values()).sort((a, b) =>
a.employee_first_name.localeCompare(b.employee_first_name, "fr", { sensitivity: "base" }),
);
return {
success: true,
data: {
pay_period_no: overview.period_no,
pay_year: overview.pay_year,
payday: overview.payday,
period_start: (overview.period_start),
period_end: overview.period_end,
label: overview.label,
employees_overview,
}
};
}
createEmployeeSeeds = (
email: string,
employee_first_name: string,
employee_last_name: string,
supervisor: {
first_name: string;
last_name: string;
email: string;
} | null = null,
): EmployeePeriodOverviewDto => ({
email,
employee_first_name,
employee_last_name,
supervisor: supervisor ?? null,
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,
},
weekly_hours: [],
total_hours: 0,
expenses: 0,
mileage: 0,
is_approved: false,
});
}