diff --git a/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_01_2026 - Lecture seule.pptx b/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_01_2026 - Lecture seule.pptx new file mode 100644 index 0000000..d3b076f Binary files /dev/null and b/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_01_2026 - Lecture seule.pptx differ diff --git a/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_02_2026.xlsx b/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_02_2026.xlsx new file mode 100644 index 0000000..6dc13d3 Binary files /dev/null and b/Fichiers-cibles/Marches_RLA_2025_Zone_Sud_02_2026.xlsx differ diff --git a/Fichiers-cibles/Marches_RLA_Marchés_En_Service___Raccordement___Medenine__Sfax_2026-03-12.pdf b/Fichiers-cibles/Marches_RLA_Marchés_En_Service___Raccordement___Medenine__Sfax_2026-03-12.pdf new file mode 100644 index 0000000..da83d81 Binary files /dev/null and b/Fichiers-cibles/Marches_RLA_Marchés_En_Service___Raccordement___Medenine__Sfax_2026-03-12.pdf differ diff --git a/Fichiers-cibles/Rapport_RLA_2025_Zone_Sud_02_2026.docx b/Fichiers-cibles/Rapport_RLA_2025_Zone_Sud_02_2026.docx new file mode 100644 index 0000000..bf9cb46 Binary files /dev/null and b/Fichiers-cibles/Rapport_RLA_2025_Zone_Sud_02_2026.docx differ diff --git a/data/users.json b/data/users.json index a4bb25f..afe2498 100644 --- a/data/users.json +++ b/data/users.json @@ -2,7 +2,7 @@ { "id": 1, "username": "nabil", - "password": "$2a$10$eQjI1sdYu4hgDnO3TanageD3/R.JqdWqAfzPVD4TxSW0Tjit0x8hu", + "password": "$2a$10$/DecPY/MldiHgQ1v.CBIp.T1iSRrcSmfzUkQZAzq2TLfFbRfu4SVe", "role": "superadmin", "region": "all" }, diff --git a/index.html b/index.html index cd8854d..3691fda 100644 --- a/index.html +++ b/index.html @@ -1,1104 +1,554 @@ - + Marchés RLA - Zone Sud | Tunisie Telecom - - - - - - + - -
-
+ + - -
+ +
+
Chargement des données...
+
- - +
+
Dernière mise à jour
+
+
+ - -
+ + - -
-
-
-
Vue générale
-
+ +
+ + +
+

Vue Générale

+
+
Total Marchés
+
Marchés Actifs
Avancement moy. : —
+
Alertes Délais
Critiques (≤45j) : —
+
Clôturés
+
+
+
+
Répartition par statut
+
+
+
+
Marchés en alerte — délais proches
+

Chargement...

-
- -
- Mis à jour : +
+ + +
+

Alertes Délais

+
+
+

Marchés à surveiller

+ 0 alertes +
+
+ + + +
RéférenceEntrepreneurProjetRégionAvt. Phy.Délai Rest.Niveau
Chargement...
-
-
+ + - -
+ +
+

Marchés En Service

+
+
+ +
+
+ +
+
+
+
+

En Cours d'Exécution

+ 0 marchés +
+
+ + + +
RéférenceProjetRégionEntrepreneurMontant MaxPériodeAvt. Phy.Délai Rest.
Chargement...
+
+
+
- -
-
-
Vue générale
-
- -
+ +
+

Pilotage Proactif — Avancement Physique

+
+
Normal
Avt. ≥ seuil standard (70%)
+
Sous Avancement
Avt. physique < seuil (70%)
+
Dépassement
Avt. physique ≥ critique (90%)
+
Non déterminé
Données insuffisantes
+
+
+
+

Détail par marché

+ 0 marchés
+
+ + + +
RéférenceEntrepreneurProjetRégionAvt. Phy.Délai Rest.Résultat
Chargement...
+
+
+
- -
-
-
-
-
Total marchés
-
-
-
-
-
Marchés actifs
-
Avancement moyen : —
-
-
-
-
-
Alertes délais
-
Critiques (≤45j) : —
-
-
-
-
-
Clôturés
-
-
+ +
+

Détail par Région

+
+

Chargement...

+
+
- -
- -
-
- Répartition par statut -
-
- -
+ +
+

Liste des Marchés

+
+
+
Marchés
+
+ +
+ + + +
+
+ + + + + + + + + + + + + + +
Référence Région Entrepreneur Projet Statut Avt. Phy. Période Montant
Chargement...
+
+
+
+
- -
-
- Timeline marchés actifs -
-
-

- Chargement du diagramme… -

-
-
+ +
+

Pipeline Appels d'Offres

+
+
+

Projets en Préparation

+ 0 projets
+
+ + + +
Description du projetRégionsEstimation (DT)Durée (mois)Date prévisionnelle DCA
Chargement...
+
+
+
- -
-
- - Marchés en alerte — délais critiques -
-
-

Chargement…

-
+ +
+

Gestion des Utilisateurs

+
+
+
Utilisateurs
+
-
+ +
+ + + +
#IdentifiantRôleRégionActions
Chargement...
+
+
+
- -
-
-
Alertes délais
+ +
+

Historique des Connexions

+
+
+ + + +
Date & heureUtilisateurRôleIPRésultat
Chargement...
-
-

Chargement…

-
-
+
+
- -
-
-
Liste des marchés
-
-
-
-
Marchés
-
- - -
- - - -
-
- - - - - - - - - - - - - - - -
Référence Entrepreneur Projet Statut Avancement Période Montant
Chargement…
-
-
-
-
+ - -
-
-
Marchés en service
-
-
-
- - - - - - - - - - -
RéférenceEntrepreneurRégionAvancementDate fin
Chargement…
-
-
-
- - -
-
-
Pilotage proactif
-
-
-
-
- - - - - - - -
RéférenceEntrepreneurRégionAvancementStatut proactif
Chargement…
-
-
-
- - -
-
-
Vue par région
-
-
-

Chargement…

