refactor(timesheets): dried findAll methods to centralize shareable methods

This commit is contained in:
Matthieu Haineault 2025-10-10 11:34:39 -04:00
parent 9efdafb20f
commit 2de8db6212
13 changed files with 145 additions and 92 deletions

View File

@ -1,8 +1,8 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto";
import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers"; import { round2, toUTCDateOnly } from "src/modules/timesheets/timesheet.helpers";
import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types"; import { EXPENSE_TYPES } from "src/modules/timesheets/timesheet.types";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
@Injectable() @Injectable()

View File

@ -0,0 +1,11 @@
export const EXPENSE_SELECT = {
date: true,
amount: true,
mileage: true,
comment: true,
is_approved: true,
supervisor_comment: true,
bank_code: { select: { type: true } },
} as const;
export const EXPENSE_ASC_ORDER = { date: 'asc' as const };

View File

@ -0,0 +1,4 @@
export const PAY_PERIOD_SELECT = {
period_start: true,
period_end: true,
} as const;

View File

@ -0,0 +1,12 @@
export const SHIFT_SELECT = {
date: true,
start_time: true,
end_time: true,
comment: true,
is_approved: true,
is_remote: true,
bank_code: {select: { type: true } },
} as const;
export const SHIFT_ASC_ORDER = [{date: 'asc' as const}, {start_time: 'asc' as const}];

View File

@ -5,7 +5,7 @@ import { PrismaService } from "src/prisma/prisma.service";
import { TimesheetsQueryService } from "./timesheets-query.service"; import { TimesheetsQueryService } from "./timesheets-query.service";
import { CreateTimesheetDto } from "../dtos/create-timesheet.dto"; import { CreateTimesheetDto } from "../dtos/create-timesheet.dto";
import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils"; import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils";
import { parseISODate, parseHHmm } from "../utils/timesheet.helpers"; import { parseISODate, parseHHmm } from "../timesheet.helpers";
import { TimesheetDto } from "../dtos/timesheet-period.dto"; import { TimesheetDto } from "../dtos/timesheet-period.dto";
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils"; import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils";

View File

