console.log('🚀 1. Démarrage du serveur...'); import express from 'express'; import cors from 'cors'; import sql from 'mssql'; import jwt from 'jsonwebtoken'; import { ConfidentialClientApplication } from '@azure/msal-node'; import axios from 'axios'; import dotenv from 'dotenv'; import crypto from 'crypto'; import multer from 'multer'; import { Client } from '@microsoft/microsoft-graph-client'; import 'isomorphic-fetch'; import PDFDocument from 'pdfkit'; import { PDFDocument as PDFLib } from 'pdf-lib'; import { generateFicheSignee, preparerLignesPDF } from './ndfPdfGenerator.js'; console.log('✅ 2. Modules de base chargés'); dotenv.config(); console.log('✅ 3. Dotenv chargé'); const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 } }); const proxyCache = new Map(); const PROXY_TTL = 30 * 60 * 1000; // 10 → 30 min const PROXY_MAX_SIZE = 200; // 50 → 200 entrées function getCached(url) { const entry = proxyCache.get(url); if (!entry) return null; if (Date.now() - entry.at > PROXY_TTL) { proxyCache.delete(url); return null; } return entry; } function setCache(url, buffer, contentType) { if (proxyCache.size >= PROXY_MAX_SIZE) { const oldest = [...proxyCache.entries()].sort((a, b) => a[1].at - b[1].at)[0]; proxyCache.delete(oldest[0]); } proxyCache.set(url, { buffer, contentType, at: Date.now() }); } const SHAREPOINT_CONFIG = { siteId: process.env.SHAREPOINT_SITE_ID, driveId: process.env.SHAREPOINT_DRIVE_ID, basePath: 'Notes de Frais', }; process.on('uncaughtException', (error) => { console.error('\n❌❌❌ ERREUR NON CAPTURÉE ❌❌❌'); console.error(error); console.error(error.stack); }); process.on('unhandledRejection', (reason, promise) => { console.error('\n❌❌❌ PROMESSE REJETÉE ❌❌❌'); console.error('Raison:', reason); }); process.on('exit', (code) => { console.log(`\n⚠️ PROCESSUS EN COURS DE TERMINAISON - CODE: ${code}\n`); }); console.log('✅ 4. Handlers d\'erreurs installés'); const app = express(); console.log('✅ 5. Express initialisé'); const PORT = process.env.PORT || 3024; console.log(`✅ 6. Port configuré: ${PORT}`); app.use(cors({ origin: [ 'http://myndf.ensup-adm.net', 'https://myndf.ensup-adm.net', 'http://localhost:3025', 'http://localhost:81' ], credentials: true, })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); console.log('✅ 7. Middlewares installés'); const dbConfig = { server: process.env.DB_SERVER || '192.168.0.3', user: process.env.DB_USER || 'ndf_app', password: process.env.DB_PASSWORD || 'P@ssw0rd2026!', database: process.env.DB_NAME || 'NDF', port: parseInt(process.env.DB_PORT) || 1433, options: { encrypt: true, trustServerCertificate: true, enableArithAbort: true, connectTimeout: 60000, requestTimeout: 60000, useUTC: false }, pool: { max: 10, min: 0, idleTimeoutMillis: 30000 } }; console.log('🔄 8. Test connexion SQL Server...'); let pool; const AZURE_CONFIG = { tenantId: process.env.AZURE_TENANT_ID, clientId: process.env.AZURE_CLIENT_ID, clientSecret: process.env.AZURE_CLIENT_SECRET, groupId: process.env.AZURE_GROUP_ID || 'c1ea877c-6bca-4f47-bfad-f223640813a0' }; async function initializeDatabase() { try { pool = await sql.connect(dbConfig); console.log('✅ 9. Connexion SQL Server réussie'); console.log(' Server:', dbConfig.server); console.log(' User:', dbConfig.user); console.log(' Database:', dbConfig.database); console.log(' Port:', dbConfig.port); } catch (err) { console.error('❌ 9. ERREUR CONNEXION SQL SERVER:', err.message); throw err; } } initializeDatabase().catch(err => { console.error('❌ Impossible de démarrer le serveur:', err); process.exit(1); }); const msalConfig = { auth: { clientId: process.env.AZURE_CLIENT_ID, authority: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}`, clientSecret: process.env.AZURE_CLIENT_SECRET }, system: { loggerOptions: { loggerCallback(loglevel, message) { if (process.env.NODE_ENV === 'development') console.log('[MSAL]', message); }, piiLoggingEnabled: false, logLevel: 'Info', }, } }; const BAREME_KM_SERVER = { 3: { t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 }, 4: { t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 }, 5: { t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 }, 6: { t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 }, 7: { t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 }, }; function getIndemniteKmServer(kmTotal, chevaux) { const cv = Math.min(Math.max(parseInt(chevaux) || 7, 3), 7); const b = BAREME_KM_SERVER[cv]; if (!b || kmTotal <= 0) return 0; if (kmTotal <= 5000) return parseFloat((kmTotal * b.t1).toFixed(2)); if (kmTotal <= 20000) return parseFloat((kmTotal * b.t2_a + b.t2_b).toFixed(2)); return parseFloat((kmTotal * b.t3).toFixed(2)); } function normalizeCampus(campus) { if (!campus) return null; const c = campus.toUpperCase(); if (c.includes('SQY') || c.includes('SAINT') || c.includes('SQUY')) return 'SQY'; if (c.includes('CGY') || c.includes('CERGY')) return 'CGY'; if (c.includes('MRS') || c.includes('MARSEILLE')) return 'MRS'; if (c.includes('NTE') || c.includes('NANTES')) return 'NTE'; return null; } // ── Helper getTarifKm ──────────────────────────────────────────── async function getTarifKm() { try { const annee = new Date().getFullYear(); const result = await pool.request() .input('annee', sql.Int, annee) .query(` SELECT TOP 1 tarifParKm FROM ParametresKm WHERE annee = @annee AND actif = 1 ORDER BY DateCreation DESC `); if (result.recordset[0]?.tarifParKm) { return parseFloat(result.recordset[0].tarifParKm); } return 0.697; // Fallback 7 CV+ } catch (e) { console.warn('⚠️ getTarifKm fallback 0.697:', e.message); return 0.697; } } async function getConfigDebiteur(campus = null) { try { const request = pool.request(); let campusWhere = ''; if (campus) { const campusCode = normalizeCampus(campus) || campus; request.input('campus', sql.NVarChar, campusCode); campusWhere = `AND (campus = @campus OR campus IS NULL)`; } const result = await request.query(` SELECT TOP 1 companyName, companyIban, companyBic, companyAddress, companyCp, companyVille, companyPays, campus FROM ConfigDebiteurXML WHERE actif = 1 ${campusWhere} ORDER BY CASE WHEN campus IS NOT NULL AND campus != '' THEN 0 ELSE 1 END ASC, DateModification DESC `); if (result.recordset.length) return result.recordset[0]; } catch (e) { console.warn('⚠️ getConfigDebiteur fallback .env:', e.message); } return { companyName: process.env.COMPANY_NAME || 'ENSUP GROUP', companyIban: process.env.COMPANY_IBAN || 'FR0000000000000000000000000', companyBic: process.env.COMPANY_BIC || 'BNPAFRPP', companyAddress: process.env.COMPANY_ADDRESS || '', companyCp: process.env.COMPANY_CP || '', companyVille: process.env.COMPANY_VILLE || '', companyPays: process.env.COMPANY_PAYS || 'FR', }; } // ── Helpers IBAN ───────────────────────────────────────────────── // ── Helpers IBAN ───────────────────────────────────────────────── function getIbanKey() { const key = process.env.IBAN_ENCRYPTION_KEY; if (!key) throw new Error('IBAN_ENCRYPTION_KEY manquante'); const buf = Buffer.from(key, 'hex'); if (buf.length !== 32) throw new Error(`IBAN_ENCRYPTION_KEY invalide: ${buf.length} octets (attendu: 32)`); return buf; } function encryptIban(iban) { const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv('aes-256-gcm', getIbanKey(), iv); const encrypted = Buffer.concat([cipher.update(iban, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`; } function decryptIban(stored) { const [ivHex, tagHex, encHex] = stored.split(':'); const decipher = crypto.createDecipheriv('aes-256-gcm', getIbanKey(), Buffer.from(ivHex, 'hex')); decipher.setAuthTag(Buffer.from(tagHex, 'hex')); return decipher.update(Buffer.from(encHex, 'hex')) + decipher.final('utf8'); } function validateIban(iban) { const lengths = { FR: 27, BE: 16, DE: 22, ES: 24, IT: 27 }; const country = iban.slice(0, 2); if (lengths[country] && iban.length !== lengths[country]) return false; const rearranged = iban.slice(4) + iban.slice(0, 4); const numeric = rearranged.split('').map(c => isNaN(c) ? (c.charCodeAt(0) - 55).toString() : c).join(''); let remainder = 0; for (const chunk of numeric.match(/.{1,9}/g)) { remainder = parseInt(remainder + chunk) % 97; } return remainder === 1; } function maskIban(iban) { if (!iban || iban.length < 8) return iban; return iban.slice(0, 4) + ' ' + iban.slice(4, -4).replace(/./g, '*').match(/.{1,4}/g).join(' ') + ' ' + iban.slice(-4); } function hashIban(iban) { return crypto.createHash('sha256').update(iban + process.env.IBAN_HASH_SALT).digest('hex'); } // ✅ NOUVEAU — Charge les rôles depuis UtilisateurRoles (remplace CollaborateurAD.role) async function getRolesForUser(collaborateurId) { try { const result = await pool.request() .input('id', sql.Int, collaborateurId) .query(` SELECT role FROM UtilisateurRoles WHERE collaborateur_id = @id AND actif = 1 `); return result.recordset.map(r => r.role); // Retourne ex: ['Collaboratrice', 'Finance'] ou ['superUtilisateur'] } catch (e) { console.warn('⚠️ getRolesForUser erreur:', e.message); return []; } } function formatDateParis(date) { if (!date) return '—'; const d = new Date(date); return d.toLocaleString('fr-FR', { timeZone: 'Europe/Paris', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } const cca = new ConfidentialClientApplication(msalConfig); console.log('✅ 10. MSAL configuré'); // ================================================ // 🔑 TOKEN MICROSOFT GRAPH // ================================================ // Cache du token Graph (évite 1 appel HTTP Azure par opération) let _graphTokenCache = null; let _sharePointTokenCache = null; async function getGraphToken() { const now = Date.now(); if (_graphTokenCache && now < _graphTokenCache.expiresAt) { return _graphTokenCache.token; } try { console.log('🔑 Obtention nouveau token Graph...'); if (!AZURE_CONFIG.tenantId || !AZURE_CONFIG.clientId || !AZURE_CONFIG.clientSecret) { throw new Error('Configuration Azure incomplète'); } 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' } } ); const token = response.data.access_token; _graphTokenCache = { token, expiresAt: now + 55 * 60 * 1000 }; // 55 min console.log('✅ Token Graph obtenu et mis en cache (55 min)'); return token; } catch (error) { console.error('❌ Erreur obtention token:', error.message); return null; } } // ================================================ // 🔄 SYNCHRONISATION ENTRA ID // ================================================ async function syncEntraIdUsers() { const syncResults = { processed: 0, inserted: 0, updated: 0, deactivated: 0, errors: [] }; try { console.log('\n🔄 === DÉBUT SYNCHRONISATION ENTRA ID ==='); const accessToken = await getGraphToken(); if (!accessToken) { console.error('❌ Impossible d\'obtenir le token'); return syncResults; } console.log('✅ Token obtenu'); const groupResponse = await axios.get( `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`, { headers: { Authorization: `Bearer ${accessToken}` } } ); const groupName = groupResponse.data.displayName; console.log(`📋 Groupe : ${groupName}`); let allAzureMembers = []; let nextLink = `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}/members?$select=id,givenName,surname,mail,department,jobTitle,officeLocation,accountEnabled&$top=999`; console.log('📥 Récupération des membres...'); while (nextLink) { const membersResponse = await axios.get(nextLink, { headers: { Authorization: `Bearer ${accessToken}` } }); allAzureMembers = allAzureMembers.concat(membersResponse.data.value); nextLink = membersResponse.data['@odata.nextLink']; if (nextLink) console.log(` 📄 ${allAzureMembers.length} membres récupérés...`); } console.log(`✅ ${allAzureMembers.length} membres trouvés`); const validMembers = allAzureMembers.filter(m => { if (!m.mail || m.mail.trim() === '') return false; if (m.accountEnabled === false) return false; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(m.mail); }); console.log(`✅ ${validMembers.length} membres valides`); const transaction = new sql.Transaction(pool); await transaction.begin(); try { const azureEmails = new Set(); validMembers.forEach(m => azureEmails.add(m.mail.toLowerCase().trim())); console.log('\n📝 Traitement des utilisateurs...'); for (const m of validMembers) { try { const emailClean = m.mail.toLowerCase().trim(); syncResults.processed++; const request = new sql.Request(transaction); request.input('email', sql.NVarChar, emailClean); const result = await request.query(` SELECT id, email, entraUserId, Actif FROM CollaborateurAD WHERE LOWER(email) = LOWER(@email) `); if (result.recordset.length > 0) { const updateRequest = new sql.Request(transaction); updateRequest.input('entraUserId', sql.NVarChar, m.id); updateRequest.input('prenom', sql.NVarChar, m.givenName || ''); updateRequest.input('nom', sql.NVarChar, m.surname || ''); updateRequest.input('departement', sql.NVarChar, m.department || ''); updateRequest.input('fonction', sql.NVarChar, m.jobTitle || ''); updateRequest.input('campus', sql.NVarChar, m.officeLocation || ''); updateRequest.input('email', sql.NVarChar, emailClean); await updateRequest.query(` UPDATE CollaborateurAD SET entraUserId = @entraUserId, prenom = @prenom, nom = @nom, departement = @departement, fonction = @fonction, campus = @campus, Actif = 1, dateMiseAJour = GETDATE(), DateModification = GETDATE() WHERE LOWER(email) = LOWER(@email) `); syncResults.updated++; console.log(` ✓ Mis à jour : ${emailClean}`); } else { const insertRequest = new sql.Request(transaction); insertRequest.input('entraUserId', sql.NVarChar, m.id); insertRequest.input('prenom', sql.NVarChar, m.givenName || ''); insertRequest.input('nom', sql.NVarChar, m.surname || ''); insertRequest.input('email', sql.NVarChar, emailClean); insertRequest.input('departement', sql.NVarChar, m.department || ''); insertRequest.input('fonction', sql.NVarChar, m.jobTitle || ''); insertRequest.input('campus', sql.NVarChar, m.officeLocation || ''); await insertRequest.query(` INSERT INTO CollaborateurAD (entraUserId, prenom, nom, email, departement, fonction, campus, role, service, Actif, DateCreation, DateModification, dateMiseAJour) VALUES (@entraUserId, @prenom, @nom, @email, @departement, @fonction, @campus, 'Collaborateur', NULL, 1, GETDATE(), GETDATE(), GETDATE()) `); syncResults.inserted++; console.log(` ✓ Créé : ${emailClean}`); } } catch (userError) { syncResults.errors.push({ email: m.mail, error: userError.message }); console.error(` ❌ Erreur ${m.mail}:`, userError.message); } } console.log('\n🔍 Désactivation des comptes obsolètes...'); if (azureEmails.size > 0) { const activeEmailsList = Array.from(azureEmails).map(e => `'${e}'`).join(','); const deactivateRequest = new sql.Request(transaction); const deactivateResult = await deactivateRequest.query(` UPDATE CollaborateurAD SET Actif = 0, DateModification = GETDATE() WHERE email IS NOT NULL AND email != '' AND LOWER(email) NOT IN (${activeEmailsList}) AND (Actif = 1 OR Actif IS NULL) `); syncResults.deactivated = deactivateResult.rowsAffected[0]; console.log(` ✓ ${syncResults.deactivated} compte(s) désactivé(s)`); } await transaction.commit(); console.log('\n📊 === RÉSUMÉ ==='); console.log(` Groupe: ${groupName}`); console.log(` Total Entra: ${allAzureMembers.length}`); console.log(` Valides: ${validMembers.length}`); console.log(` Traités: ${syncResults.processed}`); console.log(` Créés: ${syncResults.inserted}`); console.log(` Mis à jour: ${syncResults.updated}`); console.log(` Désactivés: ${syncResults.deactivated}`); console.log(` Erreurs: ${syncResults.errors.length}`); } catch (error) { await transaction.rollback(); throw error; } } catch (error) { console.error('\n❌ ERREUR SYNCHRONISATION:', error.message); } return syncResults; } // ================================================ // MIDDLEWARE JWT // ================================================ const authenticateToken = (req, res, next) => { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Token manquant' }); try { const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = decoded; next(); } catch (err) { return res.status(403).json({ error: 'Token invalide ou expiré' }); } }; // requireRole gère les rôles multiples (tableau req.user.roles) const requireRole = (...rolesRequis) => (req, res, next) => { if (!req.user) return res.status(401).json({ error: 'Non authentifié' }); const hasRole = req.user.roles && req.user.roles.some(r => rolesRequis.includes(r)); if (!hasRole) { return res.status(403).json({ error: `Accès refusé — rôle(s) requis : ${rolesRequis.join(' ou ')}` }); } next(); }; // Helper : vérifie si l'utilisateur possède un rôle parmi une liste const hasAnyRole = (user, ...roles) => user.roles && user.roles.some(r => roles.includes(r)); // ================================================ // ROUTES DE BASE // ================================================ app.get('/', (req, res) => { res.json({ message: 'API Gestion des Notes de Frais', version: '1.0.0', status: 'OK' }); }); app.get('/users-dev', async (req, res) => { try { const result = await pool.request().query(` SELECT id, email, nom, prenom, role, Actif FROM CollaborateurAD WHERE Actif = 1 OR Actif IS NULL ORDER BY nom, prenom `); res.json(result.recordset); } catch (error) { console.error('❌ Erreur /users-dev:', error); res.status(500).json({ error: error.message }); } }); // ✅ MODIFIÉ — login-dev avec rôles depuis UtilisateurRoles app.post('/login-dev', async (req, res) => { try { const { accessToken } = req.body; if (!accessToken) return res.status(400).json({ error: 'Token d\'accès manquant' }); let userInfo; try { const graphResponse = await fetch('https://graph.microsoft.com/v1.0/me', { headers: { 'Authorization': `Bearer ${accessToken}` } }); if (!graphResponse.ok) throw new Error('Token invalide'); userInfo = await graphResponse.json(); } catch (graphError) { return res.status(401).json({ error: 'Token d\'accès invalide', details: graphError.message }); } const userEmail = userInfo.mail || userInfo.userPrincipalName; const result = await pool.request() .input('email', sql.VarChar, userEmail) .query(`SELECT * FROM CollaborateurAD WHERE email = @email AND (Actif = 1 OR Actif IS NULL)`); if (!result.recordset.length) return res.status(404).json({ error: 'Utilisateur non trouvé ou compte désactivé' }); const user = result.recordset[0]; // ✅ Rôles depuis UtilisateurRoles au lieu de CollaborateurAD.role const userRoles = await getRolesForUser(user.id); if (!userRoles.length) return res.status(403).json({ error: 'Aucun rôle assigné dans UtilisateurRoles' }); const rolesAutorises = [ 'Collaborateur', 'Collaboratrice', 'Validateur', 'Validatrice', 'Finance', 'VerificateurFinance', 'ValidateurFinance', 'superUtilisateur' ]; const hasAccess = userRoles.some(r => rolesAutorises.includes(r)); if (!hasAccess) return res.status(403).json({ error: `Vos rôles n'ont pas accès à cette application` }); const token = jwt.sign( { id: user.id, email: user.email, roles: userRoles, nom: user.nom, prenom: user.prenom, societe: user.societe, campus: normalizeCampus(user.campus), }, process.env.JWT_SECRET, { expiresIn: '8h' } ); res.json({ token, user: { id: user.id, nom: user.nom, prenom: user.prenom, email: user.email, roles: userRoles, societe: user.societe, campus: user.campus, } }); } catch (error) { res.status(500).json({ error: 'Erreur serveur inattendue', details: error.message }); } }); app.get('/auth/verify', authenticateToken, (req, res) => { res.json({ valid: true, user: req.user }); }); app.post('/auth/logout', authenticateToken, (req, res) => { res.json({ success: true, message: 'Déconnexion réussie' }); }); const pkceStore = new Map(); app.get('/api/auth/microsoft', (req, res) => { const tenantId = process.env.AZURE_TENANT_ID; const clientId = process.env.AZURE_CLIENT_ID; const state = Math.random().toString(36).substring(2, 15); const codeVerifier = crypto.randomBytes(32).toString('base64url'); const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url'); pkceStore.set(state, codeVerifier); setTimeout(() => pkceStore.delete(state), 10 * 60 * 1000); // ✅ Détecte automatiquement l'URL d'origine const redirectUri = process.env.OAUTH_REDIRECT_URI || 'https://myndf.ensup-adm.net/api/auth/callback'; console.log('🔗 Redirect URI:', redirectUri); const params = new URLSearchParams({ client_id: clientId, response_type: 'code', redirect_uri: redirectUri, response_mode: 'query', scope: 'openid profile email User.Read', state, prompt: 'select_account', code_challenge: codeChallenge, code_challenge_method: 'S256' }); res.redirect(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize?${params.toString()}`); }); // ✅ MODIFIÉ — auth/callback avec rôles depuis UtilisateurRoles app.get('/api/auth/callback', async (req, res) => { const { code, error, state } = req.query; if (error) return res.redirect(`/login?error=${error}&desc=${encodeURIComponent(req.query.error_description || '')}`); if (!code) return res.redirect(`/login?error=no_code`); const codeVerifier = pkceStore.get(state); if (!codeVerifier) return res.redirect(`/login?error=invalid_state`); pkceStore.delete(state); try { // ✅ Détecte automatiquement l'URL d'origine (même logique que /api/auth/microsoft) const redirectUri = process.env.OAUTH_REDIRECT_URI || 'https://myndf.ensup-adm.net/api/auth/callback'; console.log('🔗 Callback Redirect URI:', redirectUri); const tokenResponse = await axios.post( `https://login.microsoftonline.com/${AZURE_CONFIG.tenantId}/oauth2/v2.0/token`, new URLSearchParams({ client_id: AZURE_CONFIG.clientId, client_secret: AZURE_CONFIG.clientSecret, code, redirect_uri: redirectUri, // ✅ URI détectée automatiquement grant_type: 'authorization_code', code_verifier: codeVerifier }), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } } ); const accessToken = tokenResponse.data.access_token; const userResponse = await axios.get('https://graph.microsoft.com/v1.0/me', { headers: { Authorization: `Bearer ${accessToken}` } }); const userEmail = userResponse.data.mail || userResponse.data.userPrincipalName; const result = await pool.request() .input('email', sql.VarChar, userEmail) .query(`SELECT * FROM CollaborateurAD WHERE email = @email AND (Actif = 1 OR Actif IS NULL)`); if (!result.recordset.length) return res.redirect(`/login?error=user_not_found`); const user = result.recordset[0]; // ✅ Rôles depuis UtilisateurRoles const userRoles = await getRolesForUser(user.id); if (!userRoles.length) return res.redirect(`/login?error=no_role_assigned`); const rolesAutorises = ['Collaborateur', 'Collaboratrice', 'Validateur', 'Validatrice', 'Finance', 'VerificateurFinance', 'ValidateurFinance', , 'superUtilisateur']; const hasAccess = userRoles.some(r => rolesAutorises.includes(r)); if (!hasAccess) return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3025'}/login?error=unauthorized_role`); const jwtToken = jwt.sign( { id: user.id, email: user.email, roles: userRoles, nom: user.nom, prenom: user.prenom, societe: user.societe, campus: normalizeCampus(user.campus), }, process.env.JWT_SECRET, { expiresIn: '8h' } ); // ✅ Redirection vers le frontend avec le token res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3025'}/auth/callback?token=${jwtToken}`); } catch (error) { console.error('❌ Erreur callback OAuth:', error.message); if (error.response?.data) { console.error('Détails:', JSON.stringify(error.response.data, null, 2)); } res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:3025'}/login?error=auth_failed&details=${encodeURIComponent(error.message)}`); } }); // GET /api/verificateur/notes — notes approuvées à vérifier app.get('/api/verificateur/notes', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé VerificateurFinance' }); try { const request = pool.request(); let campusWhere = ''; if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) { const campusCode = normalizeCampus(req.user.campus); if (campusCode) { request.input('campus', sql.NVarChar, `%${campusCode}%`); campusWhere = `AND c.campus LIKE @campus`; } } const result = await request.query(` SELECT n.*, c.nom + ' ' + c.prenom AS collaborateur, c.email AS collaborateurEmail, c.departement, c.campus, c.societe, <<<<<<< HEAD v1.nom + ' ' + v1.prenom AS nomN1, v2.nom + ' ' + v2.prenom AS nomN2 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id ======= v1.nom + ' ' + v1.prenom AS nomN1 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) WHERE n.statut = 'approuve' ${campusWhere} ORDER BY n.DateCreation DESC `); const notes = result.recordset; if (!notes.length) return res.json([]); // Charger les lignes refusées actives en batch (utile si une note "approuve" // a déjà eu un refus archivé qu'on veut afficher en historique côté UI) const noteIds = notes.map(n => n.id).join(','); const refusedRows = await pool.request().query(` SELECT noteDeFraisId, ligneIndex, motif, statut, dateRefus FROM LignesRefusees WHERE noteDeFraisId IN (${noteIds}) AND statut = 'active' `); const refusedByNote = {}; for (const r of refusedRows.recordset) { if (!refusedByNote[r.noteDeFraisId]) refusedByNote[r.noteDeFraisId] = []; refusedByNote[r.noteDeFraisId].push({ index: r.ligneIndex, motif: r.motif }); } for (const note of notes) { note.lignesRefusees = refusedByNote[note.id] || []; } res.json(notes); } catch (error) { console.error('GET /api/verificateur/notes:', error.message); res.status(500).json({ error: error.message }); } }); // PUT /api/verificateur/notes/:id/verifier app.put('/api/verificateur/notes/:id/verifier', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé VerificateurFinance' }); const { commentaire, montantsModifies } = req.body; const noteId = parseInt(req.params.id); try { const noteResult = await pool.request() .input('id', sql.Int, noteId) .query(` SELECT n.*, c.prenom, c.nom, c.email, c.campus FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.id = @id AND n.statut = 'approuve' `); if (!noteResult.recordset.length) return res.status(404).json({ error: 'Note introuvable ou statut incompatible' }); const note = noteResult.recordset[0]; let lignesParsed = []; try { lignesParsed = JSON.parse(note.lignesJson || '[]'); } catch { } let montantFinalAjuste = parseFloat(note.montant); let lignesJsonModifie = note.lignesJson; if (Array.isArray(montantsModifies) && montantsModifies.length > 0) { for (const mod of montantsModifies) { const { ligneIndex, montantRetenu } = mod; if ( typeof ligneIndex === 'number' && ligneIndex >= 0 && ligneIndex < lignesParsed.length && typeof montantRetenu === 'number' && montantRetenu > 0 ) { const ligneOriginale = lignesParsed[ligneIndex]; const montantOriginalVal = parseFloat(ligneOriginale.montant) || montantRetenu; const ratio = montantOriginalVal > 0 ? montantRetenu / montantOriginalVal : 1; let tvaItemsMisAJour = ligneOriginale.tvaItems; if (Array.isArray(ligneOriginale.tvaItems) && ligneOriginale.tvaItems.length > 0) { tvaItemsMisAJour = ligneOriginale.tvaItems.map(item => { const itemTTC = parseFloat((parseFloat(item.montantTTC) * ratio).toFixed(2)); const itemTau = parseFloat(item.taux) || 0; const itemHT = itemTau > 0 ? parseFloat((itemTTC / (1 + itemTau / 100)).toFixed(2)) : itemTTC; return { ...item, montantTTC: itemTTC.toFixed(2), montantHT: itemHT.toFixed(2) }; }); } lignesParsed[ligneIndex] = { ...ligneOriginale, montant: montantRetenu.toFixed(2), montantOriginal: ligneOriginale.montant, montantAjuste: true, tvaItems: tvaItemsMisAJour, }; } } const tarifKm = await getTarifKm(); montantFinalAjuste = lignesParsed.reduce((total, l) => { const isKm = (l.categorie || '').toLowerCase().includes('kilom'); if (isKm) { const km = parseFloat(l.km) || 0; const cv = parseInt(l.chevaux) || 7; return total + getIndemniteKmServer(km, cv); } return total + (parseFloat(l.montant) || 0); }, 0); montantFinalAjuste = parseFloat(montantFinalAjuste.toFixed(2)); lignesJsonModifie = JSON.stringify(lignesParsed); } const nbLignes = lignesParsed.length; const commentaireVerif = montantsModifies?.length > 0 ? `` : commentaire || null; await pool.request() .input('id', sql.Int, noteId) .input('verificateurId', sql.Int, req.user.id) .input('commentaire', sql.NVarChar, commentaireVerif) .input('montant', sql.Decimal, montantFinalAjuste) .input('lignesJson', sql.NVarChar, lignesJsonModifie) .query(` UPDATE NoteDeFrais SET statut = 'verifie', verificateurFinanceId = @verificateurId, dateVerification = GETDATE(), commentaireVerification = @commentaire, montant = @montant, lignesJson = @lignesJson, DateModification = GETDATE() WHERE id = @id `); if (Array.isArray(montantsModifies) && montantsModifies.length > 0) { for (const mod of montantsModifies) { const { ligneIndex, montantRetenu } = mod; if (typeof ligneIndex !== 'number' || montantRetenu <= 0) continue; const numPiece = ligneIndex + 1; const l = lignesParsed[ligneIndex] || {}; const taux = parseFloat(l.tauxTVA) || 0; const ht = taux > 0 ? montantRetenu / (1 + taux / 100) : montantRetenu; await pool.request() .input('noteId', sql.Int, noteId) .input('numPiece', sql.Int, numPiece) .input('montantTTC', sql.Decimal, montantRetenu) .input('montantHT', sql.Decimal, parseFloat(ht.toFixed(2))) .query(` UPDATE LigneNoteDeFrais SET montantTTC = @montantTTC, montantHT = @montantHT WHERE noteDeFraisId = @noteId AND numPiece = @numPiece `); } } const commentaireHisto = `${nbLignes} ligne${nbLignes > 1 ? 's' : ''} validée${nbLignes > 1 ? 's' : ''}${montantsModifies?.length > 0 ? ` — ${montantsModifies.length} montant(s) proratisé(s)` : ''}${commentaire ? ' — ' + commentaire : ''}`; await pool.request() .input('noteId', sql.Int, noteId) .input('validateurId', sql.Int, req.user.id) .input('action', sql.NVarChar, 'verifier') .input('commentaire', sql.NVarChar, commentaireHisto) .input('statut', sql.NVarChar, 'verifie') .query(` INSERT INTO HistoriqueValidation (NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction) VALUES (@noteId, @validateurId, 'VERIF', @action, @commentaire, @statut, GETDATE()) `); res.json({ success: true, statut: 'verifie', montantAjuste: montantFinalAjuste, nbMontantsModifies: Array.isArray(montantsModifies) ? montantsModifies.length : 0, }); setImmediate(async () => { try { // ── Récupérer historique des signatures ─────────────────── const histResult = await pool.request() .input('noteId', sql.Int, noteId) .query(` SELECT h.Niveau, h.Action, h.Commentaire, h.DateAction, c.prenom + ' ' + c.nom AS nomPrenom FROM HistoriqueValidation h JOIN CollaborateurAD c ON c.id = h.ValidateurId WHERE h.NoteDeFraisId = @noteId ORDER BY h.DateAction ASC `); const noteComplete = await pool.request() .input('id', sql.Int, noteId) .query(` SELECT n.reference, n.libelle, n.montant, n.date, n.categorie, n.lignesJson, n.fichiers, n.DateCreation, c.prenom + ' ' + c.nom AS nomPrenom, c.prenom AS collabPrenom, c.nom AS collabNom, c.departement FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.id = @id `); const nd = noteComplete.recordset[0]; if (!nd) return; const moisStr = (() => { const d = new Date(nd.date); const m = d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); return m.charAt(0).toUpperCase() + m.slice(1); })(); const nomPrenom = nd.nomPrenom; const verificateurNom = `${req.user.prenom} ${req.user.nom}`; // ── Construire les signatures COLLAB + N1/N2 + VERIF ────── const signatures = []; signatures.push({ niveau: 'COLLAB', nomPrenom, date: nd.DateCreation, action: 'soumettre', commentaire: null }); for (const h of histResult.recordset) { if (h.Niveau !== 'VERIF') { signatures.push({ niveau: h.Niveau, nomPrenom: h.nomPrenom, date: h.DateAction, action: h.Action, commentaire: h.Commentaire || null }); } } signatures.push({ niveau: 'VERIF', nomPrenom: verificateurNom, date: new Date(), action: 'verifier', commentaire: commentaireVerif || null }); const noteDataPDF = { reference: nd.reference, nomPrenom, mois: moisStr, departement: nd.departement, lignesJson: lignesJsonModifie, tarifKm: await getTarifKm(), statut: 'verifie', montant: montantFinalAjuste, }; let fichiersExistants = []; try { fichiersExistants = JSON.parse(nd.fichiers || '[]'); } catch { } const existingFolder = fichiersExistants[0]?.folderPath; const nomDossier = existingFolder ? existingFolder.split('/')[1] <<<<<<< HEAD : `${nd.collabPrenom}_${nd.collabNom}`.replace(/[^a-zA-Z0-9]/g, '_'); ======= : `${nd.collabNom}_${nd.collabPrenom}` .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9_]/g, '_'); >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const moisDossier = existingFolder ? existingFolder.split('/')[2] : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; // ── Générer PDF fiche vérifiée (fiche seule) ────────────── try { const pdfSigne = await generateFicheSignee(noteDataPDF, signatures); const suffixe = montantsModifies?.length > 0 ? 'verifie-proratise' : 'verifie'; const signedResult = await uploadToSharePointHierarchique( { buffer: pdfSigne, originalname: `${nd.reference}-${suffixe}.pdf`, mimetype: 'application/pdf', size: pdfSigne.length }, nd.reference, nomDossier, moisDossier ); fichiersExistants.push(signedResult); await pool.request() .input('id', sql.Int, noteId) .input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants)) .query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id'); console.log(`✅ [ASYNC] PDF vérification ${suffixe} généré: ${signedResult.fileName}`); } catch (e) { console.error('❌ [ASYNC] PDF vérification:', e.message); } // ── Régénérer le recap complet (fiche + justifs + 3 signatures) ── try { const EXCLUS_RECAP = ['soumission', 'resoumission', 'signe', 'verifie', 'recap']; const justifFiles = []; for (const f of fichiersExistants) { const fname = (f.fileName || '').toLowerCase(); if (EXCLUS_RECAP.some(kw => fname.includes(kw))) continue; try { const buf = await downloadFromSharePoint(f.uploadUrl); const mimetype = fname.endsWith('.pdf') ? 'application/pdf' : fname.endsWith('.png') ? 'image/png' : 'image/jpeg'; justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length }); } catch (e) { console.warn(`⚠️ Justif recap non récupérable: ${f.fileName}`, e.message); } } const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures); const recapResult = await uploadToSharePointHierarchique( { buffer: recapBuffer, originalname: `${nd.reference}_recap.pdf`, mimetype: 'application/pdf', size: recapBuffer.length }, nd.reference, nomDossier, moisDossier ); // Récupérer la liste de fichiers à jour après upload du PDF verifie const noteUpdated = await pool.request() .input('id', sql.Int, noteId) .query('SELECT fichiers FROM NoteDeFrais WHERE id = @id'); let fichiersAJour = []; try { fichiersAJour = JSON.parse(noteUpdated.recordset[0]?.fichiers || '[]'); } catch { } // Remplacer l'ancien _recap.pdf (garder recap-paiement intact) const fichiersFinaux = fichiersAJour.filter(f => { const fname = (f.fileName || '').toLowerCase(); return !fname.endsWith('_recap.pdf') || fname.includes('recap-paiement'); }); fichiersFinaux.push(recapResult); await pool.request() .input('id', sql.Int, noteId) .input('fichiers', sql.NVarChar, JSON.stringify(fichiersFinaux)) .query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id'); console.log(`✅ [ASYNC] Recap PDF régénéré avec ${signatures.length} signature(s) (proratisé: ${Array.isArray(montantsModifies) && montantsModifies.length > 0}): ${recapResult.fileName}`); } catch (recapError) { console.error('❌ [ASYNC] Régénération recap vérification:', recapError.message); } // ── Notifier les ValidateurFinance ──────────────────────── try { const campusNorm = normalizeCampus(note.campus); const validateurs = await pool.request() .input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%') .query(` SELECT c.id, c.email, c.prenom, c.nom FROM CollaborateurAD c JOIN UtilisateurRoles r ON r.collaborateur_id = c.id WHERE r.role = 'ValidateurFinance' AND r.actif = 1 AND c.campus LIKE @campus AND c.Actif = 1 `); const montantFormate = montantFinalAjuste.toFixed(2); const montantOriginal = parseFloat(note.montant).toFixed(2); const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; const aProratise = Array.isArray(montantsModifies) && montantsModifies.length > 0; for (const val of validateurs.recordset) { try { await creerNotification({ destinataireId: val.id, destinataireEmail: val.email, type: 'paiement', titre: `✅ Note vérifiée à valider — ${note.reference}`, message: `${verificateurNom} a vérifié la note ${note.reference} (${montantFormate} €${aProratise ? ` — montant ajusté de ${montantOriginal} €` : ''}) de ${note.prenom} ${note.nom}. En attente de votre validation de paiement.`, noteId }); } catch (e) { console.error('Notif ValidateurFinance:', e.message); } try { await sendMailGraph( val.email, `✅ Note vérifiée — validation paiement requise : ${note.reference}`, `

