chore: snapshot état initial API Gestion des Marchés RLA — V0
Version initiale de l'API complète : - Frontend : index.html (thème McKinsey, tableau de bord dynamique) - Backend Express : server.js, routes/, services/, middleware/ - Authentification JWT, rôles, logs - Connexion Baserow (NAS 192.168.100.33) - Export PDF/XLSX, statistiques, alertes, pilotage - Docker : Dockerfile, .env.example Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
2be438f1f1
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
logs/*.json
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"]
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -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)
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 950 KiB |
|
|
@ -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'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"username": "nabil",
|
||||||
|
"password": "$2a$10$4nqlMxOSsDV99mJHvVQJzubVLCArz6fnkQ0RSTD9p1CwdGxwibHdq",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
|
|
@ -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é' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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, selectVal } = 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 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);
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,651 @@
|
||||||
|
/**
|
||||||
|
* 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, selectVal,
|
||||||
|
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=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.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,
|
||||||
|
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': {
|
||||||
|
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) };
|
||||||
|
}
|
||||||
|
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 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}"`,
|
||||||
|
});
|
||||||
|
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 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 = 'Marchés RLA Zone Sud';
|
||||||
|
|
||||||
|
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] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
'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, PageBreak,
|
||||||
|
Header, Footer, ImageRun,
|
||||||
|
} = require('docx');
|
||||||
|
|
||||||
|
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 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 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({
|
||||||
|
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()] }));
|
||||||
|
|
||||||
|
// ── 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 = `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}"`,
|
||||||
|
});
|
||||||
|
res.end(buf);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: 'Erreur génération DOCX', detail: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* GET /api/pilotage-proactif
|
||||||
|
* 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 {
|
||||||
|
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, resultat } = 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 => 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, selectVal(r.nature)),
|
||||||
|
resultat: resultatPhysique(r),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
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é');
|
||||||
|
|
||||||
|
// Filtres optionnels
|
||||||
|
let result = items;
|
||||||
|
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: {
|
||||||
|
standard: SEUIL_STANDARD,
|
||||||
|
modernisation: SEUIL_MODERNISATION,
|
||||||
|
critique: SEUIL_CRITIQUE_PCT,
|
||||||
|
},
|
||||||
|
resume: {
|
||||||
|
total: items.length,
|
||||||
|
normal: normal.length,
|
||||||
|
sous_avancement: sous_avancement.length,
|
||||||
|
depassement: depassement.length,
|
||||||
|
non_determine: non_determine.length,
|
||||||
|
},
|
||||||
|
normal, sous_avancement, depassement, non_determine,
|
||||||
|
items: result,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: 'Erreur Baserow', detail: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
/**
|
||||||
|
* GET /api/stats — compatibilité avec l'ancien front
|
||||||
|
*/
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { getMarches } = require('../services/baserow');
|
||||||
|
const {
|
||||||
|
selectVal, parseNum, isCloture, getDelaiRestant, niveauAlerte,
|
||||||
|
DELAI_ATTENTION,
|
||||||
|
} = require('../services/calc');
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await getMarches();
|
||||||
|
const actifs = rows.filter(r => !isCloture(r));
|
||||||
|
|
||||||
|
const parStatut = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
const s = selectVal(r.observation) || 'Inconnu';
|
||||||
|
parStatut[s] = (parStatut[s] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 alertes = actifs
|
||||||
|
.map(r => ({ ...r, _delai: getDelaiRestant(r) }))
|
||||||
|
.filter(r => r._delai !== null && r._delai <= DELAI_ATTENTION)
|
||||||
|
.map(r => ({
|
||||||
|
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,
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
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;
|
||||||
|
|
||||||
|
let rows = await getMarches();
|
||||||
|
|
||||||
|
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 => 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()));
|
||||||
|
|
||||||
|
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.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 tauxMoyen = tauxList.length
|
||||||
|
? Math.round(tauxList.reduce((a, b) => a + b, 0) / tauxList.length * 10) / 10
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Par statut (observation)
|
||||||
|
const parStatut = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.id_marche || 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
|
||||||
|
const pilotage = { normal: 0, sous_avancement: 0, depasse: 0 };
|
||||||
|
for (const r of actifs) {
|
||||||
|
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,
|
||||||
|
budget: {
|
||||||
|
total: formatMontant(totalBudget),
|
||||||
|
total_raw: totalBudget,
|
||||||
|
consomme: formatMontant(totalAvtFin),
|
||||||
|
consomme_raw: totalAvtFin,
|
||||||
|
restant: formatMontant(totalBudget - totalAvtFin),
|
||||||
|
restant_raw: totalBudget - totalAvtFin,
|
||||||
|
},
|
||||||
|
taux_avancement_moyen: tauxMoyen,
|
||||||
|
par_statut: parStatut,
|
||||||
|
par_nature: parNature,
|
||||||
|
par_region: parRegion,
|
||||||
|
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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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/`);
|
||||||
|
});
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
/**
|
||||||
|
* services/calc.js
|
||||||
|
* Helpers partagés : calculs, formatage, seuils métier RLA
|
||||||
|
* Champs Baserow table 856 : id_marche, nature{value}, region, observation{value},
|
||||||
|
* taux_phy, taux_fin, avt_phy, avt_fin, m_min, m_max, tot_marche,
|
||||||
|
* date_debut, date_fin, delai_restant, debut_marche, date_fin_marche
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SEUIL_STANDARD = parseFloat(process.env.SEUIL_STANDARD || 70);
|
||||||
|
const SEUIL_MODERNISATION = parseFloat(process.env.SEUIL_MODERNISATION || 50);
|
||||||
|
const SEUIL_CRITIQUE_PCT = parseFloat(process.env.SEUIL_CRITIQUE_PCT || 90);
|
||||||
|
const DELAI_CRITIQUE = parseInt(process.env.DELAI_CRITIQUE || 45, 10);
|
||||||
|
const DELAI_ATTENTION = parseInt(process.env.DELAI_ATTENTION || 90, 10);
|
||||||
|
|
||||||
|
// ─── Helpers Baserow select/multi-select ─────────────────────────────────────
|
||||||
|
|
||||||
|
/** Extrait la valeur d'un champ Baserow (select ou string) */
|
||||||
|
function selectVal(v) {
|
||||||
|
if (!v) return '';
|
||||||
|
if (typeof v === 'object' && v.value !== undefined) return String(v.value);
|
||||||
|
if (Array.isArray(v)) return v.map(x => (x.value !== undefined ? x.value : x)).join(', ');
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Parseurs ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseNum(v) {
|
||||||
|
if (v === null || v === undefined || v === '') return 0;
|
||||||
|
if (typeof v === 'object') return 0; // objet Baserow non-numérique
|
||||||
|
const n = parseFloat(String(v).replace(/\s/g, '').replace(',', '.'));
|
||||||
|
return isNaN(n) ? 0 : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateFR(d) {
|
||||||
|
if (!d) return null;
|
||||||
|
const parts = String(d).split(/[\/\-]/);
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [a, b, c] = parts;
|
||||||
|
if (a.length === 4) return new Date(`${a}-${b}-${c}`);
|
||||||
|
if (c.length === 4) return new Date(`${c}-${b}-${a}`);
|
||||||
|
}
|
||||||
|
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: 0 }) + ' 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)} %`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Statuts métier ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isCloture(r) {
|
||||||
|
const obs = selectVal(r.observation).toLowerCase();
|
||||||
|
return obs.includes('clôtur') || obs.includes('clotur') || !!r.date_cloture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDelaiRestant(r) {
|
||||||
|
// Champ calculé Baserow
|
||||||
|
const dField = r.delai_restant;
|
||||||
|
if (dField !== null && dField !== undefined && dField !== '') {
|
||||||
|
const v = parseInt(String(dField), 10);
|
||||||
|
if (!isNaN(v)) return v;
|
||||||
|
}
|
||||||
|
// Calculer depuis date_fin
|
||||||
|
const fin = r.date_fin || r.date_fin_marche || r.datefin;
|
||||||
|
const dt = parseDateFR(fin);
|
||||||
|
if (!dt) return null;
|
||||||
|
return Math.ceil((dt - new Date()) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 nat = String(nature || '').toLowerCase();
|
||||||
|
const seuil = nat.includes('modern') ? SEUIL_MODERNISATION : SEUIL_STANDARD;
|
||||||
|
if (t >= SEUIL_CRITIQUE_PCT) return 'dépassé';
|
||||||
|
if (t >= seuil) return 'sous_avancement';
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résultat financier du marché (pour pilotage proactif)
|
||||||
|
* Compare avt_fin (avancement financier en DT) vs m_min / m_max
|
||||||
|
*/
|
||||||
|
function resultatFinancier(r) {
|
||||||
|
const avt = parseNum(r.avt_fin);
|
||||||
|
const mMin = parseNum(r.m_min);
|
||||||
|
const mMax = parseNum(r.m_max ?? r.tot_marche);
|
||||||
|
if (avt === 0 && mMin === 0) return 'Non déterminé';
|
||||||
|
if (avt > mMax && mMax > 0) return 'Dépassement';
|
||||||
|
if (avt < mMin && mMin > 0) return 'Sous Min';
|
||||||
|
return 'Normal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Résultat basé sur l'avancement PHYSIQUE du marché (pilotage proactif)
|
||||||
|
* Compare taux_phy vs seuils standards / critique
|
||||||
|
*/
|
||||||
|
function resultatPhysique(r) {
|
||||||
|
const taux = parseNum(r.taux_phy || r.avt_phy);
|
||||||
|
const nat = String(selectVal(r.nature) || '').toLowerCase();
|
||||||
|
const seuil = nat.includes('modern') ? SEUIL_MODERNISATION : SEUIL_STANDARD;
|
||||||
|
if (taux === 0) return 'Non déterminé';
|
||||||
|
if (taux >= SEUIL_CRITIQUE_PCT) return 'Dépassement';
|
||||||
|
if (taux >= seuil) return 'Normal';
|
||||||
|
return 'Sous Avancement';
|
||||||
|
}
|
||||||
|
|
||||||
|
function niveauRisque(r) {
|
||||||
|
const delai = getDelaiRestant(r);
|
||||||
|
const avt = parseNum(r.taux_phy || r.avt_phy);
|
||||||
|
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 obsValue = selectVal(r.observation);
|
||||||
|
const natureValue = selectVal(r.nature);
|
||||||
|
const delaiRestant = getDelaiRestant(r);
|
||||||
|
const tauxPhy = parseNum(r.taux_phy || r.avt_phy);
|
||||||
|
const tauxFin = parseNum(r.taux_fin || r.avt_fin);
|
||||||
|
const montant = parseNum(r.tot_marche || r.totmarche || r.montant);
|
||||||
|
const mMin = parseNum(r.m_min);
|
||||||
|
const mMax = parseNum(r.m_max || r.tot_marche);
|
||||||
|
const avt_fin_raw = parseNum(r.avt_fin);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
ref: r.id_marche || r.reference || String(r.id || ''),
|
||||||
|
projet: r.projet || '',
|
||||||
|
region: r.region || r.region_csc || '',
|
||||||
|
region_csc: r.region_csc || r.region || '',
|
||||||
|
entrepreneur: r.entrepreneur || '',
|
||||||
|
nature: natureValue,
|
||||||
|
statut: obsValue,
|
||||||
|
observation: obsValue,
|
||||||
|
lots: r.lots || '',
|
||||||
|
cloture: isCloture(r),
|
||||||
|
|
||||||
|
date_debut: formatDateFR(r.date_debut || r.debut_marche),
|
||||||
|
date_fin: formatDateFR(r.date_fin || r.date_fin_marche),
|
||||||
|
date_cloture: formatDateFR(r.date_cloture),
|
||||||
|
alerte_echeance: r.Alerte_Echeance || '',
|
||||||
|
|
||||||
|
montant_raw: montant,
|
||||||
|
montant: formatMontant(montant),
|
||||||
|
m_min_raw: mMin,
|
||||||
|
m_min: formatMontant(mMin),
|
||||||
|
m_max_raw: mMax,
|
||||||
|
m_max: formatMontant(mMax),
|
||||||
|
montant_proj_raw: avt_fin_raw,
|
||||||
|
montant_proj: formatMontant(avt_fin_raw),
|
||||||
|
|
||||||
|
taux_phy_raw: tauxPhy,
|
||||||
|
taux_phy: formatPct(tauxPhy),
|
||||||
|
taux_fin_raw: tauxFin,
|
||||||
|
taux_fin: formatPct(tauxFin),
|
||||||
|
|
||||||
|
delai_restant: delaiRestant,
|
||||||
|
niveau_alerte: niveauAlerte(delaiRestant),
|
||||||
|
niveau_avancement: niveauAvancement(tauxPhy, natureValue),
|
||||||
|
niveau_risque: niveauRisque(r),
|
||||||
|
resultat: resultatFinancier(r),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SEUIL_STANDARD, SEUIL_MODERNISATION, SEUIL_CRITIQUE_PCT,
|
||||||
|
DELAI_CRITIQUE, DELAI_ATTENTION,
|
||||||
|
selectVal, parseNum, parseDateFR,
|
||||||
|
formatMontant, formatDateFR, formatPct,
|
||||||
|
isCloture, getDelaiRestant, niveauAlerte,
|
||||||
|
niveauAvancement, resultatFinancier, resultatPhysique, niveauRisque,
|
||||||
|
normalizeMarche,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,303 @@
|
||||||
|
/**
|
||||||
|
* 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 Max','Période','Avt. Phy.','Délai Rest.','Alerte'],
|
||||||
|
colWidths:[65,140,65,110,90,90,55,55,60],
|
||||||
|
rows: (data.items||[]).map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.montant,
|
||||||
|
`${r.date_debut||'—'} → ${r.date_fin||'—'}`,r.taux_phy,r.delai_restant??'—',NL(r.niveau_alerte)]) });
|
||||||
|
footer(d, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Max','Période','Avt. Phy.','Niveau Avt.'],
|
||||||
|
colWidths:[65,140,65,110,90,90,60,70],
|
||||||
|
rows: (data.items||[]).map(r=>[r.ref,r.projet,r.region,r.entrepreneur,r.montant,
|
||||||
|
`${r.date_debut||'—'} → ${r.date_fin||'—'}`,r.taux_phy,r.niveau_avancement||'—']) });
|
||||||
|
footer(d, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,450 @@
|
||||||
|
/**
|
||||||
|
* services/export-xlsx.js
|
||||||
|
* Génération XLSX comprehensive — Situation des Marchés RLA Zone Sud
|
||||||
|
*/
|
||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
|
||||||
|
const C = {
|
||||||
|
NAVY: 'FF002D62',
|
||||||
|
WHITE: 'FFFFFFFF',
|
||||||
|
ACCENT: 'FF00D4FF',
|
||||||
|
GREEN: 'FF16A34A',
|
||||||
|
ORANGE: 'FFEA580C',
|
||||||
|
RED: 'FFDC2626',
|
||||||
|
YELLOW: 'FFEAB308',
|
||||||
|
GRAY: 'FF64748B',
|
||||||
|
ALT: 'FFF1F5F9',
|
||||||
|
LIGHT: 'FFE2E8F0',
|
||||||
|
CAPEX: 'FFD1FAE5',
|
||||||
|
OPEX: 'FFFEF3C7',
|
||||||
|
TOTAL: 'FFDBEAFE',
|
||||||
|
HEADER: 'FF0F172A',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fill(argb) { return { type: 'pattern', pattern: 'solid', fgColor: { argb } }; }
|
||||||
|
function font(argb, bold = false, size = 10) { return { color: { argb }, bold, size }; }
|
||||||
|
function border(color = C.LIGHT) {
|
||||||
|
const s = { style: 'thin', color: { argb: color } };
|
||||||
|
return { top: s, bottom: s, left: s, right: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_REGIONS = ['Gabes', 'Gafsa', 'Kebili', 'Medenine', 'Sfax', 'Tataouine', 'Tozeur'];
|
||||||
|
|
||||||
|
function parseNum(v) {
|
||||||
|
if (v === null || v === undefined || v === '') return 0;
|
||||||
|
if (typeof v === 'object') return 0;
|
||||||
|
const n = parseFloat(String(v).replace(/\s/g, '').replace(',', '.'));
|
||||||
|
return isNaN(n) ? 0 : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectVal(v) {
|
||||||
|
if (!v) return '';
|
||||||
|
if (typeof v === 'object' && v.value !== undefined) return String(v.value);
|
||||||
|
if (Array.isArray(v)) return v.map(x => x.value !== undefined ? x.value : x).join(', ');
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateFR(d) {
|
||||||
|
if (!d) return null;
|
||||||
|
const parts = String(d).split(/[\/\-]/);
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const [a, b, c] = parts;
|
||||||
|
if (a.length === 4) return new Date(`${a}-${b}-${c}`);
|
||||||
|
if (c.length === 4) return new Date(`${c}-${b}-${a}`);
|
||||||
|
}
|
||||||
|
const dt = new Date(d);
|
||||||
|
return isNaN(dt.getTime()) ? null : dt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(d) {
|
||||||
|
const dt = parseDateFR(d);
|
||||||
|
if (!dt) return '-';
|
||||||
|
return dt.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtMDT(val) {
|
||||||
|
const n = parseNum(val);
|
||||||
|
if (n === 0) return '0';
|
||||||
|
if (n >= 1000000) return `${(n / 1000000).toFixed(1)} MDT`;
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(0)} kDT`;
|
||||||
|
return `${n.toFixed(0)} DT`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCloture(r) {
|
||||||
|
const obs = selectVal(r.observation).toLowerCase();
|
||||||
|
return obs.includes('clôtur') || obs.includes('clotur') || !!r.date_cloture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDelai(r) {
|
||||||
|
const dField = r.delai_restant;
|
||||||
|
if (dField !== null && dField !== undefined && dField !== '') {
|
||||||
|
const v = parseInt(String(dField), 10);
|
||||||
|
if (!isNaN(v)) return v;
|
||||||
|
}
|
||||||
|
const fin = r.date_fin || r.date_fin_marche || r.datefin;
|
||||||
|
const dt = parseDateFR(fin);
|
||||||
|
if (!dt) return '-';
|
||||||
|
return Math.ceil((dt - new Date()) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateXlsx(view, data, allRows) {
|
||||||
|
const wb = new ExcelJS.Workbook();
|
||||||
|
wb.creator = 'RLA API';
|
||||||
|
wb.company = 'Tunisie Telecom Zone Sud';
|
||||||
|
wb.created = new Date();
|
||||||
|
|
||||||
|
const rows = allRows || data.items || data.regions || [];
|
||||||
|
const actifs = allRows ? rows.filter(r => !isCloture(r)) : rows;
|
||||||
|
|
||||||
|
await buildSheet1(wb, actifs);
|
||||||
|
await buildSheet2(wb, actifs);
|
||||||
|
|
||||||
|
return wb.xlsx.writeBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSheet1(wb, actifs) {
|
||||||
|
const ws = wb.addWorksheet('Situation des Marchés');
|
||||||
|
ws.views = [{ state: 'frozen', ySplit: 9 }];
|
||||||
|
|
||||||
|
// Column widths
|
||||||
|
const cols = [
|
||||||
|
{ width: 40 }, { width: 20 }, { width: 22 }, { width: 12 },
|
||||||
|
{ width: 16 }, { width: 14 }, { width: 8 }, { width: 14 },
|
||||||
|
{ width: 8 }, { width: 14 }, { width: 14 }, { width: 8 }, { width: 22 },
|
||||||
|
];
|
||||||
|
ws.columns = cols.map((c, i) => ({ key: String.fromCharCode(65 + i), width: c.width }));
|
||||||
|
|
||||||
|
const today = new Date().toLocaleDateString('fr-FR');
|
||||||
|
const capex = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('CAPEX') ||
|
||||||
|
!String(selectVal(r.nature)).toUpperCase().includes('OPEX'));
|
||||||
|
const opex = actifs.filter(r => String(selectVal(r.nature)).toUpperCase().includes('OPEX'));
|
||||||
|
const totalBudget = actifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
|
||||||
|
|
||||||
|
const avgPhy = (() => {
|
||||||
|
const vals = actifs.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(v => v > 0);
|
||||||
|
return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Row 1: Title
|
||||||
|
const r1 = ws.addRow(['📊 SITUATION DES MARCHÉS RLA — ZONE SUD', ...Array(12).fill('')]);
|
||||||
|
r1.height = 30;
|
||||||
|
ws.mergeCells('A1:M1');
|
||||||
|
const c1 = r1.getCell(1);
|
||||||
|
c1.fill = fill(C.NAVY);
|
||||||
|
c1.font = { color: { argb: C.WHITE }, bold: true, size: 16 };
|
||||||
|
c1.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
|
||||||
|
// Row 2: Subtitle
|
||||||
|
const r2 = ws.addRow([`Tunisie Telecom • Direction Centrale Achats • Zone Sud`, ...Array(12).fill('')]);
|
||||||
|
r2.height = 18;
|
||||||
|
ws.mergeCells('A2:M2');
|
||||||
|
r2.getCell(1).fill = fill('FF0F172A');
|
||||||
|
r2.getCell(1).font = font('FFCBD5E1', false, 11);
|
||||||
|
r2.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
|
||||||
|
// Row 3: Stats bar
|
||||||
|
const r3 = ws.addRow([
|
||||||
|
`📅 ${today} │ 📋 ${actifs.length} marchés │ 💰 ${fmtMDT(totalBudget)} │ 📈 Phy moy: ${avgPhy.toFixed(0)}%`,
|
||||||
|
...Array(12).fill(''),
|
||||||
|
]);
|
||||||
|
r3.height = 16;
|
||||||
|
ws.mergeCells('A3:M3');
|
||||||
|
r3.getCell(1).fill = fill('FF1E3A5F');
|
||||||
|
r3.getCell(1).font = font('FF94A3B8', false, 10);
|
||||||
|
r3.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
|
||||||
|
// Row 4: empty
|
||||||
|
ws.addRow([]);
|
||||||
|
|
||||||
|
// Row 5: KPI headers
|
||||||
|
const r5 = ws.addRow(['📊 GLOBAL', '', '', '', '', '🟢 CAPEX', '', '', '', '', '🟠 OPEX', '', '']);
|
||||||
|
r5.height = 22;
|
||||||
|
ws.mergeCells('A5:E5'); ws.mergeCells('F5:J5'); ws.mergeCells('K5:M5');
|
||||||
|
const kpiHdrStyle = (cell, argb) => {
|
||||||
|
cell.fill = fill(argb);
|
||||||
|
cell.font = { color: { argb: C.WHITE }, bold: true, size: 11 };
|
||||||
|
cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
};
|
||||||
|
kpiHdrStyle(r5.getCell(1), C.NAVY);
|
||||||
|
kpiHdrStyle(r5.getCell(6), 'FF16A34A');
|
||||||
|
kpiHdrStyle(r5.getCell(11), C.ORANGE);
|
||||||
|
|
||||||
|
const capexBudget = capex.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
|
||||||
|
const opexBudget = opex.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0);
|
||||||
|
const capexPhy = (() => { const v = capex.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(x => x > 0); return v.length ? v.reduce((a,b)=>a+b,0)/v.length : 0; })();
|
||||||
|
const opexPhy = (() => { const v = opex.map(r => parseNum(r.taux_phy || r.avt_phy)).filter(x => x > 0); return v.length ? v.reduce((a,b)=>a+b,0)/v.length : 0; })();
|
||||||
|
|
||||||
|
// Row 6: KPI values
|
||||||
|
const r6 = ws.addRow([`${actifs.length} marchés`, '', '', '', '',
|
||||||
|
`${capex.length} marchés`, '', '', '', '', `${opex.length} marchés`, '', '']);
|
||||||
|
r6.height = 18;
|
||||||
|
ws.mergeCells('A6:E6'); ws.mergeCells('F6:J6'); ws.mergeCells('K6:M6');
|
||||||
|
[1, 6, 11].forEach(col => {
|
||||||
|
r6.getCell(col).font = { bold: true, size: 14, color: { argb: C.NAVY } };
|
||||||
|
r6.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 7: KPI details
|
||||||
|
const r7 = ws.addRow([
|
||||||
|
`Budget: ${fmtMDT(totalBudget)} • Phy moy: ${avgPhy.toFixed(0)}%`, '', '', '', '',
|
||||||
|
`Budget: ${fmtMDT(capexBudget)} • Phy: ${capexPhy.toFixed(0)}%`, '', '', '', '',
|
||||||
|
`Budget: ${fmtMDT(opexBudget)} • Phy: ${opexPhy.toFixed(0)}%`, '', '',
|
||||||
|
]);
|
||||||
|
r7.height = 16;
|
||||||
|
ws.mergeCells('A7:E7'); ws.mergeCells('F7:J7'); ws.mergeCells('K7:M7');
|
||||||
|
[1, 6, 11].forEach(col => {
|
||||||
|
r7.getCell(col).font = { size: 9, color: { argb: C.GRAY } };
|
||||||
|
r7.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Row 8: empty
|
||||||
|
ws.addRow([]);
|
||||||
|
|
||||||
|
// Row 9: Column headers
|
||||||
|
const HEADERS = ['Référence', 'Projet', 'Entrepreneur', 'Nature',
|
||||||
|
'Montant Marché', 'Av. Phy (DT)', 'Phy %', 'Av. Fin (DT)', 'Fin %',
|
||||||
|
'Début', 'Fin', 'Délai', 'Observation'];
|
||||||
|
const r9 = ws.addRow(HEADERS);
|
||||||
|
r9.height = 22;
|
||||||
|
r9.eachCell(cell => {
|
||||||
|
cell.fill = fill(C.HEADER);
|
||||||
|
cell.font = { color: { argb: C.WHITE }, bold: true, size: 10 };
|
||||||
|
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
||||||
|
cell.border = border(C.NAVY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-region data
|
||||||
|
for (const region of ALL_REGIONS) {
|
||||||
|
const regRows = actifs.filter(r => (r.region || '') === region);
|
||||||
|
if (!regRows.length) continue;
|
||||||
|
|
||||||
|
// Region header row
|
||||||
|
const rh = ws.addRow([`📍 ${region} — ${regRows.length} marchés`, ...Array(12).fill('')]);
|
||||||
|
rh.height = 18;
|
||||||
|
ws.mergeCells(`A${rh.number}:M${rh.number}`);
|
||||||
|
rh.getCell(1).fill = fill('FF1E3A5F');
|
||||||
|
rh.getCell(1).font = { bold: true, size: 11, color: { argb: C.ACCENT } };
|
||||||
|
rh.getCell(1).alignment = { horizontal: 'left', vertical: 'middle', indent: 1 };
|
||||||
|
|
||||||
|
let subtotalBudget = 0, subtotalPhy = 0, subtotalFin = 0, phyCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < regRows.length; i++) {
|
||||||
|
const r = regRows[i];
|
||||||
|
const nat = selectVal(r.nature);
|
||||||
|
const isCapex = nat.toUpperCase().includes('CAPEX');
|
||||||
|
const budget = parseNum(r.tot_marche || r.totmarche || r.montant);
|
||||||
|
const phyDT = parseNum(r.avt_phy);
|
||||||
|
const phyPct = parseNum(r.taux_phy || r.avt_phy);
|
||||||
|
const finDT = parseNum(r.avt_fin);
|
||||||
|
const finPct = parseNum(r.taux_fin);
|
||||||
|
const delai = getDelai(r);
|
||||||
|
|
||||||
|
subtotalBudget += budget;
|
||||||
|
if (phyPct > 0) { subtotalPhy += phyPct; phyCount++; }
|
||||||
|
subtotalFin += finDT;
|
||||||
|
|
||||||
|
const rd = ws.addRow([
|
||||||
|
r.id_marche || r.reference || '',
|
||||||
|
r.projet || '',
|
||||||
|
r.entrepreneur || '',
|
||||||
|
nat,
|
||||||
|
budget || '',
|
||||||
|
phyDT || '',
|
||||||
|
phyPct > 0 ? phyPct / 100 : '',
|
||||||
|
finDT || '',
|
||||||
|
finPct > 0 ? finPct / 100 : '',
|
||||||
|
fmtDate(r.date_debut || r.debut_marche),
|
||||||
|
fmtDate(r.date_fin || r.date_fin_marche),
|
||||||
|
delai,
|
||||||
|
selectVal(r.observation),
|
||||||
|
]);
|
||||||
|
rd.height = 15;
|
||||||
|
|
||||||
|
const altFill = i % 2 === 1 ? fill(C.ALT) : undefined;
|
||||||
|
const natFill = isCapex ? fill(C.CAPEX) : fill(C.OPEX);
|
||||||
|
|
||||||
|
rd.eachCell((cell, col) => {
|
||||||
|
if (altFill) cell.fill = altFill;
|
||||||
|
if (col === 4) cell.fill = natFill;
|
||||||
|
cell.border = { bottom: { style: 'thin', color: { argb: C.LIGHT } } };
|
||||||
|
cell.alignment = { vertical: 'middle' };
|
||||||
|
if ([5, 6, 8].includes(col)) cell.numFmt = '#,##0';
|
||||||
|
if ([7, 9].includes(col)) cell.numFmt = '0%';
|
||||||
|
if ([12].includes(col)) cell.alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtotal row
|
||||||
|
const avgPct = phyCount > 0 ? subtotalPhy / phyCount : 0;
|
||||||
|
const rst = ws.addRow([
|
||||||
|
`Sous-total ${region} (${regRows.length})`, '', '',
|
||||||
|
'', subtotalBudget, '', avgPct / 100, '', '',
|
||||||
|
'', '', '', '',
|
||||||
|
]);
|
||||||
|
rst.height = 16;
|
||||||
|
ws.mergeCells(`A${rst.number}:D${rst.number}`);
|
||||||
|
rst.eachCell(cell => {
|
||||||
|
cell.fill = fill('FF1E3A5F');
|
||||||
|
cell.font = { bold: true, size: 9, color: { argb: C.WHITE } };
|
||||||
|
cell.border = { top: { style: 'medium', color: { argb: C.NAVY } }, bottom: { style: 'medium', color: { argb: C.NAVY } } };
|
||||||
|
});
|
||||||
|
rst.getCell(5).numFmt = '#,##0';
|
||||||
|
rst.getCell(7).numFmt = '0%';
|
||||||
|
rst.getCell(1).alignment = { horizontal: 'right', vertical: 'middle' };
|
||||||
|
|
||||||
|
ws.addRow([]); // spacer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grand total row
|
||||||
|
const gt = ws.addRow([
|
||||||
|
`TOTAL ZONE SUD (${actifs.length} marchés)`, '', '', '',
|
||||||
|
actifs.reduce((s, r) => s + parseNum(r.tot_marche || r.totmarche || r.montant), 0),
|
||||||
|
'', avgPhy / 100, '', '', '', '', '', '',
|
||||||
|
]);
|
||||||
|
ws.mergeCells(`A${gt.number}:D${gt.number}`);
|
||||||
|
gt.height = 22;
|
||||||
|
gt.eachCell(cell => {
|
||||||
|
cell.fill = fill(C.NAVY);
|
||||||
|
cell.font = { bold: true, size: 11, color: { argb: C.WHITE } };
|
||||||
|
cell.border = { top: { style: 'medium', color: { argb: C.ACCENT } } };
|
||||||
|
});
|
||||||
|
gt.getCell(5).numFmt = '#,##0';
|
||||||
|
gt.getCell(7).numFmt = '0%';
|
||||||
|
gt.getCell(1).alignment = { horizontal: 'right', vertical: 'middle' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSheet2(wb, actifs) {
|
||||||
|
const ws = wb.addWorksheet('Pilotage Proactif');
|
||||||
|
ws.views = [{ state: 'frozen', ySplit: 9 }];
|
||||||
|
|
||||||
|
ws.columns = [
|
||||||
|
{ width: 40 }, { width: 20 }, { width: 22 }, { width: 16 },
|
||||||
|
{ width: 10 }, { width: 10 }, { width: 12 }, { width: 12 }, { width: 18 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const today = new Date().toLocaleDateString('fr-FR');
|
||||||
|
|
||||||
|
// Title
|
||||||
|
const r1 = ws.addRow(['📈 PILOTAGE PROACTIF — ZONE SUD', ...Array(8).fill('')]);
|
||||||
|
r1.height = 30;
|
||||||
|
ws.mergeCells('A1:I1');
|
||||||
|
r1.getCell(1).fill = fill(C.NAVY);
|
||||||
|
r1.getCell(1).font = { color: { argb: C.WHITE }, bold: true, size: 16 };
|
||||||
|
r1.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
|
||||||
|
const r2 = ws.addRow([`Tunisie Telecom • Direction Centrale Achats • Zone Sud`, ...Array(8).fill('')]);
|
||||||
|
r2.height = 18;
|
||||||
|
ws.mergeCells('A2:I2');
|
||||||
|
r2.getCell(1).fill = fill('FF0F172A');
|
||||||
|
r2.getCell(1).font = font('FFCBD5E1', false, 11);
|
||||||
|
r2.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
|
||||||
|
const r3 = ws.addRow([`📅 ${today} │ 📋 ${actifs.length} marchés actifs`, ...Array(8).fill('')]);
|
||||||
|
r3.height = 16;
|
||||||
|
ws.mergeCells('A3:I3');
|
||||||
|
r3.getCell(1).fill = fill('FF1E3A5F');
|
||||||
|
r3.getCell(1).font = font('FF94A3B8', false, 10);
|
||||||
|
r3.getCell(1).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
|
||||||
|
ws.addRow([]);
|
||||||
|
|
||||||
|
// KPIs
|
||||||
|
const SEUIL_STD = parseFloat(process.env.SEUIL_STANDARD || 70);
|
||||||
|
const SEUIL_CRIT = parseFloat(process.env.SEUIL_CRITIQUE_PCT || 90);
|
||||||
|
const SEUIL_MOD = parseFloat(process.env.SEUIL_MODERNISATION || 50);
|
||||||
|
|
||||||
|
const classify = r => {
|
||||||
|
const taux = parseNum(r.taux_phy || r.avt_phy);
|
||||||
|
const nat = String(selectVal(r.nature) || '').toLowerCase();
|
||||||
|
const seuil = nat.includes('modern') ? SEUIL_MOD : SEUIL_STD;
|
||||||
|
if (taux === 0) return 'Non déterminé';
|
||||||
|
if (taux >= SEUIL_CRIT) return 'Dépassement';
|
||||||
|
if (taux >= seuil) return 'Normal';
|
||||||
|
return 'Sous Avancement';
|
||||||
|
};
|
||||||
|
|
||||||
|
const normal = actifs.filter(r => classify(r) === 'Normal').length;
|
||||||
|
const sous = actifs.filter(r => classify(r) === 'Sous Avancement').length;
|
||||||
|
const dep = actifs.filter(r => classify(r) === 'Dépassement').length;
|
||||||
|
const nd = actifs.filter(r => classify(r) === 'Non déterminé').length;
|
||||||
|
|
||||||
|
const r5 = ws.addRow(['✅ NORMAL', '', '❌ SOUS AVANCEMENT', '', '⚡ DÉPASSEMENT', '', '❓ NON DÉTERMINÉ', '', '📊 TOTAL']);
|
||||||
|
r5.height = 22;
|
||||||
|
ws.mergeCells('A5:B5'); ws.mergeCells('C5:D5'); ws.mergeCells('E5:F5'); ws.mergeCells('G5:H5');
|
||||||
|
[[1,C.GREEN],[3,'FFDC2626'],[5,C.ORANGE],[7,C.GRAY],[9,C.NAVY]].forEach(([col, argb]) => {
|
||||||
|
r5.getCell(col).fill = fill(argb);
|
||||||
|
r5.getCell(col).font = { color: { argb: C.WHITE }, bold: true, size: 10 };
|
||||||
|
r5.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
});
|
||||||
|
|
||||||
|
const r6 = ws.addRow([normal, '', sous, '', dep, '', nd, '', actifs.length]);
|
||||||
|
r6.height = 20;
|
||||||
|
ws.mergeCells('A6:B6'); ws.mergeCells('C6:D6'); ws.mergeCells('E6:F6'); ws.mergeCells('G6:H6');
|
||||||
|
[1, 3, 5, 7, 9].forEach(col => {
|
||||||
|
r6.getCell(col).font = { bold: true, size: 18 };
|
||||||
|
r6.getCell(col).alignment = { horizontal: 'center', vertical: 'middle' };
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addRow([]);
|
||||||
|
ws.addRow([]);
|
||||||
|
|
||||||
|
// Column headers
|
||||||
|
const HEADERS2 = ['Référence', 'Projet', 'Entrepreneur', 'Région',
|
||||||
|
'Phy %', 'Fin %', 'Délai', 'Alerte', 'Résultat'];
|
||||||
|
const r9 = ws.addRow(HEADERS2);
|
||||||
|
r9.height = 22;
|
||||||
|
r9.eachCell(cell => {
|
||||||
|
cell.fill = fill(C.HEADER);
|
||||||
|
cell.font = { color: { argb: C.WHITE }, bold: true, size: 10 };
|
||||||
|
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
||||||
|
cell.border = border(C.NAVY);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ALERTE_COLOR = { 'critique': 'FFDC2626', 'attention': 'FFEA580C', 'normal': C.GREEN, 'indéterminé': C.GRAY };
|
||||||
|
const RESULT_COLOR = { 'Normal': C.GREEN, 'Sous Avancement': 'FFDC2626', 'Dépassement': C.ORANGE, 'Non déterminé': C.GRAY };
|
||||||
|
|
||||||
|
const DELAI_CRIT = parseInt(process.env.DELAI_CRITIQUE || 45);
|
||||||
|
const DELAI_ATT = parseInt(process.env.DELAI_ATTENTION || 90);
|
||||||
|
const niveauAlerte = d => d === null ? 'indéterminé' : d <= DELAI_CRIT ? 'critique' : d <= DELAI_ATT ? 'attention' : 'normal';
|
||||||
|
|
||||||
|
const sorted = [...actifs].sort((a, b) => {
|
||||||
|
const ta = parseNum(a.taux_phy || a.avt_phy);
|
||||||
|
const tb = parseNum(b.taux_phy || b.avt_phy);
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
const r = sorted[i];
|
||||||
|
const phyPct = parseNum(r.taux_phy || r.avt_phy);
|
||||||
|
const finPct = parseNum(r.taux_fin);
|
||||||
|
const delai = getDelai(r);
|
||||||
|
const alerte = niveauAlerte(typeof delai === 'number' ? delai : null);
|
||||||
|
const result = classify(r);
|
||||||
|
|
||||||
|
const rd = ws.addRow([
|
||||||
|
r.id_marche || r.reference || '',
|
||||||
|
r.projet || '',
|
||||||
|
r.entrepreneur || '',
|
||||||
|
r.region || '',
|
||||||
|
phyPct / 100,
|
||||||
|
finPct / 100,
|
||||||
|
typeof delai === 'number' ? delai : '-',
|
||||||
|
alerte,
|
||||||
|
result,
|
||||||
|
]);
|
||||||
|
rd.height = 15;
|
||||||
|
|
||||||
|
if (i % 2 === 1) rd.eachCell(cell => { cell.fill = fill(C.ALT); });
|
||||||
|
|
||||||
|
rd.getCell(5).numFmt = '0%';
|
||||||
|
rd.getCell(6).numFmt = '0%';
|
||||||
|
rd.getCell(8).font = { color: { argb: ALERTE_COLOR[alerte] || C.GRAY }, bold: true };
|
||||||
|
rd.getCell(9).font = { color: { argb: RESULT_COLOR[result] || C.GRAY }, bold: true };
|
||||||
|
rd.eachCell(cell => {
|
||||||
|
cell.border = { bottom: { style: 'thin', color: { argb: C.LIGHT } } };
|
||||||
|
cell.alignment = { vertical: 'middle' };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generateXlsx };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
Loading…
Reference in New Issue