diff --git a/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_01_2026 - Lecture seule.pptx b/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_01_2026 - Lecture seule.pptx deleted file mode 100644 index d3b076f..0000000 Binary files a/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_01_2026 - Lecture seule.pptx and /dev/null differ diff --git a/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_02_2026.xlsx b/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_02_2026.xlsx deleted file mode 100644 index 6dc13d3..0000000 Binary files a/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_02_2026.xlsx and /dev/null differ diff --git a/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_03_2026.pptx b/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_03_2026.pptx new file mode 100644 index 0000000..314785d Binary files /dev/null and b/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_03_2026.pptx differ diff --git a/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_03_2026.xlsx b/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_03_2026.xlsx new file mode 100644 index 0000000..b3e057c Binary files /dev/null and b/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_03_2026.xlsx differ diff --git a/Fichiers-cibles/Rapport_RLA_2025_Zone_Sud_02_2026.docx b/Fichiers-cibles/Rapport_RLA_2025_Zone_Sud_02_2026.docx deleted file mode 100644 index bf9cb46..0000000 Binary files a/Fichiers-cibles/Rapport_RLA_2025_Zone_Sud_02_2026.docx and /dev/null differ diff --git a/Fichiers-cibles/Rapport_RLA_2025_Zone_Sud_03_2026.docx b/Fichiers-cibles/Rapport_RLA_2025_Zone_Sud_03_2026.docx new file mode 100644 index 0000000..737d593 Binary files /dev/null and b/Fichiers-cibles/Rapport_RLA_2025_Zone_Sud_03_2026.docx differ diff --git a/routes/export.js b/routes/export.js index 124faa4..b0d8339 100644 --- a/routes/export.js +++ b/routes/export.js @@ -18,6 +18,8 @@ const { const pdfGen = require('../services/export-pdf'); const { generateXlsx } = require('../services/export-xlsx'); +const { generatePptx } = require('../services/export-pptx'); +const { generateDocx } = require('../services/export-docx'); // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -186,226 +188,13 @@ router.get('/pptx', async (req, res) => { 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 allRows = await getMarches(); + const filtered = applyFilters(allRows, req); + const actifs = filtered.filter(r => !isCloture(r)); + const clotures = filtered.filter(r => isCloture(r)); const filename = `Marches_RLA_Zone_Sud_${new Date().toISOString().slice(0,10)}.pptx`; - const buf = await pptx.write({ outputType: 'nodebuffer' }); + const buf = await generatePptx(actifs, clotures, filtered); res.set({ 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'Content-Disposition': `attachment; filename="${filename}"`, @@ -423,221 +212,14 @@ router.get('/docx', async (req, res) => { 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 { getPipeline } = require('../services/baserow'); + const allRows = await getMarches(); + const pipelineRows = await getPipeline(); + const filtered = applyFilters(allRows, req); + const actifs = filtered.filter(r => !isCloture(r)); + const clotures = filtered.filter(r => isCloture(r)); - 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 buf = await generateDocx({ actifs, clotures, filtered, pipelineRows }); const filename = `Rapport_RLA_Zone_Sud_${new Date().toISOString().slice(0,10)}.docx`; res.set({ 'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', diff --git a/services/export-docx.js b/services/export-docx.js new file mode 100644 index 0000000..b88e84a --- /dev/null +++ b/services/export-docx.js @@ -0,0 +1,661 @@ +/** + * services/export-docx.js + * DOCX conforme au fichier cible Rapport_RLA_2025_Zone_Sud.docx + * Structure : Couverture, TOC, Sommaire Exécutif, Synthèse, Alertes, + * Modernisation, Synthèse/Région, Estimation, Matrice, Recommandations + */ +const { + Document, Packer, Paragraph, Table, TableRow, TableCell, + TextRun, HeadingLevel, AlignmentType, WidthType, PageBreak, + TableOfContents, ShadingType, BorderStyle, convertInchesToTwip, +} = require('docx'); +const { + parseNum, selectVal, parseDateFR, formatDateFR, formatMontant, buildRef, + getDelaiRestant, niveauAlerte, DELAI_CRITIQUE, DELAI_ATTENTION, + SEUIL_STANDARD, SEUIL_MODERNISATION, SEUIL_CRITIQUE_PCT, +} = require('./calc'); + +const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur']; + +const REGION_HEX = { + Gabes:'0891B2', Gafsa:'059669', Kebili:'7C3AED', + Medenine:'2563EB', Sfax:'0B2A55', Tataouine:'0D9488', Tozeur:'6366F1', +}; + +// ── Helpers visuels ───────────────────────────────────────────────────────────── + +function fmtPct(v) { + const n = parseNum(v); + return n === 0 ? '0 %' : `${n.toFixed(0)} %`; +} + +function fmtMDT(v) { + const n = parseNum(v); + if (!n) return '—'; + if (n >= 1e6) return `${(n / 1e6).toFixed(3)} MDT`; + if (n >= 1e3) return `${(n / 1e3).toFixed(0)} kDT`; + return `${n.toFixed(0)} DT`; +} + +function periode(r) { + const d = formatDateFR(r.date_debut || r.debut_marche); + const f = formatDateFR(r.date_fin || r.date_fin_marche); + if (d === '—' && f === '—') return '—'; + return `${d} → ${f}`; +} + +function elapsedMonths(dateDebut) { + const d = parseDateFR(dateDebut); + if (!d) return 0; + const now = new Date(); + return Math.max(0, (now.getFullYear() - d.getFullYear()) * 12 + (now.getMonth() - d.getMonth())); +} + +function totalMonths(dateDebut, dateFin) { + const d = parseDateFR(dateDebut); + const f = parseDateFR(dateFin); + if (!d || !f) return 0; + return Math.max(1, (f.getFullYear() - d.getFullYear()) * 12 + (f.getMonth() - d.getMonth())); +} + +function projection(r) { + const marche = parseNum(r.tot_marche || r.totmarche || r.montant); + const minDT = marche * (SEUIL_STANDARD / 100); + const consomme = parseNum(r.avt_fin); + const elapsed = elapsedMonths(r.date_debut || r.debut_marche); + const total = totalMonths(r.date_debut || r.debut_marche, r.date_fin || r.date_fin_marche); + const parMois = elapsed > 0 ? consomme / elapsed : 0; + const projete = parMois * total; + let verdict; + if (projete > marche * 1.03) verdict = 'Avenant'; + else if (projete >= minDT) verdict = 'Normal'; + else verdict = 'Sous Min'; + return { marche, minDT, consomme, parMois, projete, verdict }; +} + +// ── Constructeurs DOCX ────────────────────────────────────────────────────────── + +const NAVY = '0B2A55'; +const ALT = 'F1F5F9'; +const WH = 'FFFFFF'; + +function cellShade(hex) { + return { fill: hex, type: ShadingType.SOLID, color: hex }; +} + +function hdrCell(text, width, hexBg = NAVY) { + return new TableCell({ + children: [new Paragraph({ + children: [new TextRun({ text: String(text), bold: true, color: WH, size: 18 })], + alignment: AlignmentType.CENTER, + })], + width: { size: width, type: WidthType.DXA }, + shading: cellShade(hexBg), + margins: { top: 60, bottom: 60, left: 80, right: 80 }, + }); +} + +function dataCell(text, width, shade, align = AlignmentType.LEFT, bold = false, color = '1E293B') { + return new TableCell({ + children: [new Paragraph({ + children: [new TextRun({ text: String(text ?? '—'), size: 17, color, bold })], + alignment: align, + })], + width: { size: width, type: WidthType.DXA }, + shading: shade ? cellShade(shade) : undefined, + margins: { top: 50, bottom: 50, left: 80, right: 80 }, + }); +} + +function hdrRow(cols, hexBg = NAVY) { + return new TableRow({ + tableHeader: true, + children: cols.map(([text, w]) => hdrCell(text, w, hexBg)), + }); +} + +function dataRow(cells, isAlt = false) { + const shade = isAlt ? ALT : undefined; + return new TableRow({ + children: cells.map(([text, w, align, bold, color]) => + dataCell(text, w, shade, align || AlignmentType.LEFT, bold || false, color || '1E293B')), + }); +} + +function bannerRow(text, hexBg) { + return new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ + children: [new Paragraph({ + children: [new TextRun({ text, bold: true, color: WH, size: 22 })], + alignment: AlignmentType.CENTER, + })], + shading: cellShade(hexBg), + columnSpan: 1, + margins: { top: 100, bottom: 100 }, + }), + ], + }), + ], + borders: { top: { style: BorderStyle.NONE }, bottom: { style: BorderStyle.NONE }, left: { style: BorderStyle.NONE }, right: { style: BorderStyle.NONE } }, + }); +} + +function h1(text) { + return new Paragraph({ + heading: HeadingLevel.HEADING_1, + children: [new TextRun({ text, bold: true, color: NAVY, size: 36 })], + spacing: { before: 400, after: 160 }, + border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: '0680C3' } }, + }); +} + +function h2(text, hexColor = NAVY) { + return new Paragraph({ + heading: HeadingLevel.HEADING_2, + children: [new TextRun({ text, bold: true, color: hexColor, size: 26 })], + spacing: { before: 280, after: 100 }, + }); +} + +function para(text, size = 20, color = '374151') { + return new Paragraph({ + children: [new TextRun({ text, size, color })], + spacing: { after: 120 }, + }); +} + +function spacer() { + return new Paragraph({ text: '', spacing: { before: 80, after: 80 } }); +} + +// ── Export principal ──────────────────────────────────────────────────────────── + +async function generateDocx({ actifs, clotures, filtered, pipelineRows }) { + const today = new Date().toLocaleDateString('fr-FR'); + const totalBudget = actifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0); + const phyList = actifs.map(r => parseNum(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 finList = actifs.map(r => parseNum(r.taux_fin)).filter(v => v > 0); + const avgFin = finList.length ? finList.reduce((a, b) => a + b, 0) / finList.length : 0; + + const capexRows = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('CAPEX')); + const opexRows = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('OPEX')); + + const alertes = actifs + .map(r => ({ ...r, _d: getDelaiRestant(r) })) + .filter(r => r._d !== null && r._d <= DELAI_ATTENTION) + .sort((a, b) => a._d - b._d); + + const modActifs = actifs.filter(r => + String(selectVal(r.nature)).toLowerCase().includes('moderni')); + + const pipeline = Array.isArray(pipelineRows) ? pipelineRows : []; + + const children = []; + + // ── Couverture ──────────────────────────────────────────────────────────────── + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ + children: [ + new Paragraph({ children: [new TextRun({ text: 'TUNISIE TELECOM', bold: true, color: WH, size: 28 })], alignment: AlignmentType.CENTER }), + new Paragraph({ children: [new TextRun({ text: 'Direction Centrale', color: 'CBD5E1', size: 20 })], alignment: AlignmentType.CENTER }), + new Paragraph({ children: [new TextRun({ text: 'Zone Sud', color: 'CBD5E1', size: 20 })], alignment: AlignmentType.CENTER }), + ], + shading: cellShade(NAVY), + margins: { top: 200, bottom: 200, left: 300, right: 300 }, + }), + ], + }), + ], + borders: { top: { style: BorderStyle.NONE }, bottom: { style: BorderStyle.NONE }, left: { style: BorderStyle.NONE }, right: { style: BorderStyle.NONE } }, + })); + children.push(spacer()); + children.push(new Paragraph({ + children: [new TextRun({ text: 'Rapport de Suivi des Marchés RLA', bold: true, color: NAVY, size: 52 })], + alignment: AlignmentType.CENTER, + spacing: { before: 200, after: 100 }, + })); + children.push(new Paragraph({ + children: [new TextRun({ text: 'Situation Actuelle & Analyse Prospective', color: '1E40AF', size: 32 })], + alignment: AlignmentType.CENTER, + spacing: { after: 200 }, + })); + children.push(bannerRow('', '1E40AF')); + children.push(spacer()); + children.push(new Paragraph({ + children: [ + new TextRun({ text: `📅 ${today}`, size: 24, color: NAVY }), + new TextRun({ text: ` 📊 Données au ${today}`, size: 24, bold: true, color: '475569' }), + ], + alignment: AlignmentType.CENTER, + spacing: { after: 80 }, + })); + children.push(new Paragraph({ + children: [ + new TextRun({ text: `📋 ${actifs.length} marchés`, size: 24, color: NAVY }), + new TextRun({ text: ` 💰 Budget total : ${fmtMDT(totalBudget)}`, size: 24, bold: true, color: '475569' }), + ], + alignment: AlignmentType.CENTER, + spacing: { after: 200 }, + })); + children.push(new Paragraph({ children: [new TextRun({ text: 'Nabil Derouiche', bold: true, color: NAVY, size: 28 })], alignment: AlignmentType.CENTER })); + children.push(new Paragraph({ children: [new TextRun({ text: 'Responsable Achats Zone Sud', color: '475569', size: 20 })], alignment: AlignmentType.CENTER })); + children.push(new Paragraph({ children: [new TextRun({ text: 'Tunisie Telecom', color: '94A3B8', size: 20 })], alignment: AlignmentType.CENTER, spacing: { after: 300 } })); + children.push(new Paragraph({ children: [new PageBreak()] })); + + // ── Table des Matières ──────────────────────────────────────────────────────── + children.push(new Paragraph({ + children: [new TextRun({ text: 'Table des Matières', bold: true, color: NAVY, size: 44 })], + spacing: { before: 100, after: 100 }, + })); + children.push(new Paragraph({ + children: [new TextRun({ text: 'ℹ️ Mettre à jour : clic droit → Mettre à jour les champs', size: 18, color: '94A3B8' })], + spacing: { after: 200 }, + })); + children.push(new TableOfContents('Table des Matières', { + hyperlink: true, + headingStyleRange: '1-3', + })); + children.push(new Paragraph({ children: [new PageBreak()] })); + + // ── Sommaire Exécutif ───────────────────────────────────────────────────────── + children.push(h1('Sommaire Exécutif')); + children.push(bannerRow('', '1E40AF')); + children.push(spacer()); + const capexBudget = capexRows.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0); + const opexBudget = opexRows.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0); + + children.push(para( + `La Zone Sud gère actuellement ${actifs.length} marchés RLA pour un budget global de ${totalBudget.toLocaleString('fr-FR', { minimumFractionDigits: 3 })} DT, ` + + `répartis en ${capexRows.length} marchés CAPEX (${fmtMDT(capexBudget)}) et ${opexRows.length} marchés OPEX (${fmtMDT(opexBudget)}).` + )); + children.push(para( + `Sur le plan des alertes, ${alertes.filter(r => r._d <= DELAI_CRITIQUE).length} marché(s) sont en situation critique (délai ≤ ${DELAI_CRITIQUE} jours) ` + + `et ${alertes.length - alertes.filter(r => r._d <= DELAI_CRITIQUE).length} marché(s) nécessitent une attention renforcée.` + )); + const regsSansModerni = ALL_REGIONS.filter(reg => !modActifs.some(r => (r.region || '') === reg)); + if (regsSansModerni.length) { + children.push(para( + `Concernant la Modernisation, ${regsSansModerni.length} région(s) ne disposent d'aucun marché actif (${regsSansModerni.join(', ')}). ` + + `Conformément à la politique TT, un lancement immédiat est requis pour ces régions.` + )); + } + const projs = actifs.map(r => projection(r)); + children.push(para( + `L'analyse prospective révèle ${projs.filter(p => p.verdict === 'Sous Min').length} marché(s) projetés sous le Montant Minimum ` + + `et ${projs.filter(p => p.verdict === 'Avenant').length} en situation de dépassement nécessitant un avenant.` + )); + + // ── Synthèse des Indicateurs ────────────────────────────────────────────────── + children.push(h1('Synthèse des Indicateurs')); + + const capexPhy = (() => { const v = capexRows.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(x => x > 0); return v.length ? v.reduce((a, b) => a + b) / v.length : 0; })(); + const opexPhy = (() => { const v = opexRows.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(x => x > 0); return v.length ? v.reduce((a, b) => a + b) / v.length : 0; })(); + + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + hdrRow([['Indicateur',3000],['GLOBAL',1800],['CAPEX',1800],['OPEX',1800]], NAVY), + new TableRow({ children: [hdrCell('Indicateur',3000,NAVY), hdrCell('GLOBAL',1800,'0B2A55'), hdrCell('CAPEX',1800,'059669'), hdrCell('OPEX',1800,'D97706')] }), + dataRow([['Marchés actifs',3000], [actifs.length,1800,AlignmentType.CENTER], [capexRows.length,1800,AlignmentType.CENTER], [opexRows.length,1800,AlignmentType.CENTER]]), + dataRow([['Budget total',3000], [fmtMDT(totalBudget),1800,AlignmentType.CENTER], [fmtMDT(capexBudget),1800,AlignmentType.CENTER], [fmtMDT(opexBudget),1800,AlignmentType.CENTER]], true), + dataRow([['Avancement physique',3000],[fmtPct(avgPhy),1800,AlignmentType.CENTER], [fmtPct(capexPhy),1800,AlignmentType.CENTER], [fmtPct(opexPhy),1800,AlignmentType.CENTER]]), + dataRow([['Avancement financier',3000],[fmtPct(avgFin),1800,AlignmentType.CENTER], ['—',1800,AlignmentType.CENTER], ['—',1800,AlignmentType.CENTER]], true), + dataRow([['Alertes (total)',3000], [alertes.length,1800,AlignmentType.CENTER], ['—',1800,AlignmentType.CENTER], ['—',1800,AlignmentType.CENTER]]), + dataRow([['Critiques (≤45j)',3000], [alertes.filter(a=>a._d<=DELAI_CRITIQUE).length,1800,AlignmentType.CENTER,true,'DC2626'], ['—',1800,AlignmentType.CENTER], ['—',1800,AlignmentType.CENTER]], true), + ], + })); + children.push(spacer()); + children.push(new Paragraph({ children: [new PageBreak()] })); + + // ── Alertes ─────────────────────────────────────────────────────────────────── + children.push(h1('Alertes')); + children.push(para(`${alertes.length} marché(s) nécessitent une attention immédiate : ${alertes.filter(a=>a._d<=DELAI_CRITIQUE).length} critiques et ${alertes.filter(a=>a._d>DELAI_CRITIQUE).length} en surveillance renforcée.`, 20, '475569')); + + if (alertes.length) { + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + hdrRow([['Référence',2800],['Projet',2400],['Entrepreneur',2000],['Région',1200],['Av. Phy',900],['Délai',800],['Niveau',1300]]), + ...alertes.map((r, i) => { + const al = niveauAlerte(r._d); + const alLabel = { critique: 'CRITIQUE', attention: 'ATTENTION' }[al] || String(al || '').toUpperCase(); + const alColor = al === 'critique' ? 'DC2626' : al === 'attention' ? 'EA580C' : '059669'; + return dataRow([ + [buildRef(r), 2800], + [r.projet || '', 2400], + [r.entrepreneur || '', 2000], + [r.region || '', 1200, AlignmentType.CENTER], + [fmtPct(r.taux_phy || r.avt_phy), 900, AlignmentType.CENTER], + [r._d !== null ? r._d + ' j' : '—', 800, AlignmentType.CENTER, true, alColor], + [alLabel, 1300, AlignmentType.CENTER, true, alColor], + ], i % 2 === 1); + }), + ], + })); + } + children.push(new Paragraph({ children: [new PageBreak()] })); + + // ── Modernisation ───────────────────────────────────────────────────────────── + children.push(h1('Modernisation')); + children.push(para(`Seuil : avancement physique ≥ ${SEUIL_MODERNISATION}% — la région devra procéder au lancement d'un nouveau marché.`, 20, '475569')); + + const modEnSeuil = modActifs.filter(r => parseNum(r.taux_phy || r.avt_phy) >= SEUIL_MODERNISATION); + children.push(h2(`⚡ Marchés Modernisation ≥ ${SEUIL_MODERNISATION}%`, 'D97706')); + if (modEnSeuil.length) { + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + hdrRow([['Référence',2800],['Entrepreneur',2200],['Région',1400],['Av. Phy',900],['Délai',800],['Action',3100]], 'D97706'), + ...modEnSeuil.map((r, i) => { + const phy = parseNum(r.taux_phy || r.avt_phy); + const d = getDelaiRestant(r); + return dataRow([ + [buildRef(r), 2800], + [r.entrepreneur||'', 2200], + [r.region||'', 1400, AlignmentType.CENTER], + [fmtPct(phy), 900, AlignmentType.CENTER, true, phy>=SEUIL_CRITIQUE_PCT?'DC2626':'D97706'], + [d!==null?d+' j':'—', 800, AlignmentType.CENTER], + ['Lancer nouveau marché', 3100, AlignmentType.LEFT, true, 'D97706'], + ], i % 2 === 1); + }), + ], + })); + } else { + children.push(para('Aucun marché modernisation au seuil de déclenchement.', 20, '475569')); + } + children.push(spacer()); + + children.push(h2('🔴 Régions sans marché Modernisation actif', 'DC2626')); + if (regsSansModerni.length) { + for (const reg of regsSansModerni) { + children.push(new Paragraph({ + children: [new TextRun({ text: reg, bold: true, color: 'DC2626', size: 24 })], + spacing: { after: 60 }, + })); + } + children.push(para('→ Lancement immédiat d\'un nouveau marché Modernisation requis pour chacune de ces régions.', 20, 'DC2626')); + } else { + children.push(para('Toutes les régions disposent d\'un marché Modernisation actif.', 20, '059669')); + } + children.push(new Paragraph({ children: [new PageBreak()] })); + + // ── Synthèse par Région ─────────────────────────────────────────────────────── + children.push(h1('Synthèse par Région')); + children.push(para('Vue consolidée des 7 régions de la Zone Sud.', 20, '475569')); + + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + hdrRow([['Région',1800],['Marchés',1000],['Budget',2000],['Av. Physique',1500],['Av. Financier',1500],['Risque',1600]]), + ...ALL_REGIONS.map((reg, i) => { + const ra = actifs.filter(r => (r.region || '') === reg); + const bud = ra.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0); + const pl = ra.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0); + const pm = pl.length ? pl.reduce((a, b) => a + b) / pl.length : 0; + const fl = ra.map(r => parseNum(r.taux_fin)).filter(v => v > 0); + const fm = fl.length ? fl.reduce((a, b) => a + b) / fl.length : 0; + const al = ra.filter(r => { const d = getDelaiRestant(r); return d !== null && d <= DELAI_CRITIQUE; }); + const risque = al.length > 0 ? 'CRITIQUE' : pm < SEUIL_STANDARD ? 'ATTENTION' : 'NORMAL'; + const rColor = risque === 'CRITIQUE' ? 'DC2626' : risque === 'ATTENTION' ? 'D97706' : '059669'; + return dataRow([ + [reg, 1800, AlignmentType.LEFT, true, REGION_HEX[reg]||NAVY], + [ra.length, 1000, AlignmentType.CENTER], + [fmtMDT(bud), 2000, AlignmentType.CENTER], + [fmtPct(pm), 1500, AlignmentType.CENTER, true, pm >= SEUIL_STANDARD ? '059669' : 'DC2626'], + [fmtPct(fm), 1500, AlignmentType.CENTER], + [risque, 1600, AlignmentType.CENTER, true, rColor], + ], i % 2 === 1); + }), + ], + })); + children.push(spacer()); + + for (const region of ALL_REGIONS) { + const regActifs = actifs.filter(r => (r.region || '') === region); + if (!regActifs.length) continue; + + const hexReg = REGION_HEX[region] || NAVY; + const bud = regActifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0); + const pl = regActifs.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0); + const pm = pl.length ? pl.reduce((a, b) => a + b) / pl.length : 0; + const fl = regActifs.map(r => parseNum(r.taux_fin)).filter(v => v > 0); + const fm = fl.length ? fl.reduce((a, b) => a + b) / fl.length : 0; + + children.push(h1(`📍 ${region}`)); + children.push(bannerRow(`${regActifs.length} marchés • Phy ${pm.toFixed(0)}% • Fin ${fm.toFixed(0)}%`, hexReg)); + + children.push(para( + `La région ${region} compte ${regActifs.length} marché(s) pour un budget total de ${bud.toLocaleString('fr-FR', { minimumFractionDigits: 3 })} DT. ` + + `L'avancement physique moyen est de ${pm.toFixed(0)}% et l'avancement financier de ${fm.toFixed(0)}%.` + )); + + const enService = regActifs.filter(r => + String(selectVal(r.observation)).toLowerCase().includes('en service')); + const enCours = regActifs.filter(r => + !String(selectVal(r.observation)).toLowerCase().includes('en service')); + + if (enService.length) { + children.push(h2('✅ Marchés en service', '059669')); + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + hdrRow([['Référence',2600],['Projet',2200],['Entrepreneur',1800],['Période',1800],['Montant',1200],['Phy',700],['Fin',700],['Délai',700]], hexReg), + ...enService.map((r, i) => { + const phy = parseNum(r.taux_phy || r.avt_phy); + const fin = parseNum(r.taux_fin); + const d = getDelaiRestant(r); + return dataRow([ + [buildRef(r), 2600], + [r.projet||'', 2200], + [r.entrepreneur||'', 1800], + [periode(r), 1800], + [fmtMDT(r.tot_marche||r.montant), 1200, AlignmentType.CENTER], + [fmtPct(phy), 700, AlignmentType.CENTER], + [fmtPct(fin), 700, AlignmentType.CENTER], + [d!==null?d+' j':'—', 700, AlignmentType.CENTER], + ], i % 2 === 1); + }), + ], + })); + children.push(spacer()); + } + + if (enCours.length) { + children.push(h2('⏳ Marchés en cours')); + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + hdrRow([['Référence',3000],['Projet',2500],['Entrepreneur',2000],['Observation',3900]], '1E40AF'), + ...enCours.map((r, i) => dataRow([ + [buildRef(r), 3000], + [r.projet||'', 2500], + [r.entrepreneur||'', 2000], + [selectVal(r.observation)||'—', 3900], + ], i % 2 === 1)), + ], + })); + children.push(spacer()); + } + } + children.push(new Paragraph({ children: [new PageBreak()] })); + + // ── Estimation & Projection ─────────────────────────────────────────────────── + children.push(h1('Estimation & Projection')); + children.push(para('Projection fin PO basée sur le taux de consommation mensuel. Modernisation exemptée d\'avenant.', 20, '475569')); + + const projRows = actifs.map(r => ({ r, p: projection(r) })); + const verdictColors = { Normal: '059669', 'Sous Min': 'DC2626', Avenant: 'D97706' }; + + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + hdrRow([['Référence',2600],['Projet',2000],['Marché DT',1200],['Min DT',1100],['Consommé',1100],['DT/Mois',1100],['Projeté',1100],['Verdict',1100]], '6366F1'), + ...projRows.map(({ r, p }, i) => dataRow([ + [buildRef(r), 2600], + [r.projet||'', 2000], + [fmtMDT(p.marche), 1200, AlignmentType.CENTER], + [fmtMDT(p.minDT), 1100, AlignmentType.CENTER], + [fmtMDT(p.consomme), 1100, AlignmentType.CENTER], + [fmtMDT(p.parMois), 1100, AlignmentType.CENTER], + [fmtMDT(p.projete), 1100, AlignmentType.CENTER], + [p.verdict, 1100, AlignmentType.CENTER, true, verdictColors[p.verdict]||'1E293B'], + ], i % 2 === 1)), + ], + })); + + const nNormal = projRows.filter(x => x.p.verdict === 'Normal').length; + const nSous = projRows.filter(x => x.p.verdict === 'Sous Min').length; + const nAv = projRows.filter(x => x.p.verdict === 'Avenant').length; + children.push(spacer()); + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: `✅ Normal`, bold:true, color:'059669', size:20 }), new TextRun({ text: ` ${nNormal}`, bold:true, size:24 })], alignment: AlignmentType.CENTER })], width:{size:33,type:WidthType.PERCENTAGE} }), + new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: `❌ Sous Min`, bold:true, color:'DC2626', size:20 }), new TextRun({ text: ` ${nSous}`, bold:true, size:24 })], alignment: AlignmentType.CENTER })], width:{size:33,type:WidthType.PERCENTAGE} }), + new TableCell({ children: [new Paragraph({ children: [new TextRun({ text: `⚠️ Avenant`, bold:true, color:'D97706', size:20 }), new TextRun({ text: ` ${nAv}`, bold:true, size:24 })], alignment: AlignmentType.CENTER })], width:{size:34,type:WidthType.PERCENTAGE} }), + ], + }), + ], + })); + children.push(new Paragraph({ children: [new PageBreak()] })); + + // ── Matrice de Risque Globale ───────────────────────────────────────────────── + children.push(h1('Matrice de Risque Globale')); + + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + hdrRow([['Région',1200],['Marchés',800],['Budget',1400],['Phy',800],['Fin',800],['Écart',800],['Projeté',1200],['Tendance',1000],['Risque',1000]]), + ...ALL_REGIONS.map((reg, i) => { + const ra = actifs.filter(r => (r.region || '') === reg); + const bud = ra.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0); + const pl = ra.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0); + const pm = pl.length ? pl.reduce((a, b) => a + b) / pl.length : 0; + const fl = ra.map(r => parseNum(r.taux_fin)).filter(v => v > 0); + const fm = fl.length ? fl.reduce((a, b) => a + b) / fl.length : 0; + const ecart = pm - fm; + const rProjs = ra.map(r => projection(r)); + const projTotal = rProjs.reduce((s, p) => s + p.projete, 0); + const tendance = pm > fm ? '📈 Hausse phy' : pm < fm ? '📉 Hausse fin' : '➡️ Équilibre'; + const critiques = ra.filter(r => { const d = getDelaiRestant(r); return d !== null && d <= DELAI_CRITIQUE; }); + const risque = critiques.length > 0 ? 'CRITIQUE' : pm < SEUIL_STANDARD ? 'ÉLEVÉ' : 'NORMAL'; + const rc = risque === 'CRITIQUE' ? 'DC2626' : risque === 'ÉLEVÉ' ? 'D97706' : '059669'; + return dataRow([ + [reg, 1200, AlignmentType.LEFT, true, REGION_HEX[reg]||NAVY], + [ra.length, 800, AlignmentType.CENTER], + [fmtMDT(bud), 1400, AlignmentType.CENTER], + [fmtPct(pm), 800, AlignmentType.CENTER], + [fmtPct(fm), 800, AlignmentType.CENTER], + [`${ecart >= 0 ? '+' : ''}${ecart.toFixed(0)}%`, 800, AlignmentType.CENTER, false, ecart >= 0 ? '059669' : 'DC2626'], + [fmtMDT(projTotal), 1200, AlignmentType.CENTER], + [tendance, 1000, AlignmentType.CENTER], + [risque, 1000, AlignmentType.CENTER, true, rc], + ], i % 2 === 1); + }), + ], + })); + children.push(new Paragraph({ children: [new PageBreak()] })); + + // ── Recommandations ─────────────────────────────────────────────────────────── + children.push(h1('Recommandations')); + + const recs = [ + [`1. ⚡ Modernisation ≥${SEUIL_MODERNISATION}%`, `${modEnSeuil.map(r => r.region).filter((v,i,a)=>a.indexOf(v)===i).join(', ')||'Aucun'} — Procéder au lancement d'un nouveau marché pour assurer la continuité du service.`], + [`2. ❌ Sous-consommation`, `${projRows.filter(x=>x.p.verdict==='Sous Min').length} marché(s) projeté(s) sous le montant minimum. Accélérer les réceptions de travaux.`], + [`3. 📈 Dépassements`, `${projRows.filter(x=>x.p.verdict==='Avenant').length} marché(s) au-delà du montant marché. Préparer les avenants nécessaires.`], + [`4. 🚀 Régions sans Modernisation`, `${regsSansModerni.join(', ')||'Aucune'} — Lancement immédiat d'un nouveau marché Modernisation requis.`], + [`5. 📋 Suivi mensuel`, `Maintenir le reporting mensuel avec mise à jour des avancements physiques et financiers.`], + [`6. 🎯 Objectif`, `100% d'exécution avant échéance PO pour tous les marchés actifs.`], + ]; + + for (const [titre, contenu] of recs) { + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ + children: [ + new Paragraph({ children: [new TextRun({ text: titre, bold: true, color: NAVY, size: 22 })], spacing: { after: 60 } }), + new Paragraph({ children: [new TextRun({ text: contenu, color: '374151', size: 20 })] }), + ], + shading: cellShade('F8FAFC'), + margins: { top: 120, bottom: 120, left: 200, right: 200 }, + }), + ], + }), + ], + borders: { top: { style: BorderStyle.SINGLE, size: 4, color: '0680C3' }, bottom: { style: BorderStyle.NONE }, left: { style: BorderStyle.NONE }, right: { style: BorderStyle.NONE } }, + })); + children.push(spacer()); + } + + // Légende + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ + children: [ + new Paragraph({ children: [new TextRun({ text: '📖 Légende', bold: true, color: NAVY, size: 22 })], spacing: { after: 80 } }), + new Paragraph({ children: [new TextRun({ text: `Seuils avancement : 🟢 ≥ ${SEUIL_STANDARD}% Normal • 🟡 seuil Modernisation ≥ ${SEUIL_MODERNISATION}% • 🔴 CRITIQUE ≥ ${SEUIL_CRITIQUE_PCT}%`, size: 18, color: '475569' })] }), + new Paragraph({ children: [new TextRun({ text: `Délais : 🟢 > ${DELAI_ATTENTION}j • 🟡 ≤ ${DELAI_ATTENTION}j ATTENTION • 🔴 ≤ ${DELAI_CRITIQUE}j CRITIQUE`, size: 18, color: '475569' })] }), + ], + shading: cellShade('F8FAFC'), + margins: { top: 120, bottom: 120, left: 200, right: 200 }, + }), + ], + }), + ], + borders: { top: { style: BorderStyle.NONE }, bottom: { style: BorderStyle.NONE }, left: { style: BorderStyle.NONE }, right: { style: BorderStyle.NONE } }, + })); + children.push(spacer()); + + // Pied de page + children.push(new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [ + new TableRow({ + children: [ + new TableCell({ children: [ + new Paragraph({ children: [new TextRun({ text: 'Nabil Derouiche', bold:true, color:WH, size:22 })], alignment: AlignmentType.CENTER }), + new Paragraph({ children: [new TextRun({ text: 'Responsable Achats Zone Sud', color:'CBD5E1', size:18 })], alignment: AlignmentType.CENTER }), + ], shading: cellShade(NAVY), margins: { top:100, bottom:100 }, width:{size:50,type:WidthType.PERCENTAGE} }), + new TableCell({ children: [ + new Paragraph({ children: [new TextRun({ text: 'TUNISIE TELECOM', bold:true, color:WH, size:22 })], alignment: AlignmentType.CENTER }), + new Paragraph({ children: [new TextRun({ text: `Sfax — ${today}`, color:'CBD5E1', size:18 })], alignment: AlignmentType.CENTER }), + ], shading: cellShade(NAVY), margins: { top:100, bottom:100 }, width:{size:50,type:WidthType.PERCENTAGE} }), + ], + }), + ], + })); + + const doc = new Document({ + creator: 'RLA API', + description: 'Rapport de Suivi des Marchés RLA Zone Sud — Tunisie Telecom', + title: 'Rapport RLA Zone Sud', + styles: { + paragraphStyles: [ + { id: 'Heading1', name: 'Heading 1', run: { size: 36, bold: true, color: NAVY } }, + { id: 'Heading2', name: 'Heading 2', run: { size: 26, bold: true, color: NAVY } }, + ], + }, + sections: [{ children }], + }); + + return Packer.toBuffer(doc); +} + +module.exports = { generateDocx }; diff --git a/services/export-pdf.js b/services/export-pdf.js index 5ee04b6..e322cc6 100644 --- a/services/export-pdf.js +++ b/services/export-pdf.js @@ -4,18 +4,20 @@ */ const PDFDocument = require('pdfkit'); -// Palette RLA / McKinsey +// Palette RLA — conforme fichier PDF cible const C = { - primary: '#002D62', - accent: '#E31837', - success: '#10b981', - warning: '#f59e0b', - danger: '#ef4444', - muted: '#6b7280', - light: '#f8fafc', - border: '#e2e8f0', - text: '#1e293b', - white: '#ffffff', + primary: '#0B2A55', + accent: '#0680C3', + success: '#059669', + warning: '#D97706', + danger: '#DC2626', + muted: '#475569', + light: '#F8FAFC', + border: '#E2E8F0', + text: '#1E293B', + white: '#FFFFFF', + navy2: '#1E40AF', + indigo: '#6366F1', }; function hex(h) { diff --git a/services/export-pptx.js b/services/export-pptx.js new file mode 100644 index 0000000..7002e94 --- /dev/null +++ b/services/export-pptx.js @@ -0,0 +1,209 @@ +/** + * services/export-pptx.js + * PPTX conforme aux fichiers cibles : + * Slide 1 : Couverture + * Slide 2 : Alertes (header #DC2626, 8 cols, barres █/░) + * Slides 3-N : CAPEX paginé (7 cols) + * Slides N+1-M : OPEX paginé (7 cols) + * Dernière : En cours / Hors service (4 cols) + */ +const PptxGenJS = require('pptxgenjs'); +const { parseNum, selectVal, parseDateFR, formatDateFR } = require('./calc'); + +const REGION_COLORS = { + Gabes:'0891B2', Gafsa:'059669', Kebili:'7C3AED', + Medenine:'2563EB', Sfax:'0B2A55', Tataouine:'0D9488', Tozeur:'6366F1', +}; + +const DELAI_CRITIQUE = parseInt(process.env.DELAI_CRITIQUE || 45, 10); +const DELAI_ATTENTION = parseInt(process.env.DELAI_ATTENTION || 90, 10); + +function fmtMDT(v) { + const n = parseNum(v); + if (!n) return '—'; + if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MDT`; + if (n >= 1e3) return `${(n / 1e3).toFixed(0)} kDT`; + return `${n.toFixed(0)} DT`; +} + +function fmtPct(v) { + const n = parseNum(v); + return n === 0 ? '0 %' : `${n.toFixed(0)} %`; +} + +function periode(r) { + const d = formatDateFR(r.date_debut || r.debut_marche); + const f = formatDateFR(r.date_fin || r.date_fin_marche); + if (d === '—' && f === '—') return '—'; + return `${d} → ${f}`; +} + +function progBar(pctRaw, len = 14) { + const pct = Math.min(100, Math.max(0, parseNum(pctRaw))); + const n = Math.round(pct / 100 * len); + return '\u2588'.repeat(n) + '\u2591'.repeat(len - n) + ` ${pct.toFixed(0)}%`; +} + +function getDelai(r) { + const v = parseInt(String(r.delai_restant || ''), 10); + if (!isNaN(v)) return v; + const dt = parseDateFR(r.date_fin || r.date_fin_marche); + if (!dt) return null; + return Math.ceil((dt - new Date()) / 86400000); +} + +function niveauAlerte(d) { + if (d === null || d === undefined) return { label: '—', color: '94A3B8' }; + if (d <= DELAI_CRITIQUE) return { label: 'CRITIQUE', color: 'DC2626' }; + if (d <= DELAI_ATTENTION) return { label: 'ATTENTION', color: 'EA580C' }; + return { label: 'OK', color: '059669' }; +} + +function txt(text, options) { + return { text: String(text ?? '—'), options }; +} + +async function generatePptx(actifs, _clotures, filtered) { + const pptx = new PptxGenJS(); + pptx.layout = 'LAYOUT_WIDE'; + pptx.author = 'RLA API'; + pptx.company = 'Tunisie Telecom Zone Sud'; + + const today = new Date().toLocaleDateString('fr-FR'); + const totalBudget = actifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0); + + const capex = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('CAPEX')); + const opex = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('OPEX')); + const enCours = (filtered || actifs).filter(r => + ['en cours', 'raccordement', 'hors service', 'en attente'].some(k => + String(selectVal(r.observation)).toLowerCase().includes(k))); + + // ── Slide 1 : Couverture ────────────────────────────────────────────────────── + const s1 = pptx.addSlide(); + s1.background = { color: 'FFFFFF' }; + s1.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 2.6, fill: { color: '0B2A55' }, line: { color: '0B2A55' } }); + s1.addShape(pptx.ShapeType.rect, { x: 0, y: 2.6, w: '100%', h: 0.09, fill: { color: '0680C3' }, line: { color: '0680C3' } }); + s1.addText('TUNISIE TELECOM', { x: 0.4, y: 0.2, w: '90%', h: 0.4, fontSize: 11, color: '94A3B8', align: 'center' }); + s1.addText('Direction Centrale Achats', { x: 0.4, y: 0.58, w: '90%', h: 0.32, fontSize: 9.5, color: 'CBD5E1', align: 'center' }); + s1.addText('SITUATION DES MARCHÉS RLA', { x: 0.4, y: 1.08, w: '90%', h: 0.75, fontSize: 25, color: 'FFFFFF', bold: true, align: 'center' }); + s1.addText('Zone Sud — Axe Achats', { x: 0.4, y: 1.83, w: '90%', h: 0.45, fontSize: 14, color: '00D4FF', align: 'center' }); + s1.addText(today, { x: 0.4, y: 3.0, w: '90%', h: 0.55, fontSize: 20, color: '0680C3', bold: true, align: 'center' }); + s1.addText(`${actifs.length} marchés • ${fmtMDT(totalBudget)}`, { x: 0.4, y: 3.65, w: '90%', h: 0.35, fontSize: 11, color: '475569', align: 'center' }); + s1.addText('Nabil Derouiche • Responsable Achats Zone Sud', { x: 0.4, y: 4.5, w: '90%', h: 0.3, fontSize: 9.5, color: '475569', align: 'center' }); + s1.addShape(pptx.ShapeType.rect, { x: 0, y: 5.4, w: '100%', h: 0.1, fill: { color: '0680C3' }, line: { color: '0680C3' } }); + + // ── Slide 2 : Alertes ───────────────────────────────────────────────────────── + const alertes = actifs + .map(r => ({ ...r, _d: getDelai(r) })) + .filter(r => r._d !== null && r._d <= DELAI_ATTENTION) + .sort((a, b) => a._d - b._d); + + const s2 = pptx.addSlide(); + s2.background = { color: 'FAFAFA' }; + s2.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.72, fill: { color: 'DC2626' }, line: { color: 'DC2626' } }); + s2.addText('ALERTES DÉLAIS', { x: 0.3, y: 0.1, w: '55%', h: 0.5, fontSize: 17, bold: true, color: 'FFFFFF' }); + s2.addText(`${alertes.filter(r => r._d <= DELAI_CRITIQUE).length} critique(s) • ${alertes.length} total`, + { x: 7, y: 0.15, w: 5.5, h: 0.38, fontSize: 10, color: 'FEE2E2', align: 'right' }); + + const alHdr = ['Région','Désignation','Projet','Entrepreneur','Période','Avt Phy','Délai PO','Alerte'].map(t => + txt(t, { bold: true, color: 'FFFFFF', fill: '7F1D1D', fontSize: 8, valign: 'middle' })); + + const alRows = alertes.slice(0, 15).map(r => { + const al = niveauAlerte(r._d); + const phy = parseNum(r.taux_phy || r.avt_phy); + return [ + txt(r.region || '—', { fontSize: 7.5, color: REGION_COLORS[r.region] || '1E293B', bold: true }), + txt(r.ref || '', { fontSize: 7, color: '1E293B' }), + txt(r.projet || '', { fontSize: 7.5, color: '1E293B' }), + txt(r.entrepreneur || '', { fontSize: 7.5, color: '1E293B' }), + txt(periode(r), { fontSize: 6.5, color: '475569' }), + txt(progBar(phy, 13), { fontSize: 6.5, color: '1E293B', fontFace: 'Courier New' }), + txt(r._d !== null ? r._d + ' j' : '—', { fontSize: 8, bold: true, color: al.color, align: 'center' }), + txt(al.label, { fontSize: 7.5, bold: true, color: al.color, align: 'center' }), + ]; + }); + + if (alRows.length) { + s2.addTable([alHdr, ...alRows], { + x: 0.15, y: 0.82, w: 13.15, fontSize: 7.5, + border: { type: 'solid', color: 'E2E8F0' }, + colW: [1.1, 1.9, 2.0, 2.0, 1.75, 2.1, 0.8, 0.85], + rowH: 0.28, + }); + } else { + s2.addText('Aucune alerte active.', { x: 0.3, y: 2.5, w: 13, h: 0.5, fontSize: 14, color: '059669', align: 'center' }); + } + s2.addText(`Tunisie Telecom • Zone Sud • ${today}`, { x: 0, y: 5.42, w: '100%', h: 0.15, fontSize: 7, color: '94A3B8', align: 'center' }); + + // ── Slides CAPEX / OPEX ─────────────────────────────────────────────────────── + function addMarcheSlides(rows, typeLabel, headerColor) { + const PER = 13; + const total = Math.ceil(rows.length / PER) || 1; + for (let p = 0; p < total; p++) { + const chunk = rows.slice(p * PER, (p + 1) * PER); + const s = pptx.addSlide(); + s.background = { color: 'FAFAFA' }; + s.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.7, fill: { color: headerColor }, line: { color: headerColor } }); + s.addText(`MARCHÉS ${typeLabel}`, { x: 0.3, y: 0.1, w: '65%', h: 0.5, fontSize: 16, bold: true, color: 'FFFFFF' }); + s.addText(`(${p + 1}/${total})`, { x: 10.5, y: 0.12, w: 2.5, h: 0.42, fontSize: 13, color: 'FFFFFF', align: 'right' }); + + const hdr = ['Désignation','Projet','Entrepreneur','Période','Délai PO','Avt Fin %','Avt Phy %'].map(t => + txt(t, { bold: true, color: 'FFFFFF', fill: headerColor, fontSize: 8, valign: 'middle' })); + + const dataRows = chunk.map(r => { + const phy = parseNum(r.taux_phy || r.avt_phy); + const fin = parseNum(r.taux_fin); + const d = getDelai(r); + const al = niveauAlerte(d); + return [ + txt(r.ref || '', { fontSize: 7, color: '1E293B' }), + txt(r.projet || '', { fontSize: 7.5, color: '1E293B' }), + txt(r.entrepreneur || '', { fontSize: 7.5, color: '1E293B' }), + txt(periode(r), { fontSize: 6.5, color: '475569' }), + txt(d !== null ? d + ' j' : '—', { fontSize: 8, bold: true, color: al.color, align: 'center' }), + txt(fmtPct(fin), { fontSize: 8, align: 'center', color: fin >= 70 ? '059669' : 'DC2626' }), + txt(progBar(phy, 10), { fontSize: 6.5, color: '1E293B', fontFace: 'Courier New' }), + ]; + }); + + s.addTable([hdr, ...dataRows], { + x: 0.15, y: 0.79, w: 13.15, fontSize: 7.5, + border: { type: 'solid', color: 'E2E8F0' }, + colW: [2.35, 2.2, 2.1, 1.9, 0.85, 0.85, 2.9], + rowH: 0.31, + }); + s.addText(`Tunisie Telecom • Zone Sud • ${today}`, { x: 0, y: 5.42, w: '100%', h: 0.15, fontSize: 7, color: '94A3B8', align: 'center' }); + } + } + + addMarcheSlides(capex, 'CAPEX', '059669'); + addMarcheSlides(opex, 'OPEX', 'D97706'); + + // ── Slide finale : En cours / Hors service ──────────────────────────────────── + const sLast = pptx.addSlide(); + sLast.background = { color: 'FAFAFA' }; + sLast.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.7, fill: { color: '1E40AF' }, line: { color: '1E40AF' } }); + sLast.addText('MARCHÉS EN COURS / HORS SERVICE', { x: 0.3, y: 0.1, w: '85%', h: 0.5, fontSize: 15, bold: true, color: 'FFFFFF' }); + + const ecHdr = ['Désignation','Projet','Entrepreneur','Observation'].map(t => + txt(t, { bold: true, color: 'FFFFFF', fill: '1E40AF', fontSize: 9, valign: 'middle' })); + + const ecRows = enCours.slice(0, 20).map(r => [ + txt(r.ref || r.id_marche || '', { fontSize: 8, color: '1E293B' }), + txt(r.projet || '', { fontSize: 8, color: '1E293B' }), + txt(r.entrepreneur || '', { fontSize: 8, color: '1E293B' }), + txt(selectVal(r.observation) || '—', { fontSize: 8, color: '475569' }), + ]); + + sLast.addTable([ecHdr, ...ecRows], { + x: 0.15, y: 0.83, w: 13.15, fontSize: 8, + border: { type: 'solid', color: 'E2E8F0' }, + colW: [3.3, 3.3, 3.0, 3.55], + rowH: 0.33, + }); + sLast.addText(`Tunisie Telecom • Zone Sud • ${today}`, { x: 0, y: 5.42, w: '100%', h: 0.15, fontSize: 7, color: '94A3B8', align: 'center' }); + + return pptx.write({ outputType: 'nodebuffer' }); +} + +module.exports = { generatePptx }; diff --git a/services/export-xlsx.js b/services/export-xlsx.js index 11d7d35..8f3d9b2 100644 --- a/services/export-xlsx.js +++ b/services/export-xlsx.js @@ -315,137 +315,120 @@ async function buildSheet1(wb, actifs) { } async function buildSheet2(wb, actifs) { - const ws = wb.addWorksheet('Pilotage Proactif'); - ws.views = [{ state: 'frozen', ySplit: 9 }]; + const ws = wb.addWorksheet('Estimation Évolution'); + ws.views = [{ state: 'frozen', ySplit: 6 }]; + + const SEUIL_STD = parseFloat(process.env.SEUIL_STANDARD || 70); + + function projection(r) { + const marche = parseNum(r.tot_marche || r.totmarche || r.montant); + const minDT = marche * (SEUIL_STD / 100); + const consomme = parseNum(r.avt_fin); + const debut = parseDateFR(r.date_debut || r.debut_marche); + const fin = parseDateFR(r.date_fin || r.date_fin_marche); + const now = new Date(); + const elapsed = debut ? Math.max(0, (now.getFullYear()-debut.getFullYear())*12+(now.getMonth()-debut.getMonth())) : 0; + const total = (debut && fin) ? Math.max(1, (fin.getFullYear()-debut.getFullYear())*12+(fin.getMonth()-debut.getMonth())) : 0; + const parMois = elapsed > 0 ? consomme / elapsed : 0; + const projete = parMois * (total || elapsed); + let verdict; + if (projete > marche * 1.03) verdict = 'Avenant'; + else if (projete >= minDT) verdict = 'Normal'; + else verdict = 'Sous Min'; + return { marche, minDT, consomme, parMois, projete, verdict }; + } ws.columns = [ - { width: 40 }, { width: 20 }, { width: 22 }, { width: 16 }, - { width: 10 }, { width: 10 }, { width: 12 }, { width: 12 }, { width: 18 }, + { width: 42 }, { width: 22 }, { width: 22 }, { width: 16 }, + { width: 14 }, { width: 14 }, { width: 14 }, { width: 14 }, { width: 18 }, ]; const today = new Date().toLocaleDateString('fr-FR'); + const COLS = 9; - // Title - const r1 = ws.addRow(['📈 PILOTAGE PROACTIF — ZONE SUD', ...Array(8).fill('')]); + // Row 1: Title + const r1 = ws.addRow(['ESTIMATION ÉVOLUTION — ZONE SUD', ...Array(COLS - 1).fill('')]); r1.height = 30; - ws.mergeCells('A1:I1'); - r1.getCell(1).fill = fill(C.NAVY); + ws.mergeCells(`A1:I1`); + r1.getCell(1).fill = fill('FF6366F1'); r1.getCell(1).font = { color: { argb: C.WHITE }, bold: true, size: 16 }; r1.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' }; - const r2 = ws.addRow([`Tunisie Telecom • Direction Centrale Achats • Zone Sud`, ...Array(8).fill('')]); + const r2 = ws.addRow([`Tunisie Telecom • Direction Centrale Achats • Zone Sud`, ...Array(COLS - 1).fill('')]); r2.height = 18; ws.mergeCells('A2:I2'); - r2.getCell(1).fill = fill('FF0F172A'); - r2.getCell(1).font = font('FFCBD5E1', false, 11); + r2.getCell(1).fill = fill('FF4F46E5'); + r2.getCell(1).font = font(C.WHITE, false, 11); r2.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' }; - const r3 = ws.addRow([`📅 ${today} │ 📋 ${actifs.length} marchés actifs`, ...Array(8).fill('')]); + const r3 = ws.addRow([`📅 ${today} │ 📋 ${actifs.length} marchés`, ...Array(COLS - 1).fill('')]); r3.height = 16; ws.mergeCells('A3:I3'); - r3.getCell(1).fill = fill('FF1E3A5F'); - r3.getCell(1).font = font('FF94A3B8', false, 10); + r3.getCell(1).fill = fill('FF4338CA'); + r3.getCell(1).font = font('FFFDE8EC', false, 10); r3.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' }; ws.addRow([]); - // KPIs - const SEUIL_STD = parseFloat(process.env.SEUIL_STANDARD || 70); - const SEUIL_CRIT = parseFloat(process.env.SEUIL_CRITIQUE_PCT || 90); - const SEUIL_MOD = parseFloat(process.env.SEUIL_MODERNISATION || 50); - - const classify = r => { - const taux = parseNum(r.taux_phy || r.avt_phy); - const nat = String(selectVal(r.nature) || '').toLowerCase(); - const seuil = nat.includes('modern') ? SEUIL_MOD : SEUIL_STD; - if (taux === 0) return 'Non déterminé'; - if (taux >= SEUIL_CRIT) return 'Dépassement'; - if (taux >= seuil) return 'Normal'; - return 'Sous Avancement'; - }; - - 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; - - const r5 = ws.addRow(['✅ NORMAL', '', '❌ SOUS AVANCEMENT', '', '⚡ DÉPASSEMENT', '', '❓ NON DÉTERMINÉ', '', '📊 TOTAL']); - r5.height = 22; - ws.mergeCells('A5:B5'); ws.mergeCells('C5:D5'); ws.mergeCells('E5:F5'); ws.mergeCells('G5:H5'); - [[1,C.GREEN],[3,'FFDC2626'],[5,C.ORANGE],[7,C.GRAY],[9,C.NAVY]].forEach(([col, argb]) => { - r5.getCell(col).fill = fill(argb); - r5.getCell(col).font = { color: { argb: C.WHITE }, bold: true, size: 10 }; - r5.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' }; - }); - - const r6 = ws.addRow([normal, '', sous, '', dep, '', nd, '', actifs.length]); - r6.height = 20; - ws.mergeCells('A6:B6'); ws.mergeCells('C6:D6'); ws.mergeCells('E6:F6'); ws.mergeCells('G6:H6'); - [1, 3, 5, 7, 9].forEach(col => { - r6.getCell(col).font = { bold: true, size: 18 }; - r6.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' }; - }); - - ws.addRow([]); - ws.addRow([]); - // Column headers - const HEADERS2 = ['Référence', 'Projet', 'Entrepreneur', 'Région', - 'Phy %', 'Fin %', 'Délai', 'Alerte', 'Résultat']; - const r9 = ws.addRow(HEADERS2); - r9.height = 22; - r9.eachCell(cell => { - cell.fill = fill(C.HEADER); + const HEADERS2 = ['Référence','Projet','Entrepreneur','Marché DT','Min DT','Consommé DT','DT/Mois','Projeté DT','Résultat']; + const r5 = ws.addRow(HEADERS2); + r5.height = 22; + r5.eachCell(cell => { + cell.fill = fill('FF6366F1'); cell.font = { color: { argb: C.WHITE }, bold: true, size: 10 }; cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true }; cell.border = border(C.NAVY); }); - const ALERTE_COLOR = { 'critique': 'FFDC2626', 'attention': 'FFEA580C', 'normal': C.GREEN, 'indéterminé': C.GRAY }; - const RESULT_COLOR = { 'Normal': C.GREEN, 'Sous Avancement': 'FFDC2626', 'Dépassement': C.ORANGE, 'Non déterminé': C.GRAY }; + const VERDICT_COLOR = { Normal: C.GREEN, 'Sous Min': 'FFDC2626', Avenant: 'FFEA580C' }; - const DELAI_CRIT = parseInt(process.env.DELAI_CRITIQUE || 45); - const DELAI_ATT = parseInt(process.env.DELAI_ATTENTION || 90); - const niveauAlerte = d => d === null ? 'indéterminé' : d <= DELAI_CRIT ? 'critique' : d <= DELAI_ATT ? 'attention' : 'normal'; - - const sorted = [...actifs].sort((a, b) => { - const ta = parseNum(a.taux_phy || a.avt_phy); - const tb = parseNum(b.taux_phy || b.avt_phy); - return ta - tb; - }); - - for (let i = 0; i < sorted.length; i++) { - const r = sorted[i]; - const phyPct = parseNum(r.taux_phy || r.avt_phy); - const finPct = parseNum(r.taux_fin); - const delai = getDelai(r); - const alerte = niveauAlerte(typeof delai === 'number' ? delai : null); - const result = classify(r); + for (let i = 0; i < actifs.length; i++) { + const r = actifs[i]; + const p = projection(r); const rd = ws.addRow([ buildRef(r), r.projet || '', r.entrepreneur || '', - r.region || '', - phyPct / 100, - finPct / 100, - typeof delai === 'number' ? delai : '-', - alerte, - result, + p.marche || '', + p.minDT || '', + p.consomme || '', + p.parMois || '', + p.projete || '', + p.verdict, ]); rd.height = 15; - if (i % 2 === 1) rd.eachCell(cell => { cell.fill = fill(C.ALT); }); - rd.getCell(5).numFmt = '0%'; - rd.getCell(6).numFmt = '0%'; - rd.getCell(8).font = { color: { argb: ALERTE_COLOR[alerte] || C.GRAY }, bold: true }; - rd.getCell(9).font = { color: { argb: RESULT_COLOR[result] || C.GRAY }, bold: true }; + [4, 5, 6, 7, 8].forEach(col => { + rd.getCell(col).numFmt = '#,##0'; + rd.getCell(col).alignment = { horizontal: 'right', vertical: 'middle' }; + }); + rd.getCell(9).font = { color: { argb: VERDICT_COLOR[p.verdict] || C.GRAY }, bold: true }; rd.eachCell(cell => { cell.border = { bottom: { style: 'thin', color: { argb: C.LIGHT } } }; - cell.alignment = { vertical: 'middle' }; + if (!cell.alignment) cell.alignment = { vertical: 'middle' }; }); } + + // Synthèse en bas + ws.addRow([]); + const nNormal = actifs.filter(r => projection(r).verdict === 'Normal').length; + const nSous = actifs.filter(r => projection(r).verdict === 'Sous Min').length; + const nAv = actifs.filter(r => projection(r).verdict === 'Avenant').length; + const rSum = ws.addRow([`✅ Normal: ${nNormal}`, '', '', `❌ Sous Min: ${nSous}`, '', '', `⚠️ Avenant: ${nAv}`, '', '']); + ws.mergeCells(`A${rSum.number}:C${rSum.number}`); + ws.mergeCells(`D${rSum.number}:F${rSum.number}`); + ws.mergeCells(`G${rSum.number}:I${rSum.number}`); + rSum.height = 20; + rSum.getCell(1).font = { color: { argb: C.GREEN }, bold: true, size: 10 }; + rSum.getCell(4).font = { color: { argb: 'FFDC2626' }, bold: true, size: 10 }; + rSum.getCell(7).font = { color: { argb: C.ORANGE }, bold: true, size: 10 }; + [1, 4, 7].forEach(col => { + rSum.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' }; + rSum.getCell(col).fill = fill(C.ALT); + }); } module.exports = { generateXlsx };