✅ Note vérifiée — paiement à valider

Bonjour ${val.prenom} ${val.nom},

La note ${note.reference} de ${note.prenom} ${note.nom} a été vérifiée par ${verificateurNom} et est prête pour le paiement.

${aProratise ? `
⚠️ Montants proratisés
Montant original : ${montantOriginal} € → Montant retenu : ${montantFormate} € (${montantsModifies.length} repas plafonné${montantsModifies.length > 1 ? 's' : ''} à 25 €/pers.)
` : ''} ${commentaire ? `

💬 ${commentaire}

` : ''}
Valider le paiement →
` ); } catch (e) { console.error('Email ValidateurFinance:', e.message); } } // Notifier le collaborateur const montantAjusteMsg = aProratise ? `Votre note ${note.reference} a été vérifiée. Montant retenu : ${montantFormate} € (ajusté depuis ${montantOriginal} € — plafonnement repas à 25 €/pers.).` : `Votre note ${note.reference} (${montantFormate} €) a été vérifiée et est en attente de validation du paiement.`; try { await creerNotification({ destinataireId: note.collaborateurId, destinataireEmail: note.email, type: 'paiement', titre: `Note ${note.reference} ${aProratise ? 'vérifiée — montant ajusté' : 'en cours de traitement'}`, message: montantAjusteMsg, noteId }); if (aProratise) { await sendMailGraph( note.email, `ℹ️ Montant ajusté — Note ${note.reference}`, `

ℹ️ Montant de votre note ajusté

Bonjour ${note.prenom} ${note.nom},

Votre note ${note.reference} a été vérifiée par la Finance. Certains frais de repas ont été plafonnés à 25 €/personne conformément à la politique de l'entreprise.

Montant soumis${montantOriginal} €
Montant retenu${montantFormate} €
Ajustements${montantsModifies.length} ligne${montantsModifies.length > 1 ? 's' : ''} de repas plafonnée${montantsModifies.length > 1 ? 's' : ''}

Le plafond légal pour les frais de repas est de 25 € par personne. Les montants ont été ajustés en conséquence.

` ); } } catch (e) { console.error('Notif collab vérification:', e.message); } } catch (e) { console.error('❌ [ASYNC] Notifications vérification:', e.message); } } catch (e) { console.error('❌ [ASYNC] PDF vérification général:', e.message); } }); } catch (error) { console.error('Erreur PUT /verificateur/notes/:id/verifier:', error.message); res.status(500).json({ error: error.message }); } }); app.post('/api/verificateur/notes/:id/refuser', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé VerificateurFinance' }); const noteId = parseInt(req.params.id); const { lignesRefusees, notifier = true } = req.body; if (!Array.isArray(lignesRefusees) || lignesRefusees.length === 0) return res.status(400).json({ error: 'Au moins une ligne refusée est requise' }); // Validation : chaque entrée doit avoir index (number) + motif (string non vide) for (const r of lignesRefusees) { if (typeof r.index !== 'number' || r.index < 0) return res.status(400).json({ error: 'Chaque ligne refusée doit avoir un index (number) >= 0' }); if (!r.motif || typeof r.motif !== 'string' || !r.motif.trim()) return res.status(400).json({ error: `Motif manquant pour la ligne d'index ${r.index}` }); } const transaction = new sql.Transaction(pool); try { // Récupérer la note + collab + N1 + N2 (avant transaction pour validation) const noteResult = await pool.request() .input('id', sql.Int, noteId) .query(` SELECT n.id, n.reference, n.libelle, n.montant, n.statut, n.collaborateurId, n.lignesJson, c.prenom, c.nom, c.email, c.campus, <<<<<<< HEAD v1.id AS n1Id, v1.email AS emailN1, v1.prenom AS prenomN1, v1.nom AS nomN1, v2.id AS n2Id, v2.email AS emailN2, v2.prenom AS prenomN2, v2.nom AS nomN2 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id ======= v1.id AS n1Id, v1.email AS emailN1, v1.prenom AS prenomN1, v1.nom AS nomN1 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) WHERE n.id = @id AND n.statut = 'approuve' `); if (!noteResult.recordset.length) return res.status(404).json({ error: 'Note introuvable ou statut incompatible (doit être "approuve")' }); const note = noteResult.recordset[0]; // Parser les lignes pour récupérer libellé + catégorie au moment du refus let lignesData = []; try { lignesData = JSON.parse(note.lignesJson || '[]'); } catch { } // Vérifier que les index sont valides for (const r of lignesRefusees) { if (r.index >= lignesData.length) return res.status(400).json({ error: `Index ${r.index} hors limites (note a ${lignesData.length} lignes)` }); } await transaction.begin(); // ── 1. Archiver les anciens refus actifs (si re-refus après correction partielle) await new sql.Request(transaction) .input('noteId', sql.Int, noteId) .query(` UPDATE LignesRefusees SET statut = 'archive' WHERE noteDeFraisId = @noteId AND statut = 'active' `); // ── 2. Insérer les nouveaux refus for (const r of lignesRefusees) { const ligne = lignesData[r.index] || {}; await new sql.Request(transaction) .input('noteId', sql.Int, noteId) .input('ligneIndex', sql.Int, r.index) .input('ligneLibelle', sql.NVarChar, ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`) .input('ligneCategorie', sql.NVarChar, ligne.categorie || null) .input('motif', sql.NVarChar, r.motif.trim()) .input('verificateurId', sql.Int, req.user.id) .query(` INSERT INTO LignesRefusees (noteDeFraisId, ligneIndex, ligneLibelle, ligneCategorie, motif, verificateurId, dateRefus, statut) VALUES (@noteId, @ligneIndex, @ligneLibelle, @ligneCategorie, @motif, @verificateurId, GETDATE(), 'active') `); } // ── 3. Passer la note en 'refuse_verif' const commentaireSynth = lignesRefusees.map(r => { const ligne = lignesData[r.index] || {}; const label = ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`; return `• ${label} — ${r.motif.trim()}`; }).join(' | '); await new sql.Request(transaction) .input('id', sql.Int, noteId) .input('verificateurId', sql.Int, req.user.id) .input('commentaire', sql.NVarChar, `${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} refusée${lignesRefusees.length > 1 ? 's' : ''} : ${commentaireSynth}`) .query(` UPDATE NoteDeFrais SET statut = 'refuse_verif', verificateurFinanceId = @verificateurId, dateVerification = GETDATE(), commentaireVerification = @commentaire, DateModification = GETDATE() WHERE id = @id `); // ── 4. Historique await new sql.Request(transaction) .input('noteId', sql.Int, noteId) .input('validateurId', sql.Int, req.user.id) .input('commentaire', sql.NVarChar, commentaireSynth) .input('statut', sql.NVarChar, 'refuse_verif') .query(` INSERT INTO HistoriqueValidation (NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction) VALUES (@noteId, @validateurId, 'VERIF', 'refuser', @commentaire, @statut, GETDATE()) `); await transaction.commit(); // ── 5. Notifications (en dehors de la transaction) const verificateurNom = `${req.user.prenom} ${req.user.nom}`; const montantFormate = parseFloat(note.montant).toFixed(2); const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; // HTML : tableau récap des lignes refusées const tableLignesHtml = lignesRefusees.map(r => { const ligne = lignesData[r.index] || {}; const label = ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`; const cat = ligne.categorie || '—'; return ` ${r.index + 1}
${label}
${cat}
${r.motif.trim()} `; }).join(''); let notifiedCollab = false, notifiedN1 = false; if (notifier) { // Notif collaborateur try { await creerNotification({ destinataireId: note.collaborateurId, destinataireEmail: note.email, type: 'refus', titre: `❌ Note ${note.reference} refusée par la Finance`, message: `${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} refusée${lignesRefusees.length > 1 ? 's' : ''} sur votre note ${note.reference}. Vous devez corriger uniquement ces lignes et resoumettre.`, noteId }); notifiedCollab = true; } catch (e) { console.error('Notif BDD collab refus:', e.message); } try { await sendMailGraph( note.email, `❌ Note refusée — corrections demandées : ${note.reference}`, `

❌ Votre note a été refusée par la Finance

${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} à corriger

Bonjour ${note.prenom} ${note.nom},

Votre note ${note.reference} (${montantFormate} €) a été refusée par ${verificateurNom} (Vérificateur Finance).

Lignes à corriger (${lignesRefusees.length})
${tableLignesHtml}
Ligne Motif
📝 Que faire maintenant ?
  1. Connectez-vous à la plateforme NDF
  2. Ouvrez la note ${note.reference}
  3. Corrigez uniquement les lignes listées ci-dessus
  4. Resoumettez la note (les lignes validées sont conservées)
✏️ Corriger ma note →
` ); } catch (e) { console.error('Email collab refus:', e.message); } // Notif N1 if (note.n1Id && note.emailN1) { try { await creerNotification({ destinataireId: note.n1Id, destinataireEmail: note.emailN1, type: 'refus', titre: `⚠️ Note ${note.reference} refusée par la Finance`, message: `La note ${note.reference} de ${note.prenom} ${note.nom} a été refusée par ${verificateurNom} (${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} à corriger).`, noteId }); notifiedN1 = true; } catch (e) { console.error('Notif BDD N1 refus:', e.message); } try { await sendMailGraph( note.emailN1, `⚠️ Note ${note.reference} refusée par la Finance`, `

⚠️ Note refusée par la Finance

Bonjour ${note.prenomN1} ${note.nomN1},

La note ${note.reference} de ${note.prenom} ${note.nom} que vous aviez validée a été refusée par ${verificateurNom} (Vérificateur Finance).

