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
@@ -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 };
|
||||
Reference in New Issue
Block a user