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:
co-authored by
Claude Sonnet 4.6
parent
0a0ffc31cf
commit
18ec359cf2
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+14
-432
@@ -18,6 +18,8 @@ const {
|
||||
|
||||
const pdfGen = require('../services/export-pdf');
|
||||
const { generateXlsx } = require('../services/export-xlsx');
|
||||
const { generatePptx } = require('../services/export-pptx');
|
||||
const { generateDocx } = require('../services/export-docx');
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -186,226 +188,13 @@ router.get('/pptx', async (req, res) => {
|
||||
return res.status(403).json({ error: 'Accès réservé au SuperAdmin' });
|
||||
}
|
||||
try {
|
||||
const PptxGenJS = require('pptxgenjs');
|
||||
const allRows = await getMarches();
|
||||
const filtered = applyFilters(allRows, req);
|
||||
const actifs = filtered.filter(r => !isCloture(r));
|
||||
const clotures = filtered.filter(r => isCloture(r));
|
||||
const today = new Date().toLocaleDateString('fr-FR');
|
||||
const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
|
||||
|
||||
const parseN = v => { const n = parseFloat(String(v||'').replace(/\s/g,'').replace(',','.')); return isNaN(n)?0:n; };
|
||||
const fmtPct = v => { const n = parseN(v); return n===0?'0%':`${n.toFixed(0)}%`; };
|
||||
const fmtMDT = v => { const n = parseN(v); if(!n) return '—'; if(n>=1e6) return `${(n/1e6).toFixed(1)} MDT`; return `${(n/1e3).toFixed(0)} kDT`; };
|
||||
const selVal = v => { if(!v) return ''; if(typeof v==='object'&&v.value!==undefined) return String(v.value); return String(v); };
|
||||
|
||||
const totalBudget = actifs.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0);
|
||||
const phyList = actifs.map(r=>parseN(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 alerteItems = actifs
|
||||
.map(r=>({...r,_d: (() => { const v=parseInt(String(r.delai_restant||''),10); return !isNaN(v)?v:null; })()}))
|
||||
.filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION)
|
||||
.sort((a,b)=>a._d-b._d);
|
||||
|
||||
const SEUIL_STD = parseFloat(process.env.SEUIL_STANDARD||70);
|
||||
const SEUIL_MOD = parseFloat(process.env.SEUIL_MODERNISATION||50);
|
||||
const classify = r => {
|
||||
const t = parseN(r.taux_phy||r.avt_phy);
|
||||
const s = String(selVal(r.nature)||'').toLowerCase().includes('modern') ? SEUIL_MOD : SEUIL_STD;
|
||||
if(!t) return 'Non déterminé';
|
||||
if(t>=SEUIL_CRITIQUE_PCT) return 'Dépassement';
|
||||
if(t>=s) return 'Normal';
|
||||
return 'Sous Avancement';
|
||||
};
|
||||
const normal = actifs.filter(r=>classify(r)==='Normal');
|
||||
const sous = actifs.filter(r=>classify(r)==='Sous Avancement');
|
||||
const dep = actifs.filter(r=>classify(r)==='Dépassement');
|
||||
|
||||
const pptx = new PptxGenJS();
|
||||
pptx.layout = 'LAYOUT_WIDE';
|
||||
pptx.author = 'RLA API';
|
||||
pptx.company = 'Tunisie Telecom Zone Sud';
|
||||
pptx.subject = 'Marchés RLA Zone Sud';
|
||||
|
||||
const addFooter = slide => {
|
||||
slide.addText(`Tunisie Telecom • Zone Sud • ${today}`, {
|
||||
x: 0, y: 5.3, w: '100%', h: 0.25,
|
||||
fontSize: 8, color: '64748B', align: 'center',
|
||||
});
|
||||
};
|
||||
|
||||
// ── Slide 1: Couverture
|
||||
const s1 = pptx.addSlide();
|
||||
s1.background = { color: '002D62' };
|
||||
s1.addShape(pptx.ShapeType.rect, { x: 0, y: 0, w: '100%', h: 0.08, fill: { color: '00D4FF' } });
|
||||
s1.addText('TUNISIE TELECOM', { x: 0.5, y: 0.4, w: '90%', h: 0.5, fontSize: 14, color: '94A3B8', align: 'center' });
|
||||
s1.addText('RAPPORT DE SUIVI DES MARCHÉS RLA', { x: 0.5, y: 1.1, w: '90%', h: 1.0, fontSize: 30, bold: true, color: 'FFFFFF', align: 'center' });
|
||||
s1.addText('Zone Sud — Situation Actuelle & Pilotage Proactif', { x: 0.5, y: 2.2, w: '90%', h: 0.5, fontSize: 16, color: '00D4FF', align: 'center' });
|
||||
s1.addShape(pptx.ShapeType.rect, { x: 3.5, y: 3.0, w: 6.5, h: 0.05, fill: { color: '00D4FF' }, line: { color: '00D4FF' } });
|
||||
s1.addText(`📅 ${today}`, { x: 0.5, y: 3.2, w: '90%', h: 0.4, fontSize: 12, color: 'CBD5E1', align: 'center' });
|
||||
s1.addText(`📋 ${actifs.length} marchés actifs │ 💰 ${fmtMDT(totalBudget)}`, { x: 0.5, y: 3.7, w: '90%', h: 0.4, fontSize: 11, color: '94A3B8', align: 'center' });
|
||||
s1.addShape(pptx.ShapeType.rect, { x: 0, y: 5.47, w: '100%', h: 0.08, fill: { color: '00D4FF' } });
|
||||
|
||||
// ── Slide 2: Synthèse globale
|
||||
const s2 = pptx.addSlide();
|
||||
s2.background = { color: '0F172A' };
|
||||
s2.addText('SYNTHÈSE GLOBALE', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: '00D4FF' });
|
||||
s2.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: '002D62' } });
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total Marchés', val: String(actifs.length), color: '00D4FF' },
|
||||
{ label: 'Budget Total', val: fmtMDT(totalBudget), color: '10B981' },
|
||||
{ label: 'Avt. Phy Moy', val: fmtPct(avgPhy), color: '10B981' },
|
||||
{ label: 'Alertes', val: String(alerteItems.filter(r=>r._d<=DELAI_CRITIQUE).length), color: 'EF4444' },
|
||||
];
|
||||
kpis.forEach((k, i) => {
|
||||
const x = 0.3 + i * 2.5;
|
||||
s2.addShape(pptx.ShapeType.roundRect, { x, y: 0.75, w: 2.3, h: 1.3, fill: { color: '1E3A5F' }, line: { color: '002D62' } });
|
||||
s2.addText(k.val, { x, y: 0.85, w: 2.3, h: 0.7, fontSize: 26, bold: true, color: k.color, align: 'center' });
|
||||
s2.addText(k.label, { x, y: 1.6, w: 2.3, h: 0.35, fontSize: 9, color: '94A3B8', align: 'center' });
|
||||
});
|
||||
|
||||
// Par statut
|
||||
const parStatut = {};
|
||||
for (const r of filtered) {
|
||||
const s = selVal(r.observation) || 'Inconnu';
|
||||
parStatut[s] = (parStatut[s] || 0) + 1;
|
||||
}
|
||||
const statRows = Object.entries(parStatut).slice(0, 8).map(([s, n]) => [
|
||||
{ text: s, options: { color: 'CBD5E1', fontSize: 9 } },
|
||||
{ text: String(n), options: { color: '00D4FF', bold: true, fontSize: 9, align: 'right' } },
|
||||
]);
|
||||
if (statRows.length) {
|
||||
s2.addText('Répartition par Statut', { x: 0.3, y: 2.2, w: 4, h: 0.3, fontSize: 11, bold: true, color: 'FFFFFF' });
|
||||
s2.addTable([
|
||||
[{ text: 'Statut', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } },
|
||||
{ text: 'Nb', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } }],
|
||||
...statRows,
|
||||
], { x: 0.3, y: 2.55, w: 4, fontSize: 9, border: { type: 'solid', color: '1E3A5F' }, colW: [3.2, 0.8] });
|
||||
}
|
||||
|
||||
// Par région
|
||||
const regData = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'].map(reg => {
|
||||
const ra = actifs.filter(r=>(r.region||'')===reg);
|
||||
const pl = ra.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0);
|
||||
const pm = pl.length ? pl.reduce((a,b)=>a+b,0)/pl.length : 0;
|
||||
return [
|
||||
{ text: reg, options: { color: 'CBD5E1', fontSize: 9 } },
|
||||
{ text: String(ra.length), options: { color: '00D4FF', bold: true, fontSize: 9, align: 'center' } },
|
||||
{ text: fmtPct(pm), options: { color: pm>=70?'10B981':'EF4444', bold: true, fontSize: 9, align: 'center' } },
|
||||
];
|
||||
});
|
||||
s2.addText('Par Région', { x: 5.2, y: 2.2, w: 4.5, h: 0.3, fontSize: 11, bold: true, color: 'FFFFFF' });
|
||||
s2.addTable([
|
||||
[{ text: 'Région', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } },
|
||||
{ text: 'Marchés', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } },
|
||||
{ text: 'Phy %', options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 } }],
|
||||
...regData,
|
||||
], { x: 5.2, y: 2.55, w: 4.5, fontSize: 9, border: { type: 'solid', color: '1E3A5F' }, colW: [2.5, 1.0, 1.0] });
|
||||
addFooter(s2);
|
||||
|
||||
// ── Slide 3: Alertes
|
||||
const s3 = pptx.addSlide();
|
||||
s3.background = { color: '0F172A' };
|
||||
s3.addText('ALERTES DÉLAIS', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: 'EF4444' });
|
||||
s3.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: 'EF4444' } });
|
||||
s3.addText(`${alerteItems.filter(r=>r._d<=DELAI_CRITIQUE).length} critique(s) • ${alerteItems.length} total`, {
|
||||
x: 0.3, y: 0.7, w: 9.4, h: 0.3, fontSize: 10, color: '94A3B8',
|
||||
});
|
||||
if (alerteItems.length) {
|
||||
const alertRows = alerteItems.slice(0, 18).map(r => {
|
||||
const alColor = r._d <= DELAI_CRITIQUE ? 'EF4444' : 'EA580C';
|
||||
return [
|
||||
{ text: buildRef(r), options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.projet||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.region||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.entrepreneur||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: String(r._d||'—'), options: { fontSize: 8, bold: true, color: alColor, align: 'center' } },
|
||||
];
|
||||
});
|
||||
s3.addTable([
|
||||
['Référence','Projet','Région','Entrepreneur','Délai (j)'].map(t => ({
|
||||
text: t, options: { bold: true, color: 'FFFFFF', fill: '7F1D1D', fontSize: 8 },
|
||||
})),
|
||||
...alertRows,
|
||||
], { x: 0.3, y: 1.1, w: 9.4, fontSize: 8, border: { type: 'solid', color: '1E3A5F' }, colW: [2.0, 2.5, 1.2, 2.5, 1.2] });
|
||||
}
|
||||
addFooter(s3);
|
||||
|
||||
// ── Slide 4: Pilotage proactif
|
||||
const s4 = pptx.addSlide();
|
||||
s4.background = { color: '0F172A' };
|
||||
s4.addText('PILOTAGE PROACTIF', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: '00D4FF' });
|
||||
s4.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: '002D62' } });
|
||||
|
||||
const pilotKpis = [
|
||||
{ label: 'Normal', val: String(normal.length), color: '10B981' },
|
||||
{ label: 'Sous Avancement', val: String(sous.length), color: 'EF4444' },
|
||||
{ label: 'Dépassement', val: String(dep.length), color: 'EA580C' },
|
||||
];
|
||||
pilotKpis.forEach((k, i) => {
|
||||
const x = 0.5 + i * 3.3;
|
||||
s4.addShape(pptx.ShapeType.roundRect, { x, y: 0.75, w: 3.0, h: 1.0, fill: { color: '1E3A5F' }, line: { color: '002D62' } });
|
||||
s4.addText(k.val, { x, y: 0.8, w: 3.0, h: 0.55, fontSize: 28, bold: true, color: k.color, align: 'center' });
|
||||
s4.addText(k.label, { x, y: 1.4, w: 3.0, h: 0.25, fontSize: 9, color: '94A3B8', align: 'center' });
|
||||
});
|
||||
|
||||
const pilotItems = [...sous, ...dep].slice(0, 18);
|
||||
if (pilotItems.length) {
|
||||
const pilRows = pilotItems.map((r, i) => {
|
||||
const t = parseN(r.taux_phy||r.avt_phy);
|
||||
const res = classify(r);
|
||||
return [
|
||||
{ text: buildRef(r), options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.projet||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.region||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: r.entrepreneur||'', options: { fontSize: 8, color: 'CBD5E1' } },
|
||||
{ text: fmtPct(t), options: { fontSize: 8, bold: true, color: t>=SEUIL_CRITIQUE_PCT?'EA580C':'EF4444', align: 'center' } },
|
||||
{ text: res, options: { fontSize: 8, bold: true, color: res==='Dépassement'?'EA580C':'EF4444', align: 'center' } },
|
||||
];
|
||||
});
|
||||
s4.addText('Marchés Sous Avancement & Dépassement', { x: 0.3, y: 1.9, w: 9.4, h: 0.3, fontSize: 10, bold: true, color: 'FFFFFF' });
|
||||
s4.addTable([
|
||||
['Référence','Projet','Région','Entrepreneur','Phy %','Résultat'].map(t => ({
|
||||
text: t, options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 8 },
|
||||
})),
|
||||
...pilRows,
|
||||
], { x: 0.3, y: 2.25, w: 9.4, fontSize: 8, border: { type: 'solid', color: '1E3A5F' }, colW: [2.0, 2.3, 1.0, 2.0, 0.9, 1.2] });
|
||||
}
|
||||
addFooter(s4);
|
||||
|
||||
// ── Slide 5: Par région
|
||||
const s5 = pptx.addSlide();
|
||||
s5.background = { color: '0F172A' };
|
||||
s5.addText('SYNTHÈSE PAR RÉGION', { x: 0.3, y: 0.15, w: '90%', h: 0.45, fontSize: 18, bold: true, color: '00D4FF' });
|
||||
s5.addShape(pptx.ShapeType.rect, { x: 0.3, y: 0.6, w: 9.4, h: 0.04, fill: { color: '002D62' } });
|
||||
|
||||
const regTableData = ALL_REGIONS.map(reg => {
|
||||
const ra = actifs.filter(r=>(r.region||'')===reg);
|
||||
const rc = filtered.filter(r=>(r.region||'')===reg&&isCloture(r));
|
||||
const bud = ra.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0);
|
||||
const pl = ra.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0);
|
||||
const pm = pl.length ? pl.reduce((a,b)=>a+b,0)/pl.length : 0;
|
||||
const al = ra.map(r=>({_d:parseInt(String(r.delai_restant||''),10)||null})).filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION);
|
||||
return [
|
||||
{ text: reg, options: { color: '00D4FF', bold: true, fontSize: 9 } },
|
||||
{ text: String(ra.length), options: { color: 'CBD5E1', fontSize: 9, align: 'center' } },
|
||||
{ text: String(rc.length), options: { color: '64748B', fontSize: 9, align: 'center' } },
|
||||
{ text: fmtMDT(bud), options: { color: 'CBD5E1', fontSize: 9, align: 'right' } },
|
||||
{ text: fmtPct(pm), options: { color: pm>=70?'10B981':'EF4444', bold: true, fontSize: 9, align: 'center' } },
|
||||
{ text: String(al.length), options: { color: al.length>0?'EF4444':'10B981', bold: true, fontSize: 9, align: 'center' } },
|
||||
];
|
||||
});
|
||||
s5.addTable([
|
||||
['Région','Actifs','Clôturés','Budget','Phy Moy','Alertes'].map(t => ({
|
||||
text: t, options: { bold: true, color: 'FFFFFF', fill: '002D62', fontSize: 9 },
|
||||
})),
|
||||
...regTableData,
|
||||
], { x: 0.3, y: 0.75, w: 9.4, fontSize: 9, border: { type: 'solid', color: '1E3A5F' }, colW: [2.0, 1.2, 1.2, 1.8, 1.5, 1.7] });
|
||||
addFooter(s5);
|
||||
const allRows = await getMarches();
|
||||
const filtered = applyFilters(allRows, req);
|
||||
const actifs = filtered.filter(r => !isCloture(r));
|
||||
const clotures = filtered.filter(r => isCloture(r));
|
||||
|
||||
const filename = `Marches_RLA_Zone_Sud_${new Date().toISOString().slice(0,10)}.pptx`;
|
||||
const buf = await pptx.write({ outputType: 'nodebuffer' });
|
||||
const buf = await generatePptx(actifs, clotures, filtered);
|
||||
res.set({
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
@@ -423,221 +212,14 @@ router.get('/docx', async (req, res) => {
|
||||
return res.status(403).json({ error: 'Accès réservé au SuperAdmin' });
|
||||
}
|
||||
try {
|
||||
const {
|
||||
Document, Packer, Paragraph, Table, TableRow, TableCell,
|
||||
TextRun, HeadingLevel, AlignmentType, WidthType, PageBreak,
|
||||
Header, Footer, ImageRun,
|
||||
} = require('docx');
|
||||
const { getPipeline } = require('../services/baserow');
|
||||
const allRows = await getMarches();
|
||||
const pipelineRows = await getPipeline();
|
||||
const filtered = applyFilters(allRows, req);
|
||||
const actifs = filtered.filter(r => !isCloture(r));
|
||||
const clotures = filtered.filter(r => isCloture(r));
|
||||
|
||||
const allRows = await getMarches();
|
||||
const filtered = applyFilters(allRows, req);
|
||||
const actifs = filtered.filter(r => !isCloture(r));
|
||||
const clotures = filtered.filter(r => isCloture(r));
|
||||
const today = new Date().toLocaleDateString('fr-FR');
|
||||
const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
|
||||
|
||||
const parseN = v => { const n = parseFloat(String(v||'').replace(/\s/g,'').replace(',','.')); return isNaN(n)?0:n; };
|
||||
const fmtPct = v => { const n = parseN(v); return n===0?'0 %':`${n.toFixed(0)} %`; };
|
||||
const fmtMDT = v => { const n = parseN(v); if(!n) return '—'; if(n>=1e6) return `${(n/1e6).toFixed(1)} MDT`; return `${(n/1e3).toFixed(0)} kDT`; };
|
||||
const selVal = v => { if(!v) return ''; if(typeof v==='object'&&v.value!==undefined) return String(v.value); return String(v); };
|
||||
const parseDt = d => { if(!d) return null; const p=String(d).split(/[\/\-]/); if(p.length===3){const[a,b,c]=p; if(a.length===4) return new Date(`${a}-${b}-${c}`); if(c.length===4) return new Date(`${c}-${b}-${a}`);} const dt=new Date(d); return isNaN(dt.getTime())?null:dt; };
|
||||
const fmtDate = d => { const dt=parseDt(d); if(!dt) return '—'; return dt.toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric'}); };
|
||||
|
||||
const totalBudget = actifs.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0);
|
||||
const phyList = actifs.map(r=>parseN(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 SEUIL_STD = parseFloat(process.env.SEUIL_STANDARD||70);
|
||||
const SEUIL_MOD = parseFloat(process.env.SEUIL_MODERNISATION||50);
|
||||
const classify = r => {
|
||||
const t = parseN(r.taux_phy||r.avt_phy);
|
||||
const s = String(selVal(r.nature)||'').toLowerCase().includes('modern') ? SEUIL_MOD : SEUIL_STD;
|
||||
if(!t) return 'Non déterminé';
|
||||
if(t>=SEUIL_CRITIQUE_PCT) return 'Dépassement';
|
||||
if(t>=s) return 'Normal';
|
||||
return 'Sous Avancement';
|
||||
};
|
||||
|
||||
// Helpers
|
||||
const navyFill = { fill: '002D62' };
|
||||
const altFill = { fill: 'F1F5F9' };
|
||||
const hdr = (texts, opts={}) => new Paragraph({ children: texts, ...opts });
|
||||
const tr = (cells, isHeader=false) => new TableRow({
|
||||
tableHeader: isHeader,
|
||||
children: cells.map(([text, width, shade]) => new TableCell({
|
||||
children: [new Paragraph({
|
||||
children: [new TextRun({ text: String(text??'—'), bold: isHeader, color: isHeader?'FFFFFF':'1E293B', size: 18 })],
|
||||
alignment: AlignmentType.LEFT,
|
||||
})],
|
||||
width: { size: width||1000, type: WidthType.DXA },
|
||||
shading: shade || (isHeader ? navyFill : undefined),
|
||||
})),
|
||||
});
|
||||
const h1 = text => new Paragraph({
|
||||
children: [new TextRun({ text, bold: true, color: '002D62', size: 32 })],
|
||||
spacing: { before: 300, after: 150 },
|
||||
border: { bottom: { style: 'single', size: 8, color: '00D4FF' } },
|
||||
});
|
||||
const h2 = text => new Paragraph({
|
||||
children: [new TextRun({ text, bold: true, color: '0F172A', size: 24 })],
|
||||
spacing: { before: 200, after: 100 },
|
||||
});
|
||||
const spacer = () => new Paragraph({ text: '', spacing: { before: 80, after: 80 } });
|
||||
|
||||
const children = [];
|
||||
|
||||
// ── Page de couverture
|
||||
children.push(new Paragraph({ children: [new TextRun({ text: '', break: 4 })] }));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'TUNISIE TELECOM', bold: true, color: '002D62', size: 36, allCaps: true })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 100, after: 50 },
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'Direction Centrale', color: '64748B', size: 24 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'Zone Sud', color: '64748B', size: 24 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'Rapport de Suivi des Marchés RLA', bold: true, color: '002D62', size: 44 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { before: 100, after: 80 },
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: 'Situation Actuelle & Analyse Prospective', color: '0F172A', size: 26 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 200 },
|
||||
}));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: `📅 ${today} 📋 ${actifs.length} marchés 💰 Budget total : ${fmtMDT(totalBudget)}`, color: '475569', size: 20 })],
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: { after: 100 },
|
||||
}));
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Sommaire exécutif
|
||||
children.push(h1('Sommaire Exécutif'));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: `Ce rapport présente la situation au ${today} des ${actifs.length} marchés actifs de la Zone Sud de Tunisie Telecom. Budget global engagé : ${fmtMDT(totalBudget)}. Avancement physique moyen : ${fmtPct(avgPhy)}.`, size: 20, color: '374151' })],
|
||||
spacing: { after: 120 },
|
||||
}));
|
||||
|
||||
// KPI table
|
||||
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;
|
||||
|
||||
children.push(new Paragraph({ children: [new TextRun({ text: 'Indicateurs Clés', bold: true, size: 22, color: '002D62' })], spacing: { before: 150, after: 80 } }));
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
tr([['Indicateur',2500,navyFill],['Valeur',2000,navyFill]], true),
|
||||
tr([['Total marchés actifs', 2500], [String(actifs.length), 2000]]),
|
||||
tr([['Marchés clôturés', 2500], [String(clotures.length), 2000]], false),
|
||||
tr([['Budget total', 2500], [fmtMDT(totalBudget), 2000]], false),
|
||||
tr([['Avancement physique moyen', 2500], [fmtPct(avgPhy), 2000]], false),
|
||||
tr([['Normal', 2500], [String(normal), 2000]], false),
|
||||
tr([['Sous Avancement', 2500], [String(sous), 2000]], false),
|
||||
tr([['Dépassement', 2500], [String(dep), 2000]], false),
|
||||
],
|
||||
}));
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Alertes
|
||||
children.push(h1('Alertes Délais'));
|
||||
const alerteItems = actifs
|
||||
.map(r => ({ ...r, _d: (() => { const v=parseInt(String(r.delai_restant||''),10); return !isNaN(v)?v:null; })() }))
|
||||
.filter(r => r._d !== null && r._d <= DELAI_ATTENTION)
|
||||
.sort((a, b) => a._d - b._d);
|
||||
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: `${alerteItems.filter(r=>r._d<=DELAI_CRITIQUE).length} marché(s) en alerte critique (< ${DELAI_CRITIQUE}j) • ${alerteItems.length} total (< ${DELAI_ATTENTION}j)`, size: 20, color: 'EF4444', bold: true })],
|
||||
spacing: { after: 100 },
|
||||
}));
|
||||
if (alerteItems.length) {
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
tr([['Référence',2500,navyFill],['Projet',3000,navyFill],['Région',1200,navyFill],['Délai (j)',1200,navyFill]], true),
|
||||
...alerteItems.slice(0,20).map((r, i) => tr([
|
||||
[buildRef(r), 2500, i%2===1?altFill:undefined],
|
||||
[r.projet||'', 3000, i%2===1?altFill:undefined],
|
||||
[r.region||'', 1200, i%2===1?altFill:undefined],
|
||||
[String(r._d||'—'), 1200, i%2===1?altFill:undefined],
|
||||
])),
|
||||
],
|
||||
}));
|
||||
}
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Synthèse par région
|
||||
children.push(h1('Synthèse par Région'));
|
||||
for (const region of ALL_REGIONS) {
|
||||
const regActifs = actifs.filter(r => (r.region||'') === region);
|
||||
if (!regActifs.length) continue;
|
||||
children.push(h2(`📍 ${region}`));
|
||||
const bud = regActifs.reduce((s,r)=>s+parseN(r.tot_marche||r.totmarche||r.montant),0);
|
||||
const pl = regActifs.map(r=>parseN(r.taux_phy||r.avt_phy)).filter(v=>v>0);
|
||||
const pm = pl.length ? pl.reduce((a,b)=>a+b,0)/pl.length : 0;
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: `${regActifs.length} marchés • Budget : ${fmtMDT(bud)} • Phy moy : ${fmtPct(pm)}`, size: 18, color: '64748B' })],
|
||||
spacing: { after: 80 },
|
||||
}));
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
tr([['Référence',2500,navyFill],['Projet',2800,navyFill],['Entrepreneur',2000,navyFill],['Phy %',700,navyFill],['Statut',1000,navyFill]], true),
|
||||
...regActifs.map((r, i) => {
|
||||
const phy = parseN(r.taux_phy||r.avt_phy);
|
||||
return tr([
|
||||
[buildRef(r), 2500, i%2===1?altFill:undefined],
|
||||
[r.projet||'', 2800, i%2===1?altFill:undefined],
|
||||
[r.entrepreneur||'', 2000, i%2===1?altFill:undefined],
|
||||
[fmtPct(phy), 700, i%2===1?altFill:undefined],
|
||||
[selVal(r.observation)||'—', 1000, i%2===1?altFill:undefined],
|
||||
]);
|
||||
}),
|
||||
],
|
||||
}));
|
||||
children.push(spacer());
|
||||
}
|
||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||
|
||||
// ── Pilotage proactif
|
||||
children.push(h1('Pilotage Proactif'));
|
||||
children.push(new Paragraph({
|
||||
children: [new TextRun({ text: `Normal: ${normal} • Sous Avancement: ${sous} • Dépassement: ${dep} • Non déterminé: ${nd}`, size: 20, color: '374151' })],
|
||||
spacing: { after: 100 },
|
||||
}));
|
||||
const pilotItems = actifs.map(r => ({ r, res: classify(r), phy: parseN(r.taux_phy||r.avt_phy) }))
|
||||
.sort((a,b) => a.phy - b.phy);
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
tr([['Référence',2200,navyFill],['Projet',2500,navyFill],['Région',1000,navyFill],['Entrepreneur',1800,navyFill],['Phy %',700,navyFill],['Résultat',1300,navyFill]], true),
|
||||
...pilotItems.map(({ r, res, phy }, i) => tr([
|
||||
[buildRef(r), 2200, i%2===1?altFill:undefined],
|
||||
[r.projet||'', 2500, i%2===1?altFill:undefined],
|
||||
[r.region||'', 1000, i%2===1?altFill:undefined],
|
||||
[r.entrepreneur||'', 1800, i%2===1?altFill:undefined],
|
||||
[fmtPct(phy), 700, i%2===1?altFill:undefined],
|
||||
[res, 1300, i%2===1?altFill:undefined],
|
||||
])),
|
||||
],
|
||||
}));
|
||||
|
||||
const doc = new Document({
|
||||
creator: 'RLA API',
|
||||
description: 'Rapport Marchés Tunisie Telecom Zone Sud',
|
||||
title: 'Rapport RLA Zone Sud',
|
||||
sections: [{ children }],
|
||||
});
|
||||
const buf = await Packer.toBuffer(doc);
|
||||
const buf = await generateDocx({ actifs, clotures, filtered, pipelineRows });
|
||||
const filename = `Rapport_RLA_Zone_Sud_${new Date().toISOString().slice(0,10)}.docx`;
|
||||
res.set({
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
/**
|
||||
* 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 };
|
||||
+13
-11
@@ -4,18 +4,20 @@
|
||||
*/
|
||||
const PDFDocument = require('pdfkit');
|
||||
|
||||
// Palette RLA / McKinsey
|
||||
// Palette RLA — conforme fichier PDF cible
|
||||
const C = {
|
||||
primary: '#002D62',
|
||||
accent: '#E31837',
|
||||
success: '#10b981',
|
||||
warning: '#f59e0b',
|
||||
danger: '#ef4444',
|
||||
muted: '#6b7280',
|
||||
light: '#f8fafc',
|
||||
border: '#e2e8f0',
|
||||
text: '#1e293b',
|
||||
white: '#ffffff',
|
||||
primary: '#0B2A55',
|
||||
accent: '#0680C3',
|
||||
success: '#059669',
|
||||
warning: '#D97706',
|
||||
danger: '#DC2626',
|
||||
muted: '#475569',
|
||||
light: '#F8FAFC',
|
||||
border: '#E2E8F0',
|
||||
text: '#1E293B',
|
||||
white: '#FFFFFF',
|
||||
navy2: '#1E40AF',
|
||||
indigo: '#6366F1',
|
||||
};
|
||||
|
||||
function hex(h) {
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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 };
|
||||
+74
-91
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user