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": {
|
||||
"operationId": "LeaveRequestController_upsertHoliday",
|
||||
"operationId": "LeaveRequestController_upsertLeaveRequest",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpsertHolidayDto"
|
||||
"$ref": "#/components/schemas/UpsertLeaveRequestDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2474,7 +2474,7 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"UpsertHolidayDto": {
|
||||
"UpsertLeaveRequestDto": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../../../prisma/prisma.service";
|
||||
import { computeHours, getWeekStart } from "src/common/utils/date-utils";
|
||||
import { PrismaService } from "../../../prisma/prisma.service";
|
||||
|
||||
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 { PrismaService } from "../../../prisma/prisma.service";
|
||||
import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils";
|
||||
|
||||
@Injectable()
|
||||
export class SickLeaveService {
|
||||
|
|
@ -9,8 +9,17 @@ export class SickLeaveService {
|
|||
private readonly logger = new Logger(SickLeaveService.name);
|
||||
|
||||
//switch employeeId for email
|
||||
async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number):
|
||||
Promise<number> {
|
||||
async calculateSickLeavePay(
|
||||
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
|
||||
const period_start = getYearStart(reference_date);
|
||||
const period_end = reference_date;
|
||||
|
|
@ -19,18 +28,19 @@ export class SickLeaveService {
|
|||
const shifts = await this.prisma.shifts.findMany({
|
||||
where: {
|
||||
timesheet: { employee_id: employee_id },
|
||||
date: { gte: period_start, lte: period_end},
|
||||
date: { gte: period_start, lte: period_end },
|
||||
},
|
||||
select: { date: true },
|
||||
});
|
||||
|
||||
//count the amount of worked days
|
||||
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;
|
||||
this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()}
|
||||
-> ${period_end.toDateString()}`);
|
||||
this.logger.debug(
|
||||
`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} -> ${period_end.toDateString()}`,
|
||||
);
|
||||
|
||||
//less than 30 worked days returns 0
|
||||
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
|
||||
|
||||
//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);
|
||||
let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 +
|
||||
(period_end.getMonth() - first_bonus_date.getMonth()) + 1;
|
||||
if(months < 0) months = 0;
|
||||
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;
|
||||
acquired_days += months;
|
||||
|
||||
//cap of 10 days
|
||||
if (acquired_days > 10) acquired_days = 10;
|
||||
|
||||
this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()}
|
||||
, bonusMonths = ${months}, acquired Days = ${acquired_days}`);
|
||||
this.logger.debug(
|
||||
`Sick leave: threshold Date = ${threshold_date.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquired_days}`,
|
||||
);
|
||||
|
||||
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}`);
|
||||
const raw_hours = payable_days * hours_per_day * modifier;
|
||||
const rounded = roundToQuarterHour(raw_hours);
|
||||
this.logger.debug(
|
||||
`Sick leave pay: days= ${payable_days}, hoursPerDay= ${hours_per_day}, modifier= ${modifier}, hours= ${rounded}`,
|
||||
);
|
||||
return rounded;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,15 +6,7 @@ export class VacationService {
|
|||
constructor(private readonly prisma: PrismaService) {}
|
||||
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
|
||||
async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise<number> {
|
||||
//fetch hiring date
|
||||
|
|
|
|||
|
|
@ -1,21 +1,21 @@
|
|||
import { Body, Controller, Post } from "@nestjs/common";
|
||||
import { HolidayLeaveRequestsService } from "../services/holiday-leave-requests.service";
|
||||
|
||||
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')
|
||||
@ApiBearerAuth('access-token')
|
||||
// @UseGuards()
|
||||
@Controller('leave-requests')
|
||||
export class LeaveRequestController {
|
||||
constructor(private readonly leave_service: HolidayLeaveRequestsService){}
|
||||
constructor(private readonly leave_service: LeaveRequestsService){}
|
||||
|
||||
@Post('holiday')
|
||||
async upsertHoliday(@Body() dto: UpsertHolidayDto) {
|
||||
const { action, leave_requests } = await this.leave_service.handleHoliday(dto);
|
||||
@Post('upsert')
|
||||
async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
|
||||
const { action, leave_requests } = await this.leave_service.handle(dto);
|
||||
return { action, leave_requests };
|
||||
}
|
||||
}q
|
||||
|
||||
//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 { HolidayService } from "../business-logics/services/holiday.service";
|
||||
import { LeaveRequestController } from "./controllers/leave-requests.controller";
|
||||
import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
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({
|
||||
imports: [BusinessLogicsModule],
|
||||
imports: [BusinessLogicsModule, ShiftsModule],
|
||||
controllers: [LeaveRequestController],
|
||||
providers: [
|
||||
HolidayService,
|
||||
VacationLeaveRequestsService,
|
||||
SickLeaveRequestsService,
|
||||
HolidayLeaveRequestsService,
|
||||
PrismaService,
|
||||
ShiftsCommandService,
|
||||
LeaveRequestsService,
|
||||
PrismaService
|
||||
],
|
||||
exports: [
|
||||
LeaveRequestsService,
|
||||
],
|
||||
exports: [HolidayLeaveRequestsService],
|
||||
})
|
||||
|
||||
export class LeaveRequestsModule {}
|
||||
|
|
@ -1,69 +1,52 @@
|
|||
import {
|
||||
BadRequestException,
|
||||
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 { 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 { HolidayService } from 'src/modules/business-logics/services/holiday.service';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { mapRowToView } from '../mappers/leave-requests.mapper';
|
||||
import { leaveRequestsSelect } from '../utils/leave-requests.select';
|
||||
|
||||
interface HolidayUpsertResult {
|
||||
action: HolidayUpsertAction;
|
||||
leave_requests: LeaveRequestViewDto[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HolidayLeaveRequestsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly holidayService: HolidayService,
|
||||
private readonly shiftsCommand: ShiftsCommandService,
|
||||
@Inject(forwardRef(() => LeaveRequestsService)) private readonly leaveService: LeaveRequestsService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async handleHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
|
||||
//handle distribution to the right service according to the selected action
|
||||
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||
switch (dto.action) {
|
||||
case 'create':
|
||||
return this.createHoliday(dto);
|
||||
case 'update':
|
||||
return this.updateHoliday(dto);
|
||||
return this.leaveService.update(dto, LeaveTypes.HOLIDAY);
|
||||
case 'delete':
|
||||
return this.deleteHoliday(dto);
|
||||
return this.leaveService.delete(dto, LeaveTypes.HOLIDAY);
|
||||
default:
|
||||
throw new BadRequestException(`Unknown action: ${dto.action}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Create
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private async createHoliday(dto: UpsertHolidayDto): Promise<HolidayUpsertResult> {
|
||||
private async createHoliday(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||
const email = dto.email.trim();
|
||||
const employeeId = await this.resolveEmployeeIdByEmail(email);
|
||||
const bankCode = await this.resolveHolidayBankCode();
|
||||
const employee_id = await this.leaveService.resolveEmployeeIdByEmail(email);
|
||||
const bank_code = await this.leaveService.resolveBankCodeByType(LeaveTypes.HOLIDAY);
|
||||
const dates = normalizeDates(dto.dates);
|
||||
if (!dates.length) {
|
||||
throw new BadRequestException('Dates array must not be empty');
|
||||
}
|
||||
if (!dates.length) throw new BadRequestException('Dates array must not be empty');
|
||||
|
||||
const created: LeaveRequestViewDto[] = [];
|
||||
|
||||
for (const isoDate of dates) {
|
||||
const date = toDateOnly(isoDate);
|
||||
for (const iso_date of dates) {
|
||||
const date = toDateOnly(iso_date);
|
||||
|
||||
const existing = await this.prisma.leaveRequests.findUnique({
|
||||
where: {
|
||||
leave_per_employee_date: {
|
||||
employee_id: employeeId,
|
||||
employee_id: employee_id,
|
||||
leave_type: LeaveTypes.HOLIDAY,
|
||||
date,
|
||||
},
|
||||
|
|
@ -71,14 +54,14 @@ export class HolidayLeaveRequestsService {
|
|||
select: { id: true },
|
||||
});
|
||||
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({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
bank_code_id: bankCode.id,
|
||||
employee_id: employee_id,
|
||||
bank_code_id: bank_code.id,
|
||||
leave_type: LeaveTypes.HOLIDAY,
|
||||
date,
|
||||
comment: dto.comment ?? '',
|
||||
|
|
@ -91,7 +74,7 @@ export class HolidayLeaveRequestsService {
|
|||
|
||||
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||
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' });
|
||||
|
|
@ -99,223 +82,5 @@ export class HolidayLeaveRequestsService {
|
|||
|
||||
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