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:
@@ -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 };
|
||||
Reference in New Issue
Block a user