feat: RLA API v1.0.0 — API complète + exports + thème McKinsey

- 9 endpoints métier : synthese, alertes, en-service, en-cours,
  par-region, clotures, pilotage-proactif, matrice-risque
- Exports PDF (tous rôles) / XLSX PPTX DOCX (superadmin)
- services/calc.js : helpers normalisés partagés
- services/export-pdf.js : PDF async PDFKit par vue
- Thème McKinsey (#1C2B4B / bleu pétrole / gris sobre)
- Boutons XLSX/DOCX front (superadmin uniquement)
- BASEROW_API_URL → https://baserow.bolbol.tn/api/
- dotenv override: true

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nabil Derouiche 2026-03-12 23:47:10 +01:00
commit 88a0dbe6d2
39 changed files with 7622 additions and 0 deletions

14
.env.example Normal file
View File

@ -0,0 +1,14 @@
JWT_SECRET=your_jwt_secret_here
BASEROW_API_URL=https://baserow.bolbol.tn/api/
BASEROW_TOKEN=your_baserow_token
BASEROW_TABLE_MARCHES=856
BASEROW_TABLE_PIPELINE=872
PORT=3005
# Optionnel
SEUIL_STANDARD=70
SEUIL_CRITIQUE_PCT=90
DELAI_CRITIQUE=45
DELAI_ATTENTION=90

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
.env
logs/*.json
*.log
.DS_Store

15
CLAUDE.md Normal file
View File

@ -0,0 +1,15 @@
# RLA API — Gestion des Marchés Tunisie Telecom Zone Sud
**Stack cible** : Node.js Express + Baserow + Docker (NAS 192.168.100.33)
**URL prod** : https://nd.i234.me/rla
**Fichiers existants** : index.html, config.js uniquement
## Règles strictes (abonnement Pro limité en tokens)
- Lire UNIQUEMENT les fichiers demandés explicitement
- 1 tâche par session, pas de mélange de modules
- Ne jamais réécrire ce qui fonctionne déjà
- Réponses courtes et précises, sans blabla
- Travailler fichier par fichier
## MCP disponibles
- baserow : http://192.168.100.33 (données marchés RLA)
- portainer : http://192.168.100.33:9000 (déploiement Docker)

12
Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev
COPY . .
EXPOSE 3005
CMD ["node", "server.js"]

BIN
Nabil.Derouiche.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

140
README.md Normal file
View File

@ -0,0 +1,140 @@
# RLA API v1 — Marchés Tunisie Telecom Zone Sud
API REST + tableau de bord pour la gestion des marchés publics Tunisie Telecom Zone Sud.
**URL production :** `https://rla.bolbol.tn`
---
## Architecture
```
index.html → Front-end SPA (9 vues, thèmes, exports)
server.js → Entry point Express (port 3005 par défaut)
routes/ → Contrôleurs HTTP
services/ → Logique métier (Baserow, calculs, exports)
middleware/ → Auth JWT + RBAC
data/users.json → Utilisateurs (bcrypt)
logs/ → Logs connexion JSON
```
## Endpoints API
### Public
| Méthode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/health` | Santé du service |
| POST | `/api/auth/login` | Authentification → JWT |
### Données (auth requise)
| Méthode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/synthese` | KPIs globaux + alertes + pilotage |
| GET | `/api/alertes` | Marchés avec alertes délai (≤90j) |
| GET | `/api/en-service` | Marchés actifs en service |
| GET | `/api/en-cours` | Marchés en cours (taux_phy < 100%) |
| GET | `/api/par-region` | Agrégation par région |
| GET | `/api/clotures` | Marchés clôturés |
| GET | `/api/pilotage-proactif` | Niveaux avancement (normal/sous/dépassé) |
| GET | `/api/matrice-risque` | Matrice risque 3×3 (délai × avancement) |
| GET | `/api/marches` | Liste complète marchés |
| GET | `/api/marches/:id` | Détail marché |
| GET | `/api/stats` | Statistiques (compat. ancien front) |
### Exports (auth requise)
| Méthode | Endpoint | Rôle min. | Description |
|---------|----------|-----------|-------------|
| GET | `/api/export/pdf?view=<vue>` | user | PDF par vue |
| GET | `/api/export/xlsx?view=<vue>` | superadmin | Excel |
| GET | `/api/export/pptx?view=<vue>` | superadmin | PowerPoint |
| GET | `/api/export/docx?view=<vue>` | superadmin | Word |
**Vues disponibles :** `synthese`, `alertes`, `en-service`, `en-cours`, `par-region`, `clotures`, `pilotage`, `matrice-risque`
### Admin (superadmin)
| Méthode | Endpoint | Description |
|---------|----------|-------------|
| GET | `/api/users` | Liste utilisateurs |
| POST | `/api/users` | Créer utilisateur |
| DELETE | `/api/users/:id` | Supprimer utilisateur |
| GET | `/api/logs` | Logs connexion |
| GET | `/api/pipeline` | Pipeline AO (admin+) |
## Paramètres de filtres
Tous les endpoints données acceptent :
| Paramètre | Type | Exemple |
|-----------|------|---------|
| `region` | string | `?region=Sfax` |
| `entrepreneur` | string | `?entrepreneur=Ent%20X` |
| `projet` | string | `?projet=4G` |
| `nature` | string | `?nature=CAPEX` |
| `statut` | string | `?statut=en+cours` |
| `niveau` | string | `?niveau=critique` (alertes) |
## Variables d'environnement
| Variable | Requis | Description |
|----------|--------|-------------|
| `JWT_SECRET` | ✓ | Clé secrète JWT |
| `BASEROW_API_URL` | ✓ | URL Baserow (`https://baserow.bolbol.tn/api/`) |
| `BASEROW_TOKEN` | ✓ | Token d'accès Baserow |
| `BASEROW_TABLE_MARCHES` | ✓ | ID table marchés (ex: 856) |
| `BASEROW_TABLE_PIPELINE` | ✓ | ID table pipeline (ex: 872) |
| `PORT` | — | Port serveur (défaut: 3001) |
| `USERS` | — | JSON utilisateurs (init si data/users.json absent) |
| `SEUIL_STANDARD` | — | Seuil avancement normal (défaut: 70) |
| `SEUIL_CRITIQUE_PCT` | — | Seuil avancement critique (défaut: 90) |
| `DELAI_CRITIQUE` | — | Jours alerte critique (défaut: 45) |
| `DELAI_ATTENTION` | — | Jours alerte attention (défaut: 90) |
## Rôles
| Rôle | PDF | PPTX/XLSX/DOCX | Pipeline | Users | Logs |
|------|-----|----------------|----------|-------|------|
| user | ✓ | ✗ | ✗ | ✗ | ✗ |
| admin | ✓ | ✗ | ✓ | ✗ | ✗ |
| superadmin | ✓ | ✓ | ✓ | ✓ | ✓ |
## Démarrage
```bash
npm install
cp .env.example .env # configurer les variables
npm start # production
npm run dev # développement (--watch)
```
## Structure des fichiers
```
routes/
auth.js → POST /api/auth/login
marches.js → GET /api/marches[/:id]
stats.js → GET /api/stats
synthese.js → GET /api/synthese
alertes.js → GET /api/alertes
en-service.js → GET /api/en-service
en-cours.js → GET /api/en-cours
par-region.js → GET /api/par-region
clotures.js → GET /api/clotures
pilotage.js → GET /api/pilotage-proactif
matrice-risque.js → GET /api/matrice-risque
export.js → GET /api/export/[pdf|xlsx|pptx|docx]
pipeline.js → GET /api/pipeline
users.js → CRUD /api/users
logs.js → GET /api/logs
services/
baserow.js → Client Baserow (pagination auto)
calc.js → Calculs métier (alertes, risques, formatage)
export-pdf.js → Générateurs PDF par vue (PDFKit)
export-xlsx.js → Générateur Excel (ExcelJS)
users.js → CRUD fichier users.json
logs.js → Logs connexion JSON
middleware/
auth.js → Vérification JWT Bearer
roles.js → RBAC (requireUser/Admin/SuperAdmin + filterByRegion)
```

BIN
balise-TT.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 KiB

View File

@ -0,0 +1,33 @@
// config.js - Configuration centralisée
const CONFIG = {
// API Baserow
API_URL: 'https://baserow.bolbol.tn/api/database/rows/table/856/',
API_URL_PIPELINE: 'https://baserow.bolbol.tn/api/database/rows/table/872/',
API_TOKEN: 'zJaDdkttN1gr6oPvd3cxfCXNwzvvwMMF',
// Rafraîchissement (en minutes)
REFRESH_INTERVAL: 5,
// Seuils alertes (en %)
SEUIL_STANDARD: 70,
SEUIL_MODERNISATION: 50,
SEUIL_CRITIQUE: 90,
// Délais alertes (en jours)
DELAI_CRITIQUE: 45,
DELAI_ATTENTION: 90,
// Thème par défaut
DEFAULT_THEME: 'light',
// Couleurs régions
REGION_COLORS: {
'Gabes': '#17A2B8',
'Gafsa': '#22C55E',
'Kebili': '#9333EA',
'Medenine': '#0EA5E9',
'Sfax': '#002855',
'Tataouine': '#14B8A6',
'Tozeur': '#818CF8'
}
};

28
config.js Normal file
View File

@ -0,0 +1,28 @@
// config.js - Configuration centralisée
const CONFIG = {
// Rafraîchissement (en minutes)
REFRESH_INTERVAL: 60,
// Seuils alertes (en %)
SEUIL_STANDARD: 70,
SEUIL_MODERNISATION: 50,
SEUIL_CRITIQUE: 90,
// Délais alertes (en jours)
DELAI_CRITIQUE: 45,
DELAI_ATTENTION: 90,
// Thème par défaut
DEFAULT_THEME: 'light',
// Couleurs régions
REGION_COLORS: {
'Gabes': '#17A2B8',
'Gafsa': '#22C55E',
'Kebili': '#9333EA',
'Medenine': '#0EA5E9',
'Sfax': '#002855',
'Tataouine': '#14B8A6',
'Tozeur': '#818CF8'
}
};

23
data/users.json Normal file
View File

@ -0,0 +1,23 @@
[
{
"id": 1,
"username": "nabil",
"password": "$2a$10$eQjI1sdYu4hgDnO3TanageD3/R.JqdWqAfzPVD4TxSW0Tjit0x8hu",
"role": "superadmin",
"region": "all"
},
{
"id": 2,
"username": "admin",
"password": "$2a$10$CbmqfDC8gYg0oOzGD7T2seIWQf1.vQQQSsSQbvVjkwOybmtogTTzS",
"role": "admin",
"region": "all"
},
{
"id": 3,
"username": "ikram",
"password": "$2a$10$pDeN2gaPE3GGXtScS.k9l.PAj99.Cxa637AbVrOlI7HbO9Cd4H2NW",
"role": "admin",
"region": "all"
}
]

1360
index-old-statique-page.html Normal file

File diff suppressed because it is too large Load Diff

1779
index.html Normal file

File diff suppressed because it is too large Load Diff

BIN
logo-TT.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

17
middleware/auth.js Normal file
View File

@ -0,0 +1,17 @@
const jwt = require('jsonwebtoken');
module.exports = function authMiddleware(req, res, next) {
const header = req.headers['authorization'] || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) {
return res.status(401).json({ error: 'Token manquant' });
}
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
res.status(401).json({ error: 'Token invalide ou expiré' });
}
};

32
middleware/roles.js Normal file
View File

@ -0,0 +1,32 @@
module.exports = {
requireSuperAdmin(req, res, next) {
if (req.user?.role !== 'superadmin') {
return res.status(403).json({ error: 'Accès réservé au super-administrateur' });
}
next();
},
requireAdmin(req, res, next) {
if (!['superadmin', 'admin'].includes(req.user?.role)) {
return res.status(403).json({ error: 'Accès réservé aux administrateurs' });
}
next();
},
requireUser(req, res, next) {
if (!req.user?.role) {
return res.status(403).json({ error: 'Accès non autorisé' });
}
next();
},
// Injecte req.regionFilter : null = toutes régions, string = région filtrée
filterByRegion(req, res, next) {
req.regionFilter = (req.user?.role === 'user' && req.user.region !== 'all')
? req.user.region
: null;
next();
},
};

2364
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "rla-api",
"version": "1.0.0",
"description": "API REST Marchés RLA - Tunisie Telecom Zone Sud",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"axios": "^1.7.2",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"docx": "^9.6.1",
"dotenv": "^16.4.5",
"exceljs": "^4.4.0",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"pdfkit": "^0.17.2",
"pptxgenjs": "^4.0.1"
}
}

46
routes/alertes.js Normal file
View File

@ -0,0 +1,46 @@
/**
* GET /api/alertes
* Alertes délais : marchés dont la date de fin approche
*/
const express = require('express');
const router = express.Router();
const { getMarches } = require('../services/baserow');
const {
isCloture, getDelaiRestant, niveauAlerte, normalizeMarche,
DELAI_ATTENTION,
} = require('../services/calc');
router.get('/', async (req, res) => {
try {
const { region, entrepreneur, projet, niveau } = req.query;
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 (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()));
// Uniquement marchés actifs avec alerte (délai ≤ DELAI_ATTENTION)
const actifs = rows.filter(r => !isCloture(r));
const alertes = actifs
.map(r => ({ ...r, _delai: getDelaiRestant(r) }))
.filter(r => r._delai !== null && r._delai <= DELAI_ATTENTION)
.map(r => ({ ...normalizeMarche(r), delai_restant: r._delai, niveau: niveauAlerte(r._delai) }))
.filter(r => !niveau || r.niveau === niveau)
.sort((a, b) => a.delai_restant - b.delai_restant);
res.json({
count: alertes.length,
critique: alertes.filter(a => a.niveau === 'critique').length,
attention: alertes.filter(a => a.niveau === 'attention').length,
items: alertes,
});
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}
});
module.exports = router;

