Merge branch 'main' of git.targo.ca:Targo/targo_backend into dev/matthieu/refactor
This commit is contained in:
commit
0ea3f51974
|
|
@ -448,6 +448,60 @@
|
|||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "action",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpsertShiftDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"access-token": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Shifts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/shifts/delete/{email}/{date}": {
|
||||
"delete": {
|
||||
"operationId": "ShiftsController_remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "email",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "date",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,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": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "preferences" ADD COLUMN "employee_list_display" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "timesheet_display" INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN "validation_display" INTEGER NOT NULL DEFAULT 0;
|
||||
|
|
@ -4,19 +4,21 @@ 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
|
||||
|
||||
// Lundi (UTC) de la semaine courante
|
||||
function mondayOfThisWeekUTC(now = new Date()) {
|
||||
// Dimanche (UTC) de la semaine courante
|
||||
function sundayOfThisWeekUTC(now = new Date()) {
|
||||
// normalise à minuit UTC du jour courant
|
||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const day = d.getUTCDay(); // 0=Dim, 1=Lun, ...
|
||||
const diffToMonday = (day + 6) % 7; // 0 si lundi
|
||||
d.setUTCDate(d.getUTCDate() - diffToMonday);
|
||||
const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... 6=Sam
|
||||
// recule jusqu'au dimanche de cette semaine
|
||||
d.setUTCDate(d.getUTCDate() - day);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
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;
|
||||
}
|
||||
|
|
@ -28,21 +30,21 @@ async function main() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Construit la liste des lundis (1 par semaine)
|
||||
const mondays: Date[] = [];
|
||||
const mondayThisWeek = mondayOfThisWeekUTC();
|
||||
if (INCLUDE_CURRENT) mondays.push(mondayThisWeek);
|
||||
// Construit la liste des dimanches (1 par semaine)
|
||||
const sundays: Date[] = [];
|
||||
const sundayThisWeek = sundayOfThisWeekUTC();
|
||||
if (INCLUDE_CURRENT) sundays.push(sundayThisWeek);
|
||||
for (let n = 1; n <= PREVIOUS_WEEKS; n++) {
|
||||
mondays.push(mondayNWeeksBefore(mondayThisWeek, n));
|
||||
sundays.push(sundayNWeeksBefore(sundayThisWeek, n));
|
||||
}
|
||||
|
||||
// Prépare les lignes (1 timesheet / employé / semaine)
|
||||
const rows: Prisma.TimesheetsCreateManyInput[] = [];
|
||||
for (const e of employees) {
|
||||
for (const monday of mondays) {
|
||||
for (const sunday of sundays) {
|
||||
rows.push({
|
||||
employee_id: e.id,
|
||||
start_date: monday,
|
||||
start_date: sunday,
|
||||
is_approved: Math.random() < 0.3,
|
||||
} as Prisma.TimesheetsCreateManyInput);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', 'G700', 'G105', '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,33 +141,27 @@ 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);
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -398,6 +398,10 @@ model Preferences {
|
|||
lang_switch Int @default(0)
|
||||
lefty_mode Int @default(0)
|
||||
|
||||
employee_list_display Int @default(0)
|
||||
validation_display Int @default(0)
|
||||
timesheet_display Int @default(0)
|
||||
|
||||
@@map("preferences")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { EmployeesArchivalService } from './services/employees-archival.service'
|
|||
import { SharedModule } from '../shared/shared.module';
|
||||
|
||||
@Module({
|
||||
controllers: [EmployeesController, SharedModule],
|
||||
imports: [SharedModule],
|
||||
controllers: [EmployeesController],
|
||||
providers: [EmployeesService, EmployeesArchivalService],
|
||||
exports: [EmployeesService, EmployeesArchivalService],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto";
|
||||
import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers";
|
||||
import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types";
|
||||
import { round2, toUTCDateOnly } from "src/modules/timesheets/utils-helpers-others/timesheet.helpers";
|
||||
import { EXPENSE_TYPES } from "src/modules/timesheets/utils-helpers-others/timesheet.types";
|
||||
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||
|
||||
@Injectable()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { BadRequestException, Injectable } from "@nestjs/common";
|
|||
import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { LeaveTypes } from "@prisma/client";
|
||||
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
|
||||
|
||||
@Injectable()
|
||||
export class LeaveRequestsUtils {
|
||||
|
|
@ -44,7 +45,9 @@ export class LeaveRequestsUtils {
|
|||
include: { bank_code: true },
|
||||
});
|
||||
|
||||
await this.shiftsCommand.upsertShiftsByDate(email, {
|
||||
const action: UpsertAction = existing ? 'update' : 'create';
|
||||
|
||||
await this.shiftsCommand.upsertShifts(email, action, {
|
||||
old_shift: existing
|
||||
? {
|
||||
date: yyyy_mm_dd,
|
||||
|
|
@ -86,7 +89,7 @@ export class LeaveRequestsUtils {
|
|||
});
|
||||
if (!existing) return;
|
||||
|
||||
await this.shiftsCommand.upsertShiftsByDate(email, {
|
||||
await this.shiftsCommand.upsertShifts(email, 'delete', {
|
||||
old_shift: {
|
||||
date: yyyy_mm_dd,
|
||||
start_time: hhmmFromLocal(existing.start_time),
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { ShiftsCommandService } from "../shifts/services/shifts-command.service"
|
|||
import { SharedModule } from "../shared/shared.module";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { BusinessLogicsModule } from "../business-logics/business-logics.module";
|
||||
import { ShiftsHelpersService } from "../shifts/helpers/shifts.helpers";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule],
|
||||
|
|
@ -20,6 +21,7 @@ import { BusinessLogicsModule } from "../business-logics/business-logics.module"
|
|||
ExpensesCommandService,
|
||||
ShiftsCommandService,
|
||||
PrismaService,
|
||||
ShiftsHelpersService,
|
||||
],
|
||||
controllers: [PayPeriodsController],
|
||||
exports: [
|
||||
|
|
|
|||
|
|
@ -13,4 +13,13 @@ export class PreferencesDto {
|
|||
|
||||
@IsInt()
|
||||
lefty_mode: number;
|
||||
|
||||
@IsInt()
|
||||
employee_list_display: number;
|
||||
|
||||
@IsInt()
|
||||
validation_display: number;
|
||||
|
||||
@IsInt()
|
||||
timesheet_display: number;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Preferences } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { PreferencesDto } from "../dtos/preferences.dto";
|
||||
|
|
@ -20,6 +20,9 @@ export class PreferencesService {
|
|||
dark_mode: dto.dark_mode,
|
||||
lang_switch: dto.lang_switch,
|
||||
lefty_mode: dto.lefty_mode,
|
||||
employee_list_display: dto.employee_list_display,
|
||||
validation_display: dto.validation_display,
|
||||
timesheet_display: dto.timesheet_display,
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
|
|
|
|||
2
src/modules/shared/constants/date-time.constant.ts
Normal file
2
src/modules/shared/constants/date-time.constant.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const MS_PER_DAY = 86_400_000;
|
||||
export const MS_PER_HOUR = 3_600_000;
|
||||
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;
|
||||
}
|
||||
11
src/modules/shared/selects/expenses.select.ts
Normal file
11
src/modules/shared/selects/expenses.select.ts
Normal 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 };
|
||||
4
src/modules/shared/selects/pay-periods.select.ts
Normal file
4
src/modules/shared/selects/pay-periods.select.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export const PAY_PERIOD_SELECT = {
|
||||
period_start: true,
|
||||
period_end: true,
|
||||
} as const;
|
||||
12
src/modules/shared/selects/shifts.select.ts
Normal file
12
src/modules/shared/selects/shifts.select.ts
Normal 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}];
|
||||
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import { Body, Controller, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common";
|
||||
import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common";
|
||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||
import { Roles as RoleEnum } from '.prisma/client';
|
||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||
import { ShiftsCommandService } from "../services/shifts-command.service";
|
||||
import { ShiftsQueryService } from "../services/shifts-query.service";
|
||||
import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
|
||||
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
||||
import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
||||
import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
|
||||
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
|
||||
|
||||
@ApiTags('Shifts')
|
||||
@ApiBearerAuth('access-token')
|
||||
|
|
@ -21,9 +22,19 @@ export class ShiftsController {
|
|||
@Put('upsert/:email')
|
||||
async upsert_by_date(
|
||||
@Param('email') email_param: string,
|
||||
@Query('action') action: UpsertAction,
|
||||
@Body() payload: UpsertShiftDto,
|
||||
) {
|
||||
return this.shiftsCommandService.upsertShiftsByDate(email_param, payload);
|
||||
return this.shiftsCommandService.upsertShifts(email_param, action, payload);
|
||||
}
|
||||
|
||||
@Delete('delete/:email/:date')
|
||||
async remove(
|
||||
@Param('email') email: string,
|
||||
@Param('date') date: string,
|
||||
@Body() payload: UpsertShiftDto,
|
||||
) {
|
||||
return this.shiftsCommandService.deleteShift(email, date, payload);
|
||||
}
|
||||
|
||||
@Patch('approval/:id')
|
||||
|
|
@ -73,54 +84,4 @@ export class ShiftsController {
|
|||
return Buffer.from('\uFEFF' + header + body, 'utf8');
|
||||
}
|
||||
|
||||
//_____________________________________________________________________________________________
|
||||
// Deprecated or unused methods
|
||||
//_____________________________________________________________________________________________
|
||||
|
||||
// @Post()
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Create shift' })
|
||||
// @ApiResponse({ status: 201, description: 'Shift created',type: CreateShiftDto })
|
||||
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
||||
// create(@Body() dto: CreateShiftDto): Promise<Shifts> {
|
||||
// return this.shiftsService.create(dto);
|
||||
// }
|
||||
|
||||
// @Get()
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Find all shifts' })
|
||||
// @ApiResponse({ status: 201, description: 'List of shifts found',type: CreateShiftDto, isArray: true })
|
||||
// @ApiResponse({ status: 400, description: 'List of shifts not found' })
|
||||
// @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
// findAll(@Query() filters: SearchShiftsDto) {
|
||||
// return this.shiftsService.findAll(filters);
|
||||
// }
|
||||
|
||||
// @Get(':id')
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Find shift' })
|
||||
// @ApiResponse({ status: 201, description: 'Shift found',type: CreateShiftDto })
|
||||
// @ApiResponse({ status: 400, description: 'Shift not found' })
|
||||
// findOne(@Param('id', ParseIntPipe) id: number): Promise<Shifts> {
|
||||
// return this.shiftsService.findOne(id);
|
||||
// }
|
||||
|
||||
// @Patch(':id')
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Update shift' })
|
||||
// @ApiResponse({ status: 201, description: 'Shift updated',type: CreateShiftDto })
|
||||
// @ApiResponse({ status: 400, description: 'Shift not found' })
|
||||
// update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateShiftsDto): Promise<Shifts> {
|
||||
// return this.shiftsService.update(id, dto);
|
||||
// }
|
||||
|
||||
// @Delete(':id')
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Delete shift' })
|
||||
// @ApiResponse({ status: 201, description: 'Shift deleted',type: CreateShiftDto })
|
||||
// @ApiResponse({ status: 400, description: 'Shift not found' })
|
||||
// remove(@Param('id', ParseIntPipe) id: number): Promise<Shifts> {
|
||||
// return this.shiftsService.remove(id);
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { Allow, IsDateString, IsInt, IsString } from "class-validator";
|
||||
|
||||
export class CreateShiftDto {
|
||||
@ApiProperty({
|
||||
example: 1,
|
||||
description: 'Unique ID of the shift (auto-generated)',
|
||||
})
|
||||
@Allow()
|
||||
id: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 101,
|
||||
description: 'ID number for a set timesheet',
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
timesheet_id: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 7,
|
||||
description: 'ID number of a shift code (link with bank-codes)',
|
||||
})
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
bank_code_id: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: '3018-10-20T00:00:00.000Z',
|
||||
description: 'Date where the shift takes place',
|
||||
})
|
||||
@IsDateString()
|
||||
date: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '3018-10-20T08:00:00.000Z',
|
||||
description: 'Start time of the said shift',
|
||||
})
|
||||
@IsDateString()
|
||||
start_time: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '3018-10-20T17:00:00.000Z',
|
||||
description: 'End time of the said shift',
|
||||
})
|
||||
@IsDateString()
|
||||
end_time: string;
|
||||
|
||||
@IsString()
|
||||
comment: string;
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { Type } from "class-transformer";
|
||||
import { IsDateString, IsInt, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class SearchShiftsDto {
|
||||
@IsOptional()
|
||||
@Type(()=> Number)
|
||||
@IsInt()
|
||||
employee_id?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(()=> Number)
|
||||
@IsInt()
|
||||
bank_code_id?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
comment_contains?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
start_date?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
end_date?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(()=> Number)
|
||||
@IsInt()
|
||||
timesheet_id?: number;
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import { PartialType } from "@nestjs/swagger";
|
||||
import { CreateShiftDto } from "./create-shift.dto";
|
||||
|
||||
export class UpdateShiftsDto extends PartialType(CreateShiftDto){}
|
||||
|
|
@ -10,8 +10,8 @@ export function toDateOnly(ymd: string): Date {
|
|||
return new Date(y, m, d, 0, 0, 0, 0);
|
||||
}
|
||||
|
||||
export function weekStartSunday(dateLocal: Date): Date {
|
||||
const start = new Date(dateLocal.getFullYear(), dateLocal.getMonth(), dateLocal.getDate());
|
||||
export function weekStartSunday(date_local: Date): Date {
|
||||
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);
|
||||
|
|
|
|||
138
src/modules/shifts/helpers/shifts.helpers.ts
Normal file
138
src/modules/shifts/helpers/shifts.helpers.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { BadRequestException, UnprocessableEntityException, NotFoundException, ConflictException } from "@nestjs/common";
|
||||
import { Prisma, Shifts } from "@prisma/client";
|
||||
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
||||
import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
|
||||
import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils";
|
||||
import { weekStartSunday, formatHHmm } from "./shifts-date-time-helpers";
|
||||
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||
import { OvertimeService } from "src/modules/business-logics/services/overtime.service";
|
||||
|
||||
|
||||
export type Tx = Prisma.TransactionClient;
|
||||
export type Normalized = Awaited<ReturnType<typeof normalizeShiftPayload>>;
|
||||
|
||||
export class ShiftsHelpersService {
|
||||
|
||||
constructor(
|
||||
private readonly bankTypeResolver: BankCodesResolver,
|
||||
private readonly overtimeService: OvertimeService,
|
||||
) { }
|
||||
|
||||
async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) {
|
||||
const start_of_week = weekStartSunday(date_only);
|
||||
return tx.timesheets.findUnique({
|
||||
where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
async normalizeRequired(
|
||||
raw: UpsertShiftDto['new_shift'] | UpsertShiftDto['old_shift'] | undefined | null,
|
||||
label: 'old_shift' | 'new_shift' = 'new_shift',
|
||||
): Promise<Normalized> {
|
||||
if (!raw) throw new BadRequestException(`${label} is required`);
|
||||
const norm = await normalizeShiftPayload(raw);
|
||||
if (norm.end_time.getTime() <= norm.start_time.getTime()) {
|
||||
throw new UnprocessableEntityException(` ${label}.end_time must be > ${label}.start_time`);
|
||||
}
|
||||
return norm;
|
||||
}
|
||||
|
||||
async resolveBankIdRequired(tx: Tx, type: string, label: 'old_shift' | 'new_shift'): Promise<number> {
|
||||
const found = await this.bankTypeResolver.findByType(type, tx);
|
||||
const id = found?.id;
|
||||
if (typeof id !== 'number') {
|
||||
throw new NotFoundException(`bank code not found for ${label}.type: ${type ?? ''}`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
async getDayShifts(tx: Tx, timesheet_id: number, date_only: Date) {
|
||||
return tx.shifts.findMany({
|
||||
where: { timesheet_id, date: date_only },
|
||||
include: { bank_code: true },
|
||||
orderBy: { start_time: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
async assertNoOverlap(
|
||||
day_shifts: Array<Shifts & { bank_code: { type: string } | null }>,
|
||||
new_norm: Normalized | undefined,
|
||||
exclude_id?: number,
|
||||
) {
|
||||
if (!new_norm) return;
|
||||
const conflicts = day_shifts.filter((s) => {
|
||||
if (exclude_id && s.id === exclude_id) return false;
|
||||
return overlaps(
|
||||
new_norm.start_time.getTime(),
|
||||
new_norm.end_time.getTime(),
|
||||
s.start_time.getTime(),
|
||||
s.end_time.getTime(),
|
||||
);
|
||||
});
|
||||
if (conflicts.length) {
|
||||
const payload = conflicts.map((s) => ({
|
||||
start_time: formatHHmm(s.start_time),
|
||||
end_time: formatHHmm(s.end_time),
|
||||
type: s.bank_code?.type ?? 'UNKNOWN',
|
||||
}));
|
||||
throw new ConflictException({
|
||||
error_code: 'SHIFT_OVERLAP',
|
||||
message: 'New shift overlaps with existing shift(s)',
|
||||
conflicts: payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async findExactOldShift(
|
||||
tx: Tx,
|
||||
params: {
|
||||
timesheet_id: number;
|
||||
date_only: Date;
|
||||
norm: Normalized;
|
||||
bank_code_id: number;
|
||||
comment?: string;
|
||||
},
|
||||
) {
|
||||
const { timesheet_id, date_only, norm, bank_code_id } = params;
|
||||
return tx.shifts.findFirst({
|
||||
where: {
|
||||
timesheet_id,
|
||||
date: date_only,
|
||||
start_time: norm.start_time,
|
||||
end_time: norm.end_time,
|
||||
is_remote: norm.is_remote,
|
||||
is_approved: norm.is_approved,
|
||||
comment: norm.comment ?? null,
|
||||
bank_code_id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
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 = await this.overtimeService.getDailyOvertimeHoursForDay(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(
|
||||
fresh: Array<Shifts & { bank_code: { type: string } | null }>,
|
||||
): Promise<DayShiftResponse[]> {
|
||||
return fresh.map((s) => ({
|
||||
start_time: formatHHmm(s.start_time),
|
||||
end_time: formatHHmm(s.end_time),
|
||||
type: s.bank_code?.type ?? 'UNKNOWN',
|
||||
is_remote: s.is_remote,
|
||||
comment: s.comment ?? null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnprocessableEntityException } from "@nestjs/common";
|
||||
import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils";
|
||||
import { DayShiftResponse, UpsertAction } from "../types-and-interfaces/shifts-upsert.types";
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
|
||||
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||
import { Prisma, Shifts } from "@prisma/client";
|
||||
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
||||
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { OvertimeService } from "src/modules/business-logics/services/overtime.service";
|
||||
import { formatHHmm, toDateOnly, weekStartSunday } from "../helpers/shifts-date-time-helpers";
|
||||
import { toDateOnly } from "../helpers/shifts-date-time-helpers";
|
||||
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
|
||||
import { ShiftsHelpersService } from "../helpers/shifts.helpers";
|
||||
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||
|
||||
@Injectable()
|
||||
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
|
||||
|
|
@ -17,8 +17,8 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
|
|||
constructor(
|
||||
prisma: PrismaService,
|
||||
private readonly emailResolver: EmailToIdResolver,
|
||||
private readonly bankTypeResolver: BankCodesResolver,
|
||||
private readonly overtimeService: OvertimeService,
|
||||
private readonly typeResolver: BankCodesResolver,
|
||||
private readonly helpersService: ShiftsHelpersService,
|
||||
) { super(prisma); }
|
||||
|
||||
//_____________________________________________________________________________________________
|
||||
|
|
@ -38,196 +38,157 @@ export class ShiftsCommandService extends BaseApprovalService<Shifts> {
|
|||
);
|
||||
}
|
||||
|
||||
|
||||
//TODO: modifier le Master Crud pour recevoir l'ensemble des shifts de la pay-period et trier sur l'action 'create'| 'update' | 'delete'
|
||||
//_____________________________________________________________________________________________
|
||||
// MASTER CRUD METHOD
|
||||
//_____________________________________________________________________________________________
|
||||
async upsertShiftsByDate(email:string, dto: UpsertShiftDto):
|
||||
Promise<{ action: UpsertAction; day: DayShiftResponse[] }> {
|
||||
const { old_shift, new_shift } = dto;
|
||||
async upsertShifts(
|
||||
email: string,
|
||||
action: UpsertAction,
|
||||
dto: UpsertShiftDto,
|
||||
): Promise<{
|
||||
action: UpsertAction;
|
||||
day: DayShiftResponse[];
|
||||
}> {
|
||||
if (!dto.old_shift && !dto.new_shift) throw new BadRequestException('At least one of old or new shift must be provided');
|
||||
|
||||
if(!dto.old_shift && !dto.new_shift) {
|
||||
throw new BadRequestException('At least one of old or new shift must be provided');
|
||||
}
|
||||
|
||||
const date = new_shift?.date ?? old_shift?.date;
|
||||
const date = dto.new_shift?.date ?? dto.old_shift?.date;
|
||||
if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift");
|
||||
if (old_shift?.date
|
||||
&& new_shift?.date
|
||||
&& old_shift.date
|
||||
!== new_shift.date) throw new BadRequestException("old_shift.date and new_shift.date must be identical");
|
||||
|
||||
const date_only = toDateOnly(date);
|
||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const start_of_week = weekStartSunday(date_only);
|
||||
|
||||
const timesheet = await 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 },
|
||||
});
|
||||
|
||||
//validation/sanitation
|
||||
//resolve bank_code_id using type
|
||||
const old_norm_shift = old_shift ? await normalizeShiftPayload(old_shift) : undefined;
|
||||
if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) {
|
||||
throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time');
|
||||
if (dto.old_shift?.date && dto.new_shift?.date && dto.old_shift.date !== dto.new_shift.date) {
|
||||
throw new BadRequestException('old_shift.date and new_shift.date must be identical');
|
||||
}
|
||||
const old_bank_code_id: number | undefined = old_norm_shift ? (await this.bankTypeResolver.findByType(old_norm_shift.type, tx))?.id : undefined;
|
||||
|
||||
const employee_id = await this.emailResolver.findIdByEmail(email);//resolve employee id using email
|
||||
|
||||
const new_norm_shift = new_shift ? await normalizeShiftPayload(new_shift) : undefined;
|
||||
if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) {
|
||||
throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time');
|
||||
if(action === 'create') {
|
||||
if(!dto.new_shift || dto.old_shift) {
|
||||
throw new BadRequestException(`Only new_shift must be provided for create`);
|
||||
}
|
||||
const new_bank_code_id: number | undefined = new_norm_shift ? (await this.bankTypeResolver.findByType(new_norm_shift.type, tx))?.id : undefined;
|
||||
|
||||
|
||||
//fetch all shifts in a single day and verify possible overlaps
|
||||
const day_shifts = await tx.shifts.findMany({
|
||||
where: { timesheet_id: timesheet.id, date: date_only },
|
||||
include: { bank_code: true },
|
||||
orderBy: { start_time: 'asc'},
|
||||
});
|
||||
|
||||
|
||||
const findExactOldShift = async ()=> {
|
||||
if(!old_norm_shift || old_bank_code_id === undefined) return undefined;
|
||||
const old_comment = old_norm_shift.comment ?? null;
|
||||
|
||||
return await tx.shifts.findFirst({
|
||||
where: {
|
||||
timesheet_id: timesheet.id,
|
||||
date: date_only,
|
||||
start_time: old_norm_shift.start_time,
|
||||
end_time: old_norm_shift.end_time,
|
||||
is_remote: old_norm_shift.is_remote,
|
||||
is_approved: old_norm_shift.is_approved,
|
||||
comment: old_comment,
|
||||
bank_code_id: old_bank_code_id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
};
|
||||
|
||||
//checks for overlaping shifts
|
||||
const assertNoOverlap = (exclude_shift_id?: number)=> {
|
||||
if (!new_norm_shift) return;
|
||||
const overlap_with = day_shifts.filter((shift)=> {
|
||||
if(exclude_shift_id && shift.id === exclude_shift_id) return false;
|
||||
return overlaps(
|
||||
new_norm_shift.start_time.getTime(),
|
||||
new_norm_shift.end_time.getTime(),
|
||||
shift.start_time.getTime(),
|
||||
shift.end_time.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
if(overlap_with.length > 0) {
|
||||
const conflicts = overlap_with.map((shift)=> ({
|
||||
start_time: formatHHmm(shift.start_time),
|
||||
end_time: formatHHmm(shift.end_time),
|
||||
type: shift.bank_code?.type ?? 'UNKNOWN',
|
||||
}));
|
||||
throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts});
|
||||
return this.createShift(employee_id, date, dto);
|
||||
}
|
||||
};
|
||||
let action: UpsertAction;
|
||||
//_____________________________________________________________________________________________
|
||||
// DELETE
|
||||
//_____________________________________________________________________________________________
|
||||
if ( old_shift && !new_shift ) {
|
||||
if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`);
|
||||
const existing = await findExactOldShift();
|
||||
if(!existing) {
|
||||
throw new NotFoundException({
|
||||
error_code: 'SHIFT_STALE',
|
||||
message: 'The shift was modified or deleted by someone else',
|
||||
});
|
||||
if(action === 'update'){
|
||||
if(!dto.old_shift || !dto.new_shift) {
|
||||
throw new BadRequestException(`Both new_shift and old_shift must be provided for update`);
|
||||
}
|
||||
await tx.shifts.delete({ where: { id: existing.id } } );
|
||||
action = 'deleted';
|
||||
return this.updateShift(employee_id, date, dto);
|
||||
}
|
||||
//_____________________________________________________________________________________________
|
||||
throw new BadRequestException(`Unknown action: ${action}`);
|
||||
}
|
||||
|
||||
//_________________________________________________________________
|
||||
// CREATE
|
||||
//_____________________________________________________________________________________________
|
||||
else if (!old_shift && new_shift) {
|
||||
if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`);
|
||||
assertNoOverlap();
|
||||
//_________________________________________________________________
|
||||
private async createShift(
|
||||
employee_id: number,
|
||||
date_iso: string,
|
||||
dto: UpsertShiftDto,
|
||||
): Promise<{action: UpsertAction; day: DayShiftResponse[]}> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const date_only = toDateOnly(date_iso);
|
||||
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);
|
||||
|
||||
await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift);
|
||||
|
||||
await tx.shifts.create({
|
||||
data: {
|
||||
timesheet_id: timesheet.id,
|
||||
date: date_only,
|
||||
start_time: new_norm_shift!.start_time,
|
||||
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!,
|
||||
},
|
||||
});
|
||||
action = 'created';
|
||||
}
|
||||
//_____________________________________________________________________________________________
|
||||
// UPDATE
|
||||
//_____________________________________________________________________________________________
|
||||
else if (old_shift && new_shift){
|
||||
if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`);
|
||||
if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`);
|
||||
const existing = await findExactOldShift();
|
||||
if(!existing) throw new NotFoundException({ error_code: 'SHIFT_STALE', message: 'The shift was modified or deleted by someone else'});
|
||||
assertNoOverlap(existing.id);
|
||||
|
||||
await tx.shifts.update({
|
||||
where: {
|
||||
id: existing.id
|
||||
},
|
||||
data: {
|
||||
start_time: new_norm_shift!.start_time,
|
||||
end_time: new_norm_shift!.end_time,
|
||||
is_remote: new_norm_shift!.is_remote,
|
||||
comment: new_norm_shift!.comment ?? null,
|
||||
start_time: new_norm_shift.start_time,
|
||||
end_time: new_norm_shift.end_time,
|
||||
is_remote: new_norm_shift.is_remote,
|
||||
is_approved: new_norm_shift.is_approved,
|
||||
comment: new_norm_shift.comment ?? null,
|
||||
bank_code_id: new_bank_code_id,
|
||||
},
|
||||
});
|
||||
action = 'updated';
|
||||
} else throw new BadRequestException('At least one of old_shift or new_shift must be provided');
|
||||
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_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)};
|
||||
});
|
||||
}
|
||||
|
||||
//switches regular hours to overtime hours when exceeds 40hrs per week.
|
||||
await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx);
|
||||
//_________________________________________________________________
|
||||
// UPDATE
|
||||
//_________________________________________________________________
|
||||
private async updateShift(
|
||||
employee_id: number,
|
||||
date_iso: string,
|
||||
dto: UpsertShiftDto,
|
||||
): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const date_only = toDateOnly(date_iso);
|
||||
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
|
||||
if(!timesheet) throw new NotFoundException('Timesheet not found')
|
||||
|
||||
//Reload the day (truth source)
|
||||
const fresh_day = await tx.shifts.findMany({
|
||||
where: {
|
||||
date: date_only,
|
||||
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 = 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 existing = await this.helpersService.findExactOldShift(tx, {
|
||||
timesheet_id: timesheet.id,
|
||||
date_only,
|
||||
norm: old_norm_shift,
|
||||
bank_code_id: old_bank_code.id,
|
||||
});
|
||||
if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
|
||||
|
||||
await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift, existing.id);
|
||||
|
||||
await tx.shifts.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
start_time: new_norm_shift.start_time,
|
||||
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,
|
||||
},
|
||||
include: { bank_code: true },
|
||||
orderBy: { start_time: 'asc' },
|
||||
});
|
||||
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_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)};
|
||||
});
|
||||
|
||||
try {
|
||||
const [ daily_overtime, weekly_overtime ] = await Promise.all([
|
||||
this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only),
|
||||
this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only),
|
||||
]);
|
||||
this.logger.debug(`[OVERTIME] employee_id= ${employee_id}, date=${date_only.toISOString().slice(0,10)}
|
||||
| daily= ${daily_overtime.toFixed(2)}h, weekly: ${weekly_overtime.toFixed(2)}h, (action:${action})`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to compute overtime after ${action} : ${(error as Error).message}`);
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
day: fresh_day.map<DayShiftResponse>((shift) => ({
|
||||
start_time: formatHHmm(shift.start_time),
|
||||
end_time: formatHHmm(shift.end_time),
|
||||
type: shift.bank_code?.type ?? 'UNKNOWN',
|
||||
is_remote: shift.is_remote,
|
||||
comment: shift.comment ?? null,
|
||||
})),
|
||||
};
|
||||
//_________________________________________________________________
|
||||
// DELETE
|
||||
//_________________________________________________________________
|
||||
async deleteShift(
|
||||
email: string,
|
||||
date_iso: string,
|
||||
dto: UpsertShiftDto,
|
||||
){
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const date_only = toDateOnly(date_iso); //converts to Date format
|
||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||
|
||||
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
|
||||
if(!timesheet) throw new NotFoundException('Timesheet not found')
|
||||
const norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
|
||||
const bank_code_id = await this.typeResolver.findByType(norm_shift.type);
|
||||
|
||||
const existing = await this.helpersService.findExactOldShift(tx, {
|
||||
timesheet_id: timesheet.id,
|
||||
date_only,
|
||||
norm: norm_shift,
|
||||
bank_code_id: bank_code_id.id,
|
||||
});
|
||||
if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
|
||||
|
||||
await tx.shifts.delete({ where: { id: existing.id } });
|
||||
|
||||
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -111,95 +111,4 @@ export class ShiftsQueryService {
|
|||
//return by default the list of employee in ascending alphabetical order
|
||||
return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name));
|
||||
}
|
||||
|
||||
//_____________________________________________________________________________________________
|
||||
// Deprecated or unused methods
|
||||
//_____________________________________________________________________________________________
|
||||
|
||||
// async update(id: number, dto: UpdateShiftsDto): Promise<Shifts> {
|
||||
// await this.findOne(id);
|
||||
// const { timesheet_id, bank_code_id, date,start_time,end_time, comment} = dto;
|
||||
// return this.prisma.shifts.update({
|
||||
// where: { id },
|
||||
// data: {
|
||||
// ...(timesheet_id !== undefined && { timesheet_id }),
|
||||
// ...(bank_code_id !== undefined && { bank_code_id }),
|
||||
// ...(date !== undefined && { date }),
|
||||
// ...(start_time !== undefined && { start_time }),
|
||||
// ...(end_time !== undefined && { end_time }),
|
||||
// ...(comment !== undefined && { comment }),
|
||||
// },
|
||||
// include: { timesheet: { include: { employee: { include: { user: true } } } },
|
||||
// bank_code: true,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
// async remove(id: number): Promise<Shifts> {
|
||||
// await this.findOne(id);
|
||||
// return this.prisma.shifts.delete({ where: { id } });
|
||||
// }
|
||||
|
||||
// async create(dto: CreateShiftDto): Promise<Shifts> {
|
||||
// const { timesheet_id, bank_code_id, date, start_time, end_time, comment } = dto;
|
||||
|
||||
// //shift creation
|
||||
// const shift = await this.prisma.shifts.create({
|
||||
// data: { timesheet_id, bank_code_id, date, start_time, end_time, comment },
|
||||
// include: { timesheet: { include: { employee: { include: { user: true } } } },
|
||||
// bank_code: true,
|
||||
// },
|
||||
// });
|
||||
|
||||
// //fetches all shifts of the same day to check for daily overtime
|
||||
// const same_day_shifts = await this.prisma.shifts.findMany({
|
||||
// where: { timesheet_id, date },
|
||||
// select: { id: true, date: true, start_time: true, end_time: true },
|
||||
// });
|
||||
|
||||
// //sums hours of the day
|
||||
// const total_hours = same_day_shifts.reduce((sum, s) => {
|
||||
// return sum + hoursBetweenSameDay(s.date, s.start_time, s.end_time);
|
||||
// }, 0 );
|
||||
|
||||
// //Notify if total hours > 8 for a single day
|
||||
// if(total_hours > DAILY_LIMIT_HOURS ) {
|
||||
// const user_id = String(shift.timesheet.employee.user.id);
|
||||
// const date_label = new Date(date).toLocaleDateString('fr-CA');
|
||||
// this.notifs.notify(user_id, {
|
||||
// type: 'shift.overtime.daily',
|
||||
// severity: 'warn',
|
||||
// message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${date_label}
|
||||
// (total: ${total_hours.toFixed(2)}h).`,
|
||||
// ts: new Date().toISOString(),
|
||||
// meta: {
|
||||
// timesheet_id,
|
||||
// date: new Date(date).toISOString(),
|
||||
// total_hours,
|
||||
// threshold: DAILY_LIMIT_HOURS,
|
||||
// last_shift_id: shift.id
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// return shift;
|
||||
// }
|
||||
// async findAll(filters: SearchShiftsDto): Promise <Shifts[]> {
|
||||
// const where = buildPrismaWhere(filters);
|
||||
// const shifts = await this.prisma.shifts.findMany({ where })
|
||||
// return shifts;
|
||||
// }
|
||||
|
||||
// async findOne(id: number): Promise<Shifts> {
|
||||
// const shift = await this.prisma.shifts.findUnique({
|
||||
// where: { id },
|
||||
// include: { timesheet: { include: { employee: { include: { user: true } } } },
|
||||
// bank_code: true,
|
||||
// },
|
||||
// });
|
||||
// if(!shift) {
|
||||
// throw new NotFoundException(`Shift #${id} not found`);
|
||||
// }
|
||||
// return shift;
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
@ -6,18 +6,20 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
|||
import { ShiftsQueryService } from './services/shifts-query.service';
|
||||
import { ShiftsArchivalService } from './services/shifts-archival.service';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { ShiftsHelpersService } from './helpers/shifts.helpers';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BusinessLogicsModule,
|
||||
NotificationsModule,
|
||||
SharedModule
|
||||
SharedModule,
|
||||
],
|
||||
controllers: [ShiftsController],
|
||||
providers: [
|
||||
ShiftsQueryService,
|
||||
ShiftsCommandService,
|
||||
ShiftsArchivalService,
|
||||
ShiftsHelpersService,
|
||||
],
|
||||
exports: [
|
||||
ShiftsQueryService,
|
||||
|
|
|
|||
|
|
@ -6,4 +6,12 @@ export type DayShiftResponse = {
|
|||
comment: string | null;
|
||||
}
|
||||
|
||||
export type UpsertAction = 'created' | 'updated' | 'deleted';
|
||||
export type ShiftPayload = {
|
||||
date: string;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
type: string;
|
||||
is_remote: boolean;
|
||||
is_approved: boolean;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { NotFoundException } from "@nestjs/common";
|
||||
import { ShiftPayloadDto } from "../dtos/upsert-shift.dto";
|
||||
import { timeFromHHMM } from "../helpers/shifts-date-time-helpers";
|
||||
|
||||
export function overlaps(
|
||||
a_start_ms: number,
|
||||
|
|
@ -22,17 +20,38 @@ export function resolveBankCodeByType(type: string): Promise<number> {
|
|||
return bank.id;
|
||||
}
|
||||
|
||||
export function normalizeShiftPayload(payload: ShiftPayloadDto) {
|
||||
export function normalizeShiftPayload(payload: {
|
||||
date: string,
|
||||
start_time: string,
|
||||
end_time: string,
|
||||
type: string,
|
||||
is_remote: boolean,
|
||||
is_approved: boolean,
|
||||
comment?: string | null,
|
||||
}) {
|
||||
//normalize shift's infos
|
||||
const date = payload.date;
|
||||
const start_time = timeFromHHMM(payload.start_time);
|
||||
const end_time = timeFromHHMM(payload.end_time );
|
||||
const date = payload.date?.trim();
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date ?? '');
|
||||
if (!m) throw new Error(`Invalid date format (expected YYYY-MM-DD): "${payload.date}"`);
|
||||
const year = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
|
||||
|
||||
const asLocalDateOn = (input: string): Date => {
|
||||
// HH:mm ?
|
||||
const hm = /^(\d{2}):(\d{2})$/.exec((input ?? '').trim());
|
||||
if (hm) return new Date(year, mo - 1, d, Number(hm[1]), Number(hm[2]), 0, 0);
|
||||
const iso = new Date(input);
|
||||
if (isNaN(iso.getTime())) throw new Error(`Invalid time: "${input}"`);
|
||||
return new Date(year, mo - 1, d, iso.getHours(), iso.getMinutes(), iso.getSeconds(), iso.getMilliseconds());
|
||||
};
|
||||
|
||||
const start_time = asLocalDateOn(payload.start_time);
|
||||
const end_time = asLocalDateOn(payload.end_time);
|
||||
|
||||
const type = (payload.type || '').trim().toUpperCase();
|
||||
const is_remote = payload.is_remote === true;
|
||||
const is_approved = payload.is_approved === false;
|
||||
const is_remote = payload.is_remote;
|
||||
const is_approved = payload.is_approved;
|
||||
//normalize comment
|
||||
const raw_comment = payload.comment ?? null;
|
||||
const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null;
|
||||
const trimmed = typeof payload.comment === 'string' ? payload.comment.trim() : null;
|
||||
const comment = trimmed && trimmed.length > 0 ? trimmed : null;
|
||||
|
||||
return { date, start_time, end_time, type, is_remote, is_approved, comment };
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
|||
import { Roles as RoleEnum } from '.prisma/client';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { TimesheetsCommandService } from '../services/timesheets-command.service';
|
||||
import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
|
||||
import { TimesheetMap } from '../utils-helpers-others/timesheet.types';
|
||||
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
|
||||
|
||||
|
||||
@ApiTags('Timesheets')
|
||||
|
|
@ -33,7 +34,7 @@ export class TimesheetsController {
|
|||
async getByEmail(
|
||||
@Param('email') email: string,
|
||||
@Query('offset') offset?: string,
|
||||
): Promise<TimesheetDto> {
|
||||
): Promise<TimesheetMap> {
|
||||
const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0;
|
||||
return this.timesheetsQuery.getTimesheetByEmail(email, week_offset);
|
||||
}
|
||||
|
|
@ -43,37 +44,8 @@ export class TimesheetsController {
|
|||
@Param('email') email: string,
|
||||
@Body() dto: CreateWeekShiftsDto,
|
||||
@Query('offset') offset?: string,
|
||||
): Promise<TimesheetDto> {
|
||||
): Promise<TimesheetMap> {
|
||||
const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0;
|
||||
return this.timesheetsCommand.createWeekShiftsAndReturnOverview(email, dto.shifts, week_offset);
|
||||
}
|
||||
|
||||
//_____________________________________________________________________________________________
|
||||
// Deprecated or unused methods
|
||||
//_____________________________________________________________________________________________
|
||||
|
||||
// @Patch('approval/:id')
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
|
||||
// return this.timesheetsCommand.updateApproval(id, isApproved);
|
||||
// }
|
||||
|
||||
// @Get(':id')
|
||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Find timesheet' })
|
||||
// @ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto })
|
||||
// @ApiResponse({ status: 400, description: 'Timesheet not found' })
|
||||
// findOne(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
|
||||
// return this.timesheetsQuery.findOne(id);
|
||||
// }
|
||||
|
||||
// @Delete(':id')
|
||||
// // @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||
// @ApiOperation({ summary: 'Delete timesheet' })
|
||||
// @ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto })
|
||||
// @ApiResponse({ status: 400, description: 'Timesheet not found' })
|
||||
// remove(@Param('id', ParseIntPipe) id: number): Promise<Timesheets> {
|
||||
// return this.timesheetsQuery.remove(id);
|
||||
// }
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { TimesheetsArchive } from "@prisma/client";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
|
||||
export class TimesheetArchiveService {
|
||||
constructor(private readonly prisma: PrismaService){}
|
||||
|
||||
|
|
@ -21,9 +20,7 @@ export class TimesheetArchiveService {
|
|||
is_approved: true,
|
||||
},
|
||||
});
|
||||
if( oldSheets.length === 0) {
|
||||
return;
|
||||
}
|
||||
if( oldSheets.length === 0) return;
|
||||
|
||||
//preping data for archivation
|
||||
const archiveDate = oldSheets.map(sheet => ({
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Prisma, Timesheets } from "@prisma/client";
|
||||
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
import { TimesheetsQueryService } from "./timesheets-query.service";
|
||||
import { CreateTimesheetDto } from "../dtos/create-timesheet.dto";
|
||||
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils";
|
||||
import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils";
|
||||
import { parseISODate, parseHHmm } from "../utils/timesheet.helpers";
|
||||
import { TimesheetDto } from "../dtos/timesheet-period.dto";
|
||||
import { parseISODate, parseHHmm } from "../utils-helpers-others/timesheet.helpers";
|
||||
import { TimesheetsQueryService } from "./timesheets-query.service";
|
||||
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
||||
import { Prisma, Timesheets } from "@prisma/client";
|
||||
import { CreateTimesheetDto } from "../dtos/create-timesheet.dto";
|
||||
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils";
|
||||
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";
|
||||
|
||||
@Injectable()
|
||||
export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
|
||||
|
|
@ -58,7 +58,7 @@ export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
|
|||
email:string,
|
||||
shifts: CreateTimesheetDto[],
|
||||
week_offset = 0,
|
||||
): Promise<TimesheetDto> {
|
||||
): Promise<TimesheetMap> {
|
||||
//fetchs employee matchint user's email
|
||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||
if(!employee_id) throw new NotFoundException(`employee for ${ email } not found`);
|
||||
|
|
@ -69,7 +69,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
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
|
||||
import { ShiftRow, ExpenseRow } from '../types/timesheet.types';
|
||||
import { buildPeriod } from '../utils/timesheet.utils';
|
||||
import { makeEmptyTimesheet, mapExpenseRow, mapShiftRow } from '../utils-helpers-others/timesheet.mappers';
|
||||
import { buildPeriod, computeWeekRange } from '../utils-helpers-others/timesheet.utils';
|
||||
import { TimesheetSelectorsService } from '../utils-helpers-others/timesheet.selectors';
|
||||
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
|
||||
import { toRangeFromPeriod } from '../utils-helpers-others/timesheet.helpers';
|
||||
import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
|
||||
import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils';
|
||||
import { PrismaService } from 'src/prisma/prisma.service';
|
||||
import { TimesheetMap } from '../utils-helpers-others/timesheet.types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -14,212 +15,40 @@ export class TimesheetsQueryService {
|
|||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly emailResolver: EmailToIdResolver,
|
||||
private readonly fullNameResolver: FullNameResolver
|
||||
private readonly fullNameResolver: FullNameResolver,
|
||||
private readonly selectors: TimesheetSelectorsService,
|
||||
) {}
|
||||
|
||||
async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
|
||||
//finds the employee using 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
|
||||
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
|
||||
const period = await this.prisma.payPeriods.findFirst({
|
||||
where: {
|
||||
pay_year: year,
|
||||
pay_period_no: period_no
|
||||
},
|
||||
select: {
|
||||
period_start: true,
|
||||
period_end: true
|
||||
},
|
||||
});
|
||||
if(!period) throw new NotFoundException(`Period ${year}-${period_no} not found`);
|
||||
|
||||
const from = toUTCDateOnly(period.period_start);
|
||||
const to = endOfDayUTC(period.period_end);
|
||||
|
||||
const raw_shifts = await this.prisma.shifts.findMany({
|
||||
where: {
|
||||
timesheet: { is: { employee_id: employee_id } },
|
||||
date: { gte: from, lte: to },
|
||||
},
|
||||
select: {
|
||||
date: true,
|
||||
start_time: true,
|
||||
end_time: true,
|
||||
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;
|
||||
|
||||
const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email
|
||||
const full_name = await this.fullNameResolver.resolveFullName(employee_id); //finds the employee full name using employee_id
|
||||
const period = await this.selectors.getPayPeriod(year, period_no);//finds the pay period using year and period_no
|
||||
const{ from, to } = toRangeFromPeriod(period); //finds start and end dates
|
||||
//finds all shifts from selected period
|
||||
const [raw_shifts, raw_expenses] = await Promise.all([
|
||||
this.selectors.getShifts(employee_id, from, to),
|
||||
this.selectors.getExpenses(employee_id, from, to),
|
||||
]);
|
||||
// data mapping
|
||||
const shifts: ShiftRow[] = raw_shifts.map(shift => ({
|
||||
date: shift.date,
|
||||
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 ?? '',
|
||||
}));
|
||||
const shifts = raw_shifts.map(mapShiftRow);
|
||||
const expenses = raw_expenses.map(mapExpenseRow);
|
||||
|
||||
return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name);
|
||||
}
|
||||
|
||||
async getTimesheetByEmail(email: string, week_offset = 0): Promise<TimesheetDto> {
|
||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||
if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
|
||||
|
||||
//sets current week Sunday -> Saturday
|
||||
const base = new Date();
|
||||
const offset = new Date(base);
|
||||
offset.setDate(offset.getDate() + (week_offset * 7));
|
||||
|
||||
const start_date_week = getWeekStart(offset, 0);
|
||||
const end_date_week = getWeekEnd(start_date_week);
|
||||
const start_day = formatDateISO(start_date_week);
|
||||
const end_day = formatDateISO(end_date_week);
|
||||
|
||||
//build the label MM/DD/YYYY.MM/DD.YYYY
|
||||
const mm_dd = (date: Date) => `${String(date.getFullYear())}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2,'0')}`;
|
||||
const label = `${mm_dd(start_date_week)}.${mm_dd(end_date_week)}`;
|
||||
|
||||
//fetch timesheet shifts and expenses
|
||||
const timesheet = await this.prisma.timesheets.findUnique({
|
||||
where: {
|
||||
employee_id_start_date: {
|
||||
employee_id: employee_id,
|
||||
start_date: start_date_week,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
shift: {
|
||||
include: { bank_code: true },
|
||||
orderBy: [{ date: 'asc'}, { start_time: 'asc'}],
|
||||
},
|
||||
expense: {
|
||||
include: { bank_code: true },
|
||||
orderBy: [{date: 'asc'}],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
//returns an empty timesheet if not found
|
||||
if(!timesheet) {
|
||||
return {
|
||||
is_approved: false,
|
||||
start_day,
|
||||
end_day,
|
||||
label,
|
||||
shifts:[],
|
||||
expenses: [],
|
||||
} as TimesheetDto;
|
||||
}
|
||||
async getTimesheetByEmail(email: string, week_offset = 0): Promise<TimesheetMap> {
|
||||
const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email
|
||||
const { start, start_day, end_day, label } = computeWeekRange(week_offset);
|
||||
const timesheet = await this.selectors.getTimesheetWithShiftsAndExpenses(employee_id, start); //fetch timesheet shifts and expenses
|
||||
if(!timesheet) return makeEmptyTimesheet({ start_day, end_day, label});
|
||||
|
||||
//maps all shifts of selected timesheet
|
||||
const shifts = timesheet.shift.map((shift_row) => ({
|
||||
type: shift_row.bank_code?.type ?? '',
|
||||
date: formatDateISO(shift_row.date),
|
||||
start_time: toHHmm(shift_row.start_time),
|
||||
end_time: toHHmm(shift_row.end_time),
|
||||
comment: shift_row.comment ?? '',
|
||||
is_approved: shift_row.is_approved ?? false,
|
||||
is_remote: shift_row.is_remote ?? false,
|
||||
}));
|
||||
const shifts = timesheet.shift.map(mapShiftRow);
|
||||
const expenses = timesheet.expense.map(mapExpenseRow);
|
||||
|
||||
//maps all expenses of selected timsheet
|
||||
const expenses = timesheet.expense.map((exp) => ({
|
||||
type: exp.bank_code?.type ?? '',
|
||||
date: formatDateISO(exp.date),
|
||||
amount: Number(exp.amount) || 0,
|
||||
mileage: exp.mileage != null ? Number(exp.mileage) : 0,
|
||||
comment: exp.comment ?? '',
|
||||
is_approved: exp.is_approved ?? false,
|
||||
supervisor_comment: exp.supervisor_comment ?? '',
|
||||
}));
|
||||
|
||||
return {
|
||||
start_day,
|
||||
end_day,
|
||||
label,
|
||||
shifts,
|
||||
expenses,
|
||||
is_approved: timesheet.is_approved,
|
||||
} as TimesheetDto;
|
||||
return { start_day, end_day, label, shifts, expenses, is_approved: timesheet.is_approved};
|
||||
}
|
||||
//_____________________________________________________________________________________________
|
||||
// Deprecated or unused methods
|
||||
//_____________________________________________________________________________________________
|
||||
|
||||
// async findOne(id: number): Promise<any> {
|
||||
// const timesheet = await this.prisma.timesheets.findUnique({
|
||||
// where: { id },
|
||||
// include: {
|
||||
// shift: { include: { bank_code: true } },
|
||||
// expense: { include: { bank_code: true } },
|
||||
// employee: { include: { user: true } },
|
||||
// },
|
||||
// });
|
||||
// if(!timesheet) {
|
||||
// throw new NotFoundException(`Timesheet #${id} not found`);
|
||||
// }
|
||||
|
||||
// const detailedShifts = timesheet.shift.map( s => {
|
||||
// const hours = computeHours(s.start_time, s.end_time);
|
||||
// const regularHours = Math.min(8, hours);
|
||||
// const dailyOvertime = this.overtime.getDailyOvertimeHours(s.start_time, s.end_time);
|
||||
// const payRegular = regularHours * s.bank_code.modifier;
|
||||
// const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, s.bank_code.modifier);
|
||||
// return { ...s, hours, payRegular, payOvertime };
|
||||
// });
|
||||
// const weeklyOvertimeHours = detailedShifts.length
|
||||
// ? await this.overtime.getWeeklyOvertimeHours(
|
||||
// timesheet.employee_id,
|
||||
// timesheet.shift[0].date): 0;
|
||||
// return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours };
|
||||
// }
|
||||
|
||||
// async remove(id: number): Promise<Timesheets> {
|
||||
// await this.findOne(id);
|
||||
// return this.prisma.timesheets.delete({ where: { id } });
|
||||
// }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,13 @@ import { ExpensesCommandService } from '../expenses/services/expenses-command.
|
|||
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
|
||||
import { SharedModule } from '../shared/shared.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors';
|
||||
import { ShiftsHelpersService } from '../shifts/helpers/shifts.helpers';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
BusinessLogicsModule,
|
||||
SharedModule
|
||||
SharedModule,
|
||||
],
|
||||
controllers: [TimesheetsController],
|
||||
providers: [
|
||||
|
|
@ -20,6 +22,8 @@ import { Module } from '@nestjs/common';
|
|||
ShiftsCommandService,
|
||||
ExpensesCommandService,
|
||||
TimesheetArchiveService,
|
||||
TimesheetSelectorsService,
|
||||
ShiftsHelpersService,
|
||||
],
|
||||
exports: [
|
||||
TimesheetsQueryService,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { MS_PER_DAY, DayKey, DAY_KEYS } from "../types/timesheet.types";
|
||||
|
||||
import { MS_PER_DAY } from "src/modules/shared/constants/date-time.constant";
|
||||
import { DAY_KEYS, DayKey } from "././timesheet.types";
|
||||
|
||||
export function toUTCDateOnly(date: Date | string): Date {
|
||||
const d = new Date(date);
|
||||
|
|
@ -43,7 +43,6 @@ export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
|
|||
|
||||
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 {
|
||||
const [ y, m, d ] = iso.split('-').map(Number);
|
||||
return new Date(y, (m ?? 1) - 1, d ?? 1);
|
||||
|
|
@ -54,3 +53,15 @@ export function parseHHmm(t: string): Date {
|
|||
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),
|
||||
});
|
||||
|
|
@ -1,6 +1,45 @@
|
|||
import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "../dtos/timesheet-period.dto";
|
||||
import { ExpensesAmount } from "../types/timesheet.types";
|
||||
import { addDays, shortDate } from "../utils/timesheet.helpers";
|
||||
import { ShiftRow, ExpenseRow, ExpensesAmount, TimesheetMap } from "./timesheet.types";
|
||||
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
|
||||
export function makeEmptyDayExpenses(): DayExpensesDto {
|
||||
|
|
@ -53,3 +92,20 @@ export const makeAmounts = (): ExpensesAmount => ({
|
|||
expense: 0,
|
||||
mileage: 0,
|
||||
});
|
||||
|
||||
export function makeEmptyTimesheet(params: {
|
||||
start_day: string;
|
||||
end_day: string;
|
||||
label: string;
|
||||
is_approved?: boolean;
|
||||
}): TimesheetMap {
|
||||
const { start_day, end_day, label, is_approved = false } = params;
|
||||
return {
|
||||
start_day,
|
||||
end_day,
|
||||
label,
|
||||
shifts: [],
|
||||
expenses: [],
|
||||
is_approved,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { EXPENSE_ASC_ORDER, EXPENSE_SELECT } from "../../shared/selects/expenses.select";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { SHIFT_ASC_ORDER, SHIFT_SELECT } from "../../shared/selects/shifts.select";
|
||||
import { PAY_PERIOD_SELECT } from "../../shared/selects/pay-periods.select";
|
||||
import { PrismaService } from "src/prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class TimesheetSelectorsService {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
async getTimesheetWithShiftsAndExpenses(employee_id: number, start_date_week: Date) {
|
||||
return this.prisma.timesheets.findUnique({
|
||||
where: { employee_id_start_date: { employee_id, start_date: start_date_week } },
|
||||
select: {
|
||||
is_approved: true,
|
||||
shift: { select: SHIFT_SELECT, orderBy: SHIFT_ASC_ORDER },
|
||||
expense: { select: EXPENSE_SELECT, orderBy: EXPENSE_ASC_ORDER },
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -17,9 +17,14 @@ export type ExpenseRow = {
|
|||
supervisor_comment: string;
|
||||
};
|
||||
|
||||
//Date & Format
|
||||
export const MS_PER_DAY = 86_400_000;
|
||||
export const MS_PER_HOUR = 3_600_000;
|
||||
export type TimesheetMap = {
|
||||
start_day: string;
|
||||
end_day: string;
|
||||
label: string;
|
||||
shifts: ShiftRow[];
|
||||
expenses: ExpenseRow[]
|
||||
is_approved: boolean;
|
||||
}
|
||||
|
||||
// Types
|
||||
export const SHIFT_TYPES = {
|
||||
|
|
@ -1,14 +1,31 @@
|
|||
import {
|
||||
DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow, MS_PER_HOUR,
|
||||
DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow,
|
||||
SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount
|
||||
} from "../types/timesheet.types";
|
||||
} from "./timesheet.types";
|
||||
import {
|
||||
isBetweenUTC, dayKeyFromDate, toTimeString, round2,
|
||||
toUTCDateOnly, endOfDayUTC, addDays
|
||||
} from "./timesheet.helpers";
|
||||
import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "../dtos/timesheet-period.dto";
|
||||
import { makeAmounts, makeEmptyWeek } from "../mappers/timesheet.mappers";
|
||||
import { getWeekStart, getWeekEnd, formatDateISO } from "src/common/utils/date-utils";
|
||||
import { makeAmounts, makeEmptyWeek } from "./timesheet.mappers";
|
||||
import { toDateString } from "src/modules/pay-periods/utils/pay-year.util";
|
||||
import { MS_PER_HOUR } from "src/modules/shared/constants/date-time.constant";
|
||||
|
||||
export function computeWeekRange(week_offset = 0){
|
||||
//sets current week Sunday -> Saturday
|
||||
const base = new Date();
|
||||
const offset = new Date(base);
|
||||
offset.setDate(offset.getDate() + (week_offset * 7));
|
||||
|
||||
const start = getWeekStart(offset, 0);
|
||||
const end = getWeekEnd(start);
|
||||
const start_day = formatDateISO(start);
|
||||
const end_day = formatDateISO(end);
|
||||
const label = `${(start_day)}.${(end_day)}`;
|
||||
|
||||
return { start, end, start_day, end_day, label }
|
||||
};
|
||||
|
||||
export function buildWeek(
|
||||
week_start: Date,
|
||||
Loading…
Reference in New Issue
Block a user