${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} à corriger
${lignesRefusees.map(r => { const ligne = lignesData[r.index] || {}; const label = ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`; return ``; }).join('')}
${label}${r.motif.trim()}

${note.prenom} ${note.nom} a été notifié et doit corriger uniquement les lignes listées.

` ); } catch (e) { console.error('Email N1 refus:', e.message); } } } res.json({ success: true, statut: 'refuse_verif', nbLignesRefusees: lignesRefusees.length, notifiedCollab, notifiedN1 }); } catch (error) { try { await transaction.rollback(); } catch { } console.error('Erreur POST /verificateur/notes/:id/refuser:', error.message); res.status(500).json({ error: error.message }); } }); app.get('/api/verificateur/historique', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès refusé' }); try { const request = pool.request().input('verificateurId', sql.Int, req.user.id); let campusWhere = ''; if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) { const campusCode = normalizeCampus(req.user.campus); if (campusCode) { request.input('campus', sql.NVarChar, `%${campusCode}%`); campusWhere = 'AND c.campus LIKE @campus'; } } const result = await request.query(` SELECT n.id, n.reference, n.libelle, n.montant, n.statut, n.dateVerification, n.commentaireVerification, n.lignesJson, n.fichiers, c.nom + ' ' + c.prenom AS collaborateur, c.campus, c.departement FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.verificateurFinanceId = @verificateurId AND n.statut IN ('verifie', 'paiementenattente', 'payee', 'refuse_verif', 'refuse_verif_archive', 'non_conforme_verif', 'non_conforme_archive') ${campusWhere} ORDER BY n.dateVerification DESC `); const notes = result.recordset; if (!notes.length) return res.json([]); // Récupérer toutes les lignes refusées en un seul appel const noteIds = notes.map(n => n.id).join(','); const refusedRows = await pool.request().query(` SELECT noteDeFraisId, ligneIndex, ligneLibelle, ligneCategorie, motif, dateRefus FROM LignesRefusees WHERE noteDeFraisId IN (${noteIds}) ORDER BY noteDeFraisId, ligneIndex ASC `); const refusedByNote = {}; for (const r of refusedRows.recordset) { if (!refusedByNote[r.noteDeFraisId]) refusedByNote[r.noteDeFraisId] = []; refusedByNote[r.noteDeFraisId].push({ index: r.ligneIndex, motif: r.motif, ligneLibelle: r.ligneLibelle, ligneCategorie: r.ligneCategorie, dateRefus: r.dateRefus }); } const enriched = notes.map(row => { let nbLignes = 0; try { nbLignes = (JSON.parse(row.lignesJson || '[]')).length; } catch { } const lignesRefusees = refusedByNote[row.id] || []; const nbLignesRefusees = lignesRefusees.length; const nbLignesOk = Math.max(0, nbLignes - nbLignesRefusees); const isRefusee = row.statut === 'refuse_verif' || row.statut === 'refuse_verif_archive' || row.statut === 'non_conforme_verif' || row.statut === 'non_conforme_archive'; return { ...row, statut: isRefusee ? 'REFUSEE' : 'VERIFIEE', nbLignes, nbLignesOk: isRefusee ? nbLignesOk : nbLignes, nbLignesRefusees, lignesRefusees, commentaire: row.commentaireVerification, }; }); res.json(enriched); } catch (error) { console.error('GET /api/verificateur/historique:', error.message); res.status(500).json({ error: error.message }); } }); // ================================================ // SYNC ENTRA — réservé Finance // ================================================ app.post('/api/sync-entra', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ success: false, message: 'Accès refusé - réservé à la Finance' }); const results = await syncEntraIdUsers(); res.json({ success: true, message: 'Synchronisation terminée', stats: results }); }); app.get('/api/sync-status', authenticateToken, async (req, res) => { try { const totalDB = await pool.request().query(` SELECT COUNT(*) as total, SUM(CASE WHEN Actif = 1 THEN 1 ELSE 0 END) as actifs, SUM(CASE WHEN Actif = 0 THEN 1 ELSE 0 END) as inactifs FROM CollaborateurAD `); const derniers = await pool.request().query(` SELECT TOP 10 id, prenom, nom, email, role, Actif, dateCreation, dateMiseAJour FROM CollaborateurAD ORDER BY dateMiseAJour DESC `); let entraStatus = { connected: false }; try { const token = await getGraphToken(); if (token) { const groupResponse = await axios.get( `https://graph.microsoft.com/v1.0/groups/${AZURE_CONFIG.groupId}?$select=id,displayName`, { headers: { Authorization: `Bearer ${token}` } } ); entraStatus = { connected: true, groupName: groupResponse.data.displayName, groupId: AZURE_CONFIG.groupId }; } } catch (err) { entraStatus.error = err.message; } res.json({ success: true, database: totalDB.recordset[0], entraId: entraStatus, derniers_utilisateurs: derniers.recordset }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); // ── GET tarif km actif ───────────────────────────────────────────── app.get('/api/parametres/km', authenticateToken, async (req, res) => { try { const result = await pool.request().query(` SELECT TOP 1 tarifParKm FROM ParametresKm WHERE actif = 1 ORDER BY annee DESC `); const tarif = result.recordset[0]?.tarifParKm ?? 0.697; res.json({ tarifKm: tarif }); } catch (e) { res.status(500).json({ error: e.message }); } }); // ── GET taux TVA actifs ──────────────────────────────────────────── app.get('/api/parametres/tva', authenticateToken, async (req, res) => { try { const result = await pool.request().query(` SELECT taux, libelle, categorie FROM ParametresTVA WHERE actif = 1 AND dateDebut <= GETDATE() AND (dateFin IS NULL OR dateFin >= GETDATE()) ORDER BY taux ASC `); res.json(result.recordset); } catch (e) { res.status(500).json({ error: e.message }); } }); // ── PUT tarif km — réservé Finance ──────────────────────────────── app.put('/api/parametres/km', authenticateToken, requireRole('Finance'), async (req, res) => { const { tarifParKm, annee } = req.body; if (!tarifParKm || isNaN(tarifParKm)) return res.status(400).json({ error: 'Tarif invalide' }); try { await pool.request().query(`UPDATE ParametresKm SET actif = 0 WHERE actif = 1`); await pool.request() .input('tarif', sql.Decimal(10, 4), parseFloat(tarifParKm)) .input('annee', sql.Int, annee || new Date().getFullYear()) .query(`INSERT INTO ParametresKm (tarifParKm, annee, actif, DateCreation) VALUES (@tarif, @annee, 1, GETDATE())`); res.json({ success: true, tarifParKm: parseFloat(tarifParKm) }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/parametres/bareme-km', authenticateToken, (req, res) => { res.json({ baremes: [ { chevaux: 3, label: '3 CV et moins', t1: 0.529, t2_a: 0.316, t2_b: 1061, t3: 0.369 }, { chevaux: 4, label: '4 CV', t1: 0.606, t2_a: 0.340, t2_b: 1330, t3: 0.408 }, { chevaux: 5, label: '5 CV', t1: 0.636, t2_a: 0.356, t2_b: 1391, t3: 0.427 }, { chevaux: 6, label: '6 CV', t1: 0.665, t2_a: 0.374, t2_b: 1457, t3: 0.448 }, { chevaux: 7, label: '7 CV et plus', t1: 0.697, t2_a: 0.394, t2_b: 1512, t3: 0.470 }, ] }); }); // ✅ MODIFIÉ — profil expose les rôles depuis UtilisateurRoles app.get('/api/profile', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('id', sql.Int, req.user.id) .query(` SELECT id, nom, prenom, email, role, fonction as poste, TypeContrat, DateEntree, campus, departement, societe, adresse_rue, adresse_cp, adresse_ville, adresse_pays FROM CollaborateurAD WHERE id = @id `); if (!result.recordset.length) return res.status(404).json({ error: 'Profil non trouvé' }); const userProfile = result.recordset[0]; // ✅ Rôles depuis UtilisateurRoles au lieu de CollaborateurAD.role const rolesDB = await getRolesForUser(req.user.id); userProfile.roles = rolesDB.length > 0 ? rolesDB : ['Collaborateur']; res.json(userProfile); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/profile/adresse', authenticateToken, async (req, res) => { try { const { adresse_rue, adresse_cp, adresse_ville, adresse_pays, societe } = req.body; if (!adresse_rue || !adresse_cp || !adresse_ville || !adresse_pays) return res.status(400).json({ error: 'Tous les champs adresse sont obligatoires' }); await pool.request() .input('id', sql.Int, req.user.id) .input('adresse_rue', sql.NVarChar, adresse_rue.trim()) .input('adresse_cp', sql.NVarChar, adresse_cp.trim()) .input('adresse_ville', sql.NVarChar, adresse_ville.trim()) .input('adresse_pays', sql.NVarChar, adresse_pays.trim()) .input('societe', sql.NVarChar, societe ? societe.trim() : null) .query(` UPDATE CollaborateurAD SET adresse_rue = @adresse_rue, adresse_cp = @adresse_cp, adresse_ville = @adresse_ville, adresse_pays = @adresse_pays, societe = COALESCE(@societe, societe), DateModification = GETDATE() WHERE id = @id `); res.json({ success: true }); } catch (error) { console.error('PUT /api/profile/adresse :', error.message); res.status(500).json({ error: error.message }); } }); // ================================================ // HELPERS // ================================================ async function genererReference(campus, nom, prenom) { const now = new Date(); const jour = String(now.getDate()).padStart(2, '0'); const mois = String(now.getMonth() + 1).padStart(2, '0'); const annee = now.getFullYear(); const campusCode = normalizeCampus(campus) || 'XXX'; const nomClean = (nom || '').toUpperCase() .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/[^A-Z]/g, ''); const prenomInitiale = (prenom || '').charAt(0).toUpperCase() .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/[^A-Z]/g, ''); const nomPart = `${nomClean}.${prenomInitiale}`; const tx = new sql.Transaction(pool); await tx.begin(); try { await new sql.Request(tx).query(` IF NOT EXISTS (SELECT 1 FROM NDFSequence WHERE annee = ${annee}) INSERT INTO NDFSequence (annee, compteur) VALUES (${annee}, 0) `); const result = await new sql.Request(tx).query(` UPDATE NDFSequence SET compteur = compteur + 1 OUTPUT INSERTED.compteur WHERE annee = ${annee} `); await tx.commit(); const num = String(result.recordset[0].compteur).padStart(2, '0'); // Format : NDF01-SQY-IMER.O-05-05-2026 return `NDF${num}-${campusCode}-${nomPart}-${jour}-${mois}-${annee}`; } catch (e) { await tx.rollback(); throw e; } } async function uploadToSharePoint(file, ndfReference, collaborateurNomPrenom) { const accessToken = await getGraphToken(); if (!accessToken) throw new Error('Token Graph indisponible'); const safeName = file.originalname.replace(/[^a-zA-Z0-9._\-]/g, '_'); const fileName = `${ndfReference}_${safeName}`; const folderPath = `${SHAREPOINT_CONFIG.basePath}/${collaborateurNomPrenom}`; const uploadPath = `${folderPath}/${fileName}`; const res = await axios.put( `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${uploadPath}:/content`, file.buffer, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': file.mimetype } } ); return { fileName, uploadUrl: res.data.webUrl }; } const downloadUrlCache = new Map(); // webUrl SP → { url, expiresAt } async function downloadFromSharePoint(webUrl) { const accessToken = await getGraphToken(); // ✅ Cache de l'URL de téléchargement direct (valable ~1h) const cached = downloadUrlCache.get(webUrl); if (cached && Date.now() < cached.expiresAt) { try { const fileRes = await axios.get(cached.url, { responseType: 'arraybuffer', timeout: 15000 }); return Buffer.from(fileRes.data); } catch { downloadUrlCache.delete(webUrl); // URL expirée, on refait } } const urlObj = new URL(webUrl); const fullPath = decodeURIComponent(urlObj.pathname); const marker = '/Shared Documents/'; const markerAlt = '/Documents/'; let relativePath = ''; if (fullPath.includes(marker)) relativePath = fullPath.split(marker)[1]; else if (fullPath.includes(markerAlt)) relativePath = fullPath.split(markerAlt)[1]; else { const parts = fullPath.split('/sites/')[1]?.split('/'); relativePath = parts?.slice(2).join('/') || ''; } const metaRes = await axios.get( `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}`, { headers: { Authorization: `Bearer ${accessToken}` }, timeout: 5000 } ); const downloadUrl = metaRes.data['@microsoft.graph.downloadUrl']; if (!downloadUrl) throw new Error('downloadUrl absent de la réponse Graph'); // Mettre en cache 50 min (les URLs pré-signées expirent vers 1h) downloadUrlCache.set(webUrl, { url: downloadUrl, expiresAt: Date.now() + 50 * 60 * 1000 }); const fileRes = await axios.get(downloadUrl, { responseType: 'arraybuffer', timeout: 15000 }); return Buffer.from(fileRes.data); } async function uploadToSharePointHierarchique(file, noteRef, nomDossier, moisDossier) { const accessToken = await getGraphToken(); if (!accessToken) throw new Error('Token Graph indisponible'); const safeName = (file.originalname || 'fichier').replace(/[^a-zA-Z0-9._\-]/g, '_'); const fileName = safeName.startsWith(noteRef) ? safeName : `${noteRef}_${safeName}`; // moisDossier format "2026-05" → annee="2026", mois="05" const [annee, mois] = (moisDossier || '').split('-'); // Structure : Notes de Frais / Nom_Prenom / 2026 / 05 / NDF01-SQY-IMER.O-05-05-2026 / const folderPath = `${SHAREPOINT_CONFIG.basePath}/${nomDossier}/${annee}/${mois}/${noteRef}`; const uploadPath = `${folderPath}/${fileName}`; const res = await axios.put( `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${uploadPath}:/content`, file.buffer, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': file.mimetype || 'application/octet-stream' }, maxBodyLength: Infinity, maxContentLength: Infinity } ); return { fileName, uploadUrl: res.data.webUrl, folderPath }; } // ══════════════════════════════════════════════════════ // 📎 RÉCAP COMPLET (fiche PDF/A + justificatifs fusionnés) // ══════════════════════════════════════════════════════ async function generateRecapWithJustifs(noteData, justifFiles, signaturesOpt) { const signatures = signaturesOpt || [ { niveau: 'COLLAB', nomPrenom: noteData.nomPrenom || '', date: new Date(), action: 'soumettre', commentaire: null } ]; const ficheBuffer = await generateFicheSignee(noteData, signatures); const finalPdf = await PDFLib.create(); const fichePdf = await PDFLib.load(ficheBuffer); const fichePages = await finalPdf.copyPages(fichePdf, fichePdf.getPageIndices()); fichePages.forEach(p => finalPdf.addPage(p)); for (const file of justifFiles) { const mimetype = file.mimetype || ''; if (mimetype === 'application/pdf') { try { const justifPdf = await PDFLib.load(file.buffer); const pages = await finalPdf.copyPages(justifPdf, justifPdf.getPageIndices()); pages.forEach(p => finalPdf.addPage(p)); } catch (e) { console.warn(`⚠️ PDF non intégrable: ${file.originalname} — ${e.message}`); } } else if (mimetype.startsWith('image/')) { try { const page = finalPdf.addPage([595, 842]); const img = mimetype === 'image/png' ? await finalPdf.embedPng(file.buffer) : await finalPdf.embedJpg(file.buffer); const maxW = 495, maxH = 742; const ratio = Math.min(maxW / img.width, maxH / img.height); const w = img.width * ratio, h = img.height * ratio; page.drawImage(img, { x: (595 - w) / 2, y: (842 - h) / 2, width: w, height: h }); } catch (e) { console.warn(`⚠️ Image non intégrable: ${file.originalname} — ${e.message}`); } } } return Buffer.from(await finalPdf.save()); } // GET /api/notes/:id/download-urls — préchargement des URLs directes app.get('/api/notes/:id/download-urls', authenticateToken, async (req, res) => { try { const noteId = parseInt(req.params.id); // Récupérer les fichiers de la note const noteResult = await pool.request() .input('id', sql.Int, noteId) .query(`SELECT fichiers, lignesJson FROM NoteDeFrais WHERE id = @id`); if (!noteResult.recordset.length) return res.json({}); const note = noteResult.recordset[0]; let allUrls = []; // Fichiers globaux try { const fichiers = JSON.parse(note.fichiers || '[]'); fichiers.forEach(f => { if (f.uploadUrl) allUrls.push(f.uploadUrl); }); } catch { } // Fichiers des lignes try { const lignes = JSON.parse(note.lignesJson || '[]'); lignes.forEach(l => { (l.qrFiles || []).forEach(f => { if (f.uploadUrl) allUrls.push(f.uploadUrl); }); }); } catch { } if (!allUrls.length) return res.json({}); const accessToken = await getGraphToken(); const result = {}; // ✅ Graph Batch — résout toutes les URLs en UNE SEULE requête HTTP const batchRequests = allUrls.slice(0, 20).map((url, i) => { const urlObj = new URL(url); const fullPath = decodeURIComponent(urlObj.pathname); const marker = '/Shared Documents/'; const markerAlt = '/Documents/'; let relativePath = ''; if (fullPath.includes(marker)) relativePath = fullPath.split(marker)[1]; else if (fullPath.includes(markerAlt)) relativePath = fullPath.split(markerAlt)[1]; else { const parts = fullPath.split('/sites/')[1]?.split('/'); relativePath = parts?.slice(2).join('/') || ''; } return { id: String(i), method: 'GET', url: `/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}` }; }); const batchRes = await axios.post( 'https://graph.microsoft.com/v1.0/$batch', { requests: batchRequests }, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } } ); for (const response of batchRes.data.responses) { const i = parseInt(response.id); const downloadUrl = response.body?.['@microsoft.graph.downloadUrl']; if (downloadUrl && allUrls[i]) { result[allUrls[i]] = downloadUrl; // Mettre en cache côté serveur aussi downloadUrlCache.set(allUrls[i], { url: downloadUrl, expiresAt: Date.now() + 50 * 60 * 1000 }); } } res.json(result); } catch (error) { console.error('GET /api/notes/:id/download-urls:', error.message); res.json({}); // Fail silencieux — le client tombera sur le proxy normal } }); // ══════════════════════════════════════════════════════ // POST /api/notes — Créer une note de frais (multi-lignes) // ══════════════════════════════════════════════════════ app.post('/api/notes', authenticateToken, upload.any(), async (req, res) => { try { const { libelle, date, description, participants, nombreParticipants, lignes, qrNoteRef } = req.body; if (!libelle || !date || !lignes) return res.status(400).json({ error: 'Libellé, date et lignes sont obligatoires' }); let lignesParsed; try { lignesParsed = typeof lignes === 'string' ? JSON.parse(lignes) : lignes; } catch { return res.status(400).json({ error: 'Format des lignes invalide' }); } if (!Array.isArray(lignesParsed) || lignesParsed.length === 0) return res.status(400).json({ error: 'Au moins une ligne est obligatoire' }); // ── Tarif KM ───────────────────────────────────────────────────── let tarifKm = await getTarifKm(); try { const annee = new Date().getFullYear(); const kmParam = await pool.request() .input('annee', sql.Int, annee) .query(`SELECT TOP 1 tarifParKm FROM ParametresKm WHERE annee = @annee AND actif = 1 ORDER BY DateCreation DESC`); if (kmParam.recordset[0]?.tarifParKm) tarifKm = parseFloat(kmParam.recordset[0].tarifParKm); } catch { } const lignesPDF = preparerLignesPDF(lignesParsed, tarifKm); const montantTTC = lignesPDF.reduce((s, l) => s + l.montantTTC, 0); const indemKm = lignesPDF.reduce((s, l) => s + l.indemniteKm, 0); const kmTotal = lignesPDF.reduce((s, l) => s + (l.km || 0), 0); const montantFinal = parseFloat((montantTTC + indemKm).toFixed(2)); const montantFormate = montantFinal.toFixed(2); const isKmOnly = lignesPDF.every(l => l.montantTTC === 0 && l.indemniteKm > 0); const categorieNote = isKmOnly ? 'Kilométrique pur' : 'Multiple'; <<<<<<< HEAD // ── Collaborateur + hiérarchie (2 requêtes SQL, inchangé) ──────── ======= // ── Collaborateur + hiérarchie ──────────────────────────────────── >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const collabResult = await pool.request() .input('id', sql.Int, req.user.id) .query(`SELECT id, email, prenom, nom, departement, campus FROM CollaborateurAD WHERE id = @id`); if (!collabResult.recordset.length) return res.status(404).json({ error: 'Collaborateur non trouvé' }); const collaborateur = collabResult.recordset[0]; const nomPrenom = `${collaborateur.nom.toUpperCase()} ${collaborateur.prenom}`; const dateObj = new Date(date); const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1); const hierarchie = await pool.request() .input('collabId', sql.Int, req.user.id) .query(` <<<<<<< HEAD SELECT h.SuperieurId, h.[SuperieurIdn+2], s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1, s2.email AS emailN2 FROM HierarchieValidationNDF h LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId LEFT JOIN CollaborateurAD s2 ON s2.id = h.[SuperieurIdn+2] WHERE h.CollaborateurId = @collabId `); const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null; const n2Id = hierarchie.recordset[0]?.['SuperieurIdn+2'] ?? null; ======= SELECT h.SuperieurId, s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1 FROM HierarchieValidationNDF h LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId WHERE h.CollaborateurId = @collabId `); const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null; >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const emailN1 = hierarchie.recordset[0]?.emailN1 ?? null; const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null; const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null; <<<<<<< HEAD const reference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom); const nomDossier = `${collaborateur.prenom}_${collaborateur.nom}`.replace(/\s+/g, '_'); const now = new Date(); ======= const reference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom); const nomDossier = `${collaborateur.nom}_${collaborateur.prenom}` .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9_]/g, '_'); const now = new Date(); >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; // ── Collecte des fichiers (QR global) ──────────────────────────── const allFiles = [...(req.files || [])]; if (qrNoteRef) { const qrToken = await pool.request() .input('noteRef', sql.NVarChar, qrNoteRef) .query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`); if (qrToken.recordset.length && qrToken.recordset[0].fichiers) { const qrFichiers = JSON.parse(qrToken.recordset[0].fichiers); <<<<<<< HEAD // ✅ Téléchargements QR globaux en parallèle ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const qrDownloads = await Promise.all( qrFichiers.map(f => downloadFromSharePoint(f.uploadUrl) .then(buf => ({ buffer: buf, originalname: f.fileName, mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg', size: buf.length })) .catch(e => { console.warn('⚠️ QR global download fail:', e.message); return null; }) ) ); qrDownloads.filter(Boolean).forEach(f => allFiles.push(f)); } } // ── QR par ligne : téléchargement + upload en parallèle ────────── await Promise.all( lignesParsed.map(async (ligne, i) => { const ligneQrRef = ligne.qrNoteRef; if (!ligneQrRef) return; try { const qrLigne = await pool.request() .input('noteRef', sql.NVarChar, ligneQrRef) .query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`); if (!qrLigne.recordset.length || !qrLigne.recordset[0].fichiers) { console.warn(`⚠️ QR ligne ${i} (${ligneQrRef}) : token introuvable ou non utilisé`); return; } const qrFichiers = JSON.parse(qrLigne.recordset[0].fichiers); if (!ligne.qrFiles) ligne.qrFiles = []; <<<<<<< HEAD // téléchargement + upload SharePoint en parallèle pour chaque fichier de la ligne ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) await Promise.all( qrFichiers.map(async f => { try { const buf = await downloadFromSharePoint(f.uploadUrl); const fileObj = { buffer: buf, originalname: f.fileName, mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg', size: buf.length }; allFiles.push(fileObj); const uploaded = await uploadToSharePointHierarchique(fileObj, reference, nomDossier, moisDossier); const dejaSauve = ligne.qrFiles.some(x => x.fileName === uploaded.fileName); if (!dejaSauve) { ligne.qrFiles.push({ fileName: uploaded.fileName, uploadUrl: uploaded.uploadUrl }); } console.log(`✅ QR ligne ${i} stocké: ${uploaded.fileName}`); } catch (e) { console.warn(`⚠️ QR ligne ${i} download/upload fail:`, e.message); } }) ); } catch (e) { console.warn(`⚠️ QR ligne ${i} erreur DB:`, e.message); } }) ); <<<<<<< HEAD // ✅ Recalculer lignesJson APRÈS enrichissement des qrFiles ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const lignesJsonFinal = JSON.stringify(lignesParsed); // ── Upload justificatifs en parallèle ──────────────────────────── const fichiersUploades = (await Promise.all( allFiles.map(file => uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier) .catch(e => { console.error(`❌ Upload justif ${file.originalname}:`, e.message); return null; }) ) )).filter(Boolean); <<<<<<< HEAD // ── Préparer noteDataPDF (utilisé en sync ET en async) ─────────── ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const noteDataPDF = { reference, nomPrenom, mois: moisCapitalized, date, categorie: categorieNote, libelle, montant: montantFinal, lignes: lignesParsed, lignesJson: lignesJsonFinal, tarifKm, statut: 'enattente', departement: collaborateur.departement, participants: participants || null, }; // ── Insérer la note en BDD ─────────────────────────────────────── const insertResult = await pool.request() .input('reference', sql.NVarChar, reference) .input('collaborateurId', sql.Int, req.user.id) .input('libelle', sql.NVarChar, libelle) .input('montant', sql.Decimal, montantFinal) .input('date', sql.Date, new Date(date)) .input('categorie', sql.NVarChar, categorieNote) .input('description', sql.NVarChar, description || null) .input('participants', sql.NVarChar, participants ? String(participants) : null) .input('nombreParticipants', sql.Int, nombreParticipants ? parseInt(nombreParticipants) : null) .input('sharepointUrl', sql.NVarChar, fichiersUploades[0]?.uploadUrl || null) .input('fichiers', sql.NVarChar, JSON.stringify(fichiersUploades)) .input('statut', sql.NVarChar, 'enattente') .input('validateurN1Id', sql.Int, n1Id) <<<<<<< HEAD .input('validateurN2Id', sql.Int, n2Id) ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) .input('montantHT', sql.Decimal, null) .input('tauxTVA', sql.Decimal, null) .input('montantTVA21', sql.Decimal, null) .input('montantTVA55', sql.Decimal, null) .input('montantTVA10', sql.Decimal, null) .input('montantTVA20', sql.Decimal, null) .input('km', sql.Decimal, kmTotal || null) .input('indemniteKm', sql.Decimal, indemKm || null) .input('lignesJson', sql.NVarChar, lignesJsonFinal) .query(` INSERT INTO NoteDeFrais (reference, collaborateurId, libelle, montant, date, categorie, description, participants, nombreParticipants, sharepointUrl, fichiers, <<<<<<< HEAD statut, validateurN1Id, validateurN2Id, ======= statut, validateurN1Id, >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) montantHT, tauxTVA, montantTVA21, montantTVA55, montantTVA10, montantTVA20, km, indemniteKm, lignesJson) OUTPUT INSERTED.id, INSERTED.reference VALUES (@reference, @collaborateurId, @libelle, @montant, @date, @categorie, @description, @participants, @nombreParticipants, @sharepointUrl, @fichiers, <<<<<<< HEAD @statut, @validateurN1Id, @validateurN2Id, ======= @statut, @validateurN1Id, >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) @montantHT, @tauxTVA, @montantTVA21, @montantTVA55, @montantTVA10, @montantTVA20, @km, @indemniteKm, @lignesJson) `); const noteCreee = insertResult.recordset[0]; <<<<<<< HEAD // ── Insérer les lignes (séquentiel, rapide car SQL local) ──────── ======= // ── Insérer les lignes ──────────────────────────────────────────── >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) for (let i = 0; i < lignesParsed.length; i++) { const l = lignesParsed[i]; const pdf = lignesPDF[i]; try { await pool.request() .input('noteId', sql.Int, noteCreee.id) .input('numPiece', sql.Int, i + 1) .input('date', sql.Date, new Date(l.date)) .input('nature', sql.NVarChar, l.categorie || '') .input('libelle', sql.NVarChar, l.libelle || '') .input('km', sql.Decimal, pdf.km || null) .input('montantTTC', sql.Decimal, pdf.montantTTC || null) .input('tva21', sql.Decimal, pdf.tva21 || null) .input('tva55', sql.Decimal, pdf.tva55 || null) .input('tva10', sql.Decimal, pdf.tva10 || null) .input('tva20', sql.Decimal, pdf.tva20 || null) .input('montantHT', sql.Decimal, pdf.montantHT || null) .input('tauxTVA', sql.Decimal, parseFloat(l.tauxTVA) || null) .input('indemniteKm', sql.Decimal, pdf.indemniteKm || null) .query(` INSERT INTO LigneNoteDeFrais (noteDeFraisId, numPiece, date, nature, libelle, km, montantTTC, tva21, tva55, tva10, tva20, montantHT, tauxTVA, indemniteKm) VALUES (@noteId, @numPiece, @date, @nature, @libelle, @km, @montantTTC, @tva21, @tva55, @tva10, @tva20, @montantHT, @tauxTVA, @indemniteKm) `); } catch (e) { console.error(`❌ Insertion ligne ${i + 1}:`, e.message); } } console.log(`✅ Note créée: ${reference} (${lignesParsed.length} lignes) par ${collaborateur.email}`); <<<<<<< HEAD // ✅ Répondre immédiatement — le client n'attend plus le PDF ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) res.status(201).json({ success: true, id: noteCreee.id, reference: noteCreee.reference, fichiers: fichiersUploades, <<<<<<< HEAD recapUrl: null, // sera mis à jour en BDD en arrière-plan pending: true, }); // ── Traitement lourd en arrière-plan (non bloquant) ────────────── setImmediate(async () => { const fichiersAsync = [...fichiersUploades]; // copie locale pour l'async ======= recapUrl: null, pending: true, }); // ── Traitement lourd en arrière-plan ───────────────────────────── setImmediate(async () => { const fichiersAsync = [...fichiersUploades]; >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) try { console.log(`🔄 [ASYNC] PDF + emails pour ${reference}...`); const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net'; <<<<<<< HEAD // Fiche PDF soumission ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) let ficheResult = null; try { const fichePDF = await generateFicheSignee( noteDataPDF, [{ niveau: 'COLLAB', nomPrenom, date: new Date(), action: 'soumettre', commentaire: null }] ); ficheResult = await uploadToSharePointHierarchique( { buffer: fichePDF, originalname: `${reference}_soumission.pdf`, mimetype: 'application/pdf', size: fichePDF.length }, reference, nomDossier, moisDossier ); fichiersAsync.push(ficheResult); console.log(`✅ [ASYNC] Fiche soumission: ${ficheResult.fileName}`); } catch (e) { console.error('❌ [ASYNC] Fiche PDF:', e.message); } <<<<<<< HEAD // Récap PDF complet ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) let recapUrl = null; try { const recapBuffer = await generateRecapWithJustifs(noteDataPDF, allFiles); const recapResult = await uploadToSharePointHierarchique( { buffer: recapBuffer, originalname: `${reference}_recap.pdf`, mimetype: 'application/pdf', size: recapBuffer.length }, reference, nomDossier, moisDossier ); fichiersAsync.push(recapResult); recapUrl = recapResult.uploadUrl; console.log(`✅ [ASYNC] Récap PDF: ${recapResult.fileName}`); } catch (e) { console.error('❌ [ASYNC] Récap PDF:', e.message); } <<<<<<< HEAD // Mettre à jour BDD avec PDF final ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) try { await pool.request() .input('id', sql.Int, noteCreee.id) .input('sharepointUrl', sql.NVarChar, recapUrl || ficheResult?.uploadUrl || null) .input('fichiers', sql.NVarChar, JSON.stringify(fichiersAsync)) .query(` UPDATE NoteDeFrais SET sharepointUrl = @sharepointUrl, fichiers = @fichiers, DateModification = GETDATE() WHERE id = @id `); } catch (e) { console.error('❌ [ASYNC] UPDATE BDD fichiers:', e.message); } <<<<<<< HEAD // Notifications BDD (parallèle) ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) await Promise.all([ creerNotification({ destinataireId: collaborateur.id, destinataireEmail: null, type: 'soumission', titre: `✅ Note ${reference} soumise avec succès`, message: `Votre note ${reference} — ${libelle} (${montantFormate} €) a bien été reçue.`, noteId: noteCreee.id }).catch(e => console.error('❌ [ASYNC] Notif BDD collab:', e.message)), n1Id ? creerNotification({ destinataireId: n1Id, destinataireEmail: emailN1, type: 'validation', titre: `📋 Note à valider — ${reference}`, message: `${collaborateur.prenom} ${collaborateur.nom} a soumis une note de ${montantFormate} € en attente de votre validation.`, noteId: noteCreee.id }).catch(e => console.error('❌ [ASYNC] Notif BDD N1:', e.message)) : Promise.resolve(), ]); <<<<<<< HEAD // Emails (parallèle) ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) await Promise.all([ sendMailGraph( collaborateur.email, `✅ Accusé de réception — Note ${reference}`, `

✅ Note de frais bien reçue

Bonjour ${collaborateur.prenom} ${collaborateur.nom},

Votre note ${reference} — ${libelle} (${montantFormate} €) a bien été enregistrée.