37
routes/auth.js Normal file
View File

@ -0,0 +1,37 @@
const express = require('express');
const router = express.Router();
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { getUsers } = require('../services/users');
const { logLogin } = require('../services/logs');
// POST /api/auth/login
router.post('/login', async (req, res) => {
const { username, password } = req.body || {};
const ip = req.ip || req.socket?.remoteAddress || null;
const user = getUsers().find(u => u.username === username);
if (!user) {
logLogin({ username: username || '?', role: null, ip, success: false });
return res.status(401).json({ error: 'Identifiants invalides' });
}
const valid = await bcrypt.compare(password || '', user.password);
if (!valid) {
logLogin({ username, role: user.role, ip, success: false });
return res.status(401).json({ error: 'Identifiants invalides' });
}
logLogin({ username, role: user.role, ip, success: true });
const token = jwt.sign(
{ sub: user.username, id: user.id, username: user.username, role: user.role, region: user.region },
process.env.JWT_SECRET,
{ expiresIn: '8h' }
);
res.json({ token });
});
module.exports = router;

50
routes/clotures.js Normal file
View File

@ -0,0 +1,50 @@
/**
* GET /api/clotures
* Marchés clôturés
*/
const express = require('express');
const router = express.Router();
const { getMarches } = require('../services/baserow');
const { isCloture, normalizeMarche, parseNum, formatMontant } = require('../services/calc');
router.get('/', async (req, res) => {
try {
const { region, entrepreneur, projet, nature } = req.query;
const regionFilter = req.regionFilter;
let rows = await getMarches();
// Uniquement clôturés
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 (projet) rows = rows.filter(r => String(r.projet || '').toLowerCase().includes(projet.toLowerCase()));
if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase()));
const items = rows.map(normalizeMarche);
const totalBudget = rows.reduce((s, r) => s + parseNum(r.tot_marche ?? r.totmarche ?? r.montant), 0);
// Par région
const parRegion = {};
for (const r of rows) {
const reg = r.region || 'Inconnu';
parRegion[reg] = (parRegion[reg] || 0) + 1;
}
res.json({
count: items.length,
budget_total: formatMontant(totalBudget),
budget_total_raw: totalBudget,
par_region: parRegion,
items,
});
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}
});
module.exports = router;

