v0.1.1 : version et dist reconstruit
Patch (AGENTS règle 5 : correction, aucune sortie nouvelle — la quantité d'un poste passe par `dimension`, qui existait). Étiquette et merge au checkpoint de la session de pilotage ; montée dans reinnover le même jour (ouvre P1.2b). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tgtn7bJxTLiL2fRCkQMgtR
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
91c755f884
commit
8465bf7204
Vendored
+98
-19
@@ -7,6 +7,21 @@
|
||||
// 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) {
|
||||
@@ -15,11 +30,46 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
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);
|
||||
}
|
||||
@@ -47,11 +97,15 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
// 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 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);
|
||||
const alternatives = options.filter((o) => o.poste_id === poste_id_num && o.actif !== false && o.code !== poste_candidat.typologie_choisie);
|
||||
// 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);
|
||||
@@ -63,7 +117,8 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
candidats.push({
|
||||
poste: poste_candidat,
|
||||
alt: a,
|
||||
diff: (poste_candidat.cout_eur_ht || 0) - Math.round(a.cout),
|
||||
// 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -88,17 +143,16 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
typologie_choisie: meilleur.alt.code,
|
||||
nom_affichage: meilleur.alt.nom,
|
||||
description_pedago: meilleur.alt.description_pedago || '',
|
||||
dimension: meilleur.dim || p.dimension,
|
||||
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);
|
||||
total -= diff;
|
||||
downgraded = true;
|
||||
if (log)
|
||||
log.push('Downgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' -' + diff + 'EUR');
|
||||
log.push('Downgrade ' + poste_candidat.typologie_choisie + ' -> ' + meilleur.alt.code + ' -' + diff + 'EUR (accessoires compris)');
|
||||
}
|
||||
}
|
||||
if (!downgraded) {
|
||||
@@ -110,7 +164,6 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
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;
|
||||
@@ -121,6 +174,10 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
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';
|
||||
@@ -142,8 +199,6 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
// 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) {
|
||||
@@ -156,18 +211,26 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
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);
|
||||
// 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);
|
||||
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
|
||||
const codes_sel = new Set(getTousPostes().map((p) => p.typologie_choisie));
|
||||
// (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;
|
||||
@@ -184,10 +247,12 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
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);
|
||||
return a.cout > (poste_candidat.cout_eur_ht || 0) && total + diff_potential <= budget_total * overrun_seuil;
|
||||
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);
|
||||
@@ -202,7 +267,7 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
typologie_choisie: meilleur.alt.code,
|
||||
nom_affichage: meilleur.alt.nom,
|
||||
description_pedago: meilleur.alt.description_pedago || '',
|
||||
dimension: meilleur.dim || p.dimension,
|
||||
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),
|
||||
@@ -219,10 +284,13 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
@@ -260,14 +328,15 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
notes_techniques: best.opt.notes_techniques || '',
|
||||
flag_diy: best.opt.flag_diy || false,
|
||||
complexite_pose: best.opt.complexite_pose || 2,
|
||||
couplages_requis: [],
|
||||
couplages_exclus: [],
|
||||
// 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);
|
||||
codes_selectionnes.add(best.opt.code);
|
||||
upgraded = true;
|
||||
if (log)
|
||||
log.push('Ajout optionnel ' + best.opt.code + ' +' + Math.round(best.cout) + 'EUR');
|
||||
@@ -293,6 +362,16 @@ export function reajusteBudget(apres_couplages, budget_total, options, postes, i
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user