${nomN1 ? `

Validateur : ${prenomN1} ${nomN1}

` : ''} ${recapUrl ? `

📎 Voir le récapitulatif PDF

` : ''}
Suivre mes notes →
` ).catch(e => console.error('❌ [ASYNC] Email collab:', e.message)), (n1Id && emailN1) ? sendMailGraph( emailN1, `📋 Note de frais à valider — ${reference}`, `

📋 Note de frais à valider

Bonjour ${prenomN1} ${nomN1},

${collaborateur.prenom} ${collaborateur.nom} a soumis la note ${reference} — ${libelle} (${montantFormate} €).

${recapUrl ? `

📎 Récapitulatif + justificatifs

` : ''}
Valider sur la plateforme →
` ).catch(e => console.error('❌ [ASYNC] Email N1:', e.message)) : Promise.resolve(), ]); console.log(`✅ [ASYNC] Traitement terminé pour ${reference}`); } catch (e) { console.error(`❌ [ASYNC] Erreur générale ${reference}:`, e.message); } }); } catch (error) { console.error('❌ Erreur POST /api/notes:', error.message); res.status(500).json({ error: error.message }); } }); app.put('/api/notes/:id', authenticateToken, upload.any(), async (req, res) => { try { const noteId = parseInt(req.params.id); const userId = req.user.id; const noteCheck = await pool.request() .input('id', sql.Int, noteId) .input('collabId', sql.Int, userId) .query(` SELECT * FROM NoteDeFrais WHERE id = @id AND collaborateurId = @collabId AND statut IN ('enattente', 'refuse', 'refuse_verif', 'non_conforme_verif', 'brouillon') `); if (!noteCheck.recordset.length) return res.status(403).json({ error: 'Note introuvable ou non modifiable (statut incompatible)' }); const noteExist = noteCheck.recordset[0]; // ✅ Détecter si correction (refusée ou non-conforme) → nouvelle note const estCorrection = noteExist.statut === 'refuse' || noteExist.statut === 'refuse_verif' || noteExist.statut === 'non_conforme_verif'; const { libelle, date, description, participants, nombreParticipants, lignes } = req.body; if (!libelle || !date || !lignes) return res.status(400).json({ error: 'Libellé, date et lignes sont obligatoires' }); let lignesParsed; try { lignesParsed = typeof lignes === 'string' ? JSON.parse(lignes) : lignes; } catch { return res.status(400).json({ error: 'Format des lignes invalide' }); } // ── Calculs communs ─────────────────────────────────────────────────── const montantFinal = lignesParsed.reduce((total, l) => { const isKm = (l.categorie || '').toLowerCase().includes('kilom'); if (isKm) { const km = parseFloat(l.km) || 0; const cv = parseInt(l.chevaux) || 7; return total + getIndemniteKmServer(km, cv); } const items = l.tvaItems || [{ montantTTC: l.montant, taux: l.tauxTVA }]; return total + items.reduce((s, i) => s + (parseFloat(i.montantTTC) || 0), 0); }, 0); const indemKm = lignesParsed.reduce((s, l) => { if ((l.categorie || '').toLowerCase().includes('kilom')) return s + getIndemniteKmServer(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7); return s; }, 0); const kmTotal = lignesParsed.reduce((s, l) => s + ((l.categorie || '').toLowerCase().includes('kilom') ? (parseFloat(l.km) || 0) : 0), 0 ); const isKmOnly = lignesParsed.every(l => (l.categorie || '').toLowerCase().includes('kilom')); const categorieNote = isKmOnly ? 'Kilométrique pur' : 'Multiple'; const collabResult = await pool.request() .input('id', sql.Int, userId) .query(`SELECT id, email, prenom, nom, departement, campus FROM CollaborateurAD WHERE id = @id`); const collaborateur = collabResult.recordset[0]; const nomPrenom = `${collaborateur.nom.toUpperCase()} ${collaborateur.prenom}`; const dateObj = new Date(date); const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1); const nomDossier = `${collaborateur.nom}_${collaborateur.prenom}` .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9_]/g, '_'); const now = new Date(); const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; const tarifKmVal = await getTarifKm(); // ════════════════════════════════════════════════════════════════════ // CAS 1 — Note REFUSÉE ou NON_CONFORME_VERIF → créer une NOUVELLE note // ════════════════════════════════════════════════════════════════════ if (estCorrection) { // 1. Archiver l'ancienne note const statutArchive = noteExist.statut === 'non_conforme_verif' ? 'non_conforme_archive' : noteExist.statut === 'refuse_verif' ? 'refuse_verif_archive' : 'refuse_archive'; await pool.request() .input('id', sql.Int, noteId) .input('statutArchive', sql.NVarChar, statutArchive) .query(` UPDATE NoteDeFrais SET statut = @statutArchive, DateModification = GETDATE() WHERE id = @id `); // 2. Nouvelle référence const nouvelleReference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom); // 3. Upload fichiers joints const allFiles = [...(req.files || [])]; const fichiersUploades = []; for (const file of allFiles) { try { const r = await uploadToSharePointHierarchique(file, nouvelleReference, nomDossier, moisDossier); fichiersUploades.push(r); } catch (e) { console.error(`❌ Upload justif correction ${file.originalname}:`, e.message); } } // 4. Récupérer fichiers QR par ligne for (let i = 0; i < lignesParsed.length; i++) { const ligneQrRef = lignesParsed[i].qrNoteRef; if (!ligneQrRef) continue; try { const qrLigne = await pool.request() .input('noteRef', sql.NVarChar, ligneQrRef) .query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`); if (qrLigne.recordset.length && qrLigne.recordset[0].fichiers) { const qrFichiers = JSON.parse(qrLigne.recordset[0].fichiers); for (const f of qrFichiers) { try { const buf = await downloadFromSharePoint(f.uploadUrl); const r = await uploadToSharePointHierarchique( { buffer: buf, originalname: f.fileName, mimetype: f.fileName?.endsWith('.pdf') ? 'application/pdf' : 'image/jpeg', size: buf.length }, nouvelleReference, nomDossier, moisDossier ); fichiersUploades.push(r); } catch (e) { console.warn(`⚠️ QR ligne ${i} download fail:`, e.message); } } } } catch (e) { console.warn(`⚠️ QR ligne ${i} erreur DB:`, e.message); } } // 5. Générer fiche PDF const noteDataPDF = { reference: nouvelleReference, nomPrenom, mois: moisCapitalized, date, categorie: categorieNote, libelle, montant: parseFloat(montantFinal.toFixed(2)), lignes: lignesParsed, lignesJson: JSON.stringify(lignesParsed), tarifKm: tarifKmVal, statut: 'enattente', departement: collaborateur.departement, }; try { const commentairePDF = noteExist.statut === 'non_conforme_verif' ? `Correction suite à non-conformité signalée sur ${noteExist.reference}` : `Correction suite au refus de ${noteExist.reference}`; const fichePDF = await generateFicheSignee(noteDataPDF, [{ niveau: 'COLLAB', nomPrenom, date: new Date(), action: 'soumettre', commentaire: commentairePDF }]); const ficheResult = await uploadToSharePointHierarchique( { buffer: fichePDF, originalname: `${nouvelleReference}_soumission.pdf`, mimetype: 'application/pdf', size: fichePDF.length }, nouvelleReference, nomDossier, moisDossier ); fichiersUploades.push(ficheResult); } catch (e) { console.error('❌ Fiche PDF correction:', e.message); } // 6. Hiérarchie const hierarchie = await pool.request() .input('collabId', sql.Int, userId) .query(` SELECT h.SuperieurId, s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1 FROM HierarchieValidationNDF h LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId WHERE h.CollaborateurId = @collabId `); const n1Id = hierarchie.recordset[0]?.SuperieurId ?? null; const emailN1 = hierarchie.recordset[0]?.emailN1 ?? null; const prenomN1 = hierarchie.recordset[0]?.prenomN1 ?? null; const nomN1 = hierarchie.recordset[0]?.nomN1 ?? null; // 7. Insérer nouvelle note const insertResult = await pool.request() .input('reference', sql.NVarChar, nouvelleReference) .input('collaborateurId', sql.Int, userId) .input('libelle', sql.NVarChar, libelle) .input('montant', sql.Decimal, parseFloat(montantFinal.toFixed(2))) .input('date', sql.Date, new Date(date)) .input('categorie', sql.NVarChar, categorieNote) .input('description', sql.NVarChar, description || null) .input('participants', sql.NVarChar, participants ? String(participants) : null) .input('nombreParticipants', sql.Int, nombreParticipants ? parseInt(nombreParticipants) : null) .input('fichiers', sql.NVarChar, JSON.stringify(fichiersUploades)) .input('statut', sql.NVarChar, 'enattente') .input('validateurN1Id', sql.Int, n1Id) .input('km', sql.Decimal, kmTotal || null) .input('indemniteKm', sql.Decimal, indemKm || null) .input('lignesJson', sql.NVarChar, JSON.stringify(lignesParsed)) .input('noteRefuseeId', sql.Int, noteId) .query(` INSERT INTO NoteDeFrais (reference, collaborateurId, libelle, montant, date, categorie, description, participants, nombreParticipants, fichiers, statut, validateurN1Id, km, indemniteKm, lignesJson, noteRefuseeId, DateCreation, DateModification) OUTPUT INSERTED.id, INSERTED.reference VALUES (@reference, @collaborateurId, @libelle, @montant, @date, @categorie, @description, @participants, @nombreParticipants, @fichiers, @statut, @validateurN1Id, @km, @indemniteKm, @lignesJson, @noteRefuseeId, GETDATE(), GETDATE()) `); const nouvelleNote = insertResult.recordset[0]; // 8. Insérer lignes const lignesPDF = preparerLignesPDF(lignesParsed, tarifKmVal); for (let i = 0; i < lignesParsed.length; i++) { const l = lignesParsed[i]; const pdf = lignesPDF[i] || {}; const isKmLine = (l.categorie || '').toLowerCase().includes('kilom'); const kmLine = parseFloat(l.km) || 0; const cvLine = parseInt(l.chevaux) || 7; const indemLine = isKmLine ? getIndemniteKmServer(kmLine, cvLine) : 0; const items = l.tvaItems || [{ montantTTC: l.montant, taux: l.tauxTVA }]; const ttcLine = isKmLine ? indemLine : items.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0); try { await pool.request() .input('noteId', sql.Int, nouvelleNote.id) .input('numPiece', sql.Int, i + 1) .input('date', sql.Date, new Date(l.date)) .input('nature', sql.NVarChar, l.categorie || '') .input('libelle', sql.NVarChar, l.libelle || '') .input('km', sql.Decimal, isKmLine ? kmLine : null) .input('montantTTC', sql.Decimal, ttcLine || null) .input('tva21', sql.Decimal, pdf.tva21 || null) .input('tva55', sql.Decimal, pdf.tva55 || null) .input('tva10', sql.Decimal, pdf.tva10 || null) .input('tva20', sql.Decimal, pdf.tva20 || null) .input('montantHT', sql.Decimal, pdf.montantHT || null) .input('tauxTVA', sql.Decimal, parseFloat(l.tauxTVA) || null) .input('indemniteKm', sql.Decimal, indemLine || null) .query(` INSERT INTO LigneNoteDeFrais (noteDeFraisId, numPiece, date, nature, libelle, km, montantTTC, tva21, tva55, tva10, tva20, montantHT, tauxTVA, indemniteKm) VALUES (@noteId, @numPiece, @date, @nature, @libelle, @km, @montantTTC, @tva21, @tva55, @tva10, @tva20, @montantHT, @tauxTVA, @indemniteKm) `); } catch (e) { console.error(`❌ Insertion ligne correction ${i + 1}:`, e.message); } } // 9. Notifier N1 if (n1Id && emailN1) { try { const titreNotif = noteExist.statut === 'non_conforme_verif' ? `📋 Note corrigée à valider — ${nouvelleReference}` : `📋 Note corrigée à valider — ${nouvelleReference}`; // Dans le PUT /api/notes/:id — CAS 1 correction, section "Notifier N1" const msgNotif = `${collaborateur.prenom} ${collaborateur.nom} a resoumis une note corrigée. Ancienne référence : ${noteExist.reference} (${statutArchive}) Nouvelle référence : ${nouvelleReference} Montant : ${parseFloat(montantFinal.toFixed(2))} €`; await creerNotification({ destinataireId: n1Id, destinataireEmail: emailN1, type: 'validation', titre: titreNotif, message: msgNotif, noteId: nouvelleNote.id }); const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; const couleurHeader = noteExist.statut === 'non_conforme_verif' ? 'linear-gradient(135deg,#f97316,#ea580c)' : 'linear-gradient(135deg,#f59e0b,#d97706)'; const contexte = noteExist.statut === 'non_conforme_verif' ? `suite à la non-conformité signalée sur ${noteExist.reference}` : `suite au refus de ${noteExist.reference}`; await sendMailGraph(emailN1, `📋 Note corrigée à valider — ${nouvelleReference}`, `

📋 Note corrigée — à valider

Bonjour ${prenomN1} ${nomN1},

${collaborateur.prenom} ${collaborateur.nom} a resoumis une note corrigée ${contexte}.

Nouvelle référence : ${nouvelleReference}${parseFloat(montantFinal.toFixed(2))} €

Valider sur la plateforme →
` ); } catch (e) { console.error('❌ Notif N1 correction:', e.message); } } console.log(`✅ Note corrigée ${nouvelleReference} créée (remplace ${noteExist.reference} → ${statutArchive}) par ${collaborateur.email}`); return res.json({ success: true, id: nouvelleNote.id, reference: nouvelleReference, statut: 'enattente', isCorrection: true, ancienneReference: noteExist.reference }); } // ════════════════════════════════════════════════════════════════════ // CAS 2 — Note EN ATTENTE → modifier sur place (comportement original) // ════════════════════════════════════════════════════════════════════ const reference = noteExist.reference; const allFiles = [...(req.files || [])]; let fichiersExistants = []; try { fichiersExistants = JSON.parse(noteExist.fichiers || '[]'); } catch { } for (const file of allFiles) { try { const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier); fichiersExistants.push(r); } catch (e) { console.error(`❌ Upload justif modif ${file.originalname}:`, e.message); } } const noteDataPDF = { reference, nomPrenom, mois: moisCapitalized, date, categorie: categorieNote, libelle, montant: parseFloat(montantFinal.toFixed(2)), lignes: lignesParsed, lignesJson: JSON.stringify(lignesParsed), tarifKm: tarifKmVal, statut: 'enattente', departement: collaborateur.departement, }; try { const fichePDF = await generateFicheSignee(noteDataPDF, [ { niveau: 'COLLAB', nomPrenom, date: new Date(), action: 'soumettre', commentaire: 'Note modifiée et resoumise' } ]); const ficheResult = await uploadToSharePointHierarchique( { buffer: fichePDF, originalname: `${reference}_resoumission.pdf`, mimetype: 'application/pdf', size: fichePDF.length }, reference, nomDossier, moisDossier ); fichiersExistants.push(ficheResult); } catch (e) { console.error('❌ Fiche PDF re-soumission:', e.message); } await pool.request() .input('id', sql.Int, noteId) .input('libelle', sql.NVarChar, libelle) .input('montant', sql.Decimal, parseFloat(montantFinal.toFixed(2))) .input('date', sql.Date, new Date(date)) .input('categorie', sql.NVarChar, categorieNote) .input('description', sql.NVarChar, description || null) .input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants)) .input('statut', sql.NVarChar, 'enattente') .input('km', sql.Decimal, kmTotal || null) .input('indemniteKm', sql.Decimal, indemKm || null) .input('lignesJson', sql.NVarChar, JSON.stringify(lignesParsed)) .query(` UPDATE NoteDeFrais SET libelle = @libelle, montant = @montant, date = @date, categorie = @categorie, description = @description, fichiers = @fichiers, statut = @statut, km = @km, indemniteKm = @indemniteKm, lignesJson = @lignesJson, motifRefus = NULL, commentaireN1 = NULL, commentaireN2 = NULL, dateValidationN1 = NULL, dateValidationN2 = NULL, DateModification = GETDATE() WHERE id = @id `); await pool.request() .input('noteId', sql.Int, noteId) .query(`DELETE FROM LigneNoteDeFrais WHERE noteDeFraisId = @noteId`); const lignesPDF = preparerLignesPDF(lignesParsed, tarifKmVal); for (let i = 0; i < lignesParsed.length; i++) { const l = lignesParsed[i]; const pdf = lignesPDF[i] || {}; const isKmLine = (l.categorie || '').toLowerCase().includes('kilom'); const kmLine = parseFloat(l.km) || 0; const cvLine = parseInt(l.chevaux) || 7; const indemLine = isKmLine ? getIndemniteKmServer(kmLine, cvLine) : 0; const items = l.tvaItems || [{ montantTTC: l.montant, taux: l.tauxTVA }]; const ttcLine = isKmLine ? indemLine : items.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0); try { await pool.request() .input('noteId', sql.Int, noteId) .input('numPiece', sql.Int, i + 1) .input('date', sql.Date, new Date(l.date)) .input('nature', sql.NVarChar, l.categorie || '') .input('libelle', sql.NVarChar, l.libelle || '') .input('km', sql.Decimal, isKmLine ? kmLine : null) .input('montantTTC', sql.Decimal, ttcLine || null) .input('tva21', sql.Decimal, pdf.tva21 || null) .input('tva55', sql.Decimal, pdf.tva55 || null) .input('tva10', sql.Decimal, pdf.tva10 || null) .input('tva20', sql.Decimal, pdf.tva20 || null) .input('montantHT', sql.Decimal, pdf.montantHT || null) .input('tauxTVA', sql.Decimal, parseFloat(l.tauxTVA) || null) .input('indemniteKm', sql.Decimal, indemLine || null) .query(` INSERT INTO LigneNoteDeFrais (noteDeFraisId, numPiece, date, nature, libelle, km, montantTTC, tva21, tva55, tva10, tva20, montantHT, tauxTVA, indemniteKm) VALUES (@noteId, @numPiece, @date, @nature, @libelle, @km, @montantTTC, @tva21, @tva55, @tva10, @tva20, @montantHT, @tauxTVA, @indemniteKm) `); } catch (e) { console.error(`❌ Insertion ligne modif ${i + 1}:`, e.message); } } const hierarchie = await pool.request() .input('collabId', sql.Int, userId) .query(` SELECT h.SuperieurId, s1.email AS emailN1, s1.prenom AS prenomN1, s1.nom AS nomN1 FROM HierarchieValidationNDF h LEFT JOIN CollaborateurAD s1 ON s1.id = h.SuperieurId WHERE h.CollaborateurId = @collabId `); const n1 = hierarchie.recordset[0]; if (n1?.SuperieurId && n1?.emailN1) { try { await creerNotification({ destinataireId: n1.SuperieurId, destinataireEmail: n1.emailN1, type: 'validation', titre: `📋 Note modifiée à valider — ${reference}`, message: `${collaborateur.prenom} ${collaborateur.nom} a modifié et resoumis la note ${reference} (${parseFloat(montantFinal.toFixed(2))} €).`, noteId }); const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net'; await sendMailGraph(n1.emailN1, `📋 Note modifiée à valider — ${reference}`, `

📋 Note modifiée — à valider

Bonjour ${n1.prenomN1} ${n1.nomN1},

${collaborateur.prenom} ${collaborateur.nom} a modifié et resoumis la note ${reference} — ${libelle} (${parseFloat(montantFinal.toFixed(2))} €).