49
routes/en-cours.js Normal file
View File

@ -0,0 +1,49 @@
/**
* GET /api/en-cours
* Marchés en cours (actifs, progress < 100%)
*/
const express = require('express');
const router = express.Router();
const { getMarches } = require('../services/baserow');
const { isCloture, normalizeMarche, parseNum } = require('../services/calc');
router.get('/', async (req, res) => {
try {
const { region, entrepreneur, projet, nature, statut } = req.query;
const regionFilter = req.regionFilter;
let rows = await getMarches();
// Actifs dont taux_phy < 100
rows = rows.filter(r => {
if (isCloture(r)) return false;
const t = parseNum(r.taux_phy ?? r.avt_phy);
return t < 100;
});
// 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 (projet) rows = rows.filter(r => String(r.projet || '').toLowerCase().includes(projet.toLowerCase()));
if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase()));
if (statut) rows = rows.filter(r => String(r.statut || '').toLowerCase().includes(statut.toLowerCase()));
const items = rows.map(normalizeMarche);
const tauxList = items.map(r => r.taux_phy_raw).filter(v => v > 0);
const tauxMoyen = tauxList.length
? Math.round(tauxList.reduce((a, b) => a + b, 0) / tauxList.length * 10) / 10
: 0;
res.json({
count: items.length,
taux_avancement_moyen: tauxMoyen,
items,
});
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}
});
module.exports = router;

48
routes/en-service.js Normal file
View File

@ -0,0 +1,48 @@
/**
* GET /api/en-service
* Marchés actifs en service (non clôturés)
*/
const express = require('express');
const router = express.Router();
const { getMarches } = require('../services/baserow');
const { isCloture, normalizeMarche, parseNum } = require('../services/calc');
router.get('/', async (req, res) => {
try {
const { region, entrepreneur, projet, nature, statut } = req.query;
const regionFilter = req.regionFilter;
let rows = await getMarches();
// Uniquement non clôturés
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 (projet) rows = rows.filter(r => String(r.projet || '').toLowerCase().includes(projet.toLowerCase()));
if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase()));
if (statut) rows = rows.filter(r => String(r.statut || '').toLowerCase().includes(statut.toLowerCase()));
const items = rows.map(normalizeMarche);
// Agrégats
const totalBudget = rows.reduce((s, r) => s + parseNum(r.tot_marche ?? r.totmarche ?? r.montant), 0);
const tauxList = items.map(r => r.taux_phy_raw).filter(v => v > 0);
const tauxMoyen = tauxList.length
? Math.round(tauxList.reduce((a, b) => a + b, 0) / tauxList.length * 10) / 10
: 0;
res.json({
count: items.length,
budget_total_raw: totalBudget,
taux_avancement_moyen: tauxMoyen,
items,
});
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}
});
module.exports = router;

310
routes/export.js Normal file
View File

@ -0,0 +1,310 @@
/**
* routes/export.js
* Exports PDF, PPTX, XLSX, DOCX par vue
*
* PDF tous les rôles authentifiés
* PPTX / XLSX / DOCX SuperAdmin uniquement (vérifié ici)
*/
const express = require('express');
const router = express.Router();
const { getMarches } = require('../services/baserow');
const {
isCloture, normalizeMarche, parseNum, formatMontant,
getDelaiRestant, niveauAlerte, niveauAvancement, niveauRisque,
DELAI_CRITIQUE, DELAI_ATTENTION, SEUIL_STANDARD, SEUIL_CRITIQUE_PCT,
} = require('../services/calc');
const pdfGen = require('../services/export-pdf');
const { generateXlsx } = require('../services/export-xlsx');
// ─── Helpers ──────────────────────────────────────────────────────────────────
function applyFilters(rows, req) {
const { region, entrepreneur, projet, nature, statut } = req.query;
const regionFilter = req.regionFilter;
let r = rows;
if (regionFilter) r = r.filter(x => x.region === regionFilter);
else if (region) r = r.filter(x => x.region === region);
if (entrepreneur) r = r.filter(x => String(x.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
if (projet) r = r.filter(x => String(x.projet || '').toLowerCase().includes(projet.toLowerCase()));
if (nature) r = r.filter(x => String(x.nature || '').toLowerCase().includes(nature.toLowerCase()));
if (statut) r = r.filter(x => String(x.statut || '').toLowerCase().includes(statut.toLowerCase()));
return r;
}
async function buildViewData(view, req) {
const allRows = await getMarches();
let rows = applyFilters(allRows, req);
const actifs = rows.filter(r => !isCloture(r));
const clotures = rows.filter(r => isCloture(r));
switch (view) {
case 'synthese': {
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;
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; }
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)}))
.sort((a,b)=>a.delai_restant-b.delai_restant);
return {
total: rows.length, actifs: actifs.length, clotures: clotures.length,
taux_avancement_moyen: tauxMoyen, par_statut: parStatut,
budget: { total: formatMontant(totalBudget), total_raw: totalBudget },
alertes_delais: { count: alertes.length, critique: alertes.filter(a=>a.niveau==='critique').length, items: alertes },
};
}
case 'alertes': {
const items = actifs
.map(r=>({...r,_d:getDelaiRestant(r)}))
.filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION)
.map(r=>({...normalizeMarche(r),delai_restant:r._d,niveau:niveauAlerte(r._d),niveau_alerte:niveauAlerte(r._d)}))
.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-cours': {
const enCours = actifs.filter(r=>parseNum(r.taux_phy??r.avt_phy)<100);
return { count: enCours.length, items: enCours.map(normalizeMarche) };
}
case 'par-region': {
const ALL_REGIONS = ['Gabes','Gafsa','Kebili','Medenine','Sfax','Tataouine','Tozeur'];
const regions = ALL_REGIONS.map(reg => {
const regActifs = actifs.filter(r=>(r.region||'')=== reg);
const regTotal = rows.filter(r=>(r.region||'')=== reg);
const tauxList = regActifs.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;
const budget = regActifs.reduce((s,r)=>s+parseNum(r.tot_marche??r.totmarche??r.montant),0);
const alertes = regActifs
.map(r=>({...r,_d:getDelaiRestant(r)}))
.filter(r=>r._d!==null&&r._d<=DELAI_ATTENTION);
return { region: reg, actifs: regActifs.length, clotures: regTotal.length-regActifs.length, total: regTotal.length,
taux_moyen: tauxMoyen, budget: formatMontant(budget), alertes_count: alertes.length,
alertes_critique: alertes.filter(r=>r._d<=DELAI_CRITIQUE).length };
});
return { count: regions.length, regions };
}
case 'clotures': {
const totalBudget = clotures.reduce((s,r)=>s+parseNum(r.tot_marche??r.totmarche??r.montant),0);
return { count: clotures.length, budget_total: formatMontant(totalBudget), items: clotures.map(normalizeMarche) };
}
case 'pilotage': {
const items = actifs.map(r=>{
const d = getDelaiRestant(r);
return {...normalizeMarche(r), delai_restant:d, niveau_alerte:niveauAlerte(d), niveau_avancement:niveauAvancement(r.taux_phy??r.avt_phy,r.nature)};
});
const normal = items.filter(r=>r.niveau_avancement==='normal');
const sous = items.filter(r=>r.niveau_avancement==='sous_avancement');
const dep = items.filter(r=>r.niveau_avancement==='dépassé');
return { resume:{total:items.length,normal:normal.length,sous_avancement:sous.length,depasse:dep.length},
normal, sous_avancement:sous, depasse:dep, items };
}
case 'matrice-risque': {
const items = actifs.map(r=>{
const d=getDelaiRestant(r);
return {...normalizeMarche(r),delai_restant:d,niveau_alerte:niveauAlerte(d),niveau_risque:niveauRisque(r),
score_delai: d===null?1:d<=DELAI_CRITIQUE?3:d<=DELAI_ATTENTION?2:1,
score_avancement: parseNum(r.taux_phy??r.avt_phy)>=SEUIL_CRITIQUE_PCT?3:parseNum(r.taux_phy??r.avt_phy)>=SEUIL_STANDARD?2:1,
};
});
const pn={critique:0,élevé:0,moyen:0,faible:0};
for(const i of items){ if(pn[i.niveau_risque]!==undefined) pn[i.niveau_risque]++; else pn[i.niveau_risque]=1; }
return { total:items.length, par_niveau:pn, items };
}
default:
return { items: actifs.map(normalizeMarche) };
}
}
// ─── Route PDF ────────────────────────────────────────────────────────────────
router.get('/pdf', async (req, res) => {
try {
const view = req.query.view || 'synthese';
const data = await buildViewData(view, req);
let buf;
switch (view) {
case 'synthese': buf = await pdfGen.generateSynthese(data); break;
case 'alertes': buf = await pdfGen.generateAlertes(data); break;
case 'en-service': buf = await pdfGen.generateEnService(data); break;
case 'en-cours': buf = await pdfGen.generateEnCours(data); break;
case 'par-region': buf = await pdfGen.generateParRegion(data); break;
case 'clotures': buf = await pdfGen.generateClotures(data); break;
case 'pilotage': buf = await pdfGen.generatePilotage(data); break;
case 'matrice-risque': buf = await pdfGen.generateMatriceRisque(data); break;
default: buf = await pdfGen.generateGeneric(view, data); break;
}
const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.pdf`;
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': buf.length,
});
res.end(buf);
} catch (err) {
res.status(502).json({ error: 'Erreur génération PDF', detail: err.message });
}
});
// ─── Route XLSX (SuperAdmin) ──────────────────────────────────────────────────
router.get('/xlsx', async (req, res) => {
if (req.user?.role !== 'superadmin') {
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`;
res.set({
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${filename}"`,
});
res.end(buf);
} catch (err) {
res.status(502).json({ error: 'Erreur génération XLSX', detail: err.message });
}
});
// ─── Route PPTX (SuperAdmin) ──────────────────────────────────────────────────
router.get('/pptx', async (req, res) => {
if (req.user?.role !== 'superadmin') {
return res.status(403).json({ error: 'Accès réservé au SuperAdmin' });
}
try {
const PptxGenJS = require('pptxgenjs');
const view = req.query.view || 'synthese';
const data = await buildViewData(view, req);
const pptx = new PptxGenJS();
pptx.layout = 'LAYOUT_WIDE';
pptx.author = 'RLA API';
pptx.company = 'Tunisie Telecom Zone Sud';
pptx.subject = `RLA — ${view}`;
// 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 filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.pptx`;
const buf = await pptx.write({ outputType: 'nodebuffer' });
res.set({
'Content-Type': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'Content-Disposition': `attachment; filename="${filename}"`,
});
res.end(buf);
} catch (err) {
res.status(502).json({ error: 'Erreur génération PPTX', detail: err.message });
}
});
// ─── Route DOCX (SuperAdmin) ──────────────────────────────────────────────────
router.get('/docx', async (req, res) => {
if (req.user?.role !== 'superadmin') {
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 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 })],
}),
];
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 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,
})),
})),
];
children.push(new Table({
rows: tableRows,
width: { size: 100, type: WidthType.PERCENTAGE },
}));
}
const doc = new Document({ sections: [{ children }] });
const buf = await Packer.toBuffer(doc);
const filename = `RLA_${view}_${new Date().toISOString().slice(0,10)}.docx`;
res.set({
'Content-Type': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'Content-Disposition': `attachment; filename="${filename}"`,
});
res.end(buf);
} catch (err) {
res.status(502).json({ error: 'Erreur génération DOCX', detail: err.message });
}
});
module.exports = router;