-
-
- - -
-
-
Pipeline appels d'offres
-
-
-
- - - - - - - - - - -
Référence AOProjetRégionStatutDate prévue
Chargement…
-
-
-
- - -
-
-
Gestion des utilisateurs
-
- -
-
-
- -
- - - - - - - -
#IdentifiantRôleRégionActions
Chargement…
-
-
-
- - -
-
-
Historique des connexions
-
-
-
- - - - - - - -
Date & heureUtilisateurRôleIPRésultat
Chargement…
-
-
-
- -
-
-
- - -
- - -
+ +
diff --git a/routes/en-service.js b/routes/en-service.js index 9c56c30..70fe4fe 100644 --- a/routes/en-service.js +++ b/routes/en-service.js @@ -5,7 +5,7 @@ const express = require('express'); const router = express.Router(); const { getMarches } = require('../services/baserow'); -const { isCloture, normalizeMarche, parseNum } = require('../services/calc'); +const { isCloture, normalizeMarche, parseNum, selectVal } = require('../services/calc'); router.get('/', async (req, res) => { try { @@ -14,8 +14,8 @@ router.get('/', async (req, res) => { let rows = await getMarches(); - // Uniquement non clôturés - rows = rows.filter(r => !isCloture(r)); + // Uniquement en service (non clôturés, observation = "En service") + rows = rows.filter(r => !isCloture(r) && selectVal(r.observation).toLowerCase().includes('en service')); // Filtres if (regionFilter) rows = rows.filter(r => r.region === regionFilter); diff --git a/routes/export.js b/routes/export.js index 9a0b876..49d0708 100644 --- a/routes/export.js +++ b/routes/export.js @@ -10,7 +10,7 @@ const router = express.Router(); const { getMarches } = require('../services/baserow'); const { - isCloture, normalizeMarche, parseNum, formatMontant, + isCloture, normalizeMarche, parseNum, formatMontant, selectVal, getDelaiRestant, niveauAlerte, niveauAvancement, niveauRisque, DELAI_CRITIQUE, DELAI_ATTENTION, SEUIL_STANDARD, SEUIL_CRITIQUE_PCT, } = require('../services/calc'); @@ -46,11 +46,11 @@ async function buildViewData(view, req) { const tauxMoyen = tauxList.length ? Math.round(tauxList.reduce((a,b)=>a+b,0)/tauxList.length*10)/10 : 0; const totalBudget = actifs.reduce((s,r) => s+parseNum(r.tot_marche??r.totmarche??r.montant),0); const parStatut = {}; - for (const r of rows) { const s=String(r.statut||'Inconnu'); parStatut[s]=(parStatut[s]||0)+1; } + for (const r of rows) { const s=selectVal(r.observation)||'Inconnu'; parStatut[s]=(parStatut[s]||0)+1; } const alertes = actifs .map(r=>({...r,_d:getDelaiRestant(r)})) .filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION) - .map(r=>({ref:r.ref||'',projet:r.projet||'',region:r.region||'',entrepreneur:r.entrepreneur||'',delai_restant:r._d,niveau:niveauAlerte(r._d)})) + .map(r=>({ref:r.id_marche||r.reference||'',projet:r.projet||'',region:r.region||'',entrepreneur:r.entrepreneur||'',delai_restant:r._d,niveau:niveauAlerte(r._d)})) .sort((a,b)=>a.delai_restant-b.delai_restant); return { total: rows.length, actifs: actifs.length, clotures: clotures.length, @@ -67,8 +67,10 @@ async function buildViewData(view, req) { .sort((a,b)=>a.delai_restant-b.delai_restant); return { count: items.length, critique: items.filter(a=>a.niveau==='critique').length, items }; } - case 'en-service': - return { count: actifs.length, items: actifs.map(normalizeMarche) }; + case 'en-service': { + const enService = actifs.filter(r => selectVal(r.observation).toLowerCase().includes('en service')); + return { count: enService.length, items: enService.map(normalizeMarche) }; + } case 'en-cours': { const enCours = actifs.filter(r=>parseNum(r.taux_phy??r.avt_phy)<100); return { count: enCours.length, items: enCours.map(normalizeMarche) }; @@ -161,10 +163,11 @@ router.get('/xlsx', async (req, res) => { return res.status(403).json({ error: 'Accès réservé au SuperAdmin' }); } try { - const view = req.query.view || 'synthese'; - const data = await buildViewData(view, req); - const buf = await generateXlsx(view, data); - const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.xlsx`; + const allRows = await getMarches(); + const filtered = applyFilters(allRows, req); + const actifs = filtered.filter(r => !isCloture(r)); + const buf = await generateXlsx('all', {}, actifs); + const filename = `Marches_RLA_Zone_Sud_${new Date().toISOString().slice(0,10)}.xlsx`; res.set({ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Disposition': `attachment; filename="${filename}"`, @@ -183,58 +186,224 @@ router.get('/pptx', async (req, res) => { } try { const PptxGenJS = require('pptxgenjs'); - const view = req.query.view || 'synthese'; - const data = await buildViewData(view, req); + 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 = `RLA — ${view}`; + pptx.subject = 'Marchés RLA Zone Sud'; - // Slide de titre - const slide1 = pptx.addSlide(); - slide1.background = { color: '002D62' }; - slide1.addText(`RLA — ${view.toUpperCase()}`, { - x: 0.5, y: 2, w: '90%', h: 1.2, - fontSize: 36, bold: true, color: 'FFFFFF', align: 'center', - }); - slide1.addText('Marchés Tunisie Telecom Zone Sud', { - x: 0.5, y: 3.4, w: '90%', h: 0.5, - fontSize: 16, color: 'B3C5E0', align: 'center', - }); - slide1.addText(new Date().toLocaleDateString('fr-FR'), { - x: 0.5, y: 4, w: '90%', h: 0.4, - fontSize: 12, color: 'E31837', align: 'center', - }); - - // Slide données - const slide2 = pptx.addSlide(); - slide2.addText(`Données — ${view}`, { - x: 0.3, y: 0.2, w: '95%', h: 0.5, - fontSize: 18, bold: true, color: '002D62', - }); - - // Table si items - const items = data.items || data.regions || []; - if (items.length) { - const sample = items[0]; - const keys = Object.keys(sample).filter(k => !k.endsWith('_raw') && k!=='id' && typeof sample[k]!=='object').slice(0,7); - const tableData = [ - keys.map(k => ({ text: k, options: { bold: true, color: 'FFFFFF', fill: '002D62' } })), - ...items.slice(0, 20).map(item => - keys.map(k => ({ text: String(item[k] ?? '—') })) - ), - ]; - slide2.addTable(tableData, { - x: 0.3, y: 0.9, w: 9.4, - fontSize: 9, - border: { type: 'solid', color: 'E2E8F0' }, - colW: keys.map(() => +(9.4 / keys.length).toFixed(2)), + 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] }); } - const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.pptx`; + // 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: r.id_marche||r.reference||'', 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: r.id_marche||r.reference||'', 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 filename = `Marches_RLA_Zone_Sud_${new Date().toISOString().slice(0,10)}.pptx`; const buf = await pptx.write({ outputType: 'nodebuffer' }); res.set({ 'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', @@ -253,50 +422,222 @@ 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, BorderStyle } = require('docx'); - const view = req.query.view || 'synthese'; - const data = await buildViewData(view, req); - const items = data.items || data.regions || []; + const { + Document, Packer, Paragraph, Table, TableRow, TableCell, + TextRun, HeadingLevel, AlignmentType, WidthType, PageBreak, + Header, Footer, ImageRun, + } = require('docx'); - const children = [ - new Paragraph({ - text: `RLA — ${view.toUpperCase()}`, - heading: HeadingLevel.HEADING_1, - }), - new Paragraph({ - text: `Marchés Tunisie Telecom Zone Sud — Édité le ${new Date().toLocaleDateString('fr-FR')}`, - children: [new TextRun({ text: '', break: 1 })], - }), - ]; + 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']; - if (items.length) { - const sample = items[0]; - const keys = Object.keys(sample).filter(k => !k.endsWith('_raw') && k!=='id' && typeof sample[k]!=='object').slice(0,7); + 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 tableRows = [ - new TableRow({ - children: keys.map(k => new TableCell({ - children: [new Paragraph({ children: [new TextRun({ text: k, bold: true, color: 'FFFFFF' })], alignment: AlignmentType.CENTER })], - shading: { fill: '002D62' }, - })), - }), - ...items.slice(0, 50).map((item, i) => new TableRow({ - children: keys.map(k => new TableCell({ - children: [new Paragraph(String(item[k] ?? '—'))], - shading: i % 2 === 1 ? { fill: 'F1F5F9' } : undefined, - })), - })), - ]; + 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({ - rows: tableRows, 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([ + [r.id_marche||r.reference||'', 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()] })); - const doc = new Document({ sections: [{ children }] }); + // ── 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([ + [r.id_marche||r.reference||'', 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([ + [r.id_marche||r.reference||'', 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 filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.docx`; + const filename = `Rapport_RLA_Zone_Sud_${new Date().toISOString().slice(0,10)}.docx`; res.set({ 'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'Content-Disposition': `attachment; filename="${filename}"`, diff --git a/routes/pilotage.js b/routes/pilotage.js index dda2a7f..723b8da 100644 --- a/routes/pilotage.js +++ b/routes/pilotage.js @@ -1,52 +1,52 @@ /** * GET /api/pilotage-proactif - * Pilotage proactif : classement par niveau d'avancement vs seuils + * Pilotage proactif : référence, entrepreneur, projet, montant max/min/projeté, résultat */ const express = require('express'); const router = express.Router(); const { getMarches } = require('../services/baserow'); const { - isCloture, normalizeMarche, parseNum, - niveauAvancement, getDelaiRestant, niveauAlerte, + selectVal, isCloture, normalizeMarche, parseNum, + niveauAvancement, getDelaiRestant, niveauAlerte, resultatPhysique, SEUIL_STANDARD, SEUIL_MODERNISATION, SEUIL_CRITIQUE_PCT, } = require('../services/calc'); router.get('/', async (req, res) => { try { - const { region, entrepreneur, nature, niveau } = req.query; + const { region, entrepreneur, nature, niveau, resultat } = req.query; const regionFilter = req.regionFilter; let rows = await getMarches(); - rows = rows.filter(r => !isCloture(r)); - // Filtres if (regionFilter) rows = rows.filter(r => r.region === regionFilter); else if (region) rows = rows.filter(r => r.region === region); if (entrepreneur) rows = rows.filter(r => String(r.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase())); - if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase())); + if (nature) rows = rows.filter(r => selectVal(r.nature).toLowerCase().includes(nature.toLowerCase())); const items = rows.map(r => { const m = normalizeMarche(r); const delai = getDelaiRestant(r); return { ...m, - delai_restant: delai, - niveau_alerte: niveauAlerte(delai), - niveau_avancement: niveauAvancement(r.taux_phy ?? r.avt_phy, r.nature), + delai_restant: delai, + niveau_alerte: niveauAlerte(delai), + niveau_avancement: niveauAvancement(r.taux_phy || r.avt_phy, selectVal(r.nature)), + resultat: resultatPhysique(r), }; }); - // Groupement - const normal = items.filter(r => r.niveau_avancement === 'normal'); - const sous_avancement = items.filter(r => r.niveau_avancement === 'sous_avancement'); - const depasse = items.filter(r => r.niveau_avancement === 'dépassé'); + const normal = items.filter(r => r.resultat === 'Normal'); + const sous_avancement = items.filter(r => r.resultat === 'Sous Avancement'); + const depassement = items.filter(r => r.resultat === 'Dépassement'); + const non_determine = items.filter(r => r.resultat === 'Non déterminé'); - // Si filtre sur niveau + // Filtres optionnels let result = items; - if (niveau === 'normal') result = normal; - else if (niveau === 'sous') result = sous_avancement; - else if (niveau === 'depasse') result = depasse; + if (niveau === 'normal') result = normal; + else if (niveau === 'sous') result = sous_avancement; + else if (niveau === 'dep') result = depassement; + if (resultat) result = result.filter(r => r.resultat === resultat); res.json({ seuils: { @@ -55,14 +55,13 @@ router.get('/', async (req, res) => { critique: SEUIL_CRITIQUE_PCT, }, resume: { - normal: normal.length, + total: items.length, + normal: normal.length, sous_avancement: sous_avancement.length, - depasse: depasse.length, - total: items.length, + depassement: depassement.length, + non_determine: non_determine.length, }, - normal, - sous_avancement, - depasse, + normal, sous_avancement, depassement, non_determine, items: result, }); } catch (err) { diff --git a/routes/stats.js b/routes/stats.js index 458570b..b9de66d 100644 --- a/routes/stats.js +++ b/routes/stats.js @@ -1,75 +1,46 @@ +/** + * GET /api/stats — compatibilité avec l'ancien front + */ const express = require('express'); -const router = express.Router(); +const router = express.Router(); const { getMarches } = require('../services/baserow'); +const { + selectVal, parseNum, isCloture, getDelaiRestant, niveauAlerte, + DELAI_ATTENTION, +} = require('../services/calc'); -const DELAI_CRITIQUE = 45; -const DELAI_ATTENTION = 90; - -function parseNum(v) { - const n = parseFloat(String(v || '').replace(',', '.')); - return isNaN(n) ? 0 : n; -} - -function getDelaiRestant(r) { - if (r.delai_restant != null) return parseInt(r.delai_restant, 10); - const fin = r.date_fin || r.datefin; - if (!fin) return null; - const d = new Date(fin); - if (isNaN(d.getTime())) return null; - return Math.ceil((d - new Date()) / 86400000); -} - -function isCloture(r) { - const obs = String(r.observation || '').toLowerCase(); - return obs.includes('clôtur') || obs.includes('clotur') || !!r.date_cloture; -} - -// GET /api/stats router.get('/', async (req, res) => { try { - const rows = await getMarches(); - + const rows = await getMarches(); const actifs = rows.filter(r => !isCloture(r)); - // Nb marchés par statut const parStatut = {}; for (const r of rows) { - const s = String(r.statut || 'Inconnu'); + const s = selectVal(r.observation) || 'Inconnu'; parStatut[s] = (parStatut[s] || 0) + 1; } - // Taux d'avancement physique moyen (marchés actifs) - const tauxList = actifs.map(r => parseNum(r.taux_phy)).filter(v => v > 0); + const tauxList = actifs.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0); const tauxMoyen = tauxList.length - ? Math.round(tauxList.reduce((a, b) => a + b, 0) / tauxList.length * 10) / 10 - : 0; + ? Math.round(tauxList.reduce((a, b) => a + b, 0) / tauxList.length * 10) / 10 : 0; - // Alertes délais const alertes = actifs .map(r => ({ ...r, _delai: getDelaiRestant(r) })) .filter(r => r._delai !== null && r._delai <= DELAI_ATTENTION) .map(r => ({ - id: r.id, - ref: r.ref || r.reference || '', - entrepreneur: r.entrepreneur || '', - projet: r.projet || '', - region: r.region || '', - avt_fin: parseNum(r.avt_fin ?? r.avtfin), - delai_restant: r._delai, - niveau: r._delai <= DELAI_CRITIQUE ? 'critique' : 'attention', + id: r.id, ref: r.id_marche || r.reference || '', + entrepreneur: r.entrepreneur || '', projet: r.projet || '', region: r.region || '', + delai_restant: r._delai, niveau: niveauAlerte(r._delai), })); res.json({ - total: rows.length, - actifs: actifs.length, - clotures: rows.length - actifs.length, - par_statut: parStatut, - taux_avancement_moyen: tauxMoyen, + total: rows.length, actifs: actifs.length, clotures: rows.length - actifs.length, + par_statut: parStatut, taux_avancement_moyen: tauxMoyen, alertes_delais: { - count: alertes.length, + count: alertes.length, critique: alertes.filter(a => a.niveau === 'critique').length, attention: alertes.filter(a => a.niveau === 'attention').length, - items: alertes, + items: alertes, }, }); } catch (err) { diff --git a/routes/synthese.js b/routes/synthese.js index f099fe0..ca950fd 100644 --- a/routes/synthese.js +++ b/routes/synthese.js @@ -6,22 +6,21 @@ const express = require('express'); const router = express.Router(); const { getMarches } = require('../services/baserow'); const { - parseNum, formatMontant, isCloture, - getDelaiRestant, niveauAlerte, niveauAvancement, + selectVal, parseNum, formatMontant, isCloture, + getDelaiRestant, niveauAlerte, DELAI_CRITIQUE, DELAI_ATTENTION, } = require('../services/calc'); router.get('/', async (req, res) => { try { const { region, nature, entrepreneur, projet } = req.query; - const regionFilter = req.regionFilter; // set by filterByRegion middleware + const regionFilter = req.regionFilter; let rows = await getMarches(); - // Filtres if (regionFilter) rows = rows.filter(r => r.region === regionFilter); else if (region) rows = rows.filter(r => r.region === region); - if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase())); + if (nature) rows = rows.filter(r => selectVal(r.nature).toLowerCase().includes(nature.toLowerCase())); if (entrepreneur) rows = rows.filter(r => String(r.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase())); if (projet) rows = rows.filter(r => String(r.projet || '').toLowerCase().includes(projet.toLowerCase())); @@ -29,41 +28,42 @@ router.get('/', async (req, res) => { const clotures = rows.filter(r => isCloture(r)); // Montants - const totalBudget = actifs.reduce((s, r) => s + parseNum(r.tot_marche ?? r.totmarche ?? r.montant), 0); - const totalConsomme = actifs.reduce((s, r) => s + parseNum(r.consomme ?? r.montant_consomme ?? 0), 0); + const totalBudget = actifs.reduce((s, r) => s + parseNum(r.tot_marche || r.m_max), 0); + const totalAvtFin = actifs.reduce((s, r) => s + parseNum(r.avt_fin), 0); // Avancement moyen physique - const tauxList = actifs.map(r => parseNum(r.taux_phy ?? r.avt_phy)).filter(v => v > 0); + const tauxList = actifs.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0); const tauxMoyen = tauxList.length ? Math.round(tauxList.reduce((a, b) => a + b, 0) / tauxList.length * 10) / 10 : 0; - // Par statut + // Par statut (observation) const parStatut = {}; for (const r of rows) { - const s = String(r.statut || 'Inconnu'); + const s = selectVal(r.observation) || 'Inconnu'; parStatut[s] = (parStatut[s] || 0) + 1; } + // Par nature (CAPEX/OPEX) + const parNature = {}; + for (const r of actifs) { + const n = selectVal(r.nature) || 'Non défini'; + parNature[n] = (parNature[n] || 0) + 1; + } + // Par région const parRegion = {}; for (const r of actifs) { const reg = r.region || 'Inconnu'; if (!parRegion[reg]) parRegion[reg] = { count: 0, taux_sum: 0, taux_count: 0 }; parRegion[reg].count++; - const t = parseNum(r.taux_phy ?? r.avt_phy); + const t = parseNum(r.taux_phy || r.avt_phy); if (t > 0) { parRegion[reg].taux_sum += t; parRegion[reg].taux_count++; } } for (const reg of Object.keys(parRegion)) { const d = parRegion[reg]; - parRegion[reg].taux_moyen = d.taux_count ? Math.round(d.taux_sum / d.taux_count * 10) / 10 : 0; - } - - // Par nature (CAPEX/OPEX) - const parNature = {}; - for (const r of actifs) { - const n = r.nature || 'Non défini'; - parNature[n] = (parNature[n] || 0) + 1; + parRegion[reg].taux_moyen = d.taux_count + ? Math.round(d.taux_sum / d.taux_count * 10) / 10 : 0; } // Alertes délais @@ -72,7 +72,7 @@ router.get('/', async (req, res) => { .filter(r => r._delai !== null && r._delai <= DELAI_ATTENTION) .map(r => ({ id: r.id, - ref: r.ref || r.reference || '', + ref: r.id_marche || r.reference || '', projet: r.projet || '', region: r.region || '', entrepreneur: r.entrepreneur || '', @@ -81,31 +81,29 @@ router.get('/', async (req, res) => { })) .sort((a, b) => a.delai_restant - b.delai_restant); - // Pilotage proactif (niveaux d'avancement) + // Pilotage const pilotage = { normal: 0, sous_avancement: 0, depasse: 0 }; for (const r of actifs) { - const n = niveauAvancement(r.taux_phy ?? r.avt_phy, r.nature); - if (n === 'normal') pilotage.normal++; - else if (n === 'sous_avancement') pilotage.sous_avancement++; - else pilotage.depasse++; + const t = parseNum(r.taux_phy || r.avt_phy); + if (t >= 90) pilotage.depasse++; + else if (t >= 70) pilotage.sous_avancement++; + else pilotage.normal++; } res.json({ - total: rows.length, - actifs: actifs.length, - clotures: clotures.length, + total: rows.length, actifs: actifs.length, clotures: clotures.length, budget: { total: formatMontant(totalBudget), total_raw: totalBudget, - consomme: formatMontant(totalConsomme), - consomme_raw: totalConsomme, - restant: formatMontant(totalBudget - totalConsomme), - restant_raw: totalBudget - totalConsomme, + consomme: formatMontant(totalAvtFin), + consomme_raw: totalAvtFin, + restant: formatMontant(totalBudget - totalAvtFin), + restant_raw: totalBudget - totalAvtFin, }, taux_avancement_moyen: tauxMoyen, - par_statut: parStatut, - par_region: parRegion, - par_nature: parNature, + par_statut: parStatut, + par_nature: parNature, + par_region: parRegion, alertes_delais: { count: alertes.length, critique: alertes.filter(a => a.niveau === 'critique').length, diff --git a/services/calc.js b/services/calc.js index 8daba26..5287927 100644 --- a/services/calc.js +++ b/services/calc.js @@ -1,29 +1,43 @@ /** * services/calc.js * Helpers partagés : calculs, formatage, seuils métier RLA + * Champs Baserow table 856 : id_marche, nature{value}, region, observation{value}, + * taux_phy, taux_fin, avt_phy, avt_fin, m_min, m_max, tot_marche, + * date_debut, date_fin, delai_restant, debut_marche, date_fin_marche */ -const SEUIL_STANDARD = parseFloat(process.env.SEUIL_STANDARD || 70); -const SEUIL_MODERNISATION = parseFloat(process.env.SEUIL_MODERNISATION || 50); -const SEUIL_CRITIQUE_PCT = parseFloat(process.env.SEUIL_CRITIQUE_PCT || 90); -const DELAI_CRITIQUE = parseInt(process.env.DELAI_CRITIQUE || 45, 10); -const DELAI_ATTENTION = parseInt(process.env.DELAI_ATTENTION || 90, 10); +const SEUIL_STANDARD = parseFloat(process.env.SEUIL_STANDARD || 70); +const SEUIL_MODERNISATION = parseFloat(process.env.SEUIL_MODERNISATION || 50); +const SEUIL_CRITIQUE_PCT = parseFloat(process.env.SEUIL_CRITIQUE_PCT || 90); +const DELAI_CRITIQUE = parseInt(process.env.DELAI_CRITIQUE || 45, 10); +const DELAI_ATTENTION = parseInt(process.env.DELAI_ATTENTION || 90, 10); -// ─── Parseurs ─────────────────────────────────────────────────────────────── +// ─── Helpers Baserow select/multi-select ───────────────────────────────────── + +/** Extrait la valeur d'un champ Baserow (select ou string) */ +function selectVal(v) { + if (!v) return ''; + if (typeof v === 'object' && v.value !== undefined) return String(v.value); + if (Array.isArray(v)) return v.map(x => (x.value !== undefined ? x.value : x)).join(', '); + return String(v); +} + +// ─── Parseurs ──────────────────────────────────────────────────────────────── function parseNum(v) { - const n = parseFloat(String(v ?? '').replace(/\s/g, '').replace(',', '.')); + if (v === null || v === undefined || v === '') return 0; + if (typeof v === 'object') return 0; // objet Baserow non-numérique + const n = parseFloat(String(v).replace(/\s/g, '').replace(',', '.')); return isNaN(n) ? 0 : n; } function parseDateFR(d) { if (!d) return null; - // ISO or FR dd/mm/yyyy const parts = String(d).split(/[\/\-]/); if (parts.length === 3) { const [a, b, c] = parts; - if (a.length === 4) return new Date(`${a}-${b}-${c}`); // YYYY-MM-DD - if (c.length === 4) return new Date(`${c}-${b}-${a}`); // DD/MM/YYYY + 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; @@ -34,7 +48,7 @@ function parseDateFR(d) { function formatMontant(v) { const n = parseNum(v); if (n === 0) return '—'; - return n.toLocaleString('fr-TN', { minimumFractionDigits: 0, maximumFractionDigits: 3 }) + ' DT'; + return n.toLocaleString('fr-TN', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' DT'; } function formatDateFR(d) { @@ -48,19 +62,22 @@ function formatPct(v) { return n === 0 ? '0 %' : `${n.toFixed(1)} %`; } -// ─── Détermination de statuts métier ───────────────────────────────────────── +// ─── Statuts métier ─────────────────────────────────────────────────────────── function isCloture(r) { - const obs = String(r.observation || r.statut || '').toLowerCase(); + const obs = selectVal(r.observation).toLowerCase(); return obs.includes('clôtur') || obs.includes('clotur') || !!r.date_cloture; } function getDelaiRestant(r) { - if (r.delai_restant != null && r.delai_restant !== '') { - const v = parseInt(r.delai_restant, 10); - return isNaN(v) ? null : v; + // Champ calculé Baserow + const dField = r.delai_restant; + if (dField !== null && dField !== undefined && dField !== '') { + const v = parseInt(String(dField), 10); + if (!isNaN(v)) return v; } - const fin = r.date_fin || r.datefin; + // Calculer depuis date_fin + const fin = r.date_fin || r.date_fin_marche || r.datefin; const dt = parseDateFR(fin); if (!dt) return null; return Math.ceil((dt - new Date()) / 86400000); @@ -74,18 +91,45 @@ function niveauAlerte(delai) { } function niveauAvancement(tauxPhy, nature) { - const t = parseNum(tauxPhy); - const seuil = String(nature || '').toLowerCase().includes('modern') ? SEUIL_MODERNISATION : SEUIL_STANDARD; + const t = parseNum(tauxPhy); + const nat = String(nature || '').toLowerCase(); + const seuil = nat.includes('modern') ? SEUIL_MODERNISATION : SEUIL_STANDARD; if (t >= SEUIL_CRITIQUE_PCT) return 'dépassé'; if (t >= seuil) return 'sous_avancement'; return 'normal'; } -// ─── Niveau de risque global ───────────────────────────────────────────────── +/** + * Résultat financier du marché (pour pilotage proactif) + * Compare avt_fin (avancement financier en DT) vs m_min / m_max + */ +function resultatFinancier(r) { + const avt = parseNum(r.avt_fin); + const mMin = parseNum(r.m_min); + const mMax = parseNum(r.m_max ?? r.tot_marche); + if (avt === 0 && mMin === 0) return 'Non déterminé'; + if (avt > mMax && mMax > 0) return 'Dépassement'; + if (avt < mMin && mMin > 0) return 'Sous Min'; + return 'Normal'; +} + +/** + * Résultat basé sur l'avancement PHYSIQUE du marché (pilotage proactif) + * Compare taux_phy vs seuils standards / critique + */ +function resultatPhysique(r) { + const taux = parseNum(r.taux_phy || r.avt_phy); + const nat = String(selectVal(r.nature) || '').toLowerCase(); + const seuil = nat.includes('modern') ? SEUIL_MODERNISATION : SEUIL_STANDARD; + if (taux === 0) return 'Non déterminé'; + if (taux >= SEUIL_CRITIQUE_PCT) return 'Dépassement'; + if (taux >= seuil) return 'Normal'; + return 'Sous Avancement'; +} function niveauRisque(r) { const delai = getDelaiRestant(r); - const avt = parseNum(r.taux_phy || r.avt_fin); + const avt = parseNum(r.taux_phy || r.avt_phy); const nd = niveauAlerte(delai); if (nd === 'critique' || avt >= SEUIL_CRITIQUE_PCT) return 'critique'; if (nd === 'attention') return 'élevé'; @@ -96,60 +140,62 @@ function niveauRisque(r) { // ─── Normalisation d'un marché ─────────────────────────────────────────────── function normalizeMarche(r) { + const obsValue = selectVal(r.observation); + const natureValue = selectVal(r.nature); const delaiRestant = getDelaiRestant(r); - const tauxPhy = parseNum(r.taux_phy ?? r.avt_phy ?? r.avancement_physique); - const tauxFin = parseNum(r.taux_fin ?? r.avt_fin ?? r.avancement_financier); - const montant = parseNum(r.tot_marche ?? r.totmarche ?? r.montant); - const consomme = parseNum(r.consomme ?? r.montant_consomme ?? (montant * tauxFin / 100)); - const restant = montant - consomme; + const tauxPhy = parseNum(r.taux_phy || r.avt_phy); + const tauxFin = parseNum(r.taux_fin || r.avt_fin); + const montant = parseNum(r.tot_marche || r.totmarche || r.montant); + const mMin = parseNum(r.m_min); + const mMax = parseNum(r.m_max || r.tot_marche); + const avt_fin_raw = parseNum(r.avt_fin); return { - id: r.id, - ref: r.ref || r.reference || r.id_marche || '', - projet: r.projet || '', - region: r.region || r.region_csc || '', - entrepreneur: r.entrepreneur || '', - nature: r.nature || '', - statut: r.statut || '', - observation: r.observation || '', - cloture: isCloture(r), - date_debut: formatDateFR(r.date_debut), - date_fin: formatDateFR(r.date_fin || r.datefin), - date_cloture: formatDateFR(r.date_cloture), - montant_raw: montant, - montant: formatMontant(montant), - consomme_raw: consomme, - consomme: formatMontant(consomme), - restant_raw: restant, - restant: formatMontant(restant), - taux_phy_raw: tauxPhy, - taux_phy: formatPct(tauxPhy), - taux_fin_raw: tauxFin, - taux_fin: formatPct(tauxFin), - delai_restant: delaiRestant, - niveau_alerte: niveauAlerte(delaiRestant), - niveau_avancement: niveauAvancement(r.taux_phy ?? r.avt_phy, r.nature), - niveau_risque: niveauRisque(r), + id: r.id, + ref: r.id_marche || r.reference || String(r.id || ''), + projet: r.projet || '', + region: r.region || r.region_csc || '', + region_csc: r.region_csc || r.region || '', + entrepreneur: r.entrepreneur || '', + nature: natureValue, + statut: obsValue, + observation: obsValue, + lots: r.lots || '', + cloture: isCloture(r), + + date_debut: formatDateFR(r.date_debut || r.debut_marche), + date_fin: formatDateFR(r.date_fin || r.date_fin_marche), + date_cloture: formatDateFR(r.date_cloture), + alerte_echeance: r.Alerte_Echeance || '', + + montant_raw: montant, + montant: formatMontant(montant), + m_min_raw: mMin, + m_min: formatMontant(mMin), + m_max_raw: mMax, + m_max: formatMontant(mMax), + montant_proj_raw: avt_fin_raw, + montant_proj: formatMontant(avt_fin_raw), + + taux_phy_raw: tauxPhy, + taux_phy: formatPct(tauxPhy), + taux_fin_raw: tauxFin, + taux_fin: formatPct(tauxFin), + + delai_restant: delaiRestant, + niveau_alerte: niveauAlerte(delaiRestant), + niveau_avancement: niveauAvancement(tauxPhy, natureValue), + niveau_risque: niveauRisque(r), + resultat: resultatFinancier(r), }; } -// ─── Seuils exportés ───────────────────────────────────────────────────────── - module.exports = { - SEUIL_STANDARD, - SEUIL_MODERNISATION, - SEUIL_CRITIQUE_PCT, - DELAI_CRITIQUE, - DELAI_ATTENTION, - parseNum, - parseDateFR, - formatMontant, - formatDateFR, - formatPct, - isCloture, - getDelaiRestant, - niveauAlerte, - niveauAvancement, - niveauRisque, + SEUIL_STANDARD, SEUIL_MODERNISATION, SEUIL_CRITIQUE_PCT, + DELAI_CRITIQUE, DELAI_ATTENTION, + selectVal, parseNum, parseDateFR, + formatMontant, formatDateFR, formatPct, + isCloture, getDelaiRestant, niveauAlerte, + niveauAvancement, resultatFinancier, resultatPhysique, niveauRisque, normalizeMarche, }; diff --git a/services/export-pdf.js b/services/export-pdf.js index f9f026b..5ee04b6 100644 --- a/services/export-pdf.js +++ b/services/export-pdf.js @@ -187,9 +187,10 @@ function generateEnService(data) { return pdfToBuffer(doc, d => { header(d, 'Marchés en Service — RLA', `${data.count||0} marché(s) actif(s)`); table(d, { title:'Liste des Marchés en Service', - headers:['Réf.','Projet','Région','Entrepreneur','Montant','Taux Phy.','Taux Fin.','Date Fin','Alerte'], - colWidths:[60,140,65,115,90,50,50,65,55], - rows: (data.items||[]).map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.montant,r.taux_phy,r.taux_fin,r.date_fin,NL(r.niveau_alerte)]) }); + headers:['Réf.','Projet','Région','Entrepreneur','Montant Max','Période','Avt. Phy.','Délai Rest.','Alerte'], + colWidths:[65,140,65,110,90,90,55,55,60], + rows: (data.items||[]).map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.montant, + `${r.date_debut||'—'} → ${r.date_fin||'—'}`,r.taux_phy,r.delai_restant??'—',NL(r.niveau_alerte)]) }); footer(d, 1); }); } @@ -199,9 +200,10 @@ function generateEnCours(data) { return pdfToBuffer(doc, d => { header(d, 'Marchés en Cours — RLA', `${data.count||0} marché(s) en cours`); table(d, { title:'Liste des Marchés en Cours', - headers:['Réf.','Projet','Région','Entrepreneur','Montant','Taux Phy.','Taux Fin.','Date Fin','Niveau Avt.'], - colWidths:[60,135,65,115,90,50,50,65,60], - rows: (data.items||[]).map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.montant,r.taux_phy,r.taux_fin,r.date_fin,r.niveau_avancement]) }); + headers:['Réf.','Projet','Région','Entrepreneur','Montant Max','Période','Avt. Phy.','Niveau Avt.'], + colWidths:[65,140,65,110,90,90,60,70], + rows: (data.items||[]).map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.montant, + `${r.date_debut||'—'} → ${r.date_fin||'—'}`,r.taux_phy,r.niveau_avancement||'—']) }); footer(d, 1); }); } diff --git a/services/export-xlsx.js b/services/export-xlsx.js index f6a25bb..8ab9db3 100644 --- a/services/export-xlsx.js +++ b/services/export-xlsx.js @@ -1,84 +1,450 @@ /** * services/export-xlsx.js - * Génération XLSX avec ExcelJS (SuperAdmin uniquement) + * Génération XLSX comprehensive — Situation des Marchés RLA Zone Sud */ const ExcelJS = require('exceljs'); -const HEADER_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF002D62' } }; -const HEADER_FONT = { color: { argb: 'FFFFFFFF' }, bold: true, size: 10 }; -const ALT_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF1F5F9' } }; +const C = { + NAVY: 'FF002D62', + WHITE: 'FFFFFFFF', + ACCENT: 'FF00D4FF', + GREEN: 'FF16A34A', + ORANGE: 'FFEA580C', + RED: 'FFDC2626', + YELLOW: 'FFEAB308', + GRAY: 'FF64748B', + ALT: 'FFF1F5F9', + LIGHT: 'FFE2E8F0', + CAPEX: 'FFD1FAE5', + OPEX: 'FFFEF3C7', + TOTAL: 'FFDBEAFE', + HEADER: 'FF0F172A', +}; -function styleHeader(row) { - row.eachCell(cell => { - cell.fill = HEADER_FILL; - cell.font = HEADER_FONT; - cell.alignment = { vertical: 'middle', horizontal: 'center' }; - cell.border = { bottom: { style: 'thin', color: { argb: 'FFE2E8F0' } } }; - }); - row.height = 22; +function fill(argb) { return { type: 'pattern', pattern: 'solid', fgColor: { argb } }; } +function font(argb, bold = false, size = 10) { return { color: { argb }, bold, size }; } +function border(color = C.LIGHT) { + const s = { style: 'thin', color: { argb: color } }; + return { top: s, bottom: s, left: s, right: s }; } -function styleDataRow(row, alt) { - if (alt) { - row.eachCell(cell => { cell.fill = ALT_FILL; }); +const ALL_REGIONS = ['Gabes', 'Gafsa', 'Kebili', 'Medenine', 'Sfax', 'Tataouine', 'Tozeur']; + +function parseNum(v) { + if (v === null || v === undefined || v === '') return 0; + if (typeof v === 'object') return 0; + const n = parseFloat(String(v).replace(/\s/g, '').replace(',', '.')); + return isNaN(n) ? 0 : n; +} + +function selectVal(v) { + if (!v) return ''; + if (typeof v === 'object' && v.value !== undefined) return String(v.value); + if (Array.isArray(v)) return v.map(x => x.value !== undefined ? x.value : x).join(', '); + return String(v); +} + +function parseDateFR(d) { + if (!d) return null; + const parts = String(d).split(/[\/\-]/); + if (parts.length === 3) { + const [a, b, c] = parts; + if (a.length === 4) return new Date(`${a}-${b}-${c}`); + if (c.length === 4) return new Date(`${c}-${b}-${a}`); } - row.height = 16; + const dt = new Date(d); + return isNaN(dt.getTime()) ? null : dt; } -async function generateXlsx(view, data) { +function fmtDate(d) { + const dt = parseDateFR(d); + if (!dt) return '-'; + return dt.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' }); +} + +function fmtMDT(val) { + const n = parseNum(val); + if (n === 0) return '0'; + if (n >= 1000000) return `${(n / 1000000).toFixed(1)} MDT`; + if (n >= 1000) return `${(n / 1000).toFixed(0)} kDT`; + return `${n.toFixed(0)} DT`; +} + +function isCloture(r) { + const obs = selectVal(r.observation).toLowerCase(); + return obs.includes('clôtur') || obs.includes('clotur') || !!r.date_cloture; +} + +function getDelai(r) { + const dField = r.delai_restant; + if (dField !== null && dField !== undefined && dField !== '') { + const v = parseInt(String(dField), 10); + if (!isNaN(v)) return v; + } + const fin = r.date_fin || r.date_fin_marche || r.datefin; + const dt = parseDateFR(fin); + if (!dt) return '-'; + return Math.ceil((dt - new Date()) / 86400000); +} + +async function generateXlsx(view, data, allRows) { const wb = new ExcelJS.Workbook(); - wb.creator = 'RLA API'; - wb.created = new Date(); + wb.creator = 'RLA API'; + wb.company = 'Tunisie Telecom Zone Sud'; + wb.created = new Date(); - const titles = { - synthese: 'Synthèse Globale', - alertes: 'Alertes Délais', - 'en-service': 'Marchés en Service', - 'en-cours': 'Marchés en Cours', - 'par-region': 'Par Région', - clotures: 'Marchés Clôturés', - pilotage: 'Pilotage Proactif', - 'matrice-risque': 'Matrice de Risque', - }; + const rows = allRows || data.items || data.regions || []; + const actifs = allRows ? rows.filter(r => !isCloture(r)) : rows; - const title = titles[view] || view; - const ws = wb.addWorksheet(title.slice(0, 31)); - - const items = data.items || data.regions || []; - - if (!items.length) { - ws.addRow(['Aucune donnée disponible.']); - return wb.xlsx.writeBuffer(); - } - - const sample = items[0]; - const keys = Object.keys(sample).filter(k => !k.endsWith('_raw') && k !== 'id' && typeof sample[k] !== 'object'); - - // En-tête - ws.columns = keys.map(k => ({ header: k, key: k, width: 20 })); - styleHeader(ws.getRow(1)); - - // Données - items.forEach((item, i) => { - const row = ws.addRow(keys.map(k => item[k] ?? '')); - styleDataRow(row, i % 2 === 1); - }); - - // Freeze header - ws.views = [{ state: 'frozen', ySplit: 1 }]; - - // Onglet résumé si synthèse - if (view === 'synthese' && data.par_statut) { - const ws2 = wb.addWorksheet('Par Statut'); - ws2.columns = [{ header: 'Statut', key: 'statut', width: 30 }, { header: 'Nombre', key: 'nb', width: 15 }]; - styleHeader(ws2.getRow(1)); - Object.entries(data.par_statut).forEach(([s, n], i) => { - const row = ws2.addRow({ statut: s, nb: n }); - styleDataRow(row, i % 2 === 1); - }); - } + await buildSheet1(wb, actifs); + await buildSheet2(wb, actifs); return wb.xlsx.writeBuffer(); } +async function buildSheet1(wb, actifs) { + const ws = wb.addWorksheet('Situation des Marchés'); + ws.views = [{ state: 'frozen', ySplit: 9 }]; + + // Column widths + const cols = [ + { width: 40 }, { width: 20 }, { width: 22 }, { width: 12 }, + { width: 16 }, { width: 14 }, { width: 8 }, { width: 14 }, + { width: 8 }, { width: 14 }, { width: 14 }, { width: 8 }, { width: 22 }, + ]; + ws.columns = cols.map((c, i) => ({ key: String.fromCharCode(65 + i), width: c.width })); + + const today = new Date().toLocaleDateString('fr-FR'); + const capex = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('CAPEX') || + !String(selectVal(r.nature)).toUpperCase().includes('OPEX')); + const opex = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('OPEX')); + const totalBudget = actifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0); + + const avgPhy = (() => { + const vals = actifs.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0); + return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0; + })(); + + // Row 1: Title + const r1 = ws.addRow(['📊 SITUATION DES MARCHÉS RLA — ZONE SUD', ...Array(12).fill('')]); + r1.height = 30; + ws.mergeCells('A1:M1'); + const c1 = r1.getCell(1); + c1.fill = fill(C.NAVY); + c1.font = { color: { argb: C.WHITE }, bold: true, size: 16 }; + c1.alignment = { horizontal: 'center', vertical: 'middle' }; + + // Row 2: Subtitle + const r2 = ws.addRow([`Tunisie Telecom • Direction Centrale Achats • Zone Sud`, ...Array(12).fill('')]); + r2.height = 18; + ws.mergeCells('A2:M2'); + r2.getCell(1).fill = fill('FF0F172A'); + r2.getCell(1).font = font('FFCBD5E1', false, 11); + r2.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' }; + + // Row 3: Stats bar + const r3 = ws.addRow([ + `📅 ${today} │ 📋 ${actifs.length} marchés │ 💰 ${fmtMDT(totalBudget)} │ 📈 Phy moy: ${avgPhy.toFixed(0)}%`, + ...Array(12).fill(''), + ]); + r3.height = 16; + ws.mergeCells('A3:M3'); + r3.getCell(1).fill = fill('FF1E3A5F'); + r3.getCell(1).font = font('FF94A3B8', false, 10); + r3.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' }; + + // Row 4: empty + ws.addRow([]); + + // Row 5: KPI headers + const r5 = ws.addRow(['📊 GLOBAL', '', '', '', '', '🟢 CAPEX', '', '', '', '', '🟠 OPEX', '', '']); + r5.height = 22; + ws.mergeCells('A5:E5'); ws.mergeCells('F5:J5'); ws.mergeCells('K5:M5'); + const kpiHdrStyle = (cell, argb) => { + cell.fill = fill(argb); + cell.font = { color: { argb: C.WHITE }, bold: true, size: 11 }; + cell.alignment = { horizontal: 'center', vertical: 'middle' }; + }; + kpiHdrStyle(r5.getCell(1), C.NAVY); + kpiHdrStyle(r5.getCell(6), 'FF16A34A'); + kpiHdrStyle(r5.getCell(11), C.ORANGE); + + const capexBudget = capex.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0); + const opexBudget = opex.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0); + const capexPhy = (() => { const v = capex.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(x => x > 0); return v.length ? v.reduce((a,b)=>a+b,0)/v.length : 0; })(); + const opexPhy = (() => { const v = opex.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(x => x > 0); return v.length ? v.reduce((a,b)=>a+b,0)/v.length : 0; })(); + + // Row 6: KPI values + const r6 = ws.addRow([`${actifs.length} marchés`, '', '', '', '', + `${capex.length} marchés`, '', '', '', '', `${opex.length} marchés`, '', '']); + r6.height = 18; + ws.mergeCells('A6:E6'); ws.mergeCells('F6:J6'); ws.mergeCells('K6:M6'); + [1, 6, 11].forEach(col => { + r6.getCell(col).font = { bold: true, size: 14, color: { argb: C.NAVY } }; + r6.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // Row 7: KPI details + const r7 = ws.addRow([ + `Budget: ${fmtMDT(totalBudget)} • Phy moy: ${avgPhy.toFixed(0)}%`, '', '', '', '', + `Budget: ${fmtMDT(capexBudget)} • Phy: ${capexPhy.toFixed(0)}%`, '', '', '', '', + `Budget: ${fmtMDT(opexBudget)} • Phy: ${opexPhy.toFixed(0)}%`, '', '', + ]); + r7.height = 16; + ws.mergeCells('A7:E7'); ws.mergeCells('F7:J7'); ws.mergeCells('K7:M7'); + [1, 6, 11].forEach(col => { + r7.getCell(col).font = { size: 9, color: { argb: C.GRAY } }; + r7.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' }; + }); + + // Row 8: empty + ws.addRow([]); + + // Row 9: Column headers + const HEADERS = ['Référence', 'Projet', 'Entrepreneur', 'Nature', + 'Montant Marché', 'Av. Phy (DT)', 'Phy %', 'Av. Fin (DT)', 'Fin %', + 'Début', 'Fin', 'Délai', 'Observation']; + const r9 = ws.addRow(HEADERS); + r9.height = 22; + r9.eachCell(cell => { + cell.fill = fill(C.HEADER); + cell.font = { color: { argb: C.WHITE }, bold: true, size: 10 }; + cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true }; + cell.border = border(C.NAVY); + }); + + // Per-region data + for (const region of ALL_REGIONS) { + const regRows = actifs.filter(r => (r.region || '') === region); + if (!regRows.length) continue; + + // Region header row + const rh = ws.addRow([`📍 ${region} — ${regRows.length} marchés`, ...Array(12).fill('')]); + rh.height = 18; + ws.mergeCells(`A${rh.number}:M${rh.number}`); + rh.getCell(1).fill = fill('FF1E3A5F'); + rh.getCell(1).font = { bold: true, size: 11, color: { argb: C.ACCENT } }; + rh.getCell(1).alignment = { horizontal: 'left', vertical: 'middle', indent: 1 }; + + let subtotalBudget = 0, subtotalPhy = 0, subtotalFin = 0, phyCount = 0; + + for (let i = 0; i < regRows.length; i++) { + const r = regRows[i]; + const nat = selectVal(r.nature); + const isCapex = nat.toUpperCase().includes('CAPEX'); + const budget = parseNum(r.tot_marche || r.totmarche || r.montant); + const phyDT = parseNum(r.avt_phy); + const phyPct = parseNum(r.taux_phy || r.avt_phy); + const finDT = parseNum(r.avt_fin); + const finPct = parseNum(r.taux_fin); + const delai = getDelai(r); + + subtotalBudget += budget; + if (phyPct > 0) { subtotalPhy += phyPct; phyCount++; } + subtotalFin += finDT; + + const rd = ws.addRow([ + r.id_marche || r.reference || '', + r.projet || '', + r.entrepreneur || '', + nat, + budget || '', + phyDT || '', + phyPct > 0 ? phyPct / 100 : '', + finDT || '', + finPct > 0 ? finPct / 100 : '', + fmtDate(r.date_debut || r.debut_marche), + fmtDate(r.date_fin || r.date_fin_marche), + delai, + selectVal(r.observation), + ]); + rd.height = 15; + + const altFill = i % 2 === 1 ? fill(C.ALT) : undefined; + const natFill = isCapex ? fill(C.CAPEX) : fill(C.OPEX); + + rd.eachCell((cell, col) => { + if (altFill) cell.fill = altFill; + if (col === 4) cell.fill = natFill; + cell.border = { bottom: { style: 'thin', color: { argb: C.LIGHT } } }; + cell.alignment = { vertical: 'middle' }; + if ([5, 6, 8].includes(col)) cell.numFmt = '#,##0'; + if ([7, 9].includes(col)) cell.numFmt = '0%'; + if ([12].includes(col)) cell.alignment = { horizontal: 'center', vertical: 'middle' }; + }); + } + + // Subtotal row + const avgPct = phyCount > 0 ? subtotalPhy / phyCount : 0; + const rst = ws.addRow([ + `Sous-total ${region} (${regRows.length})`, '', '', + '', subtotalBudget, '', avgPct / 100, '', '', + '', '', '', '', + ]); + rst.height = 16; + ws.mergeCells(`A${rst.number}:D${rst.number}`); + rst.eachCell(cell => { + cell.fill = fill('FF1E3A5F'); + cell.font = { bold: true, size: 9, color: { argb: C.WHITE } }; + cell.border = { top: { style: 'medium', color: { argb: C.NAVY } }, bottom: { style: 'medium', color: { argb: C.NAVY } } }; + }); + rst.getCell(5).numFmt = '#,##0'; + rst.getCell(7).numFmt = '0%'; + rst.getCell(1).alignment = { horizontal: 'right', vertical: 'middle' }; + + ws.addRow([]); // spacer + } + + // Grand total row + const gt = ws.addRow([ + `TOTAL ZONE SUD (${actifs.length} marchés)`, '', '', '', + actifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0), + '', avgPhy / 100, '', '', '', '', '', '', + ]); + ws.mergeCells(`A${gt.number}:D${gt.number}`); + gt.height = 22; + gt.eachCell(cell => { + cell.fill = fill(C.NAVY); + cell.font = { bold: true, size: 11, color: { argb: C.WHITE } }; + cell.border = { top: { style: 'medium', color: { argb: C.ACCENT } } }; + }); + gt.getCell(5).numFmt = '#,##0'; + gt.getCell(7).numFmt = '0%'; + gt.getCell(1).alignment = { horizontal: 'right', vertical: 'middle' }; +} + +async function buildSheet2(wb, actifs) { + const ws = wb.addWorksheet('Pilotage Proactif'); + ws.views = [{ state: 'frozen', ySplit: 9 }]; + + ws.columns = [ + { width: 40 }, { width: 20 }, { width: 22 }, { width: 16 }, + { width: 10 }, { width: 10 }, { width: 12 }, { width: 12 }, { width: 18 }, + ]; + + const today = new Date().toLocaleDateString('fr-FR'); + + // Title + const r1 = ws.addRow(['📈 PILOTAGE PROACTIF — ZONE SUD', ...Array(8).fill('')]); + r1.height = 30; + ws.mergeCells('A1:I1'); + r1.getCell(1).fill = fill(C.NAVY); + 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('')]); + r2.height = 18; + ws.mergeCells('A2:I2'); + r2.getCell(1).fill = fill('FF0F172A'); + r2.getCell(1).font = font('FFCBD5E1', false, 11); + r2.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' }; + + const r3 = ws.addRow([`📅 ${today} │ 📋 ${actifs.length} marchés actifs`, ...Array(8).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).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); + 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 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); + + const rd = ws.addRow([ + r.id_marche || r.reference || '', + r.projet || '', + r.entrepreneur || '', + r.region || '', + phyPct / 100, + finPct / 100, + typeof delai === 'number' ? delai : '-', + alerte, + result, + ]); + 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 }; + rd.eachCell(cell => { + cell.border = { bottom: { style: 'thin', color: { argb: C.LIGHT } } }; + cell.alignment = { vertical: 'middle' }; + }); + } +} + module.exports = { generateXlsx };