feat(leave-requests): implementation of vacation, sick and holiday leave-requests.
This commit is contained in:
parent
d8bc05f6e2
commit
79153c6de3
|
|
@ -1232,16 +1232,16 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/leave-requests/holiday": {
|
"/leave-requests/upsert": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "LeaveRequestController_upsertHoliday",
|
"operationId": "LeaveRequestController_upsertLeaveRequest",
|
||||||
"parameters": [],
|
"parameters": [],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/components/schemas/UpsertHolidayDto"
|
"$ref": "#/components/schemas/UpsertLeaveRequestDto"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2474,7 +2474,7 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"UpsertHolidayDto": {
|
"UpsertLeaveRequestDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"properties": {}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
import { PrismaService } from "../../../prisma/prisma.service";
|
|
||||||
import { computeHours, getWeekStart } from "src/common/utils/date-utils";
|
import { computeHours, getWeekStart } from "src/common/utils/date-utils";
|
||||||
|
import { PrismaService } from "../../../prisma/prisma.service";
|
||||||
|
|
||||||
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { PrismaService } from "../../../prisma/prisma.service";
|
import { PrismaService } from "../../../prisma/prisma.service";
|
||||||
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SickLeaveService {
|
export class SickLeaveService {
|
||||||
|
|
@ -9,8 +9,17 @@ export class SickLeaveService {
|
||||||
private readonly logger = new Logger(SickLeaveService.name);
|
private readonly logger = new Logger(SickLeaveService.name);
|
||||||
|
|
||||||
//switch employeeId for email
|
//switch employeeId for email
|
||||||
async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number):
|
async calculateSickLeavePay(
|
||||||
Promise<number> {
|
employee_id: number,
|
||||||
|
reference_date: Date,
|
||||||
|
days_requested: number,
|
||||||
|
hours_per_day: number,
|
||||||
|
modifier: number,
|
||||||
|
): Promise<number> {
|
||||||
|
if (days_requested <= 0 || hours_per_day <= 0 || modifier <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
//sets the year to jan 1st to dec 31st
|
//sets the year to jan 1st to dec 31st
|
||||||
const period_start = getYearStart(reference_date);
|
const period_start = getYearStart(reference_date);
|
||||||
const period_end = reference_date;
|
const period_end = reference_date;
|
||||||
|
|
@ -19,18 +28,19 @@ export class SickLeaveService {
|
||||||
const shifts = await this.prisma.shifts.findMany({
|
const shifts = await this.prisma.shifts.findMany({
|
||||||
where: {
|
where: {
|
||||||
timesheet: { employee_id: employee_id },
|
timesheet: { employee_id: employee_id },
|
||||||
date: { gte: period_start, lte: period_end},
|
date: { gte: period_start, lte: period_end },
|
||||||
},
|
},
|
||||||
select: { date: true },
|
select: { date: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
//count the amount of worked days
|
//count the amount of worked days
|
||||||
const worked_dates = new Set(
|
const worked_dates = new Set(
|
||||||
shifts.map(shift => shift.date.toISOString().slice(0,10))
|
shifts.map((shift) => shift.date.toISOString().slice(0, 10)),
|
||||||
);
|
);
|
||||||
const days_worked = worked_dates.size;
|
const days_worked = worked_dates.size;
|
||||||
this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()}
|
this.logger.debug(
|
||||||
-> ${period_end.toDateString()}`);
|
`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} -> ${period_end.toDateString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
//less than 30 worked days returns 0
|
//less than 30 worked days returns 0
|
||||||
if (days_worked < 30) {
|
if (days_worked < 30) {
|
||||||
|
|
@ -45,22 +55,31 @@ export class SickLeaveService {
|
||||||
const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day
|
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
|
//calculate each completed month, starting the 1st of the next month
|
||||||
const first_bonus_date = new Date(threshold_date.getFullYear(), threshold_date.getMonth() +1, 1);
|
const first_bonus_date = new Date(
|
||||||
let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
|
threshold_date.getFullYear(),
|
||||||
(period_end.getMonth() - first_bonus_date.getMonth()) + 1;
|
threshold_date.getMonth() + 1,
|
||||||
if(months < 0) months = 0;
|
1,
|
||||||
|
);
|
||||||
|
let months =
|
||||||
|
(period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
|
||||||
|
(period_end.getMonth() - first_bonus_date.getMonth()) +
|
||||||
|
1;
|
||||||
|
if (months < 0) months = 0;
|
||||||
acquired_days += months;
|
acquired_days += months;
|
||||||
|
|
||||||
//cap of 10 days
|
//cap of 10 days
|
||||||
if (acquired_days > 10) acquired_days = 10;
|
if (acquired_days > 10) acquired_days = 10;
|
||||||
|
|
||||||
this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()}
|
this.logger.debug(
|
||||||
, bonusMonths = ${months}, acquired Days = ${acquired_days}`);
|
`Sick leave: threshold Date = ${threshold_date.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquired_days}`,
|
||||||
|
);
|
||||||
|
|
||||||
const payable_days = Math.min(acquired_days, days_requested);
|
const payable_days = Math.min(acquired_days, days_requested);
|
||||||
const raw_hours = payable_days * 8 * modifier;
|
const raw_hours = payable_days * hours_per_day * modifier;
|
||||||
const rounded = roundToQuarterHour(raw_hours)
|
const rounded = roundToQuarterHour(raw_hours);
|
||||||
this.logger.debug(`Sick leave pay: days= ${payable_days}, modifier= ${modifier}, hours= ${rounded}`);
|
this.logger.debug(
|
||||||
|
`Sick leave pay: days= ${payable_days}, hoursPerDay= ${hours_per_day}, modifier= ${modifier}, hours= ${rounded}`,
|
||||||
|
);
|
||||||
return rounded;
|
return rounded;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6,15 +6,7 @@ export class VacationService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private readonly logger = new Logger(VacationService.name);
|
private readonly logger = new Logger(VacationService.name);
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the ammount allowed for vacation days.
|
|
||||||
*
|
|
||||||
* @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
|
//switch employeeId for email
|
||||||
async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> {
|
async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> {
|
||||||
//fetch hiring date
|
//fetch hiring date
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import { Body, Controller, Post } from "@nestjs/common";
|
import { Body, Controller, Post } from "@nestjs/common";
|
||||||
import { HolidayLeaveRequestsService } from "../services/holiday-leave-requests.service";
|
|
||||||
|
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
import { UpsertHolidayDto } from "../dtos/upsert-holiday.dto";
|
import { LeaveRequestsService } from "../services/leave-request.service";
|
||||||
|
import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto";
|
||||||
|
import { LeaveTypes } from "@prisma/client";
|
||||||
|
|
||||||
@ApiTags('Leave Requests')
|
@ApiTags('Leave Requests')
|
||||||
@ApiBearerAuth('access-token')
|
@ApiBearerAuth('access-token')
|
||||||
// @UseGuards()
|
// @UseGuards()
|
||||||
@Controller('leave-requests')
|
@Controller('leave-requests')
|
||||||
export class LeaveRequestController {
|
export class LeaveRequestController {
|
||||||
constructor(private readonly leave_service: HolidayLeaveRequestsService){}
|
constructor(private readonly leave_service: LeaveRequestsService){}
|
||||||
|
|
||||||
@Post('holiday')
|
@Post('upsert')
|
||||||
async upsertHoliday(@Body() dto: UpsertHolidayDto) {
|
async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
|
||||||
const { action, leave_requests } = await this.leave_service.handleHoliday(dto);
|
const { action, leave_requests } = await this.leave_service.handle(dto);
|
||||||
return { action, leave_requests };
|
return { action, leave_requests };
|
||||||
}
|
}q
|
||||||
|
|
||||||
//TODO:
|
//TODO:
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
|
||||||
import { Type } from "class-transformer";
|
|
||||||
import {
|
|
||||||
ArrayNotEmpty,
|
|
||||||
ArrayUnique,
|
|
||||||
IsArray,
|
|
||||||
IsEmail,
|
|
||||||
IsISO8601,
|
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
Max,
|
|
||||||
Min,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
export class UpsertSickDto {
|
|
||||||
@ApiProperty({ example: "jane.doe@example.com" })
|
|
||||||
@IsEmail()
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
type: [String],
|
|
||||||
example: ["2025-03-04"],
|
|
||||||
description: "ISO dates that represent the sick leave request.",
|
|
||||||
})
|
|
||||||
@IsArray()
|
|
||||||
@ArrayNotEmpty()
|
|
||||||
@ArrayUnique()
|
|
||||||
@IsISO8601({}, { each: true })
|
|
||||||
dates!: string[];
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
required: false,
|
|
||||||
example: "Medical note provided",
|
|
||||||
description: "Optional comment applied to every date.",
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
comment?: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
required: false,
|
|
||||||
example: 8,
|
|
||||||
description: "Hours requested per day. Lets you keep the user input even if the calculation differs.",
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber({ maxDecimalPlaces: 2 })
|
|
||||||
@Min(0)
|
|
||||||
@Max(24)
|
|
||||||
requested_hours?: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { LeaveApprovalStatus } from "@prisma/client";
|
|
||||||
import { Type } from "class-transformer";
|
|
||||||
import {
|
|
||||||
ArrayNotEmpty,
|
|
||||||
ArrayUnique,
|
|
||||||
IsArray,
|
|
||||||
IsEmail,
|
|
||||||
IsEnum,
|
|
||||||
IsIn,
|
|
||||||
IsISO8601,
|
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
Max,
|
|
||||||
Min,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
export const HOLIDAY_UPSERT_ACTIONS = ['create', 'update', 'delete'] as const;
|
|
||||||
export type HolidayUpsertAction = typeof HOLIDAY_UPSERT_ACTIONS[number];
|
|
||||||
|
|
||||||
export class UpsertHolidayDto {
|
|
||||||
@IsEmail()
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@IsArray()
|
|
||||||
@ArrayNotEmpty()
|
|
||||||
@ArrayUnique()
|
|
||||||
@IsISO8601({}, { each: true })
|
|
||||||
dates!: string[];
|
|
||||||
|
|
||||||
@IsIn(HOLIDAY_UPSERT_ACTIONS)
|
|
||||||
action!: HolidayUpsertAction;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
comment?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber({ maxDecimalPlaces: 2 })
|
|
||||||
@Min(0)
|
|
||||||
@Max(24)
|
|
||||||
requested_hours?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsEnum(LeaveApprovalStatus)
|
|
||||||
approval_status?: LeaveApprovalStatus;
|
|
||||||
}
|
|
||||||
51
src/modules/leave-requests/dtos/upsert-leave-request.dto.ts
Normal file
51
src/modules/leave-requests/dtos/upsert-leave-request.dto.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { IsEmail, IsArray, ArrayNotEmpty, ArrayUnique, IsISO8601, IsIn, IsOptional, IsString, IsNumber, Min, Max, IsEnum } from "class-validator";
|
||||||
|
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
|
import { LeaveRequestViewDto } from "./leave-request-view.dto";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
//sets wich function to call
|
||||||
|
export const UPSERT_ACTIONS = ['create', 'update', 'delete'] as const;
|
||||||
|
export type UpsertAction = (typeof UPSERT_ACTIONS)[number];
|
||||||
|
|
||||||
|
//sets wich types to use
|
||||||
|
export const REQUEST_TYPES = Object.values(LeaveTypes) as readonly LeaveTypes[];
|
||||||
|
export type RequestTypes = (typeof REQUEST_TYPES)[number];
|
||||||
|
|
||||||
|
//filter requests by type and action
|
||||||
|
export interface UpsertResult {
|
||||||
|
action: UpsertAction;
|
||||||
|
leave_requests: LeaveRequestViewDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpsertLeaveRequestDto {
|
||||||
|
@IsEmail()
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@ArrayUnique()
|
||||||
|
@IsISO8601({}, { each: true })
|
||||||
|
dates!: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LeaveTypes)
|
||||||
|
type!: string;
|
||||||
|
|
||||||
|
@IsIn(UPSERT_ACTIONS)
|
||||||
|
action!: UpsertAction;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
comment?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
@Min(0)
|
||||||
|
@Max(24)
|
||||||
|
requested_hours?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(LeaveApprovalStatus)
|
||||||
|
approval_status?: LeaveApprovalStatus
|
||||||
|
}
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
import { ApiProperty } from "@nestjs/swagger";
|
|
||||||
import { Type } from "class-transformer";
|
|
||||||
import {
|
|
||||||
ArrayNotEmpty,
|
|
||||||
ArrayUnique,
|
|
||||||
IsArray,
|
|
||||||
IsEmail,
|
|
||||||
IsISO8601,
|
|
||||||
IsNumber,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
Max,
|
|
||||||
Min,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
export class UpsertVacationDto {
|
|
||||||
@ApiProperty({ example: "jane.doe@example.com" })
|
|
||||||
@IsEmail()
|
|
||||||
email!: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
type: [String],
|
|
||||||
example: ["2025-07-14", "2025-07-15"],
|
|
||||||
description: "ISO dates that represent the vacation request.",
|
|
||||||
})
|
|
||||||
@IsArray()
|
|
||||||
@ArrayNotEmpty()
|
|
||||||
@ArrayUnique()
|
|
||||||
@IsISO8601({}, { each: true })
|
|
||||||
dates!: string[];
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
required: false,
|
|
||||||
example: "Summer break",
|
|
||||||
description: "Optional comment applied to every date.",
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
comment?: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
required: false,
|
|
||||||
example: 8,
|
|
||||||
description: "Hours requested per day. Used as default when creating shifts.",
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber({ maxDecimalPlaces: 2 })
|
|
||||||
@Min(0)
|
|
||||||
@Max(24)
|
|
||||||
requested_hours?: number;
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +1,26 @@
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { HolidayService } from "../business-logics/services/holiday.service";
|
|
||||||
import { LeaveRequestController } from "./controllers/leave-requests.controller";
|
import { LeaveRequestController } from "./controllers/leave-requests.controller";
|
||||||
import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
|
import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
||||||
import { ShiftsCommandService } from "../shifts/services/shifts-command.service";
|
import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service";
|
||||||
|
import { SickLeaveRequestsService } from "./services/sick-leave-requests.service";
|
||||||
|
import { LeaveRequestsService } from "./services/leave-request.service";
|
||||||
|
import { ShiftsModule } from "../shifts/shifts.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [BusinessLogicsModule],
|
imports: [BusinessLogicsModule, ShiftsModule],
|
||||||
controllers: [LeaveRequestController],
|
controllers: [LeaveRequestController],
|
||||||
providers: [
|
providers: [
|
||||||
HolidayService,
|
VacationLeaveRequestsService,
|
||||||
|
SickLeaveRequestsService,
|
||||||
HolidayLeaveRequestsService,
|
HolidayLeaveRequestsService,
|
||||||
PrismaService,
|
LeaveRequestsService,
|
||||||
ShiftsCommandService,
|
PrismaService
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
LeaveRequestsService,
|
||||||
],
|
],
|
||||||
exports: [HolidayLeaveRequestsService],
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export class LeaveRequestsModule {}
|
export class LeaveRequestsModule {}
|
||||||
|
|
@ -1,69 +1,52 @@
|
||||||
import {
|
import { LeaveRequestsService, normalizeDates, toDateOnly } from './leave-request.service';
|
||||||
BadRequestException,
|
import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
|
|
||||||
import { HolidayUpsertAction, UpsertHolidayDto } from '../dtos/upsert-holiday.dto';
|
|
||||||
import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
|
|
||||||
import { ShiftsCommandService } from 'src/modules/shifts/services/shifts-command.service';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
|
||||||
import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
|
import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
|
||||||
|
import { BadRequestException, Inject, Injectable, forwardRef } from '@nestjs/common';
|
||||||
|
import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
|
||||||
|
import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
|
||||||
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import { mapRowToView } from '../mappers/leave-requests.mapper';
|
import { mapRowToView } from '../mappers/leave-requests.mapper';
|
||||||
import { leaveRequestsSelect } from '../utils/leave-requests.select';
|
import { leaveRequestsSelect } from '../utils/leave-requests.select';
|
||||||
|
|
||||||
interface HolidayUpsertResult {
|
|
||||||
action: HolidayUpsertAction;
|
|
||||||
leave_requests: LeaveRequestViewDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HolidayLeaveRequestsService {
|
export class HolidayLeaveRequestsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly holidayService: HolidayService,
|
private readonly holidayService: HolidayService,
|
||||||
private readonly shiftsCommand: ShiftsCommandService,
|
@Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService,
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
//handle distribution to the right service according to the selected action
|
||||||
// Public API
|
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
async handleHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
|
|
||||||
switch (dto.action) {
|
switch (dto.action) {
|
||||||
case 'create':
|
case 'create':
|
||||||
return this.createHoliday(dto);
|
return this.createHoliday(dto);
|
||||||
case 'update':
|
case 'update':
|
||||||
return this.updateHoliday(dto);
|
return this.leaveService.update(dto, LeaveTypes.HOLIDAY);
|
||||||
case 'delete':
|
case 'delete':
|
||||||
return this.deleteHoliday(dto);
|
return this.leaveService.delete(dto, LeaveTypes.HOLIDAY);
|
||||||
default:
|
default:
|
||||||
throw new BadRequestException(`Unknown action: ${dto.action}`);
|
throw new BadRequestException(`Unknown action: ${dto.action}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
private async createHoliday(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
// Create
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
private async createHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
|
|
||||||
const email = dto.email.trim();
|
const email = dto.email.trim();
|
||||||
const employeeId = await this.resolveEmployeeIdByEmail(email);
|
const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email);
|
||||||
const bankCode = await this.resolveHolidayBankCode();
|
const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.HOLIDAY);
|
||||||
const dates = normalizeDates(dto.dates);
|
const dates = normalizeDates(dto.dates);
|
||||||
if (!dates.length) {
|
if (!dates.length) throw new BadRequestException('Dates array must not be empty');
|
||||||
throw new BadRequestException('Dates array must not be empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
const created: LeaveRequestViewDto[] = [];
|
const created: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
for (const isoDate of dates) {
|
for (const iso_date of dates) {
|
||||||
const date = toDateOnly(isoDate);
|
const date = toDateOnly(iso_date);
|
||||||
|
|
||||||
const existing = await this.prisma.leaveRequests.findUnique({
|
const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
where: {
|
where: {
|
||||||
leave_per_employee_date: {
|
leave_per_employee_date: {
|
||||||
employee_id: employeeId,
|
employee_id: employee_id,
|
||||||
leave_type: LeaveTypes.HOLIDAY,
|
leave_type: LeaveTypes.HOLIDAY,
|
||||||
date,
|
date,
|
||||||
},
|
},
|
||||||
|
|
@ -71,14 +54,14 @@ export class HolidayLeaveRequestsService {
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new BadRequestException(`Holiday request already exists for ${isoDate}`);
|
throw new BadRequestException(`Holiday request already exists for ${iso_date}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier);
|
const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier);
|
||||||
const row = await this.prisma.leaveRequests.create({
|
const row = await this.prisma.leaveRequests.create({
|
||||||
data: {
|
data: {
|
||||||
employee_id: employeeId,
|
employee_id: employee_id,
|
||||||
bank_code_id: bankCode.id,
|
bank_code_id: bank_code.id,
|
||||||
leave_type: LeaveTypes.HOLIDAY,
|
leave_type: LeaveTypes.HOLIDAY,
|
||||||
date,
|
date,
|
||||||
comment: dto.comment ?? '',
|
comment: dto.comment ?? '',
|
||||||
|
|
@ -91,7 +74,7 @@ export class HolidayLeaveRequestsService {
|
||||||
|
|
||||||
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment);
|
await this.leaveService.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
created.push({ ...mapRowToView(row), action: 'create' });
|
created.push({ ...mapRowToView(row), action: 'create' });
|
||||||
|
|
@ -99,223 +82,5 @@ export class HolidayLeaveRequestsService {
|
||||||
|
|
||||||
return { action: 'create', leave_requests: created };
|
return { action: 'create', leave_requests: created };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// Update
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
private async updateHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
|
|
||||||
const email = dto.email.trim();
|
|
||||||
const employeeId = await this.resolveEmployeeIdByEmail(email);
|
|
||||||
const bankCode = await this.resolveHolidayBankCode();
|
|
||||||
const dates = normalizeDates(dto.dates);
|
|
||||||
if (!dates.length) {
|
|
||||||
throw new BadRequestException('Dates array must not be empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated: LeaveRequestViewDto[] = [];
|
|
||||||
|
|
||||||
for (const isoDate of dates) {
|
|
||||||
const date = toDateOnly(isoDate);
|
|
||||||
|
|
||||||
const existing = await this.prisma.leaveRequests.findUnique({
|
|
||||||
where: {
|
|
||||||
leave_per_employee_date: {
|
|
||||||
employee_id: employeeId,
|
|
||||||
leave_type: LeaveTypes.HOLIDAY,
|
|
||||||
date,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
select: leaveRequestsSelect,
|
|
||||||
});
|
|
||||||
if (!existing) {
|
|
||||||
throw new NotFoundException(`No HOLIDAY request found for ${isoDate}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payable = await this.holidayService.calculateHolidayPay(email, date, bankCode.modifier);
|
|
||||||
const previousStatus = existing.approval_status;
|
|
||||||
|
|
||||||
const row = await this.prisma.leaveRequests.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: {
|
|
||||||
comment: dto.comment ?? existing.comment,
|
|
||||||
requested_hours: dto.requested_hours ?? existing.requested_hours ?? 8,
|
|
||||||
payable_hours: payable,
|
|
||||||
bank_code_id: bankCode.id,
|
|
||||||
approval_status: dto.approval_status ?? existing.approval_status,
|
|
||||||
},
|
|
||||||
select: leaveRequestsSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
const wasApproved = previousStatus === LeaveApprovalStatus.APPROVED;
|
|
||||||
const isApproved = row.approval_status === LeaveApprovalStatus.APPROVED;
|
|
||||||
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
|
||||||
|
|
||||||
if (!wasApproved && isApproved) {
|
|
||||||
await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment);
|
|
||||||
} else if (wasApproved && !isApproved) {
|
|
||||||
await this.removeHolidayShift(email, employeeId, isoDate);
|
|
||||||
} else if (wasApproved && isApproved) {
|
|
||||||
await this.syncHolidayShift(email, employeeId, isoDate, hours, row.comment);
|
|
||||||
}
|
|
||||||
|
|
||||||
updated.push({ ...mapRowToView(row), action: 'update' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { action: 'update', leave_requests: updated };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// Delete
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
private async deleteHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
|
|
||||||
const email = dto.email.trim();
|
|
||||||
const employeeId = await this.resolveEmployeeIdByEmail(email);
|
|
||||||
const dates = normalizeDates(dto.dates);
|
|
||||||
if (!dates.length) {
|
|
||||||
throw new BadRequestException('Dates array must not be empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await this.prisma.leaveRequests.findMany({
|
|
||||||
where: {
|
|
||||||
employee_id: employeeId,
|
|
||||||
leave_type: LeaveTypes.HOLIDAY,
|
|
||||||
date: { in: dates.map((d) => toDateOnly(d)) },
|
|
||||||
},
|
|
||||||
select: leaveRequestsSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (rows.length !== dates.length) {
|
|
||||||
const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
|
|
||||||
throw new NotFoundException(`No HOLIDAY request found for: ${missing.join(', ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
|
||||||
const iso = toISODateKey(row.date);
|
|
||||||
await this.removeHolidayShift(email, employeeId, iso);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.leaveRequests.deleteMany({
|
|
||||||
where: { id: { in: rows.map((row) => row.id) } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: 'delete' as const}));
|
|
||||||
return { action: 'delete', leave_requests: deleted };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// Shift synchronisation
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
private async syncHolidayShift(
|
|
||||||
email: string,
|
|
||||||
employeeId: number,
|
|
||||||
isoDate: string,
|
|
||||||
hours: number,
|
|
||||||
comment?: string,
|
|
||||||
) {
|
|
||||||
if (hours <= 0) return;
|
|
||||||
|
|
||||||
const durationMinutes = Math.round(hours * 60);
|
|
||||||
if (durationMinutes > 8 * 60) {
|
|
||||||
throw new BadRequestException('Holiday hours cannot exceed 8 hours.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const startMinutes = 8 * 60;
|
|
||||||
const endMinutes = startMinutes + durationMinutes;
|
|
||||||
const toHHmm = (total: number) => `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
|
||||||
|
|
||||||
const existing = await this.prisma.shifts.findFirst({
|
|
||||||
where: {
|
|
||||||
date: new Date(isoDate),
|
|
||||||
bank_code: { type: 'HOLIDAY' },
|
|
||||||
timesheet: { employee_id: employeeId },
|
|
||||||
},
|
|
||||||
include: { bank_code: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.shiftsCommand.upsertShiftsByDate(email, isoDate, {
|
|
||||||
old_shift: existing
|
|
||||||
? {
|
|
||||||
start_time: existing.start_time.toISOString().slice(11, 16),
|
|
||||||
end_time: existing.end_time.toISOString().slice(11, 16),
|
|
||||||
type: existing.bank_code?.type ?? 'HOLIDAY',
|
|
||||||
is_remote: existing.is_remote,
|
|
||||||
comment: existing.comment ?? undefined,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
new_shift: {
|
|
||||||
start_time: toHHmm(startMinutes),
|
|
||||||
end_time: toHHmm(endMinutes),
|
|
||||||
type: 'HOLIDAY',
|
|
||||||
is_remote: existing?.is_remote ?? false,
|
|
||||||
comment: comment ?? existing?.comment ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async removeHolidayShift(email: string, employeeId: number, isoDate: string) {
|
|
||||||
const existing = await this.prisma.shifts.findFirst({
|
|
||||||
where: {
|
|
||||||
date: new Date(isoDate),
|
|
||||||
bank_code: { type: 'HOLIDAY' },
|
|
||||||
timesheet: { employee_id: employeeId },
|
|
||||||
},
|
|
||||||
include: { bank_code: true },
|
|
||||||
});
|
|
||||||
if (!existing) return;
|
|
||||||
|
|
||||||
await this.shiftsCommand.upsertShiftsByDate(email, isoDate, {
|
|
||||||
old_shift: {
|
|
||||||
start_time: existing.start_time.toISOString().slice(11, 16),
|
|
||||||
end_time: existing.end_time.toISOString().slice(11, 16),
|
|
||||||
type: existing.bank_code?.type ?? 'HOLIDAY',
|
|
||||||
is_remote: existing.is_remote,
|
|
||||||
comment: existing.comment ?? undefined,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
private async resolveEmployeeIdByEmail(email: string): Promise<number> {
|
|
||||||
const employee = await this.prisma.employees.findFirst({
|
|
||||||
where: { user: { email } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!employee) {
|
|
||||||
throw new NotFoundException(`Employee with email ${email} not found`);
|
|
||||||
}
|
|
||||||
return employee.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resolveHolidayBankCode() {
|
|
||||||
const bankCode = await this.prisma.bankCodes.findFirst({
|
|
||||||
where: { type: 'HOLIDAY' },
|
|
||||||
select: { id: true, bank_code: true, modifier: true },
|
|
||||||
});
|
|
||||||
if (!bankCode) {
|
|
||||||
throw new BadRequestException('Bank code type "HOLIDAY" not found');
|
|
||||||
}
|
|
||||||
return bankCode;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toDateOnly = (iso: string): Date => {
|
|
||||||
const date = new Date(iso);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
throw new BadRequestException(`Invalid date: ${iso}`);
|
|
||||||
}
|
|
||||||
date.setHours(0, 0, 0, 0);
|
|
||||||
return date;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const normalizeDates = (dates: string[]): string[] =>
|
|
||||||
Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso)))));
|
|
||||||
|
|
|
||||||
339
src/modules/leave-requests/services/leave-request.service.ts
Normal file
339
src/modules/leave-requests/services/leave-request.service.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
import { BadRequestException, Inject, Injectable, NotFoundException, forwardRef } from "@nestjs/common";
|
||||||
|
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
|
import { roundToQuarterHour } from "src/common/utils/date-utils";
|
||||||
|
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
||||||
|
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
||||||
|
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
||||||
|
import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
||||||
|
import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service";
|
||||||
|
import { SickLeaveRequestsService } from "./sick-leave-requests.service";
|
||||||
|
import { VacationLeaveRequestsService } from "./vacation-leave-requests.service";
|
||||||
|
import { HolidayService } from "src/modules/business-logics/services/holiday.service";
|
||||||
|
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
|
||||||
|
import { VacationService } from "src/modules/business-logics/services/vacation.service";
|
||||||
|
import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LeaveRequestsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly holidayService: HolidayService,
|
||||||
|
private readonly sickLogic: SickLeaveService,
|
||||||
|
private readonly vacationLogic: VacationService,
|
||||||
|
@Inject(forwardRef(() => HolidayLeaveRequestsService)) private readonly holidayLeaveService: HolidayLeaveRequestsService,
|
||||||
|
@Inject(forwardRef(() => SickLeaveRequestsService)) private readonly sickLeaveService: SickLeaveRequestsService,
|
||||||
|
private readonly shiftsCommand: ShiftsCommandService,
|
||||||
|
@Inject(forwardRef(() => VacationLeaveRequestsService)) private readonly vacationLeaveService: VacationLeaveRequestsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
|
switch (dto.type) {
|
||||||
|
case LeaveTypes.HOLIDAY:
|
||||||
|
return this.holidayLeaveService.handle(dto);
|
||||||
|
case LeaveTypes.VACATION:
|
||||||
|
return this.vacationLeaveService.handle(dto);
|
||||||
|
case LeaveTypes.SICK:
|
||||||
|
return this.sickLeaveService.handle(dto);
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(`Unsupported leave type: ${dto.type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveEmployeeIdByEmail(email: string): Promise<number> {
|
||||||
|
const employee = await this.prisma.employees.findFirst({
|
||||||
|
where: { user: { email } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!employee) {
|
||||||
|
throw new NotFoundException(`Employee with email ${email} not found`);
|
||||||
|
}
|
||||||
|
return employee.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveBankCodeByType(type: LeaveTypes) {
|
||||||
|
const bankCode = await this.prisma.bankCodes.findFirst({
|
||||||
|
where: { type },
|
||||||
|
select: { id: true, bank_code: true, modifier: true },
|
||||||
|
});
|
||||||
|
if (!bankCode) {
|
||||||
|
throw new BadRequestException(`Bank code type "${type}" not found`);
|
||||||
|
}
|
||||||
|
return bankCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncShift(
|
||||||
|
email: string,
|
||||||
|
employee_id: number,
|
||||||
|
iso_date: string,
|
||||||
|
hours: number,
|
||||||
|
type: LeaveTypes,
|
||||||
|
comment?: string,
|
||||||
|
) {
|
||||||
|
if (hours <= 0) return;
|
||||||
|
|
||||||
|
const duration_minutes = Math.round(hours * 60);
|
||||||
|
if (duration_minutes > 8 * 60) {
|
||||||
|
throw new BadRequestException("Amount of hours cannot exceed 8 hours per day.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const start_minutes = 8 * 60;
|
||||||
|
const end_minutes = start_minutes + duration_minutes;
|
||||||
|
const toHHmm = (total: number) =>
|
||||||
|
`${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
const existing = await this.prisma.shifts.findFirst({
|
||||||
|
where: {
|
||||||
|
date: new Date(iso_date),
|
||||||
|
bank_code: { type },
|
||||||
|
timesheet: { employee_id: employee_id },
|
||||||
|
},
|
||||||
|
include: { bank_code: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.shiftsCommand.upsertShiftsByDate(email, iso_date, {
|
||||||
|
old_shift: existing
|
||||||
|
? {
|
||||||
|
start_time: existing.start_time.toISOString().slice(11, 16),
|
||||||
|
end_time: existing.end_time.toISOString().slice(11, 16),
|
||||||
|
type: existing.bank_code?.type ?? type,
|
||||||
|
is_remote: existing.is_remote,
|
||||||
|
comment: existing.comment ?? undefined,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
new_shift: {
|
||||||
|
start_time: toHHmm(start_minutes),
|
||||||
|
end_time: toHHmm(end_minutes),
|
||||||
|
is_remote: existing?.is_remote ?? false,
|
||||||
|
comment: comment ?? existing?.comment ?? "",
|
||||||
|
type: type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeShift(
|
||||||
|
email: string,
|
||||||
|
employee_id: number,
|
||||||
|
iso_date: string,
|
||||||
|
type: LeaveTypes,
|
||||||
|
) {
|
||||||
|
const existing = await this.prisma.shifts.findFirst({
|
||||||
|
where: {
|
||||||
|
date: new Date(iso_date),
|
||||||
|
bank_code: { type },
|
||||||
|
timesheet: { employee_id: employee_id },
|
||||||
|
},
|
||||||
|
include: { bank_code: true },
|
||||||
|
});
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
await this.shiftsCommand.upsertShiftsByDate(email, iso_date, {
|
||||||
|
old_shift: {
|
||||||
|
start_time: existing.start_time.toISOString().slice(11, 16),
|
||||||
|
end_time: existing.end_time.toISOString().slice(11, 16),
|
||||||
|
type: existing.bank_code?.type ?? type,
|
||||||
|
is_remote: existing.is_remote,
|
||||||
|
comment: existing.comment ?? undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
|
||||||
|
const email = dto.email.trim();
|
||||||
|
const employee_id = await this.resolveEmployeeIdByEmail(email);
|
||||||
|
const dates = normalizeDates(dto.dates);
|
||||||
|
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
||||||
|
|
||||||
|
const rows = await this.prisma.leaveRequests.findMany({
|
||||||
|
where: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
leave_type: type,
|
||||||
|
date: { in: dates.map((d) => toDateOnly(d)) },
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows.length !== dates.length) {
|
||||||
|
const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
|
||||||
|
throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
|
const iso = toISODateKey(row.date);
|
||||||
|
await this.removeShift(email, employee_id, iso, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.leaveRequests.deleteMany({
|
||||||
|
where: { id: { in: rows.map((row) => row.id) } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const }));
|
||||||
|
return { action: "delete", leave_requests: deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
|
||||||
|
const email = dto.email.trim();
|
||||||
|
const employee_id = await this.resolveEmployeeIdByEmail(email);
|
||||||
|
const bank_code = await this.resolveBankCodeByType(type);
|
||||||
|
const modifier = Number(bank_code.modifier ?? 1);
|
||||||
|
const dates = normalizeDates(dto.dates);
|
||||||
|
if (!dates.length) {
|
||||||
|
throw new BadRequestException("Dates array must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await Promise.all(
|
||||||
|
dates.map(async (iso_date) => {
|
||||||
|
const date = toDateOnly(iso_date);
|
||||||
|
const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
|
where: {
|
||||||
|
leave_per_employee_date: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
leave_type: type,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(`No Leave request found for ${iso_date}`);
|
||||||
|
}
|
||||||
|
return { iso_date, date, existing };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
|
if (type === LeaveTypes.SICK) {
|
||||||
|
const firstExisting = entries[0].existing;
|
||||||
|
const fallbackRequested =
|
||||||
|
firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined
|
||||||
|
? Number(firstExisting.requested_hours)
|
||||||
|
: 8;
|
||||||
|
const requested_hours_per_day = dto.requested_hours ?? fallbackRequested;
|
||||||
|
const reference_date = entries.reduce(
|
||||||
|
(latest, entry) => (entry.date > latest ? entry.date : latest),
|
||||||
|
entries[0].date,
|
||||||
|
);
|
||||||
|
const total_payable_hours = await this.sickLogic.calculateSickLeavePay(
|
||||||
|
employee_id,
|
||||||
|
reference_date,
|
||||||
|
entries.length,
|
||||||
|
requested_hours_per_day,
|
||||||
|
modifier,
|
||||||
|
);
|
||||||
|
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
||||||
|
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
||||||
|
|
||||||
|
for (const { iso_date, existing } of entries) {
|
||||||
|
const previous_status = existing.approval_status;
|
||||||
|
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
||||||
|
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
||||||
|
remaining_payable_hours = roundToQuarterHour(
|
||||||
|
Math.max(0, remaining_payable_hours - payable_rounded),
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = await this.prisma.leaveRequests.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
comment: dto.comment ?? existing.comment,
|
||||||
|
requested_hours: requested_hours_per_day,
|
||||||
|
payable_hours: payable_rounded,
|
||||||
|
bank_code_id: bank_code.id,
|
||||||
|
approval_status: dto.approval_status ?? existing.approval_status,
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
|
||||||
|
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
|
||||||
|
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
|
|
||||||
|
if (!was_approved && is_approved) {
|
||||||
|
await this.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
|
} else if (was_approved && !is_approved) {
|
||||||
|
await this.removeShift(email, employee_id, iso_date, type);
|
||||||
|
} else if (was_approved && is_approved) {
|
||||||
|
await this.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
updated.push({ ...mapRowToView(row), action: "update" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "update", leave_requests: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { iso_date, date, existing } of entries) {
|
||||||
|
const previous_status = existing.approval_status;
|
||||||
|
const fallbackRequested =
|
||||||
|
existing.requested_hours !== null && existing.requested_hours !== undefined
|
||||||
|
? Number(existing.requested_hours)
|
||||||
|
: 8;
|
||||||
|
const requested_hours = dto.requested_hours ?? fallbackRequested;
|
||||||
|
|
||||||
|
let payable: number;
|
||||||
|
switch (type) {
|
||||||
|
case LeaveTypes.HOLIDAY:
|
||||||
|
payable = await this.holidayService.calculateHolidayPay(email, date, modifier);
|
||||||
|
break;
|
||||||
|
case LeaveTypes.VACATION: {
|
||||||
|
const days_requested = requested_hours / 8;
|
||||||
|
payable = await this.vacationLogic.calculateVacationPay(
|
||||||
|
employee_id,
|
||||||
|
date,
|
||||||
|
Math.max(0, days_requested),
|
||||||
|
modifier,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
payable = existing.payable_hours !== null && existing.payable_hours !== undefined
|
||||||
|
? Number(existing.payable_hours)
|
||||||
|
: requested_hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await this.prisma.leaveRequests.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
comment: dto.comment ?? existing.comment,
|
||||||
|
requested_hours,
|
||||||
|
payable_hours: payable,
|
||||||
|
bank_code_id: bank_code.id,
|
||||||
|
approval_status: dto.approval_status ?? existing.approval_status,
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
|
||||||
|
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
|
||||||
|
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
|
|
||||||
|
if (!was_approved && is_approved) {
|
||||||
|
await this.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
|
} else if (was_approved && !is_approved) {
|
||||||
|
await this.removeShift(email, employee_id, iso_date, type);
|
||||||
|
} else if (was_approved && is_approved) {
|
||||||
|
await this.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
updated.push({ ...mapRowToView(row), action: "update" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "update", leave_requests: updated };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toDateOnly = (iso: string): Date => {
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
throw new BadRequestException(`Invalid date: ${iso}`);
|
||||||
|
}
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
export const normalizeDates = (dates: string[]): string[] =>
|
||||||
|
Array.from(new Set(dates.map((iso) => toISODateKey(toDateOnly(iso)))));
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { LeaveRequestsService, normalizeDates, toDateOnly } from "./leave-request.service";
|
||||||
|
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
||||||
|
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
||||||
|
import { BadRequestException, Inject, Injectable, forwardRef } from "@nestjs/common";
|
||||||
|
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
|
import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
||||||
|
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
|
||||||
|
import { roundToQuarterHour } from "src/common/utils/date-utils";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SickLeaveRequestsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService,
|
||||||
|
private readonly sickService: SickLeaveService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
//handle distribution to the right service according to the selected action
|
||||||
|
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
|
switch (dto.action) {
|
||||||
|
case "create":
|
||||||
|
return this.createSick(dto);
|
||||||
|
case "update":
|
||||||
|
return this.leaveService.update(dto, LeaveTypes.SICK);
|
||||||
|
case "delete":
|
||||||
|
return this.leaveService.delete(dto, LeaveTypes.SICK);
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(`Unknown action: ${dto.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createSick(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
|
const email = dto.email.trim();
|
||||||
|
const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email);
|
||||||
|
const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.SICK);
|
||||||
|
const modifier = bank_code.modifier ?? 1;
|
||||||
|
const dates = normalizeDates(dto.dates);
|
||||||
|
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
||||||
|
const requested_hours_per_day = dto.requested_hours ?? 8;
|
||||||
|
|
||||||
|
const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) }));
|
||||||
|
const reference_date = entries.reduce(
|
||||||
|
(latest, entry) => (entry.date > latest ? entry.date : latest),
|
||||||
|
entries[0].date,
|
||||||
|
);
|
||||||
|
const total_payable_hours = await this.sickService.calculateSickLeavePay(
|
||||||
|
employee_id,
|
||||||
|
reference_date,
|
||||||
|
entries.length,
|
||||||
|
requested_hours_per_day,
|
||||||
|
modifier,
|
||||||
|
);
|
||||||
|
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
||||||
|
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
||||||
|
|
||||||
|
const created: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
|
for (const { iso, date } of entries) {
|
||||||
|
const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
|
where: {
|
||||||
|
leave_per_employee_date: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
leave_type: LeaveTypes.SICK,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
throw new BadRequestException(`Sick request already exists for ${iso}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
||||||
|
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
||||||
|
remaining_payable_hours = roundToQuarterHour(
|
||||||
|
Math.max(0, remaining_payable_hours - payable_rounded),
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = await this.prisma.leaveRequests.create({
|
||||||
|
data: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
bank_code_id: bank_code.id,
|
||||||
|
leave_type: LeaveTypes.SICK,
|
||||||
|
comment: dto.comment ?? "",
|
||||||
|
requested_hours: requested_hours_per_day,
|
||||||
|
payable_hours: payable_rounded,
|
||||||
|
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
|
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
|
await this.leaveService.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
created.push({ ...mapRowToView(row), action: "create" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "create", leave_requests: created };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { LeaveRequestsService, normalizeDates, toDateOnly } from "./leave-request.service";
|
||||||
|
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
||||||
|
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
||||||
|
import { BadRequestException, Inject, Injectable, forwardRef } from "@nestjs/common";
|
||||||
|
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
|
import { VacationService } from "src/modules/business-logics/services/vacation.service";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
||||||
|
import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
||||||
|
import { roundToQuarterHour } from "src/common/utils/date-utils";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VacationLeaveRequestsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
@Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService,
|
||||||
|
private readonly vacationService: VacationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
|
switch (dto.action) {
|
||||||
|
case "create":
|
||||||
|
return this.createVacation(dto);
|
||||||
|
case "update":
|
||||||
|
return this.leaveService.update(dto, LeaveTypes.VACATION);
|
||||||
|
case "delete":
|
||||||
|
return this.leaveService.delete(dto, LeaveTypes.VACATION);
|
||||||
|
default:
|
||||||
|
throw new BadRequestException(`Unknown action: ${dto.action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createVacation(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
|
const email = dto.email.trim();
|
||||||
|
const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email);
|
||||||
|
const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.VACATION);
|
||||||
|
const modifier = bank_code.modifier ?? 1;
|
||||||
|
const dates = normalizeDates(dto.dates);
|
||||||
|
const requested_hours_per_day = dto.requested_hours ?? 8;
|
||||||
|
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
||||||
|
|
||||||
|
const entries = dates
|
||||||
|
.map((iso) => ({ iso, date: toDateOnly(iso) }))
|
||||||
|
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||||
|
const start_date = entries[0].date;
|
||||||
|
const total_payable_hours = await this.vacationService.calculateVacationPay(
|
||||||
|
employee_id,
|
||||||
|
start_date,
|
||||||
|
entries.length,
|
||||||
|
modifier,
|
||||||
|
);
|
||||||
|
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
||||||
|
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
||||||
|
|
||||||
|
const created: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
|
for (const { iso, date } of entries) {
|
||||||
|
const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
|
where: {
|
||||||
|
leave_per_employee_date: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
leave_type: LeaveTypes.VACATION,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`);
|
||||||
|
|
||||||
|
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
||||||
|
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
||||||
|
remaining_payable_hours = roundToQuarterHour(
|
||||||
|
Math.max(0, remaining_payable_hours - payable_rounded),
|
||||||
|
);
|
||||||
|
|
||||||
|
const row = await this.prisma.leaveRequests.create({
|
||||||
|
data: {
|
||||||
|
employee_id: employee_id,
|
||||||
|
bank_code_id: bank_code.id,
|
||||||
|
payable_hours: payable_rounded,
|
||||||
|
requested_hours: requested_hours_per_day,
|
||||||
|
leave_type: LeaveTypes.VACATION,
|
||||||
|
comment: dto.comment ?? "",
|
||||||
|
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
|
||||||
|
date,
|
||||||
|
},
|
||||||
|
select: leaveRequestsSelect,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
|
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
|
await this.leaveService.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment);
|
||||||
|
}
|
||||||
|
created.push({ ...mapRowToView(row), action: "create" });
|
||||||
|
}
|
||||||
|
return { action: "create", leave_requests: created };
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user