Valider sur la plateforme →
` ); } catch (e) { console.error('❌ Notif N1 modif:', e.message); } } console.log(`✅ Note ${reference} modifiée par ${collaborateur.email}`); res.json({ success: true, id: noteId, reference, statut: 'enattente', isCorrection: false }); } catch (error) { console.error('❌ Erreur PUT /api/notes/:id:', error.message); res.status(500).json({ error: error.message }); } }); // ================================================ // GET /api/notes // ================================================ app.get('/api/notes', authenticateToken, async (req, res) => { try { const { statut, mois, annee } = req.query; const request = pool.request() .input('collaborateurId', sql.Int, req.user.id); let where = 'WHERE n.collaborateurId = @collaborateurId'; if (statut) { request.input('statut', sql.NVarChar, statut); where += ' AND n.statut = @statut'; } if (mois && annee) { request.input('mois', sql.Int, parseInt(mois)); request.input('annee', sql.Int, parseInt(annee)); where += ' AND MONTH(n.date) = @mois AND YEAR(n.date) = @annee'; } const result = await request.query(` SELECT n.*, v1.nom + ' ' + v1.prenom as nomValidateurN1 FROM NoteDeFrais n LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id ${where} ORDER BY n.DateCreation DESC `); const notes = result.recordset; if (!notes.length) return res.json([]); // ── 1. Une seule requête pour tous les non-conformes ───────────── const noteIds = notes.map(n => n.id).join(','); let ncByNote = {}; try { const ncAll = await pool.request().query(` SELECT noteDeFraisId, fileName, motif, statut, dateSignalement FROM JustificatifsNonConformes WHERE noteDeFraisId IN (${noteIds}) ORDER BY dateSignalement DESC `); for (const row of ncAll.recordset) { if (!ncByNote[row.noteDeFraisId]) ncByNote[row.noteDeFraisId] = []; ncByNote[row.noteDeFraisId].push(row); } } catch (e) { console.warn('⚠️ NC batch fetch:', e.message); } // ── 2. Collecter tous les qrNoteRef qui manquent encore de qrFiles const allQrRefs = new Set(); for (const note of notes) { if (!note.lignesJson) continue; try { const lignes = JSON.parse(note.lignesJson); for (const l of lignes) { if (l.qrNoteRef && !(l.qrFiles?.length)) allQrRefs.add(l.qrNoteRef); } } catch { } } // ── 3. Une seule requête pour tous les tokens QR manquants ─────── let qrByRef = {}; if (allQrRefs.size > 0) { const refsStr = [...allQrRefs] .map(r => `'${r.replace(/'/g, "''")}'`) .join(','); try { const qrAll = await pool.request().query(` SELECT noteRef, fichiers FROM UploadTokens WHERE noteRef IN (${refsStr}) AND used = 1 `); for (const row of qrAll.recordset) { try { qrByRef[row.noteRef] = JSON.parse(row.fichiers || '[]'); } catch { } } } catch (e) { console.warn('⚠️ QR batch fetch:', e.message); } } // ── 4. Enrichissement en mémoire — zéro requête SQL ────────────── for (const note of notes) { // Parser fichiers → sharepointFiles try { note.sharepointFiles = JSON.parse(note.fichiers || '[]'); } catch { note.sharepointFiles = []; } // Non-conformes depuis le batch if (note.statut === 'non_conforme_verif') { note.nonConformes = ncByNote[note.id] || []; } // Enrichissement QR en mémoire if (note.lignesJson) { try { const lignes = JSON.parse(note.lignesJson); let enrichi = false; const lignesEnrichies = lignes.map(l => { if (l.qrFiles?.length > 0) return l; if (!l.qrNoteRef) return l; const fichiers = qrByRef[l.qrNoteRef]; if (fichiers?.length) { enrichi = true; return { ...l, qrFiles: fichiers }; } return l; }); if (enrichi) note.lignesJson = JSON.stringify(lignesEnrichies); } catch (e) { console.warn('⚠️ Enrichissement QR fail:', note.id, e.message); } } } res.json(notes); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================ // 🔑 TOKEN SHAREPOINT (scope différent de Graph) // ================================================ async function getSharePointToken() { const now = Date.now(); if (_sharePointTokenCache && now < _sharePointTokenCache.expiresAt) { return _sharePointTokenCache.token; } try { const params = new URLSearchParams({ grant_type: 'client_credentials', client_id: AZURE_CONFIG.clientId, client_secret: AZURE_CONFIG.clientSecret, scope: 'https://ensup.sharepoint.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' } } ); const token = response.data.access_token; _sharePointTokenCache = { token, expiresAt: now + 55 * 60 * 1000 }; console.log('✅ Token SharePoint mis en cache (55 min)'); return token; } catch (error) { console.error('❌ Erreur token SharePoint:', error.response?.data || error.message); return null; } } // routes/sharepoint.js // ✅ route proxy-file — force les bons headers app.get('/api/proxy-pdf', async (req, res) => { let url = req.query.url; if (!url) return res.status(400).send('URL manquante'); try { url = decodeURIComponent(url); } catch { } if (url.includes('/api/proxy-pdf?url=')) { url = url.split('/api/proxy-pdf?url=')[1]; try { url = decodeURIComponent(url); } catch { } } if (!url.startsWith('http')) return res.status(400).send('URL invalide'); // ✅ Cache HIT → redirection instantanée vers CDN const cached = downloadUrlCache.get(url); if (cached && Date.now() < cached.expiresAt) { res.setHeader('Cache-Control', 'private, max-age=3600'); return res.redirect(302, cached.url); } try { const accessToken = await getGraphToken(); const urlObj = new URL(url); const fullPath = decodeURIComponent(urlObj.pathname); const marker = '/Shared Documents/'; const markerAlt = '/Documents/'; let relativePath = ''; if (fullPath.includes(marker)) relativePath = fullPath.split(marker)[1]; else if (fullPath.includes(markerAlt)) relativePath = fullPath.split(markerAlt)[1]; else { const parts = fullPath.split('/sites/')[1]?.split('/'); relativePath = parts?.slice(2).join('/') || ''; } const metaRes = await axios.get( `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${relativePath}`, { headers: { Authorization: `Bearer ${accessToken}` }, timeout: 15000 // ← 5000 → 15000ms } ); const downloadUrl = metaRes.data['@microsoft.graph.downloadUrl']; if (!downloadUrl) throw new Error('downloadUrl absent'); downloadUrlCache.set(url, { url: downloadUrl, expiresAt: Date.now() + 50 * 60 * 1000 }); res.setHeader('Cache-Control', 'private, max-age=3600'); return res.redirect(302, downloadUrl); } catch (err) { console.error('proxy-pdf erreur:', err.message); res.status(500).json({ error: err.message }); } }); // ================================================ // GET /api/notes/pending // ================================================ app.get('/api/notes/pending', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('userId', sql.Int, req.user.id) .query(` SELECT n.id, n.reference, n.libelle, n.montant, n.date, n.categorie, n.statut, n.description, n.participants, n.nombreParticipants, n.montantHT, n.tauxTVA, n.km, n.indemniteKm, n.commentaireN1, n.commentaireN2, n.sharepointUrl, n.fichiers, n.lignesJson, n.noteRefuseeId, ancienne.reference AS ancienneReference, ancienne.motifRefus AS ancienMotifRefus, c.prenom + ' ' + c.nom AS collaborateur, c.departement, c.campus FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN NoteDeFrais ancienne ON ancienne.id = n.noteRefuseeId WHERE (n.validateurN1Id = @userId AND n.statut = 'enattente') ORDER BY n.date DESC `); // Pour chaque note, récupérer les lignes depuis LigneNoteDeFrais // si lignesJson est vide const notes = result.recordset; for (const note of notes) { // ✅ Parser fichiers → sharepointFiles pour le frontend if (note.fichiers) { try { note.sharepointFiles = JSON.parse(note.fichiers); } catch { note.sharepointFiles = []; } } else { note.sharepointFiles = []; } // ✅ Toujours re-parser lignesJson depuis la BDD pour avoir les qrFiles à jour // Ne reconstruire depuis LigneNoteDeFrais qu'en dernier recours // ✅ Enrichir chaque ligne avec ses fichiers QR depuis UploadTokens if (note.lignesJson) { try { const lignes = JSON.parse(note.lignesJson); let enrichi = false; const lignesEnrichies = await Promise.all(lignes.map(async (l) => { if (l.qrFiles && l.qrFiles.length > 0) return l; const qrRef = l.qrNoteRef || ''; if (!qrRef) return l; try { const qrResult = await pool.request() .input('noteRef', sql.NVarChar, qrRef) .query(`SELECT TOP 1 fichiers FROM UploadTokens WHERE noteRef = @noteRef AND used = 1 ORDER BY expiresAt DESC`); if (qrResult.recordset.length && qrResult.recordset[0].fichiers) { const fichiers = JSON.parse(qrResult.recordset[0].fichiers); if (fichiers.length > 0) { enrichi = true; return { ...l, qrFiles: fichiers }; } } } catch (e) { } return l; })); if (enrichi) { note.lignesJson = JSON.stringify(lignesEnrichies); } } catch (e) { console.warn('⚠️ Enrichissement QR fail:', note.id, e.message); } } } res.json(notes); } catch (e) { console.error('Erreur /api/notes/pending:', e.message); res.status(500).json({ error: e.message }); } }); // ================================================ // GET /api/notes/:id/lignes // ================================================ app.get('/api/notes/:id/lignes', authenticateToken, async (req, res) => { try { const isValidator = hasAnyRole(req.user, 'Finance', 'Validateur', 'Validatrice', 'superUtilisateur') ? 1 : 0; const result = await pool.request() .input('noteId', sql.Int, req.params.id) .input('userId', sql.Int, req.user.id) .input('isVal', sql.Int, isValidator) .query(` SELECT l.* FROM LigneNoteDeFrais l JOIN NoteDeFrais n ON n.id = l.noteDeFraisId WHERE l.noteDeFraisId = @noteId AND (n.collaborateurId = @userId OR @isVal = 1) ORDER BY l.numPiece ASC `); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); // GET /api/notes/:id/detail — récupère une note par ID (pour validateur + collaborateur) app.get('/api/notes/:id/detail', authenticateToken, async (req, res) => { try { const noteId = parseInt(req.params.id); const userId = req.user.id; const result = await pool.request() .input('id', sql.Int, noteId) .query(` SELECT n.*, c.prenom + ' ' + c.nom AS collaborateur, c.departement, c.campus, ancienne.reference AS ancienneReference, ancienne.motifRefus AS ancienMotifRefus FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN NoteDeFrais ancienne ON ancienne.id = n.noteRefuseeId WHERE n.id = @id AND ( n.collaborateurId = ${userId} OR n.validateurN1Id = ${userId} OR EXISTS ( SELECT 1 FROM UtilisateurRoles r WHERE r.collaborateur_id = ${userId} AND r.role IN ('Finance','superUtilisateur','VerificateurFinance','ValidateurFinance') AND r.actif = 1 ) ) `); if (!result.recordset.length) return res.status(404).json({ error: 'Note introuvable ou accès refusé' }); const note = result.recordset[0]; if (note.fichiers) { try { note.sharepointFiles = JSON.parse(note.fichiers); } catch { note.sharepointFiles = []; } } else { note.sharepointFiles = []; } res.json(note); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================ // PUT /api/notes/:id/statut — Valider ou refuser // ================================================ app.put('/api/notes/:id/statut', authenticateToken, async (req, res) => { try { const { id } = req.params; const { action, commentaire, motifRefus } = req.body; const userId = Number(req.user.id); const noteResult = await pool.request() .input('id', sql.Int, id) .query('SELECT * FROM NoteDeFrais WHERE id = @id'); if (!noteResult.recordset.length) return res.status(404).json({ error: 'Note non trouvée' }); const note = noteResult.recordset[0]; const n1Id = Number(note.validateurN1Id); const statutNote = note.statut?.trim(); let nouveauStatut = null, niveauValidation = null; if (n1Id === userId && statutNote === 'enattente') { niveauValidation = 'N1'; nouveauStatut = action === 'valider' ? 'approuve' : 'refuse'; } else { return res.status(403).json({ error: 'Non autorisé à valider cette note' }); } await pool.request() .input('id', sql.Int, id) .input('statut', sql.NVarChar, nouveauStatut) .input('commentaire', sql.NVarChar, commentaire ?? null) .input('motifRefus', sql.NVarChar, motifRefus ?? null) .query(` UPDATE NoteDeFrais SET statut = @statut, dateValidationN1 = GETDATE(), commentaireN1 = @commentaire, motifRefus = CASE WHEN @motifRefus IS NOT NULL THEN @motifRefus ELSE motifRefus END, DateModification = GETDATE() WHERE id = @id `); await pool.request() .input('noteId', sql.Int, id) .input('validateurId', sql.Int, userId) .input('niveau', sql.NVarChar, 'N1') .input('action', sql.NVarChar, action) .input('commentaire', sql.NVarChar, commentaire ?? null) .input('motifRefus', sql.NVarChar, motifRefus ?? null) .input('statut', sql.NVarChar, nouveauStatut) .query(` INSERT INTO HistoriqueValidation (NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, MotifRefus, NouveauStatut, DateAction) VALUES (@noteId, @validateurId, @niveau, @action, @commentaire, @motifRefus, @statut, GETDATE()) `); <<<<<<< HEAD res.json({ success: true, statut: nouveauStatut, niveau: niveauValidation }); ======= res.json({ success: true, statut: nouveauStatut, niveau: 'N1' }); >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) setImmediate(async () => { try { console.log(`🔄 [ASYNC] PDF + emails validation ${id} → ${nouveauStatut}`); const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net'; const montantFormate = parseFloat(note.montant).toFixed(2); const [collabResult, validateurResult, noteCompleteResult] = await Promise.all([ pool.request() .input('id', sql.Int, note.collaborateurId) .query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'), pool.request() .input('id', sql.Int, userId) .query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'), pool.request() .input('id', sql.Int, id) .query(` SELECT n.reference, n.libelle, n.montant, n.date, n.categorie, n.fichiers, n.DateCreation, n.collaborateurId, n.lignesJson, <<<<<<< HEAD n.commentaireN1, n.commentaireN2, n.dateValidationN1, n.dateValidationN2, c.prenom + ' ' + c.nom AS nomPrenom, c.prenom AS collabPrenom, c.nom AS collabNom, c.departement, v1.prenom + ' ' + v1.nom AS nomValidateurN1, v2.prenom + ' ' + v2.nom AS nomValidateurN2 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id LEFT JOIN CollaborateurAD v2 ON v2.id = n.validateurN2Id ======= n.commentaireN1, n.dateValidationN1, c.prenom + ' ' + c.nom AS nomPrenom, c.prenom AS collabPrenom, c.nom AS collabNom, c.departement, v1.prenom + ' ' + v1.nom AS nomValidateurN1 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) WHERE n.id = @id `) ]); const c = collabResult.recordset[0]; const v = validateurResult.recordset[0]; const nd = noteCompleteResult.recordset[0]; if (!c || !nd) { console.error(`❌ [ASYNC] Données manquantes pour note ${id}`); return; <<<<<<< HEAD } const nomValidateurActuel = v ? `${v.prenom} ${v.nom}`.trim() : `${req.user.prenom} ${req.user.nom}`.trim(); // ── Construire les signatures ────────────────────────────── const signatures = [ { niveau: 'COLLAB', nomPrenom: nd.nomPrenom, date: nd.DateCreation, action: 'soumettre', commentaire: null } ]; if (niveauValidation === 'N1') { signatures.push({ niveau: 'N1', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null }); } else { if (nd.nomValidateurN1 && nd.dateValidationN1) signatures.push({ niveau: 'N1', nomPrenom: nd.nomValidateurN1, date: nd.dateValidationN1, action: 'valider', commentaire: nd.commentaireN1 ?? null }); signatures.push({ niveau: 'N2', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null }); ======= >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) } const nomValidateurActuel = v ? `${v.prenom} ${v.nom}`.trim() : `${req.user.prenom} ${req.user.nom}`.trim(); // ── Construire les signatures ────────────────────────────── const signatures = [ { niveau: 'COLLAB', nomPrenom: nd.nomPrenom, date: nd.DateCreation, action: 'soumettre', commentaire: null }, { niveau: 'N1', nomPrenom: nomValidateurActuel, date: new Date(), action, commentaire: commentaire ?? null } ]; const moisStr = (() => { if (!nd.date) return ''; const d = new Date(nd.date); const m = d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); return m.charAt(0).toUpperCase() + m.slice(1); })(); const noteDataPDF = { reference: nd.reference, nomPrenom: nd.nomPrenom, mois: moisStr, departement: nd.departement, lignesJson: nd.lignesJson, tarifKm: await getTarifKm(), statut: nouveauStatut }; let fichiersExistants = []; try { fichiersExistants = JSON.parse(nd.fichiers || '[]'); } catch { } const existingFolder = fichiersExistants[0]?.folderPath; <<<<<<< HEAD const nomDossier = existingFolder ? existingFolder.split('/')[1] : `${nd.collabPrenom}_${nd.collabNom}`.replace(/[^a-zA-Z0-9]/g, '_'); const moisDossier = existingFolder ? existingFolder.split('/')[2] : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; ======= // APRÈS let nomDossier = existingFolder ? existingFolder.split('/')[1] : `${nd.collabNom}_${nd.collabPrenom}` .normalize('NFD').replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9_]/g, '_'); let moisDossier = existingFolder ? existingFolder.split('/')[2] : `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) // ── Génération PDF signé (fiche seule) ──────────────────── try { const pdfSigne = await generateFicheSignee(noteDataPDF, signatures); <<<<<<< HEAD const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve' : nouveauStatut === 'refuse' ? 'signe-refuse' : `signe-${nouveauStatut}`; ======= const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve' : 'signe-refuse'; >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) const signedResult = await uploadToSharePointHierarchique( { buffer: pdfSigne, originalname: `${nd.reference}-${suffixe}.pdf`, mimetype: 'application/pdf', size: pdfSigne.length }, nd.reference, nomDossier, moisDossier ); fichiersExistants.push(signedResult); await pool.request() .input('id', sql.Int, id) .input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants)) .query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id'); console.log(`✅ [ASYNC] PDF signé uploadé: ${signedResult.fileName}`); } catch (pdfError) { console.error('❌ [ASYNC] Génération PDF signé:', pdfError.message); } <<<<<<< HEAD // ── Régénérer le recap complet (fiche + justifs + signatures à jour) ── try { const EXCLUS_RECAP = ['soumission', 'resoumission', 'signe', 'verifie', 'recap']; const justifFiles = []; for (const f of fichiersExistants) { const fname = (f.fileName || '').toLowerCase(); if (EXCLUS_RECAP.some(kw => fname.includes(kw))) continue; try { const buf = await downloadFromSharePoint(f.uploadUrl); const mimetype = fname.endsWith('.pdf') ? 'application/pdf' : fname.endsWith('.png') ? 'image/png' : 'image/jpeg'; justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length }); } catch (e) { console.warn(`⚠️ Justif recap non récupérable: ${f.fileName}`, e.message); } } const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures); const recapResult = await uploadToSharePointHierarchique( { buffer: recapBuffer, originalname: `${nd.reference}_recap.pdf`, mimetype: 'application/pdf', size: recapBuffer.length }, nd.reference, nomDossier, moisDossier ); // Remplacer l'ancien _recap.pdf (sauf recap-paiement) const fichiersAvecRecap = fichiersExistants.filter(f => { const fname = (f.fileName || '').toLowerCase(); return !fname.endsWith('_recap.pdf') || fname.includes('recap-paiement'); }); fichiersAvecRecap.push(recapResult); await pool.request() .input('id', sql.Int, id) .input('fichiers', sql.NVarChar, JSON.stringify(fichiersAvecRecap)) .query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id'); console.log(`✅ [ASYNC] Recap PDF régénéré avec ${signatures.length} signature(s): ${recapResult.fileName}`); } catch (recapError) { console.error('❌ [ASYNC] Régénération recap:', recapError.message); } // ── Notifications + emails ──────────────────────────────── const isApprouve = nouveauStatut === 'approuve'; const isValidn1 = nouveauStatut === 'validen1'; const isRefus = nouveauStatut === 'refuse'; const titreCollab = isApprouve ? `Note ${note.reference} approuvée` : isValidn1 ? `Note ${note.reference} validée N1` : `Note ${note.reference} refusée`; const msgCollab = isApprouve ? `Votre note ${note.reference} (${montantFormate}€) a été approuvée.` : isValidn1 ? `Votre note ${note.reference} a été validée N1 par ${nomValidateurActuel}.` : `Votre note ${note.reference} a été refusée. Motif : ${motifRefus || commentaire || 'Non précisé'}`; const motifAffiche = motifRefus || commentaire || 'Non précisé'; const emailCollabHtml = isRefus ? `

❌ Votre note de frais a été refusée

Une action de votre part est nécessaire

Bonjour ${c.prenom} ${c.nom},

Votre note ${note.reference} a été refusée par ${nomValidateurActuel}.

Motif du refus
${motifAffiche}
Référence${note.reference}
Libellé${note.libelle}
Montant${montantFormate} €
Refusé par${nomValidateurActuel}
Date${new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })}
📝 Que faire maintenant ?
  1. Connectez-vous à la plateforme NDF
  2. Rendez-vous dans Mes notes
  3. Cliquez sur la note ${note.reference}
  4. Corrigez les informations demandées
  5. Resoumettez la note
✏️ Modifier ma note →
` : `

${titreCollab}

Bonjour ${c.prenom} ${c.nom},

${msgCollab}

Voir mes notes
`; const taches = [ creerNotification({ destinataireId: c.id, destinataireEmail: c.email, type: isRefus ? 'refus' : 'validation', titre: titreCollab, message: msgCollab, noteId: parseInt(id) }).catch(e => console.error('❌ [ASYNC] Notif collab:', e.message)), sendMailGraph( c.email, isRefus ? `❌ Note refusée — action requise : ${note.reference}` : titreCollab, emailCollabHtml ).catch(e => console.error('❌ [ASYNC] Email collab:', e.message)), ]; if (action === 'valider' && niveauValidation === 'N1' && note.validateurN2Id && n2Id !== userId) { const n2Result = await pool.request() .input('id', sql.Int, note.validateurN2Id) .query('SELECT id, email, prenom, nom FROM CollaborateurAD WHERE id = @id'); if (n2Result.recordset.length) { const n2 = n2Result.recordset[0]; taches.push( creerNotification({ destinataireId: n2.id, destinataireEmail: n2.email, type: 'validation', titre: `Note à valider N2 : ${note.reference}`, message: `La note ${note.reference} (${montantFormate}€) de ${c.prenom} ${c.nom} attend votre validation finale.`, noteId: parseInt(id) }).catch(e => console.error('❌ [ASYNC] Notif N2:', e.message)), sendMailGraph(n2.email, `Note à valider N2 : ${note.reference}`, `

Note à valider — Niveau N2

Bonjour ${n2.prenom} ${n2.nom},

La note ${note.reference} de ${c.prenom} ${c.nom} (${montantFormate}€) a été validée N1 et attend votre validation finale.

Valider sur la plateforme
` ).catch(e => console.error('❌ [ASYNC] Email N2:', e.message)) ); } } await Promise.all(taches); console.log(`✅ [ASYNC] Validation terminée pour note ${id} → ${nouveauStatut}`); } catch (e) { console.error(`❌ [ASYNC] Erreur générale validation note ${id}:`, e.message); } }); ======= // ── Régénérer le recap complet ──────────────────────────── try { const EXCLUS_RECAP = ['soumission', 'resoumission', 'signe', 'verifie', 'recap']; const justifFiles = []; for (const f of fichiersExistants) { const fname = (f.fileName || '').toLowerCase(); if (EXCLUS_RECAP.some(kw => fname.includes(kw))) continue; try { const buf = await downloadFromSharePoint(f.uploadUrl); const mimetype = fname.endsWith('.pdf') ? 'application/pdf' : fname.endsWith('.png') ? 'image/png' : 'image/jpeg'; justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length }); } catch (e) { console.warn(`⚠️ Justif recap non récupérable: ${f.fileName}`, e.message); } } const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures); const recapResult = await uploadToSharePointHierarchique( { buffer: recapBuffer, originalname: `${nd.reference}_recap.pdf`, mimetype: 'application/pdf', size: recapBuffer.length }, nd.reference, nomDossier, moisDossier ); const fichiersAvecRecap = fichiersExistants.filter(f => { const fname = (f.fileName || '').toLowerCase(); return !fname.endsWith('_recap.pdf') || fname.includes('recap-paiement'); }); fichiersAvecRecap.push(recapResult); await pool.request() .input('id', sql.Int, id) .input('fichiers', sql.NVarChar, JSON.stringify(fichiersAvecRecap)) .query('UPDATE NoteDeFrais SET fichiers = @fichiers WHERE id = @id'); console.log(`✅ [ASYNC] Recap PDF régénéré avec ${signatures.length} signature(s): ${recapResult.fileName}`); } catch (recapError) { console.error('❌ [ASYNC] Régénération recap:', recapError.message); } // ── Notifications + emails ──────────────────────────────── const isApprouve = nouveauStatut === 'approuve'; const isRefus = nouveauStatut === 'refuse'; const titreCollab = isApprouve ? `Note ${note.reference} approuvée` : `Note ${note.reference} refusée`; const msgCollab = isApprouve ? `Votre note ${note.reference} (${montantFormate}€) a été approuvée.` : `Votre note ${note.reference} a été refusée. Motif : ${motifRefus || commentaire || 'Non précisé'}`; const motifAffiche = motifRefus || commentaire || 'Non précisé'; const emailCollabHtml = isRefus ? `

❌ Votre note de frais a été refusée

Une action de votre part est nécessaire

Bonjour ${c.prenom} ${c.nom},

Votre note ${note.reference} a été refusée par ${nomValidateurActuel}.

