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(); process.on('uncaughtException', (err) => { console.error('💥 ERREUR CRITIQUE NON CATCHÉE:', err); console.error('Stack:', err.stack); // On ne crash pas pour pouvoir déboguer }); process.on('unhandledRejection', (reason, promise) => { console.error('💥 PROMESSE REJETÉE NON GÉRÉE:', reason); console.error('Promise:', promise); }); app.use(cors({ origin: ['http://localhost:3013', 'http://localhost:80', 'https://mygta.ensup-adm.net'], credentials: true })); 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', port:'3306', 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}`); } }; const sseClients = new Set(); // 🔌 ROUTE SSE POUR LE CALENDRIER app.get('/api/sse', (req, res) => { const userId = req.query.user_id; if (!userId) { return res.status(400).json({ error: 'user_id requis' }); } console.log('🔌 Nouvelle connexion SSE:', userId); res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); const sendEvent = (data) => { try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch (error) { console.error('❌ Erreur envoi SSE:', error); } }; const client = { id: userId, send: sendEvent }; sseClients.add(client); console.log(`📊 Clients SSE connectés: ${sseClients.size}`); // Envoyer un heartbeat initial sendEvent({ type: 'ping', message: 'Connexion établie', timestamp: new Date().toISOString() }); // Heartbeat toutes les 30 secondes const heartbeat = setInterval(() => { try { sendEvent({ type: 'ping', timestamp: new Date().toISOString() }); } catch (error) { console.error('❌ Erreur heartbeat:', error); clearInterval(heartbeat); } }, 30000); req.on('close', () => { console.log('🔌 Déconnexion SSE:', userId); clearInterval(heartbeat); sseClients.delete(client); console.log(`📊 Clients SSE connectés: ${sseClients.size}`); }); }); // 📢 FONCTION POUR NOTIFIER LES CLIENTS const notifyClients = (event, userId = null) => { console.log(`📢 Notification SSE: ${event.type}${userId ? ` pour ${userId}` : ''}`); sseClients.forEach(client => { // Si userId est spécifié, envoyer seulement à ce client if (userId && client.id !== userId) { return; } try { client.send(event); } catch (error) { console.error('❌ Erreur envoi event:', error); } }); }; app.post('/api/webhook/receive', async (req, res) => { try { const signature = req.headers['x-webhook-signature']; const payload = req.body; console.log('\n📥 === WEBHOOK REÇU (COLLABORATEURS) ==='); console.log('Event:', payload.event); console.log('Data:', JSON.stringify(payload.data, null, 2)); // 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.COMPTEUR_UPDATED: console.log('📊 WEBHOOK COMPTEUR_UPDATED REÇ'); console.log('Collaborateur:', data.collaborateurId); console.log('Type mise à jour:', data.typeUpdate); console.log('Type congé:', data.typeConge); console.log('Année:', data.annee); console.log('Source:', data.source); // SI MODIFICATION RH OU RECALCUL, METTRE À JOUR LA BASE LOCALE if ((data.source === 'rh' || data.source === 'recalcul') && data.nouveauTotal !== undefined && data.nouveauSolde !== undefined) { console.log('🔄 Synchronisation depuis RH (source:', data.source + ')...'); console.log('Nouveau Total:', data.nouveauTotal + 'j'); console.log('Nouveau Solde:', data.nouveauSolde + 'j'); const conn = await pool.getConnection(); try { // Identifier le type de congé const typeName = data.typeConge === 'Congé payé' ? 'Congé payé' : data.typeConge === 'RTT' ? 'RTT' : data.typeConge; const [typeRow] = await conn.query( 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [typeName] ); if (typeRow.length > 0) { const typeCongeId = typeRow[0].Id; // Vérifier si le compteur existe const [existing] = await conn.query( `SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [data.collaborateurId, typeCongeId, data.annee] ); if (existing.length > 0) { // Mettre à jour await conn.query( `UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [data.nouveauTotal, data.nouveauSolde, existing[0].Id] ); console.log('✅ Compteur local mis à jour'); } else { // Créer await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, NOW())`, [data.collaborateurId, typeCongeId, data.annee, data.nouveauTotal, data.nouveauSolde] ); console.log('✅ Compteur local créé'); } } conn.release(); } catch (dbError) { console.error('❌ Erreur mise à jour locale:', dbError.message); if (conn) conn.release(); } } // NOTIFIER LE CLIENT SSE DU COLLABORATEUR notifyCollabClients({ type: 'compteur-updated', collaborateurId: data.collaborateurId, typeConge: data.typeConge, annee: data.annee, typeUpdate: data.typeUpdate, nouveauTotal: data.nouveauTotal, nouveauSolde: data.nouveauSolde, source: data.source, // Garder la source originale timestamp: new Date().toISOString() }, data.collaborateurId); console.log('✅ Notification SSE envoyée au collaborateur', data.collaborateurId); break; case EVENTS.DEMANDE_VALIDATED: console.log('\n✅ === WEBHOOK DEMANDE_VALIDATED REÇU ==='); console.log(` Demande: ${data.demandeId}`); console.log(` Statut: ${data.statut}`); console.log(` Type: ${data.typeConge}`); console.log(` Couleur: ${data.couleurHex}`); // Notifier les clients SSE avec TOUTES les infos notifyClients({ type: 'demande-validated', demandeId: data.demandeId, statut: data.statut, typeConge: data.typeConge, couleurHex: data.couleurHex || '#d946ef', date: data.date, periode: data.periode, collaborateurId: data.collaborateurId, timestamp: new Date().toISOString() }, data.collaborateurId); // Notifier les RH aussi notifyClients({ type: 'demande-list-updated', action: 'validation-collab', demandeId: data.demandeId, statut: data.statut, typeConge: data.typeConge, couleurHex: data.couleurHex || '#d946ef', timestamp: new Date().toISOString() }); console.log(' 📢 Notifications SSE envoyées'); break; case EVENTS.DEMANDE_UPDATED: console.log('\n✏️ === WEBHOOK DEMANDE_UPDATED REÇU ==='); console.log(` Demande: ${data.demandeId}`); console.log(` Collaborateur: ${data.collaborateurId}`); notifyCollabClients({ type: 'demande-updated-rh', demandeId: data.demandeId, timestamp: new Date().toISOString() }, data.collaborateurId); console.log(' 📢 Notification modification envoyée'); break; case EVENTS.DEMANDE_DELETED: console.log('\n🗑️ === WEBHOOK DEMANDE_DELETED REÇU ==='); console.log(` Demande: ${data.demandeId}`); console.log(` Collaborateur: ${data.collaborateurId}`); notifyCollabClients({ type: 'demande-deleted-rh', demandeId: data.demandeId, timestamp: new Date().toISOString() }, data.collaborateurId); console.log(' 📢 Notification suppression envoyée'); 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 }); } }); app.post('/api/syncCompteursFromRH', async (req, res) => { try { const { user_id } = req.body; if (!user_id) { return res.json({ success: false, message: 'user_id manquant' }); } console.log('\n🔄 === SYNCHRONISATION MANUELLE DEPUIS RH ==='); console.log('User ID:', user_id); // Récupérer les compteurs depuis le serveur RH const rhUrl = process.env.RH_SERVER_URL || 'http://localhost:3001'; try { const response = await fetch(`${rhUrl}/api/compteurs?user_id=${user_id}`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`Erreur serveur RH: ${response.status}`); } const rhCompteurs = await response.json(); console.log('📊 Compteurs RH récupérés:', rhCompteurs.length); // Mettre à jour la base locale const conn = await pool.getConnection(); await conn.beginTransaction(); let updated = 0; let created = 0; for (const compteur of rhCompteurs) { // Identifier le type de congé const [typeRow] = await conn.query( 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', [compteur.typeConge] ); if (typeRow.length === 0) { console.warn(`⚠️ Type ${compteur.typeConge} non trouvé`); continue; } const typeCongeId = typeRow[0].Id; // Vérifier si existe const [existing] = await conn.query(` SELECT Id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [compteur.collaborateurId, typeCongeId, compteur.annee]); if (existing.length > 0) { // Mettre à jour await conn.query(` UPDATE CompteurConges SET Total = ?, Solde = ?, SoldeReporte = ?, DerniereMiseAJour = NOW() WHERE Id = ? `, [ compteur.total, compteur.solde, compteur.soldeReporte || 0, existing[0].Id ]); updated++; } else { // Créer await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, ?, NOW()) `, [ compteur.collaborateurId, typeCongeId, compteur.annee, compteur.total, compteur.solde, compteur.soldeReporte || 0 ]); created++; } } await conn.commit(); conn.release(); console.log(`✅ Synchronisation terminée: ${updated} mis à jour, ${created} créés`); res.json({ success: true, message: 'Synchronisation réussie', stats: { total: rhCompteurs.length, updated: updated, created: created } }); } catch (fetchError) { console.error('❌ Erreur communication avec serveur RH:', fetchError.message); res.status(500).json({ success: false, message: 'Impossible de contacter le serveur RH', error: fetchError.message }); } } catch (error) { console.error('❌ Erreur synchronisation:', error); res.status(500).json({ success: false, message: 'Erreur serveur', 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 * RÈGLES : * - 37h : toujours 10 RTT/an (0.8333/mois) * - Forfait jour 2025 : 10 RTT/an (0.8333/mois) * - Forfait jour 2026+ : 12 RTT/an (1.0/mois) */ async function getConfigurationRTT(conn, annee, typeContrat = '37h') { try { // D'abord chercher en base de données 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) }; } // Si pas en base, utiliser les règles par défaut console.warn(`⚠️ Pas de config RTT en base pour ${annee}/${typeContrat}, utilisation des règles par défaut`); return getConfigurationRTTDefaut(annee, typeContrat); } catch (error) { console.error('Erreur getConfigurationRTT:', error); return getConfigurationRTTDefaut(annee, typeContrat); } } function getConfigurationRTTDefaut(annee, typeContrat) { // 37h : toujours 10 RTT/an if (typeContrat === '37h' || typeContrat === 'temps_partiel') { return { joursAnnuels: 10, acquisitionMensuelle: 10 / 12 // 0.8333 }; } // Forfait jour : dépend de l'année if (typeContrat === 'forfait_jour') { if (annee <= 2025) { // 2025 et avant : 10 RTT/an return { joursAnnuels: 10, acquisitionMensuelle: 10 / 12 // 0.8333 }; } else { // 2026 et après : 12 RTT/an return { joursAnnuels: 12, acquisitionMensuelle: 12 / 12 // 1.0 }; } } // Par défaut : 10 RTT/an return { joursAnnuels: 10, acquisitionMensuelle: 10 / 12 }; } /** * Calcule l'acquisition RTT avec la formule Excel exacte */ async function calculerAcquisitionRTT(conn, collaborateurId, dateReference = new Date()) { const d = new Date(dateReference); d.setHours(0, 0, 0, 0); const annee = d.getFullYear(); // 1️⃣ Récupérer les infos du collaborateur const [collabInfo] = await conn.query( `SELECT TypeContrat, DateEntree, role 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; const isApprenti = collabInfo[0].role === 'Apprenti'; // 2️⃣ Apprentis = pas de RTT if (isApprenti) { return { acquisition: 0, moisTravailles: 0, config: { joursAnnuels: 0, acquisitionMensuelle: 0 }, typeContrat: typeContrat }; } // 3️⃣ Récupérer la configuration RTT (avec règles 2025/2026) const config = await getConfigurationRTT(conn, annee, typeContrat); console.log(`📊 Config RTT ${annee}/${typeContrat}: ${config.joursAnnuels}j/an (${config.acquisitionMensuelle.toFixed(4)}/mois)`); // 4️⃣ Début d'acquisition = 01/01/N ou date d'entrée si postérieure let dateDebutAcquis = new Date(annee, 0, 1); // 01/01/N dateDebutAcquis.setHours(0, 0, 0, 0); if (dateEntree) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); if (entree.getFullYear() === annee && entree > dateDebutAcquis) { dateDebutAcquis = entree; } if (entree.getFullYear() > annee) { return { acquisition: 0, moisTravailles: 0, config: config, typeContrat: typeContrat }; } } // 5️⃣ Calculer avec la formule Excel const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, config.acquisitionMensuelle); // 6️⃣ Calculer les mois travaillés (pour info) const moisTravailles = config.acquisitionMensuelle > 0 ? acquisition / config.acquisitionMensuelle : 0; // 7️⃣ Plafonner au maximum annuel const acquisitionFinale = Math.min(acquisition, config.joursAnnuels); return { acquisition: Math.round(acquisitionFinale * 100) / 100, moisTravailles: Math.round(moisTravailles * 100) / 100, config: config, typeContrat: typeContrat }; } /** * Calcule l'acquisition avec la formule Excel exacte : * E1 * ((JOUR(FIN.MOIS(B1;0)) - JOUR(B1) + 1) / JOUR(FIN.MOIS(B1;0)) * + DATEDIF(B1;B2;"m") - 1 * + JOUR(B2) / JOUR(FIN.MOIS(B2;0))) */ function calculerAcquisitionFormuleExcel(dateDebut, dateReference, coeffMensuel) { const b1 = new Date(dateDebut); const b2 = new Date(dateReference); b1.setHours(0, 0, 0, 0); b2.setHours(0, 0, 0, 0); // Si date référence avant date début if (b2 < b1) { return 0; } // Si même mois et même année if (b1.getFullYear() === b2.getFullYear() && b1.getMonth() === b2.getMonth()) { const joursTotal = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate(); const joursAcquis = b2.getDate() - b1.getDate() + 1; return Math.round((joursAcquis / joursTotal) * coeffMensuel * 100) / 100; } // 1️⃣ Fraction du PREMIER mois const joursFinMoisB1 = new Date(b1.getFullYear(), b1.getMonth() + 1, 0).getDate(); const jourB1 = b1.getDate(); const fractionPremierMois = (joursFinMoisB1 - jourB1 + 1) / joursFinMoisB1; // 2️⃣ Mois COMPLETS entre const moisComplets = dateDifMonths(b1, b2) - 1; // 3️⃣ Fraction du DERNIER mois const joursFinMoisB2 = new Date(b2.getFullYear(), b2.getMonth() + 1, 0).getDate(); const jourB2 = b2.getDate(); const fractionDernierMois = jourB2 / joursFinMoisB2; // 4️⃣ Total const totalMois = fractionPremierMois + Math.max(0, moisComplets) + fractionDernierMois; const acquisition = totalMois * coeffMensuel; return Math.round(acquisition * 100) / 100; } /** * Équivalent de DATEDIF(date1, date2, "m") en JavaScript */ function dateDifMonths(date1, date2) { const d1 = new Date(date1); const d2 = new Date(date2); let months = (d2.getFullYear() - d1.getFullYear()) * 12; months += d2.getMonth() - d1.getMonth(); // Si le jour de d2 < jour de d1, on n'a pas encore complété le mois if (d2.getDate() < d1.getDate()) { months--; } return Math.max(0, months); } /** * Calcule l'acquisition CP avec la formule Excel exacte */ function calculerAcquisitionCP(dateReference = new Date(), dateEntree = null) { const d = new Date(dateReference); d.setHours(0, 0, 0, 0); const annee = d.getFullYear(); const mois = d.getMonth() + 1; // 1️⃣ Déterminer le début de l'exercice CP (01/06) let exerciceDebut; if (mois >= 6) { exerciceDebut = new Date(annee, 5, 1); // 01/06/N } else { exerciceDebut = new Date(annee - 1, 5, 1); // 01/06/N-1 } exerciceDebut.setHours(0, 0, 0, 0); // 2️⃣ Ajuster si date d'entrée postérieure let dateDebutAcquis = new Date(exerciceDebut); if (dateEntree) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); if (entree > exerciceDebut) { dateDebutAcquis = entree; } } // 3️⃣ Calculer avec la formule Excel const coeffCP = 25 / 12; // 2.0833 const acquisition = calculerAcquisitionFormuleExcel(dateDebutAcquis, d, coeffCP); // 4️⃣ Plafonner à 25 jours return Math.min(acquisition, 25); } // ======================================== // 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; } } // ✅ Calculer jusqu'à aujourd'hui 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 = []; // Récupérer les infos du collaborateur const [collabInfo] = await conn.query(` SELECT DateEntree, TypeContrat, CampusId, role FROM CollaborateurAD WHERE id = ? `, [collaborateurId]); if (collabInfo.length === 0) { throw new Error(`Collaborateur ${collaborateurId} non trouvé`); } const dateEntree = collabInfo[0].DateEntree || null; const typeContrat = collabInfo[0].TypeContrat || '37h'; const isApprenti = collabInfo[0].role === 'Apprenti'; console.log(`\n📊 === Mise à jour compteurs pour collaborateur ${collaborateurId} ===`); console.log(` Date référence: ${today.toLocaleDateString('fr-FR')}`); // ====================================== // CP (Congés Payés) // ====================================== 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; // 1️⃣ Récupérer le compteur existant 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 || 0); const ancienSolde = parseFloat(existingCP[0].Solde || 0); const soldeReporte = parseFloat(existingCP[0].SoldeReporte || 0); console.log(` CP - Ancien acquis: ${ancienTotal.toFixed(2)}j`); console.log(` CP - Nouvel acquis: ${acquisitionCP.toFixed(2)}j`); // 2️⃣ Calculer l'incrément d'acquisition (nouveaux jours acquis ce mois) const incrementAcquis = acquisitionCP - ancienTotal; if (incrementAcquis > 0) { console.log(` CP - Nouveaux jours ce mois: +${incrementAcquis.toFixed(2)}j`); // 3️⃣ Vérifier si le collaborateur a de l'anticipé utilisé const [anticipeUtilise] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalAnticipe, MIN(dd.Id) as firstDeductionId, MIN(dd.DemandeCongeId) as demandeId FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' AND dd.JoursUtilises > 0 `, [collaborateurId, cpTypeId, currentYear]); const anticipePris = parseFloat(anticipeUtilise[0]?.totalAnticipe || 0); if (anticipePris > 0) { // 4️⃣ Calculer le montant à rembourser const aRembourser = Math.min(incrementAcquis, anticipePris); console.log(` 💳 CP - Anticipé à rembourser: ${aRembourser.toFixed(2)}j (sur ${anticipePris.toFixed(2)}j)`); // 5️⃣ Rembourser l'anticipé en transférant vers "Année N" // On récupère toutes les déductions anticipées pour ce type const [deductionsAnticipees] = await conn.query(` SELECT dd.Id, dd.DemandeCongeId, dd.JoursUtilises FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' AND dd.JoursUtilises > 0 ORDER BY dd.Id ASC `, [collaborateurId, cpTypeId, currentYear]); let resteARembourser = aRembourser; for (const deduction of deductionsAnticipees) { if (resteARembourser <= 0) break; const joursAnticipes = parseFloat(deduction.JoursUtilises); const aDeduiteDeCetteDeduction = Math.min(resteARembourser, joursAnticipes); // Réduire l'anticipé await conn.query(` UPDATE DeductionDetails SET JoursUtilises = GREATEST(0, JoursUtilises - ?) WHERE Id = ? `, [aDeduiteDeCetteDeduction, deduction.Id]); // Vérifier si une déduction "Année N" existe déjà pour cette demande const [existingAnneeN] = await conn.query(` SELECT Id, JoursUtilises FROM DeductionDetails WHERE DemandeCongeId = ? AND TypeCongeId = ? AND Annee = ? AND TypeDeduction IN ('Année N', 'Anne N', 'Anne actuelle N') `, [deduction.DemandeCongeId, cpTypeId, currentYear]); if (existingAnneeN.length > 0) { // Augmenter la déduction "Année N" existante await conn.query(` UPDATE DeductionDetails SET JoursUtilises = JoursUtilises + ? WHERE Id = ? `, [aDeduiteDeCetteDeduction, existingAnneeN[0].Id]); } else { // Créer une nouvelle déduction "Année N" await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Année N', ?) `, [deduction.DemandeCongeId, cpTypeId, currentYear, aDeduiteDeCetteDeduction]); } resteARembourser -= aDeduiteDeCetteDeduction; console.log(` ✅ CP - Remboursé ${aDeduiteDeCetteDeduction.toFixed(2)}j (Demande ${deduction.DemandeCongeId})`); } // Supprimer les déductions anticipées à zéro await conn.query(` DELETE FROM DeductionDetails WHERE TypeCongeId = ? AND Annee = ? AND TypeDeduction = 'N Anticip' AND JoursUtilises <= 0 `, [cpTypeId, currentYear]); } } // 6️⃣ Recalculer le solde total (acquis + report - consommé) const [consomme] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup') AND dc.Statut != 'Refusée' `, [collaborateurId, cpTypeId, currentYear]); const totalConsomme = parseFloat(consomme[0].total || 0); const nouveauSolde = Math.max(0, acquisitionCP + soldeReporte - totalConsomme); console.log(` CP - Consommé total: ${totalConsomme.toFixed(2)}j`); console.log(` CP - Nouveau solde: ${nouveauSolde.toFixed(2)}j`); // 7️⃣ Mettre à jour le compteur await conn.query(` UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ? `, [acquisitionCP, nouveauSolde, existingCP[0].Id]); updates.push({ type: 'CP', exercice: exerciceCP, acquisitionCumulee: acquisitionCP, increment: incrementAcquis, nouveauSolde: nouveauSolde }); } else { // Créer le compteur s'il n'existe pas await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, NOW()) `, [collaborateurId, cpTypeId, currentYear, acquisitionCP, acquisitionCP]); console.log(` CP - Compteur créé: ${acquisitionCP.toFixed(2)}j`); updates.push({ type: 'CP', exercice: exerciceCP, acquisitionCumulee: acquisitionCP, action: 'created', nouveauSolde: acquisitionCP }); } } // ====================================== // RTT // ====================================== if (!isApprenti) { 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; // 1️⃣ Récupérer le compteur existant 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 || 0); const ancienSolde = parseFloat(existingRTT[0].Solde || 0); console.log(` RTT - Ancien acquis: ${ancienTotal.toFixed(2)}j`); console.log(` RTT - Nouvel acquis: ${acquisitionRTT.toFixed(2)}j`); // 2️⃣ Calculer l'incrément d'acquisition const incrementAcquis = acquisitionRTT - ancienTotal; if (incrementAcquis > 0) { console.log(` RTT - Nouveaux jours ce mois: +${incrementAcquis.toFixed(2)}j`); // 3️⃣ Vérifier si le collaborateur a de l'anticipé utilisé const [anticipeUtilise] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalAnticipe FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' AND dd.JoursUtilises > 0 `, [collaborateurId, rttTypeId, currentYear]); const anticipePris = parseFloat(anticipeUtilise[0]?.totalAnticipe || 0); if (anticipePris > 0) { // 4️⃣ Calculer le montant à rembourser const aRembourser = Math.min(incrementAcquis, anticipePris); console.log(` 💳 RTT - Anticipé à rembourser: ${aRembourser.toFixed(2)}j (sur ${anticipePris.toFixed(2)}j)`); // 5️⃣ Rembourser l'anticipé const [deductionsAnticipees] = await conn.query(` SELECT dd.Id, dd.DemandeCongeId, dd.JoursUtilises FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' AND dd.JoursUtilises > 0 ORDER BY dd.Id ASC `, [collaborateurId, rttTypeId, currentYear]); let resteARembourser = aRembourser; for (const deduction of deductionsAnticipees) { if (resteARembourser <= 0) break; const joursAnticipes = parseFloat(deduction.JoursUtilises); const aDeduiteDeCetteDeduction = Math.min(resteARembourser, joursAnticipes); // Réduire l'anticipé await conn.query(` UPDATE DeductionDetails SET JoursUtilises = GREATEST(0, JoursUtilises - ?) WHERE Id = ? `, [aDeduiteDeCetteDeduction, deduction.Id]); // Vérifier si une déduction "Année N" existe déjà const [existingAnneeN] = await conn.query(` SELECT Id, JoursUtilises FROM DeductionDetails WHERE DemandeCongeId = ? AND TypeCongeId = ? AND Annee = ? AND TypeDeduction IN ('Année N', 'Anne N') `, [deduction.DemandeCongeId, rttTypeId, currentYear]); if (existingAnneeN.length > 0) { await conn.query(` UPDATE DeductionDetails SET JoursUtilises = JoursUtilises + ? WHERE Id = ? `, [aDeduiteDeCetteDeduction, existingAnneeN[0].Id]); } else { await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Année N', ?) `, [deduction.DemandeCongeId, rttTypeId, currentYear, aDeduiteDeCetteDeduction]); } resteARembourser -= aDeduiteDeCetteDeduction; console.log(` ✅ RTT - Remboursé ${aDeduiteDeCetteDeduction.toFixed(2)}j (Demande ${deduction.DemandeCongeId})`); } // Supprimer les déductions anticipées à zéro await conn.query(` DELETE FROM DeductionDetails WHERE TypeCongeId = ? AND Annee = ? AND TypeDeduction = 'N Anticip' AND JoursUtilises <= 0 `, [rttTypeId, currentYear]); } } // 6️⃣ Recalculer le solde total const [consomme] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction NOT IN ('Accum Récup', 'Accum Recup', 'Récup Dosée') AND dc.Statut != 'Refusée' `, [collaborateurId, rttTypeId, currentYear]); const totalConsomme = parseFloat(consomme[0].total || 0); const nouveauSolde = Math.max(0, acquisitionRTT - totalConsomme); console.log(` RTT - Consommé total: ${totalConsomme.toFixed(2)}j`); console.log(` RTT - Nouveau solde: ${nouveauSolde.toFixed(2)}j`); // 7️⃣ Mettre à jour le compteur await conn.query(` UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ? `, [acquisitionRTT, 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: incrementAcquis, nouveauSolde: nouveauSolde }); } else { // Créer le compteur s'il n'existe pas await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, NOW()) `, [collaborateurId, rttTypeId, currentYear, acquisitionRTT, acquisitionRTT]); console.log(` RTT - Compteur créé: ${acquisitionRTT.toFixed(2)}j`); updates.push({ type: 'RTT', annee: currentYear, typeContrat: rttData.typeContrat, config: `${rttData.config.joursAnnuels}j/an`, moisTravailles: rttData.moisTravailles, acquisitionCumulee: acquisitionRTT, action: 'created', nouveauSolde: acquisitionRTT }); } } } console.log(`✅ Mise à jour terminée pour collaborateur ${collaborateurId}\n`); return updates; } // ======================================== // ROUTES API // ======================================== app.post('/api/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('/api/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' }); // 1. Vérification locale 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]; // Si l'utilisateur est inactif, on le bloque if (user.Actif === 0) return res.json({ authorized: false, message: 'Compte désactivé' }); return res.json({ authorized: true, role: user.role, groups: [user.role], localUserId: user.id, user: { ...user, societeId: user.SocieteId, societeNom: user.societe_nom } }); } // 2. Si pas trouvé, interrogation Microsoft Graph 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é (Hors groupe)' }); // 3. ⭐ INSERTION AVEC VALEURS PAR DÉFAUT CRITIQUES // On met SocieteId=1 et TypeContrat='37h' par défaut pour éviter les bugs de calcul const [result] = await pool.query( `INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, service, role, SocieteId, Actif, DateEntree, TypeContrat) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ userInfo.id, userInfo.givenName || 'Prénom', userInfo.surname || 'Nom', userInfo.mail || userPrincipalName, userInfo.department, 'Collaborateur', 1, // SocieteId par défaut (ex: 1 = ENSUP) 1, // Actif = 1 (Important !) new Date(), // DateEntree = Aujourd'hui '37h' // TypeContrat par défaut ] ); 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: 1, societeNom: 'Défaut' } }); } catch (error) { console.error("Erreur check-user-groups:", error); res.json({ authorized: false, message: 'Erreur serveur', error: error.message }); } }); // ======================================== // ✅ CODE CORRIGÉ POUR getDetailedLeaveCounters // À remplacer dans server.js à partir de la ligne ~1600 // ======================================== app.get('/api/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(); const isUUID = userIdParam.length > 10 && userIdParam.includes('-'); const userQuery = ` SELECT ca.id, ca.prenom, ca.nom, ca.email, ca.role, ca.TypeContrat, ca.DateEntree, ca.CampusId, ca.SocieteId, s.Nom as service, so.Nom as societe_nom, ca.description 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 today = new Date(); const currentYear = today.getFullYear(); const previousYear = currentYear - 1; console.log(`\n📊 === CALCUL COMPTEURS pour ${user.prenom} ${user.nom} ===`); console.log(` Date référence: ${today.toLocaleDateString('fr-FR')}`); const ancienneteMs = today - new Date(dateEntree || today); const ancienneteMois = Math.floor(ancienneteMs / (1000 * 60 * 60 * 24 * 30.44)); 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, description: user.description, typeContrat: typeContrat, societeId: user.SocieteId, societeNom: user.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(today), anneeRTT: currentYear, cpN1: null, cpN: null, rttN: null, rttN1: null, recupN: null, totalDisponible: { cp: 0, rtt: 0, recup: 0, total: 0 } }; const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); // ==================================== // 1️⃣ CP N-1 (Report) - CALCUL CONSOMMÉ = ACQUIS - SOLDE // ==================================== if (cpType.length > 0) { const [cpN1] = await conn.query(` SELECT Annee, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, cpType[0].Id, previousYear]); if (cpN1.length > 0) { const totalAcquis = parseFloat(cpN1[0].Total || 0); const soldeReporte = parseFloat(cpN1[0].Solde || 0); // ⭐ CALCUL : Consommé = Acquis - Solde const pris = Math.max(0, totalAcquis - soldeReporte); counters.cpN1 = { annee: previousYear, exercice: `${previousYear}-${previousYear + 1}`, reporte: parseFloat(totalAcquis.toFixed(2)), pris: parseFloat(pris.toFixed(2)), solde: parseFloat(soldeReporte.toFixed(2)), pourcentageUtilise: totalAcquis > 0 ? parseFloat(((pris / totalAcquis) * 100).toFixed(1)) : 0 }; counters.totalDisponible.cp += counters.cpN1.solde; console.log(`✅ CP N-1: Acquis=${totalAcquis}j, Solde=${soldeReporte}j → Consommé=${pris}j`); } else { counters.cpN1 = { annee: previousYear, exercice: `${previousYear}-${previousYear + 1}`, reporte: 0, pris: 0, solde: 0, pourcentageUtilise: 0 }; } // ==================================== // 2️⃣ CP N (Exercice en cours) - CALCUL CONSOMMÉ = ACQUIS - SOLDE // ==================================== const [compteurCPN] = await conn.query(` SELECT Solde, SoldeReporte, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, cpType[0].Id, currentYear]); let soldeActuelCP = 0; let totalAcquis = 0; let cpPris = 0; if (compteurCPN.length > 0) { const soldeBDD = parseFloat(compteurCPN[0].Solde || 0); const soldeReporte = parseFloat(compteurCPN[0].SoldeReporte || 0); totalAcquis = parseFloat(compteurCPN[0].Total || 0); soldeActuelCP = Math.max(0, soldeBDD - soldeReporte); // ⭐ CALCUL : Consommé = Acquis - (Solde - Report) cpPris = Math.max(0, totalAcquis - soldeActuelCP); console.log(` CP N - Total=${totalAcquis}j, Solde BDD=${soldeBDD}j, Report=${soldeReporte}j → Solde N=${soldeActuelCP}j, Consommé=${cpPris}j`); } else { const acquisCP = calculerAcquisitionCP(today, dateEntree); soldeActuelCP = acquisCP; totalAcquis = acquisCP; cpPris = 0; console.log(` CP N - Pas de compteur BDD → Calcul: ${acquisCP}j`); } // Calculer l'anticipé disponible const [anticipeUtiliseCP] = 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 dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' `, [userId, cpType[0].Id, currentYear]); const cpAnticipeUtilise = parseFloat(anticipeUtiliseCP[0]?.totalConsomme || 0); const cpAnticipeMax = Math.max(0, 25 - totalAcquis); const cpAnticipeDisponible = Math.max(0, cpAnticipeMax - cpAnticipeUtilise); console.log(` CP - Acquis: ${totalAcquis.toFixed(2)}j`); console.log(` CP - Consommé: ${cpPris.toFixed(2)}j`); console.log(` CP - Solde: ${soldeActuelCP.toFixed(2)}j`); counters.cpN = { annee: currentYear, exercice: getExerciceCP(today), totalAnnuel: 25.00, moisTravailles: parseFloat(getMoisTravaillesCP(today, dateEntree).toFixed(2)), acquisitionMensuelle: parseFloat((25 / 12).toFixed(2)), acquis: parseFloat(totalAcquis.toFixed(2)), pris: parseFloat(cpPris.toFixed(2)), // ⭐ CONSOMMÉ CALCULÉ solde: parseFloat(soldeActuelCP.toFixed(2)), tauxAcquisition: parseFloat((getMoisTravaillesCP(today, dateEntree) / 12 * 100).toFixed(1)), pourcentageUtilise: totalAcquis > 0 ? parseFloat((cpPris / totalAcquis * 100).toFixed(1)) : 0, joursRestantsAAcquerir: parseFloat((25 - totalAcquis).toFixed(2)), anticipe: { acquisPrevu: parseFloat(cpAnticipeMax.toFixed(2)), pris: parseFloat(cpAnticipeUtilise.toFixed(2)), disponible: parseFloat(cpAnticipeDisponible.toFixed(2)), depassement: cpAnticipeUtilise > cpAnticipeMax ? parseFloat((cpAnticipeUtilise - cpAnticipeMax).toFixed(2)) : 0 } }; counters.totalDisponible.cp += counters.cpN.solde + cpAnticipeDisponible; } // ==================================== // 3️⃣ RTT N - CALCUL CONSOMMÉ = ACQUIS - SOLDE // ==================================== const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['RTT']); if (rttType.length > 0 && user.role !== 'Apprenti') { const [compteurRTT] = await conn.query(` SELECT Solde, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, rttType[0].Id, currentYear]); let soldeActuelRTT = 0; let totalAcquis = 0; let rttPris = 0; const rttData = await calculerAcquisitionRTT(conn, userId, today); const rttConfig = await getConfigurationRTT(conn, currentYear, typeContrat); if (compteurRTT.length > 0) { soldeActuelRTT = parseFloat(compteurRTT[0].Solde || 0); totalAcquis = parseFloat(compteurRTT[0].Total || 0); // ⭐ CALCUL : Consommé = Acquis - Solde rttPris = Math.max(0, totalAcquis - soldeActuelRTT); console.log(` RTT - Acquis: ${totalAcquis}j, Solde: ${soldeActuelRTT}j → Consommé: ${rttPris}j`); } else { soldeActuelRTT = rttData.acquisition; totalAcquis = rttData.acquisition; rttPris = 0; console.log(` RTT - Pas de compteur BDD → Calcul: ${rttData.acquisition}j`); } counters.rttN = { annee: currentYear, typeContrat: typeContrat, totalAnnuel: parseFloat(rttConfig.joursAnnuels.toFixed(2)), moisTravailles: rttData.moisTravailles, acquisitionMensuelle: parseFloat(rttConfig.acquisitionMensuelle.toFixed(6)), acquis: parseFloat(totalAcquis.toFixed(2)), pris: parseFloat(rttPris.toFixed(2)), // ⭐ CONSOMMÉ CALCULÉ solde: parseFloat(soldeActuelRTT.toFixed(2)), tauxAcquisition: parseFloat((rttData.moisTravailles / 12 * 100).toFixed(1)), pourcentageUtilise: totalAcquis > 0 ? parseFloat((rttPris / totalAcquis * 100).toFixed(1)) : 0, joursRestantsAAcquerir: parseFloat((rttConfig.joursAnnuels - totalAcquis).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" }; // ==================================== // 4️⃣ RÉCUP - CALCUL CONSOMMÉ = ACQUIS - SOLDE // ==================================== const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']); if (recupType.length > 0) { const [compteurRecup] = await conn.query(` SELECT Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [userId, recupType[0].Id, currentYear]); const soldeRecup = compteurRecup.length > 0 ? parseFloat(compteurRecup[0].Solde || 0) : 0; // Récupérer accumulations depuis DeductionDetails const [accumRecup] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as totalAccum FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction IN ('Accum Récup', 'Accum Recup') AND dc.Statut != 'Refusée' `, [userId, recupType[0].Id, currentYear]); const acquis = parseFloat(accumRecup[0]?.totalAccum || 0); // ⭐ CALCUL : Consommé = Acquis - Solde const pris = Math.max(0, acquis - soldeRecup); counters.recupN = { annee: currentYear, acquis: parseFloat(acquis.toFixed(2)), pris: parseFloat(pris.toFixed(2)), // ⭐ CONSOMMÉ CALCULÉ solde: parseFloat(soldeRecup.toFixed(2)), message: "Jours de récupération" }; counters.totalDisponible.recup = counters.recupN.solde; console.log(`✅ Récup: Acquis=${acquis}j, Solde=${soldeRecup}j → Consommé=${pris}j`); } counters.totalDisponible.total = counters.totalDisponible.cp + counters.totalDisponible.rtt + counters.totalDisponible.recup; console.log(`\n✅ TOTAL FINAL: ${counters.totalDisponible.total.toFixed(2)}j disponibles`); conn.release(); res.json({ success: true, message: 'Compteurs détaillés récupérés avec succès', data: counters, availableCP: counters.totalDisponible.cp, availableRTT: counters.totalDisponible.rtt, availableRecup: counters.totalDisponible.recup }); } catch (error) { console.error('Erreur getDetailedLeaveCounters:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); app.post('/api/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('/api/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}`); // 1️⃣ Récupérer TOUTES les déductions (y compris Récup) const [deductions] = await conn.query( `SELECT dd.*, 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 (Année: ${Annee})`); // ======================================== // RÉCUP POSÉE - RESTAURATION // ======================================== if (TypeDeduction === 'Récup Posée') { console.log(`🔄 Restauration Récup posée: +${JoursUtilises}j`); 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 = ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [nouveauSolde, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ Récup restaurée: ${ancienSolde} → ${nouveauSolde}`); } else { console.warn(`⚠️ Compteur Récup non trouvé pour l'année ${Annee}`); } continue; } // ======================================== // N+1 ANTICIPÉ - RESTAURATION // ======================================== if (TypeDeduction === 'N+1 Anticipé') { console.log(`🔄 Restauration N+1 Anticipé: +${JoursUtilises}j`); const [compteur] = await conn.query( `SELECT Id, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { const ancienSolde = parseFloat(compteur[0].SoldeAnticipe || 0); const nouveauSolde = ancienSolde + parseFloat(JoursUtilises); await conn.query( `UPDATE CompteurConges SET SoldeAnticipe = ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [nouveauSolde, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ N+1 Anticipé restauré: ${ancienSolde} → ${nouveauSolde}`); } else { // Créer le compteur N+1 s'il n'existe pas await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour) VALUES (?, ?, ?, 0, 0, 0, ?, NOW())`, [collaborateurId, TypeCongeId, Annee, JoursUtilises] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ Compteur N+1 créé avec ${JoursUtilises}j anticipés`); } continue; } // ======================================== // N ANTICIPÉ - RESTAURATION // ======================================== if (TypeDeduction === 'N Anticipé') { console.log(`🔄 Restauration N Anticipé: +${JoursUtilises}j`); const [compteur] = await conn.query( `SELECT Id, SoldeAnticipe FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [collaborateurId, TypeCongeId, Annee] ); if (compteur.length > 0) { const ancienSolde = parseFloat(compteur[0].SoldeAnticipe || 0); const nouveauSolde = ancienSolde + parseFloat(JoursUtilises); await conn.query( `UPDATE CompteurConges SET SoldeAnticipe = ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [nouveauSolde, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ N Anticipé restauré: ${ancienSolde} → ${nouveauSolde}`); } else { // Créer le compteur s'il n'existe pas await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour) VALUES (?, ?, ?, 0, 0, 0, ?, NOW())`, [collaborateurId, TypeCongeId, Annee, JoursUtilises] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ Compteur N créé avec ${JoursUtilises}j anticipés`); } continue; } // ======================================== // REPORTÉ N-1 // ======================================== if (TypeDeduction === 'Reporté N-1' || TypeDeduction === 'Report N-1' || TypeDeduction === 'Année 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}`); } } // ======================================== // ACCUM RÉCUP (enlever de l'accumulation) // ======================================== else if (TypeDeduction === 'Accum Récup' || TypeDeduction === 'Accum Recup') { console.log(`⚠️ Accumulation Récup détectée - À enlever: ${JoursUtilises}j`); 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 = Math.max(0, ancienSolde - parseFloat(JoursUtilises)); await conn.query( `UPDATE CompteurConges SET Solde = ?, DerniereMiseAJour = NOW() WHERE Id = ?`, [nouveauSolde, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: 'Annulation Accumulation', joursRestores: JoursUtilises }); console.log(`✅ Accumulation annulée: ${ancienSolde} → ${nouveauSolde}`); } } } // ⭐ SUPPRIMER LES DÉDUCTIONS await conn.query( 'DELETE FROM DeductionDetails WHERE DemandeCongeId = ?', [demandeCongeId] ); console.log('🗑️ Déductions supprimées de la base'); // ⭐ Recalculer les soldes anticipés console.log(`\n🔄 Recalcul des soldes anticipés...`); await updateSoldeAnticipe(conn, collaborateurId); 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('/api/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('/api/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('/api/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('/api/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('/api/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('/api/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('/api/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('/api/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('/api/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 = `/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('/api/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('/api/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('/api/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('/api/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('/api/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 }); } }); // À ajouter avant app.listen() /** * POST /saisirRecupJour * Saisir une journée de récupération (samedi travaillé) */ app.post('/api/saisirRecupJour', async (req, res) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const { user_id, date, // Date du samedi travaillé nombre_heures = 1, // Par défaut 1 jour = 1 samedi commentaire } = req.body; console.log('\n📝 === SAISIE RÉCUP ==='); console.log('User ID:', user_id); console.log('Date:', date); console.log('Heures:', nombre_heures); if (!user_id || !date) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Données manquantes' }); } // Vérifier que c'est bien un samedi const dateObj = new Date(date); const dayOfWeek = dateObj.getDay(); if (dayOfWeek !== 6) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'La récupération ne peut être saisie que pour un samedi' }); } // Vérifier que ce samedi n'a pas déjà été saisi const [existing] = await conn.query(` SELECT dc.Id FROM DemandeConge dc JOIN DemandeCongeType dct ON dc.Id = dct.DemandeCongeId JOIN TypeConge tc ON dct.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = ? AND dc.DateDebut = ? AND tc.Nom = 'Récupération' `, [user_id, date]); if (existing.length > 0) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Ce samedi a déjà été déclaré' }); } // Récupérer infos utilisateur const [userInfo] = await conn.query( 'SELECT prenom, nom, email, CampusId FROM CollaborateurAD WHERE id = ?', [user_id] ); if (userInfo.length === 0) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Utilisateur non trouvé' }); } const user = userInfo[0]; const userName = `${user.prenom} ${user.nom}`; const dateFormatted = dateObj.toLocaleDateString('fr-FR'); // Récupérer le type Récupération const [recupType] = await conn.query( 'SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération'] ); if (recupType.length === 0) { await conn.rollback(); conn.release(); return res.json({ success: false, message: 'Type Récupération non trouvé' }); } const recupTypeId = recupType[0].Id; const currentYear = dateObj.getFullYear(); // CRÉER LA DEMANDE (validée automatiquement) const [result] = await conn.query(` INSERT INTO DemandeConge (CollaborateurADId, DateDebut, DateFin, TypeCongeId, Statut, DateDemande, Commentaire, NombreJours) VALUES (?, ?, ?, ?, 'Validée', NOW(), ?, ?) `, [user_id, date, date, recupTypeId, commentaire || `Samedi travaillé - ${dateFormatted}`, nombre_heures]); const demandeId = result.insertId; // SAUVEGARDER DANS DemandeCongeType await conn.query(` INSERT INTO DemandeCongeType (DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (?, ?, ?, 'Journée entière') `, [demandeId, recupTypeId, nombre_heures]); // ACCUMULER DANS LE COMPTEUR const [compteur] = await conn.query(` SELECT Id, Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [user_id, recupTypeId, currentYear]); if (compteur.length > 0) { await conn.query(` UPDATE CompteurConges SET Total = Total + ?, Solde = Solde + ?, DerniereMiseAJour = NOW() WHERE Id = ? `, [nombre_heures, nombre_heures, compteur[0].Id]); console.log(`✅ Compteur mis à jour: ${parseFloat(compteur[0].Solde) + nombre_heures}j`); } else { await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, NOW()) `, [user_id, recupTypeId, currentYear, nombre_heures, nombre_heures]); console.log(`✅ Compteur créé: ${nombre_heures}j`); } // ENREGISTRER L'ACCUMULATION await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Accum Récup', ?) `, [demandeId, recupTypeId, currentYear, nombre_heures]); // CRÉER NOTIFICATION await conn.query(` INSERT INTO Notifications (CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu) VALUES (?, 'Success', '✅ Récupération enregistrée', ?, ?, NOW(), 0) `, [ user_id, `Samedi ${dateFormatted} enregistré : +${nombre_heures}j de récupération`, demandeId ]); await conn.commit(); conn.release(); res.json({ success: true, message: `Samedi ${dateFormatted} enregistré`, jours_ajoutes: nombre_heures, demande_id: demandeId }); } catch (error) { await conn.rollback(); if (conn) conn.release(); console.error('❌ Erreur saisie récup:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); /** * GET /getMesSamedis * Récupérer les samedis déjà déclarés */ app.get('/api/getMesSamedis', async (req, res) => { try { const { user_id, annee } = req.query; const conn = await pool.getConnection(); const [samedis] = await conn.query(` SELECT dc.Id, DATE_FORMAT(dc.DateDebut, '%Y-%m-%d') as date, dc.NombreJours as jours, dc.Commentaire as commentaire, DATE_FORMAT(dc.DateDemande, '%d/%m/%Y à %H:%i') as date_saisie FROM DemandeConge dc JOIN TypeConge tc ON dc.TypeCongeId = tc.Id WHERE dc.CollaborateurADId = ? AND tc.Nom = 'Récupération' AND YEAR(dc.DateDebut) = ? ORDER BY dc.DateDebut DESC `, [user_id, annee]); conn.release(); res.json({ success: true, samedis: samedis }); } catch (error) { console.error('Erreur getMesSamedis:', error); res.status(500).json({ success: false, message: 'Erreur serveur', error: error.message }); } }); async function checkLeaveBalanceWithAnticipation(conn, collaborateurId, repartition, dateDebut) { const dateDebutObj = new Date(dateDebut); const currentYear = dateDebutObj.getFullYear(); const previousYear = currentYear - 1; console.log('\n🔍 === CHECK SOLDES AVEC ANTICIPATION ==='); console.log(`📅 Date demande: ${dateDebut}`); console.log(`📅 Année demande: ${currentYear}`); const verification = []; for (const rep of repartition) { const typeCode = rep.TypeConge; const joursNecessaires = parseFloat(rep.NombreJours || 0); 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; // ==================================== // 1️⃣ Récupérer les infos du collaborateur // ==================================== const [collabInfo] = await conn.query(` SELECT DateEntree, TypeContrat, role FROM CollaborateurAD WHERE id = ? `, [collaborateurId]); const dateEntree = collabInfo[0]?.DateEntree || null; const typeContrat = collabInfo[0]?.TypeContrat || '37h'; const isApprenti = collabInfo[0]?.role === 'Apprenti'; // ==================================== // 2️⃣ Calculer l'acquisition à la date de la demande // ==================================== let acquisALaDate = 0; let budgetAnnuel = 0; if (typeCode === 'CP') { acquisALaDate = calculerAcquisitionCP(dateDebutObj, dateEntree); budgetAnnuel = 25; console.log(`💰 Acquisition CP à la date ${dateDebut}: ${acquisALaDate.toFixed(2)}j`); } else if (typeCode === 'RTT' && !isApprenti) { const rttData = await calculerAcquisitionRTT(conn, collaborateurId, dateDebutObj); acquisALaDate = rttData.acquisition; budgetAnnuel = rttData.config.joursAnnuels; console.log(`💰 Acquisition RTT à la date ${dateDebut}: ${acquisALaDate.toFixed(2)}j`); } // ==================================== // 3️⃣ Récupérer le report N-1 (CP uniquement) // ==================================== let reporteN1 = 0; if (typeCode === 'CP') { const [compteurN1] = await conn.query(` SELECT Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, previousYear]); if (compteurN1.length > 0) { reporteN1 = parseFloat(compteurN1[0].Solde || 0); } } // ==================================== // 4️⃣ Calculer ce qui a déjà été posé (SANS l'anticipé) // ==================================== const [dejaPose] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction NOT IN ('N Anticip', 'N+1 Anticip', 'Accum Récup', 'Accum Recup') AND dc.Statut != 'Refusée' `, [collaborateurId, typeCongeId, currentYear]); const dejaPoseNormal = parseFloat(dejaPose[0]?.total || 0); // ==================================== // 5️⃣ Calculer l'anticipé déjà utilisé // ==================================== const [anticipeUtilise] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' `, [collaborateurId, typeCongeId, currentYear]); const dejaPoseAnticipe = parseFloat(anticipeUtilise[0]?.total || 0); // ==================================== // 6️⃣ Calculer l'anticipé disponible // ==================================== const anticipableMax = Math.max(0, budgetAnnuel - acquisALaDate); const anticipeDisponible = Math.max(0, anticipableMax - dejaPoseAnticipe); console.log(`💳 Anticipé max possible: ${anticipableMax.toFixed(2)}j`); console.log(`💳 Anticipé déjà utilisé: ${dejaPoseAnticipe.toFixed(2)}j`); console.log(`💳 Anticipé disponible: ${anticipeDisponible.toFixed(2)}j`); // ==================================== // 7️⃣ Calculer le solde TOTAL disponible // ==================================== const soldeActuel = Math.max(0, reporteN1 + acquisALaDate - dejaPoseNormal); const soldeTotal = soldeActuel + anticipeDisponible; console.log(`📊 Soldes détaillés ${typeCode}:`); console.log(` - Report N-1: ${reporteN1.toFixed(2)}j`); console.log(` - Acquis à date: ${acquisALaDate.toFixed(2)}j`); console.log(` - Déjà posé (normal): ${dejaPoseNormal.toFixed(2)}j`); console.log(` - Solde actuel: ${soldeActuel.toFixed(2)}j`); console.log(` - Anticipé disponible: ${anticipeDisponible.toFixed(2)}j`); console.log(` ✅ TOTAL DISPONIBLE: ${soldeTotal.toFixed(2)}j`); // ==================================== // 8️⃣ Vérifier la suffisance // ==================================== const suffisant = soldeTotal >= joursNecessaires; const deficit = Math.max(0, joursNecessaires - soldeTotal); verification.push({ type: typeName, joursNecessaires, reporteN1, acquisALaDate, dejaPoseNormal, dejaPoseAnticipe, soldeActuel, anticipeDisponible, soldeTotal, suffisant, deficit }); console.log(`🔍 Vérification ${typeCode}: ${joursNecessaires}j demandés vs ${soldeTotal.toFixed(2)}j disponibles → ${suffisant ? '✅ OK' : '❌ INSUFFISANT'}`); } const insuffisants = verification.filter(v => !v.suffisant); return { valide: insuffisants.length === 0, details: verification, insuffisants }; } /** * Déduit les jours d'un compteur avec gestion de l'anticipation * Ordre de déduction : N-1 → N → N Anticip */ async function deductLeaveBalanceWithAnticipation(conn, collaborateurId, typeCongeId, nombreJours, demandeCongeId, dateDebut) { const dateDebutObj = new Date(dateDebut); const currentYear = dateDebutObj.getFullYear(); const previousYear = currentYear - 1; let joursRestants = nombreJours; const deductions = []; console.log(`\n💳 === DÉDUCTION AVEC ANTICIPATION ===`); console.log(` Collaborateur: ${collaborateurId}`); console.log(` Type congé: ${typeCongeId}`); console.log(` Jours à déduire: ${nombreJours}j`); console.log(` Date début: ${dateDebut}`); // ==================================== // 1️⃣ Déduire du REPORT N-1 (CP uniquement) // ==================================== const [compteurN1] = await conn.query(` SELECT Id, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, previousYear]); if (compteurN1.length > 0) { const soldeN1 = parseFloat(compteurN1[0].Solde || 0); const aDeduireN1 = Math.min(soldeN1, joursRestants); if (aDeduireN1 > 0) { await conn.query(` UPDATE CompteurConges SET Solde = GREATEST(0, Solde - ?), SoldeReporte = GREATEST(0, SoldeReporte - ?), DerniereMiseAJour = NOW() WHERE Id = ? `, [aDeduireN1, aDeduireN1, compteurN1[0].Id]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Année N-1', ?) `, [demandeCongeId, typeCongeId, previousYear, aDeduireN1]); deductions.push({ annee: previousYear, type: 'Report N-1', joursUtilises: aDeduireN1, soldeAvant: soldeN1 }); joursRestants -= aDeduireN1; console.log(` ✓ Déduit ${aDeduireN1.toFixed(2)}j du report N-1`); } } // ==================================== // 2️⃣ Déduire du SOLDE N (acquis actuel) // ==================================== if (joursRestants > 0) { const [compteurN] = await conn.query(` SELECT Id, Solde, SoldeReporte, Total FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, typeCongeId, currentYear]); if (compteurN.length > 0) { const soldeTotal = parseFloat(compteurN[0].Solde || 0); const soldeReporte = parseFloat(compteurN[0].SoldeReporte || 0); const soldeN = Math.max(0, soldeTotal - soldeReporte); // Solde actuel sans le report const aDeduireN = Math.min(soldeN, joursRestants); if (aDeduireN > 0) { await conn.query(` UPDATE CompteurConges SET Solde = GREATEST(0, Solde - ?), DerniereMiseAJour = NOW() WHERE Id = ? `, [aDeduireN, compteurN[0].Id]); 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 N', joursUtilises: aDeduireN, soldeAvant: soldeN }); joursRestants -= aDeduireN; console.log(` ✓ Déduit ${aDeduireN.toFixed(2)}j du solde N actuel`); } } } // ==================================== // 3️⃣ Déduire de l'ANTICIPÉ N (ce qui reste à acquérir) // ==================================== if (joursRestants > 0) { console.log(` 💳 Il reste ${joursRestants.toFixed(2)}j à déduire → Utilisation de l'anticipé`); // Récupérer les infos pour calculer l'anticipé disponible const [collabInfo] = await conn.query(` SELECT DateEntree, TypeContrat, role FROM CollaborateurAD WHERE id = ? `, [collaborateurId]); const dateEntree = collabInfo[0]?.DateEntree || null; const typeContrat = collabInfo[0]?.TypeContrat || '37h'; const isApprenti = collabInfo[0]?.role === 'Apprenti'; // Déterminer le type de congé const [typeInfo] = await conn.query('SELECT Nom FROM TypeConge WHERE Id = ?', [typeCongeId]); const typeNom = typeInfo[0]?.Nom || ''; let acquisALaDate = 0; let budgetAnnuel = 0; if (typeNom === 'Congé payé') { acquisALaDate = calculerAcquisitionCP(dateDebutObj, dateEntree); budgetAnnuel = 25; } else if (typeNom === 'RTT' && !isApprenti) { const rttData = await calculerAcquisitionRTT(conn, collaborateurId, dateDebutObj); acquisALaDate = rttData.acquisition; budgetAnnuel = rttData.config.joursAnnuels; } // Calculer l'anticipé disponible const anticipableMax = Math.max(0, budgetAnnuel - acquisALaDate); // Vérifier combien a déjà été pris en anticipé const [anticipeUtilise] = await conn.query(` SELECT COALESCE(SUM(dd.JoursUtilises), 0) as total FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dd.TypeDeduction = 'N Anticip' AND dc.Statut != 'Refusée' AND dc.Id != ? `, [collaborateurId, typeCongeId, currentYear, demandeCongeId]); const dejaPrisAnticipe = parseFloat(anticipeUtilise[0]?.total || 0); const anticipeDisponible = Math.max(0, anticipableMax - dejaPrisAnticipe); console.log(` 💳 Anticipé max: ${anticipableMax.toFixed(2)}j`); console.log(` 💳 Déjà pris: ${dejaPrisAnticipe.toFixed(2)}j`); console.log(` 💳 Disponible: ${anticipeDisponible.toFixed(2)}j`); const aDeduireAnticipe = Math.min(anticipeDisponible, joursRestants); if (aDeduireAnticipe > 0) { // Enregistrer la déduction anticipée await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'N Anticip', ?) `, [demandeCongeId, typeCongeId, currentYear, aDeduireAnticipe]); // Mettre à jour SoldeAnticipe dans CompteurConges await conn.query(` UPDATE CompteurConges SET SoldeAnticipe = GREATEST(0, ? - ?), DerniereMiseAJour = NOW() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [anticipeDisponible, aDeduireAnticipe, collaborateurId, typeCongeId, currentYear]); deductions.push({ annee: currentYear, type: 'N Anticip', joursUtilises: aDeduireAnticipe, soldeAvant: anticipeDisponible }); joursRestants -= aDeduireAnticipe; console.log(` ✓ Déduit ${aDeduireAnticipe.toFixed(2)}j de l'anticipé N`); } else if (joursRestants > 0) { console.error(` ❌ Impossible de déduire ${joursRestants.toFixed(2)}j : anticipé épuisé !`); } } console.log(` ✅ Déduction terminée - Total déduit: ${(nombreJours - joursRestants).toFixed(2)}j\n`); return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions }; } app.post('/api/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) { 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' }); } // ⭐ 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)); // ⭐ Ne compter que CP, RTT ET RÉCUP dans la répartition const sommeRepartition = Repartition.reduce((sum, r) => { if (r.TypeConge === 'CP' || r.TypeConge === 'RTT' || r.TypeConge === 'Récup') { return sum + parseFloat(r.NombreJours || 0); } return sum; }, 0); console.log('Somme répartition CP+RTT+Récup:', sommeRepartition.toFixed(2)); // ⭐ VALIDATION : La somme doit correspondre au total const hasCountableLeave = Repartition.some(r => r.TypeConge === 'CP' || r.TypeConge === 'RTT' || r.TypeConge === 'Récup' ); if (hasCountableLeave && Math.abs(sommeRepartition - NombreJours) > 0.01) { console.error('❌ ERREUR : Répartition incohérente !'); 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)` }); } console.log('✅ Validation répartition OK'); // ⭐ Récup n'est PAS une demande auto-validée const isFormationOnly = Repartition.length === 1 && Repartition[0].TypeConge === 'Formation'; const statutDemande = statut || (isFormationOnly ? 'Validée' : 'En attente'); 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; let employeeId = null; if (!isAD) { const [user] = await conn.query('SELECT ID FROM Users WHERE Email = ? LIMIT 1', [Email]); if (user.length === 0) { 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 (MODE MIXTE AVEC ANTICIPATION N+1) // ======================================== if (isAD && collaborateurId && !isFormationOnly) { console.log('\n🔍 Vérification des soldes en mode mixte avec anticipation...'); console.log('Date début:', DateDebut); const [userRole] = await conn.query('SELECT role FROM CollaborateurAD WHERE id = ?', [collaborateurId]); const isApprenti = userRole.length > 0 && userRole[0].role === 'Apprenti'; // ⭐ CORRECTION : Passer la date de début pour détecter N+1 // ✅ APRÈS (avec anticipation) const checkResult = await checkLeaveBalanceWithAnticipation( conn, collaborateurId, Repartition, DateDebut ); // Adapter le format de la réponse if (!checkResult.valide) { uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); }); await conn.rollback(); conn.release(); // Construire le message d'erreur const messagesErreur = checkResult.insuffisants.map(ins => { return `${ins.type}: ${ins.joursNecessaires}j demandés mais seulement ${ins.soldeTotal.toFixed(2)}j disponibles (déficit: ${ins.deficit.toFixed(2)}j)`; }).join('\n'); return res.json({ success: false, message: `❌ Solde(s) insuffisant(s):\n${messagesErreur}`, details: checkResult.details, insuffisants: checkResult.insuffisants }); } console.log('✅ Tous les soldes sont suffisants (incluant anticipation si nécessaire)\n'); } // ======================================== // ÉTAPE 2 : CRÉER LA DEMANDE // ======================================== console.log('\n📝 Création de la demande...'); const typeIds = []; for (const rep of Repartition) { const code = rep.TypeConge; // Ne pas inclure ABS et Formation dans les typeIds principaux if (code === 'ABS' || code === 'Formation') { continue; } const name = code === 'CP' ? 'Congé payé' : code === 'RTT' ? 'RTT' : 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) typeIds.push(typeRow[0].Id); } // Si aucun type CP/RTT/Récup, 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' : '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}`); } } // ======================================== // É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' ] ); console.log(` ✓ ${name}: ${rep.NombreJours}j (${rep.PeriodeJournee || 'Journée entière'})`); } } // ======================================== // ÉTAPE 5 : Déduction des compteurs CP/RTT/RÉCUP (AVEC ANTICIPATION N+1) // ======================================== if (isAD && collaborateurId && !isFormationOnly) { console.log('\n📉 Déduction des compteurs (avec anticipation N+1)...'); for (const rep of Repartition) { if (rep.TypeConge === 'ABS' || rep.TypeConge === 'Formation') { console.log(` ⏩ ${rep.TypeConge} ignoré (pas de déduction)`); continue; } // ⭐ TRAITEMENT SPÉCIAL POUR RÉCUP if (rep.TypeConge === 'Récup') { const [recupType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Récupération']); if (recupType.length > 0) { await conn.query(` UPDATE CompteurConges SET Solde = GREATEST(0, Solde - ?), DerniereMiseAJour = NOW() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [rep.NombreJours, collaborateurId, recupType[0].Id, currentYear]); await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, 'Récup Posée', ?) `, [demandeId, recupType[0].Id, currentYear, rep.NombreJours]); console.log(` ✓ Récup: ${rep.NombreJours}j déduits`); } continue; } // ⭐ CP et RTT : AVEC ANTICIPATION N+1 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 deductLeaveBalanceWithAnticipation( conn, collaborateurId, typeRow[0].Id, rep.NombreJours, demandeId, DateDebut ); 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`); }); } } } await updateSoldeAnticipe(conn, collaborateurId); console.log('✅ Déductions terminées\n'); } // ======================================== // ÉTAPE 6 : Notifications (Formation uniquement) // ======================================== 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}`; if (isFormationOnly && isAD && collaborateurId) { await conn.query( `INSERT INTO Notifications (CollaborateurADId, Type, Titre, Message, DemandeCongeId, DateCreation, Lu) VALUES (?, ?, ?, ?, ?, NOW(), 0)`, [ collaborateurId, 'Success', '✅ Formation validée automatiquement', `Votre période de formation ${datesPeriode} a été validée automatiquement.`, demandeId ] ); console.log('\n📬 Notification formation créée'); } // ======================================== // ÉTAPE 7 : 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); } await conn.commit(); console.log('\n🎉 Transaction validée\n'); // ======================================== // ÉTAPE 8 : Notifier les clients SSE // ======================================== if (isFormationOnly && isAD && collaborateurId) { notifyCollabClients({ type: 'demande-validated', demandeId: parseInt(demandeId), statut: 'Validée', timestamp: new Date().toISOString() }, collaborateurId); } // ======================================== // ENVOI DES EMAILS // ======================================== const accessToken = await getGraphToken(); if (accessToken) { const fromEmail = 'gtanoreply@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 === 'Récup' ? 'Récupération' : rep.TypeConge; return `${typeNom}: ${rep.NombreJours}j`; }).join(' | '); if (isFormationOnly) { // Email formation const subjectCollab = '✅ Formation enregistrée et validée'; const bodyCollab = `
Bonjour ${Nom},
Votre période de formation a été automatiquement validée.
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 ${manager.Prenom},
${userName} a modifié sa demande de congé :
| Type : | ${getLeaveTypeName(leaveType)} |
| Dates : | du ${formatDateFR(startDate)} au ${formatDateFR(endDate)} |
| Durée : | ${businessDays} jour(s) |
✅ Les compteurs ont été automatiquement recalculés (${restorationStats.count} opération(s)).
Merci de valider ou refuser cette demande dans l'application.
📧 Cet email est envoyé automatiquement, merci de ne pas y répondre.
Bonjour ${userName.split(' ')[0]},
Votre demande de congé a bien été modifiée :
| Type : | ${getLeaveTypeName(leaveType)} |
| Dates : | du ${formatDateFR(startDate)} au ${formatDateFR(endDate)} |
| Durée : | ${businessDays} jour(s) |
Elle est maintenant en attente de validation.
📧 Cet email est envoyé automatiquement, merci de ne pas y répondre.
Bonjour ${collabName},
Votre demande de congé a bien été annulée.
| Période : | ${datesPeriode} |
| Durée totale : | ${request.NombreJours} jour(s) |
| Répartition : | |
✅ Vos compteurs ont été restaurés
${restorationStats.count} opération(s) de remboursement effectuée(s).
📧 Cet email est envoyé automatiquement, merci de ne pas y répondre.
Bonjour ${managerName},
${collabName} a annulé ${isValidated ? 'son congé validé' : 'sa demande de congé'}.
| Statut initial : | ${requestStatus} |
| Période : | ${datesPeriode} |
| Durée totale : | ${request.NombreJours} jour(s) |
| Répartition : | |
✅ Les compteurs ont été automatiquement restaurés (${restorationStats.count} opération(s)).
📧 Cet email est envoyé automatiquement, merci de ne pas y répondre.