feat: v3 — refonte thème + avancement physique + exports conformes cibles
- index.html: refonte complète thème dark gradient (top-header + slide-nav, 9 vues) - calc.js: ajout resultatPhysique(), avancement physique partout - routes/pilotage.js: catégories Normal/Sous Avancement/Dépassement/Non déterminé - services/export-xlsx.js: rapport complet multi-feuilles (Situation + Pilotage) - routes/export.js: XLSX/PPTX/DOCX — sortie unique complète Zone Sud - PPTX: 5 slides (couverture, synthèse, alertes, pilotage, par région) - DOCX: rapport structuré (couverture, KPIs, alertes, par région, pilotage) - services/export-pdf.js: colonnes avancement physique uniquement Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
88a0dbe6d2
commit
8d901da125
+115
-69
@@ -1,29 +1,43 @@
|
||||
/**
|
||||
* 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);
|
||||
const SEUIL_STANDARD = parseFloat(process.env.SEUIL_STANDARD || 70);
|
||||
const SEUIL_MODERNISATION = parseFloat(process.env.SEUIL_MODERNISATION || 50);
|
||||
const SEUIL_CRITIQUE_PCT = parseFloat(process.env.SEUIL_CRITIQUE_PCT || 90);
|
||||
const DELAI_CRITIQUE = parseInt(process.env.DELAI_CRITIQUE || 45, 10);
|
||||
const DELAI_ATTENTION = parseInt(process.env.DELAI_ATTENTION || 90, 10);
|
||||
|
||||
// ─── Parseurs ───────────────────────────────────────────────────────────────
|
||||
// ─── Helpers Baserow select/multi-select ─────────────────────────────────────
|
||||
|
||||
/** Extrait la valeur d'un champ Baserow (select ou string) */
|
||||
function selectVal(v) {
|
||||
if (!v) return '';
|
||||
if (typeof v === 'object' && v.value !== undefined) return String(v.value);
|
||||
if (Array.isArray(v)) return v.map(x => (x.value !== undefined ? x.value : x)).join(', ');
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// ─── Parseurs ────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseNum(v) {
|
||||
const n = parseFloat(String(v ?? '').replace(/\s/g, '').replace(',', '.'));
|
||||
if (v === null || v === undefined || v === '') return 0;
|
||||
if (typeof v === 'object') return 0; // objet Baserow non-numérique
|
||||
const n = parseFloat(String(v).replace(/\s/g, '').replace(',', '.'));
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function parseDateFR(d) {
|
||||
if (!d) return null;
|
||||
// ISO or FR dd/mm/yyyy
|
||||
const parts = String(d).split(/[\/\-]/);
|
||||
if (parts.length === 3) {
|
||||
const [a, b, c] = parts;
|
||||
if (a.length === 4) return new Date(`${a}-${b}-${c}`); // YYYY-MM-DD
|
||||
if (c.length === 4) return new Date(`${c}-${b}-${a}`); // DD/MM/YYYY
|
||||
if (a.length === 4) return new Date(`${a}-${b}-${c}`);
|
||||
if (c.length === 4) return new Date(`${c}-${b}-${a}`);
|
||||
}
|
||||
const dt = new Date(d);
|
||||
return isNaN(dt.getTime()) ? null : dt;
|
||||
@@ -34,7 +48,7 @@ function parseDateFR(d) {
|
||||
function formatMontant(v) {
|
||||
const n = parseNum(v);
|
||||
if (n === 0) return '—';
|
||||
return n.toLocaleString('fr-TN', { minimumFractionDigits: 0, maximumFractionDigits: 3 }) + ' DT';
|
||||
return n.toLocaleString('fr-TN', { minimumFractionDigits: 0, maximumFractionDigits: 0 }) + ' DT';
|
||||
}
|
||||
|
||||
function formatDateFR(d) {
|
||||
@@ -48,19 +62,22 @@ function formatPct(v) {
|
||||
return n === 0 ? '0 %' : `${n.toFixed(1)} %`;
|
||||
}
|
||||
|
||||
// ─── Détermination de statuts métier ─────────────────────────────────────────
|
||||
// ─── Statuts métier ───────────────────────────────────────────────────────────
|
||||
|
||||
function isCloture(r) {
|
||||
const obs = String(r.observation || r.statut || '').toLowerCase();
|
||||
const obs = selectVal(r.observation).toLowerCase();
|
||||
return obs.includes('clôtur') || obs.includes('clotur') || !!r.date_cloture;
|
||||
}
|
||||
|
||||
function getDelaiRestant(r) {
|
||||
if (r.delai_restant != null && r.delai_restant !== '') {
|
||||
const v = parseInt(r.delai_restant, 10);
|
||||
return isNaN(v) ? null : v;
|
||||
// Champ calculé Baserow
|
||||
const dField = r.delai_restant;
|
||||
if (dField !== null && dField !== undefined && dField !== '') {
|
||||
const v = parseInt(String(dField), 10);
|
||||
if (!isNaN(v)) return v;
|
||||
}
|
||||
const fin = r.date_fin || r.datefin;
|
||||
// Calculer depuis date_fin
|
||||
const fin = r.date_fin || r.date_fin_marche || r.datefin;
|
||||
const dt = parseDateFR(fin);
|
||||
if (!dt) return null;
|
||||
return Math.ceil((dt - new Date()) / 86400000);
|
||||
@@ -74,18 +91,45 @@ function niveauAlerte(delai) {
|
||||
}
|
||||
|
||||
function niveauAvancement(tauxPhy, nature) {
|
||||
const t = parseNum(tauxPhy);
|
||||
const seuil = String(nature || '').toLowerCase().includes('modern') ? SEUIL_MODERNISATION : SEUIL_STANDARD;
|
||||
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';
|
||||
}
|
||||
|
||||
// ─── Niveau de risque global ─────────────────────────────────────────────────
|
||||
/**
|
||||
* Résultat financier du marché (pour pilotage proactif)
|
||||
* Compare avt_fin (avancement financier en DT) vs m_min / m_max
|
||||
*/
|
||||
function resultatFinancier(r) {
|
||||
const avt = parseNum(r.avt_fin);
|
||||
const mMin = parseNum(r.m_min);
|
||||
const mMax = parseNum(r.m_max ?? r.tot_marche);
|
||||
if (avt === 0 && mMin === 0) return 'Non déterminé';
|
||||
if (avt > mMax && mMax > 0) return 'Dépassement';
|
||||
if (avt < mMin && mMin > 0) return 'Sous Min';
|
||||
return 'Normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Résultat basé sur l'avancement PHYSIQUE du marché (pilotage proactif)
|
||||
* Compare taux_phy vs seuils standards / critique
|
||||
*/
|
||||
function resultatPhysique(r) {
|
||||
const taux = parseNum(r.taux_phy || r.avt_phy);
|
||||
const nat = String(selectVal(r.nature) || '').toLowerCase();
|
||||
const seuil = nat.includes('modern') ? SEUIL_MODERNISATION : SEUIL_STANDARD;
|
||||
if (taux === 0) return 'Non déterminé';
|
||||
if (taux >= SEUIL_CRITIQUE_PCT) return 'Dépassement';
|
||||
if (taux >= seuil) return 'Normal';
|
||||
return 'Sous Avancement';
|
||||
}
|
||||
|
||||
function niveauRisque(r) {
|
||||
const delai = getDelaiRestant(r);
|
||||
const avt = parseNum(r.taux_phy || r.avt_fin);
|
||||
const avt = parseNum(r.taux_phy || r.avt_phy);
|
||||
const nd = niveauAlerte(delai);
|
||||
if (nd === 'critique' || avt >= SEUIL_CRITIQUE_PCT) return 'critique';
|
||||
if (nd === 'attention') return 'élevé';
|
||||
@@ -96,60 +140,62 @@ function niveauRisque(r) {
|
||||
// ─── Normalisation d'un marché ───────────────────────────────────────────────
|
||||
|
||||
function normalizeMarche(r) {
|
||||
const obsValue = selectVal(r.observation);
|
||||
const natureValue = selectVal(r.nature);
|
||||
const delaiRestant = getDelaiRestant(r);
|
||||
const tauxPhy = parseNum(r.taux_phy ?? r.avt_phy ?? r.avancement_physique);
|
||||
const tauxFin = parseNum(r.taux_fin ?? r.avt_fin ?? r.avancement_financier);
|
||||
const montant = parseNum(r.tot_marche ?? r.totmarche ?? r.montant);
|
||||
const consomme = parseNum(r.consomme ?? r.montant_consomme ?? (montant * tauxFin / 100));
|
||||
const restant = montant - consomme;
|
||||
const tauxPhy = parseNum(r.taux_phy || r.avt_phy);
|
||||
const tauxFin = parseNum(r.taux_fin || r.avt_fin);
|
||||
const montant = parseNum(r.tot_marche || r.totmarche || r.montant);
|
||||
const mMin = parseNum(r.m_min);
|
||||
const mMax = parseNum(r.m_max || r.tot_marche);
|
||||
const avt_fin_raw = parseNum(r.avt_fin);
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
ref: r.ref || r.reference || r.id_marche || '',
|
||||
projet: r.projet || '',
|
||||
region: r.region || r.region_csc || '',
|
||||
entrepreneur: r.entrepreneur || '',
|
||||
nature: r.nature || '',
|
||||
statut: r.statut || '',
|
||||
observation: r.observation || '',
|
||||
cloture: isCloture(r),
|
||||
date_debut: formatDateFR(r.date_debut),
|
||||
date_fin: formatDateFR(r.date_fin || r.datefin),
|
||||
date_cloture: formatDateFR(r.date_cloture),
|
||||
montant_raw: montant,
|
||||
montant: formatMontant(montant),
|
||||
consomme_raw: consomme,
|
||||
consomme: formatMontant(consomme),
|
||||
restant_raw: restant,
|
||||
restant: formatMontant(restant),
|
||||
taux_phy_raw: tauxPhy,
|
||||
taux_phy: formatPct(tauxPhy),
|
||||
taux_fin_raw: tauxFin,
|
||||
taux_fin: formatPct(tauxFin),
|
||||
delai_restant: delaiRestant,
|
||||
niveau_alerte: niveauAlerte(delaiRestant),
|
||||
niveau_avancement: niveauAvancement(r.taux_phy ?? r.avt_phy, r.nature),
|
||||
niveau_risque: niveauRisque(r),
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Seuils exportés ─────────────────────────────────────────────────────────
|
||||
|
||||
module.exports = {
|
||||
SEUIL_STANDARD,
|
||||
SEUIL_MODERNISATION,
|
||||
SEUIL_CRITIQUE_PCT,
|
||||
DELAI_CRITIQUE,
|
||||
DELAI_ATTENTION,
|
||||
parseNum,
|
||||
parseDateFR,
|
||||
formatMontant,
|
||||
formatDateFR,
|
||||
formatPct,
|
||||
isCloture,
|
||||
getDelaiRestant,
|
||||
niveauAlerte,
|
||||
niveauAvancement,
|
||||
niveauRisque,
|
||||
SEUIL_STANDARD, SEUIL_MODERNISATION, SEUIL_CRITIQUE_PCT,
|
||||
DELAI_CRITIQUE, DELAI_ATTENTION,
|
||||
selectVal, parseNum, parseDateFR,
|
||||
formatMontant, formatDateFR, formatPct,
|
||||
isCloture, getDelaiRestant, niveauAlerte,
|
||||
niveauAvancement, resultatFinancier, resultatPhysique, niveauRisque,
|
||||
normalizeMarche,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user