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