10
routes/logs.js Normal file
View File

@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const { getLogs } = require('../services/logs');
// GET /api/logs
router.get('/', (req, res) => {
res.json(getLogs(100));
});
module.exports = router;

26
routes/marches.js Normal file
View File

@ -0,0 +1,26 @@
const express = require('express');
const router = express.Router();
const { getMarches, getMarcheById } = require('../services/baserow');
// GET /api/marches
router.get('/', async (req, res) => {
try {
const rows = await getMarches();
res.json({ count: rows.length, results: rows });
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}
});
// GET /api/marches/:id
router.get('/:id', async (req, res) => {
try {
const row = await getMarcheById(req.params.id);
res.json(row);
} catch (err) {
const status = err.response?.status === 404 ? 404 : 502;
res.status(status).json({ error: 'Marché introuvable', detail: err.message });
}
});
module.exports = router;

90
routes/matrice-risque.js Normal file
View File

@ -0,0 +1,90 @@
/**
* GET /api/matrice-risque
* Matrice de risque : probabilité × impact (délai × avancement)
*/
const express = require('express');
const router = express.Router();
const { getMarches } = require('../services/baserow');
const {
isCloture, normalizeMarche,
getDelaiRestant, niveauAlerte, niveauRisque,
parseNum, SEUIL_STANDARD, SEUIL_CRITIQUE_PCT,
DELAI_CRITIQUE, DELAI_ATTENTION,
} = require('../services/calc');
/**
* Score délai : 1 (ok) 3 (critique)
*/
function scoreDelai(delai) {
if (delai === null) return 1; // inconnu → faible
if (delai <= DELAI_CRITIQUE) return 3;
if (delai <= DELAI_ATTENTION) return 2;
return 1;
}
/**
* Score avancement : 1 (ok) 3 (dépassé)
*/
function scoreAvancement(tauxPhy) {
const t = parseNum(tauxPhy);
if (t >= SEUIL_CRITIQUE_PCT) return 3;
if (t >= SEUIL_STANDARD) return 2;
return 1;
}
router.get('/', async (req, res) => {
try {
const { region, entrepreneur, nature } = req.query;
const regionFilter = req.regionFilter;
let rows = await getMarches();
rows = rows.filter(r => !isCloture(r));
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()));
// Matrice 3×3 : delai (1-3) × avancement (1-3)
const matrice = {};
for (let d = 1; d <= 3; d++) {
for (let a = 1; a <= 3; a++) {
matrice[`${d}_${a}`] = { score_delai: d, score_avancement: a, items: [] };
}
}
const items = rows.map(r => {
const delai = getDelaiRestant(r);
const sd = scoreDelai(delai);
const sa = scoreAvancement(r.taux_phy ?? r.avt_phy);
const risque = niveauRisque(r);
const m = normalizeMarche(r);
return { ...m, score_delai: sd, score_avancement: sa, niveau_risque: risque };
});
// Remplissage matrice
for (const item of items) {
const key = `${item.score_delai}_${item.score_avancement}`;
if (matrice[key]) matrice[key].items.push(item);
}
// Comptage par niveau de risque
const parNiveau = { critique: 0, élevé: 0, moyen: 0, faible: 0 };
for (const item of items) {
const n = item.niveau_risque;
if (parNiveau[n] !== undefined) parNiveau[n]++;
else parNiveau[n] = 1;
}
res.json({
total: items.length,
par_niveau: parNiveau,
matrice, // grille 3×3
items, // liste complète triée par risque décroissant
});
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}
});
module.exports = router;

73
routes/par-region.js Normal file
View File

