Merge branch 'main' of git.targo.ca:Targo/targo_backend
This commit is contained in:
commit
88f7c0cb0e
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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 35–45h, 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());
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
58
src/modules/shared/classes/timesheet.dto.ts
Normal file
58
src/modules/shared/classes/timesheet.dto.ts
Normal 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;
|
||||
}
|
||||
1
src/modules/shared/constants/utils.constant.ts
Normal file
1
src/modules/shared/constants/utils.constant.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const COMMENT_MAX_LENGTH = 280;
|
||||
9
src/modules/shared/interfaces/shifts.interface.ts
Normal file
9
src/modules/shared/interfaces/shifts.interface.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
29
src/modules/shared/utils/resolve-shifts-id.utils.ts
Normal file
29
src/modules/shared/utils/resolve-shifts-id.utils.ts
Normal 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 };
|
||||
};
|
||||
}
|
||||
28
src/modules/shared/utils/resolve-timesheet.utils.ts
Normal file
28
src/modules/shared/utils/resolve-timesheet.utils.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user