/** * 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 };