@ -0,0 +1,73 @@
/**
* GET /api/par-region
* Agrégation des marchés par région
*/
const express = require('express');
const router = express.Router();
const { getMarches } = require('../services/baserow');
const {
isCloture, parseNum, formatMontant, getDelaiRestant,
niveauAlerte, DELAI_ATTENTION,
} = require('../services/calc');
const ALL_REGIONS = ['Gabes', 'Gafsa', 'Kebili', 'Medenine', 'Sfax', 'Tataouine', 'Tozeur'];
router.get('/', async (req, res) => {
try {
const { nature, entrepreneur, statut } = req.query;
const regionFilter = req.regionFilter;
let rows = await getMarches();
// Filtres complémentaires
if (nature) rows = rows.filter(r => String(r.nature || '').toLowerCase().includes(nature.toLowerCase()));
if (entrepreneur) rows = rows.filter(r => String(r.entrepreneur || '').toLowerCase().includes(entrepreneur.toLowerCase()));
if (statut) rows = rows.filter(r => String(r.statut || '').toLowerCase().includes(statut.toLowerCase()));
const activeRows = rows.filter(r => !isCloture(r));
const regions = (regionFilter ? [regionFilter] : ALL_REGIONS).map(reg => {
const regRows = activeRows.filter(r => (r.region || r.region_csc || '') === reg);
const regTotal = rows.filter(r => (r.region || r.region_csc || '') === reg);
const clotures = regTotal.filter(r => isCloture(r)).length;
const budget = regRows.reduce((s, r) => s + parseNum(r.tot_marche ?? r.totmarche ?? r.montant), 0);
const tauxList = regRows.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;
const alertes = regRows
.map(r => ({ ...r, _delai: getDelaiRestant(r) }))
.filter(r => r._delai !== null && r._delai <= DELAI_ATTENTION)
.map(r => ({ id: r.id, ref: r.ref || '', projet: r.projet || '', delai_restant: r._delai, niveau: niveauAlerte(r._delai) }))
.sort((a, b) => a.delai_restant - b.delai_restant);
const parNature = {};
for (const r of regRows) {
const n = r.nature || 'Non défini';
parNature[n] = (parNature[n] || 0) + 1;
}
return {
region: reg,
actifs: regRows.length,
clotures,
total: regTotal.length,
budget_raw: budget,
budget: formatMontant(budget),
taux_moyen: tauxMoyen,
alertes_count: alertes.length,
alertes_critique: alertes.filter(a => a.niveau === 'critique').length,
alertes,
par_nature: parNature,
};
});
res.json({ count: regions.length, regions });
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}
});
module.exports = router;

73
routes/pilotage.js Normal file
View File

@ -0,0 +1,73 @@
/**
* GET /api/pilotage-proactif
* Pilotage proactif : classement par niveau d'avancement vs seuils
*/
const express = require('express');
const router = express.Router();
const { getMarches } = require('../services/baserow');
const {
isCloture, normalizeMarche, parseNum,
niveauAvancement, getDelaiRestant, niveauAlerte,
SEUIL_STANDARD, SEUIL_MODERNISATION, SEUIL_CRITIQUE_PCT,
} = require('../services/calc');
router.get('/', async (req, res) => {
try {
const { region, entrepreneur, nature, niveau } = 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()));
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),
};
});
// 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é');
// Si filtre sur niveau
let result = items;
if (niveau === 'normal') result = normal;
else if (niveau === 'sous') result = sous_avancement;
else if (niveau === 'depasse') result = depasse;
res.json({
seuils: {
standard: SEUIL_STANDARD,
modernisation: SEUIL_MODERNISATION,
critique: SEUIL_CRITIQUE_PCT,
},
resume: {
normal: normal.length,
sous_avancement: sous_avancement.length,
depasse: depasse.length,
total: items.length,
},
normal,
sous_avancement,
depasse,
items: result,
});
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}
});
module.exports = router;

15
routes/pipeline.js Normal file
View File

@ -0,0 +1,15 @@
const express = require('express');
const router = express.Router();
const { getPipeline } = require('../services/baserow');
// GET /api/pipeline
router.get('/', async (req, res) => {
try {
const rows = await getPipeline();
res.json({ count: rows.length, results: rows });
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}
});
module.exports = router;

80
routes/stats.js Normal file
View File

@ -0,0 +1,80 @@
const express = require('express');
const router = express.Router();
const { getMarches } = require('../services/baserow');
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');
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 tauxMoyen = tauxList.length
? 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',
}));
res.json({
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,
attention: alertes.filter(a => a.niveau === 'attention').length,
items: alertes,
},
});
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}
});
module.exports = router;

122
routes/synthese.js Normal file
View File

@ -0,0 +1,122 @@
/**
* GET /api/synthese
* Vue synthèse globale : KPIs, répartitions, alertes, projections
*/
const express = require('express');
const router = express.Router();
const { getMarches } = require('../services/baserow');
const {
parseNum, formatMontant, isCloture,
getDelaiRestant, niveauAlerte, niveauAvancement,
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
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 (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()));
const actifs = rows.filter(r => !isCloture(r));
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);
// Avancement moyen physique
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
const parStatut = {};
for (const r of rows) {
const s = String(r.statut || 'Inconnu');
parStatut[s] = (parStatut[s] || 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);
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;
}
// 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 || '',
projet: r.projet || '',
region: r.region || '',
entrepreneur: r.entrepreneur || '',
delai_restant: r._delai,
niveau: niveauAlerte(r._delai),
}))
.sort((a, b) => a.delai_restant - b.delai_restant);
// Pilotage proactif (niveaux d'avancement)
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++;
}
res.json({
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,
},
taux_avancement_moyen: tauxMoyen,
par_statut: parStatut,
par_region: parRegion,
par_nature: parNature,
alertes_delais: {
count: alertes.length,
critique: alertes.filter(a => a.niveau === 'critique').length,
attention: alertes.filter(a => a.niveau === 'attention').length,
items: alertes,
},
pilotage,
});
} catch (err) {
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
}
});
module.exports = router;

53
routes/users.js Normal file
View File

@ -0,0 +1,53 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const { getUsers, saveUsers } = require('../services/users');
// GET /api/users — liste sans mot de passe
router.get('/', (req, res) => {
const users = getUsers().map(({ password, ...u }) => u);
res.json(users);
});
// POST /api/users — créer un utilisateur
router.post('/', async (req, res) => {
const { username, password, role = 'user', region = 'all' } = req.body || {};
if (!username || !password) {
return res.status(400).json({ error: 'username et password requis' });
}
const users = getUsers();
if (users.find(u => u.username === username)) {
return res.status(409).json({ error: 'Identifiant déjà utilisé' });
}
const id = Math.max(0, ...users.map(u => u.id || 0)) + 1;
const hash = await bcrypt.hash(password, 10);
const newUser = { id, username, password: hash, role, region };
users.push(newUser);
saveUsers(users);
res.status(201).json({ id, username, role, region });
});
// DELETE /api/users/:id — supprimer un utilisateur
router.delete('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const users = getUsers();
const idx = users.findIndex(u => u.id === id);
if (idx === -1) {
return res.status(404).json({ error: 'Utilisateur introuvable' });
}
if (users[idx].username === req.user.username) {
return res.status(400).json({ error: 'Impossible de supprimer son propre compte' });
}
users.splice(idx, 1);
saveUsers(users);
res.json({ ok: true });
});
module.exports = router;

51
server.js Normal file
View File