@ -1,12 +1,14 @@
import { endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers'; import { endOfDayUTC, toHHmm, toNum, toRangeFromPeriod, toUTCDateOnly } from '../timesheet.helpers';
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
import { PrismaService } from 'src/prisma/prisma.service'; import { PrismaService } from 'src/prisma/prisma.service';
import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
import { ShiftRow, ExpenseRow } from '../types/timesheet.types'; import { ShiftRow, ExpenseRow } from '../timesheet.types';
import { buildPeriod } from '../utils/timesheet.utils'; import { buildPeriod } from '../timesheet.utils';
import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils'; import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils';
import { TimesheetSelectors } from '../timesheet.selectors';
import { mapExpenseRow, mapShiftRow } from '../timesheet.mappers';
@Injectable() @Injectable()
@ -14,93 +16,32 @@ export class TimesheetsQueryService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver, private readonly emailResolver: EmailToIdResolver,
private readonly fullNameResolver: FullNameResolver private readonly fullNameResolver: FullNameResolver,
private readonly selectors: TimesheetSelectors,
) {} ) {}
async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> { async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
//finds the employee using email //finds the employee using email
const employee_id = await this.emailResolver.findIdByEmail(email); const employee_id = await this.emailResolver.findIdByEmail(email);
if(!employee_id) throw new NotFoundException(`employee with email : ${email} not found`);
//finds the employee full name using employee_id //finds the employee full name using employee_id
const full_name = await this.fullNameResolver.resolveFullName(employee_id); const full_name = await this.fullNameResolver.resolveFullName(employee_id);
if(!full_name) throw new NotFoundException(`employee with id: ${employee_id} not found`)
//finds the period //finds the pay period using year and period_no
const period = await this.prisma.payPeriods.findFirst({ const period = await this.selectors.getPayPeriod(year, period_no);
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); //finds start and end dates
const to = endOfDayUTC(period.period_end); const{ from, to } = toRangeFromPeriod(period);
const raw_shifts = await this.prisma.shifts.findMany({ //finds all shifts from selected period
where: { const [raw_shifts, raw_expenses] = await Promise.all([
timesheet: { is: { employee_id: employee_id } }, this.selectors.getShifts(employee_id, from, to),
date: { gte: from, lte: to }, this.selectors.getExpenses(employee_id, from, to),
}, ]);
select: {
date: true,
start_time: true,
end_time: true,
comment: true,
is_approved: true,
is_remote: true,
bank_code: { select: { type: true } },
},
orderBy:[ { date:'asc'}, { start_time: 'asc'} ],
});
const raw_expenses = await this.prisma.expenses.findMany({
where: {
timesheet: { is: { employee_id: employee_id } },
date: { gte: from, lte: to },
},
select: {
date: true,
amount: true,
mileage: true,
comment: true,
is_approved: true,
supervisor_comment: true,
bank_code: { select: { type: true } },
},
orderBy: { date: 'asc' },
});
const toNum = (value: any) =>
value && typeof value.toNumber === 'function' ? value.toNumber() :
typeof value === 'number' ? value :
value ? Number(value) : 0;
// data mapping // data mapping
const shifts: ShiftRow[] = raw_shifts.map(shift => ({ const shifts = raw_shifts.map(mapShiftRow);
date: shift.date, const expenses = raw_expenses.map(mapExpenseRow);
start_time: shift.start_time,
end_time: shift.end_time,
comment: shift.comment ?? '',
is_approved: shift.is_approved ?? true,
is_remote: shift.is_remote ?? true,
type: String(shift.bank_code?.type ?? '').toUpperCase(),
}));
const expenses: ExpenseRow[] = raw_expenses.map(expense => ({
type: String(expense.bank_code?.type ?? '').toUpperCase(),
date: expense.date,
amount: toNum(expense.amount),
mileage: toNum(expense.mileage),
comment: expense.comment ?? '',
is_approved: expense.is_approved ?? true,
supervisor_comment: expense.supervisor_comment ?? '',
}));
return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name); return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name);
} }

View File

