156 lines
5.8 KiB
JavaScript
156 lines
5.8 KiB
JavaScript
/**
|
|
* services/calc.js
|
|
* Helpers partagés : calculs, formatage, seuils métier RLA
|
|
*/
|
|
|
|
const SEUIL_STANDARD = parseFloat(process.env.SEUIL_STANDARD || 70);
|
|
const SEUIL_MODERNISATION = parseFloat(process.env.SEUIL_MODERNISATION || 50);
|
|
const SEUIL_CRITIQUE_PCT = parseFloat(process.env.SEUIL_CRITIQUE_PCT || 90);
|
|
const DELAI_CRITIQUE = parseInt(process.env.DELAI_CRITIQUE || 45, 10);
|
|
const DELAI_ATTENTION = parseInt(process.env.DELAI_ATTENTION || 90, 10);
|
|
|
|
// ─── Parseurs ───────────────────────────────────────────────────────────────
|
|
|
|
function parseNum(v) {
|
|
const n = parseFloat(String(v ?? '').replace(/\s/g, '').replace(',', '.'));
|
|
return isNaN(n) ? 0 : n;
|
|
}
|
|
|
|
function parseDateFR(d) {
|
|
if (!d) return null;
|
|
// ISO or FR dd/mm/yyyy
|
|
const parts = String(d).split(/[\/\-]/);
|
|
if (parts.length === 3) {
|
|
const [a, b, c] = parts;
|
|
if (a.length === 4) return new Date(`${a}-${b}-${c}`); // YYYY-MM-DD
|
|
if (c.length === 4) return new Date(`${c}-${b}-${a}`); // DD/MM/YYYY
|
|
}
|
|
const dt = new Date(d);
|
|
return isNaN(dt.getTime()) ? null : dt;
|
|
}
|
|
|
|
// ─── Formatage ───────────────────────────────────────────────────────────────
|
|
|
|
function formatMontant(v) {
|
|
const n = parseNum(v);
|
|
if (n === 0) return '—';
|
|
return n.toLocaleString('fr-TN', { minimumFractionDigits: 0, maximumFractionDigits: 3 }) + ' DT';
|
|
}
|
|
|
|
function formatDateFR(d) {
|
|
const dt = parseDateFR(d);
|
|
if (!dt) return '—';
|
|
return dt.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
|
}
|
|
|
|
function formatPct(v) {
|
|
const n = parseNum(v);
|
|
return n === 0 ? '0 %' : `${n.toFixed(1)} %`;
|
|
}
|
|
|
|
// ─── Détermination de statuts métier ─────────────────────────────────────────
|
|
|
|
function isCloture(r) {
|
|
const obs = String(r.observation || r.statut || '').toLowerCase();
|
|
return obs.includes('clôtur') || obs.includes('clotur') || !!r.date_cloture;
|
|
}
|
|
|
|
function getDelaiRestant(r) {
|
|
if (r.delai_restant != null && r.delai_restant !== '') {
|
|
const v = parseInt(r.delai_restant, 10);
|
|
return isNaN(v) ? null : v;
|
|
}
|
|
const fin = r.date_fin || r.datefin;
|
|
const dt = parseDateFR(fin);
|
|
if (!dt) return null;
|
|
return Math.ceil((dt - new Date()) / 86400000);
|
|
}
|
|
|
|
function niveauAlerte(delai) {
|
|
if (delai === null) return 'indéterminé';
|
|
if (delai <= DELAI_CRITIQUE) return 'critique';
|
|
if (delai <= DELAI_ATTENTION) return 'attention';
|
|
return 'normal';
|
|
}
|
|
|
|
function niveauAvancement(tauxPhy, nature) {
|
|
const t = parseNum(tauxPhy);
|
|
const seuil = String(nature || '').toLowerCase().includes('modern') ? SEUIL_MODERNISATION : SEUIL_STANDARD;
|
|
if (t >= SEUIL_CRITIQUE_PCT) return 'dépassé';
|
|
if (t >= seuil) return 'sous_avancement';
|
|
return 'normal';
|
|
}
|
|
|
|
// ─── Niveau de risque global ─────────────────────────────────────────────────
|
|
|
|
function niveauRisque(r) {
|
|
const delai = getDelaiRestant(r);
|
|
const avt = parseNum(r.taux_phy || r.avt_fin);
|
|
const nd = niveauAlerte(delai);
|
|
if (nd === 'critique' || avt >= SEUIL_CRITIQUE_PCT) return 'critique';
|
|
if (nd === 'attention') return 'élevé';
|
|
if (avt >= SEUIL_STANDARD) return 'moyen';
|
|
return 'faible';
|
|
}
|
|
|
|
// ─── Normalisation d'un marché ───────────────────────────────────────────────
|
|
|
|
function normalizeMarche(r) {
|
|
const delaiRestant = getDelaiRestant(r);
|
|
const tauxPhy = parseNum(r.taux_phy ?? r.avt_phy ?? r.avancement_physique);
|
|
const tauxFin = parseNum(r.taux_fin ?? r.avt_fin ?? r.avancement_financier);
|
|
const montant = parseNum(r.tot_marche ?? r.totmarche ?? r.montant);
|
|
const consomme = parseNum(r.consomme ?? r.montant_consomme ?? (montant * tauxFin / 100));
|
|
const restant = montant - consomme;
|
|
|
|
return {
|
|
id: r.id,
|
|
ref: r.ref || r.reference || r.id_marche || '',
|
|
projet: r.projet || '',
|
|
region: r.region || r.region_csc || '',
|
|
entrepreneur: r.entrepreneur || '',
|
|
nature: r.nature || '',
|
|
statut: r.statut || '',
|
|
observation: r.observation || '',
|
|
cloture: isCloture(r),
|
|
date_debut: formatDateFR(r.date_debut),
|
|
date_fin: formatDateFR(r.date_fin || r.datefin),
|
|
date_cloture: formatDateFR(r.date_cloture),
|
|
montant_raw: montant,
|
|
montant: formatMontant(montant),
|
|
consomme_raw: consomme,
|
|
consomme: formatMontant(consomme),
|
|
restant_raw: restant,
|
|
restant: formatMontant(restant),
|
|
taux_phy_raw: tauxPhy,
|
|
taux_phy: formatPct(tauxPhy),
|
|
taux_fin_raw: tauxFin,
|
|
taux_fin: formatPct(tauxFin),
|
|
delai_restant: delaiRestant,
|
|
niveau_alerte: niveauAlerte(delaiRestant),
|
|
niveau_avancement: niveauAvancement(r.taux_phy ?? r.avt_phy, r.nature),
|
|
niveau_risque: niveauRisque(r),
|
|
};
|
|
}
|
|
|
|
// ─── Seuils exportés ─────────────────────────────────────────────────────────
|
|
|
|
module.exports = {
|
|
SEUIL_STANDARD,
|
|
SEUIL_MODERNISATION,
|
|
SEUIL_CRITIQUE_PCT,
|
|
DELAI_CRITIQUE,
|
|
DELAI_ATTENTION,
|
|
parseNum,
|
|
parseDateFR,
|
|
formatMontant,
|
|
formatDateFR,
|
|
formatPct,
|
|
isCloture,
|
|
getDelaiRestant,
|
|
niveauAlerte,
|
|
niveauAvancement,
|
|
niveauRisque,
|
|
normalizeMarche,
|
|
};
|