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:
co-authored by
Claude Sonnet 4.6
parent
88a0dbe6d2
commit
8d901da125
@@ -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);
|
||||
|
||||
+427
-86
@@ -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}"`,
|
||||
|
||||
+23
-24
@@ -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) {
|
||||
|
||||
+19
-48
@@ -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) {
|
||||
|
||||
+33
-35
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user