Motif du refus
${motifAffiche}
Référence${note.reference}
Libellé${note.libelle}
Montant${montantFormate} €
Refusé par${nomValidateurActuel}
Date${new Date().toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric' })}
📝 Que faire maintenant ?
  1. Connectez-vous à la plateforme NDF
  2. Rendez-vous dans Mes notes
  3. Cliquez sur la note ${note.reference}
  4. Corrigez les informations demandées
  5. Resoumettez la note
✏️ Modifier ma note →
` : `

${titreCollab}

Bonjour ${c.prenom} ${c.nom},

${msgCollab}

Voir mes notes
`; await Promise.all([ creerNotification({ destinataireId: c.id, destinataireEmail: c.email, type: isRefus ? 'refus' : 'validation', titre: titreCollab, message: msgCollab, noteId: parseInt(id) }).catch(e => console.error('❌ [ASYNC] Notif collab:', e.message)), sendMailGraph( c.email, isRefus ? `❌ Note refusée — action requise : ${note.reference}` : titreCollab, emailCollabHtml ).catch(e => console.error('❌ [ASYNC] Email collab:', e.message)), ]); console.log(`✅ [ASYNC] Validation terminée pour note ${id} → ${nouveauStatut}`); } catch (e) { console.error(`❌ [ASYNC] Erreur générale validation note ${id}:`, e.message); } }); >>>>>>> 78516cb (version_Rôle_President Version_Chatbot) } catch (error) { console.error('Erreur validation:', error.message); res.status(500).json({ error: error.message }); } }); // ================================================ // GET /api/notes/:id/historique // ================================================ app.get('/api/notes/:id/historique', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('noteId', sql.Int, req.params.id) .query(` SELECT h.id, h.Niveau, h.Action, h.Commentaire, h.MotifRefus, h.NouveauStatut, h.DateAction, v.prenom + ' ' + v.nom AS validateur, v.role AS roleValidateur FROM HistoriqueValidation h JOIN CollaborateurAD v ON v.id = h.ValidateurId WHERE h.NoteDeFraisId = @noteId ORDER BY h.DateAction ASC `); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================ // GET /api/validateur/historique // ================================================ app.get('/api/validateur/historique', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('validateurId', sql.Int, req.user.id) .query(` SELECT h.id, h.Niveau, h.Action, h.Commentaire, h.MotifRefus, h.NouveauStatut, h.DateAction, n.reference, n.libelle, n.montant, n.categorie, c.prenom + ' ' + c.nom AS collaborateur, c.departement, c.campus FROM HistoriqueValidation h JOIN NoteDeFrais n ON n.id = h.NoteDeFraisId JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE h.ValidateurId = @validateurId ORDER BY h.DateAction DESC `); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================ // GET /api/notes/all — réservé Finance // ================================================ app.get('/api/notes/all', authenticateToken, async (req, res) => { try { if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' }); const result = await pool.request().query(` SELECT n.*, c.nom + ' ' + c.prenom as collaborateur, c.departement, c.campus, v1.nom + ' ' + v1.prenom as nomN1 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id ORDER BY n.DateCreation DESC `); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================ // GET /api/admin/notes — réservé superUtilisateur // ================================================ // GET /api/admin/notes — superUtilisateur, filtré sur ENSUP SOLUTION ET SUPPORT app.get('/api/admin/notes', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé superUtilisateur' }); try { const { mois, annee, statut } = req.query; let query = ` SELECT n.id, n.reference, n.libelle, n.montant, n.date, n.categorie, n.statut, n.DateCreation, n.montantHT, n.tauxTVA, n.km, n.indemniteKm, n.sharepointUrl, n.fichiers, n.lignesJson, c.prenom + ' ' + c.nom AS collaborateur, c.email AS collaborateurEmail, c.departement, c.campus, c.societe FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE c.societe = 'ENSUP SOLUTION ET SUPPORT' `; const req2 = pool.request(); if (mois && annee) { query += ` AND MONTH(n.date) = @mois AND YEAR(n.date) = @annee`; req2.input('mois', sql.Int, parseInt(mois)); req2.input('annee', sql.Int, parseInt(annee)); } if (statut) { query += ` AND n.statut = @statut`; req2.input('statut', sql.NVarChar, statut); } query += ` ORDER BY c.nom, n.date DESC`; const result = await req2.query(query); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================ // PAIEMENTS // ================================================ app.get('/api/paiements/prochain', authenticateToken, async (req, res) => { try { const config = await pool.request().query(`SELECT TOP 1 JourPaiement FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`); const jourPaiement = config.recordset[0]?.JourPaiement ?? 20; const today = new Date(); const jour = today.getDate(), mois = today.getMonth(), annee = today.getFullYear(); const datePaiement = jour <= jourPaiement ? new Date(annee, mois, jourPaiement) : new Date(annee, mois + 1, jourPaiement); res.json({ jourPaiement, datePaiement: datePaiement.toISOString().split('T')[0], libelle: `Paiement le ${datePaiement.toLocaleDateString('fr-FR')}` }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/paiements/marquer-payees', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' }); try { const config = await pool.request().query(`SELECT TOP 1 JourPaiement FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`); const jourPaiement = config.recordset[0]?.JourPaiement ?? 20; const today = new Date(); const mois = today.getMonth() + 1, annee = today.getFullYear(); const datePaiementExacte = new Date(annee, today.getMonth(), jourPaiement); const notes = await pool.request().query(` SELECT n.id, n.reference, n.montant, c.nom + ' ' + c.prenom AS collaborateur, c.email FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.statut = 'approuve' AND n.datePaiement IS NULL `); if (!notes.recordset.length) return res.json({ success: true, message: 'Aucune note approuvée à payer', count: 0 }); await pool.request() .input('datePaiement', sql.DateTime, datePaiementExacte) .input('moisPaiement', sql.Int, mois) .input('anneePaiement', sql.Int, annee) .query(` UPDATE NoteDeFrais SET statut = 'payee', datePaiement = @datePaiement, moisPaiement = @moisPaiement, anneePaiement = @anneePaiement, DateModification = GETDATE() WHERE statut = 'approuve' AND datePaiement IS NULL `); res.json({ success: true, count: notes.recordset.length, datePaiement: datePaiementExacte.toISOString().split('T')[0], notes: notes.recordset }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/paiements/historique', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur', 'ValidateurFinance', 'VerificateurFinance')) return res.status(403).json({ error: 'Accès réservé Finance' }); try { const mois = req.query.mois ? parseInt(req.query.mois) : null; const annee = req.query.annee ? parseInt(req.query.annee) : null; const request = pool.request(); request.input('mois', sql.Int, mois); request.input('annee', sql.Int, annee); let campusFilter = ''; if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) { const campusCode = normalizeCampus(req.user.campus); if (campusCode) { request.input('campus', sql.NVarChar, `%${campusCode}%`); campusFilter = 'AND c.campus LIKE @campus'; } } const result = await request.query(` SELECT n.id, n.reference, n.libelle, n.montant, n.datePaiement, n.moisPaiement, n.anneePaiement, n.categorie, n.lignesJson, n.indemniteKm, n.km, n.statut, c.nom + ' ' + c.prenom AS collaborateur, c.campus, c.societe FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.statut = 'payee' AND (@mois IS NULL OR n.moisPaiement = @mois) AND (@annee IS NULL OR n.anneePaiement = @annee) ${campusFilter} ORDER BY n.datePaiement DESC, c.nom `); const data = result.recordset.map(row => { let montantRepas = 0, montantHebergement = 0; let montantKilometrique = row.indemniteKm || 0; let montantTransport = 0, montantAutres = 0; const cat = (row.categorie || '').toLowerCase(); if (cat !== 'multiple') { if (cat.includes('repas') || cat.includes('restaurant')) montantRepas = row.montant; else if (cat.includes('hebergement') || cat.includes('hotel')) montantHebergement = row.montant; else if (cat.includes('kilom') || cat.includes('km')) montantKilometrique = row.indemniteKm || row.montant; else if (cat.includes('transport') || cat.includes('avion') || cat.includes('train') || cat.includes('taxi')) montantTransport = row.montant; else montantAutres = row.montant; } else { try { const lignes = JSON.parse(row.lignesJson || '[]'); lignes.forEach(ligne => { const lcat = (ligne.categorie || ligne.nature || '').toLowerCase(); const montantLigne = parseFloat(ligne.montantTTC || ligne.montant || 0); if (lcat.includes('repas') || lcat.includes('restaurant')) montantRepas += montantLigne; else if (lcat.includes('hebergement') || lcat.includes('hotel')) montantHebergement += montantLigne; else if (lcat.includes('kilom') || lcat.includes('km')) montantKilometrique += parseFloat(ligne.indemniteKm || montantLigne); else if (lcat.includes('transport') || lcat.includes('avion') || lcat.includes('train') || lcat.includes('taxi')) montantTransport += montantLigne; else montantAutres += montantLigne; }); } catch { montantAutres = row.montant; } } return { ...row, montantRepas: Math.round(montantRepas * 100) / 100, montantHebergement: Math.round(montantHebergement * 100) / 100, montantKilometrique: Math.round(montantKilometrique * 100) / 100, montantTransport: Math.round(montantTransport * 100) / 100, montantAutres: Math.round(montantAutres * 100) / 100, }; }); res.json(data); } catch (error) { console.error('Erreur historique:', error.message); res.status(500).json({ error: error.message }); } }); app.get('/api/paiements/config', authenticateToken, async (req, res) => { try { const result = await pool.request().query(`SELECT TOP 1 Id, JourPaiement, Actif, DateModif FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`); res.json(result.recordset[0] ?? { JourPaiement: 20 }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/paiements/config', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' }); try { const { jourPaiement } = req.body; if (!jourPaiement || jourPaiement < 1 || jourPaiement > 28) return res.status(400).json({ error: 'Jour de paiement invalide (1-28)' }); await pool.request().query(`UPDATE ConfigPaiement SET Actif = 0 WHERE Actif = 1`); await pool.request().input('jour', sql.Int, jourPaiement).query(`INSERT INTO ConfigPaiement (JourPaiement, Actif, DateModif) VALUES (@jour, 1, GETDATE())`); res.json({ success: true, jourPaiement }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================ // HELPERS EMAIL + NOTIFICATION // ================================================ async function sendMailGraph(to, subject, htmlBody) { try { const accessToken = await getGraphToken(); if (!accessToken) throw new Error('Token Graph indisponible'); const senderEmail = process.env.MAIL_SENDER || process.env.MAIL_FROM; if (!senderEmail) { console.error('MAIL_SENDER non défini'); return; } await axios.post( `https://graph.microsoft.com/v1.0/users/${senderEmail}/sendMail`, { message: { subject, body: { contentType: 'HTML', content: htmlBody }, from: { emailAddress: { address: senderEmail } }, toRecipients: [{ emailAddress: { address: to } }] }, saveToSentItems: false }, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' } } ); console.log(`✅ Email envoyé à ${to} via ${senderEmail}`); } catch (error) { console.error('Erreur sendMailGraph:', error.response?.data || error.message); } } async function creerNotification({ destinataireId, destinataireEmail, type, titre, message, noteId }) { if (!destinataireId) { console.error('❌ creerNotification annulée : destinataireId manquant', { destinataireEmail, type, titre }); return; } try { await pool.request() .input('destinataireId', sql.Int, destinataireId) .input('type', sql.NVarChar, type) .input('titre', sql.NVarChar, titre) .input('message', sql.NVarChar, message) .input('noteId', sql.Int, noteId || null) .query(` INSERT INTO Notifications (CollaborateurId, Type, Titre, Message, NoteDeFraisId, Lu, DateCreation) VALUES (@destinataireId, @type, @titre, @message, @noteId, 0, GETDATE()) `); console.log(`🔔 Notification insérée pour ${destinataireEmail} (id: ${destinataireId})`); } catch (err) { console.error('❌ Erreur insertion notification:', err.message); } } // ================================================ // PAIEMENTS — NOTIFIER // ================================================ app.post('/api/paiements/notifier', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance')) return res.status(403).json({ error: 'Accès réservé Finance' }); try { const notes = await pool.request().query(` SELECT n.id, n.reference, n.montant, n.libelle, c.nom + ' ' + c.prenom AS collaborateur FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.statut = 'approuve' AND n.datePaiement IS NULL `); if (!notes.recordset.length) return res.json({ success: true, message: 'Aucune note en attente de paiement' }); const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0); const alexandre = await pool.request() .input('email', sql.NVarChar, process.env.RESPONSABLE_PAIEMENT_EMAIL) .query(`SELECT TOP 1 id, email, prenom, nom FROM CollaborateurAD WHERE email = @email AND Actif = 1`); if (!alexandre.recordset.length) return res.status(404).json({ error: 'Responsable paiement introuvable' }); const responsable = alexandre.recordset[0]; const config = await pool.request().query(`SELECT TOP 1 JourPaiement FROM ConfigPaiement WHERE Actif = 1 ORDER BY Id DESC`); const jourPaiement = config.recordset[0]?.JourPaiement ?? 20; const titre = `⚠️ ${notes.recordset.length} note(s) à payer — ${total.toFixed(2)} €`; await creerNotification({ destinataireId: responsable.id, destinataireEmail: responsable.email, type: 'paiement', titre, message: `${notes.recordset.length} note(s) en attente avant le ${jourPaiement}. Total : ${total.toFixed(2)} €`, noteId: null }); await sendMailGraph(responsable.email, titre, `

Bonjour ${responsable.prenom},

${notes.recordset.length} note(s) en attente de paiement avant le ${jourPaiement}. Total : ${total.toFixed(2)} €

`); res.json({ success: true, count: notes.recordset.length, total: parseFloat(total.toFixed(2)) }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================ // NOTIFICATIONS // ================================================ app.get('/api/notifications', authenticateToken, async (req, res) => { try { const result = await pool.request().input('userId', sql.Int, req.user.id).query(` SELECT TOP 50 id, Type, Titre, Message, NoteDeFraisId, Lu, DateCreation FROM Notifications WHERE CollaborateurId = @userId ORDER BY DateCreation DESC `); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); app.put('/api/notifications/:id/lu', authenticateToken, async (req, res) => { try { await pool.request().input('id', sql.Int, req.params.id).input('userId', sql.Int, req.user.id) .query(`UPDATE Notifications SET Lu = 1 WHERE id = @id AND CollaborateurId = @userId`); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ================================================ // CATÉGORIES / PARAMÈTRES TVA / KM // ================================================ app.get('/api/categories', authenticateToken, async (req, res) => { try { const result = await pool.request().query(` SELECT c.*, p.PlafondParPersonne, p.DescriptionPlafond FROM CategorieNDF c LEFT JOIN PlafondRepas p ON p.CategorieId = c.id AND p.Actif = 1 WHERE c.Actif = 1 ORDER BY c.Ordre, c.Nom `); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); app.get('/api/parametres/tva', authenticateToken, async (req, res) => { try { const result = await pool.request().query(` SELECT id, taux, libelle, categorie FROM ParametresTVA WHERE actif = 1 AND dateDebut <= GETDATE() AND (dateFin IS NULL OR dateFin >= GETDATE()) ORDER BY taux ASC `); res.json(result.recordset); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/api/parametres/km', authenticateToken, async (req, res) => { try { const annee = new Date().getFullYear(); const result = await pool.request().input('annee', sql.Int, annee).query(` SELECT TOP 1 tarifParKm FROM ParametresKm WHERE annee = @annee AND actif = 1 ORDER BY DateCreation DESC `); res.json({ tarifKm: result.recordset[0]?.tarifParKm ?? await getTarifKm() }); } catch (err) { res.status(500).json({ error: err.message }); } }); // ================================================ // QR CODE UPLOAD MOBILE // ================================================ app.post('/api/upload/generate-link', authenticateToken, async (req, res) => { try { const { noteRef } = req.body; if (!noteRef) return res.status(400).json({ error: 'noteRef obligatoire' }); const collabResult = await pool.request().input('id', sql.Int, req.user.id).query('SELECT nom, prenom FROM CollaborateurAD WHERE id = @id'); if (!collabResult.recordset.length) return res.status(404).json({ error: 'Collaborateur introuvable' }); const { nom, prenom } = collabResult.recordset[0]; const token = crypto.randomBytes(32).toString('hex'); await pool.request() .input('token', sql.VarChar, token) .input('nomPrenom', sql.NVarChar, `${nom.toUpperCase()}_${prenom}`) .input('noteRef', sql.NVarChar, noteRef) .query(`INSERT INTO UploadTokens (token, nomPrenom, noteRef, expiresAt) VALUES (@token, @nomPrenom, @noteRef, DATEADD(MINUTE, 120, GETDATE()))`); res.json({ uploadLink: `${process.env.FRONTEND_URL}/upload/${token}`, expiresAt: new Date(Date.now() + 120 * 60 * 1000), token }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/upload/check/:token', async (req, res) => { try { const valid = await pool.request().input('token', sql.VarChar, req.params.token) .query(`SELECT * FROM UploadTokens WHERE token = @token AND used = 0 AND expiresAt > GETDATE()`); if (!valid.recordset.length) return res.status(410).json({ error: 'Lien expiré ou déjà utilisé' }); const t = valid.recordset[0]; res.json({ valid: true, noteRef: t.noteRef, nomPrenom: t.nomPrenom }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/upload/submit/:token', upload.array('files', 10), async (req, res) => { try { const result = await pool.request().input('token', sql.VarChar, req.params.token) .query(`SELECT * FROM UploadTokens WHERE token = @token AND used = 0 AND expiresAt > GETDATE()`); if (!result.recordset.length) return res.status(410).json({ error: 'Lien expiré ou déjà utilisé' }); const { nomPrenom, noteRef } = result.recordset[0]; const files = req.files; if (!files || !files.length) return res.status(400).json({ error: 'Aucun fichier reçu' }); const uploaded = []; for (const file of files) { const r = await uploadToSharePoint(file, noteRef, nomPrenom); uploaded.push(r); } await pool.request() .input('token', sql.VarChar, req.params.token) .input('firstUrl', sql.NVarChar, uploaded[0].uploadUrl) .input('allFiles', sql.NVarChar, JSON.stringify(uploaded)) .query(`UPDATE UploadTokens SET used = 1, sharepointUrl = @firstUrl, fichiers = @allFiles WHERE token = @token`); res.json({ success: true, uploaded }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/upload/status/:noteRef', async (req, res) => { try { const result = await pool.request().input('noteRef', sql.NVarChar, req.params.noteRef) .query(`SELECT TOP 1 used, fichiers, sharepointUrl FROM UploadTokens WHERE noteRef = @noteRef ORDER BY expiresAt DESC`); if (!result.recordset.length) return res.json({ uploaded: false }); const row = result.recordset[0]; const fichiers = row.fichiers ? JSON.parse(row.fichiers) : []; res.json({ uploaded: row.used === true || row.used === 1, files: fichiers, // ← ajout pour le frontend fichiers: fichiers, // ← conservé pour rétrocompat sharepointUrl: row.sharepointUrl }); } catch (e) { res.status(500).json({ error: e.message }); } }); // GET /api/notes-all — réservé Finance (filtré par campus) + superUtilisateur (ENSUP SOLUTION ET SUPPORT) app.get('/api/notes-all', authenticateToken, async (req, res) => { try { if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance')) return res.status(403).json({ error: 'Accès réservé Finance' }); const request = pool.request(); let campusWhere = ''; if (req.user.campus) { const campusCode = normalizeCampus(req.user.campus); // Variantes de recherche par campus normalisé const campusVariants = { 'SQY': ['%SQY%', '%SAINT%'], 'CGY': ['%CGY%', '%CERGY%'], 'MRS': ['%MRS%', '%MARSEILLE%'], 'NTE': ['%NTE%', '%NANTES%'], }; const variants = campusVariants[campusCode] || [`%${campusCode}%`]; // Construire les conditions OR pour chaque variante const conditions = variants.map((v, i) => { request.input(`campus${i}`, sql.NVarChar, v); return `c.campus LIKE @campus${i}`; }); campusWhere = `AND (${conditions.join(' OR ')})`; } const statutFilter = hasAnyRole(req.user, 'ValidateurFinance') && !hasAnyRole(req.user, 'Finance') ? `AND LOWER(n.statut) IN ('verifie', 'en_attente_president', 'paiementenattente', 'payee')` : `AND LOWER(REPLACE(n.statut COLLATE Latin1_General_CI_AI, ' ', '')) IN ( 'approuve', 'approuv', 'verifie', 'paiementenattente', 'paiement_en_attente', 'payee' )`; const result = await request.query(` SELECT n.*, c.nom + ' ' + c.prenom AS collaborateur, c.departement, c.campus, c.societe, v1.nom + ' ' + v1.prenom AS nomN1, vf.nom + ' ' + vf.prenom AS nomVerificateur, n.dateVerification, n.commentaireVerification FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id LEFT JOIN CollaborateurAD vf ON vf.id = n.verificateurFinanceId WHERE 1=1 ${statutFilter} ${campusWhere} ORDER BY n.DateCreation DESC `); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); // POST /api/paiements/generer-xml // Body: { noteIds: number[] } // POST /api/paiements/generer-xml — Génère le XML PAIN.001 et passe statut à 'paiementenattente' app.post('/api/paiements/generer-xml', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); const { noteIds } = req.body; if (!Array.isArray(noteIds) || noteIds.length === 0) return res.status(400).json({ error: 'Aucune note sélectionnée' }); try { const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(','); const notes = await pool.request().query(` SELECT n.id, n.reference, n.montant, n.libelle, n.date, n.fichiers, n.lignesJson, n.categorie, n.DateCreation, c.nom, c.prenom, c.iban, c.bic, c.campus, c.societe, c.adresse_rue, c.adresse_cp, c.adresse_ville, c.adresse_pays, c.id AS collabId, c.email FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.id IN (${idList}) AND n.statut IN ('approuve', 'approuvé', 'verifie') `); if (!notes.recordset.length) return res.status(404).json({ error: 'Aucune note approuvée/vérifiée trouvée' }); // ── Validation des données AVANT de générer quoi que ce soit ───── // On fait toutes les vérifications en une seule requête SQL (batch) const checksResult = await pool.request().query(` SELECT id AS collabId, IBAN, BIC, adresse_rue, adresse_cp, adresse_ville, adresse_pays, nom, prenom FROM CollaborateurAD WHERE id IN (${notes.recordset.map(n => n.collabId).join(',')}) `); const checksMap = {}; for (const c of checksResult.recordset) checksMap[c.collabId] = c; const erreurs = []; for (const n of notes.recordset) { const c = checksMap[n.collabId]; if (!c) { erreurs.push(`${n.reference} : collaborateur introuvable`); continue; } if (!c.IBAN) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : IBAN manquant`); if (!c.BIC) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : BIC manquant`); if (!c.adresse_rue || !c.adresse_cp || !c.adresse_ville || !c.adresse_pays) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : adresse postale incomplète`); } if (erreurs.length > 0) return res.status(422).json({ error: 'Données manquantes — XML non généré', details: erreurs }); const now = new Date(); const annee = now.getFullYear(); const mois = String(now.getMonth() + 1).padStart(2, '0'); const todayISO = now.toISOString().split('T')[0]; const creDtTm = now.toISOString().slice(0, 19); const msgId = `NDF-${annee}${mois}-${Date.now().toString().slice(-7)}`; const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0); const totalFormate = total.toFixed(2); // Détecter le campus dominant des notes sélectionnées const campusDominant = (() => { const campusCounts = {}; for (const n of notes.recordset) { const code = normalizeCampus(n.campus || '') || n.campus || ''; if (code) campusCounts[code] = (campusCounts[code] || 0) + 1; } // Campus le plus fréquent parmi les notes return Object.entries(campusCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null; })(); const cfg = await getConfigDebiteur(campusDominant); console.log(`🏦 Config débiteur utilisée : ${cfg.companyName} (campus: ${campusDominant || 'global'})`); const { companyName: dbtrNom, companyIban: dbtrIban, companyBic: dbtrBic, companyAddress: dbtrAdrLine, companyCp: dbtrCp, companyVille: dbtrVille, companyPays: dbtrPays } = cfg; // ── Générer les transactions XML ────────────────────────────────── let transactions = ''; for (const n of notes.recordset) { let ibanClair = 'FR0000000000000000000000000'; try { if (n.iban && n.iban.includes(':')) ibanClair = decryptIban(n.iban); else if (n.iban) ibanClair = n.iban; } catch (e) { console.warn(`⚠️ Déchiffrement IBAN impossible pour ${n.reference}:`, e.message); } const benefNom = `${n.nom.toUpperCase()} ${n.prenom}`; const adrLine = (n.adresse_rue || '').toUpperCase(); const cp = n.adresse_cp || ''; const ville = (n.adresse_ville || '').toUpperCase(); const pays = (n.adresse_pays || 'FR').slice(0, 2).toUpperCase(); const benefBicBlock = n.bic ? `${n.bic}` : `NOTPROVIDED`; transactions += ` VIREMENT NUM:${n.reference} ${n.reference} ${parseFloat(n.montant).toFixed(2)} ${benefBicBlock} ${benefNom}${adrLine || cp || ville ? ` ${cp ? ` ${cp}` : ''}${ville ? ` ${ville}` : ''} ${pays}${adrLine ? ` ${adrLine}` : ''} ` : ''} ${pays} ${ibanClair} `; } const xml = ` ${msgId} ${creDtTm} ${notes.recordset.length} ${totalFormate} ${dbtrNom} ${msgId} TRF true ${notes.recordset.length} ${totalFormate} SEPA ${todayISO} ${dbtrNom} ${dbtrCp} ${dbtrVille} ${dbtrPays} ${dbtrAdrLine} ${dbtrIban} NOTPROVIDED SLEV${transactions} `; // ── Passer en 'paiementenattente' + enregistrer date XML ───────── // Fait AVANT res.send pour que le statut soit correct immédiatement await pool.request() .input('dateXml', sql.DateTime, now) .query(` UPDATE NoteDeFrais SET statut = 'paiementenattente', dateXml = @dateXml, DateModification = GETDATE() WHERE id IN (${idList}) AND statut IN ('approuve', 'approuvé', 'verifie') `); // ── Réponse immédiate — le client reçoit le XML sans attendre ──── const xmlFileName = `virements-ndf-${annee}-${mois}-${todayISO}.xml`; res.setHeader('Content-Type', 'application/xml; charset=iso-8859-1'); res.setHeader('Content-Disposition', `attachment; filename=${xmlFileName}`); res.send(xml); // ── Tout le reste en arrière-plan (non bloquant) ───────────────── setImmediate(async () => { console.log(`🔄 [ASYNC] Post-XML : SharePoint + PDFs + notifs pour ${notes.recordset.length} note(s)...`); // 1. Upload XML sur SharePoint try { const spXmlFileName = `virements-ndf-${annee}-${mois}-${Date.now().toString().slice(-5)}.xml`; const xmlUploadPath = `Virements/${annee}/${mois}/${spXmlFileName}`; const accessToken = await getGraphToken(); if (accessToken) { await axios.put( `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${xmlUploadPath}:/content`, Buffer.from(xml, 'utf-8'), { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/xml' }, maxBodyLength: Infinity } ); console.log(`✅ [ASYNC] XML uploadé sur SharePoint : ${xmlUploadPath}`); } } catch (spErr) { console.error('⚠️ [ASYNC] Upload XML SharePoint échoué :', spErr.message); } // 2. Générer les PDFs récap + notifier en parallèle par note const tarifKm = await getTarifKm(); await Promise.allSettled(notes.recordset.map(async (note) => { try { // PDFs récap let fichiersExistants = []; try { fichiersExistants = JSON.parse(note.fichiers || '[]'); } catch { } const justifFiles = []; for (const f of fichiersExistants) { const name = (f.fileName || '').toLowerCase(); if (name.includes('soumission') || name.includes('resoumission') || name.includes('recap')) continue; try { const buf = await downloadFromSharePoint(f.uploadUrl); const mimetype = name.endsWith('.pdf') ? 'application/pdf' : name.endsWith('.png') ? 'image/png' : 'image/jpeg'; justifFiles.push({ buffer: buf, originalname: f.fileName, mimetype, size: buf.length }); } catch (e) { console.warn(`⚠️ [ASYNC] Justif non récupérable: ${f.fileName}`, e.message); } } const dateObj = new Date(note.date); const moisStr = dateObj.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); const moisCapitalized = moisStr.charAt(0).toUpperCase() + moisStr.slice(1); const nomPrenom = `${note.nom.toUpperCase()} ${note.prenom}`; const histResult = await pool.request() .input('noteId', sql.Int, note.id) .query(` SELECT h.Niveau, h.Action, h.Commentaire, h.DateAction, c.prenom + ' ' + c.nom AS nomPrenom FROM HistoriqueValidation h JOIN CollaborateurAD c ON c.id = h.ValidateurId WHERE h.NoteDeFraisId = @noteId ORDER BY h.DateAction ASC `); const signatures = [ { niveau: 'COLLAB', nomPrenom, date: note.DateCreation || new Date(), action: 'soumettre', commentaire: null }, ...histResult.recordset.map(h => ({ niveau: h.Niveau, nomPrenom: h.nomPrenom, date: h.DateAction, action: h.Action, commentaire: h.Commentaire || null })) ]; const noteDataPDF = { reference: note.reference, nomPrenom, mois: moisCapitalized, date: note.date, categorie: note.categorie || 'Multiple', libelle: note.libelle, montant: parseFloat(note.montant), lignesJson: note.lignesJson, tarifKm, statut: note.statut, departement: note.departement, }; const recapBuffer = await generateRecapWithJustifs(noteDataPDF, justifFiles, signatures); const existingFolder = fichiersExistants[0]?.folderPath; const nomDossier = existingFolder ? existingFolder.split('/')[1] : `${note.prenom}_${note.nom}`.replace(/[^a-zA-Z0-9]/g, '_'); const moisDossier = existingFolder ? existingFolder.split('/')[2] : `${annee}-${mois}`; const recapResult = await uploadToSharePointHierarchique( { buffer: recapBuffer, originalname: `${note.reference}_recap-paiement.pdf`, mimetype: 'application/pdf', size: recapBuffer.length }, note.reference, nomDossier, moisDossier ); fichiersExistants.push(recapResult); await pool.request() .input('id', sql.Int, note.id) .input('fichiers', sql.NVarChar, JSON.stringify(fichiersExistants)) .input('recapUrl', sql.NVarChar, recapResult.uploadUrl) .query(` UPDATE NoteDeFrais SET fichiers = @fichiers, sharepointUrl = @recapUrl, DateModification = GETDATE() WHERE id = @id `); console.log(`✅ [ASYNC] PDF récap-paiement généré : ${note.reference}`); } catch (pdfErr) { console.error(`❌ [ASYNC] PDF récap ${note.reference}:`, pdfErr.message); } // Notification collaborateur (indépendante du PDF) try { await creerNotification({ destinataireId: note.collabId, destinataireEmail: note.email, type: 'paiement', titre: `Paiement en cours de traitement : ${note.reference}`, message: `Votre note ${note.reference} de ${parseFloat(note.montant).toFixed(2)} € est en cours de traitement bancaire.`, noteId: note.id }); } catch (e) { console.error(`❌ [ASYNC] Notif ${note.reference}:`, e.message); } })); console.log(`✅ [ASYNC] Traitement post-XML terminé`); }); } catch (error) { console.error('Erreur génération XML:', error.message); res.status(500).json({ error: error.message }); } }); // GET /api/paiements/xml-historique — liste les XML générés app.get('/api/paiements/xml-historique', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur', 'President')) return res.status(403).json({ error: 'Accès réservé Finance' }); try { const { annee, mois } = req.query; const request = pool.request(); let where = `WHERE n.dateXml IS NOT NULL AND n.statut IN ('paiementenattente', 'payee')`; if (annee) { request.input('annee', sql.Int, parseInt(annee)); where += ` AND YEAR(n.dateXml) = @annee`; } if (mois) { request.input('mois', sql.Int, parseInt(mois)); where += ` AND MONTH(n.dateXml) = @mois`; } let campusWhere = ''; if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur', 'President')) { const campusCode = normalizeCampus(req.user.campus); if (campusCode) { request.input('campus', sql.NVarChar, `%${campusCode}%`); campusWhere = `AND c.campus LIKE @campus`; } } const result = await request.query(` SELECT CONVERT(NVARCHAR(16), n.dateXml, 120) AS dateXmlJour, -- "2026-05-22 14:32" MIN(n.dateXml) AS dateXmlExacte, COUNT(*) AS nbNotes, SUM(n.montant) AS totalMontant, STRING_AGG(CAST(n.id AS NVARCHAR(20)), ',') AS noteIds, STRING_AGG(n.reference, ', ') AS listeReferences, MAX(CASE WHEN n.presidentId IS NOT NULL THEN p.prenom + ' ' + p.nom ELSE NULL END) AS presidentNom, MAX(n.dateValidationPresident) AS dateValidationPresident, MAX(n.commentairePresident) AS commentairePresident FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD p ON p.id = n.presidentId ${where} ${campusWhere} GROUP BY CONVERT(NVARCHAR(16), n.dateXml, 120) ORDER BY CONVERT(NVARCHAR(16), n.dateXml, 120) DESC `); res.json(result.recordset); } catch (error) { console.error('GET /api/paiements/xml-historique:', error.message); res.status(500).json({ error: error.message }); } }); // POST /api/paiements/regenerer-xml — régénère le XML pour un batch app.post('/api/paiements/regenerer-xml', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); const { noteIds } = req.body; if (!Array.isArray(noteIds) || noteIds.length === 0) return res.status(400).json({ error: 'Aucune note sélectionnée' }); try { const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(','); const notes = await pool.request().query(` SELECT n.id, n.reference, n.montant, n.libelle, n.dateXml, c.nom, c.prenom, c.iban, c.bic FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.id IN (${idList}) AND n.statut IN ('paiementenattente', 'payee') `); if (!notes.recordset.length) return res.status(404).json({ error: 'Notes introuvables' }); const now = new Date(); const annee = now.getFullYear(); const mois = String(now.getMonth() + 1).padStart(2, '0'); const todayISO = now.toISOString().split('T')[0]; const creDtTm = now.toISOString().slice(0, 19); const msgId = `NDF-REGEN-${annee}${mois}-${Date.now().toString().slice(-7)}`; const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0).toFixed(2); const campusDominant = (() => { const campusCounts = {}; for (const n of notes.recordset) { const code = normalizeCampus(n.campus || '') || n.campus || ''; if (code) campusCounts[code] = (campusCounts[code] || 0) + 1; } return Object.entries(campusCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null; })(); const cfg = await getConfigDebiteur(campusDominant); const dbtrNom = cfg.companyName; const dbtrIban = cfg.companyIban; const dbtrBic = cfg.companyBic; const dbtrAdrLine = cfg.companyAddress; const dbtrCp = cfg.companyCp; const dbtrVille = cfg.companyVille; const dbtrPays = cfg.companyPays; let transactions = ''; for (const n of notes.recordset) { let ibanClair = 'FR0000000000000000000000000'; try { if (n.iban && n.iban.includes(':')) ibanClair = decryptIban(n.iban); else if (n.iban) ibanClair = n.iban; } catch { } const benefNom = `${n.nom.toUpperCase()} ${n.prenom}`; const benefBicBlock = n.bic ? `${n.bic}` : `NOTPROVIDED`; transactions += ` VIREMENT NUM:${n.reference} ${n.reference} ${parseFloat(n.montant).toFixed(2)} ${benefBicBlock} ${benefNom} ${ibanClair} `; } const xml = ` ${msgId} ${creDtTm} ${notes.recordset.length} ${total} ${dbtrNom} ${msgId} TRF true ${notes.recordset.length} ${total} SEPA ${todayISO} ${dbtrNom} ${dbtrCp} ${dbtrVille} ${dbtrPays} ${dbtrAdrLine} ${dbtrIban} NOTPROVIDED SLEV${transactions} `; const xmlFileName = `virements-ndf-REGEN-${todayISO}-${Date.now().toString().slice(-5)}.xml`; res.setHeader('Content-Type', 'application/xml; charset=iso-8859-1'); res.setHeader('Content-Disposition', `attachment; filename=${xmlFileName}`); res.send(xml); } catch (error) { console.error('Erreur régénération XML:', error.message); res.status(500).json({ error: error.message }); } }); // GET /api/paiements/config-debiteur // GET — récupérer toutes les configs actives (une par campus) app.get('/api/paiements/config-debiteur', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); try { const result = await pool.request().query(` SELECT id, companyName, companyIban, companyBic, companyAddress, companyCp, companyVille, companyPays, campus, DateModification FROM ConfigDebiteurXML WHERE actif = 1 ORDER BY CASE WHEN campus IS NULL THEN 1 ELSE 0 END, campus `); res.json(result.recordset); } catch (e) { res.status(500).json({ error: e.message }); } }); // PUT — créer/remplacer la config pour un campus donné app.put('/api/paiements/config-debiteur', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); const { companyName, companyIban, companyBic, companyAddress, companyCp, companyVille, companyPays, campus } = req.body; if (!companyName || !companyIban || !companyBic) return res.status(400).json({ error: 'Nom, IBAN et BIC sont obligatoires' }); const ibanClean = companyIban.replace(/\s+/g, '').toUpperCase(); const bicClean = companyBic.replace(/\s+/g, '').toUpperCase(); const campusCode = campus ? (normalizeCampus(campus) || campus) : null; try { // Désactiver uniquement la config du même campus if (campusCode) { await pool.request() .input('campus', sql.NVarChar, campusCode) .query(`UPDATE ConfigDebiteurXML SET actif = 0 WHERE actif = 1 AND campus = @campus`); } else { await pool.request() .query(`UPDATE ConfigDebiteurXML SET actif = 0 WHERE actif = 1 AND campus IS NULL`); } await pool.request() .input('companyName', sql.NVarChar, companyName.trim()) .input('companyIban', sql.NVarChar, ibanClean) .input('companyBic', sql.NVarChar, bicClean) .input('companyAddress', sql.NVarChar, (companyAddress || '').trim()) .input('companyCp', sql.NVarChar, (companyCp || '').trim()) .input('companyVille', sql.NVarChar, (companyVille || '').trim()) .input('companyPays', sql.NVarChar, (companyPays || 'FR').trim().slice(0, 2).toUpperCase()) .input('campus', sql.NVarChar, campusCode) .input('modifiePar', sql.Int, req.user.id) .query(` INSERT INTO ConfigDebiteurXML (companyName, companyIban, companyBic, companyAddress, companyCp, companyVille, companyPays, campus, actif, modifiePar, DateCreation, DateModification) VALUES (@companyName, @companyIban, @companyBic, @companyAddress, @companyCp, @companyVille, @companyPays, @campus, 1, @modifiePar, GETDATE(), GETDATE()) `); res.json({ success: true, campus: campusCode, companyName, companyIban: ibanClean }); } catch (e) { res.status(500).json({ error: e.message }); } }); // PUT /api/paiements/config-debiteur // POST /api/paiements/confirmer-paiement // Body: { noteIds: number[], datePaiement: string (ISO) } // POST /api/paiements/confirmer-paiement — Confirme paiement et passe statut à 'payee' app.post('/api/paiements/confirmer-paiement', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); const { noteIds, datePaiement } = req.body; if (!Array.isArray(noteIds) || noteIds.length === 0) return res.status(400).json({ error: 'Aucune note sélectionnée' }); if (!datePaiement) return res.status(400).json({ error: 'Date de paiement obligatoire' }); try { const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(','); const dateObj = new Date(datePaiement); const mois = dateObj.getMonth() + 1; const annee = dateObj.getFullYear(); // Récupérer les notes pour notifications const notes = await pool.request().query(` SELECT n.id, n.reference, n.montant, n.libelle, c.id AS collabId, c.email, c.prenom, c.nom FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.id IN (${idList}) AND n.statut = 'paiementenattente' `); if (!notes.recordset.length) return res.status(404).json({ error: 'Aucune note en attente de paiement trouvée' }); // Mettre à jour statut → 'payee' await pool.request() .input('datePaiement', sql.DateTime, dateObj) .input('moisPaiement', sql.Int, mois) .input('anneePaiement', sql.Int, annee) .query(` UPDATE NoteDeFrais SET statut = 'payee', datePaiement = @datePaiement, moisPaiement = @moisPaiement, anneePaiement = @anneePaiement, DateModification = GETDATE() WHERE id IN (${idList}) AND statut = 'paiementenattente' `); // Notifier chaque collaborateur for (const n of notes.recordset) { try { await creerNotification({ destinataireId: n.collabId, destinataireEmail: n.email, type: 'paiement', titre: `Paiement effectué : ${n.reference}`, message: `Votre note ${n.reference} de ${parseFloat(n.montant).toFixed(2)}€ a été payée le ${dateObj.toLocaleDateString('fr-FR')}.`, noteId: n.id }); await sendMailGraph(n.email, `Paiement effectué : ${n.reference}`, `

