Gestion-des-Marches-RLA/services/export-docx.js

662 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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