@ -0,0 +1,51 @@
require('dotenv').config({ override: true });
const express = require('express');
const cors = require('cors');
const auth = require('./middleware/auth');
const { requireSuperAdmin, requireAdmin, requireUser, filterByRegion } = require('./middleware/roles');
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
app.use(express.static(__dirname));
// ─── Public ──────────────────────────────────────────────────────────────────
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', project: 'RLA API v1', date: new Date().toISOString() });
});
app.use('/api/auth', require('./routes/auth'));
// ─── Protégées (user+) ───────────────────────────────────────────────────────
app.use('/api/marches', auth, requireUser, filterByRegion, require('./routes/marches'));
app.use('/api/stats', auth, requireUser, filterByRegion, require('./routes/stats'));
app.use('/api/synthese', auth, requireUser, filterByRegion, require('./routes/synthese'));
app.use('/api/alertes', auth, requireUser, filterByRegion, require('./routes/alertes'));
app.use('/api/en-service', auth, requireUser, filterByRegion, require('./routes/en-service'));
app.use('/api/en-cours', auth, requireUser, filterByRegion, require('./routes/en-cours'));
app.use('/api/par-region', auth, requireUser, filterByRegion, require('./routes/par-region'));
app.use('/api/clotures', auth, requireUser, filterByRegion, require('./routes/clotures'));
app.use('/api/pilotage-proactif',auth, requireUser, filterByRegion, require('./routes/pilotage'));
app.use('/api/matrice-risque', auth, requireUser, filterByRegion, require('./routes/matrice-risque'));
app.use('/api/export', auth, requireUser, filterByRegion, require('./routes/export'));
// ─── Protégées (admin+) ──────────────────────────────────────────────────────
app.use('/api/pipeline', auth, requireAdmin, require('./routes/pipeline'));
// ─── Protégées (superadmin) ──────────────────────────────────────────────────
app.use('/api/users', auth, requireSuperAdmin, require('./routes/users'));
app.use('/api/logs', auth, requireSuperAdmin, require('./routes/logs'));
// ─── Start ───────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`RLA API v1 démarrée sur le port ${PORT}`);
console.log(`Endpoints disponibles sur http://localhost:${PORT}/api/`);
});

44
services/baserow.js Normal file
View File

@ -0,0 +1,44 @@
const axios = require('axios');
const BASE_URL = process.env.BASEROW_API_URL;
const TOKEN = process.env.BASEROW_TOKEN;
const TABLE_MARCHES = process.env.BASEROW_TABLE_MARCHES;
const TABLE_PIPELINE = process.env.BASEROW_TABLE_PIPELINE;
const client = axios.create({
baseURL: BASE_URL,
headers: { Authorization: `Token ${TOKEN}` },
});
// Récupère toutes les lignes d'une table (gestion pagination)
async function fetchAllRows(tableId, filters = {}) {
const rows = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const { data } = await client.get(`/database/rows/table/${tableId}/`, {
params: { page, size: 200, user_field_names: true, ...filters },
});
rows.push(...data.results);
hasMore = data.next !== null;
page++;
}
return rows;
}
// Récupère une seule ligne par ID
async function fetchRow(tableId, rowId) {
const { data } = await client.get(
`/database/rows/table/${tableId}/${rowId}/`,
{ params: { user_field_names: true } }
);
return data;
}
module.exports = {
getMarches: (filters) => fetchAllRows(TABLE_MARCHES, filters),
getMarcheById: (id) => fetchRow(TABLE_MARCHES, id),
getPipeline: (filters) => fetchAllRows(TABLE_PIPELINE, filters),
};

155
services/calc.js Normal file
View File

@ -0,0 +1,155 @@
/**
* services/calc.js
* Helpers partagés : calculs, formatage, seuils métier RLA
*/
const SEUIL_STANDARD = parseFloat(process.env.SEUIL_STANDARD || 70);
const SEUIL_MODERNISATION = parseFloat(process.env.SEUIL_MODERNISATION || 50);
const SEUIL_CRITIQUE_PCT = parseFloat(process.env.SEUIL_CRITIQUE_PCT || 90);
const DELAI_CRITIQUE = parseInt(process.env.DELAI_CRITIQUE || 45, 10);
const DELAI_ATTENTION = parseInt(process.env.DELAI_ATTENTION || 90, 10);
// ─── Parseurs ───────────────────────────────────────────────────────────────
function parseNum(v) {
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
}
const dt = new Date(d);
return isNaN(dt.getTime()) ? null : dt;
}
// ─── Formatage ───────────────────────────────────────────────────────────────
function formatMontant(v) {
const n = parseNum(v);
if (n === 0) return '—';
return n.toLocaleString('fr-TN', { minimumFractionDigits: 0, maximumFractionDigits: 3 }) + ' DT';
}
function formatDateFR(d) {
const dt = parseDateFR(d);
if (!dt) return '—';
return dt.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function formatPct(v) {
const n = parseNum(v);
return n === 0 ? '0 %' : `${n.toFixed(1)} %`;
}
// ─── Détermination de statuts métier ─────────────────────────────────────────
function isCloture(r) {
const obs = String(r.observation || r.statut || '').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;
}
const fin = r.date_fin || r.datefin;
const dt = parseDateFR(fin);
if (!dt) return null;
return Math.ceil((dt - new Date()) / 86400000);
}
function niveauAlerte(delai) {
if (delai === null) return 'indéterminé';
if (delai <= DELAI_CRITIQUE) return 'critique';
if (delai <= DELAI_ATTENTION) return 'attention';
return 'normal';
}
function niveauAvancement(tauxPhy, nature) {
const t = parseNum(tauxPhy);
const seuil = String(nature || '').toLowerCase().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 ─────────────────────────────────────────────────
function niveauRisque(r) {
const delai = getDelaiRestant(r);
const avt = parseNum(r.taux_phy || r.avt_fin);
const nd = niveauAlerte(delai);
if (nd === 'critique' || avt >= SEUIL_CRITIQUE_PCT) return 'critique';
if (nd === 'attention') return 'élevé';
if (avt >= SEUIL_STANDARD) return 'moyen';
return 'faible';
}
// ─── Normalisation d'un marché ───────────────────────────────────────────────
function normalizeMarche(r) {
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;
return {
id: r.id,
ref: r.ref || r.reference || r.id_marche || '',
projet: r.projet || '',
region: r.region || r.region_csc || '',
entrepreneur: r.entrepreneur || '',
nature: r.nature || '',
statut: r.statut || '',
observation: r.observation || '',
cloture: isCloture(r),
date_debut: formatDateFR(r.date_debut),
date_fin: formatDateFR(r.date_fin || r.datefin),
date_cloture: formatDateFR(r.date_cloture),
montant_raw: montant,
montant: formatMontant(montant),
consomme_raw: consomme,
consomme: formatMontant(consomme),
restant_raw: restant,
restant: formatMontant(restant),
taux_phy_raw: tauxPhy,
taux_phy: formatPct(tauxPhy),
taux_fin_raw: tauxFin,
taux_fin: formatPct(tauxFin),
delai_restant: delaiRestant,
niveau_alerte: niveauAlerte(delaiRestant),
niveau_avancement: niveauAvancement(r.taux_phy ?? r.avt_phy, r.nature),
niveau_risque: niveauRisque(r),
};
}
// ─── 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,
normalizeMarche,
};

301
services/export-pdf.js Normal file
View File