Paiement effectué

Bonjour ${n.prenom} ${n.nom},

Votre note ${n.reference} — ${n.libelle} d'un montant de ${parseFloat(n.montant).toFixed(2)}€ a été payée le ${dateObj.toLocaleDateString('fr-FR')}.

Voir mes notes
`); } catch (e) { console.error('Notif paiement confirmé:', e.message); } } const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0); res.json({ success: true, count: notes.recordset.length, total: parseFloat(total.toFixed(2)), datePaiement }); } catch (error) { console.error('Erreur confirmer-paiement:', error.message); res.status(500).json({ error: error.message }); } }); // ================================================ // ROUTES DE TEST // ================================================ app.get('/api/test-get-drive', async (req, res) => { try { const token = await getGraphToken(); const siteId = 'ensup.sharepoint.com,d94abc08-28eb-47ce-8e12-fbbd6f16b9ea,a052c325-d33a-40e3-9e7b-7896a2ea7ab7'; const r = await axios.get(`https://graph.microsoft.com/v1.0/sites/${siteId}/drive`, { headers: { Authorization: `Bearer ${token}` } }); res.json({ driveId: r.data.id, name: r.data.name }); } catch (e) { res.status(500).json({ error: e.message, details: e.response?.data }); } }); app.get('/api/test-upload', async (req, res) => { try { const token = await getGraphToken(); const testContent = Buffer.from('Test upload NDF - ' + new Date().toISOString()); const path = `Notes de Frais/TEST_Upload/test_${Date.now()}.txt`; const r = await axios.put( `https://graph.microsoft.com/v1.0/sites/${process.env.SHAREPOINT_SITE_ID}/drives/${process.env.SHAREPOINT_DRIVE_ID}/root:/${path}:/content`, testContent, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'text/plain' } } ); res.json({ ok: true, url: r.data.webUrl }); } catch (e) { res.status(500).json({ error: e.message, details: e.response?.data }); } }); // ══════════════════════════════════════════════════════════════════ // BROUILLONS — Sauvegarde serveur (statut = 'brouillon') // ══════════════════════════════════════════════════════════════════ // GET /api/notes/brouillons — récupère les brouillons du collaborateur connecté app.get('/api/notes/brouillons', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('collaborateurId', sql.Int, req.user.id) .query(` SELECT id, libelle, date, description, montant, categorie, lignesJson, DateCreation, DateModification FROM NoteDeFrais WHERE collaborateurId = @collaborateurId AND statut = 'brouillon' ORDER BY DateModification DESC `); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); // POST /api/notes/brouillons — crée un nouveau brouillon app.post('/api/notes/brouillons', authenticateToken, async (req, res) => { try { const { libelle, date, description, lignes } = req.body; let lignesJson = '[]'; if (lignes) { lignesJson = typeof lignes === 'string' ? lignes : JSON.stringify(lignes); } // Calcul du montant total estimé let montantEstime = 0; try { const tarifKm = await getTarifKm(); const lignesParsed = JSON.parse(lignesJson); montantEstime = lignesParsed.reduce((acc, l) => { const isKm = (l.categorie || '').toLowerCase().includes('kilom'); const km = parseFloat(l.km) || 0; const ttc = isKm ? parseFloat((km * tarifKm).toFixed(2)) : (parseFloat(l.montant) || 0); return acc + ttc; }, 0); } catch (e) { /* montant reste 0 */ } // Référence temporaire pour les brouillons (colonne NOT NULL) const refBrouillon = `BRO-${req.user.id}-${Date.now().toString().slice(-6)}`; const insertResult = await pool.request() .input('collaborateurId', sql.Int, req.user.id) .input('reference', sql.NVarChar, refBrouillon) .input('libelle', sql.NVarChar, libelle || 'Brouillon sans titre') .input('date', sql.Date, date ? new Date(date) : new Date()) .input('description', sql.NVarChar, description || null) .input('montant', sql.Decimal, montantEstime) .input('categorie', sql.NVarChar, 'Multiple') .input('lignesJson', sql.NVarChar, lignesJson) .input('statut', sql.NVarChar, 'brouillon') .query(` INSERT INTO NoteDeFrais (reference, collaborateurId, libelle, date, description, montant, categorie, lignesJson, statut, DateCreation, DateModification) OUTPUT INSERTED.id, INSERTED.DateCreation VALUES (@reference, @collaborateurId, @libelle, @date, @description, @montant, @categorie, @lignesJson, @statut, GETDATE(), GETDATE()) `); const created = insertResult.recordset[0]; res.status(201).json({ success: true, id: created.id, createdAt: created.DateCreation }); } catch (error) { console.error('Erreur POST brouillon:', error.message); res.status(500).json({ error: error.message }); } }); // PUT /api/notes/brouillons/:id — met à jour un brouillon existant app.put('/api/notes/brouillons/:id', authenticateToken, upload.any(), async (req, res) => { try { const { libelle, date, description, lignes } = req.body; const brouillonId = parseInt(req.params.id); // Vérifier que ce brouillon appartient bien à ce collaborateur const check = await pool.request() .input('id', sql.Int, brouillonId) .input('collaborateurId', sql.Int, req.user.id) .query(`SELECT id FROM NoteDeFrais WHERE id = @id AND collaborateurId = @collaborateurId AND statut = 'brouillon'`); if (!check.recordset.length) { return res.status(404).json({ error: 'Brouillon introuvable ou accès refusé' }); } // Récupérer infos collaborateur pour le dossier SharePoint const collabResult = await pool.request() .input('id', sql.Int, req.user.id) .query(`SELECT prenom, nom FROM CollaborateurAD WHERE id = @id`); const collaborateur = collabResult.recordset[0]; const nomDossier = `${collaborateur.prenom}${collaborateur.nom}`.replace(/[^a-zA-Z0-9]/g, '_'); const now = new Date(); const moisDossier = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; const noteRef = `BRO-${brouillonId}`; let lignesParsed = []; if (lignes) { try { lignesParsed = typeof lignes === 'string' ? JSON.parse(lignes) : lignes; } catch (e) { lignesParsed = []; } } const uploadedFiles = {}; // ✅ Upload des nouveaux fichiers vers SharePoint et injection dans lignesParsed const allFiles = req.files || []; for (const file of allFiles) { const match = file.fieldname.match(/^files_(.+)$/); if (!match) continue; const depId = String(match[1]); try { const uploaded = await uploadToSharePointHierarchique(file, noteRef, nomDossier, moisDossier); const ligne = lignesParsed.find(l => String(l.id) === depId); if (ligne) { if (!Array.isArray(ligne.qrFiles)) ligne.qrFiles = []; const dejaSauve = ligne.qrFiles.some(f => f.fileName === uploaded.fileName); if (!dejaSauve) { // ✅ Stocker avec origin='upload' ligne.qrFiles.push({ fileName: uploaded.fileName, uploadUrl: uploaded.uploadUrl, origin: 'upload' // ✅ AJOUT }); // ✅ Tracker pour le retour if (!uploadedFiles[depId]) uploadedFiles[depId] = []; uploadedFiles[depId].push({ fileName: uploaded.fileName, uploadUrl: uploaded.uploadUrl, origin: 'upload' }); } } } catch (e) { console.error(`Upload brouillon fichier ${file.originalname}:`, e.message); } } // Recalcul montant let montantEstime = 0; try { const tarifKm = await getTarifKm(); montantEstime = lignesParsed.reduce((acc, l) => { const isKm = (l.categorie || '').toLowerCase().includes('kilom'); const km = parseFloat(l.km) || 0; const ttc = isKm ? parseFloat((km * tarifKm).toFixed(2)) : (parseFloat(l.montant) || parseFloat(l.tvaItems?.[0]?.montantTTC) || 0); return acc + ttc; }, 0); } catch (e) { /* montant reste 0 */ } const lignesJson = JSON.stringify(lignesParsed); await pool.request() .input('id', sql.Int, brouillonId) .input('libelle', sql.NVarChar, libelle || 'Brouillon sans titre') .input('date', sql.Date, date ? new Date(date) : new Date()) .input('description', sql.NVarChar, description || null) .input('montant', sql.Decimal, montantEstime) .input('lignesJson', sql.NVarChar, lignesJson) .query(` UPDATE NoteDeFrais SET libelle = @libelle, date = @date, description = @description, montant = @montant, lignesJson = @lignesJson, DateModification = GETDATE() WHERE id = @id `); res.json({ success: true, updatedAt: new Date().toISOString(), uploadedFiles }); } catch (error) { console.error('Erreur PUT brouillon:', error.message); res.status(500).json({ error: error.message }); } }); // DELETE /api/notes/brouillons/:id — supprime un brouillon app.delete('/api/notes/brouillons/:id', authenticateToken, async (req, res) => { try { const brouillonId = parseInt(req.params.id); const check = await pool.request() .input('id', sql.Int, brouillonId) .input('collaborateurId', sql.Int, req.user.id) .query(`SELECT id FROM NoteDeFrais WHERE id = @id AND collaborateurId = @collaborateurId AND statut = 'brouillon'`); if (!check.recordset.length) { return res.status(404).json({ error: 'Brouillon introuvable ou accès refusé' }); } await pool.request() .input('id', sql.Int, brouillonId) .query(`DELETE FROM NoteDeFrais WHERE id = @id`); res.json({ success: true }); } catch (error) { console.error('Erreur DELETE brouillon:', error.message); res.status(500).json({ error: error.message }); } }); // ══════════════════════════════════════════════════════════════════ // DOCUMENTS COLLABORATEUR — RIB / Carte grise / Permis // Coller dans server.js après les routes /api/profil // ══════════════════════════════════════════════════════════════════ // ============================================================ // DOCUMENTS COLLABORATEUR — RIB / Carte grise / Permis // ============================================================ const DOCTYPES = ['rib', 'cartegrise', 'permis']; // GET /api/profil/documents app.get('/api/profil/documents', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('collabId', sql.Int, req.user.id) .query(` SELECT type, fileName, sharepointUrl, dateUpload, DateModification, statut, commentaire FROM DocumentsCollaborateur WHERE collaborateurId = @collabId AND type != 'rib' -- ← exclure le rib de cette table `); // Vérifier si IBAN saisi directement dans CollaborateurAD const ibanResult = await pool.request() .input('collabId', sql.Int, req.user.id) .query(`SELECT IBAN FROM CollaborateurAD WHERE id = @collabId`); const ibanSaisi = !!(ibanResult.recordset[0]?.IBAN); const docs = { rib: ibanSaisi ? { fileName: 'IBAN_saisi', sharepointUrl: '', updatedAt: new Date().toISOString(), statut: 'valide', commentaire: null } : null, carte_grise: null, permis: null }; for (const row of result.recordset) { docs[row.type] = { fileName: row.fileName, sharepointUrl: row.sharepointUrl, updatedAt: row.DateModification, statut: row.statut ?? 'en_attente', commentaire: row.commentaire ?? null }; } res.json(docs); } catch (error) { console.error('GET /api/profil/documents', error.message); res.status(500).json({ error: error.message }); } }); // POST /api/profil/documents/:type — upload ou remplacement app.post('/api/profil/documents/:type', authenticateToken, upload.single('file'), async (req, res) => { try { const type = req.params.type; if (!DOCTYPES.includes(type)) return res.status(400).json({ error: 'Type invalide. Valeurs : rib, cartegrise, permis' }); if (!req.file) return res.status(400).json({ error: 'Aucun fichier fourni' }); // Infos collaborateur (campus pour notifier la bonne Finance) const collabResult = await pool.request() .input('id', sql.Int, req.user.id) .query(`SELECT prenom, nom, campus FROM CollaborateurAD WHERE id = @id`); if (!collabResult.recordset.length) return res.status(404).json({ error: 'Collaborateur introuvable' }); const { prenom, nom, campus } = collabResult.recordset[0]; const nomDossier = `${prenom}${nom}`.replace(/[^a-zA-Z0-9]/g, '_'); const safeFileName = `${type}_${req.file.originalname.replace(/[^a-zA-Z0-9.\-]/g, '_')}`; const folderPath = `Documents-Profil/${nomDossier}`; const uploadPath = `${folderPath}/${safeFileName}`; // Upload SharePoint const accessToken = await getGraphToken(); if (!accessToken) throw new Error('Token Graph indisponible'); const spRes = await axios.put( `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${uploadPath}:/content`, req.file.buffer, { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': req.file.mimetype || 'application/octet-stream' }, maxBodyLength: Infinity } ); const sharepointUrl = spRes.data.webUrl; // UPSERT — remet en attente si document remplacé await pool.request() .input('collabId', sql.Int, req.user.id) .input('type', sql.NVarChar, type) .input('fileName', sql.NVarChar, safeFileName) .input('sharepointUrl', sql.NVarChar, sharepointUrl) .query(` IF EXISTS (SELECT 1 FROM DocumentsCollaborateur WHERE collaborateurId = @collabId AND type = @type) UPDATE DocumentsCollaborateur SET fileName = @fileName, sharepointUrl = @sharepointUrl, DateModification = GETDATE(), dateUpload = GETDATE(), statut = 'en_attente', validePar = NULL, dateValidation = NULL, commentaire = NULL WHERE collaborateurId = @collabId AND type = @type ELSE INSERT INTO DocumentsCollaborateur (collaborateurId, type, fileName, sharepointUrl, dateUpload, DateModification, statut) VALUES (@collabId, @type, @fileName, @sharepointUrl, GETDATE(), GETDATE(), 'en_attente') `); // Notifier les Finance du même campus const typeLabels = { rib: 'RIB', cartegrise: 'Carte grise', permis: 'Permis de conduire' }; const campusNorm = normalizeCampus(campus); const financeResult = await pool.request() .input('campus', sql.NVarChar, campusNorm ? `%${campusNorm}%` : '%') .query(` SELECT DISTINCT c.id, c.email, c.prenom, c.nom FROM CollaborateurAD c JOIN UtilisateurRoles r ON r.collaborateur_id = c.id WHERE r.role IN ('Finance', 'VerificateurFinance') AND r.actif = 1 AND c.campus LIKE @campus AND c.Actif = 1 `); const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; for (const finance of financeResult.recordset) { try { await creerNotification({ destinataireId: finance.id, destinataireEmail: finance.email, type: 'validationdoc', titre: `Document à valider — ${prenom} ${nom}`, message: `${prenom} ${nom} (${campus}) a soumis son ${typeLabels[type]} pour validation.`, noteId: null }); } catch (e) { console.error('Notif BDD Finance doc', e.message); } try { await sendMailGraph( finance.email, `Document à valider — ${typeLabels[type]} de ${prenom} ${nom}`, `

Document à valider

Bonjour ${finance.prenom} ${finance.nom},

${prenom} ${nom} (${campus}) a soumis son ${typeLabels[type]} en attente de votre validation.

Valider les documents
` ); } catch (e) { console.error('Email Finance doc', e.message); } } res.json({ success: true, type, fileName: safeFileName, sharepointUrl, statut: 'en_attente', updatedAt: new Date().toISOString() }); } catch (error) { console.error(`POST /api/profil/documents/${req.params.type}`, error.message); res.status(500).json({ error: error.message }); } }); // DELETE /api/profil/documents/:type app.delete('/api/profil/documents/:type', authenticateToken, async (req, res) => { try { const type = req.params.type; if (!DOCTYPES.includes(type)) return res.status(400).json({ error: 'Type invalide' }); await pool.request() .input('collabId', sql.Int, req.user.id) .input('type', sql.NVarChar, type) .query(`DELETE FROM DocumentsCollaborateur WHERE collaborateurId = @collabId AND type = @type`); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); // GET /api/finance/documents-a-valider — Finance voit les docs en_attente de son campus app.get('/api/finance/documents-a-valider', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); try { const request = pool.request(); let campusFilter = ''; if (req.user.campus && !hasAnyRole(req.user, 'superUtilisateur')) { const campusCode = normalizeCampus(req.user.campus); if (campusCode) { request.input('campus', sql.NVarChar, `%${campusCode}%`); campusFilter = 'AND c.campus LIKE @campus'; } } const result = await request.query(` SELECT d.id, d.collaborateurId, d.type, d.fileName, d.sharepointUrl, d.dateUpload, d.DateModification, d.statut, d.commentaire, c.prenom, c.nom, c.email, c.campus, c.departement FROM DocumentsCollaborateur d JOIN CollaborateurAD c ON c.id = d.collaborateurId WHERE d.statut = 'en_attente' ${campusFilter} ORDER BY d.DateModification ASC `); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); // PUT /api/finance/documents/:id/valider — Finance valide ou refuse un document // PUT /api/finance/documents/:id/valider app.put('/api/finance/documents/:id/valider', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); try { const docId = parseInt(req.params.id); const { action, commentaire } = req.body; if (!['valider', 'refuser'].includes(action)) return res.status(400).json({ error: 'Action invalide' }); const newStatut = action === 'valider' ? 'valide' : 'refuse'; const docResult = await pool.request() .input('id', sql.Int, docId) .query(`SELECT d.*, c.prenom, c.nom, c.email, c.campus FROM DocumentsCollaborateur d JOIN CollaborateurAD c ON c.id = d.collaborateurId WHERE d.id = @id`); if (!docResult.recordset.length) return res.status(404).json({ error: 'Document introuvable' }); const doc = docResult.recordset[0]; await pool.request() .input('id', sql.Int, docId) .input('statut', sql.NVarChar, newStatut) .input('validePar', sql.Int, req.user.id) .input('commentaire', sql.NVarChar, commentaire || null) .query(` UPDATE DocumentsCollaborateur SET statut = @statut, validePar = @validePar, dateValidation = GETDATE(), commentaire = @commentaire, DateModification = GETDATE() WHERE id = @id `); const typeLabels = { rib: 'RIB', carte_grise: 'Carte grise', cartegrise: 'Carte grise', permis: 'Permis de conduire' }; const isValide = newStatut === 'valide'; const titre = isValide ? `${typeLabels[doc.type] || doc.type} validé ✅` : `${typeLabels[doc.type] || doc.type} refusé ❌`; const message = isValide ? `Votre ${typeLabels[doc.type] || doc.type} a été validé par la Finance.` : `Votre ${typeLabels[doc.type] || doc.type} a été refusé.${commentaire ? ' Motif : ' + commentaire : ''}`; try { await creerNotification({ destinataireId: doc.collaborateurId, destinataireEmail: doc.email, type: isValide ? 'doc_valide' : 'doc_refuse', titre, message, noteId: null }); } catch (e) { console.error('Notif doc validé', e.message); } try { const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; await sendMailGraph(doc.email, titre, `

${titre}

Bonjour ${doc.prenom} ${doc.nom},

${message}

