import express from 'express'; import mysql from 'mysql2/promise'; import cors from 'cors'; import axios from 'axios'; import multer from 'multer'; import path from 'path'; import { fileURLToPath } from 'url'; import cron from 'node-cron'; import crypto from 'crypto'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); import WebhookManager from './webhook-utils.js'; import { WEBHOOKS, EVENTS } from './webhook-config.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const app = express(); const PORT = 3000; const webhookManager = new WebhookManager(WEBHOOKS.SECRET_KEY); const sseClientsCollab = new Set(); app.use(cors({ origin: '*', methods: ['GET', 'POST', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'] })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); const dbConfig = { host: '192.168.0.4', user: 'wpuser', password: '-2b/)ru5/Bi8P[7_', database: 'DemandeConge', charset: 'utf8mb4' }; function nowFR() { const d = new Date(); d.setHours(d.getHours() + 2); return d.toISOString().slice(0, 19).replace('T', ' '); } // ======================================== // HELPER POUR DATES SANS CONVERSION UTC // ======================================== function formatDateWithoutUTC(date) { if (!date) return null; const d = new Date(date); const year = d.getFullYear(); const month = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; function formatDateToFrenchTime(date) { if (!date) return null; // Créer un objet Date et le convertir en heure française (Europe/Paris) const d = new Date(date); // Formater en ISO avec le fuseau horaire français return d.toLocaleString('fr-FR', { timeZone: 'Europe/Paris', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); } // Ou plus simple, pour avoir un format ISO compatible avec le frontend function formatDateToFrenchISO(date) { if (!date) return null; const d = new Date(date); // Convertir en heure française const frenchDate = new Date(d.toLocaleString('en-US', { timeZone: 'Europe/Paris' })); return frenchDate.toISOString(); } // ======================================== // FONCTIONS POUR GÉRER LES ARRÊTÉS COMPTABLES // À ajouter après : const pool = mysql.createPool(dbConfig); // ======================================== /** * Récupère le dernier arrêté validé/clôturé */ async function getDernierArrete(conn) { const [arretes] = await conn.query(` SELECT * FROM ArreteComptable WHERE Statut IN ('Validé', 'Clôturé') ORDER BY DateArrete DESC LIMIT 1 `); return arretes.length > 0 ? arretes[0] : null; } /** * Vérifie si une date est avant le dernier arrêté */ async function estAvantArrete(conn, date) { const dernierArrete = await getDernierArrete(conn); if (!dernierArrete) { return false; // Pas d'arrêté = toutes les dates sont autorisées } const dateTest = new Date(date); const dateArrete = new Date(dernierArrete.DateArrete); return dateTest <= dateArrete; } /** * Récupère le solde figé d'un collaborateur pour un type de congé */ async function getSoldeFige(conn, collaborateurId, typeCongeId, annee) { const dernierArrete = await getDernierArrete(conn); if (!dernierArrete) { return null; // Pas d'arrêté } const [soldes] = await conn.query(` SELECT * FROM SoldesFiges WHERE ArreteId = ? AND CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? LIMIT 1 `, [dernierArrete.Id, collaborateurId, typeCongeId, annee]); return soldes.length > 0 ? soldes[0] : null; } /** * Calcule l'acquisition depuis le dernier arrêté */ async function calculerAcquisitionDepuisArrete(conn, collaborateurId, typeConge, dateReference = new Date()) { const dernierArrete = await getDernierArrete(conn); const anneeRef = dateReference.getFullYear(); // Déterminer le type de congé const [typeRow] = await conn.query( 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [typeConge === 'CP' ? 'Congé payé' : 'RTT'] ); if (typeRow.length === 0) { throw new Error(`Type de congé ${typeConge} non trouvé`); } const typeCongeId = typeRow[0].Id; const [collab] = await conn.query('SELECT role FROM CollaborateurAD WHERE id = ?', [collaborateurId]); const isApprenti = collab.length > 0 && collab[0].role === 'Apprenti'; if (typeConge === 'RTT' && isApprenti) { return 0; // ⭐ Les apprentis n'ont pas de RTT } // Si pas d'arrêté, calcul normal depuis le début if (!dernierArrete) { if (typeConge === 'CP') { const [collab] = await conn.query('SELECT DateEntree, CampusId FROM CollaborateurAD WHERE id = ?', [collaborateurId]); const dateEntree = collab.length > 0 ? collab[0].DateEntree : null; return calculerAcquisitionCP(dateReference, dateEntree); } else { const rttData = await calculerAcquisitionRTT(conn, collaborateurId, dateReference); return rttData.acquisition; } } const dateArrete = new Date(dernierArrete.DateArrete); // Si la date de référence est AVANT l'arrêté, utiliser le solde figé if (dateReference <= dateArrete) { const soldeFige = await getSoldeFige(conn, collaborateurId, typeCongeId, anneeRef); return soldeFige ? soldeFige.TotalAcquis : 0; } // Si la date est APRÈS l'arrêté, partir du solde figé + calcul depuis l'arrêté const soldeFige = await getSoldeFige(conn, collaborateurId, typeCongeId, anneeRef); const acquisFigee = soldeFige ? soldeFige.TotalAcquis : 0; // Calculer l'acquisition DEPUIS l'arrêté let acquisDepuisArrete = 0; if (typeConge === 'CP') { const moisDepuisArrete = getMoisTravaillesCP(dateReference, dateArrete); acquisDepuisArrete = moisDepuisArrete * (25 / 12); } else { const [collab] = await conn.query('SELECT TypeContrat, CampusId FROM CollaborateurAD WHERE id = ?', [collaborateurId]); const typeContrat = collab.length > 0 && collab[0].TypeContrat ? collab[0].TypeContrat : '37h'; const config = await getConfigurationRTT(conn, anneeRef, typeContrat); const moisDepuisArrete = getMoisTravaillesRTT(dateReference, dateArrete); acquisDepuisArrete = moisDepuisArrete * config.acquisitionMensuelle; } return acquisFigee + acquisDepuisArrete; } const pool = mysql.createPool(dbConfig); const AZURE_CONFIG = { tenantId: '9840a2a0-6ae1-4688-b03d-d2ec291be0f9', clientId: '4bb4cc24-bac3-427c-b02c-5d14fc67b561', clientSecret: 'gvf8Q~545Bafn8yYsgjW~QG_P1lpzaRe6gJNgb2t', groupId: 'c1ea877c-6bca-4f47-bfad-f223640813a0' }; const storage = multer.diskStorage({ destination: './uploads/', filename: (req, file, cb) => { cb(null, Date.now() + path.extname(file.originalname)); } }); const upload = multer({ storage }); const medicalStorage = multer.diskStorage({ destination: './uploads/medical/', filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, 'medical-' + uniqueSuffix + path.extname(file.originalname)); } }); const ACCES_TRANSVERSAUX = { 'sloisil@ensup.eu': { typeAcces: 'service_multi_campus', serviceNom: 'Pédagogie', description: 'Sandrine - Vue complète Pédagogie (tous campus)' }, 'mbouteiller@ensup.eu': { typeAcces: 'service_multi_campus', serviceNom: 'Admissions', description: 'Morgane - Vue complète Admissions (tous campus)' }, 'vnoel@ensup.eu': { typeAcces: 'service_multi_campus', serviceNom: 'Relations Entreprises', description: 'Viviane - Vue complète Relations Entreprises (tous campus)' }, 'vpierrel@ensup.eu': { typeAcces: 'service_multi_campus', // ✅ CORRIGÉ - même type que les autres serviceNom: 'Administratif & Financier', description: 'Virginie - Vue complète Administratif & Financier (tous campus)' } }; function getUserAccesTransversal(userEmail) { const acces = ACCES_TRANSVERSAUX[userEmail?.toLowerCase()] || null; if (acces) { console.log(`🌐 Accès transversal: ${acces.description}`); } return acces; } const uploadMedical = multer({ storage: medicalStorage, limits: { fileSize: 5 * 1024 * 1024 }, fileFilter: (req, file, cb) => { const allowedTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png']; if (allowedTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error('Type de fichier non autorisé')); } } }); import fs from 'fs'; if (!fs.existsSync('./uploads/medical')) { fs.mkdirSync('./uploads/medical', { recursive: true }); } app.get('/api/events/collaborateur', (req, res) => { const userId = req.query.user_id; if (!userId) { return res.status(401).json({ error: 'user_id requis' }); } console.log('🔔 Nouvelle connexion SSE collaborateur:', userId); // ⭐ HEADERS CRITIQUES POUR SSE res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('Access-Control-Allow-Origin', '*'); // ⭐ FLUSH IMMÉDIATEMENT POUR ÉTABLIR LA CONNEXION res.flushHeaders(); const sendEvent = (data) => { try { if (res.writableEnded) { console.log('⚠️ Tentative d\'envoi sur connexion fermée'); return false; } res.write(`data: ${JSON.stringify(data)}\n\n`); return true; } catch (error) { console.error('❌ Erreur envoi SSE:', error); return false; } }; const client = { userId: parseInt(userId), send: sendEvent, res: res // ⭐ Garder référence pour vérifier l'état }; sseClientsCollab.add(client); console.log(`📊 Clients SSE collaborateurs connectés: ${sseClientsCollab.size}`); // ⭐ ÉVÉNEMENT DE CONNEXION sendEvent({ type: 'connected', message: 'Connexion établie', timestamp: new Date().toISOString() }); // ⭐ HEARTBEAT AVEC VÉRIFICATION const heartbeat = setInterval(() => { const success = sendEvent({ type: 'heartbeat', timestamp: new Date().toISOString() }); if (!success) { console.log('💔 Heartbeat échoué, nettoyage...'); clearInterval(heartbeat); sseClientsCollab.delete(client); } }, 30000); // 30 secondes // ⭐ GESTION PROPRE DE LA DÉCONNEXION const cleanup = () => { console.log('🔌 Déconnexion SSE collaborateur:', userId); clearInterval(heartbeat); sseClientsCollab.delete(client); console.log(`📊 Clients SSE collaborateurs connectés: ${sseClientsCollab.size}`); }; req.on('close', cleanup); req.on('error', (err) => { console.error('❌ Erreur SSE connexion:', err.message); cleanup(); }); // ⭐ TIMEOUT DE SÉCURITÉ (optionnel, mais recommandé) req.socket.setTimeout(0); // Désactiver timeout pour SSE }); const notifyCollabClients = (event, targetUserId = null) => { console.log( `📢 Notification SSE Collab: ${event.type}`, targetUserId ? `pour user ${targetUserId}` : 'pour tous' ); const deadClients = []; sseClientsCollab.forEach(client => { // ⭐ FILTRER PAR USER SI NÉCESSAIRE if (targetUserId && client.userId !== targetUserId) { return; } // ⭐ VÉRIFIER SI LA CONNEXION EST TOUJOURS ACTIVE if (client.res && client.res.writableEnded) { console.log(`💀 Client mort détecté: ${client.userId}`); deadClients.push(client); return; } // ⭐ ENVOYER L'ÉVÉNEMENT const success = client.send(event); if (!success) { deadClients.push(client); } }); // ⭐ NETTOYER LES CLIENTS MORTS deadClients.forEach(client => { console.log(`🧹 Nettoyage client mort: ${client.userId}`); sseClientsCollab.delete(client); }); if (deadClients.length > 0) { console.log(`📊 Clients SSE après nettoyage: ${sseClientsCollab.size}`); } }; app.post('/api/webhook/receive', async (req, res) => { try { const signature = req.headers['x-webhook-signature']; const payload = req.body; console.log('📥 Webhook reçu:', payload.event); // Vérifier la signature if (!webhookManager.verifySignature(payload, signature)) { console.error('❌ Signature webhook invalide'); return res.status(401).json({ error: 'Signature invalide' }); } const { event, data } = payload; // Traiter selon le type d'événement switch (event) { case EVENTS.DEMANDE_VALIDATED: console.log(`📥 Validation reçue: Demande ${data.demandeId} - Statut: ${data.statut}`); const conn = await pool.getConnection(); try { await conn.beginTransaction(); // ⭐ GESTION DES COMPTEURS SELON LE STATUT if (data.statut === 'Refusée' && data.collaborateurId) { console.log(`❌ DEMANDE REFUSÉE - Restauration des soldes...`); // Restaurer les soldes via la fonction existante const restoration = await restoreLeaveBalance( conn, data.demandeId, data.collaborateurId ); console.log('✅ Restauration terminée:', restoration); } else if (data.statut === 'Validée') { console.log(`✅ DEMANDE VALIDÉE - Les jours ont déjà été déduits à la création`); } // ⭐ CRÉER UNE NOTIFICATION EN BASE DE DONNÉES const notifTitle = data.statut === 'Validée' ? 'Demande approuvée ✅' : 'Demande refusée ❌'; let notifMessage = `Votre demande a été ${data.statut === 'Validée' ? 'approuvée' : 'refusée'}`; if (data.commentaire) { notifMessage += ` (Commentaire: ${data.commentaire})`; } const notifType = data.statut === 'Validée' ? 'Success' : 'Error'; // ✅ FIX : Remplacer NOW() par nowFR() await conn.query( 'INSERT INTO Notifications (CollaborateurADId, Titre, Message, Type, DemandeCongeId, DateCreation, lu) VALUES (?, ?, ?, ?, ?, ?, 0)', [data.collaborateurId, notifTitle, notifMessage, notifType, data.demandeId, nowFR()] ); console.log('✅ Notification créée en base de données'); await conn.commit(); } catch (error) { await conn.rollback(); console.error('❌ Erreur traitement webhook:', error); throw error; } finally { conn.release(); } // Notifier les clients SSE notifyCollabClients({ type: 'demande-validated-rh', demandeId: data.demandeId, statut: data.statut, timestamp: new Date().toISOString() }, data.collaborateurId); notifyCollabClients({ type: 'demande-list-updated', action: 'validation-rh', demandeId: data.demandeId, timestamp: new Date().toISOString() }); break; case EVENTS.COMPTEUR_UPDATED: console.log(`🔄 Compteur mis à jour pour collaborateur ${data.collaborateurId}`); notifyCollabClients({ type: 'compteur-updated', collaborateurId: data.collaborateurId, timestamp: new Date().toISOString() }, data.collaborateurId); break; case EVENTS.DEMANDE_UPDATED: console.log(`✏️ Demande ${data.demandeId} modifiée via RH`); // ⭐ CRÉER UNE NOTIFICATION POUR LA MODIFICATION const connUpdate = await pool.getConnection(); try { // ✅ FIX : Remplacer NOW() par nowFR() await connUpdate.query( 'INSERT INTO Notifications (CollaborateurADId, Titre, Message, Type, DemandeCongeId, DateCreation, lu) VALUES (?, ?, ?, ?, ?, ?, 0)', [data.collaborateurId, 'Demande modifiée ✏️', 'Votre demande a été modifiée par le service RH', 'Info', data.demandeId, nowFR()] ); console.log('✅ Notification modification créée'); } catch (error) { console.error('❌ Erreur création notification:', error); } finally { connUpdate.release(); } notifyCollabClients({ type: 'demande-updated-rh', demandeId: data.demandeId, timestamp: new Date().toISOString() }, data.collaborateurId); break; case EVENTS.DEMANDE_DELETED: console.log(`🗑️ Demande ${data.demandeId} supprimée via RH`); // ⭐ CRÉER UNE NOTIFICATION POUR LA SUPPRESSION const connDelete = await pool.getConnection(); try { // ✅ FIX : Remplacer NOW() par nowFR() await connDelete.query( 'INSERT INTO Notifications (CollaborateurADId, Titre, Message, Type, DemandeCongeId, DateCreation, lu) VALUES (?, ?, ?, ?, ?, ?, 0)', [data.collaborateurId, 'Demande supprimée 🗑️', 'Votre demande a été supprimée par le service RH', 'Warning', data.demandeId, nowFR()] ); console.log('✅ Notification suppression créée'); } catch (error) { console.error('❌ Erreur création notification:', error); } finally { connDelete.release(); } notifyCollabClients({ type: 'demande-deleted-rh', demandeId: data.demandeId, timestamp: new Date().toISOString() }, data.collaborateurId); break; default: console.warn(`⚠️ Type d'événement webhook inconnu: ${event}`); } res.json({ success: true, message: 'Webhook traité' }); } catch (error) { console.error('❌ Erreur traitement webhook:', error); res.status(500).json({ error: error.message }); } }); function getDateFinMoisPrecedent(referenceDate = new Date()) { const now = new Date(referenceDate); now.setHours(0, 0, 0, 0); return new Date(now.getFullYear(), now.getMonth(), 0); } function parseDateYYYYMMDD(s) { if (!s) return null; if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); } return new Date(s); } const LEAVE_RULES = { CP: { nom: 'Congé payé', joursAnnuels: 25, periodeDebut: { mois: 6, jour: 1 }, periodeFin: { mois: 5, jour: 31 }, acquisitionMensuelle: 25 / 12, reportable: true, periodeReport: 'exercice' }, RTT: { nom: 'RTT', joursAnnuels: 10, periodeDebut: { mois: 1, jour: 1 }, periodeFin: { mois: 12, jour: 31 }, acquisitionMensuelle: 10 / 12, reportable: false, periodeReport: null } }; // ======================================== // NOUVELLES FONCTIONS POUR RTT VARIABLES // ======================================== /** * Récupère la configuration RTT pour une année et un type de contrat donnés */ async function getConfigurationRTT(conn, annee, typeContrat = '37h') { try { const [config] = await conn.query( `SELECT JoursAnnuels, AcquisitionMensuelle FROM ConfigurationRTT WHERE Annee = ? AND TypeContrat = ? LIMIT 1`, [annee, typeContrat] ); if (config.length > 0) { return { joursAnnuels: parseFloat(config[0].JoursAnnuels), acquisitionMensuelle: parseFloat(config[0].AcquisitionMensuelle) }; } // Valeurs par défaut si pas de config trouvée console.warn(`⚠️ Pas de config RTT pour ${annee}/${typeContrat}, utilisation des valeurs par défaut`); return typeContrat === 'forfait_jour' ? { joursAnnuels: 12, acquisitionMensuelle: 1.0 } : { joursAnnuels: 10, acquisitionMensuelle: 0.833333 }; } catch (error) { console.error('Erreur getConfigurationRTT:', error); // Retour valeur par défaut en cas d'erreur return { joursAnnuels: 10, acquisitionMensuelle: 0.833333 }; } } /** * Calcule l'acquisition RTT en tenant compte du type de contrat et de l'année */ async function calculerAcquisitionRTT(conn, collaborateurId, dateReference = new Date()) { try { const d = new Date(dateReference); const annee = d.getFullYear(); // Récupérer le type de contrat et la date d'entrée du collaborateur const [collabInfo] = await conn.query( `SELECT TypeContrat, DateEntree, CampusId FROM CollaborateurAD WHERE id = ?`, [collaborateurId] ); if (collabInfo.length === 0) { throw new Error(`Collaborateur ${collaborateurId} non trouvé`); } const typeContrat = collabInfo[0].TypeContrat || '37h'; const dateEntree = collabInfo[0].DateEntree; // Récupérer la configuration RTT pour l'année et le type de contrat const config = await getConfigurationRTT(conn, annee, typeContrat); // Calculer les mois travaillés dans l'année const moisTravailles = getMoisTravaillesRTT(dateReference, dateEntree); // Calculer l'acquisition cumulée const acquisition = moisTravailles * config.acquisitionMensuelle; return { acquisition: Math.round(acquisition * 100) / 100, moisTravailles: parseFloat(moisTravailles.toFixed(2)), config: config, typeContrat: typeContrat }; } catch (error) { console.error('Erreur calculerAcquisitionRTT:', error); throw error; } } /** * Calcule l'acquisition CP (inchangé, mais pour cohérence) */ function calculerAcquisitionCP(dateReference = new Date(), dateEntree = null) { const moisTravailles = getMoisTravaillesCP(dateReference, dateEntree); const acquisition = moisTravailles * (25 / 12); return Math.round(acquisition * 100) / 100; } // ======================================== // TÂCHES CRON // ======================================== cron.schedule('0 2 * * *', async () => { console.log('🔄 [CRON] Mise à jour quotidienne des compteurs...'); try { const conn = await pool.getConnection(); await conn.beginTransaction(); const [collaborateurs] = await conn.query(` SELECT id, prenom, nom, CampusId FROM CollaborateurAD WHERE (actif = 1 OR actif IS NULL) `); let successCount = 0; const today = new Date(); for (const collab of collaborateurs) { try { await updateMonthlyCounters(conn, collab.id, today); successCount++; } catch (error) { console.error(`❌ Erreur pour ${collab.prenom} ${collab.nom}:`, error.message); } } await conn.commit(); console.log(`✅ [CRON] ${successCount}/${collaborateurs.length} compteurs mis à jour`); conn.release(); } catch (error) { console.error('❌ [CRON] Erreur mise à jour quotidienne:', error); } }); cron.schedule('59 23 31 12 *', async () => { console.log('🎆 [CRON] Traitement fin d\'année RTT...'); try { const conn = await pool.getConnection(); await conn.beginTransaction(); const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD'); let successCount = 0; for (const collab of collaborateurs) { try { await processEndOfYearRTT(conn, collab.id); successCount++; } catch (error) { console.error(`❌ Erreur RTT pour ${collab.id}:`, error.message); } } await conn.commit(); console.log(`✅ [CRON] ${successCount}/${collaborateurs.length} RTT réinitialisés`); conn.release(); } catch (error) { console.error('❌ [CRON] Erreur traitement fin d\'année:', error); } }); cron.schedule('59 23 31 5 *', async () => { console.log('📅 [CRON] Traitement fin d\'exercice CP...'); try { const conn = await pool.getConnection(); await conn.beginTransaction(); const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD'); let successCount = 0; for (const collab of collaborateurs) { try { await processEndOfExerciceCP(conn, collab.id); successCount++; } catch (error) { console.error(`❌ Erreur CP pour ${collab.id}:`, error.message); } } await conn.commit(); console.log(`✅ [CRON] ${successCount}/${collaborateurs.length} CP reportés`); conn.release(); } catch (error) { console.error('❌ [CRON] Erreur traitement fin d\'exercice:', error); } }); // ======================================== // CRON : CRÉER ARRÊTÉS MENSUELS AUTOMATIQUEMENT // ======================================== cron.schedule('55 23 28-31 * *', async () => { const today = new Date(); const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); // ⭐ Vérifier qu'on est bien le dernier jour du mois if (today.getDate() === lastDay.getDate()) { console.log(`📅 [CRON] Création arrêté fin de mois: ${today.toISOString().split('T')[0]}`); const conn = await pool.getConnection(); try { await conn.beginTransaction(); const annee = today.getFullYear(); const mois = today.getMonth() + 1; // 1-12 const dateArrete = today.toISOString().split('T')[0]; // ⭐ Vérifier si l'arrêté n'existe pas déjà const [existing] = await conn.query( 'SELECT Id FROM ArreteComptable WHERE Annee = ? AND Mois = ?', [annee, mois] ); if (existing.length > 0) { console.log(`⚠️ [CRON] Arrêté ${mois}/${annee} existe déjà, skip`); await conn.rollback(); conn.release(); return; } // ⭐ Créer l'arrêté const [result] = await conn.query(` INSERT INTO ArreteComptable (DateArrete, Annee, Mois, Libelle, Description, Statut, DateCreation) VALUES (?, ?, ?, ?, ?, 'En cours', NOW()) `, [ dateArrete, annee, mois, `Arrêté comptable ${getMonthName(mois)} ${annee}`, `Arrêté mensuel automatique - Clôture des soldes au ${dateArrete}` ]); const arreteId = result.insertId; console.log(`✅ [CRON] Arrêté créé: ID ${arreteId}`); // ⭐ Créer le snapshot await conn.query('CALL sp_creer_snapshot_arrete(?)', [arreteId]); console.log(`📸 [CRON] Snapshot créé pour l'arrêté ${arreteId}`); // ⭐ Compter les soldes figés const [count] = await conn.query( 'SELECT COUNT(*) as total FROM SoldesFiges WHERE ArreteId = ?', [arreteId] ); await conn.commit(); console.log(`🎉 [CRON] Arrêté ${mois}/${annee} terminé: ${count[0].total} soldes figés`); } catch (error) { await conn.rollback(); console.error(`❌ [CRON] Erreur création arrêté:`, error.message); } finally { conn.release(); } } }); // Mail mensuel le 1er à 9h cron.schedule('0 9 1 * *', async () => { console.log('📧 Envoi mails compte-rendu mensuel...'); const conn = await pool.getConnection(); const [cadres] = await conn.query(` SELECT id, email, prenom, nom FROM CollaborateurAD WHERE TypeContrat = 'forfait_jour' AND (actif = 1 OR actif IS NULL) `); const moisPrecedent = new Date(); moisPrecedent.setMonth(moisPrecedent.getMonth() - 1); for (const cadre of cadres) { // Envoyer mail via Microsoft Graph API // Enregistrer dans MailsCompteRendu } conn.release(); }); // Relance hebdomadaire le lundi à 9h cron.schedule('0 9 * * 1', async () => { console.log('🔔 Relance hebdomadaire compte-rendu...'); const conn = await pool.getConnection(); const moisCourant = new Date().getMonth() + 1; const anneeCourante = new Date().getFullYear(); const [nonValides] = await conn.query(` SELECT DISTINCT ca.id, ca.email, ca.prenom, ca.nom FROM CollaborateurAD ca LEFT JOIN CompteRenduMensuel crm ON ca.id = crm.CollaborateurADId AND crm.Annee = ? AND crm.Mois = ? WHERE ca.TypeContrat = 'forfait_jour' AND (crm.Statut IS NULL OR crm.Statut != 'Validé') `, [anneeCourante, moisCourant - 1]); for (const cadre of nonValides) { // Envoyer relance } conn.release(); }); // ⭐ Fonction helper pour les noms de mois function getMonthName(mois) { const mois_names = ['', 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre']; return mois_names[mois] || mois; } async function getGraphToken() { try { const params = new URLSearchParams({ grant_type: 'client_credentials', client_id: AZURE_CONFIG.clientId, client_secret: AZURE_CONFIG.clientSecret, scope: 'https://graph.microsoft.com/.default' }); const response = await axios.post( `https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`, params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } ); return response.data.access_token; } catch (error) { console.error('Erreur obtention token:', error); return null; } } async function sendMailGraph(accessToken, fromEmail, toEmail, subject, bodyHtml) { try { await axios.post( `https://graph.microsoft.com/v1.0/users/${fromEmail}/sendMail`, { message: { subject, body: { contentType: 'HTML', content: bodyHtml }, toRecipients: [{ emailAddress: { address: toEmail } }] }, saveToSentItems: false }, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } } ); return true; } catch (error) { console.error('Erreur envoi email:', error); return false; } } function getWorkingDays(startDate, endDate) { let workingDays = 0; const current = new Date(startDate); const end = new Date(endDate); while (current <= end) { const dayOfWeek = current.getDay(); if (dayOfWeek !== 0 && dayOfWeek !== 6) { workingDays++; } current.setDate(current.getDate() + 1); } return workingDays; } function formatDate(date) { const d = new Date(date); const day = String(d.getDate()).padStart(2, '0'); const month = String(d.getMonth() + 1).padStart(2, '0'); const year = d.getFullYear(); return `${day}/${month}/${year}`; } function getExerciceCP(date = new Date()) { const d = new Date(date); const annee = d.getFullYear(); const mois = d.getMonth() + 1; if (mois >= 1 && mois <= 5) { return `${annee - 1}-${annee}`; } return `${annee}-${annee + 1}`; } function getMoisTravaillesCP(date = new Date(), dateEntree = null) { const d = new Date(date); d.setHours(0, 0, 0, 0); const annee = d.getFullYear(); const mois = d.getMonth() + 1; let debutExercice; if (mois >= 6) { debutExercice = new Date(annee, 5, 1); } else { debutExercice = new Date(annee - 1, 5, 1); } debutExercice.setHours(0, 0, 0, 0); if (dateEntree) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); if (entree > debutExercice) { debutExercice = entree; } } const diffMs = d - debutExercice; const diffJours = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1; const moisTravailles = diffJours / 30.44; return Math.max(0, Math.min(12, moisTravailles)); } function getMoisTravaillesRTT(date = new Date(), dateEntree = null) { const d = new Date(date); d.setHours(0, 0, 0, 0); const annee = d.getFullYear(); let debutAnnee = new Date(annee, 0, 1); debutAnnee.setHours(0, 0, 0, 0); if (dateEntree) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); if (entree.getFullYear() === annee && entree > debutAnnee) { debutAnnee = entree; } else if (entree.getFullYear() > annee) { return 0; } } const diffMs = d - debutAnnee; const diffJours = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1; const moisTravailles = diffJours / 30.44; return Math.max(0, Math.min(12, moisTravailles)); } function calculerAcquisitionCumulee(typeConge, dateReference = new Date(), dateEntree = null) { const rules = LEAVE_RULES[typeConge]; if (!rules) return 0; let moisTravailles; if (typeConge === 'CP') { moisTravailles = getMoisTravaillesCP(dateReference, dateEntree); } else { moisTravailles = getMoisTravaillesRTT(dateReference, dateEntree); } const acquisition = moisTravailles * rules.acquisitionMensuelle; return Math.round(acquisition * 100) / 100; } async function processEndOfYearRTT(conn, collaborateurId) { const currentYear = new Date().getFullYear(); const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); if (rttType.length === 0) return null; await conn.query( `UPDATE CompteurConges SET Solde = 0, Total = 0, SoldeReporte = 0, DerniereMiseAJour = NOW() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, rttType[0].Id, currentYear] ); return { type: 'RTT', action: 'reset_end_of_year', annee: currentYear }; } async function processEndOfExerciceCP(conn, collaborateurId) { const today = new Date(); const currentYear = today.getFullYear(); const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); if (cpType.length === 0) return null; const cpTypeId = cpType[0].Id; const [currentCounter] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, cpTypeId, currentYear] ); if (currentCounter.length === 0) return null; const soldeAReporter = parseFloat(currentCounter[0].Solde); const [nextYearCounter] = await conn.query( `SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, cpTypeId, currentYear + 1] ); if (nextYearCounter.length > 0) { await conn.query( `UPDATE CompteurConges SET SoldeReporte = ?, Solde = Solde + ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [soldeAReporter, soldeAReporter, nextYearCounter[0].Id] ); } else { await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, 0, ?, ?, NOW())`, [collaborateurId, cpTypeId, currentYear + 1, soldeAReporter, soldeAReporter] ); } return { type: 'CP', action: 'report_exercice', soldeReporte: soldeAReporter }; } async function deductLeaveBalance(conn, collaborateurId, typeCongeId, nombreJours) { const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; let joursRestants = nombreJours; const deductions = []; const [compteurN1] = await conn.query( `SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, previousYear] ); if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) { const soldeN1 = parseFloat(compteurN1[0].SoldeReporte); const aDeduireN1 = Math.min(soldeN1, joursRestants); if (aDeduireN1 > 0) { await conn.query( `UPDATE CompteurConges SET SoldeReporte = GREATEST(0, SoldeReporte - ?), Solde = GREATEST(0, Solde - ?) WHERE Id = ?`, [aDeduireN1, aDeduireN1, compteurN1[0].Id] ); deductions.push({ annee: previousYear, type: 'Reporté N-1', joursUtilises: aDeduireN1, soldeAvant: soldeN1 }); joursRestants -= aDeduireN1; } } if (joursRestants > 0) { const [compteurN] = await conn.query( `SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, currentYear] ); if (compteurN.length > 0) { const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0); const aDeduireN = Math.min(soldeN, joursRestants); if (aDeduireN > 0) { await conn.query( `UPDATE CompteurConges SET Solde = GREATEST(0, Solde - ?) WHERE Id = ?`, [aDeduireN, compteurN[0].Id] ); deductions.push({ annee: currentYear, type: 'Année actuelle N', joursUtilises: aDeduireN, soldeAvant: soldeN }); joursRestants -= aDeduireN; } } } return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions }; } async function checkLeaveBalance(conn, collaborateurId, repartition) { const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; const verification = []; for (const rep of repartition) { const typeCode = rep.TypeConge; const joursNecessaires = parseFloat(rep.NombreJours); if (typeCode === 'ABS' || typeCode === 'Formation') continue; const typeName = typeCode === 'CP' ? 'Congé payé' : typeCode === 'RTT' ? 'RTT' : typeCode; const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [typeName]); if (typeRow.length === 0) continue; const typeCongeId = typeRow[0].Id; const [compteurN1] = await conn.query( `SELECT SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, previousYear] ); const [compteurN] = await conn.query( `SELECT Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, currentYear] ); const soldeN1 = compteurN1.length > 0 ? parseFloat(compteurN1[0].SoldeReporte || 0) : 0; const soldeN = compteurN.length > 0 ? parseFloat(compteurN[0].Solde || 0) - parseFloat(compteurN[0].SoldeReporte || 0) : 0; const soldeTotal = soldeN1 + soldeN; verification.push({ type: typeName, joursNecessaires, soldeN1, soldeN, soldeTotal, suffisant: soldeTotal >= joursNecessaires, deficit: Math.max(0, joursNecessaires - soldeTotal) }); } const insuffisants = verification.filter(v => !v.suffisant); return { valide: insuffisants.length === 0, details: verification, insuffisants }; } // ======================================== // MISE À JOUR DE updateMonthlyCounters // ======================================== async function updateMonthlyCounters(conn, collaborateurId, dateReference = null) { const today = dateReference ? new Date(dateReference) : getDateFinMoisPrecedent(); const currentYear = today.getFullYear(); const updates = []; const [collabInfo] = await conn.query( 'SELECT DateEntree, TypeContrat, CampusId FROM CollaborateurAD WHERE id = ?', [collaborateurId] ); const dateEntree = collabInfo.length > 0 && collabInfo[0].DateEntree ? collabInfo[0].DateEntree : null; const typeContrat = collabInfo.length > 0 && collabInfo[0].TypeContrat ? collabInfo[0].TypeContrat : '37h'; // ===== CP (inchangé) ===== const exerciceCP = getExerciceCP(today); const acquisitionCP = calculerAcquisitionCP(today, dateEntree); const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); if (cpType.length > 0) { const cpTypeId = cpType[0].Id; const [existingCP] = await conn.query( `SELECT Id, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, cpTypeId, currentYear] ); if (existingCP.length > 0) { const ancienTotal = parseFloat(existingCP[0].Total); const ancienSolde = parseFloat(existingCP[0].Solde); const soldeReporte = parseFloat(existingCP[0].SoldeReporte || 0); const incrementTotal = acquisitionCP - ancienTotal; const nouveauSolde = ancienSolde + incrementTotal; await conn.query( `UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [acquisitionCP, Math.max(0, nouveauSolde), existingCP[0].Id] ); updates.push({ type: 'CP', exercice: exerciceCP, acquisitionCumulee: acquisitionCP, increment: incrementTotal, nouveauSolde: Math.max(0, nouveauSolde) }); } else { await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, NOW())`, [collaborateurId, cpTypeId, currentYear, acquisitionCP, acquisitionCP] ); updates.push({ type: 'CP', exercice: exerciceCP, acquisitionCumulee: acquisitionCP, action: 'created', nouveauSolde: acquisitionCP }); } } // ===== RTT (NOUVEAU avec gestion variable) ===== const rttData = await calculerAcquisitionRTT(conn, collaborateurId, today); const acquisitionRTT = rttData.acquisition; const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); if (rttType.length > 0) { const rttTypeId = rttType[0].Id; const [existingRTT] = await conn.query( `SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, rttTypeId, currentYear] ); if (existingRTT.length > 0) { const ancienTotal = parseFloat(existingRTT[0].Total); const ancienSolde = parseFloat(existingRTT[0].Solde); const incrementTotal = acquisitionRTT - ancienTotal; const nouveauSolde = ancienSolde + incrementTotal; await conn.query( `UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [acquisitionRTT, Math.max(0, nouveauSolde), existingRTT[0].Id] ); updates.push({ type: 'RTT', annee: currentYear, typeContrat: rttData.typeContrat, config: `${rttData.config.joursAnnuels}j/an`, moisTravailles: rttData.moisTravailles, acquisitionCumulee: acquisitionRTT, increment: incrementTotal, nouveauSolde: Math.max(0, nouveauSolde) }); } else { await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, NOW())`, [collaborateurId, rttTypeId, currentYear, acquisitionRTT, acquisitionRTT] ); updates.push({ type: 'RTT', annee: currentYear, typeContrat: rttData.typeContrat, config: `${rttData.config.joursAnnuels}j/an`, moisTravailles: rttData.moisTravailles, acquisitionCumulee: acquisitionRTT, action: 'created', nouveauSolde: acquisitionRTT }); } } return updates; } // ======================================== // ROUTES API // ======================================== app.post('/login', async (req, res) => { try { const { email, mot_de_passe, entraUserId, userPrincipalName } = req.body; const accessToken = req.headers.authorization?.replace('Bearer ', ''); if (accessToken && entraUserId) { const [users] = await pool.query(` SELECT ca.*, s.Nom as service, so.Nom as societe_nom FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE ca.entraUserId=? OR ca.email=? LIMIT 1 `, [entraUserId, email]); if (users.length === 0) return res.json({ success: false, message: 'Utilisateur non autorisé' }); const user = users[0]; try { const graphResponse = await axios.get(`https://graph.microsoft.com/v1.0/users/${userPrincipalName}/memberOf?$select=id`, { headers: { Authorization: `Bearer ${accessToken}` } }); const userGroups = graphResponse.data.value.map(g => g.id); const [allowedGroups] = await pool.query('SELECT Id FROM EntraGroups WHERE IsActive=1'); const allowed = allowedGroups.map(g => g.Id); const authorized = userGroups.some(g => allowed.includes(g)); if (authorized) { return res.json({ success: true, message: 'Connexion réussie via Azure AD', user: { id: user.id, prenom: user.prenom, nom: user.nom, email: user.email, role: user.role, service: user.service, societeId: user.SocieteId, societeNom: user.societe_nom } }); } else { return res.json({ success: false, message: 'Utilisateur non autorisé' }); } } catch (error) { return res.json({ success: false, message: 'Erreur vérification groupes' }); } } if (email && mot_de_passe) { const [users] = await pool.query(` SELECT u.ID, u.Prenom, u.Nom, u.Email, u.Role, u.ServiceId, s.Nom AS ServiceNom FROM Users u LEFT JOIN Services s ON u.ServiceId = s.Id WHERE u.Email = ? AND u.MDP = ? `, [email, mot_de_passe]); if (users.length === 1) { return res.json({ success: true, message: 'Connexion réussie', user: { id: users[0].ID, prenom: users[0].Prenom, nom: users[0].Nom, email: users[0].Email, role: users[0].Role, service: users[0].ServiceNom || 'Non défini' } }); } return res.json({ success: false, message: 'Identifiants incorrects' }); } res.json({ success: false, message: 'Aucune méthode de connexion fournie' }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.post('/check-user-groups', async (req, res) => { try { const { userPrincipalName } = req.body; const accessToken = req.headers.authorization?.replace('Bearer ', ''); if (!userPrincipalName || !accessToken) return res.json({ authorized: false, message: 'Email ou token manquant' }); const [users] = await pool.query(` SELECT ca.id, ca.entraUserId, ca.prenom, ca.nom, ca.email, s.Nom as service, ca.role, ca.CampusId, ca.SocieteId, so.Nom as societe_nom FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE ca.email = ? LIMIT 1 `, [userPrincipalName]); if (users.length > 0) { const user = users[0]; return res.json({ authorized: true, role: user.role, groups: [user.role], localUserId: user.id, user: { ...user, societeId: user.SocieteId, societeNom: user.societe_nom } }); } const userGraph = await axios.get(`https://graph.microsoft.com/v1.0/users/${userPrincipalName}?$select=id,displayName,givenName,surname,mail,department,jobTitle`, { headers: { Authorization: `Bearer ${accessToken}` } }); const userInfo = userGraph.data; const checkMemberResponse = await axios.post(`https://graph.microsoft.com/v1.0/users/${userInfo.id}/checkMemberGroups`, { groupIds: [AZURE_CONFIG.groupId] }, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } }); const isInGroup = checkMemberResponse.data.value.includes(AZURE_CONFIG.groupId); if (!isInGroup) return res.json({ authorized: false, message: 'Utilisateur non autorisé' }); // ⭐ Insertion avec SocieteId par défaut (ajuster selon votre logique) const [result] = await pool.query( `INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, role, SocieteId) VALUES (?, ?, ?, ?, ?, ?, ?)`, [userInfo.id, userInfo.givenName, userInfo.surname, userInfo.mail, userInfo.department, 'Collaborateur', null] ); res.json({ authorized: true, role: 'Collaborateur', groups: ['Collaborateur'], localUserId: result.insertId, user: { id: result.insertId, entraUserId: userInfo.id, prenom: userInfo.givenName, nom: userInfo.surname, email: userInfo.mail, service: userInfo.department, role: 'Collaborateur', societeId: null, societeNom: null } }); } catch (error) { res.json({ authorized: false, message: 'Erreur serveur', error: error.message }); } }); app.get('/getDetailedLeaveCounters', async (req, res) => { try { const userIdParam = req.query.user_id; if (!userIdParam) { return res.json({ success: false, message: 'ID utilisateur manquant' }); } const conn = await pool.getConnection(); // ⭐ NOUVEAU : Récupérer le dernier arrêté const dernierArrete = await getDernierArrete(conn); const dateArrete = dernierArrete ? new Date(dernierArrete.DateArrete) : null; // Déterminer l'ID (UUID ou numérique) const isUUID = userIdParam.length > 10 && userIdParam.includes('-'); const userQuery = ` SELECT ca.id, ca.prenom, ca.nom, ca.email, ca.DateEntree, ca.role, ca.TypeContrat, s.Nom as service, ca.CampusId, ca.SocieteId, so.Nom as societe_nom FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE ${isUUID ? 'ca.entraUserId' : 'ca.id'} = ? AND (ca.Actif = 1 OR ca.Actif IS NULL) `; const [userInfo] = await conn.query(userQuery, [userIdParam]); if (userInfo.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } const user = userInfo[0]; const userId = user.id; const dateEntree = user.DateEntree; const typeContrat = user.TypeContrat || '37h'; const dateRefParam = req.query.dateRef; const today = dateRefParam ? parseDateYYYYMMDD(dateRefParam) : new Date(); const currentYear = today.getFullYear(); const previousYear = currentYear - 1; // ⭐ MODIFICATION CRITIQUE : Utiliser la date d'arrêté si elle est plus récente que la référence const dateCalcul = dateArrete && today <= dateArrete ? dateArrete : today; const ancienneteMs = today - new Date(dateEntree || today); const ancienneteMois = Math.floor(ancienneteMs / (1000 * 60 * 60 * 24 * 30.44)); // ⭐ Calculer avec la bonne date const cpMonthsCurrent = getMoisTravaillesCP(dateCalcul, dateEntree); let counters = { user: { id: user.id, nom: `${user.prenom} ${user.nom}`, prenom: user.prenom, nomFamille: user.nom, email: user.email, service: user.service || 'Non défini', role: user.role, typeContrat: typeContrat, societeId: userInfo.SocieteId, societeNom: userInfo.societe_nom || 'Non défini', dateEntree: dateEntree ? formatDateWithoutUTC(dateEntree) : null, ancienneteMois: ancienneteMois, ancienneteAnnees: Math.floor(ancienneteMois / 12), ancienneteMoisRestants: ancienneteMois % 12 }, dateReference: today.toISOString().split('T')[0], exerciceCP: getExerciceCP(dateCalcul), anneeRTT: currentYear, // ⭐ NOUVEAU : Indiquer si on est en période d'arrêté arreteInfo: dernierArrete ? { dateArrete: formatDateWithoutUTC(dateArrete), libelle: dernierArrete.Libelle, bloquage: today <= dateArrete } : null, cpN1: null, cpN: null, rttN: null, rttN1: null, totalDisponible: { cp: 0, rtt: 0, total: 0 } }; const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); // ===== CP N-1 (Reporté) ===== if (cpType.length > 0) { const [cpN1] = await conn.query(` SELECT Annee, SoldeReporte, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, cpType[0].Id, previousYear]); if (cpN1.length > 0 && parseFloat(cpN1[0].SoldeReporte || 0) > 0) { const soldeReporte = parseFloat(cpN1[0].SoldeReporte || 0); const soldeActuel = parseFloat(cpN1[0].Solde || 0); const pris = Math.max(0, soldeReporte - soldeActuel); counters.cpN1 = { annee: previousYear, exercice: `${previousYear - 1}-${previousYear}`, reporte: parseFloat(soldeReporte.toFixed(2)), pris: parseFloat(pris.toFixed(2)), solde: parseFloat(soldeActuel.toFixed(2)), pourcentageUtilise: soldeReporte > 0 ? parseFloat(((pris / soldeReporte) * 100).toFixed(1)) : 0 }; counters.totalDisponible.cp += counters.cpN1.solde; } else { counters.cpN1 = { annee: previousYear, exercice: `${previousYear - 1}-${previousYear}`, reporte: 0, pris: 0, solde: 0, pourcentageUtilise: 0 }; } // ===== CP N (Exercice en cours) ===== const [cpN] = await conn.query(` SELECT Annee, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, cpType[0].Id, currentYear]); // ⭐ CORRECTION : Gérer le retour de calculerAcquisitionDepuisArrete let acquisCumuleeCP; if (typeof calculerAcquisitionDepuisArrete === 'function' && dernierArrete) { const result = await calculerAcquisitionDepuisArrete(conn, userId, 'CP', dateCalcul); acquisCumuleeCP = typeof result === 'number' ? result : parseFloat(result) || 0; } else { acquisCumuleeCP = calculerAcquisitionCP(dateCalcul, dateEntree) || 0; } // ⭐ SÉCURITÉ : S'assurer que c'est un nombre acquisCumuleeCP = parseFloat(acquisCumuleeCP) || 0; if (cpN.length > 0) { const total = parseFloat(cpN[0].Total || 0); const solde = parseFloat(cpN[0].Solde || 0); const soldeReporte = parseFloat(cpN[0].SoldeReporte || 0); const soldeN = solde - soldeReporte; const pris = Math.max(0, total - soldeN); counters.cpN = { annee: currentYear, exercice: getExerciceCP(dateCalcul), totalAnnuel: 25.00, moisTravailles: parseFloat(cpMonthsCurrent.toFixed(2)), acquisitionMensuelle: parseFloat((25 / 12).toFixed(2)), // ⭐ MODIFICATION : Afficher l'acquisition à la date de calcul acquis: parseFloat(acquisCumuleeCP.toFixed(2)), pris: parseFloat(pris.toFixed(2)), solde: parseFloat(soldeN.toFixed(2)), tauxAcquisition: parseFloat(((cpMonthsCurrent / 12) * 100).toFixed(1)), pourcentageUtilise: total > 0 ? parseFloat(((pris / total) * 100).toFixed(1)) : 0, joursRestantsAAcquerir: parseFloat((25 - acquisCumuleeCP).toFixed(2)) }; counters.totalDisponible.cp += counters.cpN.solde; } else { counters.cpN = { annee: currentYear, exercice: getExerciceCP(dateCalcul), totalAnnuel: 25.00, moisTravailles: parseFloat(cpMonthsCurrent.toFixed(2)), acquisitionMensuelle: parseFloat((25 / 12).toFixed(2)), acquis: parseFloat(acquisCumuleeCP.toFixed(2)), pris: 0, solde: parseFloat(acquisCumuleeCP.toFixed(2)), tauxAcquisition: parseFloat(((cpMonthsCurrent / 12) * 100).toFixed(1)), pourcentageUtilise: 0, joursRestantsAAcquerir: parseFloat((25 - acquisCumuleeCP).toFixed(2)) }; counters.totalDisponible.cp += counters.cpN.solde; } } // ===== RTT N (même logique) ===== const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); const isApprenti = userInfo[0].role === 'Apprenti'; if (rttType.length > 0 && !isApprenti) { // ⭐ Condition modifiée const rttConfig = await getConfigurationRTT(conn, currentYear, typeContrat) // ⭐ CORRECTION : Gérer le retour de calculerAcquisitionDepuisArrete pour RTT let rttData; if (typeof calculerAcquisitionDepuisArrete === 'function' && dernierArrete) { const result = await calculerAcquisitionDepuisArrete(conn, userId, 'RTT', dateCalcul); const acquisRTT = typeof result === 'number' ? result : parseFloat(result) || 0; rttData = { acquisition: acquisRTT, typeContrat: typeContrat, moisTravailles: getMoisTravaillesRTT(dateCalcul, dateEntree), config: rttConfig }; } else { rttData = await calculerAcquisitionRTT(conn, userId, dateCalcul); } // ⭐ SÉCURITÉ : S'assurer que acquisition est un nombre rttData.acquisition = parseFloat(rttData.acquisition) || 0; const [rttN] = await conn.query(` SELECT Annee, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, rttType[0].Id, currentYear]); if (rttN.length > 0) { const total = parseFloat(rttN[0].Total || 0); const solde = parseFloat(rttN[0].Solde || 0); const pris = Math.max(0, total - solde); counters.rttN = { annee: currentYear, typeContrat: typeContrat, totalAnnuel: parseFloat(rttConfig.joursAnnuels.toFixed(2)), moisTravailles: rttData.moisTravailles, acquisitionMensuelle: parseFloat(rttConfig.acquisitionMensuelle.toFixed(6)), acquis: parseFloat(rttData.acquisition.toFixed(2)), pris: parseFloat(pris.toFixed(2)), solde: parseFloat(solde.toFixed(2)), tauxAcquisition: parseFloat(((rttData.moisTravailles / 12) * 100).toFixed(1)), pourcentageUtilise: total > 0 ? parseFloat(((pris / total) * 100).toFixed(1)) : 0, joursRestantsAAcquerir: parseFloat((rttConfig.joursAnnuels - rttData.acquisition).toFixed(2)) }; counters.totalDisponible.rtt += counters.rttN.solde; } else { counters.rttN = { annee: currentYear, typeContrat: typeContrat, totalAnnuel: parseFloat(rttConfig.joursAnnuels.toFixed(2)), moisTravailles: rttData.moisTravailles, acquisitionMensuelle: parseFloat(rttConfig.acquisitionMensuelle.toFixed(6)), acquis: parseFloat(rttData.acquisition.toFixed(2)), pris: 0, solde: parseFloat(rttData.acquisition.toFixed(2)), tauxAcquisition: parseFloat(((rttData.moisTravailles / 12) * 100).toFixed(1)), pourcentageUtilise: 0, joursRestantsAAcquerir: parseFloat((rttConfig.joursAnnuels - rttData.acquisition).toFixed(2)) }; counters.totalDisponible.rtt += counters.rttN.solde; } counters.rttN1 = { annee: previousYear, reporte: 0, pris: 0, solde: 0, pourcentageUtilise: 0, message: "Les RTT ne sont pas reportables d'une année sur l'autre" }; } counters.totalDisponible.total = counters.totalDisponible.cp + counters.totalDisponible.rtt; // ===== RÉCUP (NOUVEAU) ===== const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']); if (recupType.length > 0) { const [recupN] = await conn.query(` SELECT Annee, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, recupType[0].Id, currentYear]); if (recupN.length > 0) { const total = parseFloat(recupN[0].Total || 0); const solde = parseFloat(recupN[0].Solde || 0); counters.recupN = { annee: currentYear, acquis: parseFloat(total.toFixed(2)), pris: parseFloat((total - solde).toFixed(2)), solde: parseFloat(solde.toFixed(2)), message: "Jours de récupération accumulés (samedis travaillés)" }; counters.totalDisponible.recup = counters.recupN.solde; counters.totalDisponible.total += counters.recupN.solde; } else { counters.recupN = { annee: currentYear, acquis: 0, pris: 0, solde: 0, message: "Jours de récupération accumulés (samedis travaillés)" }; counters.totalDisponible.recup = 0; } } conn.release(); res.json({ success: true, message: 'Compteurs détaillés récupérés avec succès', data: counters }); } catch (error) { console.error('Erreur getDetailedLeaveCounters:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.post('/reinitializeAllCounters', async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); console.log('🔄 Réinitialisation de tous les compteurs...'); const [collaborateurs] = await conn.query(` SELECT id, prenom, nom, DateEntree, TypeContrat, CampusId, SocieteId FROM CollaborateurAD WHERE (actif = 1 OR actif IS NULL) `); console.log(`📊 ${collaborateurs.length} collaborateurs trouvés`); const dateRefParam = req.body.dateReference; const today = dateRefParam ? new Date(dateRefParam) : new Date(); const currentYear = today.getFullYear(); const previousYear = currentYear - 1; const results = []; for (const collab of collaborateurs) { const dateEntree = collab.DateEntree; const typeContrat = collab.TypeContrat || '37h'; // Calculer les acquisitions const acquisCP = calculerAcquisitionCP(today, dateEntree); const isApprenti = collab.role === 'Apprenti'; const rttData = isApprenti ? { acquisition: 0 } : await calculerAcquisitionRTT(conn, collab.id, today); const acquisRTT = rttData.acquisition; const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); // ===== CP N ===== if (cpType.length > 0) { // ⭐ CALCUL FIABLE : Utiliser DeductionDetails au lieu des anciennes valeurs const [deductionsCP] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dc.Statut != 'Refusée' `, [collab.id, cpType[0].Id, currentYear]); const totalConsomme = parseFloat(deductionsCP[0].totalConsomme || 0); // Récupérer le reporté (qui ne change pas) const [compteurExisting] = await conn.query(` SELECT SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collab.id, cpType[0].Id, currentYear]); const soldeReporte = compteurExisting.length > 0 ? parseFloat(compteurExisting[0].SoldeReporte || 0) : 0; // ⭐ CALCUL EXACT DU SOLDE const nouveauSolde = Math.max(0, acquisCP + soldeReporte - totalConsomme); // Mise à jour ou insertion if (compteurExisting.length > 0) { await conn.query(` UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [acquisCP, nouveauSolde, collab.id, cpType[0].Id, currentYear]); } else { await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, NOW()) `, [collab.id, cpType[0].Id, currentYear, acquisCP, nouveauSolde]); } console.log(`📊 ${collab.prenom} ${collab.nom} - CP: Acquis ${acquisCP.toFixed(2)}j, Consommé ${totalConsomme.toFixed(2)}j, Reporté ${soldeReporte.toFixed(2)}j → Solde ${nouveauSolde.toFixed(2)}j`); // Créer CP N-1 si nécessaire const [cpN1] = await conn.query(` SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collab.id, cpType[0].Id, previousYear]); if (cpN1.length === 0) { await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, 0, 0, 0, NOW()) `, [collab.id, cpType[0].Id, previousYear]); } } // ===== RTT N ===== if (rttType.length > 0 && !isApprenti) { // ⭐ MÊME LOGIQUE POUR LES RTT const [deductionsRTT] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalConsomme FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dc.Statut != 'Refusée' `, [collab.id, rttType[0].Id, currentYear]); const totalConsomme = parseFloat(deductionsRTT[0].totalConsomme || 0); const nouveauSolde = Math.max(0, acquisRTT - totalConsomme); const [compteurExisting] = await conn.query(` SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collab.id, rttType[0].Id, currentYear]); if (compteurExisting.length > 0) { await conn.query(` UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ? `, [acquisRTT, nouveauSolde, compteurExisting[0].Id]); } else { await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, NOW()) `, [collab.id, rttType[0].Id, currentYear, acquisRTT, nouveauSolde]); } console.log(`📊 ${collab.prenom} ${collab.nom} - RTT: Acquis ${acquisRTT.toFixed(2)}j, Consommé ${totalConsomme.toFixed(2)}j → Solde ${nouveauSolde.toFixed(2)}j`); // Créer RTT N-1 si nécessaire const [rttN1] = await conn.query(` SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collab.id, rttType[0].Id, previousYear]); if (rttN1.length === 0) { await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, 0, 0, 0, NOW()) `, [collab.id, rttType[0].Id, previousYear]); } } results.push({ collaborateur: `${collab.prenom} ${collab.nom}`, type_contrat: typeContrat, cp_acquis: acquisCP.toFixed(2), cp_solde: (acquisCP - (deductionsCP?.[0]?.totalConsomme || 0)).toFixed(2), rtt_acquis: acquisRTT.toFixed(2), rtt_solde: (acquisRTT - (deductionsRTT?.[0]?.totalConsomme || 0)).toFixed(2) }); } await conn.commit(); console.log('✅ Réinitialisation terminée'); res.json({ success: true, message: `✅ Compteurs réinitialisés pour ${collaborateurs.length} collaborateurs`, date_reference: today.toISOString().split('T')[0], total_collaborateurs: collaborateurs.length, results: results }); } catch (error) { await conn.rollback(); console.error('❌ Erreur réinitialisation:', error); res.status(500).json({ success: false, message: 'Erreur lors de la réinitialisation', error: error.message }); } finally { conn.release(); } }); app.post('/updateCounters', async (req, res) => { const conn = await pool.getConnection(); try { const { collaborateur_id } = req.body; if (!collaborateur_id) return res.json({ success: false, message: 'ID collaborateur manquant' }); await conn.beginTransaction(); const updates = await updateMonthlyCounters(conn, collaborateur_id, new Date()); await conn.commit(); res.json({ success: true, message: 'Compteurs mis à jour', updates }); } catch (error) { await conn.rollback(); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } finally { conn.release(); } }); app.post('/updateAllCounters', async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD WHERE actif = 1 OR actif IS NULL'); const allUpdates = []; for (const collab of collaborateurs) { const updates = await updateMonthlyCounters(conn, collab.id, new Date()); allUpdates.push({ collaborateur_id: collab.id, updates }); } await conn.commit(); res.json({ success: true, message: `Compteurs mis à jour pour ${collaborateurs.length} collaborateurs`, total_collaborateurs: collaborateurs.length, details: allUpdates }); } catch (error) { await conn.rollback(); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } finally { conn.release(); } }); async function deductLeaveBalanceWithTracking(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId) { const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; let joursRestants = nombreJours; const deductions = []; // Étape 1: Déduire du reporté N-1 d'abord const [compteurN1] = await conn.query( `SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, previousYear] ); if (compteurN1.length > 0 && compteurN1[0].SoldeReporte > 0) { const soldeN1 = parseFloat(compteurN1[0].SoldeReporte); const aDeduireN1 = Math.min(soldeN1, joursRestants); if (aDeduireN1 > 0) { // Déduction dans la base await conn.query( `UPDATE CompteurConges SET SoldeReporte = GREATEST(0, SoldeReporte - ?), Solde = GREATEST(0, Solde - ?) WHERE Id = ?`, [aDeduireN1, aDeduireN1, compteurN1[0].Id] ); // Sauvegarde du détail de la déduction await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Accum Récup', ?) `, [demandeId, recupType[0].Id, currentYear, recupJours]); deductions.push({ annee: previousYear, type: 'Reporté N-1', joursUtilises: aDeduireN1, soldeAvant: soldeN1 }); joursRestants -= aDeduireN1; } } // Étape 2: Déduire de l'année N si besoin if (joursRestants > 0) { const [compteurN] = await conn.query( `SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, typeCongeId, currentYear] ); if (compteurN.length > 0) { const soldeN = parseFloat(compteurN[0].Solde) - parseFloat(compteurN[0].SoldeReporte || 0); const aDeduireN = Math.min(soldeN, joursRestants); if (aDeduireN > 0) { // Déduction dans la base await conn.query( `UPDATE CompteurConges SET Solde = GREATEST(0, Solde - ?) WHERE Id = ?`, [aDeduireN, compteurN[0].Id] ); // Sauvegarde du détail de la déduction await conn.query( `INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Année N', ?)`, [demandeCongeId, typeCongeId, currentYear, aDeduireN] ); deductions.push({ annee: currentYear, type: 'Année actuelle N', joursUtilises: aDeduireN, soldeAvant: soldeN }); joursRestants -= aDeduireN; } } } return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions }; }; async function restoreLeaveBalance(conn, demandeCongeId, collaborateurId) { try { console.log(`\n🔄 === RESTAURATION COMPTEURS ===`); console.log(`Demande ID: ${demandeCongeId}`); console.log(`Collaborateur ID: ${collaborateurId}`); // Récupérer tous les détails de déduction pour cette demande const [deductions] = await conn.query( `SELECT dd.TypeCongeId, dd.Annee, dd.TypeDeduction, dd.JoursUtilises, tc.Nom as TypeNom FROM DeductionDetails dd JOIN TypeConge tc ON dd.TypeCongeId = tc.Id WHERE dd.DemandeCongeId = ? ORDER BY dd.Id DESC`, [demandeCongeId] ); console.log(`📊 ${deductions.length} déductions trouvées`); if (deductions.length === 0) { console.log('⚠️ Aucune déduction trouvée pour cette demande'); return { success: false, message: 'Aucune déduction à restaurer' }; } const restorations = []; for (const deduction of deductions) { const { TypeCongeId, Annee, TypeDeduction, JoursUtilises, TypeNom } = deduction; console.log(`\n🔍 Traitement: ${TypeNom} - ${TypeDeduction} - ${JoursUtilises}j`); // 🔹 CAS SPÉCIAL : Récupération accumulée (RETIRER les jours) if (TypeDeduction === 'Accum Récup') { console.log(`❌ Annulation accumulation ${TypeNom}: -${JoursUtilises}j`); const [compteur] = await conn.query( `SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { await conn.query( `UPDATE CompteurConges SET Total = GREATEST(0, Total - ?), Solde = GREATEST(0, Solde - ?), DerniereMiseAJour = NOW() WHERE Id = ?`, [JoursUtilises, JoursUtilises, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRetires: JoursUtilises }); console.log(`✅ Récup retirée: ${compteur[0].Solde} → ${Math.max(0, compteur[0].Solde - JoursUtilises)}`); } continue; } // 🔹 Reporté N-1 if (TypeDeduction === 'Reporté N-1' || TypeDeduction === 'Report N-1') { const [compteur] = await conn.query( `SELECT Id, SoldeReporte, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { const ancienSolde = parseFloat(compteur[0].Solde || 0); const nouveauSolde = ancienSolde + parseFloat(JoursUtilises); await conn.query( `UPDATE CompteurConges SET SoldeReporte = SoldeReporte + ?, Solde = Solde + ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [JoursUtilises, JoursUtilises, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ Reporté restauré: ${ancienSolde} → ${nouveauSolde}`); } } // 🔹 Année N else if (TypeDeduction === 'Année N') { const [compteur] = await conn.query( `SELECT Id, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { const ancienSolde = parseFloat(compteur[0].Solde || 0); const nouveauSolde = ancienSolde + parseFloat(JoursUtilises); await conn.query( `UPDATE CompteurConges SET Solde = Solde + ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [JoursUtilises, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ Année N restaurée: ${ancienSolde} → ${nouveauSolde}`); } } } console.log(`\n✅ Restauration terminée: ${restorations.length} opérations\n`); return { success: true, restorations, message: `${restorations.length} restaurations effectuées` }; } catch (error) { console.error('❌ Erreur lors de la restauration des soldes:', error); throw error; } } app.get('/testProrata', async (req, res) => { try { const userId = parseInt(req.query.user_id || 0); if (userId <= 0) return res.json({ success: false, message: 'ID utilisateur requis' }); const conn = await pool.getConnection(); const [userInfo] = await conn.query(`SELECT id, prenom, nom, DateEntree, TypeContrat, CampusId FROM CollaborateurAD WHERE id = ?`, [userId]); if (userInfo.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } const user = userInfo[0]; const dateEntree = user.DateEntree; const typeContrat = user.TypeContrat || '37h'; const today = new Date(); const moisCP = getMoisTravaillesCP(today, dateEntree); const acquisCP = calculerAcquisitionCP(today, dateEntree); const rttData = await calculerAcquisitionRTT(conn, userId, today); conn.release(); res.json({ success: true, user: { id: user.id, nom: `${user.prenom} ${user.nom}`, dateEntree: dateEntree ? dateEntree.toISOString().split('T')[0] : null, typeContrat: typeContrat }, dateReference: today.toISOString().split('T')[0], calculs: { CP: { moisTravailles: parseFloat(moisCP.toFixed(2)), acquisitionMensuelle: 25 / 12, acquisitionCumulee: acquisCP, formule: `${moisCP.toFixed(2)} mois × ${(25 / 12).toFixed(2)}j/mois = ${acquisCP}j` }, RTT: { moisTravailles: rttData.moisTravailles, acquisitionMensuelle: rttData.config.acquisitionMensuelle, acquisitionCumulee: rttData.acquisition, totalAnnuel: rttData.config.joursAnnuels, formule: `${rttData.moisTravailles} mois × ${rttData.config.acquisitionMensuelle.toFixed(6)}j/mois = ${rttData.acquisition}j` } } }); } catch (error) { console.error('Erreur testProrata:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.post('/fixAllCounters', async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const today = new Date(); const currentYear = today.getFullYear(); const [collaborateurs] = await conn.query('SELECT id, prenom, nom, DateEntree, CampusId FROM CollaborateurAD WHERE (actif = 1 OR actif IS NULL)'); console.log(`🔄 Correction de ${collaborateurs.length} compteurs...`); const corrections = []; for (const collab of collaborateurs) { const dateEntree = collab.DateEntree; const moisCP = getMoisTravaillesCP(today, dateEntree); const acquisCP = calculerAcquisitionCP(today, dateEntree); const rttData = await calculerAcquisitionRTT(conn, collab.id, today); const acquisRTT = rttData.acquisition; const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); if (cpType.length > 0) { const [existingCP] = await conn.query(`SELECT Id, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collab.id, cpType[0].Id, currentYear]); if (existingCP.length > 0) { const ancienTotal = parseFloat(existingCP[0].Total); const ancienSolde = parseFloat(existingCP[0].Solde); const difference = acquisCP - ancienTotal; const nouveauSolde = Math.max(0, ancienSolde + difference); await conn.query(`UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [acquisCP, nouveauSolde, existingCP[0].Id]); corrections.push({ collaborateur: `${collab.prenom} ${collab.nom}`, type: 'CP', ancienTotal: ancienTotal.toFixed(2), nouveauTotal: acquisCP.toFixed(2), ancienSolde: ancienSolde.toFixed(2), nouveauSolde: nouveauSolde.toFixed(2) }); } } if (rttType.length > 0) { const [existingRTT] = await conn.query(`SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collab.id, rttType[0].Id, currentYear]); if (existingRTT.length > 0) { const ancienTotal = parseFloat(existingRTT[0].Total); const ancienSolde = parseFloat(existingRTT[0].Solde); const difference = acquisRTT - ancienTotal; const nouveauSolde = Math.max(0, ancienSolde + difference); await conn.query(`UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [acquisRTT, nouveauSolde, existingRTT[0].Id]); corrections.push({ collaborateur: `${collab.prenom} ${collab.nom}`, type: 'RTT', ancienTotal: ancienTotal.toFixed(2), nouveauTotal: acquisRTT.toFixed(2), ancienSolde: ancienSolde.toFixed(2), nouveauSolde: nouveauSolde.toFixed(2) }); } } } await conn.commit(); res.json({ success: true, message: `✅ ${collaborateurs.length} compteurs corrigés`, corrections: corrections }); } catch (error) { await conn.rollback(); console.error('❌ Erreur correction compteurs:', error); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } finally { conn.release(); } }); app.post('/processEndOfYear', async (req, res) => { const conn = await pool.getConnection(); try { const { collaborateur_id } = req.body; await conn.beginTransaction(); let result; if (collaborateur_id) { result = await processEndOfYearRTT(conn, collaborateur_id); } else { const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD'); const results = []; for (const c of collaborateurs) { const r = await processEndOfYearRTT(conn, c.id); if (r) results.push({ collaborateur_id: c.id, ...r }); } result = results; } await conn.commit(); res.json({ success: true, message: 'Traitement de fin d\'année effectué', result }); } catch (error) { await conn.rollback(); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } finally { conn.release(); } }); app.post('/processEndOfExercice', async (req, res) => { const conn = await pool.getConnection(); try { const { collaborateur_id } = req.body; await conn.beginTransaction(); let result; if (collaborateur_id) { result = await processEndOfExerciceCP(conn, collaborateur_id); } else { const [collaborateurs] = await conn.query('SELECT id, CampusId FROM CollaborateurAD'); const results = []; for (const c of collaborateurs) { const r = await processEndOfExerciceCP(conn, c.id); if (r) results.push({ collaborateur_id: c.id, ...r }); } result = results; } await conn.commit(); res.json({ success: true, message: 'Traitement de fin d\'exercice CP effectué', result }); } catch (error) { await conn.rollback(); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } finally { conn.release(); } }); app.get('/getAcquisitionDetails', async (req, res) => { try { const today = new Date(); const details = { date_reference: today.toISOString().split('T')[0], CP: { exercice: getExerciceCP(today), mois_travailles: getMoisTravaillesCP(today), acquisition_mensuelle: LEAVE_RULES.CP.acquisitionMensuelle, acquisition_cumulee: calculerAcquisitionCumulee('CP', today), total_annuel: LEAVE_RULES.CP.joursAnnuels, periode: '01/06 - 31/05', reportable: LEAVE_RULES.CP.reportable }, RTT: { annee: today.getFullYear(), mois_travailles: getMoisTravaillesRTT(today), acquisition_mensuelle: LEAVE_RULES.RTT.acquisitionMensuelle, acquisition_cumulee: calculerAcquisitionCumulee('RTT', today), total_annuel: LEAVE_RULES.RTT.joursAnnuels, periode: '01/01 - 31/12', reportable: LEAVE_RULES.RTT.reportable } }; res.json({ success: true, details }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.get('/getLeaveCounters', async (req, res) => { try { const userId = parseInt(req.query.user_id || 0); const data = {}; if (userId > 0) { const [rows] = await pool.query(`SELECT tc.Nom, cc.Annee, cc.Solde, cc.Total, cc.SoldeReporte FROM CompteurConges cc JOIN TypeConge tc ON cc.TypeCongeId = tc.Id WHERE cc.CollaborateurADId = ?`, [userId]); rows.forEach(row => { data[row.Nom] = { Annee: row.Annee, Solde: parseFloat(row.Solde), Total: parseFloat(row.Total), SoldeReporte: parseFloat(row.SoldeReporte) }; }); } res.json({ success: true, message: 'Compteurs récupérés', counters: data }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.get('/getEmploye', async (req, res) => { try { const id = parseInt(req.query.id || 0); if (id <= 0) return res.json({ success: false, message: 'ID invalide' }); const conn = await pool.getConnection(); // 1️⃣ Récupérer les infos du collaborateur const [rows] = await conn.query(` SELECT ca.id, ca.Nom, ca.Prenom, ca.Email, ca.role, ca.TypeContrat, ca.DateEntree, ca.CampusId, ca.SocieteId, s.Nom as service, so.Nom as societe_nom FROM CollaborateurAD ca LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Societe so ON ca.SocieteId = so.Id WHERE ca.id = ? `, [id]); if (rows.length === 0) { conn.release(); return res.json({ success: false, message: 'Collaborateur non trouvé' }); } const employee = rows[0]; // 2️⃣ Déterminer si c'est un apprenti const isApprenti = normalizeRole(employee.role) === 'apprenti'; // 3️⃣ Récupérer les compteurs CP const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); let cpTotal = 0, cpSolde = 0; if (cpType.length > 0) { const currentYear = new Date().getFullYear(); const previousYear = currentYear - 1; // CP N-1 (reporté) const [cpN1] = await conn.query(` SELECT SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [id, cpType[0].Id, previousYear]); // CP N (année courante) const [cpN] = await conn.query(` SELECT Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [id, cpType[0].Id, currentYear]); const cpN1Solde = cpN1.length > 0 ? parseFloat(cpN1[0].SoldeReporte || 0) : 0; const cpNSolde = cpN.length > 0 ? parseFloat(cpN[0].Solde || 0) : 0; const cpNReporte = cpN.length > 0 ? parseFloat(cpN[0].SoldeReporte || 0) : 0; cpTotal = cpN1Solde + (cpNSolde - cpNReporte); cpSolde = cpTotal; } // 4️⃣ Récupérer les compteurs RTT (sauf pour apprentis) let rttTotal = 0, rttSolde = 0; if (!isApprenti) { const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); if (rttType.length > 0) { const currentYear = new Date().getFullYear(); const [rttN] = await conn.query(` SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [id, rttType[0].Id, currentYear]); rttTotal = rttN.length > 0 ? parseFloat(rttN[0].Solde || 0) : 0; rttSolde = rttTotal; } } conn.release(); // 5️⃣ Retourner les données complètes res.json({ success: true, employee: { id: employee.id, Nom: employee.Nom, Prenom: employee.Prenom, Email: employee.Email, role: employee.role, TypeContrat: employee.TypeContrat, DateEntree: employee.DateEntree, CampusId: employee.CampusId, SocieteId: employee.SocieteId, service: employee.service, societe_nom: employee.societe_nom, conges_restants: parseFloat(cpSolde.toFixed(2)), rtt_restants: parseFloat(rttSolde.toFixed(2)) } }); } catch (error) { console.error('❌ Erreur getEmploye:', error); res.status(500).json({ success: false, message: 'Erreur DB', error: error.message }); } }); app.get('/getEmployeRequest', async (req, res) => { try { const id = parseInt(req.query.id || 0); if (id <= 0) return res.json({ success: false, message: 'ID invalide' }); const [rows] = await pool.query(` SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.NombreJours as days, dc.Statut as status, dc.DateDemande, GROUP_CONCAT(DISTINCT tc.Nom ORDER BY tc.Nom SEPARATOR ', ') AS type, CONCAT( DATE_FORMAT(dc.DateDebut, '%d/%m/%Y'), IF(dc.DateDebut = dc.DateFin, '', CONCAT(' - ', DATE_FORMAT(dc.DateFin, '%d/%m/%Y'))) ) as date_display FROM DemandeConge dc LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = ? GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.NombreJours, dc.Statut, dc.DateDemande ORDER BY dc.DateDemande DESC `, [id]); res.json({ success: true, requests: rows }); } catch (error) { console.error('❌ Erreur getEmployeRequest:', error); res.status(500).json({ success: false, message: 'Erreur DB', error: error.message }); } }); app.get('/getRequests', async (req, res) => { try { const userId = req.query.user_id; if (!userId) return res.json({ success: false, message: 'ID utilisateur manquant' }); // ✅ REQUÊTE CORRIGÉE avec la table de liaison const [rows] = await pool.query(` SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CommentaireValidation, dc.Validateur, dc.DocumentJoint, GROUP_CONCAT(DISTINCT tc.Nom ORDER BY tc.Nom SEPARATOR ', ') AS TypeConges, SUM(dct.NombreJours) as NombreJoursTotal FROM DemandeConge dc LEFT JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId LEFT JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE (dc.EmployeeId = ? OR dc.CollaborateurADId = ?) GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CommentaireValidation, dc.Validateur, dc.DocumentJoint ORDER BY dc.DateDemande DESC `, [userId, userId]); const requests = rows.map(row => { const workingDays = getWorkingDays(row.DateDebut, row.DateFin); const dateDisplay = row.DateDebut === row.DateFin ? formatDate(row.DateDebut) : `${formatDate(row.DateDebut)} - ${formatDate(row.DateFin)}`; let fileUrl = null; if (row.TypeConges && row.TypeConges.includes('Congé maladie') && row.DocumentJoint) { fileUrl = `http://localhost:3000/uploads/${path.basename(row.DocumentJoint)}`; } return { id: row.Id, type: row.TypeConges || 'Non défini', // ✅ Gérer le cas null startDate: row.DateDebut, endDate: row.DateFin, dateDisplay, days: row.NombreJoursTotal || workingDays, // ✅ Utiliser le total de la table de liaison status: row.Statut, reason: row.Commentaire || 'Aucun commentaire', submittedAt: row.DateDemande, submittedDisplay: formatDate(row.DateDemande), validator: row.Validateur || null, validationComment: row.CommentaireValidation || null, fileUrl }; }); res.json({ success: true, message: 'Demandes récupérées', requests, total: requests.length }); } catch (error) { console.error('❌ Erreur getRequests:', error); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.get('/getAllTeamRequests', async (req, res) => { try { const managerId = req.query.SuperieurId; if (!managerId) return res.json({ success: false, message: 'Paramètre SuperieurId manquant' }); const [rows] = await pool.query(`SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.DocumentJoint, dc.CollaborateurADId AS employee_id, CONCAT(ca.Prenom, ' ', ca.Nom) as employee_name, ca.Email as employee_email, tc.Nom as type FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id JOIN TypeConge tc ON dc.TypeCongeId = tc.Id JOIN HierarchieValidationAD hv ON hv.CollaborateurId = ca.id WHERE hv.SuperieurId = ? ORDER BY dc.DateDemande DESC`, [managerId]); const requests = rows.map(row => ({ id: row.Id, employee_id: row.employee_id, employee_name: row.employee_name, employee_email: row.employee_email, type: row.type, start_date: row.DateDebut, end_date: row.DateFin, date_display: row.DateDebut === row.DateFin ? formatDate(row.DateDebut) : `${formatDate(row.DateDebut)} - ${formatDate(row.DateFin)}`, days: getWorkingDays(row.DateDebut, row.DateFin), status: row.Statut, reason: row.Commentaire || '', file: row.DocumentJoint || null, submitted_at: row.DateDemande, submitted_display: formatDate(row.DateDemande) })); res.json({ success: true, requests }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur DB', error: error.message }); } }); app.get('/getPendingRequests', async (req, res) => { try { const managerId = req.query.manager_id; if (!managerId) return res.json({ success: false, message: 'ID manager manquant' }); const [managerRows] = await pool.query('SELECT ServiceId, CampusId FROM CollaborateurAD WHERE id = ?', [managerId]); if (managerRows.length === 0) return res.json({ success: false, message: 'Manager non trouvé' }); const serviceId = managerRows[0].ServiceId; const [rows] = await pool.query(`SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CollaborateurADId, CONCAT(ca.prenom, ' ', ca.nom) as employee_name, ca.email as employee_email, GROUP_CONCAT(tc.Nom ORDER BY tc.Nom SEPARATOR ', ') as types FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.id JOIN TypeConge tc ON FIND_IN_SET(tc.Id, dc.TypeCongeId) WHERE ca.ServiceId = ? AND dc.Statut = 'En attente' AND ca.id != ? GROUP BY dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CollaborateurADId, ca.prenom, ca.nom, ca.email ORDER BY dc.DateDemande ASC`, [serviceId, managerId]); const requests = rows.map(row => ({ id: row.Id, employee_id: row.CollaborateurADId, employee_name: row.employee_name, employee_email: row.employee_email, type: row.types, start_date: row.DateDebut, end_date: row.DateFin, date_display: row.DateDebut === row.DateFin ? formatDate(row.DateDebut) : `${formatDate(row.DateDebut)} - ${formatDate(row.DateFin)}`, days: getWorkingDays(row.DateDebut, row.DateFin), status: row.Statut, reason: row.Commentaire || '', submitted_at: row.DateDemande, submitted_display: formatDate(row.DateDemande) })); res.json({ success: true, message: 'Demandes récupérées', requests, service_id: serviceId }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.get('/getTeamMembers', async (req, res) => { try { const managerId = req.query.manager_id; if (!managerId) return res.json({ success: false, message: 'ID manager manquant' }); const [managerRows] = await pool.query('SELECT ServiceId, CampusId FROM CollaborateurAD WHERE id = ?', [managerId]); if (managerRows.length === 0) return res.json({ success: false, message: 'Manager non trouvé' }); const serviceId = managerRows[0].ServiceId; const [members] = await pool.query(`SELECT c.id, c.nom, c.prenom, c.email, c.role, s.Nom as service_name, c.CampusId FROM CollaborateurAD c JOIN Services s ON c.ServiceId = s.Id WHERE c.ServiceId = ? AND c.id != ? ORDER BY c.prenom, c.nom`, [serviceId, managerId]); res.json({ success: true, message: 'Équipe récupérée', team_members: members, service_id: serviceId }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.get('/getNotifications', async (req, res) => { try { const userIdParam = req.query.user_id; if (!userIdParam) { return res.json({ success: false, message: 'ID utilisateur manquant' }); } const conn = await pool.getConnection(); // ✅ Déterminer si c'est un UUID ou un ID numérique const isUUID = userIdParam.length > 10 && userIdParam.includes('-'); // ✅ Récupérer l'ID numérique si on a un UUID let userId = userIdParam; if (isUUID) { const [userRows] = await conn.query( 'SELECT id, CampusId FROM CollaborateurAD WHERE entraUserId = ? AND (Actif = 1 OR Actif IS NULL)', [userIdParam] ); if (userRows.length === 0) { conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé ou compte désactivé' }); } userId = userRows[0].id; } else { userId = parseInt(userIdParam); } // ✅ Utiliser l'ID numérique pour la requête const [notifications] = await conn.query(` SELECT * FROM Notifications WHERE CollaborateurADId = ? ORDER BY DateCreation DESC LIMIT 50 `, [userId]); conn.release(); res.json({ success: true, notifications: notifications || [] }); } catch (error) { console.error('Erreur getNotifications:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.post('/markNotificationRead', async (req, res) => { try { const { notificationId } = req.body; if (!notificationId || notificationId <= 0) return res.status(400).json({ success: false, message: 'ID notification invalide' }); await pool.query('UPDATE Notifications SET lu = 1 WHERE Id = ?', [notificationId]); res.json({ success: true, message: 'Notification marquée comme lue' }); } catch (error) { res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); app.post('/submitLeaveRequest', uploadMedical.array('medicalDocuments', 5), async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const currentYear = new Date().getFullYear(); // ✅ Récupérer les fichiers uploadés const uploadedFiles = req.files || []; console.log('📎 Fichiers médicaux reçus:', uploadedFiles.length); // ✅ Les données arrivent différemment avec FormData const { DateDebut, DateFin, NombreJours, Email, Nom, Commentaire, statut } = req.body; // ✅ Parser la répartition (elle arrive en string depuis FormData) const Repartition = JSON.parse(req.body.Repartition || '[]'); if (!DateDebut || !DateFin || !Repartition || !Email || !Nom) { // ✅ Nettoyer les fichiers en cas d'erreur uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); return res.json({ success: false, message: 'Données manquantes' }); } // ✅ Validation : Si arrêt maladie, il faut au moins 1 fichier const hasABS = Repartition.some(r => r.TypeConge === 'ABS'); if (hasABS && uploadedFiles.length === 0) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Un justificatif médical est obligatoire pour un arrêt maladie' }); } // ⭐ NOUVEAU : Validation de la répartition console.log('\n📥 === SOUMISSION DEMANDE CONGÉ ==='); console.log('Email:', Email); console.log('Période:', DateDebut, '→', DateFin); console.log('Nombre de jours total:', NombreJours); console.log('Répartition reçue:', JSON.stringify(Repartition, null, 2)); console.log('Fichiers médicaux:', uploadedFiles.length); // ⭐ Calculer la somme de la répartition const sommeRepartition = Repartition.reduce((sum, r) => { // Ne compter que CP et RTT (pas ABS ni Formation ni Récup) if (r.TypeConge === 'CP' || r.TypeConge === 'RTT') { return sum + parseFloat(r.NombreJours || 0); } return sum; }, 0); console.log('Somme répartition CP+RTT:', sommeRepartition.toFixed(2)); // ⭐ VALIDATION : La somme doit correspondre au total (tolérance 0.01j) const hasCountableLeave = Repartition.some(r => r.TypeConge === 'CP' || r.TypeConge === 'RTT'); if (hasCountableLeave && Math.abs(sommeRepartition - NombreJours) > 0.01) { console.error('❌ ERREUR : Répartition incohérente !'); console.error(` Attendu: ${NombreJours}j`); console.error(` Reçu: ${sommeRepartition}j`); // ✅ Nettoyer les fichiers uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); await conn.rollback(); conn.release(); return res.json({ success: false, message: `Erreur de répartition : la somme (${sommeRepartition.toFixed(2)}j) ne correspond pas au total (${NombreJours}j)`, details: { repartition: Repartition, somme: sommeRepartition, attendu: NombreJours } }); } // ⭐ Vérifier qu'aucun type n'a 0 jour for (const rep of Repartition) { if ((rep.TypeConge === 'CP' || rep.TypeConge === 'RTT') && parseFloat(rep.NombreJours || 0) <= 0) { console.error(`❌ ERREUR : ${rep.TypeConge} a ${rep.NombreJours} jours !`); // ✅ Nettoyer les fichiers uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); await conn.rollback(); conn.release(); return res.json({ success: false, message: `Le type ${rep.TypeConge} doit avoir au moins 0.5 jour` }); } } console.log('✅ Validation répartition OK'); // ⭐ Détection si c'est uniquement une formation const isFormationOnly = Repartition.length === 1 && Repartition[0].TypeConge === 'Formation'; const statutDemande = statut || (isFormationOnly ? 'Validée' : 'En attente'); const dateDebut = new Date(DateDebut).toLocaleDateString('fr-FR'); const dateFin = new Date(DateFin).toLocaleDateString('fr-FR'); const datesPeriode = dateDebut === dateFin ? dateDebut : `du ${dateDebut} au ${dateFin}`; console.log('🔍 Type de demande:', { isFormationOnly, statut: statutDemande }); const [collabAD] = await conn.query('SELECT id, CampusId FROM CollaborateurAD WHERE email = ? LIMIT 1', [Email]); const isAD = collabAD.length > 0; const collaborateurId = isAD ? collabAD[0].id : null; const dateEntree = isAD && collabAD[0].DateEntree ? collabAD[0].DateEntree : null; let employeeId = null; if (!isAD) { const [user] = await conn.query('SELECT ID FROM Users WHERE Email = ? LIMIT 1', [Email]); if (user.length === 0) { // ✅ Nettoyer les fichiers uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } employeeId = user[0].ID; } // ======================================== // ÉTAPE 1 : Vérification des soldes AVANT tout // ======================================== if (isAD && collaborateurId && !isFormationOnly) { console.log('\n🔍 Vérification des soldes (avec anticipation)...'); const [userRole] = await conn.query('SELECT role FROM CollaborateurAD WHERE id = ?', [collaborateurId]); const isApprenti = userRole.length > 0 && userRole[0].role === 'Apprenti'; for (const rep of Repartition) { if (rep.TypeConge === 'ABS' || rep.TypeConge === 'Formation' || rep.TypeConge === 'Récup') { continue; } if (rep.TypeConge === 'RTT' && isApprenti) { uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); }); await conn.rollback(); conn.release(); return res.json({ success: false, message: `❌ Les apprentis ne peuvent pas poser de RTT` }); } const joursNecessaires = parseFloat(rep.NombreJours); const name = rep.TypeConge === 'CP' ? 'Congé payé' : 'RTT'; const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]); if (typeRow.length === 0) continue; const typeCongeId = typeRow[0].Id; // Récupérer le solde actuel const [compteur] = await conn.query(` SELECT Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, currentYear]); let soldeDisponible = 0; if (compteur.length > 0) { soldeDisponible = parseFloat(compteur[0].Solde || 0); } // Si le solde est insuffisant, calculer l'anticipation possible if (soldeDisponible < joursNecessaires) { const manque = joursNecessaires - soldeDisponible; // Calculer l'acquisition future let acquisitionFuture = 0; if (rep.TypeConge === 'CP') { const finExercice = new Date(currentYear + 1, 4, 31); const acquisTotale = calculerAcquisitionCP(finExercice, dateEntree); const acquisActuelle = compteur.length > 0 ? parseFloat(compteur[0].Total || 0) : 0; acquisitionFuture = acquisTotale - acquisActuelle; } else { const finAnnee = new Date(currentYear, 11, 31); const rttDataTotal = await calculerAcquisitionRTT(conn, collaborateurId, finAnnee); const acquisActuelle = compteur.length > 0 ? parseFloat(compteur[0].Total || 0) : 0; acquisitionFuture = rttDataTotal.acquisition - acquisActuelle; } // Vérifier si l'anticipation est possible if (manque > acquisitionFuture) { uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); }); await conn.rollback(); conn.release(); return res.json({ success: false, message: `❌ Solde insuffisant pour ${name}`, details: { type: name, demande: joursNecessaires, soldeActuel: soldeDisponible.toFixed(2), acquisitionFutureMax: acquisitionFuture.toFixed(2), manque: (manque - acquisitionFuture).toFixed(2) } }); } console.log(`⚠️ ${name}: Utilisation de ${manque.toFixed(2)}j en anticipé`); } } console.log('✅ Soldes suffisants (avec anticipation si nécessaire)'); } // ======================================== // ÉTAPE 2 : CRÉER LA DEMANDE EN PREMIER // ======================================== console.log('\n📝 Création de la demande...'); const typeIds = []; for (const rep of Repartition) { const code = rep.TypeConge; // Ne pas inclure ABS, Formation, Récup dans les typeIds principaux if (code === 'ABS' || code === 'Formation' || code === 'Récup') { continue; } const name = code === 'CP' ? 'Congé payé' : 'RTT'; const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]); if (typeRow.length > 0) typeIds.push(typeRow[0].Id); } // ⭐ Si aucun type CP/RTT, prendre le premier type de la répartition if (typeIds.length === 0) { const firstType = Repartition[0]?.TypeConge; const name = firstType === 'Formation' ? 'Formation' : firstType === 'ABS' ? 'Congé maladie' : firstType === 'Récup' ? 'Récupération' : 'Congé payé'; const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]); if (typeRow.length > 0) { typeIds.push(typeRow[0].Id); } else { uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); }); await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Aucun type de congé valide' }); } } const typeCongeIdCsv = typeIds.join(','); const currentDate = new Date().toISOString().slice(0, 19).replace('T', ' '); // ✅ CRÉER LA DEMANDE const [result] = await conn.query( `INSERT INTO DemandeConge (EmployeeId, CollaborateurADId, DateDebut, DateFin, TypeCongeId, Statut, DateDemande, Commentaire, Validateur, NombreJours) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [isAD ? 0 : employeeId, collaborateurId, DateDebut, DateFin, typeCongeIdCsv, statutDemande, currentDate, Commentaire || '', '', NombreJours] ); const demandeId = result.insertId; console.log(`✅ Demande créée avec ID ${demandeId} - Statut: ${statutDemande}`); // ======================================== // ÉTAPE 3 : Sauvegarder les fichiers médicaux // ======================================== if (uploadedFiles.length > 0) { console.log('\n📎 Sauvegarde des fichiers médicaux...'); for (const file of uploadedFiles) { await conn.query( `INSERT INTO DocumentsMedicaux (DemandeCongeId, NomFichier, CheminFichier, TypeMime, TailleFichier, DateUpload) VALUES (?, ?, ?, ?, ?, NOW())`, [demandeId, file.originalname, file.path, file.mimetype, file.size] ); console.log(` ✓ ${file.originalname} (${(file.size / 1024).toFixed(2)} KB)`); } } // ======================================== // ÉTAPE 4 : Sauvegarder la répartition // ======================================== console.log('\n📊 Sauvegarde de la répartition en base...'); for (const rep of Repartition) { const code = rep.TypeConge; const name = code === 'CP' ? 'Congé payé' : code === 'RTT' ? 'RTT' : code === 'ABS' ? 'Congé maladie' : code === 'Formation' ? 'Formation' : code === 'Récup' ? 'Récupération' : code; const [typeRow] = await conn.query( 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name] ); if (typeRow.length > 0) { await conn.query( `INSERT INTO DemandeCongeType (DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (?, ?, ?, ?)`, [ demandeId, typeRow[0].Id, rep.NombreJours, rep.PeriodeJournee || 'Journée entière' // ✅ NOUVELLE COLONNE ] ); console.log(` ✓ ${name}: ${rep.NombreJours}j (${rep.PeriodeJournee || 'Journée entière'})`); } } // ======================================== // ÉTAPE 5 : ACCUMULATION DES RÉCUP (maintenant demandeId existe) // ======================================== if (isAD && collaborateurId && !isFormationOnly) { const hasRecup = Repartition.some(r => r.TypeConge === 'Récup'); if (hasRecup) { console.log('\n📥 Accumulation des jours de récupération...'); const [recupType] = await conn.query( 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération'] ); if (recupType.length > 0) { const recupJours = Repartition.find(r => r.TypeConge === 'Récup')?.NombreJours || 0; if (recupJours > 0) { const [compteurExisting] = await conn.query(` SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, recupType[0].Id, currentYear]); if (compteurExisting.length > 0) { // ⭐ AJOUTER les jours au compteur existant await conn.query(` UPDATE CompteurConges SET Total = Total + ?, Solde = Solde + ?, DerniereMiseAJour = NOW() WHERE Id = ? `, [recupJours, recupJours, compteurExisting[0].Id]); console.log(` ✓ Récupération: +${recupJours}j ajoutés (nouveau solde: ${(parseFloat(compteurExisting[0].Solde) + recupJours).toFixed(2)}j)`); } else { // ⭐ CRÉER le compteur avec les jours await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, NOW()) `, [collaborateurId, recupType[0].Id, currentYear, recupJours, recupJours]); console.log(` ✓ Récupération: ${recupJours}j créés (nouveau compteur)`); } // ⭐ Enregistrer l'ACCUMULATION (maintenant demandeId existe !) await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Accum Récup', ?) `, [demandeId, recupType[0].Id, currentYear, recupJours]); console.log(` ✓ Accumulation enregistrée dans DeductionDetails`); } } } } // ======================================== // ÉTAPE 6 : Déduction des compteurs CP/RTT // ======================================== if (isAD && collaborateurId && !isFormationOnly) { console.log('\n📉 Déduction des compteurs...'); for (const rep of Repartition) { if (rep.TypeConge === 'ABS' || rep.TypeConge === 'Formation' || rep.TypeConge === 'Récup') { console.log(` ⏩ ${rep.TypeConge} ignoré (pas de déduction)`); continue; } const name = rep.TypeConge === 'CP' ? 'Congé payé' : 'RTT'; const [typeRow] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [name]); if (typeRow.length > 0) { const result = await deductLeaveBalanceWithTracking( conn, collaborateurId, typeRow[0].Id, rep.NombreJours, demandeId ); console.log(` ✓ ${name}: ${rep.NombreJours}j déduits`); if (result.details && result.details.length > 0) { result.details.forEach(d => { console.log(` - ${d.type} (${d.annee}): ${d.joursUtilises}j`); }); } } } console.log('✅ Déductions terminées'); } // ======================================== // ÉTAPE 7 : Créer notification pour formation // ======================================== // ÉTAPE 7 : Créer notification pour formation if (isFormationOnly && isAD && collaborateurId) { await conn.query( `INSERT INTO Notifications (CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu) VALUES (?, ?, ?, ?, ?, NOW(), 0)`, [ collaborateurId, 'Success', // ✅ Valeur correcte de l'enum '✅ Formation validée automatiquement', `Votre période de formation ${datesPeriode} a été validée automatiquement.`, demandeId ] ); console.log('\n📬 Notification formation créée'); } // ======================================== // ÉTAPE 8 : Récupérer les managers // ======================================== let managers = []; if (isAD) { const [rows] = await conn.query( `SELECT c.email FROM HierarchieValidationAD hv JOIN CollaborateurAD c ON hv.SuperieurId = c.id WHERE hv.CollaborateurId = ?`, [collaborateurId] ); managers = rows.map(r => r.email); } else { const [rows] = await conn.query( `SELECT u.Email FROM HierarchieValidation hv JOIN Users u ON hv.SuperieurId = u.ID WHERE hv.EmployeId = ?`, [employeeId] ); managers = rows.map(r => r.Email); } // ======================================== // COMMIT DE LA TRANSACTION // ======================================== await conn.commit(); console.log('\n🎉 Transaction validée\n'); // ======================================== // ÉTAPE 9 : Notifier les clients SSE // ======================================== if (isFormationOnly && isAD && collaborateurId) { notifyCollabClients({ type: 'demande-validated', demandeId: parseInt(demandeId), statut: 'Validée', timestamp: new Date().toISOString() }, collaborateurId); notifyCollabClients({ type: 'demande-list-updated', action: 'formation-auto-validated', demandeId: parseInt(demandeId), timestamp: new Date().toISOString() }); } // ======================================== // ENVOI DES EMAILS (code inchangé) // ======================================== const accessToken = await getGraphToken(); if (accessToken) { const fromEmail = 'noreply@ensup.eu'; const typesConges = Repartition.map(rep => { const typeNom = rep.TypeConge === 'CP' ? 'Congé payé' : rep.TypeConge === 'RTT' ? 'RTT' : rep.TypeConge === 'ABS' ? 'Congé maladie' : rep.TypeConge === 'Formation' ? 'Formation' : rep.TypeConge; return `${typeNom}: ${rep.NombreJours}j`; }).join(' | '); if (isFormationOnly) { const subjectCollab = '✅ Votre saisie de période de formation a été enregistrée'; const bodyCollab = `
Bonjour ${Nom},
Votre période de formation a bien été enregistrée et validée automatiquement.
Type : Formation
Période : ${datesPeriode}
Durée : ${NombreJours} jour(s)
${Commentaire ? `Description : ${Commentaire}
` : ''}${Nom} vous informe d'une période de formation.
Période : ${datesPeriode}
Durée : ${NombreJours} jour(s)
Bonjour ${Nom},
Votre demande de congé a bien été enregistrée.
Type : ${typesConges}
Période : ${datesPeriode}
Durée : ${NombreJours} jour(s)
${Nom} a soumis une nouvelle demande.
Type : ${typesConges}
Période : ${datesPeriode}
Bonjour ${collaborateurNom},
Votre demande de congé a été approuvée par ${validateurNom}.
Type : ${request.TypeConge}
Période : ${datesPeriode}
Durée : ${request.NombreJours} jour(s)
${comment ? `Commentaire : ${comment}
` : ''}Vous pouvez consulter votre demande dans votre espace personnel.
Bonjour ${collaborateurNom},
Votre demande de congé a été refusée par ${validateurNom}.
Type : ${request.TypeConge}
Période : ${datesPeriode}
Durée : ${request.NombreJours} jour(s)
${comment ? `Motif du refus : ${comment}
` : ''}Pour plus d'informations, contactez ${validateurNom}.
Bonjour ${managerName},
${userName} a modifié sa demande de congé.
| Type : | ${typesConges} |
| Du : | ${newStartDate} |
| Au : | ${newEndDate} |
| Jours : | ${businessDays} jour(s) |
| Motif : | ${reason} |
Cette demande est toujours en attente de validation.
Bonjour ${managerName},
${userName} a supprimé sa demande de congé.
| Type : | ${request.TypeConge || 'N/A'} |
| Période : | ${new Date(request.DateDebut).toLocaleDateString('fr-FR')} au ${new Date(request.DateFin).toLocaleDateString('fr-FR')} |
| Jours : | ${request.NombreJours} jour(s) |
| Statut initial : | ${requestStatus} |
Les compteurs de congés ont été restaurés si nécessaire.