256 lines
10 KiB
TypeScript
256 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.client.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,
|
|
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 = amount / 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 has_data = timesheets.some(timesheet => timesheet.shift.length > 0 || timesheet.expense.length > 0);
|
|
|
|
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;
|
|
|
|
record.is_approved = has_data && timesheets.every(timesheet => timesheet.is_approved === true);
|
|
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,
|
|
});
|
|
} |