feat(timesheet): replaced findAll by getPeriodByQuery with year, period_no and email query params.
This commit is contained in:
parent
2eadabbdb4
commit
301d5f2c9d
|
|
@ -1,4 +1,4 @@
|
|||
import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { TimesheetsQueryService } from '../services/timesheets-query.service';
|
||||
import { CreateTimesheetDto } from '../dtos/create-timesheet.dto';
|
||||
import { Timesheets } from '@prisma/client';
|
||||
|
|
@ -8,6 +8,7 @@ import { Roles as RoleEnum } from '.prisma/client';
|
|||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { TimesheetsCommandService } from '../services/timesheets-command.service';
|
||||
import { SearchTimesheetDto } from '../dtos/search-timesheet.dto';
|
||||
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
|
||||
|
||||
@ApiTags('Timesheets')
|
||||
@ApiBearerAuth('access-token')
|
||||
|
|
@ -15,8 +16,8 @@ import { SearchTimesheetDto } from '../dtos/search-timesheet.dto';
|
|||
@Controller('timesheets')
|
||||
export class TimesheetsController {
|
||||
constructor(
|
||||
private readonly timesheetsService: TimesheetsQueryService,
|
||||
private readonly timesheetsCommandService: TimesheetsCommandService,
|
||||
private readonly timesheetsQuery: TimesheetsQueryService,
|
||||
private readonly timesheetsCommand: TimesheetsCommandService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
|
|
@ -25,17 +26,18 @@ export class TimesheetsController {
|
|||
@ApiResponse({ status: 201, description: 'Timesheet created', type: CreateTimesheetDto })
|
||||
@ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
||||
create(@Body() dto: CreateTimesheetDto): Promise<Timesheets> {
|
||||
return this.timesheetsService.create(dto);
|
||||
return this.timesheetsQuery.create(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
@ApiOperation({ summary: 'Find all timesheets' })
|
||||
@ApiResponse({ status: 201, description: 'List of timesheet found', type: CreateTimesheetDto, isArray: true })
|
||||
@ApiResponse({ status: 400, description: 'List of timesheets not found' })
|
||||
@UsePipes(new ValidationPipe({transform: true, whitelist: true }))
|
||||
findAll(@Query() filters: SearchTimesheetDto): Promise<any[]> {
|
||||
return this.timesheetsService.findAll(filters);
|
||||
async getPeriodByQuery(
|
||||
@Query('year', ParseIntPipe ) year: number,
|
||||
@Query('period_no', ParseIntPipe ) period_no: number,
|
||||
@Query('email') email?: string
|
||||
): Promise<TimesheetPeriodDto> {
|
||||
if(!email || !email.trim()) throw new BadRequestException('Query param "email" is mandatory for this route.');
|
||||
return this.timesheetsQuery.findAll(year, period_no, email.trim());
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
|
@ -44,7 +46,7 @@ export class TimesheetsController {
|
|||
@ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto })
|
||||
@ApiResponse({ status: 400, description: 'Timesheet not found' })
|
||||
findOne(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
|
||||
return this.timesheetsService.findOne(id);
|
||||
return this.timesheetsQuery.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
|
|
@ -56,7 +58,7 @@ export class TimesheetsController {
|
|||
@Param('id', ParseIntPipe) id:number,
|
||||
@Body() dto: UpdateTimesheetDto,
|
||||
): Promise<Timesheets> {
|
||||
return this.timesheetsService.update(id, dto);
|
||||
return this.timesheetsQuery.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
|
|
@ -65,12 +67,12 @@ export class TimesheetsController {
|
|||
@ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto })
|
||||
@ApiResponse({ status: 400, description: 'Timesheet not found' })
|
||||
remove(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
|
||||
return this.timesheetsService.remove(id);
|
||||
return this.timesheetsQuery.remove(id);
|
||||
}
|
||||
|
||||
@Patch(':id/approval')
|
||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
|
||||
return this.timesheetsCommandService.updateApproval(id, isApproved);
|
||||
return this.timesheetsCommand.updateApproval(id, isApproved);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
45
src/modules/timesheets/dtos/timesheet-period.dto.ts
Normal file
45
src/modules/timesheets/dtos/timesheet-period.dto.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
export class ShiftDto {
|
||||
start: string;
|
||||
end : string;
|
||||
is_approved: boolean;
|
||||
}
|
||||
|
||||
export class ExpenseDto {
|
||||
amount: number;
|
||||
is_approved: boolean;
|
||||
}
|
||||
|
||||
export type DayShiftsDto = ShiftDto[];
|
||||
|
||||
export class DayExpensesDto {
|
||||
cash: ExpenseDto[] = [];
|
||||
km : ExpenseDto[] = [];
|
||||
[otherType:string]: ExpenseDto[] | any; //pour si on ajoute d'autre type de dépenses
|
||||
}
|
||||
|
||||
export class WeekDto {
|
||||
is_approved: boolean;
|
||||
shifts: {
|
||||
sun: DayShiftsDto;
|
||||
mon: DayShiftsDto;
|
||||
tue: DayShiftsDto;
|
||||
wed: DayShiftsDto;
|
||||
thu: DayShiftsDto;
|
||||
fri: DayShiftsDto;
|
||||
sat: DayShiftsDto;
|
||||
}
|
||||
expenses: {
|
||||
sun: DayExpensesDto;
|
||||
mon: DayExpensesDto;
|
||||
tue: DayExpensesDto;
|
||||
wed: DayExpensesDto;
|
||||
thu: DayExpensesDto;
|
||||
fri: DayExpensesDto;
|
||||
sat: DayExpensesDto;
|
||||
}
|
||||
}
|
||||
|
||||
export class TimesheetPeriodDto {
|
||||
week1: WeekDto;
|
||||
week2: WeekDto;
|
||||
}
|
||||
|
|
@ -7,8 +7,12 @@ import { OvertimeService } from 'src/modules/business-logics/services/overtime.s
|
|||
import { computeHours } from 'src/common/utils/date-utils';
|
||||
import { buildPrismaWhere } from 'src/common/shared/build-prisma-where';
|
||||
import { SearchTimesheetDto } from '../dtos/search-timesheet.dto';
|
||||
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
|
||||
import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers';
|
||||
|
||||
const ROUND_TO = 5;
|
||||
// deprecated (used with old findAll) const ROUND_TO = 5;
|
||||
type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean };
|
||||
type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean };
|
||||
|
||||
@Injectable()
|
||||
export class TimesheetsQueryService {
|
||||
|
|
@ -30,38 +34,63 @@ export class TimesheetsQueryService {
|
|||
});
|
||||
}
|
||||
|
||||
async findAll(filters: SearchTimesheetDto): Promise<any[]> {
|
||||
const where = buildPrismaWhere(filters);
|
||||
|
||||
//fetchs lists of shifts and expenses for a selected timesheet
|
||||
const rawlist = await this.prisma.timesheets.findMany({
|
||||
where, include: {
|
||||
shift: { include: {bank_code: true } },
|
||||
expense: { include: { bank_code: true } },
|
||||
employee: { include: { user : true } },
|
||||
},
|
||||
async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
|
||||
//finds the employee
|
||||
const employee = await this.prisma.employees.findFirst({
|
||||
where: { user: { is: { email } } },
|
||||
select: { id: true },
|
||||
});
|
||||
if(!employee) throw new NotFoundException(`no employee with email ${email} found`);
|
||||
|
||||
const detailedlist = await Promise.all(
|
||||
rawlist.map(async timesheet => {
|
||||
//detailed shifts
|
||||
const detailedShifts = timesheet.shift.map(shift => {
|
||||
const totalhours = computeHours(shift.start_time, shift.end_time, ROUND_TO);
|
||||
const regularHours = Math.min(8, totalhours);
|
||||
const dailyOvertime = this.overtime.getDailyOvertimeHours(shift.start_time, shift.end_time);
|
||||
const payRegular = regularHours * shift.bank_code.modifier;
|
||||
const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, shift.bank_code.modifier);
|
||||
return { ...shift, totalhours, payRegular, payOvertime };
|
||||
});
|
||||
//calculate overtime weekly
|
||||
const weeklyOvertimeHours = detailedShifts.length
|
||||
? await this.overtime.getWeeklyOvertimeHours(
|
||||
timesheet.employee_id,
|
||||
timesheet.shift[0].date): 0;
|
||||
return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours };
|
||||
//finds the period
|
||||
const period = await this.prisma.payPeriods.findFirst({
|
||||
where: { pay_year: year, pay_period_no: period_no },
|
||||
select: { period_start: true, period_end: true },
|
||||
});
|
||||
if(!period) throw new NotFoundException(`Period ${year}-${period_no} not found`);
|
||||
|
||||
const from = toUTCDateOnly(period.period_start);
|
||||
const to = endOfDayUTC(period.period_end);
|
||||
|
||||
//collects data from shifts and expenses
|
||||
const [ raw_shifts, raw_expenses] = await Promise.all([
|
||||
this.prisma.shifts.findMany({
|
||||
where: {
|
||||
timesheet: { is: { employee_id: employee.id } },
|
||||
date: { gte: from, lte: to },
|
||||
},
|
||||
select: { date: true,start_time: true, end_time: true, is_approved: true },
|
||||
orderBy: [{date: 'asc'}, { start_time: 'asc' }],
|
||||
}),
|
||||
);
|
||||
return detailedlist;
|
||||
this.prisma.expenses.findMany({
|
||||
where: {
|
||||
timesheet: { is: { employee_id: employee.id } },
|
||||
date: { gte: from, lte: to },
|
||||
},
|
||||
select: { date: true, amount: true, is_approved: true, bank_code: {
|
||||
select: { type: true } },
|
||||
},
|
||||
orderBy: { date: 'asc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
//Shift data mapping
|
||||
const shifts: ShiftRow[] = raw_shifts.map(shift => ({
|
||||
date: shift.date,
|
||||
start_time: shift.start_time,
|
||||
end_time: shift.end_time,
|
||||
is_approved: shift.is_approved ?? true,
|
||||
}));
|
||||
|
||||
const expenses: ExpenseRow[] = raw_expenses.map(expense => ({
|
||||
date: expense.date,
|
||||
amount: typeof (expense.amount as any)?.toNumber() === 'function' ?
|
||||
(expense.amount as any).toNumber() : Number(expense.amount),
|
||||
type: expense.bank_code?.type ?? 'CASH',
|
||||
is_approved: expense.is_approved ?? true,
|
||||
}));
|
||||
|
||||
return buildPeriod(period.period_start, period.period_end, shifts , expenses);
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<any> {
|
||||
|
|
|
|||
134
src/modules/timesheets/utils/timesheet.helpers.ts
Normal file
134
src/modules/timesheets/utils/timesheet.helpers.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { DayExpensesDto, DayShiftsDto, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto";
|
||||
|
||||
//makes the strings indexes for arrays
|
||||
export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
|
||||
export type DayKey = 'sun'|'mon'|'tue'|'wed'|'thu'|'fri'|'sat';
|
||||
|
||||
//DB line types
|
||||
type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean };
|
||||
type ExpenseRow = {date: Date; amount: number; type: string; is_approved?: boolean };
|
||||
|
||||
export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
|
||||
const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday
|
||||
return DAY_KEYS[index];
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
export function toUTCDateOnly(date: Date | string): Date {
|
||||
const d = new Date(date);
|
||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||
}
|
||||
|
||||
export function addDays(date:Date, days: number): Date {
|
||||
return new Date(date.getTime() + days * MS_PER_DAY);
|
||||
}
|
||||
|
||||
export function endOfDayUTC(date: Date | string): Date {
|
||||
const d = toUTCDateOnly(date);
|
||||
return new Date(d.getTime() + MS_PER_DAY - 1);
|
||||
}
|
||||
|
||||
export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): boolean {
|
||||
const time = date.getTime();
|
||||
return time >= start.getTime() && time <= end_inclusive.getTime();
|
||||
}
|
||||
|
||||
export function dayIndex(week_start: Date, date: Date): 1|2|3|4|5|6|7 {
|
||||
const diff = Math.floor((toUTCDateOnly(date).getTime() - toUTCDateOnly(week_start).getTime())/ MS_PER_DAY);
|
||||
const index = Math.min(6, Math.max(0, diff)) + 1;
|
||||
return index as 1|2|3|4|5|6|7;
|
||||
}
|
||||
|
||||
export function toTimeString(date: Date): string {
|
||||
const hours = String(date.getUTCHours()).padStart(2,'0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2,'0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
export function round2(num: number) {
|
||||
return Math.round(num * 100) / 100;
|
||||
}
|
||||
|
||||
export function makeEmptyDayShifts(): DayShiftsDto { return []; }
|
||||
|
||||
export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; }
|
||||
|
||||
export function makeEmptyWeek(): WeekDto {
|
||||
return {
|
||||
is_approved: true,
|
||||
shifts: {
|
||||
sun: makeEmptyDayShifts(),
|
||||
mon: makeEmptyDayShifts(),
|
||||
tue: makeEmptyDayShifts(),
|
||||
wed: makeEmptyDayShifts(),
|
||||
thu: makeEmptyDayShifts(),
|
||||
fri: makeEmptyDayShifts(),
|
||||
sat: makeEmptyDayShifts(),
|
||||
},
|
||||
expenses: {
|
||||
sun: makeEmptyDayExpenses(),
|
||||
mon: makeEmptyDayExpenses(),
|
||||
tue: makeEmptyDayExpenses(),
|
||||
wed: makeEmptyDayExpenses(),
|
||||
thu: makeEmptyDayExpenses(),
|
||||
fri: makeEmptyDayExpenses(),
|
||||
sat: makeEmptyDayExpenses(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function makeEmptyPeriod(): TimesheetPeriodDto {
|
||||
return { week1: makeEmptyWeek(), week2: makeEmptyWeek() };
|
||||
}
|
||||
|
||||
//needs ajusting according to DB's data for expenses types
|
||||
export function normalizeExpenseBucket(db_type: string): 'km' | 'cash' {
|
||||
const type = db_type.trim().toUpperCase();
|
||||
if(type.includes('KM') || type.includes('MILEAGE')) return 'km';
|
||||
return 'cash';
|
||||
}
|
||||
|
||||
export function buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): WeekDto {
|
||||
const week = makeEmptyWeek();
|
||||
let all_approved = true;
|
||||
|
||||
//Shifts mapped and filtered by dates
|
||||
const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end));
|
||||
for (const shift of week_shifts) {
|
||||
const key = dayKeyFromDate(shift.date, true);
|
||||
week.shifts[key].push({
|
||||
start: toTimeString(shift.start_time),
|
||||
end : toTimeString(shift.end_time),
|
||||
is_approved: shift.is_approved ?? true,
|
||||
} as ShiftDto);
|
||||
all_approved = all_approved && (shift.is_approved ?? true);
|
||||
}
|
||||
|
||||
//Expenses mapped and filtered by dates
|
||||
const week_expenses = expenses.filter(expense => isBetweenUTC(expense.date, week_start, week_end));
|
||||
for (const expense of week_expenses) {
|
||||
const key = dayKeyFromDate(expense.date, true);
|
||||
const bucket = normalizeExpenseBucket(expense.type);
|
||||
if (!Array.isArray(week.expenses[key][bucket])) week.expenses[key][bucket] = [];
|
||||
week.expenses[key][bucket].push({
|
||||
amount: round2(expense.amount),
|
||||
is_approved: expense.is_approved ?? true,
|
||||
});
|
||||
all_approved = all_approved && (expense.is_approved ?? true);
|
||||
}
|
||||
week.is_approved = all_approved;
|
||||
return week;
|
||||
}
|
||||
|
||||
export function buildPeriod( period_start: Date, period_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): TimesheetPeriodDto {
|
||||
const week1_start = toUTCDateOnly(period_start);
|
||||
const week1_end = endOfDayUTC(addDays(week1_start, 6));
|
||||
const week2_start = toUTCDateOnly(addDays(week1_start, 7));
|
||||
const week2_end = endOfDayUTC(period_end);
|
||||
|
||||
return {
|
||||
week1: buildWeek(week1_start, week1_end, shifts, expenses),
|
||||
week2: buildWeek(week2_start, week2_end, shifts, expenses),
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user