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.
268 lines
10 KiB
TypeScript
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,
|
|
});
|
|
} |