210 lines
11 KiB
JavaScript
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 };
|