6485 lines
315 KiB
JavaScript
6485 lines
315 KiB
JavaScript
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;
|
||
}
|
||
|
||
function recalculerMontantNote(note) {
|
||
try {
|
||
const lignes = JSON.parse(note.lignesJson || '[]');
|
||
if (!lignes.length) return parseFloat(note.montant) || 0;
|
||
return lignes.reduce((sum, l) => {
|
||
if ((l.categorie || '').toLowerCase().includes('kilom'))
|
||
return sum + getIndemniteKmServer(parseFloat(l.km) || 0, parseInt(l.chevaux) || 7);
|
||
return sum + (parseFloat(l.montant) || 0);
|
||
}, 0);
|
||
} catch { return parseFloat(note.montant) || 0; }
|
||
}
|
||
|
||
// ── 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,
|
||
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
|
||
|
||
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]
|
||
: `${nd.collabNom}_${nd.collabPrenom}`
|
||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||
.replace(/[^a-zA-Z0-9_]/g, '_');
|
||
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}`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#7c3aed,#6d28d9);color:white;padding:24px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">✅ Note vérifiée — paiement à valider</h2>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${val.prenom} ${val.nom}</strong>,</p>
|
||
<p>La note <strong>${note.reference}</strong> de <strong>${note.prenom} ${note.nom}</strong> a été vérifiée par <strong>${verificateurNom}</strong> et est prête pour le paiement.</p>
|
||
${aProratise ? `<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:8px;padding:12px;margin:14px 0">
|
||
<strong style="color:#92400e">⚠️ Montants proratisés</strong><br>
|
||
<span style="color:#78350f;font-size:13px">Montant original : ${montantOriginal} € → Montant retenu : <strong>${montantFormate} €</strong> (${montantsModifies.length} repas plafonné${montantsModifies.length > 1 ? 's' : ''} à 25 €/pers.)</span>
|
||
</div>` : ''}
|
||
${commentaire ? `<p style="background:#f1f5f9;padding:12px;border-radius:8px;border-left:4px solid #7c3aed">💬 ${commentaire}</p>` : ''}
|
||
<div style="text-align:center;margin-top:24px">
|
||
<a href="${frontendUrl}" style="background:linear-gradient(135deg,#7c3aed,#6d28d9);color:white;padding:14px 32px;text-decoration:none;border-radius:8px;font-weight:700;display:inline-block">Valider le paiement →</a>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
);
|
||
} 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}`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#f59e0b,#d97706);color:white;padding:24px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">ℹ️ Montant de votre note ajusté</h2>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${note.prenom} ${note.nom}</strong>,</p>
|
||
<p>Votre note <strong>${note.reference}</strong> 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.</p>
|
||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin:16px 0">
|
||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||
<tr><td style="color:#64748b;padding:4px 0;width:160px">Montant soumis</td><td style="font-weight:700;text-decoration:line-through;color:#6b7280">${montantOriginal} €</td></tr>
|
||
<tr><td style="color:#64748b;padding:4px 0">Montant retenu</td><td style="font-weight:800;color:#15803d;font-size:15px">${montantFormate} €</td></tr>
|
||
<tr><td style="color:#64748b;padding:4px 0">Ajustements</td><td style="color:#92400e">${montantsModifies.length} ligne${montantsModifies.length > 1 ? 's' : ''} de repas plafonnée${montantsModifies.length > 1 ? 's' : ''}</td></tr>
|
||
</table>
|
||
</div>
|
||
<p style="font-size:13px;color:#64748b">Le plafond légal pour les frais de repas est de 25 € par personne. Les montants ont été ajustés en conséquence.</p>
|
||
</div>
|
||
</div>`
|
||
);
|
||
}
|
||
} 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,
|
||
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 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 = recalculerMontantNote(note).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 `
|
||
<tr style="border-bottom:1px solid #fecaca">
|
||
<td style="padding:10px 12px;font-size:12px;color:#64748b;width:60px;font-weight:700">${r.index + 1}</td>
|
||
<td style="padding:10px 12px;font-size:13px">
|
||
<div style="font-weight:700;color:#111827">${label}</div>
|
||
<div style="font-size:11px;color:#6b7280;margin-top:2px">${cat}</div>
|
||
</td>
|
||
<td style="padding:10px 12px;font-size:12px;color:#dc2626;font-style:italic">${r.motif.trim()}</td>
|
||
</tr>`;
|
||
}).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}`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:640px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#ef4444,#dc2626);color:white;padding:24px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0;font-size:18px">❌ Votre note a été refusée par la Finance</h2>
|
||
<p style="margin:8px 0 0;opacity:.9;font-size:13px">${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} à corriger</p>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${note.prenom} ${note.nom}</strong>,</p>
|
||
<p>Votre note <strong>${note.reference}</strong> (${montantFormate} €) a été refusée par <strong>${verificateurNom}</strong> (Vérificateur Finance).</p>
|
||
|
||
<div style="background:#fff;border:1.5px solid #fecaca;border-radius:8px;overflow:hidden;margin:20px 0">
|
||
<div style="background:#fef2f2;padding:10px 14px;border-bottom:1px solid #fecaca">
|
||
<span style="font-size:12px;font-weight:700;color:#991b1b;text-transform:uppercase;letter-spacing:.5px">
|
||
Lignes à corriger (${lignesRefusees.length})
|
||
</span>
|
||
</div>
|
||
<table style="width:100%;border-collapse:collapse">
|
||
<thead>
|
||
<tr style="background:#fafafa">
|
||
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">N°</th>
|
||
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">Ligne</th>
|
||
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">Motif</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${tableLignesHtml}</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:16px;margin:16px 0">
|
||
<div style="font-size:13px;font-weight:700;color:#1e40af;margin-bottom:8px">📝 Que faire maintenant ?</div>
|
||
<ol style="margin:0;padding-left:18px;font-size:13px;color:#1d4ed8;line-height:2">
|
||
<li>Connectez-vous à la plateforme NDF</li>
|
||
<li>Ouvrez la note <strong>${note.reference}</strong></li>
|
||
<li>Corrigez <strong>uniquement les lignes listées ci-dessus</strong></li>
|
||
<li>Resoumettez la note (les lignes validées sont conservées)</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<div style="text-align:center;margin-top:28px">
|
||
<a href="${frontendUrl}" style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;padding:14px 36px;text-decoration:none;border-radius:8px;font-weight:700;display:inline-block">
|
||
✏️ Corriger ma note →
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
);
|
||
} 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`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#f59e0b,#d97706);color:white;padding:20px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">⚠️ Note refusée par la Finance</h2>
|
||
</div>
|
||
<div style="padding:20px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${note.prenomN1} ${note.nomN1}</strong>,</p>
|
||
<p>La note <strong>${note.reference}</strong> de <strong>${note.prenom} ${note.nom}</strong> que vous aviez validée a été refusée par <strong>${verificateurNom}</strong> (Vérificateur Finance).</p>
|
||
<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:8px;padding:14px;margin:16px 0">
|
||
<div style="font-size:13px;font-weight:700;color:#92400e;margin-bottom:6px">${lignesRefusees.length} ligne${lignesRefusees.length > 1 ? 's' : ''} à corriger</div>
|
||
<table style="width:100%;border-collapse:collapse;font-size:12px;color:#78350f">
|
||
${lignesRefusees.map(r => {
|
||
const ligne = lignesData[r.index] || {};
|
||
const label = ligne.libelle || ligne.categorie || `Ligne ${r.index + 1}`;
|
||
return `<tr><td style="padding:4px 0;font-weight:600">${label}</td><td style="padding:4px 0 4px 12px;font-style:italic">${r.motif.trim()}</td></tr>`;
|
||
}).join('')}
|
||
</table>
|
||
</div>
|
||
<p style="font-size:13px;color:#64748b">${note.prenom} ${note.nom} a été notifié et doit corriger uniquement les lignes listées.</p>
|
||
</div>
|
||
</div>`
|
||
);
|
||
} 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 {
|
||
// ✅ MERGE atomique — crée la ligne si elle n'existe pas, puis incrémente
|
||
const result = await new sql.Request(tx).query(`
|
||
MERGE NDFSequence WITH (HOLDLOCK) AS target
|
||
USING (SELECT ${annee} AS annee) AS source
|
||
ON target.annee = source.annee
|
||
WHEN MATCHED THEN
|
||
UPDATE SET compteur = ISNULL(target.compteur, 0) + 1
|
||
WHEN NOT MATCHED THEN
|
||
INSERT (annee, compteur) VALUES (${annee}, 1);
|
||
|
||
SELECT compteur FROM NDFSequence WHERE annee = ${annee};
|
||
`);
|
||
|
||
await tx.commit();
|
||
|
||
const compteur = result.recordset?.[0]?.compteur;
|
||
if (!compteur || Number.isNaN(Number(compteur))) {
|
||
throw new Error(`Compteur NDFSequence invalide pour ${annee}`);
|
||
}
|
||
|
||
// ✅ padStart(3) pour avoir NDF001, NDF002... NDF999
|
||
const num = String(compteur).padStart(3, '0');
|
||
return `NDF N\u00B0${num}-${campusCode}-${nomPart}-${jour}-${mois}-${annee}`;
|
||
|
||
|
||
} catch (e) {
|
||
try { await tx.rollback(); } catch { }
|
||
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 kmTotal = lignesPDF.reduce((s, l) => s + (l.km || 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 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';
|
||
|
||
// ── Collaborateur + hiérarchie ────────────────────────────────────
|
||
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(`
|
||
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;
|
||
|
||
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();
|
||
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);
|
||
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 = [];
|
||
|
||
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);
|
||
}
|
||
})
|
||
);
|
||
|
||
// ── Upload fichiers soumis directement par ligne (files_${depId}) ──
|
||
// Identique à la logique du PUT /api/notes/brouillons/:id
|
||
const perLigneFieldnameSet = new Set();
|
||
const perLigneUploaded = [];
|
||
|
||
for (const file of req.files || []) {
|
||
const match = (file.fieldname || '').match(/^files_(.+)$/);
|
||
if (!match) continue;
|
||
|
||
const depId = String(match[1]);
|
||
perLigneFieldnameSet.add(file.fieldname);
|
||
|
||
// Trouver la ligne par son id frontend (stocké dans lignesJson)
|
||
const li = lignesParsed.findIndex(l => String(l.id) === depId);
|
||
|
||
try {
|
||
const up = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
|
||
perLigneUploaded.push(up);
|
||
|
||
if (li >= 0) {
|
||
if (!Array.isArray(lignesParsed[li].qrFiles)) lignesParsed[li].qrFiles = [];
|
||
const dejaSauve = lignesParsed[li].qrFiles.some(f => f.fileName === up.fileName);
|
||
if (!dejaSauve) {
|
||
lignesParsed[li].qrFiles.push({
|
||
fileName: up.fileName,
|
||
uploadUrl: up.uploadUrl,
|
||
origin: 'upload'
|
||
});
|
||
}
|
||
console.log(`✅ Fichier injecté ligne ${li}: ${up.fileName}`);
|
||
} else {
|
||
console.warn(`⚠️ Fichier ${file.originalname} : aucune ligne trouvée pour depId=${depId}`);
|
||
}
|
||
} catch (e) {
|
||
console.error(`❌ Upload ligne file ${file.originalname}:`, e.message);
|
||
}
|
||
}
|
||
|
||
// lignesJsonFinal APRÈS injection des qrFiles dans chaque ligne
|
||
const lignesJsonFinal = JSON.stringify(lignesParsed);
|
||
|
||
// ── Upload justificatifs restants (QR globaux, hors per-ligne déjà traités) ──
|
||
const fichiersUploades = [
|
||
...perLigneUploaded,
|
||
...(await Promise.all(
|
||
allFiles
|
||
.filter(file => !perLigneFieldnameSet.has(file.fieldname))
|
||
.map(file =>
|
||
uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier)
|
||
.catch(e => { console.error(`❌ Upload justif ${file.originalname}:`, e.message); return null; })
|
||
)
|
||
)).filter(Boolean)
|
||
];
|
||
|
||
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)
|
||
.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,
|
||
statut, validateurN1Id,
|
||
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,
|
||
@statut, @validateurN1Id,
|
||
@montantHT, @tauxTVA, @montantTVA21, @montantTVA55, @montantTVA10, @montantTVA20,
|
||
@km, @indemniteKm, @lignesJson)
|
||
`);
|
||
|
||
const noteCreee = insertResult.recordset[0];
|
||
|
||
// ── Insérer les lignes ────────────────────────────────────────────
|
||
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}`);
|
||
|
||
res.status(201).json({
|
||
success: true,
|
||
id: noteCreee.id,
|
||
reference: noteCreee.reference,
|
||
fichiers: fichiersUploades,
|
||
recapUrl: null,
|
||
pending: true,
|
||
});
|
||
|
||
// ── Traitement lourd en arrière-plan ─────────────────────────────
|
||
setImmediate(async () => {
|
||
const fichiersAsync = [...fichiersUploades];
|
||
try {
|
||
console.log(`🔄 [ASYNC] PDF + emails pour ${reference}...`);
|
||
const frontendUrl = process.env.FRONTEND_URL || 'myndf.ensup-adm.net';
|
||
|
||
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); }
|
||
|
||
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); }
|
||
|
||
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); }
|
||
|
||
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(),
|
||
]);
|
||
|
||
await Promise.all([
|
||
sendMailGraph(
|
||
collaborateur.email,
|
||
`✅ Accusé de réception — Note ${reference}`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#10b981,#059669);color:white;padding:24px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">✅ Note de frais bien reçue</h2>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${collaborateur.prenom} ${collaborateur.nom}</strong>,</p>
|
||
<p>Votre note <strong>${reference}</strong> — ${libelle} (${montantFormate} €) a bien été enregistrée.</p>
|
||
${nomN1 ? `<p>Validateur : <strong>${prenomN1} ${nomN1}</strong></p>` : ''}
|
||
${recapUrl ? `<p><a href="${recapUrl}" style="color:#6366f1">📎 Voir le récapitulatif PDF</a></p>` : ''}
|
||
<div style="text-align:center;margin-top:24px">
|
||
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700">Suivre mes notes →</a>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
).catch(e => console.error('❌ [ASYNC] Email collab:', e.message)),
|
||
|
||
(n1Id && emailN1) ? sendMailGraph(
|
||
emailN1,
|
||
`📋 Note de frais à valider — ${reference}`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;padding:24px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">📋 Note de frais à valider</h2>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${prenomN1} ${nomN1}</strong>,</p>
|
||
<p><strong>${collaborateur.prenom} ${collaborateur.nom}</strong> a soumis la note <strong>${reference}</strong> — ${libelle} (<strong>${montantFormate} €</strong>).</p>
|
||
${recapUrl ? `<p><a href="${recapUrl}" style="color:#6366f1">📎 Récapitulatif + justificatifs</a></p>` : ''}
|
||
<div style="text-align:center;margin-top:24px">
|
||
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:14px 32px;text-decoration:none;border-radius:8px;font-weight:700">Valider sur la plateforme →</a>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
).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];
|
||
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) {
|
||
|
||
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
|
||
`);
|
||
|
||
const nouvelleReference = await genererReference(collaborateur.campus, collaborateur.nom, collaborateur.prenom);
|
||
|
||
// ── Upload fichiers par ligne (files_${depId}) + injection qrFiles ──
|
||
const fichiersUploades = [];
|
||
const perLigneFieldnameSetCas1 = new Set();
|
||
|
||
for (const file of req.files || []) {
|
||
const match = (file.fieldname || '').match(/^files_(.+)$/);
|
||
if (!match) continue;
|
||
const depId = String(match[1]);
|
||
perLigneFieldnameSetCas1.add(file.fieldname);
|
||
const li = lignesParsed.findIndex(l => String(l.id) === depId);
|
||
try {
|
||
const up = await uploadToSharePointHierarchique(file, nouvelleReference, nomDossier, moisDossier);
|
||
fichiersUploades.push(up);
|
||
if (li >= 0) {
|
||
if (!Array.isArray(lignesParsed[li].qrFiles)) lignesParsed[li].qrFiles = [];
|
||
if (!lignesParsed[li].qrFiles.some(f => f.fileName === up.fileName)) {
|
||
lignesParsed[li].qrFiles.push({ fileName: up.fileName, uploadUrl: up.uploadUrl, origin: 'upload' });
|
||
}
|
||
console.log(`✅ [CAS1] Fichier injecté ligne ${li}: ${up.fileName}`);
|
||
}
|
||
} catch (e) { console.error(`❌ Upload justif correction ligne ${file.originalname}:`, e.message); }
|
||
}
|
||
|
||
// Fichiers globaux non per-ligne
|
||
for (const file of req.files || []) {
|
||
if (perLigneFieldnameSetCas1.has(file.fieldname)) continue;
|
||
try {
|
||
const r = await uploadToSharePointHierarchique(file, nouvelleReference, nomDossier, moisDossier);
|
||
fichiersUploades.push(r);
|
||
} catch (e) { console.error(`❌ Upload justif correction global ${file.originalname}:`, e.message); }
|
||
}
|
||
|
||
// 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); }
|
||
}
|
||
|
||
// lignesJsonCorrige APRÈS injection
|
||
const lignesJsonCorrige = JSON.stringify(lignesParsed);
|
||
|
||
// Générer fiche PDF
|
||
const noteDataPDF = {
|
||
reference: nouvelleReference, nomPrenom, mois: moisCapitalized, date,
|
||
categorie: categorieNote, libelle,
|
||
montant: parseFloat(montantFinal.toFixed(2)),
|
||
lignes: lignesParsed, lignesJson: lignesJsonCorrige,
|
||
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); }
|
||
|
||
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;
|
||
|
||
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, lignesJsonCorrige)
|
||
.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];
|
||
|
||
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); }
|
||
}
|
||
|
||
if (n1Id && emailN1) {
|
||
try {
|
||
const titreNotif = `📋 Note corrigée à valider — ${nouvelleReference}`;
|
||
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 <strong>${noteExist.reference}</strong>`
|
||
: `suite au refus de <strong>${noteExist.reference}</strong>`;
|
||
|
||
await sendMailGraph(emailN1, `📋 Note corrigée à valider — ${nouvelleReference}`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:${couleurHeader};color:white;padding:20px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">📋 Note corrigée — à valider</h2>
|
||
</div>
|
||
<div style="padding:20px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${prenomN1} ${nomN1}</strong>,</p>
|
||
<p><strong>${collaborateur.prenom} ${collaborateur.nom}</strong> a resoumis une note corrigée ${contexte}.</p>
|
||
<p>Nouvelle référence : <strong>${nouvelleReference}</strong> — <strong>${parseFloat(montantFinal.toFixed(2))} €</strong></p>
|
||
<div style="text-align:center;margin-top:24px">
|
||
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:14px 32px;text-decoration:none;border-radius:8px;font-weight:700">Valider sur la plateforme →</a>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
);
|
||
} 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
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
const reference = noteExist.reference;
|
||
let fichiersExistants = [];
|
||
try { fichiersExistants = JSON.parse(noteExist.fichiers || '[]'); } catch { }
|
||
|
||
// ── Upload fichiers par ligne (files_${depId}) + injection qrFiles ──
|
||
const perLigneFieldnameSetCas2 = new Set();
|
||
|
||
for (const file of req.files || []) {
|
||
const match = (file.fieldname || '').match(/^files_(.+)$/);
|
||
if (!match) continue;
|
||
const depId = String(match[1]);
|
||
perLigneFieldnameSetCas2.add(file.fieldname);
|
||
const li = lignesParsed.findIndex(l => String(l.id) === depId);
|
||
try {
|
||
const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
|
||
fichiersExistants.push(r);
|
||
if (li >= 0) {
|
||
if (!Array.isArray(lignesParsed[li].qrFiles)) lignesParsed[li].qrFiles = [];
|
||
if (!lignesParsed[li].qrFiles.some(f => f.fileName === r.fileName)) {
|
||
lignesParsed[li].qrFiles.push({ fileName: r.fileName, uploadUrl: r.uploadUrl, origin: 'upload' });
|
||
}
|
||
console.log(`✅ [CAS2] Fichier injecté ligne ${li}: ${r.fileName}`);
|
||
}
|
||
} catch (e) { console.error(`❌ Upload justif modif ligne ${file.originalname}:`, e.message); }
|
||
}
|
||
|
||
// Fichiers globaux non per-ligne
|
||
for (const file of req.files || []) {
|
||
if (perLigneFieldnameSetCas2.has(file.fieldname)) continue;
|
||
try {
|
||
const r = await uploadToSharePointHierarchique(file, reference, nomDossier, moisDossier);
|
||
fichiersExistants.push(r);
|
||
} catch (e) { console.error(`❌ Upload justif modif global ${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}`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#f59e0b,#d97706);color:white;padding:20px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">📋 Note modifiée — à valider</h2>
|
||
</div>
|
||
<div style="padding:20px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${n1.prenomN1} ${n1.nomN1}</strong>,</p>
|
||
<p><strong>${collaborateur.prenom} ${collaborateur.nom}</strong> a modifié et resoumis la note <strong>${reference}</strong> — ${libelle} (<strong>${parseFloat(montantFinal.toFixed(2))} €</strong>).</p>
|
||
<div style="text-align:center;margin-top:24px">
|
||
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:14px 32px;text-decoration:none;border-radius:8px;font-weight:700">Valider sur la plateforme →</a>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
);
|
||
} 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); }
|
||
}
|
||
}
|
||
|
||
const notesAvecMontant = notes.map(n => ({
|
||
...n,
|
||
montant: recalculerMontantNote(n),
|
||
}));
|
||
|
||
res.json(notesAvecMontant);
|
||
} 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
const notesAvecMontant = notes.map(n => ({
|
||
...n,
|
||
montant: recalculerMontantNote(n),
|
||
}));
|
||
|
||
res.json(notesAvecMontant);
|
||
} 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 });
|
||
}
|
||
});
|
||
|
||
app.get('/api/notes/:id/lignes-refusees-n1', authenticateToken, async (req, res) => {
|
||
try {
|
||
const noteId = parseInt(req.params.id);
|
||
|
||
// Vérifier accès : collaborateur propriétaire ou validateur ou Finance
|
||
const noteCheck = await pool.request()
|
||
.input('id', sql.Int, noteId)
|
||
.query(`
|
||
SELECT collaborateurId, validateurN1Id
|
||
FROM NoteDeFrais WHERE id = @id
|
||
`);
|
||
if (!noteCheck.recordset.length)
|
||
return res.status(404).json({ error: 'Note introuvable' });
|
||
|
||
const note = noteCheck.recordset[0];
|
||
const isOwner = note.collaborateurId === req.user.id;
|
||
const isN1 = note.validateurN1Id === req.user.id;
|
||
const isFinance = hasAnyRole(req.user, 'Finance', 'VerificateurFinance', 'ValidateurFinance', 'superUtilisateur');
|
||
|
||
if (!isOwner && !isN1 && !isFinance)
|
||
return res.status(403).json({ error: 'Accès refusé' });
|
||
|
||
const result = await pool.request()
|
||
.input('noteId', sql.Int, noteId)
|
||
.query(`
|
||
SELECT lr.ligneIndex, lr.ligneLibelle, lr.ligneCategorie,
|
||
lr.motif, lr.statut, lr.dateRefus,
|
||
c.prenom + ' ' + c.nom AS verificateur
|
||
FROM LignesRefusees lr
|
||
JOIN CollaborateurAD c ON c.id = lr.verificateurId
|
||
WHERE lr.noteDeFraisId = @noteId
|
||
AND lr.statut = 'active'
|
||
ORDER BY lr.ligneIndex 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 = []; }
|
||
|
||
// juste avant res.json(notes);
|
||
const notesAvecMontant = notes.map(n => ({
|
||
...n,
|
||
montant: recalculerMontantNote(n),
|
||
}));
|
||
|
||
res.json(notesAvecMontant);
|
||
} 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;
|
||
// lignesDecisions = [{ ligneIndex, statut: 'valide'|'refuse', commentaire }]
|
||
const { action, commentaire, motifRefus, lignesDecisions } = 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();
|
||
|
||
// Seul le N1 peut valider une note 'enattente'
|
||
if (!(n1Id === userId && statutNote === 'enattente'))
|
||
return res.status(403).json({ error: 'Non autorisé à valider cette note' });
|
||
|
||
// --- Déterminer le nouveau statut ---
|
||
// Si lignesDecisions fourni, on fait une validation ligne par ligne
|
||
let nouveauStatut;
|
||
let lignesRefuseesFinal = [];
|
||
|
||
if (Array.isArray(lignesDecisions) && lignesDecisions.length > 0) {
|
||
// Vérifier si au moins une ligne est refusée
|
||
const lignesRefusees = lignesDecisions.filter(l => l.statut === 'refuse');
|
||
const lignesValides = lignesDecisions.filter(l => l.statut === 'valide');
|
||
|
||
if (lignesRefusees.length === 0) {
|
||
// Toutes les lignes sont validées → approuvé
|
||
nouveauStatut = 'approuve';
|
||
} else {
|
||
// Au moins une ligne refusée → refus global avec détail
|
||
nouveauStatut = 'refuse';
|
||
lignesRefuseesFinal = lignesRefusees;
|
||
}
|
||
} else {
|
||
// Comportement legacy (validation globale sans ligne)
|
||
nouveauStatut = action === 'valider' ? 'approuve' : 'refuse';
|
||
}
|
||
|
||
// --- Construire le commentaire synthétique ---
|
||
let commentaireFinal = commentaire || null;
|
||
let motifRefusFinal = motifRefus || null;
|
||
|
||
if (lignesRefuseesFinal.length > 0) {
|
||
// Parser les lignes de la note pour avoir les libellés
|
||
let lignesData = [];
|
||
try { lignesData = JSON.parse(note.lignesJson || '[]'); } catch { }
|
||
|
||
const detailRefus = lignesRefuseesFinal.map(l => {
|
||
const ligne = lignesData[l.ligneIndex] || {};
|
||
const label = ligne.libelle || ligne.categorie || `Ligne ${l.ligneIndex + 1}`;
|
||
return `• ${label} — ${l.commentaire || 'Non conforme'}`;
|
||
}).join('\n');
|
||
|
||
motifRefusFinal = `${lignesRefuseesFinal.length} ligne(s) refusée(s) :\n${detailRefus}`;
|
||
commentaireFinal = motifRefusFinal;
|
||
}
|
||
|
||
// --- Transaction : mise à jour note + LignesRefusees ---
|
||
const transaction = new sql.Transaction(pool);
|
||
await transaction.begin();
|
||
|
||
try {
|
||
// 1. Mettre à jour la note
|
||
await new sql.Request(transaction)
|
||
.input('id', sql.Int, id)
|
||
.input('statut', sql.NVarChar, nouveauStatut)
|
||
.input('commentaire', sql.NVarChar, commentaireFinal)
|
||
.input('motifRefus', sql.NVarChar, motifRefusFinal)
|
||
.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
|
||
`);
|
||
|
||
// 2. Si des lignes sont refusées, les enregistrer dans LignesRefusees
|
||
if (lignesRefuseesFinal.length > 0) {
|
||
let lignesData = [];
|
||
try { lignesData = JSON.parse(note.lignesJson || '[]'); } catch { }
|
||
|
||
// Archiver les anciens refus actifs si re-validation
|
||
await new sql.Request(transaction)
|
||
.input('noteId', sql.Int, id)
|
||
.query(`
|
||
UPDATE LignesRefusees
|
||
SET statut = 'archive'
|
||
WHERE noteDeFraisId = @noteId AND statut = 'active'
|
||
`);
|
||
|
||
// Insérer les nouveaux refus
|
||
for (const l of lignesRefuseesFinal) {
|
||
const ligne = lignesData[l.ligneIndex] || {};
|
||
await new sql.Request(transaction)
|
||
.input('noteId', sql.Int, id)
|
||
.input('ligneIndex', sql.Int, l.ligneIndex)
|
||
.input('ligneLibelle', sql.NVarChar, ligne.libelle || ligne.categorie || `Ligne ${l.ligneIndex + 1}`)
|
||
.input('ligneCategorie', sql.NVarChar, ligne.categorie || null)
|
||
.input('motif', sql.NVarChar, l.commentaire || 'Non conforme')
|
||
.input('verificateurId', sql.Int, userId)
|
||
.query(`
|
||
INSERT INTO LignesRefusees
|
||
(noteDeFraisId, ligneIndex, ligneLibelle, ligneCategorie, motif, verificateurId, dateRefus, statut)
|
||
VALUES
|
||
(@noteId, @ligneIndex, @ligneLibelle, @ligneCategorie, @motif, @verificateurId, GETDATE(), 'active')
|
||
`);
|
||
}
|
||
}
|
||
|
||
// 3. Historique de validation
|
||
await new sql.Request(transaction)
|
||
.input('noteId', sql.Int, id)
|
||
.input('validateurId', sql.Int, userId)
|
||
.input('niveau', sql.NVarChar, 'N1')
|
||
.input('action', sql.NVarChar, nouveauStatut === 'approuve' ? 'valider' : 'refuser')
|
||
.input('commentaire', sql.NVarChar, commentaireFinal)
|
||
.input('motifRefus', sql.NVarChar, motifRefusFinal)
|
||
.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())
|
||
`);
|
||
|
||
await transaction.commit();
|
||
} catch (e) {
|
||
try { await transaction.rollback(); } catch { }
|
||
throw e;
|
||
}
|
||
|
||
// Répondre immédiatement
|
||
res.json({
|
||
success: true,
|
||
statut: nouveauStatut,
|
||
niveau: 'N1',
|
||
nbLignesRefusees: lignesRefuseesFinal.length,
|
||
nbLignesValidees: Array.isArray(lignesDecisions)
|
||
? lignesDecisions.filter(l => l.statut === 'valide').length
|
||
: (nouveauStatut === 'approuve' ? 1 : 0)
|
||
});
|
||
|
||
// --- Traitement asynchrone : PDFs + emails ---
|
||
setImmediate(async () => {
|
||
try {
|
||
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,
|
||
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
|
||
WHERE n.id = @id
|
||
`)
|
||
]);
|
||
|
||
const c = collabResult.recordset[0];
|
||
const v = validateurResult.recordset[0];
|
||
const nd = noteCompleteResult.recordset[0];
|
||
if (!c || !nd) return;
|
||
|
||
console.log('🔍 nd.montant =', nd.montant);
|
||
console.log('🔍 note.montant =', note.montant);
|
||
|
||
const nomValidateurActuel = v
|
||
? `${v.prenom} ${v.nom}`.trim()
|
||
: `${req.user.prenom} ${req.user.nom}`.trim();
|
||
|
||
// Construire les signatures pour le PDF
|
||
const signatures = [
|
||
{ niveau: 'COLLAB', nomPrenom: nd.nomPrenom, date: nd.DateCreation, action: 'soumettre', commentaire: null },
|
||
{ niveau: 'N1', nomPrenom: nomValidateurActuel, date: new Date(), action: nouveauStatut === 'approuve' ? 'valider' : 'refuser', commentaire: commentaireFinal }
|
||
];
|
||
|
||
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,
|
||
montant: parseFloat(nd.montant),
|
||
};
|
||
|
||
let fichiersExistants = [];
|
||
try { fichiersExistants = JSON.parse(nd.fichiers || '[]'); } catch { }
|
||
|
||
const existingFolder = fichiersExistants[0]?.folderPath;
|
||
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')}`;
|
||
|
||
// Génération PDF signé
|
||
try {
|
||
const pdfSigne = await generateFicheSignee(noteDataPDF, signatures);
|
||
const suffixe = nouveauStatut === 'approuve' ? 'signe-approuve' : 'signe-refuse';
|
||
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');
|
||
} catch (pdfError) {
|
||
console.error('❌ [ASYNC] PDF signé N1:', pdfError.message);
|
||
}
|
||
|
||
// Régénérer le recap
|
||
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 { }
|
||
}
|
||
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');
|
||
} catch (recapError) {
|
||
console.error('❌ [ASYNC] Recap N1:', recapError.message);
|
||
}
|
||
|
||
// --- Emails + notifications ---
|
||
const isApprouve = nouveauStatut === 'approuve';
|
||
const isRefus = nouveauStatut === 'refuse';
|
||
|
||
// Construire le tableau HTML des lignes refusées (pour l'email)
|
||
let lignesRefuseesHtml = '';
|
||
if (lignesRefuseesFinal.length > 0) {
|
||
let lignesData = [];
|
||
try { lignesData = JSON.parse(nd.lignesJson || '[]'); } catch { }
|
||
|
||
lignesRefuseesHtml = `
|
||
<div style="background:#fff;border:1.5px solid #fecaca;border-radius:8px;overflow:hidden;margin:20px 0">
|
||
<div style="background:#fef2f2;padding:10px 14px;border-bottom:1px solid #fecaca">
|
||
<span style="font-size:12px;font-weight:700;color:#991b1b;text-transform:uppercase;letter-spacing:.5px">
|
||
Lignes à corriger (${lignesRefuseesFinal.length})
|
||
</span>
|
||
</div>
|
||
<table style="width:100%;border-collapse:collapse">
|
||
<thead>
|
||
<tr style="background:#fafafa">
|
||
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">N°</th>
|
||
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">Dépense</th>
|
||
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">Motif</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${lignesRefuseesFinal.map(l => {
|
||
const ligne = lignesData[l.ligneIndex] || {};
|
||
const label = ligne.libelle || ligne.categorie || `Ligne ${l.ligneIndex + 1}`;
|
||
const cat = ligne.categorie || '';
|
||
return `
|
||
<tr style="border-bottom:1px solid #fecaca">
|
||
<td style="padding:10px 12px;font-size:12px;color:#64748b;font-weight:700">${l.ligneIndex + 1}</td>
|
||
<td style="padding:10px 12px">
|
||
<div style="font-weight:700;color:#111827;font-size:13px">${label}</div>
|
||
${cat ? `<div style="font-size:11px;color:#6b7280;margin-top:2px">${cat}</div>` : ''}
|
||
</td>
|
||
<td style="padding:10px 12px;font-size:12px;color:#dc2626;font-style:italic">${l.commentaire || 'Non conforme'}</td>
|
||
</tr>`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
const titreCollab = isApprouve
|
||
? `Note ${note.reference} approuvée ✅`
|
||
: `Note ${note.reference} — corrections demandées ❌`;
|
||
|
||
const msgCollab = isApprouve
|
||
? `Votre note ${note.reference} (${montantFormate}€) a été approuvée par ${nomValidateurActuel}.`
|
||
: `Votre note ${note.reference} a été refusée par ${nomValidateurActuel}. ${lignesRefuseesFinal.length} ligne(s) à corriger.`;
|
||
|
||
const emailCollabHtml = isRefus ? `
|
||
<div style="font-family:Arial,sans-serif;max-width:640px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#ef4444,#dc2626);color:white;padding:24px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0;font-size:18px">❌ Corrections demandées sur votre note</h2>
|
||
<p style="margin:8px 0 0;opacity:.85;font-size:13px">${lignesRefuseesFinal.length} ligne(s) à corriger</p>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${c.prenom} ${c.nom}</strong>,</p>
|
||
<p>Votre note <strong>${note.reference}</strong> a été examinée par <strong>${nomValidateurActuel}</strong>. Certaines dépenses nécessitent des corrections avant approbation.</p>
|
||
${lignesRefuseesHtml}
|
||
<div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:16px;margin:16px 0">
|
||
<div style="font-size:13px;font-weight:700;color:#1e40af;margin-bottom:8px">📝 Que faire maintenant ?</div>
|
||
<ol style="margin:0;padding-left:18px;font-size:13px;color:#1d4ed8;line-height:2">
|
||
<li>Connectez-vous à la plateforme NDF</li>
|
||
<li>Ouvrez la note <strong>${note.reference}</strong></li>
|
||
<li>Corrigez <strong>uniquement les lignes listées ci-dessus</strong></li>
|
||
<li>Resoumettez la note</li>
|
||
</ol>
|
||
</div>
|
||
<div style="text-align:center;margin-top:28px">
|
||
<a href="${frontendUrl}" style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;padding:14px 36px;text-decoration:none;border-radius:8px;font-weight:700;display:inline-block">
|
||
✏️ Corriger ma note →
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>` : `
|
||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#10b981,#059669);color:white;padding:20px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">${titreCollab}</h2>
|
||
</div>
|
||
<div style="padding:20px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${c.prenom} ${c.nom}</strong>,</p>
|
||
<p>${msgCollab}</p>
|
||
<div style="text-align:center;margin-top:24px">
|
||
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700">Voir mes notes</a>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
|
||
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 N1] Notif collab:', e.message)),
|
||
sendMailGraph(
|
||
c.email,
|
||
isRefus ? `❌ Corrections demandées — ${note.reference}` : titreCollab,
|
||
emailCollabHtml
|
||
).catch(e => console.error('❌ [ASYNC N1] Email collab:', e.message)),
|
||
]);
|
||
|
||
console.log(`✅ [ASYNC N1] Validation terminée note ${id} → ${nouveauStatut} (${lignesRefuseesFinal.length} ligne(s) refusée(s))`);
|
||
} catch (e) {
|
||
console.error(`❌ [ASYNC N1] Erreur générale validation note ${id}:`, e.message);
|
||
}
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Erreur validation N1:', 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');
|
||
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 + recalculerMontantNote(n), 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, `<p>Bonjour ${responsable.prenom},</p><p>${notes.recordset.length} note(s) en attente de paiement avant le <strong>${jourPaiement}</strong>. Total : <strong>${total.toFixed(2)} €</strong></p>`);
|
||
|
||
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,
|
||
CONVERT(VARCHAR(23), DateCreation, 126) +
|
||
CASE DATEDIFF(HOUR, GETUTCDATE(), GETDATE())
|
||
WHEN 2 THEN '+02:00'
|
||
WHEN 1 THEN '+01:00'
|
||
ELSE '+00:00'
|
||
END AS 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 + recalculerMontantNote(n), 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
|
||
? `<FinInstnId><BIC>${n.bic}</BIC></FinInstnId>`
|
||
: `<FinInstnId><Othr><Id>NOTPROVIDED</Id></Othr></FinInstnId>`;
|
||
|
||
transactions += `
|
||
<CdtTrfTxInf>
|
||
<PmtId>
|
||
<InstrId>VIREMENT NUM:${n.reference}</InstrId>
|
||
<EndToEndId>${n.reference}</EndToEndId>
|
||
</PmtId>
|
||
<Amt>
|
||
<InstdAmt Ccy="EUR">${recalculerMontantNote(n).toFixed(2) }</InstdAmt>
|
||
</Amt>
|
||
<CdtrAgt>
|
||
${benefBicBlock}
|
||
</CdtrAgt>
|
||
<Cdtr>
|
||
<Nm>${benefNom}</Nm>${adrLine || cp || ville ? `
|
||
<PstlAdr>${cp ? `
|
||
<PstCd>${cp}</PstCd>` : ''}${ville ? `
|
||
<TwnNm>${ville}</TwnNm>` : ''}
|
||
<Ctry>${pays}</Ctry>${adrLine ? `
|
||
<AdrLine>${adrLine}</AdrLine>` : ''}
|
||
</PstlAdr>` : ''}
|
||
<CtryOfRes>${pays}</CtryOfRes>
|
||
</Cdtr>
|
||
<CdtrAcct>
|
||
<Id>
|
||
<IBAN>${ibanClair}</IBAN>
|
||
</Id>
|
||
</CdtrAcct>
|
||
</CdtTrfTxInf>`;
|
||
}
|
||
|
||
const xml = `<?xml version="1.0" encoding="iso-8859-1"?>
|
||
<Document xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03">
|
||
<CstmrCdtTrfInitn>
|
||
<GrpHdr>
|
||
<MsgId>${msgId}</MsgId>
|
||
<CreDtTm>${creDtTm}</CreDtTm>
|
||
<NbOfTxs>${notes.recordset.length}</NbOfTxs>
|
||
<CtrlSum>${totalFormate}</CtrlSum>
|
||
<InitgPty>
|
||
<Nm>${dbtrNom}</Nm>
|
||
</InitgPty>
|
||
</GrpHdr>
|
||
<PmtInf>
|
||
<PmtInfId>${msgId}</PmtInfId>
|
||
<PmtMtd>TRF</PmtMtd>
|
||
<BtchBookg>true</BtchBookg>
|
||
<NbOfTxs>${notes.recordset.length}</NbOfTxs>
|
||
<CtrlSum>${totalFormate}</CtrlSum>
|
||
<PmtTpInf>
|
||
<SvcLvl>
|
||
<Cd>SEPA</Cd>
|
||
</SvcLvl>
|
||
</PmtTpInf>
|
||
<ReqdExctnDt>${todayISO}</ReqdExctnDt>
|
||
<Dbtr>
|
||
<Nm>${dbtrNom}</Nm>
|
||
<PstlAdr>
|
||
<PstCd>${dbtrCp}</PstCd>
|
||
<TwnNm>${dbtrVille}</TwnNm>
|
||
<Ctry>${dbtrPays}</Ctry>
|
||
<AdrLine>${dbtrAdrLine}</AdrLine>
|
||
</PstlAdr>
|
||
</Dbtr>
|
||
<DbtrAcct>
|
||
<Id>
|
||
<IBAN>${dbtrIban}</IBAN>
|
||
</Id>
|
||
</DbtrAcct>
|
||
<DbtrAgt>
|
||
<FinInstnId>
|
||
<Othr>
|
||
<Id>NOTPROVIDED</Id>
|
||
</Othr>
|
||
</FinInstnId>
|
||
</DbtrAgt>
|
||
<ChrgBr>SLEV</ChrgBr>${transactions}
|
||
</PmtInf>
|
||
</CstmrCdtTrfInitn>
|
||
</Document>`;
|
||
|
||
// ── 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, n.lignesJson, 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 + recalculerMontantNote(n), 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
|
||
? `<FinInstnId><BIC>${n.bic}</BIC></FinInstnId>`
|
||
: `<FinInstnId><Othr><Id>NOTPROVIDED</Id></Othr></FinInstnId>`;
|
||
|
||
transactions += `
|
||
<CdtTrfTxInf>
|
||
<PmtId>
|
||
<InstrId>VIREMENT NUM:${n.reference}</InstrId>
|
||
<EndToEndId>${n.reference}</EndToEndId>
|
||
</PmtId>
|
||
<Amt>
|
||
<InstdAmt Ccy="EUR">${recalculerMontantNote(n).toFixed(2) }</InstdAmt>
|
||
</Amt>
|
||
<CdtrAgt>${benefBicBlock}</CdtrAgt>
|
||
<Cdtr><Nm>${benefNom}</Nm></Cdtr>
|
||
<CdtrAcct><Id><IBAN>${ibanClair}</IBAN></Id></CdtrAcct>
|
||
</CdtTrfTxInf>`;
|
||
}
|
||
|
||
const xml = `<?xml version="1.0" encoding="iso-8859-1"?>
|
||
<Document xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03">
|
||
<CstmrCdtTrfInitn>
|
||
<GrpHdr>
|
||
<MsgId>${msgId}</MsgId>
|
||
<CreDtTm>${creDtTm}</CreDtTm>
|
||
<NbOfTxs>${notes.recordset.length}</NbOfTxs>
|
||
<CtrlSum>${total}</CtrlSum>
|
||
<InitgPty><Nm>${dbtrNom}</Nm></InitgPty>
|
||
</GrpHdr>
|
||
<PmtInf>
|
||
<PmtInfId>${msgId}</PmtInfId>
|
||
<PmtMtd>TRF</PmtMtd>
|
||
<BtchBookg>true</BtchBookg>
|
||
<NbOfTxs>${notes.recordset.length}</NbOfTxs>
|
||
<CtrlSum>${total}</CtrlSum>
|
||
<PmtTpInf><SvcLvl><Cd>SEPA</Cd></SvcLvl></PmtTpInf>
|
||
<ReqdExctnDt>${todayISO}</ReqdExctnDt>
|
||
<Dbtr>
|
||
<Nm>${dbtrNom}</Nm>
|
||
<PstlAdr>
|
||
<PstCd>${dbtrCp}</PstCd>
|
||
<TwnNm>${dbtrVille}</TwnNm>
|
||
<Ctry>${dbtrPays}</Ctry>
|
||
<AdrLine>${dbtrAdrLine}</AdrLine>
|
||
</PstlAdr>
|
||
</Dbtr>
|
||
<DbtrAcct><Id><IBAN>${dbtrIban}</IBAN></Id></DbtrAcct>
|
||
<DbtrAgt><FinInstnId><Othr><Id>NOTPROVIDED</Id></Othr></FinInstnId></DbtrAgt>
|
||
<ChrgBr>SLEV</ChrgBr>${transactions}
|
||
</PmtInf>
|
||
</CstmrCdtTrfInitn>
|
||
</Document>`;
|
||
|
||
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,n.lignesJson,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 ${recalculerMontantNote(n).toFixed(2) }€ a été payée le ${dateObj.toLocaleDateString('fr-FR')}.`,
|
||
noteId: n.id
|
||
});
|
||
await sendMailGraph(n.email, `Paiement effectué : ${n.reference}`, `
|
||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#10b981,#059669);color:white;padding:24px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">Paiement effectué</h2>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${n.prenom} ${n.nom}</strong>,</p>
|
||
<p>Votre note <strong>${n.reference}</strong> — ${n.libelle} d'un montant de <strong>${recalculerMontantNote(n).toFixed(2) }€</strong> a été payée le <strong>${dateObj.toLocaleDateString('fr-FR')}</strong>.</p>
|
||
<div style="text-align:center;margin-top:24px">
|
||
<a href="${process.env.FRONTEND_URL || 'myndf.ensup-adm.net'}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700">Voir mes notes</a>
|
||
</div>
|
||
</div>
|
||
</div>`);
|
||
} catch (e) { console.error('Notif paiement confirmé:', e.message); }
|
||
}
|
||
|
||
const total = notes.recordset.reduce((s, n) => s + recalculerMontantNote(n), 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, upload.any(), 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))
|
||
: (l.tvaItems?.length
|
||
? l.tvaItems.reduce((s, item) => s + (parseFloat(item.montantTTC) || 0), 0)
|
||
: 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', 'carte_grise', 'permis'];
|
||
|
||
// GET /api/profil/documents
|
||
// 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'
|
||
`);
|
||
|
||
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) {
|
||
// ✅ Normaliser cartegrise → carte_grise pour le frontend
|
||
const frontendKey = row.type === 'cartegrise' ? 'carte_grise' : row.type;
|
||
docs[frontendKey] = {
|
||
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 rawType = req.params.type;
|
||
const type = rawType === 'carte_grise' ? 'cartegrise' : rawType;
|
||
|
||
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}`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;padding:24px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">Document à valider</h2>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${finance.prenom} ${finance.nom}</strong>,</p>
|
||
<p><strong>${prenom} ${nom}</strong> (${campus}) a soumis son <strong>${typeLabels[type]}</strong> en attente de votre validation.</p>
|
||
<div style="text-align:center;margin-top:24px">
|
||
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700">
|
||
Valider les documents
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
);
|
||
} 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 rawType = req.params.type;
|
||
const type = rawType === 'carte_grise' ? 'cartegrise' : rawType;
|
||
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,
|
||
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,${isValide ? '#10b981,#059669' : '#ef4444,#dc2626'});color:white;padding:24px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">${titre}</h2>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${doc.prenom} ${doc.nom}</strong>,</p>
|
||
<p>${message}</p>
|
||
${!isValide ? '<p>Veuillez soumettre un nouveau document corrigé depuis votre profil.</p>' : ''}
|
||
<div style="text-align:center;margin-top:24px">
|
||
<a href="${frontendUrl}" style="background:#6366f1;color:white;padding:13px 28px;text-decoration:none;border-radius:8px;font-weight:700">Accéder à mon profil</a>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
);
|
||
} 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.lignesJson,
|
||
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 = recalculerMontantNote(note).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}`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:620px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#ef4444,#dc2626);color:white;padding:24px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0;font-size:18px">⚠️ Justificatif non conforme</h2>
|
||
<p style="margin:8px 0 0;opacity:.85;font-size:13px">Une correction est nécessaire</p>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${note.prenom} ${note.nom}</strong>,</p>
|
||
<p>Le vérificateur Finance <strong>${verificateurNom}</strong> a signalé
|
||
un justificatif non conforme sur votre note <strong>${note.reference}</strong>.</p>
|
||
|
||
<div style="background:#fef2f2;border:1.5px solid #fecaca;border-left:4px solid #ef4444;
|
||
border-radius:0 8px 8px 0;padding:16px;margin:20px 0">
|
||
<div style="font-size:12px;font-weight:700;color:#991b1b;
|
||
text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">
|
||
Justificatif concerné
|
||
</div>
|
||
<div style="font-size:13px;color:#dc2626;font-weight:600;margin-bottom:10px">
|
||
📄 ${fileName}
|
||
</div>
|
||
<div style="font-size:12px;font-weight:700;color:#991b1b;
|
||
text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">
|
||
Motif
|
||
</div>
|
||
<div style="font-size:14px;color:#dc2626;font-weight:600">${motif}</div>
|
||
</div>
|
||
|
||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;
|
||
padding:16px;margin:16px 0">
|
||
<table style="width:100%;border-collapse:collapse;font-size:13px">
|
||
<tr><td style="color:#64748b;padding:4px 0;width:140px">Référence</td>
|
||
<td style="font-weight:700;font-family:monospace;color:#6366f1">${note.reference}</td></tr>
|
||
<tr><td style="color:#64748b;padding:4px 0">Libellé</td>
|
||
<td style="font-weight:600">${note.libelle}</td></tr>
|
||
<tr><td style="color:#64748b;padding:4px 0">Montant</td>
|
||
<td style="font-weight:700">${montantFormate} €</td></tr>
|
||
</table>
|
||
</div>
|
||
|
||
<div style="background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;
|
||
padding:16px;margin:16px 0">
|
||
<div style="font-size:13px;font-weight:700;color:#1e40af;margin-bottom:8px">
|
||
📝 Que faire ?
|
||
</div>
|
||
<ol style="margin:0;padding-left:18px;font-size:13px;color:#1d4ed8;line-height:2">
|
||
<li>Retrouvez le justificatif original corrigé</li>
|
||
<li>Contactez votre responsable ou la Finance</li>
|
||
<li>Soumettez un nouveau justificatif via la plateforme</li>
|
||
</ol>
|
||
</div>
|
||
|
||
<div style="text-align:center;margin-top:28px">
|
||
<a href="${frontendUrl}"
|
||
style="background:linear-gradient(135deg,#6366f1,#4f46e5);color:white;
|
||
padding:14px 36px;text-decoration:none;border-radius:8px;
|
||
font-weight:700;font-size:14px;display:inline-block">
|
||
Accéder à ma note →
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
);
|
||
} 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`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#f59e0b,#d97706);color:white;
|
||
padding:20px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0">⚠️ Justificatif non conforme signalé</h2>
|
||
</div>
|
||
<div style="padding:20px;background:#f8fafc;border:1px solid #e2e8f0;
|
||
border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${note.prenomN1} ${note.nomN1}</strong>,</p>
|
||
<p>Le vérificateur Finance <strong>${verificateurNom}</strong> a signalé
|
||
un justificatif non conforme sur la note
|
||
<strong>${note.reference}</strong> de
|
||
<strong>${note.prenom} ${note.nom}</strong>.</p>
|
||
<div style="background:#fef3c7;border:1px solid #fde68a;border-radius:8px;
|
||
padding:14px;margin:16px 0">
|
||
<div style="font-size:13px;font-weight:700;color:#92400e">
|
||
📄 ${fileName}
|
||
</div>
|
||
<div style="font-size:13px;color:#b45309;margin-top:6px">
|
||
Motif : ${motif}
|
||
</div>
|
||
</div>
|
||
<p style="font-size:13px;color:#64748b">
|
||
Le collaborateur a été notifié et doit fournir un justificatif corrigé.
|
||
</p>
|
||
</div>
|
||
</div>`
|
||
);
|
||
} 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, n.lignesJson,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 + recalculerMontantNote(n), 0);
|
||
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 =>
|
||
`<tr style="border-bottom:1px solid #e2e8f0">
|
||
<td style="padding:8px 12px;font-family:monospace;font-size:12px;color:#6366f1;font-weight:700">${n.reference}</td>
|
||
<td style="padding:8px 12px;font-size:13px">${n.prenom} ${n.nom}</td>
|
||
<td style="padding:8px 12px;font-size:13px;font-weight:700">${recalculerMontantNote(n).toFixed(2)} €</td>
|
||
</tr>`
|
||
).join('');
|
||
|
||
await sendMailGraph(
|
||
president.email,
|
||
`💼 ${notes.recordset.length} note(s) de frais en attente de votre validation`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:640px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#1e3a5f,#1d4ed8);color:white;padding:28px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0;font-size:20px">💼 Notes de frais — Validation Président</h2>
|
||
<p style="margin:10px 0 0;opacity:.85;font-size:13px">${notes.recordset.length} note(s) soumises par ${validateurNom}</p>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${president.prenom} ${president.nom}</strong>,</p>
|
||
<p>${validateurNom} vous soumet les notes de frais suivantes pour validation avant génération du virement bancaire :</p>
|
||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:20px 0">
|
||
<table style="width:100%;border-collapse:collapse">
|
||
<thead>
|
||
<tr style="background:#f1f5f9">
|
||
<th style="padding:10px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">Référence</th>
|
||
<th style="padding:10px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">Collaborateur</th>
|
||
<th style="padding:10px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">Montant</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${lignesNotes}</tbody>
|
||
<tfoot>
|
||
<tr style="background:#f0fdf4;border-top:2px solid #86efac">
|
||
<td colspan="2" style="padding:10px 12px;font-weight:700;color:#15803d;font-size:13px">Total à virer</td>
|
||
<td style="padding:10px 12px;font-weight:900;color:#15803d;font-size:15px">${total} €</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
<div style="text-align:center;margin-top:28px">
|
||
<a href="${frontendUrl}" style="background:linear-gradient(135deg,#1d4ed8,#1e40af);color:white;padding:14px 36px;text-decoration:none;border-radius:8px;font-weight:700;display:inline-block">
|
||
✅ Valider et générer le virement →
|
||
</a>
|
||
</div>
|
||
<p style="font-size:12px;color:#64748b;margin-top:20px;text-align:center">
|
||
Cette validation est définitive. Le fichier XML de virement sera généré automatiquement.
|
||
</p>
|
||
</div>
|
||
</div>`
|
||
);
|
||
} 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
|
||
`);
|
||
|
||
// ✅ Recalculer le montant (km, etc.) au lieu d'afficher n.montant brut
|
||
const notes = result.recordset.map(n => ({
|
||
...n,
|
||
montant: recalculerMontantNote(n),
|
||
}));
|
||
|
||
res.json(notes);
|
||
} 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 + recalculerMontantNote(n), 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
|
||
? `<FinInstnId><BIC>${n.bic}</BIC></FinInstnId>`
|
||
: `<FinInstnId><Othr><Id>NOTPROVIDED</Id></Othr></FinInstnId>`;
|
||
|
||
transactions += `
|
||
<CdtTrfTxInf>
|
||
<PmtId>
|
||
<InstrId>VIREMENT NUM:${n.reference}</InstrId>
|
||
<EndToEndId>${n.reference}</EndToEndId>
|
||
</PmtId>
|
||
<Amt>
|
||
<InstdAmt Ccy="EUR">${recalculerMontantNote(n).toFixed(2) }</InstdAmt>
|
||
</Amt>
|
||
<CdtrAgt>
|
||
${benefBicBlock}
|
||
</CdtrAgt>
|
||
<Cdtr>
|
||
<Nm>${benefNom}</Nm>${adrLine || cp || ville ? `
|
||
<PstlAdr>${cp ? `
|
||
<PstCd>${cp}</PstCd>` : ''}${ville ? `
|
||
<TwnNm>${ville}</TwnNm>` : ''}
|
||
<Ctry>${pays}</Ctry>${adrLine ? `
|
||
<AdrLine>${adrLine}</AdrLine>` : ''}
|
||
</PstlAdr>` : ''}
|
||
<CtryOfRes>${pays}</CtryOfRes>
|
||
</Cdtr>
|
||
<CdtrAcct>
|
||
<Id>
|
||
<IBAN>${ibanClair}</IBAN>
|
||
</Id>
|
||
</CdtrAcct>
|
||
</CdtTrfTxInf>`;
|
||
}
|
||
|
||
// Mention Président dans le message du fichier
|
||
const xml = `<?xml version="1.0" encoding="iso-8859-1"?>
|
||
<!-- Validé par le Président ${presidentNom} le ${now.toLocaleDateString('fr-FR')} à ${now.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} -->
|
||
<Document xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03">
|
||
<CstmrCdtTrfInitn>
|
||
<GrpHdr>
|
||
<MsgId>${msgId}</MsgId>
|
||
<CreDtTm>${creDtTm}</CreDtTm>
|
||
<NbOfTxs>${notes.recordset.length}</NbOfTxs>
|
||
<CtrlSum>${totalFormate}</CtrlSum>
|
||
<InitgPty>
|
||
<Nm>${dbtrNom}</Nm>
|
||
</InitgPty>
|
||
</GrpHdr>
|
||
<PmtInf>
|
||
<PmtInfId>${msgId}</PmtInfId>
|
||
<PmtMtd>TRF</PmtMtd>
|
||
<BtchBookg>true</BtchBookg>
|
||
<NbOfTxs>${notes.recordset.length}</NbOfTxs>
|
||
<CtrlSum>${totalFormate}</CtrlSum>
|
||
<PmtTpInf>
|
||
<SvcLvl>
|
||
<Cd>SEPA</Cd>
|
||
</SvcLvl>
|
||
</PmtTpInf>
|
||
<ReqdExctnDt>${todayISO}</ReqdExctnDt>
|
||
<Dbtr>
|
||
<Nm>${dbtrNom}</Nm>
|
||
<PstlAdr>
|
||
<PstCd>${dbtrCp}</PstCd>
|
||
<TwnNm>${dbtrVille}</TwnNm>
|
||
<Ctry>${dbtrPays}</Ctry>
|
||
<AdrLine>${dbtrAdrLine}</AdrLine>
|
||
</PstlAdr>
|
||
</Dbtr>
|
||
<DbtrAcct>
|
||
<Id>
|
||
<IBAN>${dbtrIban}</IBAN>
|
||
</Id>
|
||
</DbtrAcct>
|
||
<DbtrAgt>
|
||
<FinInstnId>
|
||
<Othr>
|
||
<Id>NOTPROVIDED</Id>
|
||
</Othr>
|
||
</FinInstnId>
|
||
</DbtrAgt>
|
||
<ChrgBr>SLEV</ChrgBr>${transactions}
|
||
</PmtInf>
|
||
</CstmrCdtTrfInitn>
|
||
</Document>`;
|
||
|
||
// ── 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 =>
|
||
`<tr style="border-bottom:1px solid #e2e8f0">
|
||
<td style="padding:8px 12px;font-family:monospace;font-size:12px;color:#6366f1;font-weight:700">${n.reference}</td>
|
||
<td style="padding:8px 12px;font-size:13px">${n.prenom} ${n.nom}</td>
|
||
<td style="padding:8px 12px;font-size:13px;font-weight:700">${recalculerMontantNote(n).toFixed(2) } €</td>
|
||
</tr>`
|
||
).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}`,
|
||
`<div style="font-family:Arial,sans-serif;max-width:640px;margin:0 auto">
|
||
<div style="background:linear-gradient(135deg,#1e3a5f,#1d4ed8);color:white;padding:28px;border-radius:12px 12px 0 0">
|
||
<h2 style="margin:0;font-size:20px">✅ Virement validé par le Président</h2>
|
||
<p style="margin:10px 0 0;opacity:.85;font-size:13px">Le fichier XML a été généré et est prêt pour votre banque</p>
|
||
</div>
|
||
<div style="padding:24px;background:#f8fafc;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
||
<p>Bonjour <strong>${soumetteur.prenom} ${soumetteur.nom}</strong>,</p>
|
||
<p>Le Président <strong>${presidentNom}</strong> a validé et généré le fichier XML de virement bancaire pour les notes que vous lui avez soumises.</p>
|
||
|
||
<div style="background:#fff;border:1.5px solid #bfdbfe;border-left:4px solid #1d4ed8;border-radius:0 8px 8px 0;padding:16px;margin:20px 0">
|
||
<div style="font-size:13px;color:#1e40af;margin-bottom:8px">
|
||
<strong>Date :</strong> ${dateLabel} à ${heureLabel}<br/>
|
||
<strong>Validé par :</strong> ${presidentNom}<br/>
|
||
<strong>Nombre de virements :</strong> ${notes.recordset.length}<br/>
|
||
<strong>Montant total :</strong> <span style="font-weight:900;color:#15803d;font-size:15px">${totalFormate} €</span>
|
||
</div>
|
||
${commentaire ? `<div style="margin-top:10px;padding:10px;background:#eff6ff;border-radius:6px;font-size:12px;color:#1d4ed8">💬 <em>${commentaire}</em></div>` : ''}
|
||
</div>
|
||
|
||
<div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;margin:20px 0">
|
||
<div style="background:#f1f5f9;padding:10px 14px;border-bottom:1px solid #e2e8f0">
|
||
<span style="font-size:12px;font-weight:700;color:#64748b;text-transform:uppercase;letter-spacing:.5px">
|
||
Détail des virements (${notes.recordset.length})
|
||
</span>
|
||
</div>
|
||
<table style="width:100%;border-collapse:collapse">
|
||
<thead>
|
||
<tr style="background:#fafafa">
|
||
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">Référence</th>
|
||
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">Collaborateur</th>
|
||
<th style="padding:8px 12px;text-align:left;font-size:11px;color:#64748b;text-transform:uppercase">Montant</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${lignesNotes}</tbody>
|
||
<tfoot>
|
||
<tr style="background:#f0fdf4;border-top:2px solid #86efac">
|
||
<td colspan="2" style="padding:10px 12px;font-weight:700;color:#15803d;font-size:13px">Total</td>
|
||
<td style="padding:10px 12px;font-weight:900;color:#15803d;font-size:15px">${totalFormate} €</td>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
|
||
<p style="font-size:13px;color:#374151">
|
||
Vous pouvez re-télécharger ce fichier XML depuis la rubrique <strong>"XML virements"</strong> de la plateforme.
|
||
</p>
|
||
<div style="text-align:center;margin-top:28px">
|
||
<a href="${process.env.FRONTEND_URL || 'https://myndf.ensup-adm.net'}" style="background:linear-gradient(135deg,#1d4ed8,#1e40af);color:white;padding:14px 36px;text-decoration:none;border-radius:8px;font-weight:700;display:inline-block">
|
||
🏦 Voir les XML virements →
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>`
|
||
);
|
||
|
||
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); |