/** * 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, };