@ -0,0 +1,301 @@
/**
* services/export-pdf.js
* Génération de PDF par vue avec PDFKit (async/Promise)
*/
const PDFDocument = require('pdfkit');
// Palette RLA / McKinsey
const C = {
primary: '#002D62',
accent: '#E31837',
success: '#10b981',
warning: '#f59e0b',
danger: '#ef4444',
muted: '#6b7280',
light: '#f8fafc',
border: '#e2e8f0',
text: '#1e293b',
white: '#ffffff',
};
function hex(h) {
const s = h.replace('#', '');
return [parseInt(s.slice(0,2),16), parseInt(s.slice(2,4),16), parseInt(s.slice(4,6),16)];
}
const fill = (d, h) => d.fillColor(hex(h));
const stroke = (d, h) => d.strokeColor(hex(h));
// ─── Collect PDF to Buffer ────────────────────────────────────────────────────
function pdfToBuffer(doc, writeFn) {
return new Promise((resolve, reject) => {
const chunks = [];
doc.on('data', c => chunks.push(c));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', err => reject(err));
try { writeFn(doc); doc.end(); } catch (e) { reject(e); }
});
}
// ─── Header / Footer ─────────────────────────────────────────────────────────
function header(doc, title, subtitle) {
fill(doc, C.primary);
doc.rect(0, 0, doc.page.width, 68).fill();
fill(doc, C.white);
doc.fontSize(17).font('Helvetica-Bold').text(title || '', 40, 18, { width: 500 });
if (subtitle) doc.fontSize(9).font('Helvetica').text(subtitle, 40, 42, { width: 500 });
const now = new Date().toLocaleDateString('fr-FR', { day:'2-digit', month:'2-digit', year:'numeric' });
doc.fontSize(8).text(`Édité le ${now}`, 0, 50, { align:'right', width: doc.page.width - 40 });
doc.y = 88;
}
function footer(doc, n) {
const y = doc.page.height - 38;
fill(doc, C.border);
doc.rect(0, y - 4, doc.page.width, 1).fill();
fill(doc, C.muted);
doc.fontSize(7.5).font('Helvetica')
.text('RLA — Marchés Tunisie Telecom Zone Sud', 40, y)
.text(`Page ${n}`, 0, y, { align:'right', width: doc.page.width - 40 });
}
// ─── KPI Box ─────────────────────────────────────────────────────────────────
function kpiBox(doc, x, y, w, h, label, value, color) {
fill(doc, C.light);
doc.rect(x, y, w, h).fill();
fill(doc, color || C.primary);
doc.rect(x, y, 4, h).fill();
fill(doc, C.muted);
doc.fontSize(7.5).font('Helvetica').text(label, x+10, y+7, { width: w-14 });
fill(doc, C.text);
doc.fontSize(16).font('Helvetica-Bold').text(String(value ?? '—'), x+10, y+20, { width: w-14 });
}
// ─── Table ────────────────────────────────────────────────────────────────────
function table(doc, { title, headers, rows, colWidths }) {
const pageW = doc.page.width - 80;
const totalW = colWidths.reduce((a, b) => a + b, 0);
const scale = pageW / totalW;
const widths = colWidths.map(w => Math.round(w * scale));
let y = doc.y;
if (title) {
fill(doc, C.text);
doc.fontSize(10).font('Helvetica-Bold').text(title, 40, y);
y += 16;
}
function drawHeader() {
fill(doc, C.primary);
doc.rect(40, y, pageW, 17).fill();
fill(doc, C.white);
doc.fontSize(7.5).font('Helvetica-Bold');
let x = 40;
for (let i = 0; i < headers.length; i++) {
doc.text(headers[i], x + 3, y + 4, { width: widths[i] - 6, ellipsis: true });
x += widths[i];
}
y += 17;
}
drawHeader();
let alt = false;
for (const row of rows) {
if (y > doc.page.height - 75) {
footer(doc, '—');
doc.addPage();
header(doc, '', '');
y = doc.y;
drawHeader();
alt = false;
}
const rowH = 15;
fill(doc, alt ? '#f1f5f9' : C.white);
doc.rect(40, y, pageW, rowH).fill();
fill(doc, C.text);
doc.fontSize(7).font('Helvetica');
let x = 40;
for (let i = 0; i < row.length; i++) {
doc.text(String(row[i] ?? '—'), x + 3, y + 4, { width: widths[i] - 6, ellipsis: true });
x += widths[i];
}
stroke(doc, C.border);
doc.moveTo(40, y + rowH).lineTo(40 + pageW, y + rowH).stroke();
y += rowH;
alt = !alt;
}
doc.y = y + 8;
}
const NL = n => ({ critique:'CRITIQUE', attention:'ATTENTION', élevé:'ÉLEVÉ', moyen:'MOYEN', faible:'FAIBLE', normal:'NORMAL', sous_avancement:'SOUS-AVT' }[n] || String(n||'').toUpperCase());
// ─── Vues ─────────────────────────────────────────────────────────────────────
function generateSynthese(data) {
const doc = new PDFDocument({ margin:40, size:'A4' });
return pdfToBuffer(doc, d => {
header(d, 'Synthèse Globale — Marchés RLA', 'Tunisie Telecom Zone Sud');
const kpis = [
{ l:'Total Marchés', v: data.total, c: C.primary },
{ l:'Actifs', v: data.actifs, c: C.success },
{ l:'Clôturés', v: data.clotures,c: C.muted },
{ l:'Alertes', v: data.alertes_delais?.count||0, c: C.warning },
{ l:'Avt. Moy.(%)', v:`${data.taux_avancement_moyen||0}%`, c: C.accent },
];
let kx = 40;
for (const k of kpis) { kpiBox(d, kx, d.y, 95, 48, k.l, k.v, k.c); kx += 101; }
d.y += 58;
if (data.budget) {
fill(d, C.text); d.fontSize(10).font('Helvetica-Bold').text('Budget', 40, d.y); d.y += 12;
for (const [l, v] of [['Total',data.budget.total],['Consommé',data.budget.consomme],['Restant',data.budget.restant]]) {
fill(d, C.muted); d.fontSize(8.5).font('Helvetica').text(l+' :', 40, d.y, {width:110});
fill(d, C.text); d.fontSize(8.5).font('Helvetica-Bold').text(v, 155, d.y, {width:250}); d.y += 13;
}
}
if (data.par_statut) {
d.y += 6;
table(d, { title:'Répartition par Statut', headers:['Statut','Nombre'], colWidths:[350,100],
rows: Object.entries(data.par_statut).map(([s,n])=>[s,n]) });
}
if (data.alertes_delais?.items?.length) {
const items = data.alertes_delais.items.slice(0,10);
table(d, { title:`Top ${items.length} Alertes Délais`, headers:['Réf.','Projet','Région','Entrepreneur','J. Rest.','Niveau'],
colWidths:[70,140,65,120,55,55], rows: items.map(a=>[a.ref,a.projet,a.region,a.entrepreneur,a.delai_restant,NL(a.niveau)]) });
}
footer(d, 1);
});
}
function generateAlertes(data) {
const doc = new PDFDocument({ margin:40, size:'A4', layout:'landscape' });
return pdfToBuffer(doc, d => {
header(d, 'Alertes Délais — Marchés RLA', `${data.count||0} alerte(s) — dont ${data.critique||0} critique(s)`);
if (data.items?.length) {
table(d, { title:'Liste des Alertes', headers:['Réf.','Projet','Région','Entrepreneur','Taux Phy.','Date Fin','J. Rest.','Niveau'],
colWidths:[70,155,65,125,55,65,55,55], rows: data.items.map(a=>[a.ref,a.projet,a.region,a.entrepreneur,a.taux_phy,a.date_fin,a.delai_restant,NL(a.niveau_alerte||a.niveau)]) });
} else { fill(d, C.success); d.fontSize(14).font('Helvetica-Bold').text('Aucune alerte active.', {align:'center'}); }
footer(d, 1);
});
}
function generateEnService(data) {
const doc = new PDFDocument({ margin:40, size:'A4', layout:'landscape' });
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)]) });
footer(d, 1);
});
}
function generateEnCours(data) {
const doc = new PDFDocument({ margin:40, size:'A4', layout:'landscape' });
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]) });
footer(d, 1);
});
}
function generateParRegion(data) {
const doc = new PDFDocument({ margin:40, size:'A4' });
return pdfToBuffer(doc, d => {
header(d, 'Vue par Région — Marchés RLA', `${data.count||0} région(s)`);
for (const reg of data.regions||[]) {
if (d.y > d.page.height - 120) { footer(d, '—'); d.addPage(); header(d,'',''); }
fill(d, C.primary); d.rect(40, d.y, d.page.width-80, 22).fill();
fill(d, C.white); d.fontSize(11).font('Helvetica-Bold').text(reg.region, 52, d.y+5); d.y += 30;
const kpis = [
{l:'Actifs',v:reg.actifs},{l:'Clôturés',v:reg.clotures},
{l:'Alertes',v:reg.alertes_count,c:C.warning},{l:'Critiques',v:reg.alertes_critique,c:C.danger},
{l:'Taux moy.',v:`${reg.taux_moyen}%`},
];
let kx=40; for(const k of kpis){kpiBox(d,kx,d.y,92,40,k.l,k.v,k.c||C.primary);kx+=98;}
d.y+=50; d.moveDown(0.3);
}
footer(d, 1);
});
}
function generateClotures(data) {
const doc = new PDFDocument({ margin:40, size:'A4', layout:'landscape' });
return pdfToBuffer(doc, d => {
header(d, 'Marchés Clôturés — RLA', `${data.count||0} marché(s) — Budget : ${data.budget_total||'—'}`);
table(d, { title:'Liste des Marchés Clôturés',
headers:['Réf.','Projet','Région','Entrepreneur','Montant','Taux Phy.','Date Clôture'],
colWidths:[70,155,65,130,100,60,75],
rows: (data.items||[]).map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.montant,r.taux_phy,r.date_cloture]) });
footer(d, 1);
});
}
function generatePilotage(data) {
const doc = new PDFDocument({ margin:40, size:'A4' });
return pdfToBuffer(doc, d => {
const r = data.resume||{};
header(d, 'Pilotage Proactif — Marchés RLA', `Total actifs : ${r.total||0}`);
const kpis = [
{l:'Dans les normes',v:r.normal||0,c:C.success},
{l:'Sous avancement',v:r.sous_avancement||0,c:C.warning},
{l:'Dépassé',v:r.depasse||0,c:C.danger},
];
let kx=40; for(const k of kpis){kpiBox(d,kx,d.y,148,50,k.l,k.v,k.c);kx+=156;}
d.y+=62;
const problematic = [...(data.depasse||[]),...(data.sous_avancement||[])];
if (problematic.length) {
table(d, { title:'Marchés à surveiller', headers:['Réf.','Projet','Région','Entrepreneur','Taux Phy.','Niveau'],
colWidths:[70,155,65,130,60,75], rows: problematic.map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.taux_phy,r.niveau_avancement]) });
}
footer(d, 1);
});
}
function generateMatriceRisque(data) {
const doc = new PDFDocument({ margin:40, size:'A4' });
return pdfToBuffer(doc, d => {
const pn = data.par_niveau||{};
header(d, 'Matrice de Risque — Marchés RLA', `${data.total||0} marchés analysés`);
const kpis = [
{l:'Critique',v:pn.critique||0,c:C.danger},
{l:'Élevé',v:pn['élevé']||0,c:C.warning},
{l:'Moyen',v:pn.moyen||0,c:'#6366f1'},
{l:'Faible',v:pn.faible||0,c:C.success},
];
let kx=40; for(const k of kpis){kpiBox(d,kx,d.y,110,50,k.l,k.v,k.c);kx+=118;}
d.y+=62;
if (data.items?.length) {
const sorted = [...data.items].sort((a,b)=>(b.score_delai+b.score_avancement)-(a.score_delai+a.score_avancement));
table(d, { title:'Marchés classés par niveau de risque', headers:['Réf.','Projet','Région','Entrepreneur','Taux Phy.','J. Rest.','Risque'],
colWidths:[70,145,65,125,55,50,55], rows: sorted.map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.taux_phy,r.delai_restant??'—',NL(r.niveau_risque)]) });
}
footer(d, 1);
});
}
function generateGeneric(title, data) {
const doc = new PDFDocument({ margin:40, size:'A4', layout:'landscape' });
return pdfToBuffer(doc, d => {
header(d, title, '');
const items = data.items||[];
if (items.length) {
const keys = Object.keys(items[0]).filter(k=>!k.endsWith('_raw')&&k!=='id'&&typeof items[0][k]!=='object').slice(0,8);
table(d, { headers:keys, colWidths:keys.map(()=>Math.floor(700/keys.length)),
rows: items.slice(0,100).map(r=>keys.map(k=>r[k])) });
}
footer(d, 1);
});
}
module.exports = {
generateSynthese, generateAlertes, generateEnService, generateEnCours,
generateParRegion, generateClotures, generatePilotage, generateMatriceRisque, generateGeneric,
};