${!isValide ? '

Veuillez soumettre un nouveau document corrigé depuis votre profil.

' : ''}
Accéder à mon profil
` ); } catch (e) { console.error('Email doc validé', e.message); } res.json({ success: true, statut: newStatut }); } catch (error) { res.status(500).json({ error: error.message }); } }); // GET /api/profil/docs-statut — vérifie si le collaborateur peut soumettre (docs validés) app.get('/api/profil/docs-statut', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('collaborateurId', sql.Int, req.user.id) .query(`SELECT type, statut FROM DocumentsCollaborateur WHERE collaborateurId = @collaborateurId`); const map = {}; for (const row of result.recordset) map[row.type] = row.statut; res.json({ rib: map['rib'] || 'absent', carte_grise: map['carte_grise'] || 'absent', permis: map['permis'] || 'absent', }); } catch (error) { res.status(500).json({ error: error.message }); } }); // ── PUT /api/profil/iban ───────────────────────────────────────── app.put('/api/profil/iban', authenticateToken, async (req, res) => { try { let { iban, bic } = req.body; if (!iban) return res.status(400).json({ error: 'IBAN obligatoire' }); if (!bic) return res.status(400).json({ error: 'BIC obligatoire' }); iban = iban.replace(/\s+/g, '').toUpperCase(); bic = bic.replace(/\s+/g, '').toUpperCase(); if (!validateIban(iban)) return res.status(400).json({ error: 'IBAN invalide (format ou checksum incorrect)' }); const ibanChiffre = encryptIban(iban); const ibanHash = hashIban(iban); await pool.request() .input('id', sql.Int, req.user.id) .input('iban', sql.NVarChar, ibanChiffre) .input('ibanHash', sql.NVarChar, ibanHash) .input('bic', sql.NVarChar, bic) .query(` UPDATE CollaborateurAD SET IBAN = @iban, IBAN_HASH = @ibanHash, BIC = @bic, DateModification = GETDATE() WHERE id = @id `); // ← Le second bloc DocumentsCollaborateur est supprimé res.json({ success: true, iban: maskIban(iban), bic, statut: 'valide' }); } catch (error) { console.error('PUT /api/profil/iban :', error.message); res.status(500).json({ error: 'Erreur serveur' }); } }); // ── GET /api/profil/iban ───────────────────────────────────────── app.get('/api/profil/iban', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('id', sql.Int, req.user.id) .query(`SELECT IBAN, BIC FROM CollaborateurAD WHERE id = @id`); const row = result.recordset[0]; if (!row) return res.status(404).json({ error: 'Utilisateur introuvable' }); let ibanMasque = null; if (row.IBAN) { try { const ibanClair = decryptIban(row.IBAN); ibanMasque = maskIban(ibanClair); } catch { // IBAN encore en clair en base (avant migration) — on masque directement ibanMasque = maskIban(row.IBAN); } } res.json({ iban: ibanMasque, ibanSaisi: !!row.IBAN, bic: row.BIC || null, }); } catch (error) { console.error('GET /api/profil/iban :', error.message); res.status(500).json({ error: 'Erreur serveur' }); } }); app.post('/api/verificateur/notes/:id/non-conforme', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès refusé' }); const { fileName, motif } = req.body; const noteId = parseInt(req.params.id); try { const noteResult = await pool.request() .input('id', sql.Int, noteId) .query(` SELECT n.id, n.reference, n.libelle, n.montant, n.collaborateurId, c.prenom, c.nom, c.email, v1.id AS n1Id, v1.email AS emailN1, v1.prenom AS prenomN1, v1.nom AS nomN1 FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id WHERE n.id = @id `); if (!noteResult.recordset.length) return res.status(404).json({ error: 'Note introuvable' }); const note = noteResult.recordset[0]; // ✅ NOUVEAU — sauvegarder en BDD await pool.request() .input('noteId', sql.Int, noteId) .input('verificateurId', sql.Int, req.user.id) .input('fileName', sql.NVarChar, fileName) .input('motif', sql.NVarChar, motif) .query(` INSERT INTO JustificatifsNonConformes (noteDeFraisId, verificateurId, fileName, motif, statut, dateSignalement) VALUES (@noteId, @verificateurId, @fileName, @motif, 'non_conforme', GETDATE()) `); await pool.request() .input('id', sql.Int, noteId) .query(` UPDATE NoteDeFrais SET statut = 'non_conforme_verif', DateModification = GETDATE() WHERE id = @id AND statut = 'approuve' `); // ✅ NOUVEAU — tracer dans HistoriqueValidation await pool.request() .input('noteId', sql.Int, noteId) .input('validateurId', sql.Int, req.user.id) .input('commentaire', sql.NVarChar, `Justificatif non conforme : "${fileName}" — ${motif}`) .input('statut', sql.NVarChar, 'approuve') .query(` INSERT INTO HistoriqueValidation (NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction) VALUES (@noteId, @validateurId, 'VERIF', 'non_conforme', @commentaire, @statut, GETDATE()) `); const verificateurNom = `${req.user.prenom} ${req.user.nom}`; const montantFormate = parseFloat(note.montant).toFixed(2); const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; // Notifications (votre code existant inchangé) try { await creerNotification({ destinataireId: note.collaborateurId, destinataireEmail: note.email, type: 'refus', titre: `⚠️ Justificatif non conforme — ${note.reference}`, message: `Le justificatif "${fileName}" de votre note ${note.reference} est non conforme. Motif : ${motif}`, noteId }); } catch (e) { console.error('Notif BDD collab non-conforme:', e.message); } try { await sendMailGraph( note.email, `⚠️ Justificatif non conforme — ${note.reference}`, `

⚠️ Justificatif non conforme

Une correction est nécessaire

Bonjour ${note.prenom} ${note.nom},

Le vérificateur Finance ${verificateurNom} a signalé un justificatif non conforme sur votre note ${note.reference}.

Justificatif concerné
📄 ${fileName}
Motif
${motif}
Référence ${note.reference}
Libellé ${note.libelle}
Montant ${montantFormate} €
📝 Que faire ?
  1. Retrouvez le justificatif original corrigé
  2. Contactez votre responsable ou la Finance
  3. Soumettez un nouveau justificatif via la plateforme
Accéder à ma note →
` ); } catch (e) { console.error('Email collab non-conforme:', e.message); } // ── Notifier le validateur N1 ── if (note.n1Id && note.emailN1) { try { await creerNotification({ destinataireId: note.n1Id, destinataireEmail: note.emailN1, type: 'refus', titre: `⚠️ Justificatif non conforme — ${note.reference}`, message: `La note ${note.reference} de ${note.prenom} ${note.nom} comporte un justificatif non conforme : "${fileName}". Motif : ${motif}`, noteId }); } catch (e) { console.error('Notif BDD N1 non-conforme:', e.message); } try { await sendMailGraph( note.emailN1, `⚠️ Note ${note.reference} — justificatif non conforme`, `

⚠️ Justificatif non conforme signalé

Bonjour ${note.prenomN1} ${note.nomN1},

Le vérificateur Finance ${verificateurNom} a signalé un justificatif non conforme sur la note ${note.reference} de ${note.prenom} ${note.nom}.

📄 ${fileName}
Motif : ${motif}

Le collaborateur a été notifié et doit fournir un justificatif corrigé.

` ); } catch (e) { console.error('Email N1 non-conforme:', e.message); } } res.json({ success: true, notifiedCollab: true, notifiedN1: !!(note.n1Id && note.emailN1) }); } catch (error) { console.error('Erreur POST non-conforme:', error.message); res.status(500).json({ error: error.message }); } }); app.get('/api/paiements/filtres-disponibles', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance' }); try { const result = await pool.request().query(` SELECT DISTINCT c.campus, c.societe FROM CollaborateurAD c WHERE c.Actif = 1 AND c.campus IS NOT NULL AND c.campus != '' ORDER BY c.campus, c.societe `); const campusSet = new Set(); // Map : campusCode → Set de sociétés const societeParCampus = {}; const societeSet = new Set(); for (const row of result.recordset) { if (row.campus) { const c = row.campus.toUpperCase(); const code = c.includes('SQY') || c.includes('SAINT') ? 'SQY' : c.includes('CGY') || c.includes('CERGY') ? 'CGY' : c.includes('MRS') || c.includes('MARSEILLE') ? 'MRS' : c.includes('NTE') || c.includes('NANTES') ? 'NTE' : row.campus; campusSet.add(code); // Grouper les sociétés par campus normalisé if (!societeParCampus[code]) societeParCampus[code] = new Set(); if (row.societe && row.societe.trim()) { societeParCampus[code].add(row.societe.trim()); societeSet.add(row.societe.trim()); } } } // Convertir les Sets en tableaux triés const societeParCampusFinal = {}; for (const [campus, set] of Object.entries(societeParCampus)) { societeParCampusFinal[campus] = [...set].sort(); } res.json({ campus: [...campusSet].sort(), societes: [...societeSet].sort(), // toutes sociétés (fallback) societeParCampus: societeParCampusFinal, // sociétés par campus ← nouveau }); } catch (error) { console.error('GET /api/paiements/filtres-disponibles:', error.message); res.status(500).json({ error: error.message }); } }); app.get('/api/verificateur/notes/:id/non-conformes', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'VerificateurFinance', 'Finance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès refusé' }); try { const result = await pool.request() .input('noteId', sql.Int, parseInt(req.params.id)) .query(` SELECT j.id, j.fileName, j.motif, j.statut, j.dateSignalement, c.prenom + ' ' + c.nom AS verificateur FROM JustificatifsNonConformes j JOIN CollaborateurAD c ON c.id = j.verificateurId WHERE j.noteDeFraisId = @noteId ORDER BY j.dateSignalement DESC `); res.json(result.recordset); } catch (error) { res.status(500).json({ error: error.message }); } }); // ========================================================= // ROUTES À AJOUTER DANS server.js // Coller après les routes /api/profil/iban existantes // ========================================================= // ── GET /api/profil/vehicule ───────────────────────────── app.get('/api/profil/vehicule', authenticateToken, async (req, res) => { try { const result = await pool.request() .input('id', sql.Int, req.user.id) .query(` SELECT chevauxFiscaux, vehiculeMarque, vehiculeModele, vehiculeImmat, vehiculeDateMaj FROM CollaborateurAD WHERE id = @id `); const row = result.recordset[0]; if (!row) return res.status(404).json({ error: 'Utilisateur introuvable' }); // Retourner null si pas encore configuré if (!row.chevauxFiscaux) { return res.json({ configured: false, vehicule: null }); } res.json({ configured: true, vehicule: { chevaux: row.chevauxFiscaux, marque: row.vehiculeMarque || '', modele: row.vehiculeModele || '', immatriculation: row.vehiculeImmat || '', dateMaj: row.vehiculeDateMaj, } }); } catch (error) { console.error('GET /api/profil/vehicule :', error.message); res.status(500).json({ error: 'Erreur serveur' }); } }); // ── PUT /api/profil/vehicule ───────────────────────────── app.put('/api/profil/vehicule', authenticateToken, async (req, res) => { try { const { chevaux, marque, modele, immatriculation } = req.body; // Validation const cv = parseInt(chevaux); if (!cv || cv < 3 || cv > 7) { return res.status(400).json({ error: 'Cheval fiscal invalide (valeurs acceptées : 3, 4, 5, 6, 7)' }); } const immatClean = (immatriculation || '') .replace(/\s+/g, '-') .toUpperCase() .slice(0, 20); await pool.request() .input('id', sql.Int, req.user.id) .input('chevaux', sql.Int, cv) .input('marque', sql.NVarChar, (marque || '').trim().slice(0, 100)) .input('modele', sql.NVarChar, (modele || '').trim().slice(0, 100)) .input('immat', sql.NVarChar, immatClean) .query(` UPDATE CollaborateurAD SET chevauxFiscaux = @chevaux, vehiculeMarque = @marque, vehiculeModele = @modele, vehiculeImmat = @immat, vehiculeDateMaj = GETDATE(), DateModification = GETDATE() WHERE id = @id `); console.log(`✅ Profil véhicule mis à jour — user ${req.user.email} : ${cv} CV`); res.json({ success: true, vehicule: { chevaux: cv, marque: marque || '', modele: modele || '', immatriculation: immatClean } }); } catch (error) { console.error('PUT /api/profil/vehicule :', error.message); res.status(500).json({ error: 'Erreur serveur' }); } }); // ── DELETE /api/profil/vehicule ────────────────────────── app.delete('/api/profil/vehicule', authenticateToken, async (req, res) => { try { await pool.request() .input('id', sql.Int, req.user.id) .query(` UPDATE CollaborateurAD SET chevauxFiscaux = NULL, vehiculeMarque = NULL, vehiculeModele = NULL, vehiculeImmat = NULL, vehiculeDateMaj = NULL, DateModification = GETDATE() WHERE id = @id `); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message }); } }); app.post('/api/paiements/soumettre-president', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'Finance', 'ValidateurFinance', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé Finance / ValidateurFinance' }); const { noteIds } = req.body; if (!Array.isArray(noteIds) || noteIds.length === 0) return res.status(400).json({ error: 'Aucune note sélectionnée' }); try { const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(','); // Vérifier que toutes les notes sont bien au statut 'verifie' const notes = await pool.request().query(` SELECT n.id, n.reference, n.montant, n.libelle, n.statut, c.prenom, c.nom, c.email, c.campus, c.id AS collabId FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.id IN (${idList}) AND n.statut IN ('approuve', 'verifie') `); if (!notes.recordset.length) return res.status(404).json({ error: 'Aucune note approuvée/vérifiée trouvée' }); // Passer en 'en_attente_president' await pool.request().query(` UPDATE NoteDeFrais SET presidentValidation = 'en_attente_president', statut = 'en_attente_president', DateModification = GETDATE() WHERE id IN (${idList}) AND statut IN ('approuve', 'verifie') `); // Historique for (const note of notes.recordset) { try { await pool.request() .input('noteId', sql.Int, note.id) .input('validateurId', sql.Int, req.user.id) .input('commentaire', sql.NVarChar, `Soumis au Président par ${req.user.prenom} ${req.user.nom} (${notes.recordset.length} note(s))`) .input('statut', sql.NVarChar, 'en_attente_president') .query(` INSERT INTO HistoriqueValidation (NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction) VALUES (@noteId, @validateurId, 'PRESIDENT', 'soumettre', @commentaire, @statut, GETDATE()) `); } catch (e) { console.error('Histo President soumettre:', e.message); } } // Trouver le(s) Président(s) → notifier const presidents = await pool.request().query(` SELECT c.id, c.email, c.prenom, c.nom FROM CollaborateurAD c JOIN UtilisateurRoles r ON r.collaborateur_id = c.id WHERE r.role = 'President' AND r.actif = 1 AND c.Actif = 1 `); const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0).toFixed(2); const frontendUrl = process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'; const validateurNom = `${req.user.prenom} ${req.user.nom}`; for (const president of presidents.recordset) { try { await creerNotification({ destinataireId: president.id, destinataireEmail: president.email, type: 'validation', titre: `💼 ${notes.recordset.length} note(s) en attente de votre validation`, message: `${validateurNom} vous soumet ${notes.recordset.length} note(s) de frais pour un total de ${total} € en attente de votre validation avant virement.`, noteId: null }); } catch (e) { console.error('Notif President BDD:', e.message); } try { const lignesNotes = notes.recordset.map(n => ` ${n.reference} ${n.prenom} ${n.nom} ${parseFloat(n.montant).toFixed(2)} € ` ).join(''); await sendMailGraph( president.email, `💼 ${notes.recordset.length} note(s) de frais en attente de votre validation`, `

💼 Notes de frais — Validation Président

${notes.recordset.length} note(s) soumises par ${validateurNom}

Bonjour ${president.prenom} ${president.nom},

${validateurNom} vous soumet les notes de frais suivantes pour validation avant génération du virement bancaire :

${lignesNotes}
Référence Collaborateur Montant
Total à virer ${total} €
✅ Valider et générer le virement →

Cette validation est définitive. Le fichier XML de virement sera généré automatiquement.

` ); } catch (e) { console.error('Email President:', e.message); } } res.json({ success: true, nbNotes: notes.recordset.length, total: parseFloat(total), presidentsNotifies: presidents.recordset.length }); } catch (error) { console.error('Erreur POST /api/paiements/soumettre-president:', error.message); res.status(500).json({ error: error.message }); } }); // ══════════════════════════════════════════════════════════════════════════ // 2. Président → Récupérer les notes en attente de sa validation // GET /api/president/notes // ══════════════════════════════════════════════════════════════════════════ app.get('/api/president/notes', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'President', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé au Président' }); try { const result = await pool.request().query(` SELECT n.*, c.nom + ' ' + c.prenom AS collaborateur, c.email AS collaborateurEmail, c.departement, c.campus, c.societe, v1.nom + ' ' + v1.prenom AS nomN1, vf.nom + ' ' + vf.prenom AS nomVerificateur, n.dateVerification, n.commentaireVerification FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId LEFT JOIN CollaborateurAD v1 ON v1.id = n.validateurN1Id LEFT JOIN CollaborateurAD vf ON vf.id = n.verificateurFinanceId WHERE n.statut = 'en_attente_president' AND n.presidentValidation = 'en_attente_president' ORDER BY n.DateModification DESC `); res.json(result.recordset); } catch (error) { console.error('GET /api/president/notes:', error.message); res.status(500).json({ error: error.message }); } }); // ══════════════════════════════════════════════════════════════════════════ // 3. Président → Générer le XML + valider les notes // POST /api/president/generer-xml // Body: { noteIds: number[], commentaire?: string } // ══════════════════════════════════════════════════════════════════════════ app.post('/api/president/generer-xml', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'President', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé au Président' }); const { noteIds, commentaire } = req.body; if (!Array.isArray(noteIds) || noteIds.length === 0) return res.status(400).json({ error: 'Aucune note sélectionnée' }); try { const idList = noteIds.map(id => parseInt(id)).filter(Boolean).join(','); const notes = await pool.request().query(` SELECT n.id, n.reference, n.montant, n.libelle, n.date, n.fichiers, n.lignesJson, n.categorie, n.DateCreation, c.nom, c.prenom, c.iban, c.bic, c.campus, c.societe, c.adresse_rue, c.adresse_cp, c.adresse_ville, c.adresse_pays, c.id AS collabId, c.email FROM NoteDeFrais n JOIN CollaborateurAD c ON c.id = n.collaborateurId WHERE n.id IN (${idList}) AND n.statut = 'en_attente_president' `); if (!notes.recordset.length) return res.status(404).json({ error: 'Aucune note en attente de validation Président trouvée' }); // ── Validation IBAN / adresse ───────────────────────────────────── const erreurs = []; for (const n of notes.recordset) { if (!n.iban) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : IBAN manquant`); if (!n.bic) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : BIC manquant`); if (!n.adresse_rue || !n.adresse_cp || !n.adresse_ville || !n.adresse_pays) erreurs.push(`${n.reference} (${n.prenom} ${n.nom}) : adresse postale incomplète`); } if (erreurs.length > 0) return res.status(422).json({ error: 'Données manquantes — XML non généré', details: erreurs }); // ── Construction XML PAIN.001 ───────────────────────────────────── const now = new Date(); const annee = now.getFullYear(); const mois = String(now.getMonth() + 1).padStart(2, '0'); const todayISO = now.toISOString().split('T')[0]; const creDtTm = now.toISOString().slice(0, 19); const presidentNom = `${req.user.prenom} ${req.user.nom}`; const msgId = `NDF-PRES-${annee}${mois}-${Date.now().toString().slice(-7)}`; const total = notes.recordset.reduce((s, n) => s + parseFloat(n.montant), 0); const totalFormate = total.toFixed(2); // Campus dominant pour le compte débiteur const campusDominant = (() => { const campusCounts = {}; for (const n of notes.recordset) { const code = normalizeCampus(n.campus || '') || n.campus || ''; if (code) campusCounts[code] = (campusCounts[code] || 0) + 1; } return Object.entries(campusCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null; })(); const cfg = await getConfigDebiteur(campusDominant); const { companyName: dbtrNom, companyIban: dbtrIban, companyBic: dbtrBic, companyAddress: dbtrAdrLine, companyCp: dbtrCp, companyVille: dbtrVille, companyPays: dbtrPays } = cfg; let transactions = ''; for (const n of notes.recordset) { let ibanClair = 'FR0000000000000000000000000'; try { if (n.iban && n.iban.includes(':')) ibanClair = decryptIban(n.iban); else if (n.iban) ibanClair = n.iban; } catch (e) { console.warn(`⚠️ Déchiffrement IBAN impossible pour ${n.reference}:`, e.message); } const benefNom = `${n.nom.toUpperCase()} ${n.prenom}`; const adrLine = (n.adresse_rue || '').toUpperCase(); const cp = n.adresse_cp || ''; const ville = (n.adresse_ville || '').toUpperCase(); const pays = (n.adresse_pays || 'FR').slice(0, 2).toUpperCase(); const benefBicBlock = n.bic ? `${n.bic}` : `NOTPROVIDED`; transactions += ` VIREMENT NUM:${n.reference} ${n.reference} ${parseFloat(n.montant).toFixed(2)} ${benefBicBlock} ${benefNom}${adrLine || cp || ville ? ` ${cp ? ` ${cp}` : ''}${ville ? ` ${ville}` : ''} ${pays}${adrLine ? ` ${adrLine}` : ''} ` : ''} ${pays} ${ibanClair} `; } // Mention Président dans le message du fichier const xml = ` ${msgId} ${creDtTm} ${notes.recordset.length} ${totalFormate} ${dbtrNom} ${msgId} TRF true ${notes.recordset.length} ${totalFormate} SEPA ${todayISO} ${dbtrNom} ${dbtrCp} ${dbtrVille} ${dbtrPays} ${dbtrAdrLine} ${dbtrIban} NOTPROVIDED SLEV${transactions} `; // ── Mettre à jour les notes : 'paiementenattente' + validation Président ── await pool.request() .input('presidentId', sql.Int, req.user.id) .input('commentaire', sql.NVarChar, commentaire || null) .input('dateXml', sql.DateTime, now) .query(` UPDATE NoteDeFrais SET statut = 'paiementenattente', presidentValidation = 'valide_president', presidentId = @presidentId, dateValidationPresident = GETDATE(), commentairePresident = @commentaire, dateXml = @dateXml, DateModification = GETDATE() WHERE id IN (${idList}) AND statut = 'en_attente_president' `); // ── Historique ──────────────────────────────────────────────────── for (const note of notes.recordset) { try { await pool.request() .input('noteId', sql.Int, note.id) .input('presidentId', sql.Int, req.user.id) .input('commentaire', sql.NVarChar, `Validé par le Président ${presidentNom}${commentaire ? ' — ' + commentaire : ''}`) .input('statut', sql.NVarChar, 'paiementenattente') .query(` INSERT INTO HistoriqueValidation (NoteDeFraisId, ValidateurId, Niveau, Action, Commentaire, NouveauStatut, DateAction) VALUES (@noteId, @presidentId, 'PRESIDENT', 'valider_xml', @commentaire, @statut, GETDATE()) `); } catch (e) { console.error('Histo President valider:', e.message); } } // ── Envoyer le XML immédiatement ────────────────────────────────── const xmlFileName = `virements-ndf-PRESIDENT-${annee}-${mois}-${todayISO}.xml`; res.setHeader('Content-Type', 'application/xml; charset=iso-8859-1'); res.setHeader('Content-Disposition', `attachment; filename=${xmlFileName}`); res.send(xml); // ── Traitement asynchrone : SharePoint + emails ValidateurFinance ── setImmediate(async () => { console.log(`🔄 [ASYNC PRESIDENT] Post-XML pour ${notes.recordset.length} note(s)...`); // Upload XML sur SharePoint try { const spXmlFileName = `virements-ndf-PRESIDENT-${annee}-${mois}-${Date.now().toString().slice(-5)}.xml`; const xmlUploadPath = `Virements/President/${annee}/${mois}/${spXmlFileName}`; const accessToken = await getGraphToken(); if (accessToken) { await require('axios').put( `https://graph.microsoft.com/v1.0/sites/${SHAREPOINT_CONFIG.siteId}/drives/${SHAREPOINT_CONFIG.driveId}/root:/${xmlUploadPath}:/content`, Buffer.from(xml, 'utf-8'), { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/xml' }, maxBodyLength: Infinity } ); console.log(`✅ [ASYNC PRESIDENT] XML uploadé : ${xmlUploadPath}`); } } catch (e) { console.error('⚠️ [ASYNC PRESIDENT] Upload XML SharePoint:', e.message); } // ── Retrouver le ValidateurFinance qui a soumis au président ────────── try { // On prend la première note du batch pour retrouver qui a soumis // APRÈS (cherche sur TOUTES les notes du batch) const allNoteIds = notes.recordset.map(n => n.id).join(','); const histResult = await pool.request() .query(` SELECT TOP 1 h.ValidateurId, c.email, c.prenom, c.nom FROM HistoriqueValidation h JOIN CollaborateurAD c ON c.id = h.ValidateurId WHERE h.NoteDeFraisId IN (${allNoteIds}) AND h.Niveau = 'PRESIDENT' AND h.Action = 'soumettre' ORDER BY h.DateAction DESC `); const soumetteur = histResult.recordset[0]; if (soumetteur) { const dateLabel = now.toLocaleDateString('fr-FR', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' }); const heureLabel = now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); const lignesNotes = notes.recordset.map(n => ` ${n.reference} ${n.prenom} ${n.nom} ${parseFloat(n.montant).toFixed(2)} € ` ).join(''); // Notification BDD await creerNotification({ destinataireId: soumetteur.ValidateurId, destinataireEmail: soumetteur.email, type: 'paiement', titre: `✅ XML virement validé par le Président — ${notes.recordset.length} note(s)`, message: `Le Président ${presidentNom} a validé et généré le XML de virement le ${dateLabel} à ${heureLabel} pour ${notes.recordset.length} note(s) — ${totalFormate} €.`, noteId: null }); // Email await sendMailGraph( soumetteur.email, `✅ XML virement validé par le Président ${presidentNom}`, `

✅ Virement validé par le Président

Le fichier XML a été généré et est prêt pour votre banque

Bonjour ${soumetteur.prenom} ${soumetteur.nom},

Le Président ${presidentNom} a validé et généré le fichier XML de virement bancaire pour les notes que vous lui avez soumises.

Date : ${dateLabel} à ${heureLabel}
Validé par : ${presidentNom}
Nombre de virements : ${notes.recordset.length}
Montant total : ${totalFormate} €
${commentaire ? `
💬 ${commentaire}
` : ''}
Détail des virements (${notes.recordset.length})
${lignesNotes}
Référence Collaborateur Montant
Total ${totalFormate} €

Vous pouvez re-télécharger ce fichier XML depuis la rubrique "XML virements" de la plateforme.

🏦 Voir les XML virements →
` ); console.log(`✅ [ASYNC PRESIDENT] ValidateurFinance notifié : ${soumetteur.email}`); } else { console.warn('⚠️ [ASYNC PRESIDENT] Soumetteur introuvable dans HistoriqueValidation'); } } catch (e) { console.error('❌ [ASYNC PRESIDENT] Notification soumetteur:', e.message); } console.log(`✅ [ASYNC PRESIDENT] Traitement terminé pour ${notes.recordset.length} note(s)`); }); } catch (error) { console.error('Erreur POST /api/president/generer-xml:', error.message); res.status(500).json({ error: error.message }); } }); // ══════════════════════════════════════════════════════════════════════════ // 4. Président → Historique de ses validations // GET /api/president/historique // ══════════════════════════════════════════════════════════════════════════ app.get('/api/president/historique', authenticateToken, async (req, res) => { if (!hasAnyRole(req.user, 'President', 'superUtilisateur')) return res.status(403).json({ error: 'Accès réservé au Président' }); try { const result = await pool.request() .input('presidentId', sql.Int, req.user.id) .query(` SELECT CAST(n.dateXml AS DATE) AS dateXmlJour, MIN(n.dateXml) AS dateXmlExacte, n.dateValidationPresident, n.commentairePresident, COUNT(*) AS nbNotes, SUM(n.montant) AS totalMontant, STRING_AGG(CAST(n.id AS NVARCHAR(20)), ',') AS noteIds, STRING_AGG(n.reference, ', ') AS listeReferences FROM NoteDeFrais n WHERE n.presidentId = @presidentId AND n.presidentValidation = 'valide_president' GROUP BY CAST(n.dateXml AS DATE), n.dateValidationPresident, n.commentairePresident ORDER BY CAST(n.dateXml AS DATE) DESC `); res.json(result.recordset); } catch (error) { console.error('GET /api/president/historique:', error.message); res.status(500).json({ error: error.message }); } }); // ================================================ // GESTION DES ERREURS // ================================================ app.use((err, req, res, next) => { console.error('❌ Erreur middleware:', err.stack); res.status(500).json({ error: 'Une erreur est survenue', details: process.env.NODE_ENV === 'development' ? err.message : undefined }); }); // ================================================ // DÉMARRAGE DU SERVEUR // ================================================ const server = app.listen(PORT, '0.0.0.0', () => { console.log('\n================================================'); console.log(`✅ SERVEUR DÉMARRÉ sur http://0.0.0.0:${PORT}`); console.log('================================================'); setTimeout(async () => { console.log('\n🚀 Lancement synchronisation automatique Entra ID...'); await syncEntraIdUsers(); setInterval(async () => { console.log('\n🔁 Synchronisation périodique Entra ID...'); await syncEntraIdUsers(); }, 6 * 60 * 60 * 1000); }, 5000); }); server.on('error', (error) => { console.error('\n❌ ERREUR SERVEUR:', error); if (error.code === 'EADDRINUSE') console.error(`⚠️ Le port ${PORT} est déjà utilisé`); process.exit(1); }); setInterval(() => { }, 60000);