@ -1,5 +1,4 @@
import { MS_PER_DAY, DayKey, DAY_KEYS } from "../types/timesheet.types"; import { MS_PER_DAY, DayKey, DAY_KEYS } from "./timesheet.types";
export function toUTCDateOnly(date: Date | string): Date { export function toUTCDateOnly(date: Date | string): Date {
const d = new Date(date); const d = new Date(date);
@ -43,7 +42,6 @@ export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
export const toHHmm = (date: Date) => date.toISOString().slice(11, 16); export const toHHmm = (date: Date) => date.toISOString().slice(11, 16);
//create shifts within timesheet's week - employee overview functions
export function parseISODate(iso: string): Date { export function parseISODate(iso: string): Date {
const [ y, m, d ] = iso.split('-').map(Number); const [ y, m, d ] = iso.split('-').map(Number);
return new Date(y, (m ?? 1) - 1, d ?? 1); return new Date(y, (m ?? 1) - 1, d ?? 1);
@ -54,3 +52,15 @@ export function parseHHmm(t: string): Date {
return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0); return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0);
} }
export const toNum = (value: any) =>
value && typeof value.toNumber === 'function' ? value.toNumber() :
typeof value === 'number' ? value :
value ? Number(value) : 0;
export const upper = (s?: string | null) => String(s ?? '').toUpperCase();
export const toRangeFromPeriod = (period: { period_start: Date; period_end: Date }) => ({
from: toUTCDateOnly(period.period_start),
to: endOfDayUTC(period.period_end),
});

View File

@ -1,6 +1,46 @@
import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "../dtos/timesheet-period.dto"; import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "./dtos/timesheet-period.dto";
import { ExpensesAmount } from "../types/timesheet.types"; import { ExpenseRow, ExpensesAmount, ShiftRow } from "./timesheet.types";
import { addDays, shortDate } from "../utils/timesheet.helpers"; import { addDays, shortDate, toNum, upper } from "./timesheet.helpers";
import { Prisma } from "@prisma/client";
//mappers
export const mapShiftRow = (shift: {
date: Date;
start_time: Date;
end_time: Date;
comment?: string | null;
is_approved: boolean;
is_remote: boolean;
bank_code: { type: string };
}): ShiftRow => ({
date: shift.date,
start_time: shift.start_time,
end_time: shift.end_time,
comment: shift.comment ?? '',
is_approved: shift.is_approved,
is_remote: shift.is_remote,
type: upper(shift.bank_code.type),
});
export const mapExpenseRow = (expense: {
date: Date;
amount: Prisma.Decimal | number | null;
mileage: Prisma.Decimal | number | null;
comment?: string | null;
is_approved: boolean;
supervisor_comment?: string|null;
bank_code: { type: string },
}): ExpenseRow => ({
date: expense.date,
amount: toNum(expense.amount),
mileage: toNum(expense.mileage),
comment: expense.comment ?? '',
is_approved: expense.is_approved,
supervisor_comment: expense.supervisor_comment ?? '',
type: upper(expense.bank_code.type),
});
// Factories // Factories
export function makeEmptyDayExpenses(): DayExpensesDto { export function makeEmptyDayExpenses(): DayExpensesDto {

View File

@ -0,0 +1,35 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { SHIFT_ASC_ORDER, SHIFT_SELECT } from "../shared/selects/shifts.select";
import { PAY_PERIOD_SELECT } from "../shared/selects/pay-periods.select";
import { EXPENSE_ASC_ORDER, EXPENSE_SELECT } from "../shared/selects/expenses.select";
@Injectable()
export class TimesheetSelectors {
constructor(readonly prisma: PrismaService){}
async getPayPeriod(pay_year: number, pay_period_no: number) {
const period = await this.prisma.payPeriods.findFirst({
where: { pay_year, pay_period_no },
select: PAY_PERIOD_SELECT ,
});
if(!period) throw new NotFoundException(`period ${pay_year}-${pay_period_no} not found`);
return period;
}
async getShifts(employee_id: number, from: Date, to: Date) {
return this.prisma.shifts.findMany({
where: {timesheet: { is: { employee_id } }, date: { gte: from, lte: to } },
select: SHIFT_SELECT,
orderBy: SHIFT_ASC_ORDER,
});
}
async getExpenses(employee_id: number, from: Date, to: Date) {
return this.prisma.expenses.findMany({
where: { timesheet: {is: { employee_id } }, date: { gte: from, lte: to } },
select: EXPENSE_SELECT,
orderBy: EXPENSE_ASC_ORDER,
});
}
}

View File

@ -1,13 +1,13 @@
import { import {
DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow, MS_PER_HOUR, DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow, MS_PER_HOUR,
SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount
} from "../types/timesheet.types"; } from "./timesheet.types";
import { import {
isBetweenUTC, dayKeyFromDate, toTimeString, round2, isBetweenUTC, dayKeyFromDate, toTimeString, round2,
toUTCDateOnly, endOfDayUTC, addDays toUTCDateOnly, endOfDayUTC, addDays
} from "./timesheet.helpers"; } from "./timesheet.helpers";
import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "../dtos/timesheet-period.dto"; import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "./dtos/timesheet-period.dto";
import { makeAmounts, makeEmptyWeek } from "../mappers/timesheet.mappers"; import { makeAmounts, makeEmptyWeek } from "./timesheet.mappers";
import { toDateString } from "src/modules/pay-periods/utils/pay-year.util"; import { toDateString } from "src/modules/pay-periods/utils/pay-year.util";
export function buildWeek( export function buildWeek(