From a73ed4b6206d135a7807ffcaff5147bd69397d7b Mon Sep 17 00:00:00 2001 From: Matthieu Haineault Date: Mon, 8 Sep 2025 09:43:46 -0400 Subject: [PATCH] refactor(seeders): added complexity to shifts and expenses seeders --- docs/swagger/swagger-spec.json | 50 +++++++ prisma/mock-seeds-scripts/10-shifts.ts | 174 ++++++++++++++++++----- prisma/mock-seeds-scripts/12-expenses.ts | 144 +++++++++++++------ 3 files changed, 288 insertions(+), 80 deletions(-) diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 212a779..fe0a963 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -504,6 +504,52 @@ ] } }, + "/timesheets/shifts/{email}": { + "post": { + "operationId": "TimesheetsController_createTimesheetShifts", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "offset", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWeekShiftsDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "access-token": [] + } + ], + "tags": [ + "Timesheets" + ] + } + }, "/timesheets/{id}": { "get": { "operationId": "TimesheetsController_findOne", @@ -2359,6 +2405,10 @@ } } }, + "CreateWeekShiftsDto": { + "type": "object", + "properties": {} + }, "CreateTimesheetDto": { "type": "object", "properties": {} diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index 9175030..ebe8a2b 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -4,14 +4,16 @@ const prisma = new PrismaClient(); // ====== Config ====== const PREVIOUS_WEEKS = 5; -const INCLUDE_CURRENT = false; +const INCLUDE_CURRENT = true; +const INCR = 15; // incrément ferme de 15 minutes (0.25 h) +const DAY_MIN = 5 * 60; // 5h +const DAY_MAX = 11 * 60; // 11h +const HARD_END = 19 * 60 + 30; // 19:30 -// Times-only via Date (UTC 1970-01-01) +// ====== Helpers temps ====== function timeAt(hour: number, minute: number) { return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); } - -// Lundi (UTC) de la date fournie function mondayOfThisWeekUTC(now = new Date()) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const day = d.getUTCDay(); @@ -20,7 +22,6 @@ function mondayOfThisWeekUTC(now = new Date()) { d.setUTCHours(0, 0, 0, 0); return d; } - function weekDatesFromMonday(monday: Date) { return Array.from({ length: 5 }, (_, i) => { const d = new Date(monday); @@ -28,16 +29,35 @@ function weekDatesFromMonday(monday: Date) { return d; }); } - function mondayNWeeksBefore(monday: Date, n: number) { const d = new Date(monday); d.setUTCDate(d.getUTCDate() - n * 7); return d; } - function rndInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; } +function clamp(n: number, min: number, max: number) { + return Math.min(max, Math.max(min, n)); +} +function addMinutes(h: number, m: number, delta: number) { + const total = h * 60 + m + delta; + const hh = Math.floor(total / 60); + 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; +} +// 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); + const q = rndInt(qmin, qmax); + return q * INCR; +} // Helper: garantit le timesheet de la semaine (upsert) async function getOrCreateTimesheet(employee_id: number, start_date: Date) { @@ -50,8 +70,13 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) { } async function main() { - // Bank codes utilisés + // --- Bank codes (pondérés: surtout G1 = régulier) --- const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305', 'G43'] as const; + const WEIGHTED_CODES = [ + 'G1','G1','G1','G1','G1','G1','G1','G1', // 8x régulier + 'G56','G48','G700','G105','G305','G43' + ] as const; + const bcRows = await prisma.bankCodes.findMany({ where: { bank_code: { in: BANKS as unknown as string[] } }, select: { id: true, bank_code: true }, @@ -70,59 +95,140 @@ async function main() { 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)); - } + 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 weekDays = weekDatesFromMonday(monday); + const days = weekDatesFromMonday(monday); for (let ei = 0; ei < employees.length; ei++) { const e = employees[ei]; - const baseStartHour = 6 + (ei % 5); - const baseStartMinute = (ei * 15) % 60; + // Cible hebdo 35–45h, multiple de 15 min + const weeklyTargetMin = rndQuantized(35 * 60, 45 * 60); - for (let di = 0; di < weekDays.length; di++) { - const date = weekDays[di]; + // 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 - // 1) Trouver/Créer le timesheet de CETTE semaine pour CET employé - const weekStart = mondayOfThisWeekUTC(date); - const ts = await getOrCreateTimesheet(e.id, weekStart); + // Planification journalière (5 jours) ~8h ± 45 min, quantisée 15 min + const plannedDaily: number[] = []; + for (let d = 0; d < 5; d++) { + const jitter = rndInt(-3, 3) * INCR; // -45..+45 par pas de 15 + const base = 8 * 60 + jitter; + plannedDaily.push(quantize(clamp(base, DAY_MIN, DAY_MAX))); + } - // 2) Tirage aléatoire du bank_code - const randomCode = BANKS[Math.floor(Math.random() * BANKS.length)]; - const bank_code_id = bcMap.get(randomCode)!; + // Ajuster le 5e jour pour atteindre la cible hebdo exactement (par pas de 15) + const sumFirst4 = plannedDaily.slice(0, 4).reduce((a, b) => a + b, 0); + plannedDaily[4] = quantize(clamp(weeklyTargetMin - sumFirst4, DAY_MIN, DAY_MAX)); - // 3) Horaire - const duration = rndInt(4, 10); - const dayWeekOffset = (di + wi + (ei % 3)) % 3; - const startH = Math.min(12, baseStartHour + dayWeekOffset); - const startM = baseStartMinute; - const endH = startH + duration; - const endM = startM; + // Corriger le petit écart restant (devrait être multiple de 15) en redistribuant ±15 + let diff = weeklyTargetMin - plannedDaily.reduce((a, b) => a + b, 0); + const step = diff > 0 ? INCR : -INCR; + let guard = 100; // anti-boucle + while (diff !== 0 && guard-- > 0) { + for (let d = 0; d < 5 && diff !== 0; d++) { + const next = plannedDaily[d] + step; + if (next >= DAY_MIN && next <= DAY_MAX) { + plannedDaily[d] = next; + diff -= step; + } + } + } + // 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 + + // Départ ~ base + jitter (par pas de 15 min aussi) + 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) + 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 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); + } + + // Fin de journée (quantisée par construction) + 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 bcMorningId = bcMap.get(bcMorningCode)!; + const bcAfternoonId = bcMap.get(bcAfternoonCode)!; + + // Shift matin + const lunchStartHM = { h: Math.floor(lunchStartMin / 60), m: lunchStartMin % 60 }; await prisma.shifts.create({ data: { timesheet_id: ts.id, - bank_code_id, - description: `Shift ${di + 1} (semaine du ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${randomCode}`, + bank_code_id: bcMorningId, + description: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcMorningCode}`, date, start_time: timeAt(startH, startM), - end_time: timeAt(endH, endM), - is_approved: Math.random() < 0.5, + end_time: timeAt(lunchStartHM.h, lunchStartHM.m), + is_approved: Math.random() < 0.6, }, }); created++; + + // Shift après-midi (si >= 30 min — sera de toute façon multiple de 15) + const pmDuration = endMin - lunchEndMin; + if (pmDuration >= 30) { + const lunchEndHM = { h: Math.floor(lunchEndMin / 60), m: lunchEndMin % 60 }; + const finalEndHM = { h: Math.floor(endMin / 60), m: endMin % 60 }; + await prisma.shifts.create({ + data: { + timesheet_id: ts.id, + bank_code_id: bcAfternoonId, + description: `Après-midi J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcAfternoonCode}`, + date, + start_time: timeAt(lunchEndHM.h, lunchEndHM.m), + end_time: timeAt(finalEndHM.h, finalEndHM.m), + is_approved: Math.random() < 0.6, + }, + }); + created++; + } else { + // Fallback très rare : un seul shift couvrant la journée (tout en multiples de 15) + const fallbackEnd = addMinutes(startH, startM, targetWorkMin + lunchDur); + await prisma.shifts.create({ + data: { + timesheet_id: ts.id, + bank_code_id: bcMap.get('G1')!, + description: `Fallback J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — G1`, + date, + start_time: timeAt(startH, startM), + end_time: timeAt(fallbackEnd.h, fallbackEnd.m), + is_approved: Math.random() < 0.6, + }, + }); + created++; + } } } } const total = await prisma.shifts.count(); - console.log(`✓ Shifts: ${created} nouvelles lignes, ${total} total rows (${INCLUDE_CURRENT ? 'courante +' : ''}${PREVIOUS_WEEKS} semaines précédentes, L→V)`); + 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})`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index f9a63d1..4318871 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -2,7 +2,12 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -// Lundi (UTC) de la date fournie +// ====== Config ====== +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()) { const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const day = d.getUTCDay(); @@ -11,10 +16,13 @@ function mondayOfThisWeekUTC(now = new Date()) { d.setUTCHours(0, 0, 0, 0); return d; } - -// Dates Lundi→Vendredi (UTC minuit) -function currentWeekDates() { - const monday = mondayOfThisWeekUTC(); +function mondayNWeeksBefore(monday: Date, n: number) { + const d = new Date(monday); + d.setUTCDate(monday.getUTCDate() - n * 7); + return d; +} +// L→V (UTC minuit) +function weekDatesMonToFri(monday: Date) { return Array.from({ length: 5 }, (_, i) => { const d = new Date(monday); d.setUTCDate(monday.getUTCDate() + i); @@ -22,15 +30,30 @@ function currentWeekDates() { }); } +// ====== Helpers random / amount ====== function rndInt(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1)) + min; } -function rndAmount(minCents: number, maxCents: number) { - const cents = rndInt(minCents, maxCents); - return (cents / 100).toFixed(2); +// String "xx.yy" à partir de cents ENTiers (jamais de float) +function centsToAmountString(cents: number): string { + const sign = cents < 0 ? '-' : ''; + const abs = Math.abs(cents); + const dollars = Math.floor(abs / 100); + const c = abs % 100; + return `${sign}${dollars}.${c.toString().padStart(2, '0')}`; +} +// 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); + const qmax = Math.floor(maxCents / step); + const q = rndInt(qmin, qmax); + return q * step; +} +function rndAmount(minCents: number, maxCents: number): string { + return centsToAmountString(rndQuantizedCents(minCents, maxCents)); } -// Helper: garantit le timesheet de la semaine (upsert) +// ====== Timesheet upsert ====== async function getOrCreateTimesheet(employee_id: number, start_date: Date) { return prisma.timesheets.upsert({ where: { employee_id_start_date: { employee_id, start_date } }, @@ -41,8 +64,10 @@ async function getOrCreateTimesheet(employee_id: number, start_date: Date) { } async function main() { - // Codes autorisés (aléatoires à chaque dépense) + // Codes d'EXPENSES (exemples) const BANKS = ['G517', 'G503', 'G502', 'G202', 'G234'] as const; + + // Précharger les bank codes const bcRows = await prisma.bankCodes.findMany({ where: { bank_code: { in: BANKS as unknown as string[] } }, select: { id: true, bank_code: true }, @@ -52,58 +77,85 @@ async function main() { if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`); } + // Employés const employees = await prisma.employees.findMany({ select: { id: true } }); if (!employees.length) { console.warn('Aucun employé — rien à insérer.'); return; } - const weekDays = currentWeekDates(); - const monday = weekDays[0]; - const friday = weekDays[4]; + // 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)); let created = 0; - for (const e of employees) { - // 1) Semaine courante → assurer le timesheet de la semaine - const weekStart = mondayOfThisWeekUTC(); - const ts = await getOrCreateTimesheet(e.id, weekStart); + for (const monday of mondays) { + const weekDays = weekDatesMonToFri(monday); + const friday = weekDays[4]; - // 2) Skip si l’employé a déjà une dépense cette semaine (on garantit ≥1) - const already = await prisma.expenses.findFirst({ - where: { timesheet_id: ts.id, date: { gte: monday, lte: friday } }, - select: { id: true }, - }); - if (already) continue; + for (const e of employees) { + // Upsert timesheet pour CETTE semaine/employee + const ts = await getOrCreateTimesheet(e.id, monday); - // 3) Choix aléatoire du code + jour - const randomCode = BANKS[Math.floor(Math.random() * BANKS.length)]; - const bank_code_id = bcMap.get(randomCode)!; - const date = weekDays[Math.floor(Math.random() * weekDays.length)]; + // Idempotence: si déjà au moins une expense L→V, on skip la semaine + const already = await prisma.expenses.findFirst({ + where: { timesheet_id: ts.id, date: { gte: monday, lte: friday } }, + select: { id: true }, + }); + if (already) continue; - // 4) Montant varié - const amount = - randomCode === 'G503' - ? rndAmount(1000, 7500) // 10.00..75.00 - : rndAmount(2000, 25000); // 20.00..250.00 + // 1 à 3 expenses (jours distincts) + const count = rndInt(1, 3); + const dayIndexes = [0, 1, 2, 3, 4].sort(() => Math.random() - 0.5).slice(0, count); - await prisma.expenses.create({ - data: { - timesheet_id: ts.id, - bank_code_id, - date, - amount, - attachement: null, - description: `Expense ${randomCode} ${amount}$ (emp ${e.id})`, - is_approved: Math.random() < 0.6, - supervisor_comment: Math.random() < 0.2 ? 'OK' : null, - }, - }); - created++; + for (const idx of dayIndexes) { + const date = weekDays[idx]; + 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 + let amount: string; + switch (code) { + case 'G503': // petites fournitures + amount = rndAmount(1000, 7500); // 10.00 à 75.00 + break; + case 'G502': // repas + amount = rndAmount(1500, 3000); // 15.00 à 30.00 + break; + case 'G202': // essence + amount = rndAmount(2000, 15000); // 20.00 à 150.00 + break; + case 'G234': // hébergement + amount = rndAmount(6000, 25000); // 60.00 à 250.00 + break; + case 'G517': // péages / divers + default: + amount = rndAmount(500, 5000); // 5.00 à 50.00 + break; + } + + await prisma.expenses.create({ + data: { + timesheet_id: ts.id, + bank_code_id, + date, + amount, // string "xx.yy" (2 décimales exactes) + attachement: null, + description: `Expense ${code} ${amount}$ (emp ${e.id})`, + is_approved: Math.random() < 0.65, + supervisor_comment: Math.random() < 0.25 ? 'OK' : null, + }, + }); + created++; + } + } } const total = await prisma.expenses.count(); - console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (≥1 expense/employee pour la semaine courante)`); + console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (sem courante + ${WEEKS_BACK} précédentes, L→V uniquement, montants en quarts de dollar)`); } main().finally(() => prisma.$disconnect());