feat: v3 — refonte thème + avancement physique + exports conformes cibles

- index.html: refonte complète thème dark gradient (top-header + slide-nav, 9 vues)
- calc.js: ajout resultatPhysique(), avancement physique partout
- routes/pilotage.js: catégories Normal/Sous Avancement/Dépassement/Non déterminé
- services/export-xlsx.js: rapport complet multi-feuilles (Situation + Pilotage)
- routes/export.js: XLSX/PPTX/DOCX — sortie unique complète Zone Sud
  - PPTX: 5 slides (couverture, synthèse, alertes, pilotage, par région)
  - DOCX: rapport structuré (couverture, KPIs, alertes, par région, pilotage)
- services/export-pdf.js: colonnes avancement physique uniquement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nabil Derouiche 2026-03-13 19:45:53 +01:00
parent 88a0dbe6d2
commit 8d901da125
14 changed files with 1911 additions and 1861 deletions

Binary file not shown.

Binary file not shown.

View File

@ -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"
},

2115
index.html

File diff suppressed because it is too large Load Diff

View File

@ -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);

View File

@ -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',
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 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',
});
// ── 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' } });
// 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] ?? '—') }))
),
// ── 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' },
];
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)),
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' },
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),
})),
}),
...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 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({
rows: tableRows,
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
tr([['Indicateur',2500,navyFill],['Valeur',2000,navyFill]], true),
tr([['Total marchés actifs', 2500], [String(actifs.length), 2000]]),
tr([['Marchés clôturés', 2500], [String(clotures.length), 2000]], false),
tr([['Budget total', 2500], [fmtMDT(totalBudget), 2000]], false),
tr([['Avancement physique moyen', 2500], [fmtPct(avgPhy), 2000]], false),
tr([['Normal', 2500], [String(normal), 2000]], false),
tr([['Sous Avancement', 2500], [String(sous), 2000]], false),
tr([['Dépassement', 2500], [String(dep), 2000]], false),
],
}));
children.push(new Paragraph({ children: [new PageBreak()] }));
// ── Alertes
children.push(h1('Alertes Délais'));
const alerteItems = actifs
.map(r => ({ ...r, _d: (() => { const v=parseInt(String(r.delai_restant||''),10); return !isNaN(v)?v:null; })() }))
.filter(r => r._d !== null && r._d <= DELAI_ATTENTION)
.sort((a, b) => a._d - b._d);
children.push(new Paragraph({
children: [new TextRun({ text: `${alerteItems.filter(r=>r._d<=DELAI_CRITIQUE).length} marché(s) en alerte critique (< ${DELAI_CRITIQUE}j) • ${alerteItems.length} total (< ${DELAI_ATTENTION}j)`, size: 20, color: 'EF4444', bold: true })],
spacing: { after: 100 },
}));
if (alerteItems.length) {
children.push(new Table({
width: { size: 100, type: WidthType.PERCENTAGE },
rows: [
tr([['Référence',2500,navyFill],['Projet',3000,navyFill],['Région',1200,navyFill],['Délai (j)',1200,navyFill]], true),
...alerteItems.slice(0,20).map((r, i) => tr([
[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}"`,

View File

@ -1,30 +1,28 @@
/**
* 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);
@ -33,20 +31,22 @@ router.get('/', async (req, res) => {
...m,
delai_restant: delai,
niveau_alerte: niveauAlerte(delai),
niveau_avancement: niveauAvancement(r.taux_phy ?? r.avt_phy, r.nature),
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;
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: {
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) {

View File

@ -1,70 +1,41 @@
/**
* GET /api/stats compatibilité avec l'ancien front
*/
const express = require('express');
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 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,
critique: alertes.filter(a => a.niveau === 'critique').length,

View File

@ -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_region: parRegion,
alertes_delais: {
count: alertes.length,
critique: alertes.filter(a => a.niveau === 'critique').length,

View File

@ -1,6 +1,9 @@
/**
* 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);
@ -9,21 +12,32 @@ 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);
@ -75,17 +92,44 @@ function niveauAlerte(delai) {
function niveauAvancement(tauxPhy, nature) {
const t = parseNum(tauxPhy);
const seuil = String(nature || '').toLowerCase().includes('modern') ? SEUIL_MODERNISATION : SEUIL_STANDARD;
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 || '',
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: r.nature || '',
statut: r.statut || '',
observation: r.observation || '',
nature: natureValue,
statut: obsValue,
observation: obsValue,
lots: r.lots || '',
cloture: isCloture(r),
date_debut: formatDateFR(r.date_debut),
date_fin: formatDateFR(r.date_fin || r.datefin),
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),
consomme_raw: consomme,
consomme: formatMontant(consomme),
restant_raw: restant,
restant: formatMontant(restant),
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(r.taux_phy ?? r.avt_phy, r.nature),
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,
};

View File

@ -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);
});
}

View File

@ -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.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;
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 title = titles[view] || view;
const ws = wb.addWorksheet(title.slice(0, 31));
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 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);
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' };
});
// Freeze header
ws.views = [{ state: 'frozen', ySplit: 1 }];
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' };
});
// 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);
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' };
});
}
return wb.xlsx.writeBuffer();
}
module.exports = { generateXlsx };