feat: exports conformes fichiers-cibles (PPTX/XLSX/DOCX/PDF)

- PPTX : 9 slides ciblées — couverture blanche #0B2A55, alertes rouge
  #DC2626 (8 cols + barres █/░), CAPEX/OPEX paginés (7 cols), en cours (4 cols)
- XLSX : Sheet 2 renommée "Estimation Évolution" avec colonnes Marché DT,
  Min DT, Consommé DT, DT/Mois, Projeté DT, Résultat (Normal/Sous Min/Avenant)
- DOCX : rapport complet 10 sections — TOC, Synthèse, Alertes, Modernisation,
  par-région avec couleurs régionales, Estimation, Matrice risque, Recommandations
- PDF : palette mise à jour (#0B2A55, #0680C3, régions) conforme au cible
- export-pptx.js et export-docx.js extraits en services dédiés
- routes/export.js allégé (inline PPTX/DOCX ~400 lignes → 20 lignes)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nabil Derouiche
2026-04-19 11:35:37 +01:00
co-authored by Claude Sonnet 4.6
parent 0a0ffc31cf
commit 18ec359cf2
11 changed files with 971 additions and 534 deletions
+74 -91
View File
@@ -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 };