feat(approval): selected approval by bulk for pay-period using crew view. renamed variables to use snake_case
This commit is contained in:
parent
d44f8da99f
commit
3c8c999bfe
|
|
@ -2073,36 +2073,26 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
"/pay-periods/approval/{year}/{periodNumber}": {
|
||||
"/pay-periods/crew/bulk-approval": {
|
||||
"patch": {
|
||||
"operationId": "PayPeriodsController_approve",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "year",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"example": 2024,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "periodNumber",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "1..26",
|
||||
"schema": {
|
||||
"example": 1,
|
||||
"type": "number"
|
||||
"operationId": "PayPeriodsController_bulkApproval",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/BulkCrewApprovalDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Pay period approved"
|
||||
}
|
||||
},
|
||||
"summary": "Approve all timesheets with activity in the period",
|
||||
"summary": "Approve all selected timesheets in the period",
|
||||
"tags": [
|
||||
"pay-periods"
|
||||
]
|
||||
|
|
@ -3097,6 +3087,10 @@
|
|||
"periods"
|
||||
]
|
||||
},
|
||||
"BulkCrewApprovalDto": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
"EmployeePeriodOverviewDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export abstract class BaseApprovalService<T> {
|
|||
}
|
||||
|
||||
//approval with transaction to avoid many requests
|
||||
async updateApprovalWithTx(transaction: Prisma.TransactionClient, id: number, isApproved: boolean): Promise<T> {
|
||||
async updateApprovalWithTransaction(transaction: Prisma.TransactionClient, id: number, isApproved: boolean): Promise<T> {
|
||||
try {
|
||||
return await this.delegateFor(transaction).update({
|
||||
where: { id },
|
||||
|
|
|
|||
|
|
@ -14,15 +14,15 @@ export class AfterHoursService {
|
|||
|
||||
|
||||
private getPreBusinessMinutes(start: Date, end: Date): number {
|
||||
const bizStart = new Date(start);
|
||||
bizStart.setHours(AfterHoursService.BUSINESS_START, 0,0,0);
|
||||
const biz_start = new Date(start);
|
||||
biz_start.setHours(AfterHoursService.BUSINESS_START, 0,0,0);
|
||||
|
||||
if (end>= start || start >= bizStart) {
|
||||
if (end>= start || start >= biz_start) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const segmentEnd = end < bizStart ? end : bizStart;
|
||||
const minutes = (segmentEnd.getTime() - start.getTime()) / 60000;
|
||||
const segment_end = end < biz_start ? end : biz_start;
|
||||
const minutes = (segment_end.getTime() - start.getTime()) / 60000;
|
||||
|
||||
this.logger.debug(`getPreBusinessMintutes -> ${minutes.toFixed(1)}min`);
|
||||
return minutes;
|
||||
|
|
@ -30,15 +30,15 @@ export class AfterHoursService {
|
|||
}
|
||||
|
||||
private getPostBusinessMinutes(start: Date, end: Date): number {
|
||||
const bizEnd = new Date(start);
|
||||
bizEnd.setHours(AfterHoursService.BUSINESS_END,0,0,0);
|
||||
const biz_end = new Date(start);
|
||||
biz_end.setHours(AfterHoursService.BUSINESS_END,0,0,0);
|
||||
|
||||
if( end <= bizEnd ) {
|
||||
if( end <= biz_end ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const segmentStart = start > bizEnd ? start : bizEnd;
|
||||
const minutes = (end.getTime() - segmentStart.getTime()) / 60000;
|
||||
const segment_start = start > biz_end ? start : biz_end;
|
||||
const minutes = (end.getTime() - segment_start.getTime()) / 60000;
|
||||
|
||||
this.logger.debug(`getPostBusinessMintutes -> ${minutes.toFixed(1)}min`);
|
||||
return minutes;
|
||||
|
|
@ -62,17 +62,17 @@ export class AfterHoursService {
|
|||
'You must create 2 instances, one on the first day and the second during the next day.');
|
||||
}
|
||||
|
||||
const preMin = this.getPreBusinessMinutes(start, end);
|
||||
const postMin = this.getPostBusinessMinutes(start, end);
|
||||
const rawAftermin = preMin + postMin;
|
||||
const pre_min = this.getPreBusinessMinutes(start, end);
|
||||
const post_min = this.getPostBusinessMinutes(start, end);
|
||||
const raw_aftermin = pre_min + post_min;
|
||||
|
||||
const roundedMin = this.roundToNearestQUarterMinute(rawAftermin);
|
||||
const rounded_min = this.roundToNearestQUarterMinute(raw_aftermin);
|
||||
|
||||
const hours = roundedMin / 60;
|
||||
const hours = rounded_min / 60;
|
||||
const result = parseFloat(hours.toFixed(2));
|
||||
|
||||
this.logger.debug(`computeAfterHours -> rawAfterMin= ${rawAftermin.toFixed(1)}min, +
|
||||
rounded = ${roundedMin}min, hours = ${result.toFixed(2)}`);
|
||||
this.logger.debug(`computeAfterHours -> raw_aftermin = ${raw_aftermin.toFixed(1)}min, +
|
||||
rounded = ${rounded_min}min, hours = ${result.toFixed(2)}`);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,34 +9,34 @@ export class HolidayService {
|
|||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
//switch employeeId for email
|
||||
private async computeHoursPrevious4Weeks(employeeId: number, holidayDate: Date): Promise<number> {
|
||||
private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise<number> {
|
||||
//sets the end of the window to 1ms before the week with the holiday
|
||||
const holidayWeekStart = getWeekStart(holidayDate);
|
||||
const windowEnd = new Date(holidayWeekStart.getTime() - 1);
|
||||
const holiday_week_start = getWeekStart(holiday_date);
|
||||
const window_end = new Date(holiday_week_start.getTime() - 1);
|
||||
//sets the start of the window to 28 days ( 4 completed weeks ) before the week with the holiday
|
||||
const windowStart = new Date(windowEnd.getTime() - 28 * 24 * 60 * 60000 + 1 )
|
||||
const window_start = new Date(window_end.getTime() - 28 * 24 * 60 * 60000 + 1 )
|
||||
|
||||
const validCodes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700'];
|
||||
const valid_codes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700'];
|
||||
//fetches all shift of the employee in said window ( 4 previous completed weeks )
|
||||
const shifts = await this.prisma.shifts.findMany({
|
||||
where: { timesheet: { employee_id: employeeId } ,
|
||||
date: { gte: windowStart, lte: windowEnd },
|
||||
bank_code: { bank_code: { in: validCodes } },
|
||||
where: { timesheet: { employee_id: employee_id } ,
|
||||
date: { gte: window_start, lte: window_end },
|
||||
bank_code: { bank_code: { in: valid_codes } },
|
||||
},
|
||||
select: { date: true, start_time: true, end_time: true },
|
||||
});
|
||||
|
||||
const totalHours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0);
|
||||
const dailyHours = totalHours / 20;
|
||||
const total_hours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0);
|
||||
const daily_hours = total_hours / 20;
|
||||
|
||||
return dailyHours;
|
||||
return daily_hours;
|
||||
}
|
||||
|
||||
//switch employeeId for email
|
||||
async calculateHolidayPay( employeeId: number, holidayDate: Date, modifier: number): Promise<number> {
|
||||
const hours = await this.computeHoursPrevious4Weeks(employeeId, holidayDate);
|
||||
const dailyRate = Math.min(hours, 8);
|
||||
async calculateHolidayPay( employee_id: number, holiday_date: Date, modifier: number): Promise<number> {
|
||||
const hours = await this.computeHoursPrevious4Weeks(employee_id, holiday_date);
|
||||
const daily_rate = Math.min(hours, 8);
|
||||
this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`);
|
||||
return dailyRate * modifier;
|
||||
return daily_rate * modifier;
|
||||
}
|
||||
}
|
||||
|
|
@ -8,28 +8,28 @@ export class MileageService {
|
|||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
public async calculateReimbursement(amount: number, bankCodeId: number): Promise<number> {
|
||||
public async calculateReimbursement(amount: number, bank_code_id: number): Promise<number> {
|
||||
if(amount < 0) {
|
||||
throw new BadRequestException(`The amount most be higher than 0`);
|
||||
}
|
||||
|
||||
//fetch modifier
|
||||
const bankCode = await this.prisma.bankCodes.findUnique({
|
||||
where: { id: bankCodeId },
|
||||
const bank_code = await this.prisma.bankCodes.findUnique({
|
||||
where: { id: bank_code_id },
|
||||
select: { modifier: true, type: true },
|
||||
});
|
||||
|
||||
if(!bankCode) {
|
||||
throw new BadRequestException(`bank_code ${bankCodeId} not found`);
|
||||
if(!bank_code) {
|
||||
throw new BadRequestException(`bank_code ${bank_code_id} not found`);
|
||||
}
|
||||
if(bankCode.type !== 'mileage') {
|
||||
this.logger.warn(`bank_code ${bankCodeId} of type ${bankCode.type} is used for mileage`)
|
||||
if(bank_code.type !== 'mileage') {
|
||||
this.logger.warn(`bank_code ${bank_code_id} of type ${bank_code.type} is used for mileage`)
|
||||
}
|
||||
|
||||
//calculate total amount to reimburs
|
||||
const reimboursement = amount * bankCode.modifier;
|
||||
const reimboursement = amount * bank_code.modifier;
|
||||
const result = parseFloat(reimboursement.toFixed(2));
|
||||
this.logger.debug(`calculateReimbursement -> amount= ${amount}, modifier= ${bankCode.modifier}, total= ${result}`);
|
||||
this.logger.debug(`calculateReimbursement -> amount= ${amount}, modifier= ${bank_code.modifier}, total= ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,29 +6,29 @@ import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-ut
|
|||
export class OvertimeService {
|
||||
|
||||
private logger = new Logger(OvertimeService.name);
|
||||
private dailyMax = 8; // maximum for regular hours per day
|
||||
private weeklyMax = 40; //maximum for regular hours per week
|
||||
private daily_max = 12; // maximum for regular hours per day
|
||||
private weekly_max = 80; //maximum for regular hours per week
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
//calculate Daily overtime
|
||||
getDailyOvertimeHours(start: Date, end: Date): number {
|
||||
const hours = computeHours(start, end, 5);
|
||||
const overtime = Math.max(0, hours - this.dailyMax);
|
||||
this.logger.debug(`getDailyOvertimeHours : ${overtime.toFixed(2)}h (threshold ${this.dailyMax})`);
|
||||
const overtime = Math.max(0, hours - this.daily_max);
|
||||
this.logger.debug(`getDailyOvertimeHours : ${overtime.toFixed(2)}h (threshold ${this.daily_max})`);
|
||||
return overtime;
|
||||
}
|
||||
|
||||
//calculate Weekly overtime
|
||||
//switch employeeId for email
|
||||
async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise<number> {
|
||||
const weekStart = getWeekStart(refDate);
|
||||
const weekEnd = getWeekEnd(weekStart);
|
||||
const week_start = getWeekStart(refDate);
|
||||
const week_end = getWeekEnd(week_start);
|
||||
|
||||
//fetches all shifts containing hours
|
||||
const shifts = await this.prisma.shifts.findMany({
|
||||
where: { timesheet: { employee_id: employeeId, shift: {
|
||||
every: {date: { gte: weekStart, lte: weekEnd } }
|
||||
every: {date: { gte: week_start, lte: week_end } }
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -38,16 +38,16 @@ export class OvertimeService {
|
|||
//calculate total hours of those shifts minus weekly Max to find total overtime hours
|
||||
const total = shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5))
|
||||
.reduce((sum, hours)=> sum+hours, 0);
|
||||
const overtime = Math.max(0, total - this.weeklyMax);
|
||||
const overtime = Math.max(0, total - this.weekly_max);
|
||||
|
||||
this.logger.debug(`weekly total = ${total.toFixed(2)}h, weekly Overtime= ${overtime.toFixed(2)}h`);
|
||||
return overtime;
|
||||
}
|
||||
|
||||
//apply modifier to overtime hours
|
||||
calculateOvertimePay(overtimeHours: number, modifier: number): number {
|
||||
const pay = overtimeHours * modifier;
|
||||
this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtimeHours}, modifier ${modifier})`);
|
||||
calculateOvertimePay(overtime_hours: number, modifier: number): number {
|
||||
const pay = overtime_hours * modifier;
|
||||
this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`);
|
||||
|
||||
return pay;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,55 +9,58 @@ export class SickLeaveService {
|
|||
private readonly logger = new Logger(SickLeaveService.name);
|
||||
|
||||
//switch employeeId for email
|
||||
async calculateSickLeavePay(employeeId: number, referenceDate: Date, daysRequested: number, modifier: number): Promise<number> {
|
||||
async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number):
|
||||
Promise<number> {
|
||||
//sets the year to jan 1st to dec 31st
|
||||
const periodStart = getYearStart(referenceDate);
|
||||
const periodEnd = referenceDate;
|
||||
const period_start = getYearStart(reference_date);
|
||||
const period_end = reference_date;
|
||||
|
||||
//fetches all shifts of a selected employee
|
||||
const shifts = await this.prisma.shifts.findMany({
|
||||
where: {
|
||||
timesheet: { employee_id: employeeId },
|
||||
date: { gte: periodStart, lte: periodEnd},
|
||||
timesheet: { employee_id: employee_id },
|
||||
date: { gte: period_start, lte: period_end},
|
||||
},
|
||||
select: { date: true },
|
||||
});
|
||||
|
||||
//count the amount of worked days
|
||||
const workedDates = new Set(
|
||||
const worked_dates = new Set(
|
||||
shifts.map(shift => shift.date.toISOString().slice(0,10))
|
||||
);
|
||||
const daysWorked = workedDates.size;
|
||||
this.logger.debug(`Sick leave: days worked= ${daysWorked} in ${periodStart.toDateString()} -> ${periodEnd.toDateString()}`);
|
||||
const days_worked = worked_dates.size;
|
||||
this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()}
|
||||
-> ${period_end.toDateString()}`);
|
||||
|
||||
//less than 30 worked days returns 0
|
||||
if (daysWorked < 30) {
|
||||
if (days_worked < 30) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
//default 3 days allowed after 30 worked days
|
||||
let acquiredDays = 3;
|
||||
let acquired_days = 3;
|
||||
|
||||
//identify the date of the 30th worked day
|
||||
const orderedDates = Array.from(workedDates).sort();
|
||||
const thresholdDate = new Date(orderedDates[29]); // index 29 is the 30th day
|
||||
const ordered_dates = Array.from(worked_dates).sort();
|
||||
const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day
|
||||
|
||||
//calculate each completed month, starting the 1st of the next month
|
||||
const firstBonusDate = new Date(thresholdDate.getFullYear(), thresholdDate.getMonth() +1, 1);
|
||||
let months = (periodEnd.getFullYear() - firstBonusDate.getFullYear()) * 12 +
|
||||
(periodEnd.getMonth() - firstBonusDate.getMonth()) + 1;
|
||||
const first_bonus_date = new Date(threshold_date.getFullYear(), threshold_date.getMonth() +1, 1);
|
||||
let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
|
||||
(period_end.getMonth() - first_bonus_date.getMonth()) + 1;
|
||||
if(months < 0) months = 0;
|
||||
acquiredDays += months;
|
||||
acquired_days += months;
|
||||
|
||||
//cap of 10 days
|
||||
if (acquiredDays > 10) acquiredDays = 10;
|
||||
if (acquired_days > 10) acquired_days = 10;
|
||||
|
||||
this.logger.debug(`Sick leave: threshold Date = ${thresholdDate.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquiredDays}`);
|
||||
this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()}
|
||||
, bonusMonths = ${months}, acquired Days = ${acquired_days}`);
|
||||
|
||||
const payableDays = Math.min(acquiredDays, daysRequested);
|
||||
const rawHours = payableDays * 8 * modifier;
|
||||
const rounded = roundToQuarterHour(rawHours)
|
||||
this.logger.debug(`Sick leave pay: days= ${payableDays}, modifier= ${modifier}, hours= ${rounded}`);
|
||||
const payable_days = Math.min(acquired_days, days_requested);
|
||||
const raw_hours = payable_days * 8 * modifier;
|
||||
const rounded = roundToQuarterHour(raw_hours)
|
||||
this.logger.debug(`Sick leave pay: days= ${payable_days}, modifier= ${modifier}, hours= ${rounded}`);
|
||||
return rounded;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,76 +9,76 @@ export class VacationService {
|
|||
/**
|
||||
* Calculate the ammount allowed for vacation days.
|
||||
*
|
||||
* @param employeeId employee ID
|
||||
* @param employee_id employee ID
|
||||
* @param startDate first day of vacation
|
||||
* @param daysRequested number of days requested
|
||||
* @param modifier Coefficient of hours(1)
|
||||
* @returns amount of payable hours
|
||||
*/
|
||||
//switch employeeId for email
|
||||
async calculateVacationPay(employeeId: number, startDate: Date, daysRequested: number, modifier: number): Promise<number> {
|
||||
async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> {
|
||||
//fetch hiring date
|
||||
const employee = await this.prisma.employees.findUnique({
|
||||
where: { id: employeeId },
|
||||
where: { id: employee_id },
|
||||
select: { first_work_day: true },
|
||||
});
|
||||
if(!employee) {
|
||||
throw new NotFoundException(`Employee #${employeeId} not found`);
|
||||
throw new NotFoundException(`Employee #${employee_id} not found`);
|
||||
}
|
||||
const hireDate = employee.first_work_day;
|
||||
const hire_date = employee.first_work_day;
|
||||
|
||||
//sets "year" to may 1st to april 30th
|
||||
//check if hiring date is in may or later, we use hiring year, otherwise we use the year before
|
||||
const yearOfRequest = startDate.getMonth() >= 4
|
||||
? startDate.getFullYear() : startDate.getFullYear() -1;
|
||||
const periodStart = new Date(yearOfRequest, 4, 1); //may = 4
|
||||
const periodEnd = new Date(yearOfRequest + 1, 4, 0); //day 0 of may == april 30th
|
||||
const year_of_request = start_date.getMonth() >= 4
|
||||
? start_date.getFullYear() : start_date.getFullYear() -1;
|
||||
const period_start = new Date(year_of_request, 4, 1); //may = 4
|
||||
const period_end = new Date(year_of_request + 1, 4, 0); //day 0 of may == april 30th
|
||||
|
||||
this.logger.debug(`Vacation period for request: ${periodStart.toDateString()} -> ${periodEnd.toDateString()}`);
|
||||
this.logger.debug(`Vacation period for request: ${period_start.toDateString()} -> ${period_end.toDateString()}`);
|
||||
|
||||
//steps to reach to get more vacation weeks in years
|
||||
const checkpoint = [5, 10, 15];
|
||||
const anniversaries = checkpoint.map(years => {
|
||||
const anniversaryDate = new Date(hireDate);
|
||||
anniversaryDate.setFullYear(anniversaryDate.getFullYear() + years);
|
||||
return anniversaryDate;
|
||||
}).filter(d => d>= periodStart && d <= periodEnd).sort((a,b) => a.getTime() - b.getTime());
|
||||
const anniversary_date = new Date(hire_date);
|
||||
anniversary_date.setFullYear(anniversary_date.getFullYear() + years);
|
||||
return anniversary_date;
|
||||
}).filter(d => d>= period_start && d <= period_end).sort((a,b) => a.getTime() - b.getTime());
|
||||
|
||||
this.logger.debug(`anniversatries steps during the period: ${anniversaries.map(date => date.toDateString()).join(',') || 'aucun'}`);
|
||||
|
||||
const boundaries = [periodStart, ...anniversaries,periodEnd];
|
||||
const boundaries = [period_start, ...anniversaries,period_end];
|
||||
//calculate prorata per segment
|
||||
let totalVacationDays = 0;
|
||||
const msPerDay = 1000 * 60 * 60 * 24;
|
||||
let total_vacation_days = 0;
|
||||
const ms_per_day = 1000 * 60 * 60 * 24;
|
||||
|
||||
for (let i = 0; i < boundaries.length -1; i++) {
|
||||
const segmentStart = boundaries[i];
|
||||
const segmentEnd = boundaries[i+1];
|
||||
const segment_start = boundaries[i];
|
||||
const segment_end = boundaries[i+1];
|
||||
|
||||
//number of days in said segment
|
||||
const daysInSegment = Math.round((segmentEnd.getTime() - segmentStart.getTime())/ msPerDay);
|
||||
const yearsSinceHire = (segmentStart.getFullYear() - hireDate.getFullYear()) -
|
||||
(segmentStart < new Date(segmentStart.getFullYear(), hireDate.getMonth()) ? 1 : 0);
|
||||
let allocDays: number;
|
||||
if(yearsSinceHire < 5) allocDays = 10;
|
||||
else if(yearsSinceHire < 10) allocDays = 15;
|
||||
else if(yearsSinceHire < 15) allocDays = 20;
|
||||
else allocDays = 25;
|
||||
const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day);
|
||||
const years_since_hire = (segment_start.getFullYear() - hire_date.getFullYear()) -
|
||||
(segment_start < new Date(segment_start.getFullYear(), hire_date.getMonth()) ? 1 : 0);
|
||||
let alloc_days: number;
|
||||
if(years_since_hire < 5) alloc_days = 10;
|
||||
else if(years_since_hire < 10) alloc_days = 15;
|
||||
else if(years_since_hire < 15) alloc_days = 20;
|
||||
else alloc_days = 25;
|
||||
|
||||
//prorata for said segment
|
||||
const prorata = (allocDays / 365) * daysInSegment;
|
||||
totalVacationDays += prorata;
|
||||
const prorata = (alloc_days / 365) * days_in_segment;
|
||||
total_vacation_days += prorata;
|
||||
}
|
||||
//compares allowed vacation pools with requested days
|
||||
const payableDays = Math.min(totalVacationDays, daysRequested);
|
||||
const payable_days = Math.min(total_vacation_days, days_requested);
|
||||
|
||||
|
||||
const rawHours = payableDays * 8 * modifier;
|
||||
const roundedHours = Math.round(rawHours * 4) / 4;
|
||||
this.logger.debug(`Vacation pay: entitledDays=${totalVacationDays.toFixed(2)}, requestedDays=${daysRequested},
|
||||
payableDays=${payableDays.toFixed(2)}, hours=${roundedHours}`);
|
||||
const raw_hours = payable_days * 8 * modifier;
|
||||
const rounded_hours = Math.round(raw_hours * 4) / 4;
|
||||
this.logger.debug(`Vacation pay: entitledDays=${total_vacation_days.toFixed(2)}, requestedDays=${days_requested},
|
||||
payableDays=${payable_days.toFixed(2)}, hours=${rounded_hours}`);
|
||||
|
||||
return roundedHours;
|
||||
return rounded_hours;
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ export class CustomersService {
|
|||
invoice_id,
|
||||
} = dto;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const user: Users = await tx.users.create({
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const user: Users = await transaction.users.create({
|
||||
data: {
|
||||
first_name,
|
||||
last_name,
|
||||
|
|
@ -28,7 +28,7 @@ export class CustomersService {
|
|||
residence,
|
||||
},
|
||||
});
|
||||
return tx.customers.create({
|
||||
return transaction.customers.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
invoice_id,
|
||||
|
|
@ -69,8 +69,8 @@ async update(
|
|||
invoice_id,
|
||||
} = dto;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.users.update({
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
await transaction.users.update({
|
||||
where: { id: customer.user_id },
|
||||
data: {
|
||||
...(first_name !== undefined && { first_name }),
|
||||
|
|
@ -81,7 +81,7 @@ async update(
|
|||
},
|
||||
});
|
||||
|
||||
return tx.customers.update({
|
||||
return transaction.customers.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(invoice_id !== undefined && { invoice_id }),
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto';
|
|||
|
||||
function toDateOrNull(v?: string | null): Date | null {
|
||||
if (!v) return null;
|
||||
const d = new Date(v);
|
||||
return isNaN(d.getTime()) ? null : d;
|
||||
const day = new Date(v);
|
||||
return isNaN(day.getTime()) ? null : day;
|
||||
}
|
||||
function toDateOrUndefined(v?: string | null): Date | undefined {
|
||||
const d = toDateOrNull(v ?? undefined);
|
||||
return d === null ? undefined : d;
|
||||
const day = toDateOrNull(v ?? undefined);
|
||||
return day === null ? undefined : day;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -35,8 +35,8 @@ export class EmployeesService {
|
|||
is_supervisor,
|
||||
} = dto;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const user: Users = await tx.users.create({
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
const user: Users = await transaction.users.create({
|
||||
data: {
|
||||
first_name,
|
||||
last_name,
|
||||
|
|
@ -45,7 +45,7 @@ export class EmployeesService {
|
|||
residence,
|
||||
},
|
||||
});
|
||||
return tx.employees.create({
|
||||
return transaction.employees.create({
|
||||
data: {
|
||||
user_id: user.id,
|
||||
external_payroll_id,
|
||||
|
|
@ -176,18 +176,18 @@ export class EmployeesService {
|
|||
first_work_day,
|
||||
last_work_day,
|
||||
is_supervisor,
|
||||
email: newEmail,
|
||||
email: new_email,
|
||||
} = dto;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
if(
|
||||
first_name !== undefined ||
|
||||
last_name !== undefined ||
|
||||
newEmail !== undefined ||
|
||||
phone_number !== undefined ||
|
||||
residence !== undefined
|
||||
first_name !== undefined ||
|
||||
last_name !== undefined ||
|
||||
new_email !== undefined ||
|
||||
phone_number !== undefined ||
|
||||
residence !== undefined
|
||||
){
|
||||
await tx.users.update({
|
||||
await transaction.users.update({
|
||||
where: { id: emp.user_id },
|
||||
data: {
|
||||
...(first_name !== undefined && { first_name }),
|
||||
|
|
@ -199,7 +199,7 @@ export class EmployeesService {
|
|||
});
|
||||
}
|
||||
|
||||
const updated = await tx.employees.update({
|
||||
const updated = await transaction.employees.update({
|
||||
where: { id: emp.id },
|
||||
data: {
|
||||
...(external_payroll_id !== undefined && { external_payroll_id }),
|
||||
|
|
@ -219,18 +219,18 @@ export class EmployeesService {
|
|||
|
||||
const emp = await this.findOne(email);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.employees.updateMany({
|
||||
return this.prisma.$transaction(async (transaction) => {
|
||||
await transaction.employees.updateMany({
|
||||
where: { supervisor_id: emp.id },
|
||||
data: { supervisor_id: null },
|
||||
});
|
||||
const deletedEmployee = await tx.employees.delete({
|
||||
const deleted_employee = await transaction.employees.delete({
|
||||
where: {id: emp.id },
|
||||
});
|
||||
await tx.users.delete({
|
||||
await transaction.users.delete({
|
||||
where: { id: emp.user_id },
|
||||
});
|
||||
return deletedEmployee;
|
||||
return deleted_employee;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -254,7 +254,7 @@ async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees |
|
|||
const {
|
||||
first_name,
|
||||
last_name,
|
||||
email: newEmail,
|
||||
email: new_email,
|
||||
phone_number,
|
||||
residence,
|
||||
external_payroll_id,
|
||||
|
|
@ -271,28 +271,28 @@ async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees |
|
|||
? toDateOrNull(last_work_day ?? null)
|
||||
: undefined;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await this.prisma.$transaction(async (transaction) => {
|
||||
if(
|
||||
first_name !== undefined ||
|
||||
last_name !== undefined ||
|
||||
newEmail !== undefined ||
|
||||
new_email !== undefined ||
|
||||
phone_number !== undefined ||
|
||||
residence !== undefined
|
||||
) {
|
||||
await tx.users.update({
|
||||
await transaction.users.update({
|
||||
where: { id: active.user_id },
|
||||
data: {
|
||||
...(first_name !== undefined ? { first_name } : {}),
|
||||
...(last_name !== undefined ? { last_name } : {}),
|
||||
...(email !== undefined ? { email: newEmail }: {}),
|
||||
...(phone_number !== undefined ? { phone_number } : {}),
|
||||
...(residence !== undefined ? { residence } : {}),
|
||||
...(first_name !== undefined ? { first_name } : {}),
|
||||
...(last_name !== undefined ? { last_name } : {}),
|
||||
...(email !== undefined ? { email: new_email }: {}),
|
||||
...(phone_number !== undefined ? { phone_number } : {}),
|
||||
...(residence !== undefined ? { residence } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
const updated = await tx.employees.update({
|
||||
const updated = await transaction.employees.update({
|
||||
where: { id: active.id },
|
||||
data: {
|
||||
...(external_payroll_id !== undefined ? { external_payroll_id } : {}),
|
||||
|
|
@ -366,7 +366,7 @@ async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise<Employees |
|
|||
|
||||
//transfers the employee from archive to the employees table
|
||||
private async restoreEmployee(archived: EmployeesArchive & { user:Users }, dto: UpdateEmployeeDto): Promise<Employees> {
|
||||
const first_work_d = toDateOrUndefined(dto.first_work_day);
|
||||
// const first_work_d = toDateOrUndefined(dto.first_work_day);
|
||||
return this.prisma.$transaction(async transaction => {
|
||||
//restores the archived employee into the employees table
|
||||
const restored = await transaction.employees.create({
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
|
|||
|
||||
async updateApproval(id: number, isApproved: boolean): Promise<Expenses> {
|
||||
return this.prisma.$transaction((transaction) =>
|
||||
this.updateApprovalWithTx(transaction, id, isApproved),
|
||||
this.updateApprovalWithTransaction(transaction, id, isApproved),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,24 +20,24 @@ export class ExpensesQueryService {
|
|||
|
||||
|
||||
//fetches type and modifier
|
||||
const bankCode = await this.prisma.bankCodes.findUnique({
|
||||
const bank_code = await this.prisma.bankCodes.findUnique({
|
||||
where: { id: bank_code_id },
|
||||
select: { type: true, modifier: true },
|
||||
});
|
||||
if(!bankCode) {
|
||||
if(!bank_code) {
|
||||
throw new NotFoundException(`bank_code #${bank_code_id} not found`)
|
||||
}
|
||||
|
||||
//if mileage -> service, otherwise the ratio is amount:1
|
||||
let finalAmount: number;
|
||||
if(bankCode.type === 'mileage') {
|
||||
finalAmount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
|
||||
let final_amount: number;
|
||||
if(bank_code.type === 'mileage') {
|
||||
final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
|
||||
}else {
|
||||
finalAmount = parseFloat( (rawAmount * bankCode.modifier).toFixed(2));
|
||||
final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
|
||||
}
|
||||
|
||||
return this.prisma.expenses.create({
|
||||
data: { timesheet_id, bank_code_id, date, amount: finalAmount, description, is_approved, supervisor_comment},
|
||||
data: { timesheet_id, bank_code_id, date, amount: final_amount, description, is_approved, supervisor_comment},
|
||||
include: { timesheet: { include: { employee: { include: { user: true }}}},
|
||||
bank_code: true,
|
||||
},
|
||||
|
|
@ -94,28 +94,28 @@ export class ExpensesQueryService {
|
|||
|
||||
async archiveOld(): Promise<void> {
|
||||
//fetches archived timesheet's Ids
|
||||
const archivedTimesheets = await this.prisma.timesheetsArchive.findMany({
|
||||
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
|
||||
select: { timesheet_id: true },
|
||||
});
|
||||
|
||||
const timesheetIds = archivedTimesheets.map(sheet => sheet.timesheet_id);
|
||||
if(timesheetIds.length === 0) {
|
||||
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
|
||||
if(timesheet_ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// copy/delete transaction
|
||||
await this.prisma.$transaction(async transaction => {
|
||||
//fetches expenses to move to archive
|
||||
const expensesToArchive = await transaction.expenses.findMany({
|
||||
where: { timesheet_id: { in: timesheetIds } },
|
||||
const expenses_to_archive = await transaction.expenses.findMany({
|
||||
where: { timesheet_id: { in: timesheet_ids } },
|
||||
});
|
||||
if(expensesToArchive.length === 0) {
|
||||
if(expenses_to_archive.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
//copies sent to archive table
|
||||
await transaction.expensesArchive.createMany({
|
||||
data: expensesToArchive.map(exp => ({
|
||||
data: expenses_to_archive.map(exp => ({
|
||||
expense_id: exp.id,
|
||||
timesheet_id: exp.timesheet_id,
|
||||
bank_code_id: exp.bank_code_id,
|
||||
|
|
@ -130,7 +130,7 @@ export class ExpensesQueryService {
|
|||
|
||||
//delete from expenses table
|
||||
await transaction.expenses.deleteMany({
|
||||
where: { id: { in: expensesToArchive.map(exp => exp.id) } },
|
||||
where: { id: { in: expenses_to_archive.map(exp => exp.id) } },
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ export class LeaveRequestController {
|
|||
|
||||
@Patch(':id/approval')
|
||||
updateApproval( @Param('id', ParseIntPipe) id: number,
|
||||
@Body('is_approved', ParseBoolPipe) isApproved: boolean): Promise<LeaveRequests> {
|
||||
const approvalStatus = isApproved ?
|
||||
@Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise<LeaveRequests> {
|
||||
const approvalStatus = is_approved ?
|
||||
LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED;
|
||||
return this.leaveRequetsService.update(id, { approval_status: approvalStatus });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ export class LeaveRequestsService {
|
|||
}
|
||||
|
||||
async findAll(filters: SearchLeaveRequestsDto): Promise<any[]> {
|
||||
const {start_date, end_date, ...otherFilters } = filters;
|
||||
const where: Record<string, any> = buildPrismaWhere(otherFilters);
|
||||
const {start_date, end_date, ...other_filters } = filters;
|
||||
const where: Record<string, any> = buildPrismaWhere(other_filters);
|
||||
|
||||
if (start_date) {
|
||||
where.start_date_time = { ...(where.start_date_time ?? {}), gte: new Date(start_date) };
|
||||
|
|
@ -50,15 +50,15 @@ export class LeaveRequestsService {
|
|||
},
|
||||
});
|
||||
|
||||
const msPerDay = 1000 * 60 * 60 * 24;
|
||||
const ms_per_day = 1000 * 60 * 60 * 24;
|
||||
|
||||
return Promise.all(
|
||||
list.map(async request => {
|
||||
// end_date fallback
|
||||
const endDate = request.end_date_time ?? request.start_date_time;
|
||||
const end_date = request.end_date_time ?? request.start_date_time;
|
||||
|
||||
//Requested days
|
||||
const diffDays = Math.round((endDate.getTime() - request.start_date_time.getTime()) / msPerDay) +1;
|
||||
const diff_days = Math.round((end_date.getTime() - request.start_date_time.getTime()) / ms_per_day) +1;
|
||||
|
||||
// modifier fallback/validation
|
||||
if (!request.bank_code || request.bank_code.modifier == null) {
|
||||
|
|
@ -72,15 +72,15 @@ export class LeaveRequestsService {
|
|||
cost = await this.holidayService.calculateHolidayPay( request.employee_id, request.start_date_time, modifier );
|
||||
break;
|
||||
case 'vacation' :
|
||||
cost = await this.vacationService.calculateVacationPay( request.employee_id, request.start_date_time,diffDays, modifier );
|
||||
cost = await this.vacationService.calculateVacationPay( request.employee_id, request.start_date_time,diff_days, modifier );
|
||||
break;
|
||||
case 'sick' :
|
||||
cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diffDays, modifier );
|
||||
cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diff_days, modifier );
|
||||
break;
|
||||
default:
|
||||
cost = diffDays * modifier;
|
||||
cost = diff_days * modifier;
|
||||
}
|
||||
return {...request, daysRequested: diffDays, cost };
|
||||
return {...request, days_requested: diff_days, cost };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -96,11 +96,11 @@ export class LeaveRequestsService {
|
|||
throw new NotFoundException(`LeaveRequest #${id} not found`);
|
||||
}
|
||||
//validation and fallback for end_date_time
|
||||
const endDate = request.end_date_time ?? request.start_date_time;
|
||||
const end_Date = request.end_date_time ?? request.start_date_time;
|
||||
|
||||
//calculate included days
|
||||
const msPerDay = 1000 * 60 * 60 * 24;
|
||||
const diffDays = Math.floor((endDate.getTime() - request.start_date_time.getTime())/ msPerDay) + 1;
|
||||
const diff_days = Math.floor((end_Date.getTime() - request.start_date_time.getTime())/ msPerDay) + 1;
|
||||
|
||||
if (!request.bank_code || request.bank_code.modifier == null) {
|
||||
throw new BadRequestException(`Modifier missing for code ${request.bank_code_id}`);
|
||||
|
|
@ -108,27 +108,24 @@ export class LeaveRequestsService {
|
|||
const modifier = request.bank_code.modifier;
|
||||
|
||||
//calculate cost based on bank_code types
|
||||
let cost = diffDays * modifier;
|
||||
let cost = diff_days * modifier;
|
||||
switch(request.bank_code.type) {
|
||||
case 'holiday':
|
||||
cost = await this.holidayService.calculateHolidayPay( request.employee_id, request.start_date_time, modifier );
|
||||
break;
|
||||
case 'vacation':
|
||||
cost = await this.vacationService.calculateVacationPay( request.employee_id, request.start_date_time, diffDays, modifier );
|
||||
cost = await this.vacationService.calculateVacationPay( request.employee_id, request.start_date_time, diff_days, modifier );
|
||||
break;
|
||||
case 'sick':
|
||||
cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diffDays, modifier );
|
||||
cost = await this.sickLeaveService.calculateSickLeavePay( request.employee_id, request.start_date_time, diff_days, modifier );
|
||||
break;
|
||||
default:
|
||||
cost = diffDays * modifier;
|
||||
cost = diff_days * modifier;
|
||||
}
|
||||
return {...request, daysRequested: diffDays, cost };
|
||||
return {...request, days_requested: diff_days, cost };
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
dto: UpdateLeaveRequestsDto,
|
||||
): Promise<LeaveRequests> {
|
||||
async update(id: number, dto: UpdateLeaveRequestsDto): Promise<LeaveRequests> {
|
||||
await this.findOne(id);
|
||||
const { employee_id, leave_type, start_date_time, end_date_time, comment, approval_status } = dto;
|
||||
return this.prisma.leaveRequests.update({
|
||||
|
|
|
|||
|
|
@ -11,47 +11,47 @@ export class NotificationsService {
|
|||
private buffers = new Map<string, NotificationCard[]>();
|
||||
private readonly BUFFER_MAX = Number(process.env.NOTIF_BUFFER_MAX ?? 50);
|
||||
|
||||
private getOrCreateStream(userId: string): Subject<NotificationCard> {
|
||||
let stream = this.streams.get(userId);
|
||||
private getOrCreateStream(user_id: string): Subject<NotificationCard> {
|
||||
let stream = this.streams.get(user_id);
|
||||
if (!stream){
|
||||
stream = new Subject<NotificationCard>();
|
||||
this.streams.set(userId, stream);
|
||||
this.streams.set(user_id, stream);
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
private getOrCreateBuffer(userId: string){
|
||||
let buffer = this.buffers.get(userId);
|
||||
private getOrCreateBuffer(user_id: string){
|
||||
let buffer = this.buffers.get(user_id);
|
||||
if(!buffer) {
|
||||
buffer = [];
|
||||
this.buffers.set(userId, buffer);
|
||||
this.buffers.set(user_id, buffer);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
//in-app pushes and keep a small history
|
||||
notify(userId: string, card: NotificationCard) {
|
||||
const buffer = this.getOrCreateBuffer(userId);
|
||||
notify(user_id: string, card: NotificationCard) {
|
||||
const buffer = this.getOrCreateBuffer(user_id);
|
||||
buffer.unshift(card);
|
||||
if (buffer.length > this.BUFFER_MAX) {
|
||||
buffer.length = this.BUFFER_MAX;
|
||||
}
|
||||
this.getOrCreateStream(userId).next(card);
|
||||
this.logger.debug(`Notification in-app => user: ${userId} (${card.type})`);
|
||||
this.getOrCreateStream(user_id).next(card);
|
||||
this.logger.debug(`Notification in-app => user: ${user_id} (${card.type})`);
|
||||
}
|
||||
|
||||
//SSE flux for current user
|
||||
stream(userId: string) {
|
||||
return this.getOrCreateStream(userId).asObservable();
|
||||
stream(user_id: string) {
|
||||
return this.getOrCreateStream(user_id).asObservable();
|
||||
}
|
||||
|
||||
//return a summary of notifications kept in memory
|
||||
async summary(userId: string): Promise<NotificationCard[]> {
|
||||
return this.getOrCreateBuffer(userId);
|
||||
async summary(user_id: string): Promise<NotificationCard[]> {
|
||||
return this.getOrCreateBuffer(user_id);
|
||||
}
|
||||
|
||||
//clear buffers from memory
|
||||
clear(userId: string) {
|
||||
this.buffers.set(userId, []);
|
||||
clear(user_id: string) {
|
||||
this.buffers.set(user_id, []);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Query } from "@nestjs/common";
|
||||
import { ApiNotFoundResponse, ApiOperation, ApiParam, ApiQuery, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { PayPeriodDto } from "../dtos/pay-period.dto";
|
||||
import { PayPeriodOverviewDto } from "../dtos/overview-pay-period.dto";
|
||||
|
|
@ -7,6 +7,7 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
|||
import { Roles as RoleEnum } from '.prisma/client';
|
||||
import { PayPeriodsCommandService } from "../services/pay-periods-command.service";
|
||||
import { PayPeriodBundleDto } from "../dtos/bundle-pay-period.dto";
|
||||
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
|
||||
|
||||
@ApiTags('pay-periods')
|
||||
@Controller('pay-periods')
|
||||
|
|
@ -57,18 +58,12 @@ export class PayPeriodsController {
|
|||
return this.queryService.findOneByYearPeriod(year, period_no);
|
||||
}
|
||||
|
||||
@Patch("approval/:year/:periodNumber")
|
||||
@Patch("crew/bulk-approval")
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({ summary: "Approve all timesheets with activity in the period" })
|
||||
@ApiParam({ name: "year", type: Number, example: 2024 })
|
||||
@ApiParam({ name: "periodNumber", type: Number, example: 1, description: "1..26" })
|
||||
@ApiOperation({ summary: "Approve all selected timesheets in the period" })
|
||||
@ApiResponse({ status: 200, description: "Pay period approved" })
|
||||
async approve(
|
||||
@Param("year", ParseIntPipe) year: number,
|
||||
@Param("periodNumber", ParseIntPipe) period_no: number,
|
||||
) {
|
||||
await this.commandService.approvalPayPeriod(year, period_no);
|
||||
return { message: `Pay-period ${year}-${period_no} approved` };
|
||||
async bulkApproval(@Body() dto: BulkCrewApprovalDto) {
|
||||
return this.commandService.bulkApproveCrew(dto);
|
||||
}
|
||||
|
||||
@Get(':year/:periodNumber/:email')
|
||||
|
|
|
|||
29
src/modules/pay-periods/dtos/bulk-crew-approval.dto.ts
Normal file
29
src/modules/pay-periods/dtos/bulk-crew-approval.dto.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsBoolean, IsEmail, IsInt, IsOptional, ValidateNested } from "class-validator";
|
||||
|
||||
export class BulkCrewApprovalItemDto {
|
||||
@IsInt()
|
||||
pay_year: number;
|
||||
|
||||
@IsInt()
|
||||
period_no: number;
|
||||
|
||||
@IsEmail()
|
||||
employee_email!: string;
|
||||
|
||||
@IsBoolean()
|
||||
approve: boolean;
|
||||
}
|
||||
|
||||
export class BulkCrewApprovalDto {
|
||||
@IsEmail()
|
||||
supervisor_email: string;
|
||||
|
||||
@IsBoolean()
|
||||
include_subtree: boolean = false;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({each: true})
|
||||
@Type(()=> BulkCrewApprovalItemDto)
|
||||
items: BulkCrewApprovalItemDto[]
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Allow, IsOptional } from 'class-validator';
|
||||
|
||||
export class EmployeePeriodOverviewDto {
|
||||
// @ApiProperty({
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { ShiftsCommandService } from "../shifts/services/shifts-command.service"
|
|||
@Module({
|
||||
imports: [PrismaModule, TimesheetsModule],
|
||||
providers: [
|
||||
PayPeriodsQueryService,
|
||||
PayPeriodsQueryService,
|
||||
PayPeriodsCommandService,
|
||||
TimesheetsCommandService,
|
||||
|
|
|
|||
|
|
@ -1,44 +1,105 @@
|
|||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { TimesheetsCommandService } from "src/modules/timesheets/services/timesheets-command.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
|
||||
import { PayPeriodsQueryService } from "./pay-periods-query.service";
|
||||
|
||||
@Injectable()
|
||||
export class PayPeriodsCommandService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly timesheets_approval: TimesheetsCommandService,
|
||||
private readonly query: PayPeriodsQueryService,
|
||||
) {}
|
||||
|
||||
async approvalPayPeriod(pay_year: number , period_no: number): Promise<void> {
|
||||
const period = await this.prisma.payPeriods.findFirst({
|
||||
where: { pay_year, pay_period_no: period_no},
|
||||
});
|
||||
if (!period) throw new NotFoundException(`PayPeriod #${pay_year}-${period_no} not found`);
|
||||
//function to approve pay-periods according to selected crew members
|
||||
async bulkApproveCrew(dto:BulkCrewApprovalDto): Promise<{updated: number}> {
|
||||
const { supervisor_email, include_subtree, items } = dto;
|
||||
if(!items?.length) throw new BadRequestException('no items to process');
|
||||
|
||||
//fetches timesheet of selected period if the timesheet has atleast 1 shift or 1 expense
|
||||
const timesheet_ist = await this.prisma.timesheets.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ shift: {some: { date: { gte: period.period_start,
|
||||
lte: period.period_end,
|
||||
},
|
||||
}},
|
||||
},
|
||||
{ expense: { some: { date: { gte: period.period_start,
|
||||
lte: period.period_end,
|
||||
},
|
||||
}},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
//fetch and validate supervisor status
|
||||
const supervisor = await this.query.getSupervisor(supervisor_email);
|
||||
if(!supervisor) throw new NotFoundException('No employee record linked to current user');
|
||||
if(!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor');
|
||||
|
||||
//approval of both timesheet (cascading to the approval of related shifts and expenses)
|
||||
await this.prisma.$transaction(async (transaction)=> {
|
||||
for(const {id} of timesheet_ist) {
|
||||
await this.timesheets_approval.updateApprovalWithTx(transaction,id, true);
|
||||
//fetches emails of crew members linked to supervisor
|
||||
const crew_emails = await this.query.resolveCrewEmails(supervisor.id, include_subtree);
|
||||
|
||||
|
||||
for(const item of items) {
|
||||
if(!crew_emails.has(item.employee_email)) {
|
||||
throw new ForbiddenException(`Employee ${item.employee_email} not in supervisor crew`);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const period_cache = new Map<string, {period_start: Date, period_end: Date}>();
|
||||
const getPeriod = async (y:number, no: number) => {
|
||||
const key = `${y}-${no}`;
|
||||
if(!period_cache.has(key)) return period_cache.get(key)!;
|
||||
const period = await this.query.getPeriodWindow(y,no);
|
||||
if(!period) throw new NotFoundException(`Pay period ${y}-${no} not found`);
|
||||
period_cache.set(key, period);
|
||||
return period;
|
||||
};
|
||||
|
||||
let updated = 0;
|
||||
|
||||
await this.prisma.$transaction(async (transaction) => {
|
||||
for(const item of items) {
|
||||
const { period_start, period_end } = await getPeriod(item.pay_year, item.period_no);
|
||||
|
||||
const t_sheets = await transaction.timesheets.findMany({
|
||||
where: {
|
||||
employee: { user: { email: item.employee_email } },
|
||||
OR: [
|
||||
{shift : { some: { date: { gte: period_start, lte: period_end } } } },
|
||||
{expense: { some: { date: { gte: period_start, lte: period_end } } } },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for(const { id } of t_sheets) {
|
||||
await this.timesheets_approval.updateApprovalWithTransaction(transaction, id, item.approve);
|
||||
updated++;
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
return {updated};
|
||||
}
|
||||
|
||||
//function to approve a single pay-period of a single employee (deprecated)
|
||||
// async approvalPayPeriod(pay_year: number , period_no: number): Promise<void> {
|
||||
// const period = await this.prisma.payPeriods.findFirst({
|
||||
// where: { pay_year, pay_period_no: period_no},
|
||||
// });
|
||||
// if (!period) throw new NotFoundException(`PayPeriod #${pay_year}-${period_no} not found`);
|
||||
|
||||
// //fetches timesheet of selected period if the timesheet has atleast 1 shift or 1 expense
|
||||
// const timesheet_ist = await this.prisma.timesheets.findMany({
|
||||
// where: {
|
||||
// OR: [
|
||||
// { shift: {some: { date: { gte: period.period_start,
|
||||
// lte: period.period_end,
|
||||
// },
|
||||
// }},
|
||||
// },
|
||||
// { expense: { some: { date: { gte: period.period_start,
|
||||
// lte: period.period_end,
|
||||
// },
|
||||
// }},
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// select: { id: true },
|
||||
// });
|
||||
|
||||
// //approval of both timesheet (cascading to the approval of related shifts and expenses)
|
||||
// await this.prisma.$transaction(async (transaction)=> {
|
||||
// for(const {id} of timesheet_ist) {
|
||||
// await this.timesheets_approval.updateApprovalWithTransaction(transaction,id, true);
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
}
|
||||
|
|
@ -11,18 +11,6 @@ import { mapPayPeriodToDto } from "../mappers/pay-periods.mapper";
|
|||
export class PayPeriodsQueryService {
|
||||
constructor( private readonly prisma: PrismaService) {}
|
||||
|
||||
async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodOverviewDto> {
|
||||
const period = computePeriod(pay_year, period_no);
|
||||
return 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,
|
||||
} as any);
|
||||
}
|
||||
|
||||
async getOverview(pay_period_no: number): Promise<PayPeriodOverviewDto> {
|
||||
const period = await this.prisma.payPeriods.findFirst({
|
||||
where: { pay_period_no },
|
||||
|
|
@ -40,6 +28,87 @@ export class PayPeriodsQueryService {
|
|||
});
|
||||
}
|
||||
|
||||
async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodOverviewDto> {
|
||||
const period = computePeriod(pay_year, period_no);
|
||||
return 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,
|
||||
} as any);
|
||||
}
|
||||
|
||||
private async resolveCrew(supervisor_id: number, include_subtree: boolean):
|
||||
Promise<Array<{ id: number; first_name: string; last_name: string; email: 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 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 result;
|
||||
}
|
||||
|
||||
async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise<Set<string>> {
|
||||
const crew = await this.resolveCrew(supervisor_id, include_subtree);
|
||||
return new Set(crew.map(crew_member => crew_member.email).filter(Boolean));
|
||||
}
|
||||
|
||||
async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean):
|
||||
Promise<PayPeriodOverviewDto> {
|
||||
// 1) Search for the period
|
||||
const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } });
|
||||
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`);
|
||||
|
||||
// 2) fetch supervisor
|
||||
const supervisor = await this.prisma.employees.findFirst({
|
||||
where: { user: { email: email }},
|
||||
select: {
|
||||
id: true,
|
||||
is_supervisor: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!supervisor) throw new NotFoundException('No employee record linked to current user');
|
||||
if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor');
|
||||
|
||||
// 3)fetchs crew members
|
||||
const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }]
|
||||
const crew_ids = crew.map(c => c.id);
|
||||
// seed names map for employee without data
|
||||
const seed_names = new Map<number, string>(crew.map(crew => [crew.id, `${crew.first_name} ${crew.last_name}`.trim()]));
|
||||
|
||||
// 4) overview build
|
||||
return 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 });
|
||||
}
|
||||
|
||||
private async buildOverview(
|
||||
period: { period_start: string | Date; period_end: string | Date; payday: string | Date;
|
||||
period_no: number; pay_year: number; label: string; },
|
||||
|
|
@ -130,10 +199,10 @@ export class PayPeriodsQueryService {
|
|||
}
|
||||
}
|
||||
|
||||
const ensure = (id: number, name: string) => {
|
||||
const ensure = (id: number, name: string, email: string) => {
|
||||
if (!by_employee.has(id)) {
|
||||
by_employee.set(id, {
|
||||
email: '',
|
||||
email,
|
||||
employee_name: name,
|
||||
regular_hours: 0,
|
||||
evening_hours: 0,
|
||||
|
|
@ -150,7 +219,7 @@ export class PayPeriodsQueryService {
|
|||
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);
|
||||
const record = ensure(employee.id, name, employee.user.email);
|
||||
|
||||
const hours = computeHours(shift.start_time, shift.end_time);
|
||||
const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase();
|
||||
|
|
@ -167,7 +236,7 @@ export class PayPeriodsQueryService {
|
|||
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);
|
||||
const record = ensure(exp.id, name, exp.user.email);
|
||||
|
||||
const amount = toMoney(expense.amount);
|
||||
record.expenses += amount;
|
||||
|
|
@ -195,135 +264,85 @@ export class PayPeriodsQueryService {
|
|||
};
|
||||
}
|
||||
|
||||
|
||||
async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean):
|
||||
Promise<PayPeriodOverviewDto> {
|
||||
// 1) Search for the period
|
||||
const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } });
|
||||
if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`);
|
||||
|
||||
// 2) fetch supervisor
|
||||
const supervisor = await this.prisma.employees.findFirst({
|
||||
where: { user: { email: email }},
|
||||
select: {
|
||||
id: true,
|
||||
is_supervisor: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!supervisor) throw new NotFoundException('No employee record linked to current user');
|
||||
if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor');
|
||||
|
||||
// 3)fetchs crew members
|
||||
const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }]
|
||||
const crew_ids = crew.map(c => c.id);
|
||||
// seed names map for employee without data
|
||||
const seed_names = new Map<number, string>(crew.map(crew => [crew.id, `${crew.first_name} ${crew.last_name}`.trim()]));
|
||||
|
||||
// 4) overview build
|
||||
return 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 });
|
||||
}
|
||||
|
||||
private async resolveCrew(supervisor_id: number, include_subtree: boolean):
|
||||
Promise<Array<{ id: number; first_name: string; last_name: string }>> {
|
||||
const result: Array<{ id: number; first_name: string; last_name: 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 })));
|
||||
|
||||
if (!include_subtree) return 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 } } },
|
||||
async getSupervisor(email:string) {
|
||||
return this.prisma.employees.findFirst({
|
||||
where: { user: { email } },
|
||||
select: { id: true, is_supervisor: 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 result;
|
||||
}
|
||||
|
||||
|
||||
async findAll(): Promise<PayPeriodDto[]> {
|
||||
const currentPayYear = payYearOfDate(new Date());
|
||||
return 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,
|
||||
}));
|
||||
const currentPayYear = payYearOfDate(new Date());
|
||||
return 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<PayPeriodDto> {
|
||||
const row = await this.prisma.payPeriods.findFirst({
|
||||
where: { pay_period_no: period_no },
|
||||
orderBy: { pay_year: "desc" },
|
||||
});
|
||||
if (!row) throw new NotFoundException(`Pay period #${period_no} not found`);
|
||||
return mapPayPeriodToDto(row);
|
||||
const row = await this.prisma.payPeriods.findFirst({
|
||||
where: { pay_period_no: period_no },
|
||||
orderBy: { pay_year: "desc" },
|
||||
});
|
||||
if (!row) throw new NotFoundException(`Pay period #${period_no} not found`);
|
||||
return mapPayPeriodToDto(row);
|
||||
}
|
||||
|
||||
async findCurrent(date?: string): Promise<PayPeriodDto> {
|
||||
const iso_day = date ?? new Date().toISOString().slice(0,10);
|
||||
return this.findByDate(iso_day);
|
||||
}
|
||||
|
||||
async findOneByYearPeriod(pay_year: number, period_no: number): Promise<PayPeriodDto> {
|
||||
const row = await this.prisma.payPeriods.findFirst({
|
||||
where: { pay_year, pay_period_no: period_no },
|
||||
});
|
||||
if(row) return mapPayPeriodToDto(row);
|
||||
const row = await this.prisma.payPeriods.findFirst({
|
||||
where: { pay_year, pay_period_no: period_no },
|
||||
});
|
||||
if(row) return mapPayPeriodToDto(row);
|
||||
|
||||
// fallback for outside of view periods
|
||||
const period = computePeriod(pay_year, period_no);
|
||||
return {
|
||||
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
|
||||
}
|
||||
// fallback for outside of view periods
|
||||
const period = computePeriod(pay_year, period_no);
|
||||
return {
|
||||
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<PayPeriodDto> {
|
||||
const dt = new Date(date);
|
||||
const row = await this.prisma.payPeriods.findFirst({
|
||||
where: { period_start: { lte: dt }, period_end: { gte: dt } },
|
||||
});
|
||||
if(row) return mapPayPeriodToDto(row);
|
||||
const dt = new Date(date);
|
||||
const row = await this.prisma.payPeriods.findFirst({
|
||||
where: { period_start: { lte: dt }, period_end: { gte: dt } },
|
||||
});
|
||||
if(row) return 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) throw new NotFoundException(`No period found for ${date}`);
|
||||
|
||||
return {
|
||||
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
|
||||
}
|
||||
//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) throw new NotFoundException(`No period found for ${date}`);
|
||||
|
||||
return {
|
||||
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 findCurrent(date?: string): Promise<PayPeriodDto> {
|
||||
const iso_day = date ?? new Date().toISOString().slice(0,10);
|
||||
return this.findByDate(iso_day);
|
||||
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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,14 +86,14 @@ export class ShiftsController {
|
|||
|
||||
//CSV Headers
|
||||
const header = [
|
||||
'fullName',
|
||||
'full_name',
|
||||
'supervisor',
|
||||
'totalRegularHrs',
|
||||
'totalEveningHrs',
|
||||
'totalOvertimeHrs',
|
||||
'totalExpenses',
|
||||
'totalMileage',
|
||||
'isValidated'
|
||||
'total_regular_hrs',
|
||||
'total_evening_hrs',
|
||||
'total_overtime_hrs',
|
||||
'total_expenses',
|
||||
'total_mileage',
|
||||
'is_validated'
|
||||
].join(',') + '\n';
|
||||
|
||||
//CSV rows
|
||||
|
|
@ -101,14 +101,14 @@ export class ShiftsController {
|
|||
const esc = (str: string) => `"${str.replace(/"/g, '""')}"`;
|
||||
|
||||
return [
|
||||
esc(r.fullName),
|
||||
esc(r.full_name),
|
||||
esc(r.supervisor),
|
||||
r.totalRegularHrs.toFixed(2),
|
||||
r.totalEveningHrs.toFixed(2),
|
||||
r.totalOvertimeHrs.toFixed(2),
|
||||
r.totalExpenses.toFixed(2),
|
||||
r.totalMileage.toFixed(2),
|
||||
r.isValidated,
|
||||
r.total_regular_hrs.toFixed(2),
|
||||
r.total_evening_hrs.toFixed(2),
|
||||
r.total_overtime_hrs.toFixed(2),
|
||||
r.total_expenses.toFixed(2),
|
||||
r.total_mileage.toFixed(2),
|
||||
r.is_validated,
|
||||
].join(',');
|
||||
}).join('\n');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { Allow, IsDate, IsDateString, IsInt, IsString } from "class-validator";
|
||||
import { Allow, IsDateString, IsInt, IsString } from "class-validator";
|
||||
|
||||
export class CreateShiftDto {
|
||||
@ApiProperty({
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
|
|||
return transaction.shifts;
|
||||
}
|
||||
|
||||
async updateApproval(id: number, isApproved: boolean): Promise<Shifts> {
|
||||
async updateApproval(id: number, is_approved: boolean): Promise<Shifts> {
|
||||
return this.prisma.$transaction((transaction) =>
|
||||
this.updateApprovalWithTx(transaction, id, isApproved),
|
||||
this.updateApprovalWithTransaction(transaction, id, is_approved),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,17 +8,17 @@ import { SearchShiftsDto } from "../dtos/search-shift.dto";
|
|||
import { NotificationsService } from "src/modules/notifications/services/notifications.service";
|
||||
import { computeHours, hoursBetweenSameDay } from "src/common/utils/date-utils";
|
||||
|
||||
const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 8);
|
||||
const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12);
|
||||
|
||||
export interface OverviewRow {
|
||||
fullName: string;
|
||||
full_name: string;
|
||||
supervisor: string;
|
||||
totalRegularHrs: number;
|
||||
totalEveningHrs: number;
|
||||
totalOvertimeHrs: number;
|
||||
totalExpenses: number;
|
||||
totalMileage: number;
|
||||
isValidated: boolean;
|
||||
total_regular_hrs: number;
|
||||
total_evening_hrs: number;
|
||||
total_overtime_hrs: number;
|
||||
total_expenses: number;
|
||||
total_mileage: number;
|
||||
is_validated: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -40,31 +40,32 @@ export class ShiftsQueryService {
|
|||
});
|
||||
|
||||
//fetches all shifts of the same day to check for daily overtime
|
||||
const sameDayShifts = await this.prisma.shifts.findMany({
|
||||
const same_day_shifts = await this.prisma.shifts.findMany({
|
||||
where: { timesheet_id, date },
|
||||
select: { id: true, date: true, start_time: true, end_time: true },
|
||||
});
|
||||
|
||||
//sums hours of the day
|
||||
const totalHours = sameDayShifts.reduce((sum, s) => {
|
||||
const total_hours = same_day_shifts.reduce((sum, s) => {
|
||||
return sum + hoursBetweenSameDay(s.date, s.start_time, s.end_time);
|
||||
}, 0 );
|
||||
|
||||
//Notify if total hours > 8 for a single day
|
||||
if(totalHours > DAILY_LIMIT_HOURS ) {
|
||||
const userId = String(shift.timesheet.employee.user.id);
|
||||
const dateLabel = new Date(date).toLocaleDateString('fr-CA');
|
||||
this.notifs.notify(userId, {
|
||||
if(total_hours > DAILY_LIMIT_HOURS ) {
|
||||
const user_id = String(shift.timesheet.employee.user.id);
|
||||
const date_label = new Date(date).toLocaleDateString('fr-CA');
|
||||
this.notifs.notify(user_id, {
|
||||
type: 'shift.overtime.daily',
|
||||
severity: 'warn',
|
||||
message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${dateLabel} (total: ${totalHours.toFixed(2)}h).`,
|
||||
message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${date_label}
|
||||
(total: ${total_hours.toFixed(2)}h).`,
|
||||
ts: new Date().toISOString(),
|
||||
meta: {
|
||||
timesheet_id,
|
||||
date: new Date(date).toISOString(),
|
||||
totalHours,
|
||||
total_hours,
|
||||
threshold: DAILY_LIMIT_HOURS,
|
||||
lastShiftId: shift.id
|
||||
last_shift_id: shift.id
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -152,93 +153,93 @@ export class ShiftsQueryService {
|
|||
|
||||
const mapRow = new Map<string, OverviewRow>();
|
||||
|
||||
for(const s of shifts) {
|
||||
const employeeId = s.timesheet.employee.user_id;
|
||||
const user = s.timesheet.employee.user;
|
||||
const sup = s.timesheet.employee.supervisor?.user;
|
||||
for(const shift of shifts) {
|
||||
const employeeId = shift.timesheet.employee.user_id;
|
||||
const user = shift.timesheet.employee.user;
|
||||
const sup = shift.timesheet.employee.supervisor?.user;
|
||||
|
||||
let row = mapRow.get(employeeId);
|
||||
if(!row) {
|
||||
row = {
|
||||
fullName: `${user.first_name} ${user.last_name}`,
|
||||
full_name: `${user.first_name} ${user.last_name}`,
|
||||
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
|
||||
totalRegularHrs: 0,
|
||||
totalEveningHrs: 0,
|
||||
totalOvertimeHrs: 0,
|
||||
totalExpenses: 0,
|
||||
totalMileage: 0,
|
||||
isValidated: false,
|
||||
total_regular_hrs: 0,
|
||||
total_evening_hrs: 0,
|
||||
total_overtime_hrs: 0,
|
||||
total_expenses: 0,
|
||||
total_mileage: 0,
|
||||
is_validated: false,
|
||||
};
|
||||
}
|
||||
const hours = computeHours(s.start_time, s.end_time);
|
||||
const hours = computeHours(shift.start_time, shift.end_time);
|
||||
|
||||
switch(s.bank_code.type) {
|
||||
case 'regular' : row.totalRegularHrs += hours;
|
||||
switch(shift.bank_code.type) {
|
||||
case 'regular' : row.total_regular_hrs += hours;
|
||||
break;
|
||||
case 'evening' : row.totalEveningHrs += hours;
|
||||
case 'evening' : row.total_evening_hrs += hours;
|
||||
break;
|
||||
case 'overtime' : row.totalOvertimeHrs += hours;
|
||||
case 'overtime' : row.total_overtime_hrs += hours;
|
||||
break;
|
||||
default: row.totalRegularHrs += hours;
|
||||
default: row.total_regular_hrs += hours;
|
||||
}
|
||||
mapRow.set(employeeId, row);
|
||||
}
|
||||
|
||||
for(const e of expenses) {
|
||||
const employeeId = e.timesheet.employee.user_id;
|
||||
const user = e.timesheet.employee.user;
|
||||
const sup = e.timesheet.employee.supervisor?.user;
|
||||
for(const exp of expenses) {
|
||||
const employee_id = exp.timesheet.employee.user_id;
|
||||
const user = exp.timesheet.employee.user;
|
||||
const sup = exp.timesheet.employee.supervisor?.user;
|
||||
|
||||
let row = mapRow.get(employeeId);
|
||||
let row = mapRow.get(employee_id);
|
||||
if(!row) {
|
||||
row = {
|
||||
fullName: `${user.first_name} ${user.last_name}`,
|
||||
full_name: `${user.first_name} ${user.last_name}`,
|
||||
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
|
||||
totalRegularHrs: 0,
|
||||
totalEveningHrs: 0,
|
||||
totalOvertimeHrs: 0,
|
||||
totalExpenses: 0,
|
||||
totalMileage: 0,
|
||||
isValidated: false,
|
||||
total_regular_hrs: 0,
|
||||
total_evening_hrs: 0,
|
||||
total_overtime_hrs: 0,
|
||||
total_expenses: 0,
|
||||
total_mileage: 0,
|
||||
is_validated: false,
|
||||
};
|
||||
}
|
||||
const amount = Number(e.amount);
|
||||
row.totalExpenses += amount;
|
||||
if(e.bank_code.type === 'mileage') {
|
||||
row.totalMileage += amount;
|
||||
const amount = Number(exp.amount);
|
||||
row.total_expenses += amount;
|
||||
if(exp.bank_code.type === 'mileage') {
|
||||
row.total_mileage += amount;
|
||||
}
|
||||
mapRow.set(employeeId, row);
|
||||
mapRow.set(employee_id, row);
|
||||
}
|
||||
//return by default the list of employee in ascending alphabetical order
|
||||
return Array.from(mapRow.values()).sort((a,b) => a.fullName.localeCompare(b.fullName));
|
||||
return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name));
|
||||
}
|
||||
|
||||
//archivation functions ******************************************************
|
||||
|
||||
async archiveOld(): Promise<void> {
|
||||
//fetches archived timesheet's Ids
|
||||
const archivedTimesheets = await this.prisma.timesheetsArchive.findMany({
|
||||
const archived_timesheets = await this.prisma.timesheetsArchive.findMany({
|
||||
select: { timesheet_id: true },
|
||||
});
|
||||
|
||||
const timesheetIds = archivedTimesheets.map(sheet => sheet.timesheet_id);
|
||||
if(timesheetIds.length === 0) {
|
||||
const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id);
|
||||
if(timesheet_ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// copy/delete transaction
|
||||
await this.prisma.$transaction(async transaction => {
|
||||
//fetches shifts to move to archive
|
||||
const shiftsToArchive = await transaction.shifts.findMany({
|
||||
where: { timesheet_id: { in: timesheetIds } },
|
||||
const shifts_to_archive = await transaction.shifts.findMany({
|
||||
where: { timesheet_id: { in: timesheet_ids } },
|
||||
});
|
||||
if(shiftsToArchive.length === 0) {
|
||||
if(shifts_to_archive.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
//copies sent to archive table
|
||||
await transaction.shiftsArchive.createMany({
|
||||
data: shiftsToArchive.map(shift => ({
|
||||
data: shifts_to_archive.map(shift => ({
|
||||
shift_id: shift.id,
|
||||
timesheet_id: shift.timesheet_id,
|
||||
bank_code_id: shift.bank_code_id,
|
||||
|
|
@ -251,7 +252,7 @@ export class ShiftsQueryService {
|
|||
|
||||
//delete from shifts table
|
||||
await transaction.shifts.deleteMany({
|
||||
where: { id: { in: shiftsToArchive.map(shift => shift.id) } },
|
||||
where: { id: { in: shifts_to_archive.map(shift => shift.id) } },
|
||||
})
|
||||
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
|
|||
|
||||
async updateApproval(id: number, isApproved: boolean): Promise<Timesheets> {
|
||||
return this.prisma.$transaction((transaction) =>
|
||||
this.updateApprovalWithTx(transaction, id, isApproved),
|
||||
this.updateApprovalWithTransaction(transaction, id, isApproved),
|
||||
);
|
||||
}
|
||||
|
||||
async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise<Timesheets> {
|
||||
const timesheet = await this.updateApprovalWithTx(transaction, timesheetId, isApproved);
|
||||
const timesheet = await this.updateApprovalWithTransaction(transaction, timesheetId, isApproved);
|
||||
|
||||
await transaction.shifts.updateMany({
|
||||
where: { timesheet_id: timesheetId },
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user