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

210 lines
11 KiB
JavaScript

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