/** * 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 buildRef(r) { const base = r.id_marche || r.reference || String(r.id || ''); const reg = r.region_csc || r.region || ''; return reg ? `${base} - ${reg}` : base; } 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: buildRef(r), 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, buildRef, };