// 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. // // Correctifs v0.1.1 (SPEC menu §12 n° 1, 2, 3, 5, 7) : // - CAS A : un downgrade ne cible jamais une typologie déjà présente (le conduit // forcé se retrouvait en double) ; après chaque retrait ou downgrade, les // accessoires forcés que plus rien ne requiert sont lâchés (le plancher de // coût n'est plus verrouillé par des couplages orphelins). // - CAS B, stratégie 1 : une ligne forcée n'est jamais « upgradée » (elle est // une contrainte, pas un choix ; l'origine la triait en tête avec son score // 0,8 et la transformait, puis la passe suivante re-forçait l'original) ; // la cible d'un upgrade n'est jamais une typologie déjà présente. // - CAS B, stratégie 2 : un poste = une ligne. L'origine ajoutait une 2e, 3e… // option au même poste (jusqu'à cinq `basse_planche_bec`), parce qu'elle // testait le code et pas le poste, sur une liste figée avant la boucle. // La quantité d'un poste est portée par `dimension`, pas par des lignes. import { codesChoisis, retireForcesOrphelins } from './couplages.js'; 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 alias = config.couplages_alias || {}; 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 }))); } /** Lâche les accessoires forcés orphelins ou absorbés ; vrai si quelque chose a bougé. */ function lacheOrphelins() { const retires = retireForcesOrphelins(axes, alias); if (retires.length && log) log.push(...retires); return retires.length > 0; } /** * Coût des lignes forcées que lâcherait le passage de `ligne` à la typologie * `nouveau` (null = retrait de la ligne) : ses requis que plus personne ne * demande, et la ligne forcée que le nouveau choix absorbe. Sert aux * arbitrages (CAS A « au plus près du budget », CAS B « tient dans le seuil ») * pour que le total prévu soit celui que la passe couplages rendra. */ function coutLibere(ligne, nouveau, instantane) { const requis_autres = new Set(); for (const p of instantane) { if (p.force_par_couplage || p === ligne || (p.typologie_choisie === ligne.typologie_choisie && p._axe === ligne._axe)) continue; for (const r of p.couplages_requis || []) requis_autres.add(alias[r] || r); } if (nouveau) for (const r of parseJsonArr(nouveau.couplages_requis)) requis_autres.add(alias[r] || r); let libere = 0; for (const p of instantane) { if (!p.force_par_couplage) continue; if (!requis_autres.has(p.typologie_choisie) || (nouveau && p.typologie_choisie === nouveau.code)) libere += p.cout_eur_ht || 0; } return libere; } 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 instantane = getTousPostes(); const tous = instantane.filter((p) => !p.force_par_couplage && !p.manuel); const choisis = codesChoisis(axes); const candidats = []; for (const poste_candidat of tous) { const poste_id_num = _posteIdNum(poste_candidat, options); // v0.1.1 : jamais vers le choix d'un autre poste (une ligne forcée de ce // code, elle, est absorbée par la passe couplages) const alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie && !choisis.has(o.code)); 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, // v0.1.1 : la baisse réelle compte les accessoires forcés que ce changement lâche diff: (poste_candidat.cout_eur_ht || 0) - Math.round(a.cout) + coutLibere(poste_candidat, a.alt, instantane), }); } } 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: dimensionDe(meilleur.alt, meilleur.dim), 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); downgraded = true; if (log) log.push('Downgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' -' + diff + 'EUR (accessoires compris)'); } } 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) { ax.postes.splice(idx, 1); recalcSousTotal(axe_poste); downgraded = true; if (log) log.push('Retrait optionnel ' + pire.typologie_choisie); } } if (!downgraded) break; } // v0.1.1 : le principal a pu disparaître ou changer — ses accessoires forcés // ne comptent plus dans le plancher. lacheOrphelins(); total = calcTotal(); } 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 || []); // 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; // v0.1.1 : état réel à chaque tour (l'origine figeait la liste des codes // avant la boucle et ne la mettait pas à jour après un upgrade) const instantane = getTousPostes(); const codes_selectionnes = codesChoisis(axes); const postes_occupes = new Set(instantane.map((p) => _posteIdNum(p, options))); // Stratégie 1 : upgrader des typologies existantes (jamais une ligne forcée : // c'est une contrainte, la passe couplages re-forcerait l'original) const tous = instantane.filter((p) => !p.manuel && !p.force_par_couplage); 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 && !codes_selectionnes.has(o.code)); 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 // (v0.1.1 : la ligne qu'on remplace ne compte pas — un requis égal à // son code actuel devra être forcé après l'upgrade) const codes_sel = new Set(instantane.filter((p) => !(p.typologie_choisie === poste_candidat.typologie_choisie && p._axe === poste_candidat._axe)).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 // (v0.1.1 : et en déduisant les accessoires forcés que le changement lâche) }) .filter((a) => { const diff_potential = a.cout + a.cout_couplages_requis - (poste_candidat.cout_eur_ht || 0) - coutLibere(poste_candidat, a.alt, instantane); // v0.1.1 : un « upgrade » dont l'effet net fait baisser le total n'en est pas un return a.cout > (poste_candidat.cout_eur_ht || 0) && diff_potential > 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: dimensionDe(meilleur.alt, meilleur.dim), 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 // (v0.1.1 : un poste = une ligne ; la quantité passe par `dimension`) if (!upgraded) { const postes_manquants = options.filter((o) => { if (o.actif === false || codes_selectionnes.has(o.code)) return false; if (postes_occupes.has(o.poste_id)) 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, // v0.1.1 : la ligne dit ce qu'elle requiert (l'origine écrivait [] : // un poêle ajouté ici n'aurait jamais eu son conduit) couplages_requis: parseJsonArr(best.opt.couplages_requis), couplages_exclus: parseJsonArr(best.opt.couplages_exclus), manuel: false, _added_cas_b: true, }); ax.sous_total_eur_ht += Math.round(best.cout); total += Math.round(best.cout); 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, }; } /** * Dimension d'une ligne qui change de typologie : celle calculée pour la * nouvelle option, sinon le forfait de l'option (même convention que * selectionnePoste). v0.1.1 : l'origine héritait de la dimension de la ligne * remplacée (une batterie au plomb affichée « 0,4 kWc », une chaudière « 6 ml »), * et le taux de couverture énergie lisait ces kWc fantômes. */ function dimensionDe(alt, dim) { return dim || { valeur: alt.quantite_default || 1, unite: alt.unite || '' }; } function _posteIdNum(poste_result, options) { const typo = options.find((o) => o.code === poste_result.typologie_choisie); return typo ? typo.poste_id : -1; }