Merge branch 'main' of git.targo.ca:Targo/targo_backend

This commit is contained in:
Nicolas Drolet 2025-10-21 10:51:06 -04:00
commit 88f7c0cb0e
18 changed files with 364 additions and 261 deletions

View File

@ -36,7 +36,7 @@
"seed:12": "tsx prisma/mock-seeds-scripts/12-expenses.ts",
"seed:13": "tsx prisma/mock-seeds-scripts/13-expenses-archive.ts",
"seed:14": "tsx prisma/mock-seeds-scripts/14-oauth-sessions.ts",
"seed:all": "npm run seed:01 && npm run seed:02 && npm run seed:03 && npm run seed:04 && npm run seed:05 && npm run seed:06 && npm run seed:07 && npm run seed:08 && npm run seed:09 && npm run seed:10 && npm run seed:11 && npm run seed:12 && npm run seed:13 && npm run seed:14",
"seed:all": "npm run seed:01 && npm run seed:02 && npm run seed:03 && npm run seed:09 && npm run seed:10 && npm run seed:12 && npm run seed:14",
"db:reseed": "npm run db:reset && npm run seed:all"
},
"dependencies": {

View File

@ -4,7 +4,7 @@ const prisma = new PrismaClient();
// ====== Config ======
const PREVIOUS_WEEKS = 16; // nombre de semaines à créer (passé)
const INCLUDE_CURRENT = false; // true si tu veux aussi la semaine courante
const INCLUDE_CURRENT = true; // true si tu veux aussi la semaine courante
// Dimanche (UTC) de la semaine courante
function sundayOfThisWeekUTC(now = new Date()) {

View File

@ -14,26 +14,29 @@ const HARD_END = 19 * 60 + 30; // 19:30
function timeAt(hour: number, minute: number) {
return new Date(Date.UTC(1970, 0, 1, hour, minute, 0));
}
function mondayOfThisWeekUTC(now = new Date()) {
// Ancre SEMAINE = DIMANCHE (UTC)
function sundayOfThisWeekUTC(now = new Date()) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const day = d.getUTCDay();
const diffToMonday = (day + 6) % 7;
d.setUTCDate(d.getUTCDate() - diffToMonday);
const day = d.getUTCDay(); // 0 = Dim
d.setUTCDate(d.getUTCDate() - day); // recule jusqu'au dimanche
d.setUTCHours(0, 0, 0, 0);
return d;
}
function weekDatesFromMonday(monday: Date) {
return Array.from({ length: 5 }, (_, i) => {
const d = new Date(monday);
d.setUTCDate(monday.getUTCDate() + i);
return d;
});
}
function mondayNWeeksBefore(monday: Date, n: number) {
const d = new Date(monday);
function sundayNWeeksBefore(sunday: Date, n: number) {
const d = new Date(sunday);
d.setUTCDate(d.getUTCDate() - n * 7);
return d;
}
// Génère L→V à partir du dimanche (Lundi = dimanche + 1)
function weekDatesMonToFriFromSunday(sunday: Date) {
return Array.from({ length: 5 }, (_, i) => {
const d = new Date(sunday);
d.setUTCDate(sunday.getUTCDate() + (i + 1)); // +1..+5
return d;
});
}
function rndInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
@ -46,12 +49,9 @@ function addMinutes(h: number, m: number, delta: number) {
const mm = ((total % 60) + 60) % 60;
return { h: hh, m: mm };
}
// Aligne vers le multiple de INCR le plus proche
function quantize(mins: number): number {
const q = Math.round(mins / INCR) * INCR;
return q;
return Math.round(mins / INCR) * INCR;
}
// Tire un multiple de INCR dans [min,max] (inclus), supposés entiers minutes
function rndQuantized(min: number, max: number): number {
const qmin = Math.ceil(min / INCR);
const qmax = Math.floor(max / INCR);
@ -59,19 +59,9 @@ function rndQuantized(min: number, max: number): number {
return q * INCR;
}
// Helper: garantit le timesheet de la semaine (upsert)
async function getOrCreateTimesheet(employee_id: number, start_date: Date) {
return prisma.timesheets.upsert({
where: { employee_id_start_date: { employee_id, start_date } },
update: {},
create: { employee_id, start_date, is_approved: Math.random() < 0.3 },
select: { id: true },
});
}
async function main() {
// --- Bank codes (pondérés: surtout G1 = régulier) ---
const BANKS = ['G1', 'G56', 'G48','G105','G104', 'G305'] as const;
const BANKS = ['G1', 'G56', 'G48', 'G105', 'G104', 'G305'] as const;
const WEIGHTED_CODES = [
'G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1',
'G56','G48','G104','G105','G305','G1','G1','G1','G1','G1','G1'
@ -85,50 +75,62 @@ async function main() {
for (const c of BANKS) {
if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`);
}
for (const c of Array.from(new Set(WEIGHTED_CODES))) {
if (!bcMap.has(c)) throw new Error(`Bank code manquant dans WEIGHTED_CODES: ${c}`);
}
const employees = await prisma.employees.findMany({ select: { id: true } });
if (!employees.length) {
console.log('Aucun employé — rien à insérer.');
// ====== Fenêtre de semaines à remplir (d'après les timesheets existants) ======
const sundayThisWeek = sundayOfThisWeekUTC();
const minSunday = sundayNWeeksBefore(sundayThisWeek, PREVIOUS_WEEKS);
const maxSunday = sundayThisWeek;
// Récupère les timesheets existants dans la fenêtre (sans en créer)
const timesheets = await prisma.timesheets.findMany({
where: {
start_date: {
gte: minSunday,
lte: INCLUDE_CURRENT ? maxSunday : sundayNWeeksBefore(maxSunday, 1), // exclut la semaine courante si demandé
},
},
select: { id: true, employee_id: true, start_date: true },
orderBy: [{ start_date: 'desc' }, { employee_id: 'asc' }],
});
if (!timesheets.length) {
console.log('Aucun timesheet existant trouvé dans la fenêtre demandée — aucun shift créé.');
return;
}
const mondayThisWeek = mondayOfThisWeekUTC();
const mondays: Date[] = [];
if (INCLUDE_CURRENT) mondays.push(mondayThisWeek);
for (let n = 1; n <= PREVIOUS_WEEKS; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n));
let created = 0;
for (let wi = 0; wi < mondays.length; wi++) {
const monday = mondays[wi];
const days = weekDatesFromMonday(monday);
// Pour chaque timesheet existant, on génère les shifts L→V rattachés à son id
for (const ts of timesheets) {
const sunday = new Date(ts.start_date); // ancre = dimanche
const days = weekDatesMonToFriFromSunday(sunday); // L→V
for (let ei = 0; ei < employees.length; ei++) {
const e = employees[ei];
// Optionnel : si tu veux éviter de dupliquer des shifts, décommente :
// const existingCount = await prisma.shifts.count({ where: { timesheet_id: ts.id } });
// if (existingCount > 0) continue;
// Cible hebdo 3545h, multiple de 15 min
// On paramètre le pattern à partir de l'employee_id pour varier un peu
const baseStartH = 7 + (ts.employee_id % 3); // 7,8,9
const baseStartM = ((ts.employee_id * 15) % 60); // 0,15,30,45
const weeklyTargetMin = rndQuantized(35 * 60, 45 * 60);
// Start de base (7:00, 7:15, 7:30, 7:45, 8:00, 8:15, 8:30, 8:45, 9:00 ...)
const baseStartH = 7 + (ei % 3); // 7,8,9
const baseStartM = ( (ei * 15) % 60 ); // aligné 15 min
// Planification journalière (5 jours) ~8h ± 45 min, quantisée 15 min
// Planification journalière (5 jours) ~8h ± 45 min
const plannedDaily: number[] = [];
for (let d = 0; d < 5; d++) {
const jitter = rndInt(-3, 3) * INCR; // -45..+45 par pas de 15
const jitter = rndInt(-3, 3) * INCR; // -45..+45
const base = 8 * 60 + jitter;
plannedDaily.push(quantize(clamp(base, DAY_MIN, DAY_MAX)));
}
// Ajuster le 5e jour pour atteindre la cible hebdo exactement (par pas de 15)
// Ajuste le 5e jour pour matcher la cible hebdo
const sumFirst4 = plannedDaily.slice(0, 4).reduce((a, b) => a + b, 0);
plannedDaily[4] = quantize(clamp(weeklyTargetMin - sumFirst4, DAY_MIN, DAY_MAX));
// Corriger le petit écart restant (devrait être multiple de 15) en redistribuant ±15
// Fine tuning ±15
let diff = weeklyTargetMin - plannedDaily.reduce((a, b) => a + b, 0);
const step = diff > 0 ? INCR : -INCR;
let guard = 100; // anti-boucle
let guard = 100;
while (diff !== 0 && guard-- > 0) {
for (let d = 0; d < 5 && diff !== 0; d++) {
const next = plannedDaily[d] + step;
@ -139,39 +141,33 @@ async function main() {
}
}
// Upsert du timesheet (semaine)
const ts = await getOrCreateTimesheet(e.id, mondayOfThisWeekUTC(days[0]));
for (let di = 0; di < 5; di++) {
const date = days[di];
const targetWorkMin = plannedDaily[di]; // multiple de 15
const date = days[di]; // Lundi..Vendredi
const targetWorkMin = plannedDaily[di];
// Départ ~ base + jitter (par pas de 15 min aussi)
// Départ ~ base + jitter
const startJitter = rndInt(-1, 2) * INCR; // -15,0,+15,+30
const { h: startH, m: startM } = addMinutes(baseStartH, baseStartM, startJitter);
// Pause: entre 11:00 et 14:00, mais pas avant start+3h ni après start+6h (le tout quantisé 15)
// Pause: entre 11:00 et 14:00, bornée par start+3h .. start+6h
const earliestLunch = Math.max((startH * 60 + startM) + 3 * 60, 11 * 60);
const latestLunch = Math.min((startH * 60 + startM) + 6 * 60, 14 * 60);
const lunchStartMin = rndQuantized(earliestLunch, latestLunch);
const lunchDur = rndQuantized(30, 120); // 30..120 min en pas de 15
const lunchDur = rndQuantized(30, 120);
const lunchEndMin = lunchStartMin + lunchDur;
// Travail = (lunchStart - start) + (end - lunchEnd)
const morningWork = Math.max(0, lunchStartMin - (startH * 60 + startM)); // multiple de 15
let afternoonWork = Math.max(60, targetWorkMin - morningWork); // multiple de 15 (diff de deux multiples de 15)
if (afternoonWork % INCR !== 0) {
// sécurité (ne devrait pas arriver)
afternoonWork = quantize(afternoonWork);
}
const morningWork = Math.max(0, lunchStartMin - (startH * 60 + startM));
let afternoonWork = Math.max(60, targetWorkMin - morningWork);
if (afternoonWork % INCR !== 0) afternoonWork = quantize(afternoonWork);
// Fin de journée (quantisée par construction)
// Fin quantisée + borne max
const endMinRaw = lunchEndMin + afternoonWork;
const endMin = Math.min(endMinRaw, HARD_END);
// Bank codes variés
const bcMorningCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)];
const bcAfternoonCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)];
const bcAfternoonCode= WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)];
const bcMorningId = bcMap.get(bcMorningCode)!;
const bcAfternoonId = bcMap.get(bcAfternoonCode)!;
@ -181,7 +177,7 @@ async function main() {
data: {
timesheet_id: ts.id,
bank_code_id: bcMorningId,
comment: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} - ${bcMorningCode}`,
comment: `Matin J${di + 1} (sem ${sunday.toISOString().slice(0, 10)}) emp ${ts.employee_id} - ${bcMorningCode}`,
date,
start_time: timeAt(startH, startM),
end_time: timeAt(lunchStartHM.h, lunchStartHM.m),
@ -190,7 +186,7 @@ async function main() {
});
created++;
// Shift après-midi (si >= 30 min — sera de toute façon multiple de 15)
// Shift après-midi (si >= 30 min)
const pmDuration = endMin - lunchEndMin;
if (pmDuration >= 30) {
const lunchEndHM = { h: Math.floor(lunchEndMin / 60), m: lunchEndMin % 60 };
@ -199,7 +195,7 @@ async function main() {
data: {
timesheet_id: ts.id,
bank_code_id: bcAfternoonId,
comment: `Après-midi J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id}${bcAfternoonCode}`,
comment: `Après-midi J${di + 1} (sem ${sunday.toISOString().slice(0, 10)}) emp ${ts.employee_id}${bcAfternoonCode}`,
date,
start_time: timeAt(lunchEndHM.h, lunchEndHM.m),
end_time: timeAt(finalEndHM.h, finalEndHM.m),
@ -208,13 +204,13 @@ async function main() {
});
created++;
} else {
// Fallback très rare : un seul shift couvrant la journée (tout en multiples de 15)
// Fallback: un seul shift couvrant la journée
const fallbackEnd = addMinutes(startH, startM, targetWorkMin + lunchDur);
await prisma.shifts.create({
data: {
timesheet_id: ts.id,
bank_code_id: bcMap.get('G1')!,
comment: `Fallback J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — G1`,
comment: `Fallback J${di + 1} (sem ${sunday.toISOString().slice(0, 10)}) emp ${ts.employee_id} — G1`,
date,
start_time: timeAt(startH, startM),
end_time: timeAt(fallbackEnd.h, fallbackEnd.m),
@ -225,10 +221,9 @@ async function main() {
}
}
}
}
const total = await prisma.shifts.count();
console.log(`✓ Shifts créés: ${created} | total en DB: ${total} (${INCLUDE_CURRENT ? 'inclut semaine courante, ' : ''}${PREVIOUS_WEEKS} sem passées, L→V, 2 shifts/jour, pas de décimaux foireux})`);
console.log(`✓ Shifts créés: ${created} | total en DB: ${total} (${INCLUDE_CURRENT ? 'inclut semaine courante, ' : ''}${PREVIOUS_WEEKS} sem passées, Dim ancre + L→V, 2 shifts/jour, **aucun timesheet créé**})`);
}
main().finally(() => prisma.$disconnect());

View File

@ -1,3 +1,4 @@
import { NotFoundException } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
@ -7,25 +8,24 @@ const WEEKS_BACK = 4; // 4 semaines avant + semaine courante
const INCLUDE_CURRENT = true; // inclure la semaine courante
const STEP_CENTS = 25; // montants en quarts de dollar (.00/.25/.50/.75)
// ====== Helpers dates ======
function mondayOfThisWeekUTC(now = new Date()) {
// ====== Helpers dates (ancre DIMANCHE UTC) ======
function sundayOfThisWeekUTC(now = new Date()) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const day = d.getUTCDay();
const diffToMonday = (day + 6) % 7;
d.setUTCDate(d.getUTCDate() - diffToMonday);
const day = d.getUTCDay(); // 0=Dim, 1=Lun, ...
d.setUTCDate(d.getUTCDate() - day); // recule jusqu'au dimanche
d.setUTCHours(0, 0, 0, 0);
return d;
}
function mondayNWeeksBefore(monday: Date, n: number) {
const d = new Date(monday);
d.setUTCDate(monday.getUTCDate() - n * 7);
function sundayNWeeksBefore(sunday: Date, n: number) {
const d = new Date(sunday);
d.setUTCDate(d.getUTCDate() - n * 7);
return d;
}
// L→V (UTC minuit)
function weekDatesMonToFri(monday: Date) {
// Génère L→V à partir du dimanche (Lundi = dimanche + 1)
function weekDatesMonToFriFromSunday(sunday: Date) {
return Array.from({ length: 5 }, (_, i) => {
const d = new Date(monday);
d.setUTCDate(monday.getUTCDate() + i);
const d = new Date(sunday);
d.setUTCDate(sunday.getUTCDate() + (i + 1)); // +1..+5
return d;
});
}
@ -34,7 +34,7 @@ function weekDatesMonToFri(monday: Date) {
function rndInt(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// String "xx.yy" à partir de cents ENTiers (jamais de float)
// String "xx.yy" à partir de cents entiers (pas de float binaire en DB)
function centsToAmountString(cents: number): string {
const sign = cents < 0 ? '-' : '';
const abs = Math.abs(cents);
@ -42,12 +42,9 @@ function centsToAmountString(cents: number): string {
const c = abs % 100;
return `${sign}${dollars}.${c.toString().padStart(2, '0')}`;
}
function to2(value: string): string {
// normalise au cas où (sécurité)
return (Math.round(parseFloat(value) * 100) / 100).toFixed(2);
}
// Tire un multiple de STEP_CENTS entre minCents et maxCents (inclus)
function rndQuantizedCents(minCents: number, maxCents: number, step = STEP_CENTS): number {
const qmin = Math.ceil(minCents / step);
@ -59,12 +56,10 @@ function rndAmount(minCents: number, maxCents: number): string {
return centsToAmountString(rndQuantizedCents(minCents, maxCents));
}
// ====== Timesheet upsert ======
async function getOrCreateTimesheet(employee_id: number, start_date: Date) {
return prisma.timesheets.upsert({
// ====== Lookup timesheet (AUCUNE création ici) ======
async function findTimesheet(employee_id: number, start_date: Date) {
return prisma.timesheets.findUnique({
where: { employee_id_start_date: { employee_id, start_date } },
update: {},
create: { employee_id, start_date, is_approved: Math.random() < 0.3 },
select: { id: true },
});
}
@ -90,21 +85,23 @@ async function main() {
return;
}
// Liste des lundis (courant + 4 précédents)
const mondayThisWeek = mondayOfThisWeekUTC();
const mondays: Date[] = [];
if (INCLUDE_CURRENT) mondays.push(mondayThisWeek);
for (let n = 1; n <= WEEKS_BACK; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n));
// Fenêtre de semaines ancrées au DIMANCHE
const sundayThisWeek = sundayOfThisWeekUTC();
const sundays: Date[] = [];
if (INCLUDE_CURRENT) sundays.push(sundayThisWeek);
for (let n = 1; n <= WEEKS_BACK; n++) sundays.push(sundayNWeeksBefore(sundayThisWeek, n));
let created = 0;
for (const monday of mondays) {
const weekDays = weekDatesMonToFri(monday);
for (const sunday of sundays) {
const weekDays = weekDatesMonToFriFromSunday(sunday); // L→V
const monday = weekDays[0];
const friday = weekDays[4];
for (const e of employees) {
// Upsert timesheet pour CETTE semaine/employee
const ts = await getOrCreateTimesheet(e.id, monday);
// Utiliser le timesheet EXISTANT (ancré au DIMANCHE)
const ts = await findTimesheet(e.id, sunday);
if (!ts) throw new NotFoundException(`Timesheet manquant pour emp ${e.id} @ ${sunday.toISOString().slice(0,10)}`);
// Idempotence: si déjà au moins une expense L→V, on skip la semaine
const already = await prisma.expenses.findFirst({
@ -122,7 +119,7 @@ async function main() {
const code = BANKS[rndInt(0, BANKS.length - 1)];
const bank_code_id = bcMap.get(code)!;
// Montants (cents) quantisés à 25¢ => aucun flottant binaire plus tard
// Montants (cents) quantisés à 25¢ => aucun flottant binaire en DB
let amount: string = '0.00';
let mileage: string = '0.00';
switch (code) {
@ -132,7 +129,7 @@ async function main() {
case 'G502': // per_diem
amount = to2(rndAmount(1500, 3000)); // 15.00 à 30.00
break;
case 'G202': // on_call /prime de garde
case 'G202': // on_call / prime de garde
amount = to2(rndAmount(2000, 15000)); // 20.00 à 150.00
break;
case 'G517': // expenses
@ -160,7 +157,7 @@ async function main() {
}
const total = await prisma.expenses.count();
console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (sem courante + ${WEEKS_BACK} précédentes, L→V uniquement, montants en quarts de dollar)`);
console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (ancre dimanche, L→V, sem courante ${INCLUDE_CURRENT ? 'incluse' : 'exclue'} + ${WEEKS_BACK} précédentes)`);
}
main().finally(() => prisma.$disconnect());

View File

@ -184,11 +184,6 @@ model TimesheetsArchive {
@@map("timesheets_archive")
}
model SchedulePresets {
id Int @id @default(autoincrement())
employee Employees @relation("SchedulePreset", fields: [employee_id], references: [id])

View File

@ -14,7 +14,7 @@ export class OvertimeService {
constructor(private prisma: PrismaService) {}
//calculate daily overtime
async getDailyOvertimeHoursForDay(employee_id: number, date: Date): Promise<number> {
async getDailyOvertimeHours(employee_id: number, date: Date): Promise<number> {
const shifts = await this.prisma.shifts.findMany({
where: { date: date, timesheet: { employee_id: employee_id } },
select: { start_time: true, end_time: true },

View File

@ -5,7 +5,7 @@ import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
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-timesheet.utils";
import {
BadRequestException,
Injectable,
@ -66,7 +66,7 @@ export class ExpensesCommandService extends BaseApprovalService<Expenses> {
const employee_id = await this.emailResolver.findIdByEmail(email);
//make sure a timesheet existes
const timesheet_id = await this.timesheetsResolver.ensureForDate(employee_id, date_only);
const timesheet_id = await this.timesheetsResolver.findTimesheetIdByEmail(email, date_only);
if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`)
const {id} = timesheet_id;

View File

@ -0,0 +1,58 @@
export class Session {
user_id: number;
}
export class Timesheets {
timesheet_id: number;
days: TimesheetDay[];
weekly_hours: TotalHours[];
weekly_expenses: TotalExpenses[];
}
export class TimesheetDay {
date: string;
shifts: Shift[];
expenses: Expense[];
daily_hours: TotalHours[];
daily_expenses: TotalExpenses[];
}
export class TotalHours {
regular: number;
evening: number;
emergency: number;
overtime: number;
vacation: number;
holiday: number;
sick: number;
}
export class TotalExpenses {
expenses: number;
perd_diem: number;
on_call: number;
mileage: number;
}
export class Shift {
date: Date;
start_time: Date;
end_time: Date;
type: string;
is_remote: boolean;
is_approved: boolean;
shift_id?: number | null;
comment?: string | null;
}
export class Expense {
date: string;
is_approved: boolean;
comment: string;
amount?: number;
mileage?: number;
attachment?: string;
expense_id?: number | null;
supervisor_comment?: string | null;
}

View File

@ -0,0 +1 @@
export const COMMENT_MAX_LENGTH = 280;

View File

@ -0,0 +1,9 @@
export interface ShiftKey {
timesheet_id: number;
date: Date;
start_time: Date;
end_time: Date;
bank_code_id: number;
is_remote: boolean;
comment?: string | null;
}

View File

@ -1,6 +1,6 @@
import { Module } from "@nestjs/common";
import { EmailToIdResolver } from "./utils/resolve-email-id.utils";
import { EmployeeTimesheetResolver } from "./utils/resolve-employee-timesheet.utils";
import { EmployeeTimesheetResolver } from "./utils/resolve-timesheet.utils";
import { FullNameResolver } from "./utils/resolve-full-name.utils";
import { BankCodesResolver } from "./utils/resolve-bank-type-id.utils";
import { PrismaModule } from "src/prisma/prisma.module";

View File

@ -1,42 +0,0 @@
import { Injectable } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client";
import { weekStartSunday } from "src/modules/shifts/helpers/shifts-date-time-helpers";
import { PrismaService } from "src/prisma/prisma.service";
type Tx = Prisma.TransactionClient | PrismaClient;
@Injectable()
export class EmployeeTimesheetResolver {
constructor(private readonly prisma: PrismaService) {}
//find an existing timesheet linked to the employee
readonly ensureForDate = async (employee_id: number, date: Date, client?: Tx,
): Promise<{id: number; start_date: Date }> => {
const db = client ?? this.prisma;
const startOfWeek = weekStartSunday(date);
const existing = await db.timesheets.findFirst({
where: {
employee_id: employee_id,
start_date: startOfWeek,
},
select: {
id: true,
start_date: true,
},
});
if(existing) return existing;
const created = await db.timesheets.create({
data: {
employee_id: employee_id,
start_date: startOfWeek,
},
select: {
id: true,
start_date: true,
},
});
return created;
}
}

View File

@ -0,0 +1,29 @@
import { Prisma, PrismaClient } from "@prisma/client";
import { NotFoundException } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { ShiftKey } from "../interfaces/shifts.interface";
type Tx = Prisma.TransactionClient | PrismaClient;
export class ShiftIdResolver {
constructor(private readonly prisma: PrismaService) {}
readonly findShiftIdByData = async ( key: ShiftKey, client?: Tx ): Promise<{id:number}> => {
const db = client ?? this.prisma;
const shift = await db.shifts.findFirst({
where: {
timesheet_id: key.timesheet_id,
bank_code_id: key.bank_code_id,
date: key.date,
start_time: key.start_time,
end_time: key.end_time,
is_remote: key.is_remote,
comment: key.comment,
},
select: { id: true },
});
if(!shift) throw new NotFoundException(`shift not found`);
return { id: shift.id };
};
}

View File

@ -0,0 +1,28 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, PrismaClient } from "@prisma/client";
import { weekStartSunday } from "src/modules/shifts/helpers/shifts-date-time-helpers";
import { PrismaService } from "src/prisma/prisma.service";
import { EmailToIdResolver } from "./resolve-email-id.utils";
type Tx = Prisma.TransactionClient | PrismaClient;
@Injectable()
export class EmployeeTimesheetResolver {
constructor(
private readonly prisma: PrismaService,
private readonly emailResolver: EmailToIdResolver,
) {}
readonly findTimesheetIdByEmail = async (email: string, date: Date, client?: Tx): Promise<{id: number}> => {
const db = client ?? this.prisma;
const employee_id = await this.emailResolver.findIdByEmail(email);
const start_date = weekStartSunday(date);
const timesheet = await db.timesheets.findFirst({
where: { employee_id : employee_id, start_date: start_date },
select: { id: true },
});
if(!timesheet) throw new NotFoundException(`timesheet not found`);
return { id: timesheet.id };
}
}

View File

@ -11,7 +11,7 @@ export function toDateOnly(ymd: string): Date {
}
export function weekStartSunday(date_local: Date): Date {
const start = new Date(date_local.getFullYear(), date_local.getMonth(), date_local.getDate());
const start = new Date(Date.UTC(date_local.getFullYear(), date_local.getMonth(), date_local.getDate()));
const dow = start.getDay(); // 0 = dimanche
start.setDate(start.getDate() - dow);
start.setHours(0, 0, 0, 0);

View File

@ -18,16 +18,6 @@ export class ShiftsHelpersService {
private readonly overtimeService: OvertimeService,
) { }
async findOrUpsertTimesheet(tx: Tx, employee_id: number, date_only: Date) {
const start_of_week = weekStartSunday(date_only);
return tx.timesheets.upsert({
where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
update: {},
create: { employee_id, start_date: start_of_week },
select: { id: true },
});
}
async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) {
const start_of_week = weekStartSunday(date_only);
console.log('start of week: ', start_of_week);
@ -125,10 +115,13 @@ export class ShiftsHelpersService {
async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date) {
// Switch regular → weekly overtime si > 40h
await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx);
const [daily, weekly] = await Promise.all([
this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only),
this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only),
]);
const daily = await this.overtimeService.getDailyOvertimeHours(employee_id, date_only);
const weekly = await this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only);
// const [daily, weekly] = await Promise.all([
// this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only),
// this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only),
// ]);
return { daily, weekly };
}
async mapDay(

View File

@ -84,18 +84,18 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
): Promise<{action: UpsertAction; day: DayShiftResponse[]}> {
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso);
const { id: timesheet_id } = await this.helpersService.findOrUpsertTimesheet(tx, employee_id, date_only);
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
if(!timesheet) throw new NotFoundException('Timesheet not found')
const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift);
const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift');
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_only);
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift);
await tx.shifts.create({
data: {
timesheet_id,
timesheet_id: timesheet.id,
date: date_only,
start_time: new_norm_shift.start_time,
end_time: new_norm_shift.end_time,
@ -106,7 +106,7 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
},
});
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_only);
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)};
});
}
@ -121,20 +121,21 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{
return this.prisma.$transaction(async (tx) => {
const date_only = toDateOnly(date_iso);
const { id: timesheet_id } = await this.helpersService.findOrUpsertTimesheet(tx, employee_id, date_only);
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
if(!timesheet) throw new NotFoundException('Timesheet not found')
const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift, 'new_shift');
const old_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, old_norm_shift.type, 'old_shift');
const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift');
const old_bank_code = await this.typeResolver.findByType(old_norm_shift.type);
const new_bank_code = await this.typeResolver.findByType(new_norm_shift.type);
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet_id, date_only);
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
const existing = await this.helpersService.findExactOldShift(tx, {
timesheet_id,
timesheet_id: timesheet.id,
date_only,
norm: old_norm_shift,
bank_code_id: old_bank_code_id,
bank_code_id: old_bank_code.id,
});
if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
@ -147,11 +148,11 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
end_time: new_norm_shift.end_time,
is_remote: new_norm_shift.is_remote,
comment: new_norm_shift.comment ?? null,
bank_code_id: new_bank_code_id,
bank_code_id: new_bank_code.id,
},
});
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet_id, date_only);
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)};
});
@ -189,7 +190,7 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
await tx.shifts.delete({ where: { id: existing.id } });
// await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
});
}
}

View File

@ -1,5 +1,5 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils";
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils";
import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils";
import { parseISODate, parseHHmm } from "../utils-helpers-others/timesheet.helpers";
import { TimesheetsQueryService } from "./timesheets-query.service";
@ -10,6 +10,7 @@ import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.uti
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
import { PrismaService } from "src/prisma/prisma.service";
import { TimesheetMap } from "../utils-helpers-others/timesheet.types";
import { Shift, Expense } from "../dtos/timesheet.dto";
@Injectable()
export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
@ -50,6 +51,44 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
return timesheet;
}
/**_____________________________________________________________________________________________
create/update/delete shifts and expenses from 1 or many timesheet(s)
-this function receives an email and an array of timesheets
-this function will find the timesheets with all shifts and expenses
-this function will calculate total hours, total expenses, filtered by types,
cumulate in daily and weekly.
-the timesheet_id will be determined using the employee email
-with the timesheet_id, all shifts and expenses will be fetched
-with shift_id and expense_id, this function will compare both
datas from the DB and from the body of the function and then:
-it will create a shift if no shift is found in the DB
-it will update a shift if a shift is found in the DB
-it will delete a shift if a shift is found and no data is received from the frontend
This function will be used for the Timesheet Page for an employee to enter, modify or delete and entry
This function will also be used in the modal of the timesheet validation page to
allow a supervisor to enter, modify or delete and entry of a selected employee
_____________________________________________________________________________________________*/
async findTimesheetsByEmailAndPayPeriod(email: string, year: number, period_no: number, timesheets: Timesheets[]): Promise<Timesheets[]> {
const employee_id = await this.emailResolver.findIdByEmail(email);
return timesheets;
}
async upsertOrDeleteShiftsByEmailAndDate(email:string, shift_ids: Shift[]) {}
async upsertOrDeleteExpensesByEmailAndDate(email:string, expenses_id: Expense[]) {}
//_____________________________________________________________________________________________
//
//_____________________________________________________________________________________________
@ -69,7 +108,7 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
const start_week = getWeekStart(base, 0);
const end_week = getWeekEnd(start_week);
const timesheet = await this.timesheetResolver.ensureForDate(employee_id, base)
const timesheet = await this.timesheetResolver.findTimesheetIdByEmail(email, base)
if(!timesheet) throw new NotFoundException(`no timesheet found for employe ${employee_id}`);
//validations and insertions