// reajuste.ts — Étape 5 : réajustement budget global // Spec: V2-algo-spec.md §Étape 5 — origine calculs/src/lib/autonomie/reajuste.js. // Correction LOT P1.0 : `reglages` en paramètre (plus de getConfig()). // Comportement d'origine conservé : la fonction modifie EN PLACE les axes // reçus (qui sont déjà la copie produite par appliqueCouplages) et les renvoie. // // Note (origine, conservée) : les messages utilisent toLocaleString('fr-FR') // (Intl). Texte de présentation, non assert par les tests ; à sortir du noyau // en patch si l'octet-pour-octet inter-environnements devient un besoin. import { cout_typologie, dimension_optimale } from './dimension.js'; import { parseJsonArr } from './types.js'; export function reajusteBudget(apres_couplages, budget_total, options, postes, inputs, contexte_derived, reglages, debug = false) { const config = reglages.config; const coefficients = reglages.coefficients; const tolerances = config.tolerances || {}; const overrun_seuil = 1 + (tolerances.budget_overrun_pct || 5) / 100; const underspend_seuil = 1 - (tolerances.budget_underspend_pct || 15) / 100; const axes = apres_couplages.axes; const axes_list = ['eau', 'energie', 'alim']; function getTousPostes() { return axes_list.flatMap((axe) => (axes[axe] && axes[axe].postes ? axes[axe].postes : []).map((p) => Object.assign({}, p, { _axe: axe }))); } function calcTotal() { return axes_list.reduce((s, axe) => s + (axes[axe] ? axes[axe].sous_total_eur_ht || 0 : 0), 0); } function recalcSousTotal(axe) { const a = axes[axe]; if (a) { a.sous_total_eur_ht = a.postes.reduce((s, p) => s + (p.cout_eur_ht || 0), 0); } } let total = calcTotal(); let status = 'OK'; let message = ''; const log = debug ? [] : null; // CAS A : overrun if (total > budget_total * overrun_seuil) { if (log) log.push('CAS A : total ' + total + ' > seuil ' + Math.round(budget_total * overrun_seuil)); let iterations = 0; while (total > budget_total * overrun_seuil && iterations < 80) { iterations++; // Correctif V3-1 (recalibrage) : downgrade "au plus pres du budget" au // lieu du moins cher. Historiquement, le poste candidat sautait vers son // alternative LA MOINS CHERE (ex : poele de masse -> rocket stove, // -23 000 EUR la ou quelques centaines suffisaient a repasser sous le // seuil). Desormais : parmi TOUS les downgrades possibles on choisit // celui dont le total resultant est le plus proche du budget cible — // evite a la fois le surcout et le sous-equipement massif. const tous = getTousPostes().filter((p) => !p.force_par_couplage && !p.manuel); const candidats = []; for (const poste_candidat of tous) { const poste_id_num = _posteIdNum(poste_candidat, options); const alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie); const alts_scored = alternatives .map((alt) => { const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients); const cout = cout_typologie(alt, dim, inputs); return { alt, dim, cout }; }) .filter((a) => a.cout < (poste_candidat.cout_eur_ht || 0)); for (const a of alts_scored) { candidats.push({ poste: poste_candidat, alt: a, diff: (poste_candidat.cout_eur_ht || 0) - Math.round(a.cout), }); } } let choix = null; if (candidats.length > 0) { // total resultant le plus proche du budget (tiebreak : pire score d'abord) candidats.sort((a, b) => Math.abs(total - a.diff - budget_total) - Math.abs(total - b.diff - budget_total) || (a.poste.score_final || 0) - (b.poste.score_final || 0)); choix = candidats[0]; } let downgraded = false; if (choix) { const poste_candidat = choix.poste; const meilleur = choix.alt; const diff = choix.diff; const axe_poste = poste_candidat._axe; const ax = axes[axe_poste]; const idx = ax.postes.findIndex((p) => p.typologie_choisie === poste_candidat.typologie_choisie); if (idx >= 0) { const p = ax.postes[idx]; ax.postes[idx] = Object.assign({}, p, { typologie_choisie: meilleur.alt.code, nom_affichage: meilleur.alt.nom, description_pedago: meilleur.alt.description_pedago || '', dimension: meilleur.dim || p.dimension, cout_eur_ht: Math.round(meilleur.cout), couplages_requis: parseJsonArr(meilleur.alt.couplages_requis), couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus), _downgraded: true, }); recalcSousTotal(axe_poste); total -= diff; downgraded = true; if (log) log.push('Downgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' -' + diff + 'EUR'); } } if (!downgraded) { const optionnels = getTousPostes().filter((p) => !p.obligatoire && !p.force_par_couplage && !p.manuel); optionnels.sort((a, b) => (a.score_final || 0) - (b.score_final || 0)); if (optionnels.length > 0) { const pire = optionnels[0]; const axe_poste = pire._axe; const ax = axes[axe_poste]; const idx = ax.postes.findIndex((p) => p.typologie_choisie === pire.typologie_choisie); if (idx >= 0) { total -= ax.postes[idx].cout_eur_ht || 0; ax.postes.splice(idx, 1); recalcSousTotal(axe_poste); downgraded = true; if (log) log.push('Retrait optionnel ' + pire.typologie_choisie); } } if (!downgraded) break; } } if (total > budget_total * overrun_seuil) { status = 'BUDGET_INSUFFISANT'; message = 'Budget insuffisant pour équiper tous les postes obligatoires. Estimation minimale : ' + Math.round(total).toLocaleString('fr-FR') + ' EUR. Votre budget : ' + Math.round(budget_total).toLocaleString('fr-FR') + ' EUR.'; if (log) log.push('BUDGET_INSUFFISANT — toutes alternatives épuisées'); } } // CAS B : underspend else if (total < budget_total * underspend_seuil) { if (log) log.push('CAS B : total ' + total + ' < seuil ' + Math.round(budget_total * underspend_seuil)); // Axes explicitement désactivés par l'utilisateur (V3 toggles d'axes) : // ne jamais y réinjecter de postes optionnels. Marqueur posé par // axes-actifs.ts (configPourAxes). Absent = comportement historique inchangé. const axes_desactives = new Set(config._axes_desactives || []); // Identifier les postes BDD non encore sélectionnés (optionnels manquants) const codes_selectionnes = new Set(getTousPostes().map((p) => p.typologie_choisie)); // Construire mapping poste_code → axe via postes BDD const posteIdToAxe = {}; if (postes) { for (const p of postes) { const axe_norm = (p.axe === 'alimentation' ? 'alim' : p.axe); posteIdToAxe[p.Id] = axe_norm; } } let iterations = 0; while (total < budget_total * underspend_seuil && iterations < 80) { iterations++; let upgraded = false; // Stratégie 1 : upgrader des typologies existantes const tous = getTousPostes().filter((p) => !p.manuel); tous.sort((a, b) => (b.score_adequation || 0) - (a.score_adequation || 0)); for (const poste_candidat of tous) { const poste_id_num = _posteIdNum(poste_candidat, options); const alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie); const alts_scored = alternatives .map((alt) => { const dim = dimension_optimale(alt, inputs, contexte_derived, coefficients); const cout = cout_typologie(alt, dim, inputs); // Estimer le coût des couplages requis non encore sélectionnés const codes_sel = new Set(getTousPostes().map((p) => p.typologie_choisie)); const alias = (config && config.couplages_alias) || {}; const requis_list = parseJsonArr(alt.couplages_requis); let cout_couplages_requis = 0; for (const r of requis_list) { const r_resolved = alias[r] || r; if (!codes_sel.has(r) && !codes_sel.has(r_resolved)) { const typo_req = options.find((o) => o.code === r_resolved || o.code === r); if (typo_req) { const dim_req = dimension_optimale(typo_req, inputs, contexte_derived, coefficients); cout_couplages_requis += cout_typologie(typo_req, dim_req, inputs); } } } return { alt, dim, cout, cout_couplages_requis }; // Ne conserver que les upgrades qui ne dépassent pas le budget global // en incluant le coût estimé des couplages requis non encore satisfaits }) .filter((a) => { const diff_potential = a.cout + a.cout_couplages_requis - (poste_candidat.cout_eur_ht || 0); return a.cout > (poste_candidat.cout_eur_ht || 0) && total + diff_potential <= budget_total * overrun_seuil; }); if (alts_scored.length > 0) { alts_scored.sort((a, b) => a.cout - b.cout); const meilleur = alts_scored[0]; const diff = Math.round(meilleur.cout) - (poste_candidat.cout_eur_ht || 0); const axe_poste = poste_candidat._axe; const ax = axes[axe_poste]; const idx = ax.postes.findIndex((p) => p.typologie_choisie === poste_candidat.typologie_choisie); if (idx >= 0) { const p = ax.postes[idx]; ax.postes[idx] = Object.assign({}, p, { typologie_choisie: meilleur.alt.code, nom_affichage: meilleur.alt.nom, description_pedago: meilleur.alt.description_pedago || '', dimension: meilleur.dim || p.dimension, cout_eur_ht: Math.round(meilleur.cout), // IMPORTANT: mettre à jour les couplages de la nouvelle typo (pas hériter ancienne) couplages_requis: parseJsonArr(meilleur.alt.couplages_requis), couplages_exclus: parseJsonArr(meilleur.alt.couplages_exclus), _upgraded: true, }); recalcSousTotal(axe_poste); total += diff; upgraded = true; if (log) log.push('Upgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' +' + diff + 'EUR'); break; } } } // Stratégie 2 : ajouter des postes optionnels non encore sélectionnés if (!upgraded) { const postes_manquants = options.filter((o) => { if (o.actif === false || codes_selectionnes.has(o.code)) return false; // Ne pas ajouter de poste appartenant à un axe désactivé (V3) if (axes_desactives.size > 0 && posteIdToAxe[o.poste_id] && axes_desactives.has(posteIdToAxe[o.poste_id])) return false; return true; }); // Trier par rapport score/cout et taille raisonnable const candidats_add = postes_manquants .map((o) => { const dim = dimension_optimale(o, inputs, contexte_derived, coefficients); const cout = cout_typologie(o, dim, inputs); return { opt: o, dim, cout }; }) .filter((a) => a.cout > 0 && total + a.cout <= budget_total * overrun_seuil); candidats_add.sort((a, b) => a.cout - b.cout); if (candidats_add.length > 0) { const best = candidats_add[candidats_add.length - 1]; // le plus cher qui rentre const axe_target = posteIdToAxe[best.opt.poste_id] || 'energie'; const ax = axes[axe_target]; if (ax) { const valeur_dim = best.dim ? best.dim.valeur : best.opt.quantite_default || 1; const unite_dim = best.dim ? best.dim.unite : best.opt.unite || ''; ax.postes.push({ poste_id: best.opt.poste_id, poste_nom: best.opt.nom, obligatoire: false, budget_alloue_poste: Math.round(best.cout), typologie_choisie: best.opt.code, nom_affichage: best.opt.nom, description_pedago: best.opt.description_pedago || '', dimension: { valeur: valeur_dim, unite: unite_dim }, cout_eur_ht: Math.round(best.cout), score_adequation: 0.6, score_final: 0, alternatives: [], notes_techniques: best.opt.notes_techniques || '', flag_diy: best.opt.flag_diy || false, complexite_pose: best.opt.complexite_pose || 2, couplages_requis: [], couplages_exclus: [], manuel: false, _added_cas_b: true, }); ax.sous_total_eur_ht += Math.round(best.cout); total += Math.round(best.cout); codes_selectionnes.add(best.opt.code); upgraded = true; if (log) log.push('Ajout optionnel ' + best.opt.code + ' +' + Math.round(best.cout) + 'EUR'); } } } if (!upgraded) { status = 'AJOUTER_OPTIONNEL'; message = 'Budget non consommé (' + Math.round(budget_total - total).toLocaleString('fr-FR') + ' EUR disponibles). Postes optionnels suggérés non inclus.'; if (log) log.push('AJOUTER_OPTIONNEL — budget non consommable'); break; } } } total = calcTotal(); return { axes, total: Math.round(total), status, message, couplages_appliques: apres_couplages.couplages_appliques ?? [], debug_reajust: log, }; } function _posteIdNum(poste_result, options) { const typo = options.find((o) => o.code === poste_result.typologie_choisie); return typo ? typo.poste_id : -1; }