/** * 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, selectVal, getDelaiRestant, niveauAlerte, niveauAvancement, niveauRisque, DELAI_CRITIQUE, DELAI_ATTENTION, SEUIL_STANDARD, SEUIL_CRITIQUE_PCT, buildRef, } = 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=selectVal(r.observation)||'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:buildRef(r),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': { const enService = actifs.filter(r => selectVal(r.observation).toLowerCase().includes('en service')); return { count: enService.length, items: enService.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 allRows = await getMarches(); const filtered = applyFilters(allRows, req); const actifs = filtered.filter(r => !isCloture(r)); const buf = await generateXlsx('all', {}, actifs); const filename = `Marches_RLA_Zone_Sud_${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 allRows = await getMarches(); const filtered = applyFilters(allRows, req); const actifs = filtered.filter(r => !isCloture(r)); const clotures = filtered.filter(r => isCloture(r)); const today = new Date().toLocaleDateString('fr-FR'); const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur']; const parseN = v => { const n = parseFloat(String(v||'').replace(/\s/g,'').replace(',','.')); return isNaN(n)?0:n; }; const fmtPct = v => { const n = parseN(v); return n===0?'0%':`${n.toFixed(0)}%`; }; const fmtMDT = v => { const n = parseN(v); if(!n) return '—'; if(n>=1e6) return `${(n/1e6).toFixed(1)} MDT`; return `${(n/1e3).toFixed(0)} kDT`; }; const selVal = v => { if(!v) return ''; if(typeof v==='object'&&v.value!==undefined) return String(v.value); return String(v); }; const totalBudget = actifs.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0); const phyList = actifs.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0); const avgPhy = phyList.length ? phyList.reduce((a,b)=>a+b,0)/phyList.length : 0; const alerteItems = actifs .map(r=>({...r,_d: (() => { const v=parseInt(String(r.delai_restant||''),10); return !isNaN(v)?v:null; })()})) .filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION) .sort((a,b)=>a._d-b._d); const SEUIL_STD = parseFloat(process.env.SEUIL_STANDARD||70); const SEUIL_MOD = parseFloat(process.env.SEUIL_MODERNISATION||50); const classify = r => { const t = parseN(r.taux_phy||r.avt_phy); const s = String(selVal(r.nature)||'').toLowerCase().includes('modern') ? SEUIL_MOD : SEUIL_STD; if(!t) return 'Non déterminé'; if(t>=SEUIL_CRITIQUE_PCT) return 'Dépassement'; if(t>=s) return 'Normal'; return 'Sous Avancement'; }; const normal = actifs.filter(r=>classify(r)==='Normal'); const sous = actifs.filter(r=>classify(r)==='Sous Avancement'); const dep = actifs.filter(r=>classify(r)==='Dépassement'); const pptx = new PptxGenJS(); pptx.layout = 'LAYOUT_WIDE'; pptx.author = 'RLA API'; pptx.company = 'Tunisie Telecom Zone Sud'; pptx.subject = 'Marchés RLA Zone Sud'; const addFooter = slide => { slide.addText(`Tunisie Telecom • Zone Sud • ${today}`, { x: 0, y: 5.3, w: '100%', h: 0.25, fontSize: 8, color: '64748B', align: 'center', }); }; // ── Slide 1: Couverture const s1 = pptx.addSlide(); s1.background = { color: '002D62' }; s1.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.08, fill: { color: '00D4FF' } }); s1.addText('TUNISIE TELECOM', { x: 0.5, y: 0.4, w: '90%', h: 0.5, fontSize: 14, color: '94A3B8', align: 'center' }); s1.addText('RAPPORT DE SUIVI DES MARCHÉS RLA', { x: 0.5, y: 1.1, w: '90%', h: 1.0, fontSize: 30, bold: true, color: 'FFFFFF', align: 'center' }); s1.addText('Zone Sud — Situation Actuelle & Pilotage Proactif', { x: 0.5, y: 2.2, w: '90%', h: 0.5, fontSize: 16, color: '00D4FF', align: 'center' }); s1.addShape(pptx.ShapeType.rect, { x: 3.5, y: 3.0, w: 6.5, h: 0.05, fill: { color: '00D4FF' }, line: { color: '00D4FF' } }); s1.addText(`📅 ${today}`, { x: 0.5, y: 3.2, w: '90%', h: 0.4, fontSize: 12, color: 'CBD5E1', align: 'center' }); s1.addText(`📋 ${actifs.length} marchés actifs │ 💰 ${fmtMDT(totalBudget)}`, { x: 0.5, y: 3.7, w: '90%', h: 0.4, fontSize: 11, color: '94A3B8', align: 'center' }); s1.addShape(pptx.ShapeType.rect, { x: 0, y: 5.47, w: '100%', h: 0.08, fill: { color: '00D4FF' } }); // ── Slide 2: Synthèse globale const s2 = pptx.addSlide(); s2.background = { color: '0F172A' }; s2.addText('SYNTHÈSE GLOBALE', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: '00D4FF' }); s2.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: '002D62' } }); const kpis = [ { label: 'Total Marchés', val: String(actifs.length), color: '00D4FF' }, { label: 'Budget Total', val: fmtMDT(totalBudget), color: '10B981' }, { label: 'Avt. Phy Moy', val: fmtPct(avgPhy), color: '10B981' }, { label: 'Alertes', val: String(alerteItems.filter(r=>r._d<=DELAI_CRITIQUE).length), color: 'EF4444' }, ]; kpis.forEach((k, i) => { const x = 0.3 + i * 2.5; s2.addShape(pptx.ShapeType.roundRect, { x, y: 0.75, w: 2.3, h: 1.3, fill: { color: '1E3A5F' }, line: { color: '002D62' } }); s2.addText(k.val, { x, y: 0.85, w: 2.3, h: 0.7, fontSize: 26, bold: true, color: k.color, align: 'center' }); s2.addText(k.label, { x, y: 1.6, w: 2.3, h: 0.35, fontSize: 9, color: '94A3B8', align: 'center' }); }); // Par statut const parStatut = {}; for (const r of filtered) { const s = selVal(r.observation) || 'Inconnu'; parStatut[s] = (parStatut[s] || 0) + 1; } const statRows = Object.entries(parStatut).slice(0, 8).map(([s, n]) => [ { text: s, options: { color: 'CBD5E1', fontSize: 9 } }, { text: String(n), options: { color: '00D4FF', bold: true, fontSize: 9, align: 'right' } }, ]); if (statRows.length) { s2.addText('Répartition par Statut', { x: 0.3, y: 2.2, w: 4, h: 0.3, fontSize: 11, bold: true, color: 'FFFFFF' }); s2.addTable([ [{ text: 'Statut', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } }, { text: 'Nb', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } }], ...statRows, ], { x: 0.3, y: 2.55, w: 4, fontSize: 9, border: { type: 'solid', color: '1E3A5F' }, colW: [3.2, 0.8] }); } // Par région const regData = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'].map(reg => { const ra = actifs.filter(r=>(r.region||'')===reg); const pl = ra.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0); const pm = pl.length ? pl.reduce((a,b)=>a+b,0)/pl.length : 0; return [ { text: reg, options: { color: 'CBD5E1', fontSize: 9 } }, { text: String(ra.length), options: { color: '00D4FF', bold: true, fontSize: 9, align: 'center' } }, { text: fmtPct(pm), options: { color: pm>=70?'10B981':'EF4444', bold: true, fontSize: 9, align: 'center' } }, ]; }); s2.addText('Par Région', { x: 5.2, y: 2.2, w: 4.5, h: 0.3, fontSize: 11, bold: true, color: 'FFFFFF' }); s2.addTable([ [{ text: 'Région', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } }, { text: 'Marchés', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } }, { text: 'Phy %', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } }], ...regData, ], { x: 5.2, y: 2.55, w: 4.5, fontSize: 9, border: { type: 'solid', color: '1E3A5F' }, colW: [2.5, 1.0, 1.0] }); addFooter(s2); // ── Slide 3: Alertes const s3 = pptx.addSlide(); s3.background = { color: '0F172A' }; s3.addText('ALERTES DÉLAIS', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: 'EF4444' }); s3.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: 'EF4444' } }); s3.addText(`${alerteItems.filter(r=>r._d<=DELAI_CRITIQUE).length} critique(s) • ${alerteItems.length} total`, { x: 0.3, y: 0.7, w: 9.4, h: 0.3, fontSize: 10, color: '94A3B8', }); if (alerteItems.length) { const alertRows = alerteItems.slice(0, 18).map(r => { const alColor = r._d <= DELAI_CRITIQUE ? 'EF4444' : 'EA580C'; return [ { text: buildRef(r), options: { fontSize: 8, color: 'CBD5E1' } }, { text: r.projet||'', options: { fontSize: 8, color: 'CBD5E1' } }, { text: r.region||'', options: { fontSize: 8, color: 'CBD5E1' } }, { text: r.entrepreneur||'', options: { fontSize: 8, color: 'CBD5E1' } }, { text: String(r._d||'—'), options: { fontSize: 8, bold: true, color: alColor, align: 'center' } }, ]; }); s3.addTable([ ['Référence','Projet','Région','Entrepreneur','Délai (j)'].map(t => ({ text: t, options: { bold: true, color: 'FFFFFF', fill: '7F1D1D', fontSize: 8 }, })), ...alertRows, ], { x: 0.3, y: 1.1, w: 9.4, fontSize: 8, border: { type: 'solid', color: '1E3A5F' }, colW: [2.0, 2.5, 1.2, 2.5, 1.2] }); } addFooter(s3); // ── Slide 4: Pilotage proactif const s4 = pptx.addSlide(); s4.background = { color: '0F172A' }; s4.addText('PILOTAGE PROACTIF', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: '00D4FF' }); s4.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: '002D62' } }); const pilotKpis = [ { label: 'Normal', val: String(normal.length), color: '10B981' }, { label: 'Sous Avancement', val: String(sous.length), color: 'EF4444' }, { label: 'Dépassement', val: String(dep.length), color: 'EA580C' }, ]; pilotKpis.forEach((k, i) => { const x = 0.5 + i * 3.3; s4.addShape(pptx.ShapeType.roundRect, { x, y: 0.75, w: 3.0, h: 1.0, fill: { color: '1E3A5F' }, line: { color: '002D62' } }); s4.addText(k.val, { x, y: 0.8, w: 3.0, h: 0.55, fontSize: 28, bold: true, color: k.color, align: 'center' }); s4.addText(k.label, { x, y: 1.4, w: 3.0, h: 0.25, fontSize: 9, color: '94A3B8', align: 'center' }); }); const pilotItems = [...sous, ...dep].slice(0, 18); if (pilotItems.length) { const pilRows = pilotItems.map((r, i) => { const t = parseN(r.taux_phy||r.avt_phy); const res = classify(r); return [ { text: buildRef(r), options: { fontSize: 8, color: 'CBD5E1' } }, { text: r.projet||'', options: { fontSize: 8, color: 'CBD5E1' } }, { text: r.region||'', options: { fontSize: 8, color: 'CBD5E1' } }, { text: r.entrepreneur||'', options: { fontSize: 8, color: 'CBD5E1' } }, { text: fmtPct(t), options: { fontSize: 8, bold: true, color: t>=SEUIL_CRITIQUE_PCT?'EA580C':'EF4444', align: 'center' } }, { text: res, options: { fontSize: 8, bold: true, color: res==='Dépassement'?'EA580C':'EF4444', align: 'center' } }, ]; }); s4.addText('Marchés Sous Avancement & Dépassement', { x: 0.3, y: 1.9, w: 9.4, h: 0.3, fontSize: 10, bold: true, color: 'FFFFFF' }); s4.addTable([ ['Référence','Projet','Région','Entrepreneur','Phy %','Résultat'].map(t => ({ text: t, options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 8 }, })), ...pilRows, ], { x: 0.3, y: 2.25, w: 9.4, fontSize: 8, border: { type: 'solid', color: '1E3A5F' }, colW: [2.0, 2.3, 1.0, 2.0, 0.9, 1.2] }); } addFooter(s4); // ── Slide 5: Par région const s5 = pptx.addSlide(); s5.background = { color: '0F172A' }; s5.addText('SYNTHÈSE PAR RÉGION', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: '00D4FF' }); s5.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: '002D62' } }); const regTableData = ALL_REGIONS.map(reg => { const ra = actifs.filter(r=>(r.region||'')===reg); const rc = filtered.filter(r=>(r.region||'')===reg&&isCloture(r)); const bud = ra.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0); const pl = ra.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0); const pm = pl.length ? pl.reduce((a,b)=>a+b,0)/pl.length : 0; const al = ra.map(r=>({_d:parseInt(String(r.delai_restant||''),10)||null})).filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION); return [ { text: reg, options: { color: '00D4FF', bold: true, fontSize: 9 } }, { text: String(ra.length), options: { color: 'CBD5E1', fontSize: 9, align: 'center' } }, { text: String(rc.length), options: { color: '64748B', fontSize: 9, align: 'center' } }, { text: fmtMDT(bud), options: { color: 'CBD5E1', fontSize: 9, align: 'right' } }, { text: fmtPct(pm), options: { color: pm>=70?'10B981':'EF4444', bold: true, fontSize: 9, align: 'center' } }, { text: String(al.length), options: { color: al.length>0?'EF4444':'10B981', bold: true, fontSize: 9, align: 'center' } }, ]; }); s5.addTable([ ['Région','Actifs','Clôturés','Budget','Phy Moy','Alertes'].map(t => ({ text: t, options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 }, })), ...regTableData, ], { x: 0.3, y: 0.75, w: 9.4, fontSize: 9, border: { type: 'solid', color: '1E3A5F' }, colW: [2.0, 1.2, 1.2, 1.8, 1.5, 1.7] }); addFooter(s5); const filename = `Marches_RLA_Zone_Sud_${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, PageBreak, Header, Footer, ImageRun, } = require('docx'); const allRows = await getMarches(); const filtered = applyFilters(allRows, req); const actifs = filtered.filter(r => !isCloture(r)); const clotures = filtered.filter(r => isCloture(r)); const today = new Date().toLocaleDateString('fr-FR'); const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur']; const parseN = v => { const n = parseFloat(String(v||'').replace(/\s/g,'').replace(',','.')); return isNaN(n)?0:n; }; const fmtPct = v => { const n = parseN(v); return n===0?'0 %':`${n.toFixed(0)} %`; }; const fmtMDT = v => { const n = parseN(v); if(!n) return '—'; if(n>=1e6) return `${(n/1e6).toFixed(1)} MDT`; return `${(n/1e3).toFixed(0)} kDT`; }; const selVal = v => { if(!v) return ''; if(typeof v==='object'&&v.value!==undefined) return String(v.value); return String(v); }; const parseDt = d => { if(!d) return null; const p=String(d).split(/[\/\-]/); if(p.length===3){const[a,b,c]=p; if(a.length===4) return new Date(`${a}-${b}-${c}`); if(c.length===4) return new Date(`${c}-${b}-${a}`);} const dt=new Date(d); return isNaN(dt.getTime())?null:dt; }; const fmtDate = d => { const dt=parseDt(d); if(!dt) return '—'; return dt.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'}); }; const totalBudget = actifs.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0); const phyList = actifs.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0); const avgPhy = phyList.length ? phyList.reduce((a,b)=>a+b,0)/phyList.length : 0; const SEUIL_STD = parseFloat(process.env.SEUIL_STANDARD||70); const SEUIL_MOD = parseFloat(process.env.SEUIL_MODERNISATION||50); const classify = r => { const t = parseN(r.taux_phy||r.avt_phy); const s = String(selVal(r.nature)||'').toLowerCase().includes('modern') ? SEUIL_MOD : SEUIL_STD; if(!t) return 'Non déterminé'; if(t>=SEUIL_CRITIQUE_PCT) return 'Dépassement'; if(t>=s) return 'Normal'; return 'Sous Avancement'; }; // Helpers const navyFill = { fill: '002D62' }; const altFill = { fill: 'F1F5F9' }; const hdr = (texts, opts={}) => new Paragraph({ children: texts, ...opts }); const tr = (cells, isHeader=false) => new TableRow({ tableHeader: isHeader, children: cells.map(([text, width, shade]) => new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: String(text??'—'), bold: isHeader, color: isHeader?'FFFFFF':'1E293B', size: 18 })], alignment: AlignmentType.LEFT, })], width: { size: width||1000, type: WidthType.DXA }, shading: shade || (isHeader ? navyFill : undefined), })), }); const h1 = text => new Paragraph({ children: [new TextRun({ text, bold: true, color: '002D62', size: 32 })], spacing: { before: 300, after: 150 }, border: { bottom: { style: 'single', size: 8, color: '00D4FF' } }, }); const h2 = text => new Paragraph({ children: [new TextRun({ text, bold: true, color: '0F172A', size: 24 })], spacing: { before: 200, after: 100 }, }); const spacer = () => new Paragraph({ text: '', spacing: { before: 80, after: 80 } }); const children = []; // ── Page de couverture children.push(new Paragraph({ children: [new TextRun({ text: '', break: 4 })] })); children.push(new Paragraph({ children: [new TextRun({ text: 'TUNISIE TELECOM', bold: true, color: '002D62', size: 36, allCaps: true })], alignment: AlignmentType.CENTER, spacing: { before: 100, after: 50 }, })); children.push(new Paragraph({ children: [new TextRun({ text: 'Direction Centrale', color: '64748B', size: 24 })], alignment: AlignmentType.CENTER, })); children.push(new Paragraph({ children: [new TextRun({ text: 'Zone Sud', color: '64748B', size: 24 })], alignment: AlignmentType.CENTER, spacing: { after: 200 }, })); children.push(new Paragraph({ children: [new TextRun({ text: 'Rapport de Suivi des Marchés RLA', bold: true, color: '002D62', size: 44 })], alignment: AlignmentType.CENTER, spacing: { before: 100, after: 80 }, })); children.push(new Paragraph({ children: [new TextRun({ text: 'Situation Actuelle & Analyse Prospective', color: '0F172A', size: 26 })], alignment: AlignmentType.CENTER, spacing: { after: 200 }, })); children.push(new Paragraph({ children: [new TextRun({ text: `📅 ${today} 📋 ${actifs.length} marchés 💰 Budget total : ${fmtMDT(totalBudget)}`, color: '475569', size: 20 })], alignment: AlignmentType.CENTER, spacing: { after: 100 }, })); children.push(new Paragraph({ children: [new PageBreak()] })); // ── Sommaire exécutif children.push(h1('Sommaire Exécutif')); children.push(new Paragraph({ children: [new TextRun({ text: `Ce rapport présente la situation au ${today} des ${actifs.length} marchés actifs de la Zone Sud de Tunisie Telecom. Budget global engagé : ${fmtMDT(totalBudget)}. Avancement physique moyen : ${fmtPct(avgPhy)}.`, size: 20, color: '374151' })], spacing: { after: 120 }, })); // KPI table const normal = actifs.filter(r=>classify(r)==='Normal').length; const sous = actifs.filter(r=>classify(r)==='Sous Avancement').length; const dep = actifs.filter(r=>classify(r)==='Dépassement').length; const nd = actifs.filter(r=>classify(r)==='Non déterminé').length; children.push(new Paragraph({ children: [new TextRun({ text: 'Indicateurs Clés', bold: true, size: 22, color: '002D62' })], spacing: { before: 150, after: 80 } })); children.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [ tr([['Indicateur',2500,navyFill],['Valeur',2000,navyFill]], true), tr([['Total marchés actifs', 2500], [String(actifs.length), 2000]]), tr([['Marchés clôturés', 2500], [String(clotures.length), 2000]], false), tr([['Budget total', 2500], [fmtMDT(totalBudget), 2000]], false), tr([['Avancement physique moyen', 2500], [fmtPct(avgPhy), 2000]], false), tr([['Normal', 2500], [String(normal), 2000]], false), tr([['Sous Avancement', 2500], [String(sous), 2000]], false), tr([['Dépassement', 2500], [String(dep), 2000]], false), ], })); children.push(new Paragraph({ children: [new PageBreak()] })); // ── Alertes children.push(h1('Alertes Délais')); const alerteItems = actifs .map(r => ({ ...r, _d: (() => { const v=parseInt(String(r.delai_restant||''),10); return !isNaN(v)?v:null; })() })) .filter(r => r._d !== null && r._d <= DELAI_ATTENTION) .sort((a, b) => a._d - b._d); children.push(new Paragraph({ children: [new TextRun({ text: `${alerteItems.filter(r=>r._d<=DELAI_CRITIQUE).length} marché(s) en alerte critique (< ${DELAI_CRITIQUE}j) • ${alerteItems.length} total (< ${DELAI_ATTENTION}j)`, size: 20, color: 'EF4444', bold: true })], spacing: { after: 100 }, })); if (alerteItems.length) { children.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [ tr([['Référence',2500,navyFill],['Projet',3000,navyFill],['Région',1200,navyFill],['Délai (j)',1200,navyFill]], true), ...alerteItems.slice(0,20).map((r, i) => tr([ [buildRef(r), 2500, i%2===1?altFill:undefined], [r.projet||'', 3000, i%2===1?altFill:undefined], [r.region||'', 1200, i%2===1?altFill:undefined], [String(r._d||'—'), 1200, i%2===1?altFill:undefined], ])), ], })); } children.push(new Paragraph({ children: [new PageBreak()] })); // ── Synthèse par région children.push(h1('Synthèse par Région')); for (const region of ALL_REGIONS) { const regActifs = actifs.filter(r => (r.region||'') === region); if (!regActifs.length) continue; children.push(h2(`📍 ${region}`)); const bud = regActifs.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0); const pl = regActifs.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0); const pm = pl.length ? pl.reduce((a,b)=>a+b,0)/pl.length : 0; children.push(new Paragraph({ children: [new TextRun({ text: `${regActifs.length} marchés • Budget : ${fmtMDT(bud)} • Phy moy : ${fmtPct(pm)}`, size: 18, color: '64748B' })], spacing: { after: 80 }, })); children.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [ tr([['Référence',2500,navyFill],['Projet',2800,navyFill],['Entrepreneur',2000,navyFill],['Phy %',700,navyFill],['Statut',1000,navyFill]], true), ...regActifs.map((r, i) => { const phy = parseN(r.taux_phy||r.avt_phy); return tr([ [buildRef(r), 2500, i%2===1?altFill:undefined], [r.projet||'', 2800, i%2===1?altFill:undefined], [r.entrepreneur||'', 2000, i%2===1?altFill:undefined], [fmtPct(phy), 700, i%2===1?altFill:undefined], [selVal(r.observation)||'—', 1000, i%2===1?altFill:undefined], ]); }), ], })); children.push(spacer()); } children.push(new Paragraph({ children: [new PageBreak()] })); // ── Pilotage proactif children.push(h1('Pilotage Proactif')); children.push(new Paragraph({ children: [new TextRun({ text: `Normal: ${normal} • Sous Avancement: ${sous} • Dépassement: ${dep} • Non déterminé: ${nd}`, size: 20, color: '374151' })], spacing: { after: 100 }, })); const pilotItems = actifs.map(r => ({ r, res: classify(r), phy: parseN(r.taux_phy||r.avt_phy) })) .sort((a,b) => a.phy - b.phy); children.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows: [ tr([['Référence',2200,navyFill],['Projet',2500,navyFill],['Région',1000,navyFill],['Entrepreneur',1800,navyFill],['Phy %',700,navyFill],['Résultat',1300,navyFill]], true), ...pilotItems.map(({ r, res, phy }, i) => tr([ [buildRef(r), 2200, i%2===1?altFill:undefined], [r.projet||'', 2500, i%2===1?altFill:undefined], [r.region||'', 1000, i%2===1?altFill:undefined], [r.entrepreneur||'', 1800, i%2===1?altFill:undefined], [fmtPct(phy), 700, i%2===1?altFill:undefined], [res, 1300, i%2===1?altFill:undefined], ])), ], })); const doc = new Document({ creator: 'RLA API', description: 'Rapport Marchés Tunisie Telecom Zone Sud', title: 'Rapport RLA Zone Sud', sections: [{ children }], }); const buf = await Packer.toBuffer(doc); const filename = `Rapport_RLA_Zone_Sud_${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;