/** * routes/export.js * Exports PDF, PPTX, XLSX, DOCX par vue * * PDF → tous les rôles authentifiés * PPTX / XLSX / DOCX → SuperAdmin uniquement (vérifié ici) */ const express = require('express'); const router = express.Router(); const { getMarches } = require('../services/baserow'); const { isCloture, normalizeMarche, parseNum, formatMontant, getDelaiRestant, niveauAlerte, niveauAvancement, niveauRisque, DELAI_CRITIQUE, DELAI_ATTENTION, SEUIL_STANDARD, SEUIL_CRITIQUE_PCT, } = require('../services/calc'); const pdfGen = require('../services/export-pdf'); const { generateXlsx } = require('../services/export-xlsx'); // ─── Helpers ────────────────────────────────────────────────────────────────── function applyFilters(rows, req) { const { region, entrepreneur, projet, nature, statut } = req.query; const regionFilter = req.regionFilter; let r = rows; if (regionFilter) r = r.filter(x => x.region === regionFilter); else if (region) r = r.filter(x => x.region === region); if (entrepreneur) r = r.filter(x => String(x.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase())); if (projet) r = r.filter(x => String(x.projet || '').toLowerCase().includes(projet.toLowerCase())); if (nature) r = r.filter(x => String(x.nature || '').toLowerCase().includes(nature.toLowerCase())); if (statut) r = r.filter(x => String(x.statut || '').toLowerCase().includes(statut.toLowerCase())); return r; } async function buildViewData(view, req) { const allRows = await getMarches(); let rows = applyFilters(allRows, req); const actifs = rows.filter(r => !isCloture(r)); const clotures = rows.filter(r => isCloture(r)); switch (view) { case 'synthese': { const tauxList = actifs.map(r => parseNum(r.taux_phy ?? r.avt_phy)).filter(v => v > 0); const tauxMoyen = tauxList.length ? Math.round(tauxList.reduce((a,b)=>a+b,0)/tauxList.length*10)/10 : 0; const totalBudget = actifs.reduce((s,r) => s+parseNum(r.tot_marche??r.totmarche??r.montant),0); const parStatut = {}; for (const r of rows) { const s=String(r.statut||'Inconnu'); parStatut[s]=(parStatut[s]||0)+1; } const alertes = actifs .map(r=>({...r,_d:getDelaiRestant(r)})) .filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION) .map(r=>({ref:r.ref||'',projet:r.projet||'',region:r.region||'',entrepreneur:r.entrepreneur||'',delai_restant:r._d,niveau:niveauAlerte(r._d)})) .sort((a,b)=>a.delai_restant-b.delai_restant); return { total: rows.length, actifs: actifs.length, clotures: clotures.length, taux_avancement_moyen: tauxMoyen, par_statut: parStatut, budget: { total: formatMontant(totalBudget), total_raw: totalBudget }, alertes_delais: { count: alertes.length, critique: alertes.filter(a=>a.niveau==='critique').length, items: alertes }, }; } case 'alertes': { const items = actifs .map(r=>({...r,_d:getDelaiRestant(r)})) .filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION) .map(r=>({...normalizeMarche(r),delai_restant:r._d,niveau:niveauAlerte(r._d),niveau_alerte:niveauAlerte(r._d)})) .sort((a,b)=>a.delai_restant-b.delai_restant); return { count: items.length, critique: items.filter(a=>a.niveau==='critique').length, items }; } case 'en-service': return { count: actifs.length, items: actifs.map(normalizeMarche) }; case 'en-cours': { const enCours = actifs.filter(r=>parseNum(r.taux_phy??r.avt_phy)<100); return { count: enCours.length, items: enCours.map(normalizeMarche) }; } case 'par-region': { const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur']; const regions = ALL_REGIONS.map(reg => { const regActifs = actifs.filter(r=>(r.region||'')=== reg); const regTotal = rows.filter(r=>(r.region||'')=== reg); const tauxList = regActifs.map(r=>parseNum(r.taux_phy??r.avt_phy)).filter(v=>v>0); const tauxMoyen = tauxList.length ? Math.round(tauxList.reduce((a,b)=>a+b,0)/tauxList.length*10)/10 : 0; const budget = regActifs.reduce((s,r)=>s+parseNum(r.tot_marche??r.totmarche??r.montant),0); const alertes = regActifs .map(r=>({...r,_d:getDelaiRestant(r)})) .filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION); return { region: reg, actifs: regActifs.length, clotures: regTotal.length-regActifs.length, total: regTotal.length, taux_moyen: tauxMoyen, budget: formatMontant(budget), alertes_count: alertes.length, alertes_critique: alertes.filter(r=>r._d<=DELAI_CRITIQUE).length }; }); return { count: regions.length, regions }; } case 'clotures': { const totalBudget = clotures.reduce((s,r)=>s+parseNum(r.tot_marche??r.totmarche??r.montant),0); return { count: clotures.length, budget_total: formatMontant(totalBudget), items: clotures.map(normalizeMarche) }; } case 'pilotage': { const items = actifs.map(r=>{ const d = getDelaiRestant(r); return {...normalizeMarche(r), delai_restant:d, niveau_alerte:niveauAlerte(d), niveau_avancement:niveauAvancement(r.taux_phy??r.avt_phy,r.nature)}; }); const normal = items.filter(r=>r.niveau_avancement==='normal'); const sous = items.filter(r=>r.niveau_avancement==='sous_avancement'); const dep = items.filter(r=>r.niveau_avancement==='dépassé'); return { resume:{total:items.length,normal:normal.length,sous_avancement:sous.length,depasse:dep.length}, normal, sous_avancement:sous, depasse:dep, items }; } case 'matrice-risque': { const items = actifs.map(r=>{ const d=getDelaiRestant(r); return {...normalizeMarche(r),delai_restant:d,niveau_alerte:niveauAlerte(d),niveau_risque:niveauRisque(r), score_delai: d===null?1:d<=DELAI_CRITIQUE?3:d<=DELAI_ATTENTION?2:1, score_avancement: parseNum(r.taux_phy??r.avt_phy)>=SEUIL_CRITIQUE_PCT?3:parseNum(r.taux_phy??r.avt_phy)>=SEUIL_STANDARD?2:1, }; }); const pn={critique:0,élevé:0,moyen:0,faible:0}; for(const i of items){ if(pn[i.niveau_risque]!==undefined) pn[i.niveau_risque]++; else pn[i.niveau_risque]=1; } return { total:items.length, par_niveau:pn, items }; } default: return { items: actifs.map(normalizeMarche) }; } } // ─── Route PDF ──────────────────────────────────────────────────────────────── router.get('/pdf', async (req, res) => { try { const view = req.query.view || 'synthese'; const data = await buildViewData(view, req); let buf; switch (view) { case 'synthese': buf = await pdfGen.generateSynthese(data); break; case 'alertes': buf = await pdfGen.generateAlertes(data); break; case 'en-service': buf = await pdfGen.generateEnService(data); break; case 'en-cours': buf = await pdfGen.generateEnCours(data); break; case 'par-region': buf = await pdfGen.generateParRegion(data); break; case 'clotures': buf = await pdfGen.generateClotures(data); break; case 'pilotage': buf = await pdfGen.generatePilotage(data); break; case 'matrice-risque': buf = await pdfGen.generateMatriceRisque(data); break; default: buf = await pdfGen.generateGeneric(view, data); break; } const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.pdf`; res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': `attachment; filename="${filename}"`, 'Content-Length': buf.length, }); res.end(buf); } catch (err) { res.status(502).json({ error: 'Erreur génération PDF', detail: err.message }); } }); // ─── Route XLSX (SuperAdmin) ────────────────────────────────────────────────── router.get('/xlsx', async (req, res) => { if (req.user?.role !== 'superadmin') { return res.status(403).json({ error: 'Accès réservé au SuperAdmin' }); } try { const view = req.query.view || 'synthese'; const data = await buildViewData(view, req); const buf = await generateXlsx(view, data); const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.xlsx`; res.set({ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Disposition': `attachment; filename="${filename}"`, }); res.end(buf); } catch (err) { res.status(502).json({ error: 'Erreur génération XLSX', detail: err.message }); } }); // ─── Route PPTX (SuperAdmin) ────────────────────────────────────────────────── router.get('/pptx', async (req, res) => { if (req.user?.role !== 'superadmin') { return res.status(403).json({ error: 'Accès réservé au SuperAdmin' }); } try { const PptxGenJS = require('pptxgenjs'); const view = req.query.view || 'synthese'; const data = await buildViewData(view, req); const pptx = new PptxGenJS(); pptx.layout = 'LAYOUT_WIDE'; pptx.author = 'RLA API'; pptx.company = 'Tunisie Telecom Zone Sud'; pptx.subject = `RLA — ${view}`; // Slide de titre const slide1 = pptx.addSlide(); slide1.background = { color: '002D62' }; slide1.addText(`RLA — ${view.toUpperCase()}`, { x: 0.5, y: 2, w: '90%', h: 1.2, fontSize: 36, bold: true, color: 'FFFFFF', align: 'center', }); slide1.addText('Marchés Tunisie Telecom Zone Sud', { x: 0.5, y: 3.4, w: '90%', h: 0.5, fontSize: 16, color: 'B3C5E0', align: 'center', }); slide1.addText(new Date().toLocaleDateString('fr-FR'), { x: 0.5, y: 4, w: '90%', h: 0.4, fontSize: 12, color: 'E31837', align: 'center', }); // Slide données const slide2 = pptx.addSlide(); slide2.addText(`Données — ${view}`, { x: 0.3, y: 0.2, w: '95%', h: 0.5, fontSize: 18, bold: true, color: '002D62', }); // Table si items const items = data.items || data.regions || []; if (items.length) { const sample = items[0]; const keys = Object.keys(sample).filter(k => !k.endsWith('_raw') && k!=='id' && typeof sample[k]!=='object').slice(0,7); const tableData = [ keys.map(k => ({ text: k, options: { bold: true, color: 'FFFFFF', fill: '002D62' } })), ...items.slice(0, 20).map(item => keys.map(k => ({ text: String(item[k] ?? '—') })) ), ]; slide2.addTable(tableData, { x: 0.3, y: 0.9, w: 9.4, fontSize: 9, border: { type: 'solid', color: 'E2E8F0' }, colW: keys.map(() => +(9.4 / keys.length).toFixed(2)), }); } const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.pptx`; const buf = await pptx.write({ outputType: 'nodebuffer' }); res.set({ 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'Content-Disposition': `attachment; filename="${filename}"`, }); res.end(buf); } catch (err) { res.status(502).json({ error: 'Erreur génération PPTX', detail: err.message }); } }); // ─── Route DOCX (SuperAdmin) ────────────────────────────────────────────────── router.get('/docx', async (req, res) => { if (req.user?.role !== 'superadmin') { return res.status(403).json({ error: 'Accès réservé au SuperAdmin' }); } try { const { Document, Packer, Paragraph, Table, TableRow, TableCell, TextRun, HeadingLevel, AlignmentType, WidthType, BorderStyle } = require('docx'); const view = req.query.view || 'synthese'; const data = await buildViewData(view, req); const items = data.items || data.regions || []; const children = [ new Paragraph({ text: `RLA — ${view.toUpperCase()}`, heading: HeadingLevel.HEADING_1, }), new Paragraph({ text: `Marchés Tunisie Telecom Zone Sud — Édité le ${new Date().toLocaleDateString('fr-FR')}`, children: [new TextRun({ text: '', break: 1 })], }), ]; if (items.length) { const sample = items[0]; const keys = Object.keys(sample).filter(k => !k.endsWith('_raw') && k!=='id' && typeof sample[k]!=='object').slice(0,7); const tableRows = [ new TableRow({ children: keys.map(k => new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: k, bold: true, color: 'FFFFFF' })], alignment: AlignmentType.CENTER })], shading: { fill: '002D62' }, })), }), ...items.slice(0, 50).map((item, i) => new TableRow({ children: keys.map(k => new TableCell({ children: [new Paragraph(String(item[k] ?? '—'))], shading: i % 2 === 1 ? { fill: 'F1F5F9' } : undefined, })), })), ]; children.push(new Table({ rows: tableRows, width: { size: 100, type: WidthType.PERCENTAGE }, })); } const doc = new Document({ sections: [{ children }] }); const buf = await Packer.toBuffer(doc); const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.docx`; res.set({ 'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'Content-Disposition': `attachment; filename="${filename}"`, }); res.end(buf); } catch (err) { res.status(502).json({ error: 'Erreur génération DOCX', detail: err.message }); } }); module.exports = router;