// ============================================================================ // 🚀 GTA - GESTION DES TEMPS ET ABSENCES // ============================================================================ // Serveur Backend Node.js avec SQL Server // Port: 3004 // Base de données: GTA (SQL Server) // ============================================================================ import express from 'express'; import sql from 'mssql'; 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 = 3004; 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-dev.ensup-adm.net'], credentials: true })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); const dbConfig = { server: '192.168.0.3', // ⭐ 'server' au lieu de 'host' user: 'gta_app', password: 'GTA2025!Secure', database: 'GTA', port: 1433, // ⭐ Nombre, pas string options: { encrypt: true, // ⭐ Pas de SSL en réseau local trustServerCertificate: true, enableArithAbort: true, connectTimeout: 60000, requestTimeout: 60000 }, pool: { max: 10, min: 0, idleTimeoutMillis: 30000 } }; function nowFR() { const d = new Date(); d.setHours(d.getHours() + 2); return d.toISOString().slice(0, 19).replace('T', ' '); } 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(); } /** * Récupère le dernier arrêté validé/clôturé */ async function getDernierArrete(conn) { const [arretes] = await conn.query(` SELECT TOP 1 * FROM ArreteComptable WHERE Statut IN ('Validé', 'Clôturé') ORDER BY DateArrete DESC `); 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; } 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 TOP 1 * FROM SoldesFiges WHERE ArreteId = ? AND CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [dernierArrete.Id, collaborateurId, typeCongeId, annee]); return soldes.length > 0 ? soldes[0] : null; } 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 TOP 1 Id FROM TypeConge WHERE Nom = ?', [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 = new sql.ConnectionPool(dbConfig); // ⭐ CONNEXION AU DÉMARRAGE pool.connect() .then(() => { console.log('✅ =========================================='); console.log(' CONNECTÉ À SQL SERVER'); console.log(` Base: ${dbConfig.database}@${dbConfig.host}`); console.log('=========================================='); }) .catch(err => { console.error('❌ =========================================='); console.error(' ERREUR CONNEXION SQL SERVER'); console.error(' Message:', err.message); console.error('=========================================='); }); // ======================================== // 🔧 MONKEY-PATCH MSSQL POUR SUPPORTER "?" // ======================================== // MONKEY-PATCH MSSQL POUR SUPPORTER ? ET LIMIT const originalRequest = sql.Request; sql.Request = function (...args) { const request = new originalRequest(...args); const originalQuery = request.query.bind(request); request.query = async function (queryText, ...queryArgs) { let convertedQuery = queryText; // 🔥 AJOUTÉ ICI try { if (!queryArgs || queryArgs.length === 0 || !Array.isArray(queryArgs[0])) { return await originalQuery(queryText); } const params = queryArgs[0]; params.forEach((value, index) => { request.input(`param${index}`, value); }); let paramIndex = 0; convertedQuery = convertedQuery.replace(/\?/g, () => `@param${paramIndex++}`); // CONVERSION GETDATE() → GETDATE() convertedQuery = convertedQuery.replace(/NOW\(\)/gi, 'GETDATE()'); // CONVERSION LEAST() → CASE WHEN while (convertedQuery.match(/LEAST\s*\(/i)) { convertedQuery = convertedQuery.replace( /LEAST\s*\(\s*([^,]+?)\s*,\s*([^)]+?)\s*\)/i, '(CASE WHEN $1 < $2 THEN $1 ELSE $2 END)' ); } // LIMIT → TOP conversion convertedQuery = convertedQuery.replace( /LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi, 'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY' ); const simpleLimitMatch = convertedQuery.match(/LIMIT\s+(\d+)(?!\s+OFFSET)/i); if (simpleLimitMatch) { const limitValue = simpleLimitMatch[1]; convertedQuery = convertedQuery.replace(/LIMIT\s+\d+(?!\s+OFFSET)/i, ''); convertedQuery = convertedQuery.replace( /SELECT(\s+DISTINCT)?/i, `SELECT$1 TOP ${limitValue}` ); } return await originalQuery(convertedQuery); } catch (error) { console.error('❌ Erreur query SQL:', error.message); console.error('Query originale:', queryText.substring(0, 300)); console.error('Query convertie:', convertedQuery?.substring(0, 300)); throw error; } }; return request; }; console.log('✅ Driver mssql patché: support des ?, LIMIT, GETDATE() et LEAST() activé'); // ======================================== // ⭐ WRAPPER POUR COMPATIBILITÉ MYSQL // ======================================== /** * Simule pool.getConnection() de MySQL * Retourne un objet avec query(), beginTransaction(), commit(), rollback(), release() */ // ⭐ WRAPPER POUR COMPATIBILITÉ MYSQL // ⭐ WRAPPER POUR COMPATIBILITÉ MYSQL pool.getConnection = async function () { if (!pool.connected) { await pool.connect(); } let transaction = null; return { query: async function (queryText, params = []) { // ⭐ FIX: Déclarer parameterizedQuery EN DEHORS du try block let parameterizedQuery = queryText; try { const request = transaction ? new sql.Request(transaction) : pool.request(); // Ajouter les paramètres (@param0, @param1, ...) params.forEach((value, index) => { request.input(`param${index}`, value); }); // Remplacer ? par @param0, @param1, etc. let paramIndex = 0; parameterizedQuery = parameterizedQuery.replace(/\?/g, () => `@param${paramIndex++}`); // ⭐⭐⭐ CONVERSION LIMIT → TOP (VERSION CORRIGÉE) ⭐⭐⭐ // 1. Gérer LIMIT avec OFFSET parameterizedQuery = parameterizedQuery.replace( /LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi, 'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY' ); // 2. Marquer tous les LIMIT (même sans espace avant) parameterizedQuery = parameterizedQuery.replace( /\s*LIMIT\s+(\d+)(?!\s+OFFSET)/gi, ' __LIMIT__$1__' ); // 3. Injecter TOP après SELECT let limitValue = null; const limitMatch = parameterizedQuery.match(/__LIMIT__(\d+)__/); if (limitMatch) { limitValue = limitMatch[1]; parameterizedQuery = parameterizedQuery.replace(/__LIMIT__\d+__/g, ''); } if (limitValue) { parameterizedQuery = parameterizedQuery.replace( /(SELECT\s+(?:DISTINCT\s+)?)/i, `$1TOP ${limitValue} ` ); } // ⭐ FIX: Convertir TRUE/FALSE en 1/0 pour SQL Server parameterizedQuery = parameterizedQuery.replace(/\bTRUE\b/gi, '1'); parameterizedQuery = parameterizedQuery.replace(/\bFALSE\b/gi, '0'); const result = await request.query(parameterizedQuery); return [result.recordset || []]; } catch (error) { console.error('❌ Erreur query SQL:', error.message); console.error('Query originale:', queryText); console.error('Query convertie:', parameterizedQuery?.substring(0, 500)); throw error; } }, beginTransaction: async function () { transaction = new sql.Transaction(pool); await transaction.begin(); }, commit: async function () { if (transaction) { await transaction.commit(); transaction = null; } }, rollback: async function () { if (transaction) { await transaction.rollback(); transaction = null; } }, release: function () { console.log('🔄 Connection released (no-op avec mssql)'); } }; }; // ⭐ pool.query() direct (sans transaction) // ⭐ pool.query() direct (sans transaction) pool.query = async function (queryText, params = []) { if (!pool.connected) { await pool.connect(); } // ⭐ FIX: Déclarer parameterizedQuery EN DEHORS du try/catch implicite let parameterizedQuery = queryText; const request = pool.request(); params.forEach((value, index) => { request.input(`param${index}`, value); }); let paramIndex = 0; parameterizedQuery = parameterizedQuery.replace(/\?/g, () => `@param${paramIndex++}`); // ⭐⭐⭐ CONVERSION LIMIT → TOP (VERSION CORRIGÉE) ⭐⭐⭐ // 1. Gérer LIMIT avec OFFSET parameterizedQuery = parameterizedQuery.replace( /LIMIT\s+(\d+)\s+OFFSET\s+(\d+)/gi, 'OFFSET $2 ROWS FETCH NEXT $1 ROWS ONLY' ); // 2. Marquer tous les LIMIT (même sans espace avant) parameterizedQuery = parameterizedQuery.replace( /\s*LIMIT\s+(\d+)(?!\s+OFFSET)/gi, ' __LIMIT__$1__' ); // 3. Injecter TOP après SELECT let limitValue = null; const limitMatch = parameterizedQuery.match(/__LIMIT__(\d+)__/); if (limitMatch) { limitValue = limitMatch[1]; parameterizedQuery = parameterizedQuery.replace(/__LIMIT__\d+__/g, ''); } if (limitValue) { parameterizedQuery = parameterizedQuery.replace( /(SELECT\s+(?:DISTINCT\s+)?)/i, `$1TOP ${limitValue} ` ); } // ⭐ FIX: Convertir TRUE/FALSE en 1/0 pour SQL Server parameterizedQuery = parameterizedQuery.replace(/\bTRUE\b/gi, '1'); parameterizedQuery = parameterizedQuery.replace(/\bFALSE\b/gi, '0'); const result = await request.query(parameterizedQuery); return [result.recordset || []]; }; 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.GETDATE() + path.extname(file.originalname)); } }); const upload = multer({ storage }); const medicalStorage = multer.diskStorage({ destination: './uploads/medical/', filename: (req, file, cb) => { const uniqueSuffix = Date.GETDATE() + '-' + 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ÇU'); 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('Nouveau Total:', data.nouveauTotal + 'j'); console.log('Nouveau Solde:', data.nouveauSolde + 'j'); console.log('Source:', data.source); // ✅ PAS D'UPDATE EN BASE (car même DB partagée) // ✅ UNIQUEMENT NOTIFICATION SSE pour rafraîchir l'interface 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, 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 }); } }); 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); } // ======================================== // CALCUL CP INTELLIGENT (MODE AUTO) // ======================================== /** * Calcule l'acquisition CP avec détection automatique du mode */ function calculerAcquisitionCP_Smart(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️⃣ Obtenir le mode de calcul const modeInfo = getModeCalcul('CP', dateEntree); const dateDebutAcquis = modeInfo.dateDebut; console.log(` 📅 ${modeInfo.description}`); // 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); } // ======================================== // CALCUL RTT INTELLIGENT (MODE AUTO) // ======================================== /** * Calcule l'acquisition RTT avec détection automatique du mode */ async function calculerAcquisitionRTT_Smart(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, mode: 'APPRENTI' }; } // 3️⃣ Récupérer la configuration RTT (avec règles 2025/2026) const config = await getConfigurationRTT(conn, annee, typeContrat); // 4️⃣ Obtenir le mode de calcul const modeInfo = getModeCalcul('RTT', dateEntree); const dateDebutAcquis = modeInfo.dateDebut; console.log(` 📅 ${modeInfo.description}`); // 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, mode: modeInfo.mode }; } // ======================================== // FONCTION DE DÉTECTION AUTOMATIQUE DU MODE // ======================================== /** * Détermine automatiquement si on doit utiliser le mode transition * en comparant la date actuelle avec le début de l'exercice */ function isInExerciceActuel(typeConge) { const today = new Date(); today.setHours(0, 0, 0, 0); const currentYear = today.getFullYear(); const currentMonth = today.getMonth() + 1; // 1-12 if (typeConge === 'CP') { // Exercice CP : 01/06/N → 31/05/N+1 // Déterminer le début de l'exercice ACTUEL let debutExercice; if (currentMonth >= 6) { // On est entre juin et décembre → exercice commence le 01/06 de cette année debutExercice = new Date(currentYear, 5, 1); // 01/06/N } else { // On est entre janvier et mai → exercice a commencé le 01/06 de l'année dernière debutExercice = new Date(currentYear - 1, 5, 1); // 01/06/N-1 } // ⭐ Si aujourd'hui est le PREMIER JOUR du nouvel exercice ou après → MODE NORMAL // ⭐ Si on est encore dans l'exercice commencé avant → MODE TRANSITION const finExercice = new Date(debutExercice); finExercice.setFullYear(finExercice.getFullYear() + 1); finExercice.setMonth(4, 31); // 31/05/N+1 // Si on vient de passer le 01/06, c'est le nouvel exercice → mode NORMAL const nouveauExercice = new Date(currentYear, 5, 1); return today < nouveauExercice; // TRUE = mode transition (avant le 01/06) } else if (typeConge === 'RTT') { // Année RTT : 01/01/N → 31/12/N const debutAnnee = new Date(currentYear, 0, 1); // 01/01/N // Si on vient de passer le 01/01, c'est la nouvelle année → mode NORMAL return today < debutAnnee; // TRUE = mode transition (avant le 01/01) } return false; } /** * Version améliorée : retourne le mode ET la date de début à utiliser */ /** * Détermine le mode de calcul et la date de début selon le contexte * @param {string} typeConge - 'CP' ou 'RTT' * @param {Date|null} dateEntree - Date d'entrée du collaborateur * @returns {Object} { mode, dateDebut, description } */ function getModeCalcul(typeConge, dateEntree = null) { const today = new Date(); today.setHours(0, 0, 0, 0); const currentYear = today.getFullYear(); const currentMonth = today.getMonth() + 1; if (typeConge === 'CP') { // Déterminer le début de l'exercice ACTUEL let debutExerciceActuel; if (currentMonth >= 6) { debutExerciceActuel = new Date(currentYear, 5, 1); // 01/06/N } else { debutExerciceActuel = new Date(currentYear - 1, 5, 1); // 01/06/N-1 } debutExerciceActuel.setHours(0, 0, 0, 0); // ⭐ RÈGLE DE BASCULE : // On bascule en mode NORMAL uniquement si : // 1. On est dans un NOUVEL exercice (après le prochain 01/06) // 2. ET la personne est arrivée APRÈS le début de ce nouvel exercice // Calculer le début du PROCHAIN exercice const prochainExercice = new Date(currentYear, 5, 1); // 01/06/N if (currentMonth < 6) { // Si on est avant juin, le prochain exercice est cette année prochainExercice.setFullYear(currentYear); } else { // Si on est après juin, le prochain exercice est l'année prochaine prochainExercice.setFullYear(currentYear + 1); } // ⭐ BASCULE : on passe en mode NORMAL seulement si aujourd'hui >= prochain exercice if (today >= prochainExercice && dateEntree) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); // Si la personne est arrivée APRÈS le début du nouvel exercice if (entree >= prochainExercice) { return { mode: 'NORMAL', dateDebut: entree, description: `CP avec DateEntree (${entree.toLocaleDateString('fr-FR')})` }; } } // ⭐ MODE TRANSITION : calcul depuis début de l'exercice actuel (SANS DateEntree) return { mode: 'TRANSITION', dateDebut: debutExerciceActuel, description: `CP mode transition (depuis ${debutExerciceActuel.toLocaleDateString('fr-FR')})` }; } else if (typeConge === 'RTT') { const debutAnneeActuelle = new Date(currentYear, 0, 1); // 01/01/N debutAnneeActuelle.setHours(0, 0, 0, 0); // ⭐ RÈGLE DE BASCULE : // On bascule en mode NORMAL uniquement si : // 1. On est dans une NOUVELLE année (après le prochain 01/01) // 2. ET la personne est arrivée APRÈS le début de cette nouvelle année // Calculer le début de la PROCHAINE année const prochaineAnnee = new Date(currentYear + 1, 0, 1); // 01/01/N+1 // ⭐ BASCULE : on passe en mode NORMAL seulement si aujourd'hui >= prochaine année if (today >= prochaineAnnee && dateEntree) { const entree = new Date(dateEntree); entree.setHours(0, 0, 0, 0); // Si la personne est arrivée APRÈS le début de la nouvelle année if (entree >= prochaineAnnee) { return { mode: 'NORMAL', dateDebut: entree, description: `RTT avec DateEntree (${entree.toLocaleDateString('fr-FR')})` }; } } // ⭐ MODE TRANSITION : calcul depuis début de l'année actuelle (SANS DateEntree) return { mode: 'TRANSITION', dateDebut: debutAnneeActuelle, description: `RTT mode transition (depuis ${debutAnneeActuelle.toLocaleDateString('fr-FR')})` }; } return null; } // ======================================== // TÂCHES CRON // ======================================== cron.schedule('1 0 1 1 *', async () => { console.log('\n🎉 ===== RÉINITIALISATION RTT - 1ER JANVIER ====='); const conn = await pool.getConnection(); try { await conn.beginTransaction(); const today = new Date(); const nouvelleAnnee = today.getFullYear(); const ancienneAnnee = nouvelleAnnee - 1; console.log(` 📅 Passage de ${ancienneAnnee} à ${nouvelleAnnee}`); const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']); if (rttType.length === 0) { throw new Error('Type de congé RTT introuvable'); } const rttTypeId = rttType[0].Id; const [collaborateurs] = await conn.query(` SELECT id, prenom, nom, TypeContrat, role FROM CollaborateurAD WHERE (Actif = 1 OR Actif IS NULL) AND (role IS NULL OR role != 'Apprenti') `); console.log(` 👥 ${collaborateurs.length} collaborateurs à traiter`); let compteursReinitialises = 0; for (const collab of collaborateurs) { const collaborateurId = collab.id; const [ancienCompteur] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, ancienneAnnee]); if (ancienCompteur.length > 0) { const soldeAncien = parseFloat(ancienCompteur[0].Solde || 0); console.log(` 👤 ${collab.prenom} ${collab.nom}: ${soldeAncien.toFixed(2)}j RTT perdus`); await conn.query(` UPDATE CompteurConges SET DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, ancienneAnnee]); } const [nouveauCompteur] = await conn.query(` SELECT id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, nouvelleAnnee]); if (nouveauCompteur.length === 0) { await conn.query(` INSERT INTO CompteurConges ( CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour ) VALUES (?, ?, ?, 0, 0, 0, GETDATE()) `, [collaborateurId, rttTypeId, nouvelleAnnee]); console.log(` ✅ Compteur RTT ${nouvelleAnnee} créé à 0`); } else { await conn.query(` UPDATE CompteurConges SET Total = 0, Solde = 0, SoldeReporte = 0, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, nouvelleAnnee]); console.log(` ✅ Compteur RTT ${nouvelleAnnee} réinitialisé à 0`); } compteursReinitialises++; } await conn.commit(); console.log(`\n✅ Réinitialisation RTT terminée : ${compteursReinitialises} compteurs`); } catch (error) { await conn.rollback(); console.error('❌ Erreur réinitialisation RTT:', error); } finally { conn.release(); } }, { timezone: "Europe/Paris" }); // ============================================================================ // 📅 CRON REPORT CP - 31 mai 23h59 // ============================================================================ 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 ARRÊTÉS MENSUELS // ============================================================================ cron.schedule('55 23 28-31 * *', async () => { const today = new Date(); const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0); 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; const dateArrete = today.toISOString().split('T')[0]; 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; } const [result] = await conn.query(` INSERT INTO ArreteComptable (DateArrete, Annee, Mois, Libelle, Description, Statut, DateCreation) VALUES (?, ?, ?, ?, ?, 'En cours', GETDATE()) `, [ 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}`); await conn.query('CALL sp_creer_snapshot_arrete(?)', [arreteId]); console.log(`📸 [CRON] Snapshot créé pour l'arrêté ${arreteId}`); 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(); } } }); // ============================================================================ // 📧 CRON MAILS COMPTE-RENDU // ============================================================================ 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(); }); // ============================================================================ // 🔔 CRON RELANCES // ============================================================================ 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(); }); // ============================================================================ // 🚀 RATTRAPAGE IMMÉDIAT - 5 minutes après démarrage // ============================================================================ setTimeout(() => { console.log('🚀 ===== EXÉCUTION IMMÉDIATE - RATTRAPAGE DEPUIS LE 1ER DU MOIS ====='); updateMonthlyCounters(true); // true = mode rattrapage }, 5 * 60 * 1000); console.log('⏰ CRON quotidien programmé : 00h00 Europe/Paris'); console.log('⏰ CRON réinitialisation RTT programmé : 1er janvier à 00h01'); console.log(`⏰ CRON immédiat programmé dans 5 minutes`); // ⭐ 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 = GETDATE() 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 = GETDATE() WHERE Id = ?`, [soldeAReporter, soldeAReporter, nextYearCounter[0].Id] ); } else { await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, 0, ?, ?, GETDATE())`, [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 = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END, Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END 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 = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END 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 }; } // ============================================================================ // 📅 FONCTION UTILITAIRE - Obtenir le nombre de jours du mois // ============================================================================ function getJoursDuMois(date) { const annee = date.getFullYear(); const mois = date.getMonth(); // 0-11 // Le jour 0 du mois suivant = dernier jour du mois actuel const dernierJour = new Date(annee, mois + 1, 0); return dernierJour.getDate(); } // ============================================================================ // 🔄 FONCTION DE MISE À JOUR DES COMPTEURS (CORRIGÉE - DÉCRÉMENTE) // ============================================================================ async function updateMonthlyCounters(rattrapage = false) { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const today = new Date(); const currentYear = today.getFullYear(); const currentMonth = today.getMonth(); const jourActuel = today.getDate(); // ⭐ Obtenir le nombre de jours du mois actuel const joursDuMois = getJoursDuMois(today); console.log(`\n🔄 === MISE À JOUR ${rattrapage ? 'RATTRAPAGE' : 'QUOTIDIENNE'} COMPTEURS - ${today.toLocaleDateString('fr-FR')} ===`); console.log(` 📅 Mois actuel : ${joursDuMois} jours`); // ⭐ RATTRAPAGE : Calculer les jours manqués depuis le 1er du mois let joursARattraper = 1; // Par défaut : incrément d'1 jour if (rattrapage) { joursARattraper = jourActuel; // Du 1er au jour actuel console.log(` 📅 Rattrapage depuis le 1er du mois : ${joursARattraper} jours`); } // Récupérer tous les collaborateurs actifs const [collaborateurs] = await conn.query(` SELECT id, prenom, nom, DateEntree, TypeContrat, role FROM CollaborateurAD WHERE (Actif = 1 OR Actif IS NULL) `); const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['Congé payé']); const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']); let compteursMisAJour = 0; for (const collab of collaborateurs) { const collaborateurId = collab.id; const dateEntree = collab.DateEntree; const typeContrat = collab.TypeContrat || '37h'; const role = collab.role; console.log(`\n 👤 ${collab.prenom} ${collab.nom}`); // ==================================== // 📅 CP - Incrément avec prorata mensuel // ==================================== if (cpType.length > 0) { const modeCP = getModeCalcul('CP', dateEntree); // ⭐ Calcul acquisition mensuelle const acquisitionMensuelleCP = 25 / 12; // 2.0833j/mois // ⭐ Calcul acquisition quotidienne selon le nombre de jours du mois const incrementJournalierCP = acquisitionMensuelleCP / joursDuMois; const incrementTotal = incrementJournalierCP * joursARattraper; console.log(` CP: ${incrementJournalierCP.toFixed(6)}j/jour (${acquisitionMensuelleCP.toFixed(4)}j/${joursDuMois}j)`); // Vérifier si compteur existe const [compteurCP] = await conn.query(` SELECT Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, cpType[0].Id, currentYear]); if (compteurCP.length > 0) { const totalAvant = parseFloat(compteurCP[0].Total || 0); const soldeAvant = parseFloat(compteurCP[0].Solde || 0); // ⭐ INCRÉMENTER (ne pas écraser) await conn.query(` UPDATE CompteurConges SET Total = LEAST(Total + ?, 25), Solde = LEAST(Solde + ?, 25 + COALESCE(SoldeReporte, 0)), DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [incrementTotal, incrementTotal, collaborateurId, cpType[0].Id, currentYear]); console.log(` CP: ${totalAvant.toFixed(2)}j → ${(totalAvant + incrementTotal).toFixed(2)}j (+${incrementTotal.toFixed(4)}j) [${modeCP.mode}]`); compteursMisAJour++; } else { // Créer compteur initial avec Smart const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree); // Générer l'ID manuellement const compteurCPId = await getNextId(conn, 'CompteurConges'); await conn.query(` INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, ?, 0, GETDATE()) `, [compteurCPId, collaborateurId, cpType[0].Id, currentYear, acquisCP, acquisCP]); console.log(` ✅ CP créé avec ID ${compteurCPId}: ${acquisCP.toFixed(2)}j [${modeCP.mode}]`); compteursMisAJour++; } } // ==================================== // 📅 RTT - Incrément avec prorata mensuel // ==================================== if (rttType.length > 0 && role !== 'Apprenti') { const modeRTT = getModeCalcul('RTT', dateEntree); const rttConfig = await getConfigurationRTT(conn, currentYear, typeContrat); // ⭐ Calcul acquisition mensuelle const acquisitionMensuelleRTT = rttConfig.joursAnnuels / 12; // ⭐ Calcul acquisition quotidienne selon le nombre de jours du mois const incrementJournalierRTT = acquisitionMensuelleRTT / joursDuMois; const incrementTotal = incrementJournalierRTT * joursARattraper; console.log(` RTT: ${incrementJournalierRTT.toFixed(6)}j/jour (${acquisitionMensuelleRTT.toFixed(4)}j/${joursDuMois}j)`); // Vérifier si compteur existe const [compteurRTT] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttType[0].Id, currentYear]); if (compteurRTT.length > 0) { const totalAvant = parseFloat(compteurRTT[0].Total || 0); const soldeAvant = parseFloat(compteurRTT[0].Solde || 0); // ⭐ INCRÉMENTER (ne pas écraser) await conn.query(` UPDATE CompteurConges SET Total = LEAST(Total + ?, ?), Solde = LEAST(Solde + ?, ?), DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [incrementTotal, rttConfig.joursAnnuels, incrementTotal, rttConfig.joursAnnuels, collaborateurId, rttType[0].Id, currentYear]); console.log(` RTT: ${totalAvant.toFixed(2)}j → ${(totalAvant + incrementTotal).toFixed(2)}j (+${incrementTotal.toFixed(4)}j) [${modeRTT.mode}]`); compteursMisAJour++; } else { // Créer compteur initial avec Smart const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, today); // ✅ CODE CORRIGÉ - Génération manuelle de l'ID console.log(` 🆕 RTT créé pour ${collaborateurId}`); // Générer l'ID manuellement const compteurRTTId = await getNextId(conn, 'CompteurConges'); await conn.query(` INSERT INTO CompteurConges (Id, CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, GETDATE()) `, [compteurRTTId, collaborateurId, rttType[0].Id, currentYear, rttData.acquisition, rttData.acquisition]); console.log(` ✅ RTT créé avec ID ${compteurRTTId}: ${rttData.acquisition.toFixed(2)}j [${modeRTT.mode}]`); compteursMisAJour++; } } } await conn.commit(); console.log(`\n✅ Mise à jour terminée : ${compteursMisAJour} compteurs pour ${collaborateurs.length} collaborateurs`); } catch (error) { await conn.rollback(); console.error('❌ Erreur mise à jour compteurs:', error); throw error; } finally { conn.release(); } } // ============================================================================ // ⏰ PLANIFICATION DES CRONS // ============================================================================ // ❌ DÉSACTIVER le rattrapage immédiat (commenté) // ✅ EXÉCUTION QUOTIDIENNE à 00h01 (à partir de demain - DÉCRÉMENTE) cron.schedule('1 0 * * *', () => { console.log('🕐 ===== EXÉCUTION QUOTIDIENNE AUTOMATIQUE - 00h01 ====='); updateMonthlyCounters(false); // false = incrément normal d'1 jour }, { timezone: "Europe/Paris" }); console.log('⏰ CRON quotidien programmé : 00h01 Europe/Paris (à partir de demain)'); console.log('⏰ CRON réinitialisation RTT programmé : 1er janvier à 00h01'); console.log('⚠️ Rattrapage immédiat DÉSACTIVÉ'); // ============================================================================ // 🎉 CRON - RÉINITIALISATION RTT AU 1ER JANVIER // ============================================================================ cron.schedule('1 0 1 1 *', async () => { console.log('\n🎉 ===== RÉINITIALISATION RTT - 1ER JANVIER ====='); const conn = await pool.getConnection(); try { await conn.beginTransaction(); const today = new Date(); const nouvelleAnnee = today.getFullYear(); const ancienneAnnee = nouvelleAnnee - 1; console.log(` 📅 Passage de ${ancienneAnnee} à ${nouvelleAnnee}`); // Récupérer le TypeCongeId pour RTT const [rttType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ?', ['RTT']); if (rttType.length === 0) { throw new Error('Type de congé RTT introuvable'); } const rttTypeId = rttType[0].Id; // Récupérer tous les collaborateurs actifs (sauf apprentis) const [collaborateurs] = await conn.query(` SELECT id, prenom, nom, TypeContrat, role FROM CollaborateurAD WHERE (Actif = 1 OR Actif IS NULL) AND (role IS NULL OR role != 'Apprenti') `); console.log(` 👥 ${collaborateurs.length} collaborateurs à traiter`); let compteursReinitialises = 0; for (const collab of collaborateurs) { const collaborateurId = collab.id; // 1️⃣ Archiver/Marquer l'ancien compteur RTT N-1 const [ancienCompteur] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, ancienneAnnee]); if (ancienCompteur.length > 0) { const soldeAncien = parseFloat(ancienCompteur[0].Solde || 0); console.log(` 👤 ${collab.prenom} ${collab.nom}: ${soldeAncien.toFixed(2)}j RTT perdus`); // Marquer l'ancien compteur comme "clos" await conn.query(` UPDATE CompteurConges SET DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, ancienneAnnee]); } // 2️⃣ Créer ou réinitialiser le compteur RTT pour la nouvelle année const [nouveauCompteur] = await conn.query(` SELECT id FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, nouvelleAnnee]); if (nouveauCompteur.length === 0) { // Créer le compteur à 0 await conn.query(` INSERT INTO CompteurConges ( CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour ) VALUES (?, ?, ?, 0, 0, 0, GETDATE()) `, [collaborateurId, rttTypeId, nouvelleAnnee]); console.log(` ✅ Compteur RTT ${nouvelleAnnee} créé à 0`); } else { // Le compteur existe déjà, le remettre à 0 await conn.query(` UPDATE CompteurConges SET Total = 0, Solde = 0, SoldeReporte = 0, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, rttTypeId, nouvelleAnnee]); console.log(` ✅ Compteur RTT ${nouvelleAnnee} réinitialisé à 0`); } compteursReinitialises++; } await conn.commit(); console.log(`\n✅ Réinitialisation RTT terminée : ${compteursReinitialises} compteurs`); } catch (error) { await conn.rollback(); console.error('❌ Erreur réinitialisation RTT:', error); } finally { conn.release(); } }, { timezone: "Europe/Paris" }); console.log('⏰ CRON réinitialisation RTT programmé : 1er janvier à 00h01'); // ======================================== // MISE À JOUR DE updateMonthlyCounters // ======================================== async function updateMonthlyCounters_Smart(conn, collaborateurId, dateReference = null) { const today = dateReference ? new Date(dateReference) : new Date(); 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 pour collaborateur ${collaborateurId} ===`); console.log(` Date référence: ${today.toLocaleDateString('fr-FR')}`); // ====================================== // CP (Congés Payés) // ====================================== const acquisitionCP = calculerAcquisitionCP_Smart(today, dateEntree); console.log(` CP - Acquisition: ${acquisitionCP.toFixed(2)}j`); const [cpType] = await conn.query('SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1', ['Congé payé']); if (cpType.length > 0) { const cpTypeId = cpType[0].Id; const [existingCP] = await conn.query(` SELECT Id, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [collaborateurId, cpTypeId, currentYear]); if (existingCP.length > 0) { const ancienTotal = parseFloat(existingCP[0].Total || 0); const ancienSolde = parseFloat(existingCP[0].Solde || 0); const soldeReporte = parseFloat(existingCP[0].SoldeReporte || 0); const incrementAcquis = acquisitionCP - ancienTotal; if (incrementAcquis > 0) { console.log(` CP - Nouveaux jours: +${incrementAcquis.toFixed(2)}j`); // Gérer le remboursement d'anticipé (logique existante) 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, cpTypeId, currentYear]); const anticipePris = parseFloat(anticipeUtilise[0]?.totalAnticipe || 0); if (anticipePris > 0) { const aRembourser = Math.min(incrementAcquis, anticipePris); console.log(` 💳 CP - Remboursement anticipé: ${aRembourser.toFixed(2)}j`); // [Logique de remboursement complète - identique à avant] 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); await conn.query(` UPDATE DeductionDetails SET JoursUtilises = CASE WHEN (JoursUtilises - ?) < 0 THEN 0 ELSE (JoursUtilises - ?) END WHERE Id = ? `, [aDeduiteDeCetteDeduction, deduction.Id]); 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) { 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, cpTypeId, currentYear, aDeduiteDeCetteDeduction]); } resteARembourser -= aDeduiteDeCetteDeduction; } // 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]); } } // Recalculer le solde 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); await conn.query(` UPDATE CompteurConges SET Total = ?, Solde = ?, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [acquisitionCP, nouveauSolde, existingCP[0].Id]); updates.push({ type: 'CP', acquisitionCumulee: acquisitionCP, increment: incrementAcquis, nouveauSolde: nouveauSolde }); } else { // Créer le compteur await conn.query(` INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, DerniereMiseAJour) VALUES (?, ?, ?, ?, ?, 0, GETDATE()) `, [collaborateurId, cpTypeId, currentYear, acquisitionCP, acquisitionCP]); updates.push({ type: 'CP', action: 'created', acquisitionCumulee: acquisitionCP }); } } // ====================================== // RTT (identique avec Smart) // ====================================== 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 = CASE WHEN (JoursUtilises - ?) < 0 THEN 0 ELSE (JoursUtilises - ?) END 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 = GETDATE() 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, GETDATE()) `, [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 || 'Prénom', nom: user.nom || 'Nom', email: user.email, role: user.role || 'Collaborateur', service: user.service || 'Non défini', societeId: user.SocieteId, societeNom: user.societe_nom || 'Non défini', typeContrat: user.TypeContrat || '37h', description: user.description || null, dateEntree: user.DateEntree || null, campusId: user.CampusId || null } }); } 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 societeNom, 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) LIMIT 1 `; 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 ==='); console.log('User:', user.prenom, user.nom, '(ID:', userId, ')'); console.log('Date référence:', today.toLocaleDateString('fr-FR')); // ═══════════════════════════════════════════════════════════ // 1️⃣ CALCUL AVEC LES FORMULES INTELLIGENTES // ═══════════════════════════════════════════════════════════ // CP N const acquisCP = calculerAcquisitionCP_Smart(today, dateEntree); console.log('🧮 CP calculé:', acquisCP.toFixed(2) + 'j'); // RTT N let acquisRTT = 0; let rttTypeId = null; const [rttType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['RTT']); if (rttType.length > 0 && user.role !== 'Apprenti') { rttTypeId = rttType[0].Id; const rttData = await calculerAcquisitionRTT_Smart(conn, userId, today); acquisRTT = rttData.acquisition; console.log('🧮 RTT calculé:', acquisRTT.toFixed(2) + 'j'); } // ═══════════════════════════════════════════════════════════ // 2️⃣ RÉCUPÉRER INFOS POUR CALCUL DES SOLDES // ═══════════════════════════════════════════════════════════ const [cpType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Congé payé']); if (cpType.length === 0) { conn.release(); return res.json({ success: false, message: 'Type de congé CP non trouvé' }); } // Solde reporté CP let soldeReporte = 0; const [compteurCP] = await conn.query(` SELECT SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userId, cpType[0].Id, currentYear] ); if (compteurCP.length > 0) { soldeReporte = parseFloat(compteurCP[0].SoldeReporte) || 0; } // Consommé CP const [consommeCP] = 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é'`, [userId, cpType[0].Id, currentYear] ); const totalConsommeCP = parseFloat(consommeCP[0].total) || 0; const nouveauSoldeCP = Math.max(0, acquisCP + soldeReporte - totalConsommeCP); console.log('💰 CP - Acquis:', acquisCP.toFixed(2), '+ Reporté:', soldeReporte.toFixed(2), '- Consommé:', totalConsommeCP.toFixed(2), '= Solde:', nouveauSoldeCP.toFixed(2)); // Consommé RTT let totalConsommeRTT = 0; let nouveauSoldeRTT = 0; if (rttTypeId) { const [consommeRTT] = 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 Dose') AND dc.Statut != 'Refusé'`, [userId, rttTypeId, currentYear] ); totalConsommeRTT = parseFloat(consommeRTT[0].total) || 0; nouveauSoldeRTT = Math.max(0, acquisRTT - totalConsommeRTT); console.log('💰 RTT - Acquis:', acquisRTT.toFixed(2), '- Consommé:', totalConsommeRTT.toFixed(2), '= Solde:', nouveauSoldeRTT.toFixed(2)); } // ═══════════════════════════════════════════════════════════ // 3️⃣ ENVOYER À GTA-RH POUR SYNCHRONISATION // ═══════════════════════════════════════════════════════════ try { const rhUrl = process.env.RH_SERVER_URL || 'http://192.168.0.4:3001'; const syncPayload = { collaborateurId: userId, annee: currentYear, compteurs: [ { typeConge: 'Congé payé', typeCongeId: cpType[0].Id, total: parseFloat(acquisCP.toFixed(2)), solde: parseFloat(nouveauSoldeCP.toFixed(2)), source: 'calcul_gta' } ] }; if (rttTypeId) { syncPayload.compteurs.push({ typeConge: 'RTT', typeCongeId: rttTypeId, total: parseFloat(acquisRTT.toFixed(2)), solde: parseFloat(nouveauSoldeRTT.toFixed(2)), source: 'calcul_gta' }); } console.log('📤 Envoi synchronisation vers GTA-RH...'); const syncResponse = await fetch(`${rhUrl}/api/syncCompteursFromGTA`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(syncPayload) }); if (syncResponse.ok) { const syncResult = await syncResponse.json(); console.log('✅ Synchronisation GTA-RH réussie:', syncResult.message); } else { console.warn('⚠️ Synchronisation GTA-RH échouée:', syncResponse.status); } } catch (syncError) { console.error('❌ Erreur synchronisation GTA-RH:', syncError.message); // On continue même si la sync échoue } // ═══════════════════════════════════════════════════════════ // 4️⃣ RELIRE LA BASE POUR AFFICHER LES VRAIES VALEURS // ═══════════════════════════════════════════════════════════ console.log('\n📖 Lecture base après synchronisation...'); // Ancienneté 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.societeNom || '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, recupN: null, totalDisponible: { cp: 0, rtt: 0, recup: 0, total: 0 } }; // ✅ CP N-1 (Report) - LECTURE BASE const [cpN1Data] = await conn.query(` SELECT Annee, Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userId, cpType[0].Id, previousYear] ); if (cpN1Data.length > 0) { const totalAcquis = parseFloat(cpN1Data[0].Total) || 0; const soldeReporte = parseFloat(cpN1Data[0].Solde) || 0; 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 BASE: Acquis=' + totalAcquis + 'j, Solde=' + soldeReporte + 'j'); } // ✅ CP N - LECTURE BASE const [cpNData] = await conn.query(` SELECT Total, Solde, SoldeReporte FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userId, cpType[0].Id, currentYear] ); if (cpNData.length > 0) { const totalAcquis = parseFloat(cpNData[0].Total) || 0; const soldeBDD = parseFloat(cpNData[0].Solde) || 0; const soldeReporteBDD = parseFloat(cpNData[0].SoldeReporte) || 0; const soldeReel = Math.max(0, soldeBDD - soldeReporteBDD); const pris = Math.max(0, totalAcquis - soldeReel); counters.cpN = { annee: currentYear, exercice: getExerciceCP(today), totalAnnuel: 25.00, acquis: parseFloat(totalAcquis.toFixed(2)), pris: parseFloat(pris.toFixed(2)), solde: parseFloat(soldeReel.toFixed(2)), pourcentageUtilise: totalAcquis > 0 ? parseFloat((pris / totalAcquis * 100).toFixed(1)) : 0 }; counters.totalDisponible.cp += counters.cpN.solde; console.log('✅ CP N BASE: Acquis=' + totalAcquis + 'j, Solde=' + soldeReel + 'j'); } // ✅ RTT N - LECTURE BASE if (rttType.length > 0 && user.role !== 'Apprenti') { const [rttNData] = await conn.query(` SELECT Total, Solde FROM CompteurConges WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ?`, [userId, rttTypeId, currentYear] ); if (rttNData.length > 0) { const totalAcquis = parseFloat(rttNData[0].Total) || 0; const soldeBDD = parseFloat(rttNData[0].Solde) || 0; const pris = Math.max(0, totalAcquis - soldeBDD); const rttConfig = await getConfigurationRTT(conn, currentYear, typeContrat); counters.rttN = { annee: currentYear, typeContrat: typeContrat, totalAnnuel: parseFloat(rttConfig.joursAnnuels.toFixed(2)), acquis: parseFloat(totalAcquis.toFixed(2)), pris: parseFloat(pris.toFixed(2)), solde: parseFloat(soldeBDD.toFixed(2)), pourcentageUtilise: totalAcquis > 0 ? parseFloat((pris / totalAcquis * 100).toFixed(1)) : 0 }; counters.totalDisponible.rtt += counters.rttN.solde; console.log('✅ RTT BASE: Acquis=' + totalAcquis + 'j, Solde=' + soldeBDD + 'j'); } } // ✅ Récup - LECTURE BASE const [recupType] = await conn.query(`SELECT Id FROM TypeConge WHERE Nom = ? LIMIT 1`, ['Récupération']); if (recupType.length > 0) { const [recupData] = await conn.query(` SELECT COALESCE(SUM(CASE WHEN dd.TypeDeduction IN ('Accum Récup', 'Accum Recup') THEN dd.JoursUtilises ELSE 0 END), 0) as acquis, COALESCE(SUM(CASE WHEN dd.TypeDeduction = 'Récup Dose' THEN dd.JoursUtilises ELSE 0 END), 0) as pris FROM DeductionDetails dd JOIN DemandeConge dc ON dd.DemandeCongeId = dc.Id WHERE dc.CollaborateurADId = ? AND dd.TypeCongeId = ? AND dd.Annee = ? AND dc.Statut != 'Refusé'`, [userId, recupType[0].Id, currentYear] ); if (recupData.length > 0) { const acquis = parseFloat(recupData[0].acquis) || 0; const pris = parseFloat(recupData[0].pris) || 0; const solde = Math.max(0, acquis - pris); counters.recupN = { annee: currentYear, acquis: parseFloat(acquis.toFixed(2)), pris: parseFloat(pris.toFixed(2)), solde: parseFloat(solde.toFixed(2)), message: "Jours de récupération accumulés suite à du temps supplémentaire" }; counters.totalDisponible.recup = counters.recupN.solde; console.log('✅ RÉCUP BASE: Acquis=' + acquis + 'j, Solde=' + solde + 'j'); } } // Total disponible counters.totalDisponible.total = counters.totalDisponible.cp + counters.totalDisponible.rtt + counters.totalDisponible.recup; conn.release(); console.log('\n✅ Réponse envoyée au frontend'); console.log(' CP disponible:', counters.totalDisponible.cp + 'j'); console.log(' RTT disponible:', counters.totalDisponible.rtt + 'j'); console.log(' RÉCUP disponible:', counters.totalDisponible.recup + 'j'); console.log(' TOTAL disponible:', counters.totalDisponible.total + 'j'); res.json({ success: true, data: counters }); } catch (error) { console.error('❌ Erreur getDetailedLeaveCounters:', error); res.status(500).json({ success: false, message: 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 = CASE WHEN (SoldeReporte - ?) < 0 THEN 0 ELSE (SoldeReporte - ?) END, Solde = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END 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 = CASE WHEN (Solde - ?) < 0 THEN 0 ELSE (Solde - ?) END 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}`); const [deductions] = await conn.query( `SELECT dd.TypeCongeId, dd.Annee, dd.TypeDeduction, dd.JoursUtilises, tc.Nom as TypeNom FROM DeductionDetails dd JOIN TypeConge tc ON dd.TypeCongeId = tc.Id WHERE dd.DemandeCongeId = ? ORDER BY dd.Id DESC`, [demandeCongeId] ); console.log(`📊 ${deductions.length} déductions trouvées`); if (deductions.length === 0) { console.log('⚠️ Aucune déduction trouvée pour cette demande'); return { success: false, message: 'Aucune déduction à restaurer' }; } const restorations = []; for (const deduction of deductions) { const { TypeCongeId, Annee, TypeDeduction, JoursUtilises, TypeNom } = deduction; console.log(`\n🔍 Traitement: ${TypeNom} - ${TypeDeduction} - ${JoursUtilises}j (Année: ${Annee})`); // ⭐ NOUVEAU : Gestion des Récup posées 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 = GETDATE() WHERE Id = ?`, [nouveauSolde, compteur[0].Id] ); restorations.push({ type: TypeNom, annee: Annee, typeDeduction: TypeDeduction, joursRestores: JoursUtilises }); console.log(`✅ Récup restaurée: ${ancienSolde} → ${nouveauSolde}`); } continue; } // 🔹 N+1 Anticipé - ⭐ RESTAURATION CORRECTE 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 = GETDATE() 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, ?, GETDATE())`, [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 CORRECTE 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 = GETDATE() 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 (cas rare) await conn.query( `INSERT INTO CompteurConges (CollaborateurADId, TypeCongeId, Annee, Total, Solde, SoldeReporte, SoldeAnticipe, DerniereMiseAJour) VALUES (?, ?, ?, 0, 0, 0, ?, GETDATE())`, [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') { 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 = GETDATE() 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 = GETDATE() 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}`); } } } // ⭐ IMPORTANT : Recalculer les soldes anticipés après restauration 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_Smart(today, dateEntree); const rttData = await calculerAcquisitionRTT_Smart(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_Smart(today, dateEntree); const rttData = await calculerAcquisitionRTT_Smart(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 = GETDATE() 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 = GETDATE() 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 || 'Non défini', Prenom: employee.Prenom || 'Non défini', Email: employee.Email || 'Non défini', role: employee.role || 'Collaborateur', TypeContrat: employee.TypeContrat || '37h', DateEntree: employee.DateEntree, CampusId: employee.CampusId, SocieteId: employee.SocieteId, service: employee.service || 'Non défini', societe_nom: employee.societe_nom || 'Non défini', 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' }); // 🔍 Déterminer si c'est un UUID ou un ID numérique const isUUID = userId.length > 10 && userId.includes('-'); console.log(`📝 Type userId détecté: ${isUUID ? 'UUID (entraUserId)' : 'ID numérique'}`); let mainRequest = pool.request(); let whereClause; if (isUUID) { // Si UUID, chercher d'abord le CollaborateurADId const lookupRequest = pool.request(); lookupRequest.input('entraUserId', userId); const userLookup = await lookupRequest.query(` SELECT id FROM CollaborateurAD WHERE entraUserId = @entraUserId `); if (userLookup.recordset.length === 0) { return res.json({ success: false, message: 'Utilisateur non trouvé', requests: [], total: 0 }); } const collaborateurId = userLookup.recordset[0].id; console.log(`✅ CollaborateurADId trouvé: ${collaborateurId}`); // Créer une nouvelle request pour la requête principale mainRequest.input('collaborateurId', collaborateurId); whereClause = 'dc.CollaborateurADId = @collaborateurId'; } else { // Si ID numérique, utiliser directement mainRequest.input('userId', parseInt(userId)); whereClause = '(dc.EmployeeId = @userId OR dc.CollaborateurADId = @userId)'; } // ✅ REQUÊTE CORRIGÉE pour MSSQL avec la table de liaison const result = await mainRequest.query(` SELECT dc.Id, dc.DateDebut, dc.DateFin, dc.Statut, dc.DateDemande, dc.Commentaire, dc.CommentaireValidation, dc.Validateur, dc.DocumentJoint, ( SELECT STRING_AGG(Nom, ', ') WITHIN GROUP (ORDER BY Nom) FROM ( SELECT DISTINCT tc2.Nom FROM DemandeCongeType dct2 JOIN TypeConge tc2 ON dct2.TypeCongeId = tc2.Id WHERE dct2.DemandeCongeId = dc.Id ) AS DistinctTypes ) AS TypeConges, ( SELECT SUM(dct2.NombreJours) FROM DemandeCongeType dct2 WHERE dct2.DemandeCongeId = dc.Id ) AS NombreJoursTotal FROM DemandeConge dc WHERE ${whereClause} ORDER BY dc.DateDemande DESC `); const rows = result.recordset; console.log(`📋 ${rows.length} demandes trouvées pour 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 validatorId = req.query.validator_id; if (!validatorId) { return res.json({ success: false, message: 'ID validateur manquant' }); } const conn = await pool.getConnection(); // Récupérer les infos du validateur const [managerRows] = await conn.query( 'SELECT TOP 1 ServiceId, CampusId, role FROM CollaborateurAD WHERE id = ?', [validatorId] ); if (managerRows.length === 0) { conn.release(); return res.json({ success: false, message: 'Validateur non trouvé' }); } const serviceId = managerRows[0].ServiceId; const campusId = managerRows[0].CampusId; const role = normalizeRole(managerRows[0].role); let requests; if (role === 'admin' || role === 'president' || role === 'rh') { // Admin/President/RH : toutes les demandes en attente [requests] = await conn.query(` SELECT dc.Id, dc.CollaborateurADId, dc.DateDebut, dc.DateFin, dc.NombreJours, dc.Statut, dc.Commentaire, tc.Nom as TypeConge, ca.prenom, ca.nom, s.Nom as service_name, camp.Nom as campus_name FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.Id JOIN TypeConge tc ON dc.TypeCongeId = tc.Id LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Campus camp ON ca.CampusId = camp.Id WHERE dc.Statut = 'En attente' ORDER BY dc.DateCreation DESC `); } else if (role === 'validateur' || role === 'directeur de campus') { // Validateur/Directeur : leurs collaborateurs directs via hiérarchie [requests] = await conn.query(` SELECT DISTINCT dc.Id, dc.CollaborateurADId, dc.DateDebut, dc.DateFin, dc.NombreJours, dc.Statut, dc.Commentaire, tc.Nom as TypeConge, ca.prenom, ca.nom, s.Nom as service_name, camp.Nom as campus_name FROM DemandeConge dc JOIN CollaborateurAD ca ON dc.CollaborateurADId = ca.Id JOIN HierarchieValidationAD hv ON ca.id = hv.CollaborateurId JOIN TypeConge tc ON dc.TypeCongeId = tc.Id LEFT JOIN Services s ON ca.ServiceId = s.Id LEFT JOIN Campus camp ON ca.CampusId = camp.Id WHERE dc.Statut = 'En attente' AND hv.SuperieurId = ? ORDER BY dc.DateCreation DESC `, [validatorId]); } else { conn.release(); return res.json({ success: false, message: 'Rôle non autorisé pour validation' }); } conn.release(); res.json({ success: true, message: 'Demandes récupérées', requests, service_id: serviceId, campus_id: campusId }); } catch (error) { console.error('❌ Erreur getPendingRequests:', error); res.status(500).json({ success: false, message: 'Erreur', error: error.message }); } }); // Route getTeamMembers - Membres de l'équipe 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 managerRequest = pool.request(); managerRequest.input('managerId', managerId); const managerResult = await managerRequest.query(` SELECT TOP 1 ServiceId, CampusId, role, email FROM CollaborateurAD WHERE id = @managerId `); if (managerResult.recordset.length === 0) { return res.json({ success: false, message: 'Manager non trouvé' }); } const managerInfo = managerResult.recordset[0]; const serviceId = managerInfo.ServiceId; const campusId = managerInfo.CampusId; const role = normalizeRole(managerInfo.role); console.log(`🔍 getTeamMembers - Manager: ${managerId}, Role: ${role}, Campus: ${campusId}`); let members; if (role === 'admin' || role === 'president' || role === 'rh') { // CAS 1: Admin/President/RH - Vue globale console.log("CAS 1: Admin/President/RH - Vue globale"); const membersRequest = pool.request(); membersRequest.input('managerId', managerId); const membersResult = await membersRequest.query(` SELECT c.id, c.nom, c.prenom, c.email, c.role, s.Nom as service_name, c.CampusId, camp.Nom as campus_name FROM CollaborateurAD c JOIN Services s ON c.ServiceId = s.Id LEFT JOIN Campus camp ON c.CampusId = camp.Id WHERE c.id != @managerId AND (c.actif = 1 OR c.actif IS NULL) ORDER BY c.prenom, c.nom `); members = membersResult.recordset; console.log(` ✅ ${members.length} collaborateur(s) au total`); } else if (role === 'validateur' || role === 'directeur de campus') { // CAS 2: Validateur/Directeur - Collaborateurs directs via hiérarchie console.log("CAS 2: Validateur/Directeur - Collaborateurs directs via hiérarchie"); const membersRequest = pool.request(); membersRequest.input('managerId', managerId); const membersResult = await membersRequest.query(` SELECT DISTINCT c.id, c.nom, c.prenom, c.email, c.role, s.Nom as service_name, c.CampusId, camp.Nom as campus_name FROM CollaborateurAD c JOIN HierarchieValidationAD hv ON c.id = hv.CollaborateurId JOIN Services s ON c.ServiceId = s.Id LEFT JOIN Campus camp ON c.CampusId = camp.Id WHERE hv.SuperieurId = @managerId AND (c.actif = 1 OR c.actif IS NULL) ORDER BY c.prenom, c.nom `); members = membersResult.recordset; console.log(` ✅ ${members.length} collaborateur(s) sous ${managerId}`); } else if (role === 'collaborateur' || role === 'apprenti') { // CAS 3: Collaborateur/Apprenti - Collègues du même service ET campus console.log("CAS 3: Collaborateur/Apprenti - Collègues du même service et campus"); const membersRequest = pool.request(); membersRequest.input('managerId', managerId); membersRequest.input('serviceId', serviceId); membersRequest.input('campusId', campusId); const membersResult = await membersRequest.query(` SELECT c.id, c.nom, c.prenom, c.email, c.role, s.Nom as service_name, c.CampusId, camp.Nom as campus_name FROM CollaborateurAD c JOIN Services s ON c.ServiceId = s.Id LEFT JOIN Campus camp ON c.CampusId = camp.Id WHERE c.ServiceId = @serviceId AND c.CampusId = @campusId AND c.id != @managerId AND (c.actif = 1 OR c.actif IS NULL) ORDER BY c.prenom, c.nom `); members = membersResult.recordset; console.log(` ✅ ${members.length} collègue(s) trouvé(s)`); } else { return res.json({ success: false, message: 'Rôle non autorisé' }); } res.json({ success: true, team_members: members || [], service_id: serviceId, campus_id: campusId }); } catch (error) { console.error('❌ Erreur getTeamMembers:', 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', GETDATE(), ?, ?) `, [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 = GETDATE() 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, GETDATE()) `, [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', ?, ?, GETDATE(), 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_Smart(dateDebutObj, dateEntree); budgetAnnuel = 25; console.log(`💰 Acquisition CP à la date ${dateDebut}: ${acquisALaDate.toFixed(2)}j`); } else if (typeCode === 'RTT' && !isApprenti) { const rttData = await calculerAcquisitionRTT_Smart(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 */ // ======================================== // 💰 FONCTION DE DÉDUCTION AVEC ANTICIPATION (SANS ID MANUEL) // ======================================== 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(`💳 === 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}`); // ===== ÉTAPE 1 : Déduire du REPORT N-1 ===== try { 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 || 0); const aDeduireN1 = Math.min(soldeN1, joursRestants); if (aDeduireN1 > 0) { await conn.query(` UPDATE CompteurConges SET SoldeReporte = SoldeReporte - ?, Solde = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [aDeduireN1, aDeduireN1, compteurN1[0].Id]); // ⭐ SANS SPÉCIFIER L'ID await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, ?, ?) `, [demandeCongeId, typeCongeId, previousYear, 'Report N-1', aDeduireN1]); deductions.push({ annee: previousYear, type: 'Report N-1', joursUtilises: aDeduireN1 }); joursRestants -= aDeduireN1; console.log(` ✅ Report N-1: ${aDeduireN1.toFixed(2)}j déduits - reste ${joursRestants}j`); } } } catch (error) { console.error('❌ Erreur déduction N-1:', error.message); throw error; } // ===== ÉTAPE 2 : Déduire du SOLDE N ===== if (joursRestants > 0) { try { 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 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 = Solde - ?, DerniereMiseAJour = GETDATE() WHERE Id = ? `, [aDeduireN, compteurN[0].Id]); // ⭐ SANS SPÉCIFIER L'ID await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, ?, ?) `, [demandeCongeId, typeCongeId, currentYear, 'Année N', aDeduireN]); deductions.push({ annee: currentYear, type: 'Année N', joursUtilises: aDeduireN }); joursRestants -= aDeduireN; console.log(` ✅ Solde N: ${aDeduireN.toFixed(2)}j déduits - reste ${joursRestants}j`); } } } catch (error) { console.error('❌ Erreur déduction N:', error.message); throw error; } } // ===== ÉTAPE 3 : ANTICIPÉ ===== if (joursRestants > 0) { console.log(` 💳 Il reste ${joursRestants.toFixed(2)}j à déduire → Anticipé`); try { const [collabInfo] = await conn.query(` SELECT DateEntree, TypeContrat, role FROM CollaborateurAD WHERE id = ? `, [collaborateurId]); const dateEntree = collabInfo[0]?.DateEntree || null; const [typeInfo] = await conn.query(` SELECT Nom FROM TypeConge WHERE Id = ? `, [typeCongeId]); const typeNom = typeInfo[0]?.Nom; let budgetAnnuel = 0; let acquisALaDate = 0; if (typeNom === 'Congé payé') { acquisALaDate = calculerAcquisitionCP_Smart(dateDebutObj, dateEntree); budgetAnnuel = 25; } else if (typeNom === 'RTT') { const rttData = await calculerAcquisitionRTT_Smart(conn, collaborateurId, dateDebutObj); acquisALaDate = rttData.acquisition; budgetAnnuel = rttData.config.joursAnnuels; } const anticipableMax = Math.max(0, budgetAnnuel - acquisALaDate); 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 dejaPrisAnticipe = parseFloat(anticipeUtilise[0]?.total || 0); const anticipeDisponible = Math.max(0, anticipableMax - dejaPrisAnticipe); console.log(` 💳 Anticipable max: ${anticipableMax.toFixed(2)}j`); console.log(` 💳 Déjà pris: ${dejaPrisAnticipe.toFixed(2)}j`); console.log(` 💳 Disponible: ${anticipeDisponible.toFixed(2)}j`); if (anticipeDisponible >= joursRestants) { // ⭐ SANS SPÉCIFIER L'ID await conn.query(` INSERT INTO DeductionDetails (DemandeCongeId, TypeCongeId, Annee, TypeDeduction, JoursUtilises) VALUES (?, ?, ?, ?, ?) `, [demandeCongeId, typeCongeId, currentYear, 'N Anticip', joursRestants]); deductions.push({ annee: currentYear, type: 'N Anticip', joursUtilises: joursRestants }); console.log(` ✅ Anticipé: ${joursRestants.toFixed(2)}j`); joursRestants = 0; } else { return { success: false, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions, error: `Solde insuffisant (manque ${joursRestants.toFixed(2)}j)` }; } } catch (error) { console.error('❌ Erreur anticipé:', error.message); throw error; } } console.log(` ✅ Déduction OK - Total: ${(nombreJours - joursRestants).toFixed(2)}j`); return { success: joursRestants === 0, joursDeduitsTotal: nombreJours - joursRestants, joursNonDeduits: joursRestants, details: deductions }; } // ======================================== // 🔧 FONCTION HELPER - GÉNÉRATION D'ID // ======================================== // ✅ VERSION CORRIGÉE async function getNextId(connection, tableName) { try { const [result] = await connection.query( `SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM ${tableName}` ); return result[0].NextId; } catch (error) { console.error(`❌ Erreur génération ID pour ${tableName}:`, error.message); throw error; } } 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 = req.body.DateDebut; const DateFin = req.body.DateFin; const NombreJours = parseFloat(req.body.NombreJours); const Email = req.body.Email; const Nom = req.body.Nom; const Commentaire = req.body.Commentaire || ''; const statut = req.body.statut || null; // ✅ Parser la répartition (elle arrive en string depuis FormData) let Repartition; try { Repartition = JSON.parse(req.body.Repartition || '[]'); } catch (parseError) { console.error('❌ Erreur parsing Repartition:', parseError); if (req.files) { req.files.forEach(file => { if (fs.existsSync(file.path)) { fs.unlinkSync(file.path); } }); } return res.status(400).json({ success: false, message: 'Erreur de format de la répartition' }); } 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'; const checkResult = await checkLeaveBalanceWithAnticipation( conn, collaborateurId, Repartition, DateDebut ); if (!checkResult.valide) { uploadedFiles.forEach(file => { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); }); await conn.rollback(); conn.release(); 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 (AVEC GÉNÉRATION MANUELLE D'ID) // ======================================== console.log('\n📝 Création de la demande...'); const typeIds = []; for (const rep of Repartition) { const code = rep.TypeConge; 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); } 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(','); // 🔥 GÉNÉRATION MANUELLE DE L'ID (CONTOURNEMENT IDENTITY) const [maxIdResult] = await conn.query('SELECT ISNULL(MAX(Id), 0) + 1 AS NextId FROM DemandeConge'); const demandeId = maxIdResult[0].NextId; console.log(`🆔 ID généré manuellement: ${demandeId}`); // 🔥 INSERT AVEC ID EXPLICITE await conn.query(` INSERT INTO DemandeConge (Id, CollaborateurADId, DateDebut, DateFin, TypeCongeId, Statut, DateDemande, Commentaire, Validateur, NombreJours) VALUES (?, ?, ?, ?, ?, ?, GETDATE(), ?, ?, ?) `, [ demandeId, collaborateurId, DateDebut, DateFin, typeCongeIdCsv, statutDemande, Commentaire || '', '', NombreJours ]); 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 (?, ?, ?, ?, ?, GETDATE()) `, [demandeId, file.originalname, file.path, file.mimetype, file.size]); console.log(` ✓ ${file.originalname}`); } } // ======================================== // ÉTAPE 4 : Sauvegarder la répartition // ======================================== // ======================================== // ÉTAPE 4 : Sauvegarder la répartition // ======================================== // 5️⃣ 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) { // ⭐ GÉNÉRER L'ID MANUELLEMENT const demandeCongeTypeId = await getNextId(conn, 'DemandeCongeType'); await conn.query(` INSERT INTO DemandeCongeType (Id, DemandeCongeId, TypeCongeId, NombreJours, PeriodeJournee) VALUES (?, ?, ?, ?, ?) `, [ demandeCongeTypeId, 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 = CASE WHEN Solde - ? < 0 THEN 0 ELSE Solde - ? END, DerniereMiseAJour = GETDATE() WHERE CollaborateurADId = ? AND TypeCongeId = ? AND Annee = ? `, [rep.NombreJours, 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 deductResult = await deductLeaveBalanceWithAnticipation( conn, collaborateurId, typeRow[0].Id, rep.NombreJours, demandeId, DateDebut ); console.log(` ✓ ${name}: ${rep.NombreJours}j déduits`); if (deductResult.details && deductResult.details.length > 0) { deductResult.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 (?, ?, ?, ?, ?, GETDATE(), 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) { 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 ${request.TypeConge} pour ${request.NombreJours} jour(s) ${datesPeriode} a été ${action === 'approve' ? 'approuvée' : 'refusée'} par ${validateurNom}.
${comment ? `Commentaire: ${comment}
` : ''}Vous pouvez consulter les détails dans l'application GTA.
Ceci est un email automatique, merci de ne pas y répondre.
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.
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.
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.