84
services/export-xlsx.js Normal file
View File

@ -0,0 +1,84 @@
/**
* services/export-xlsx.js
* Génération XLSX avec ExcelJS (SuperAdmin uniquement)
*/
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' } };
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 styleDataRow(row, alt) {
if (alt) {
row.eachCell(cell => { cell.fill = ALT_FILL; });
}
row.height = 16;
}
async function generateXlsx(view, data) {
const wb = new ExcelJS.Workbook();
wb.creator = 'RLA API';
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 title = titles[view] || view;
const ws = wb.addWorksheet(title.slice(0, 31));
const items = data.items || data.regions || [];
if (!items.length) {
ws.addRow(['Aucune donnée disponible.']);
return wb.xlsx.writeBuffer();
}
const sample = items[0];
const keys = Object.keys(sample).filter(k => !k.endsWith('_raw') && k !== 'id' && typeof sample[k] !== 'object');
// En-tête
ws.columns = keys.map(k => ({ header: k, key: k, width: 20 }));
styleHeader(ws.getRow(1));
// Données
items.forEach((item, i) => {
const row = ws.addRow(keys.map(k => item[k] ?? ''));
styleDataRow(row, i % 2 === 1);
});
// Freeze header
ws.views = [{ state: 'frozen', ySplit: 1 }];
// Onglet résumé si synthèse
if (view === 'synthese' && data.par_statut) {
const ws2 = wb.addWorksheet('Par Statut');
ws2.columns = [{ header: 'Statut', key: 'statut', width: 30 }, { header: 'Nombre', key: 'nb', width: 15 }];
styleHeader(ws2.getRow(1));
Object.entries(data.par_statut).forEach(([s, n], i) => {
const row = ws2.addRow({ statut: s, nb: n });
styleDataRow(row, i % 2 === 1);
});
}
return wb.xlsx.writeBuffer();
}
module.exports = { generateXlsx };

31
services/logs.js Normal file
View File

@ -0,0 +1,31 @@
const fs = require('fs');
const path = require('path');
const LOGS_FILE = path.join(__dirname, '..', 'logs', 'connexions.json');
const MAX_LOGS = 500;
function readLogs() {
try {
return JSON.parse(fs.readFileSync(LOGS_FILE, 'utf8'));
} catch (_) {
return [];
}
}
function writeLogs(logs) {
fs.mkdirSync(path.dirname(LOGS_FILE), { recursive: true });
fs.writeFileSync(LOGS_FILE, JSON.stringify(logs, null, 2), 'utf8');
}
function logLogin({ username, role = null, ip = null, success }) {
const logs = readLogs();
logs.unshift({ timestamp: new Date().toISOString(), username, role, ip, success: !!success });
if (logs.length > MAX_LOGS) logs.length = MAX_LOGS;
writeLogs(logs);
}
function getLogs(limit = 100) {
return readLogs().slice(0, limit);
}
module.exports = { logLogin, getLogs };

30
services/users.js Normal file
View File

@ -0,0 +1,30 @@
const fs = require('fs');
const path = require('path');
const DATA_FILE = path.join(__dirname, '..', 'data', 'users.json');
function initUsersFile() {
if (fs.existsSync(DATA_FILE)) return;
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
try {
const envUsers = JSON.parse(process.env.USERS || '[]');
fs.writeFileSync(DATA_FILE, JSON.stringify(envUsers, null, 2), 'utf8');
console.log(`[users] Fichier initialisé depuis .env (${envUsers.length} utilisateurs)`);
} catch (_) {
fs.writeFileSync(DATA_FILE, '[]', 'utf8');
}
}
function getUsers() {
initUsersFile();
try {
return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
} catch (_) { return []; }
}
function saveUsers(users) {
fs.mkdirSync(path.dirname(DATA_FILE), { recursive: true });
fs.writeFileSync(DATA_FILE, JSON.stringify(users, null, 2), 'utf8');
}
module.exports = { getUsers, saveUsers };