From c26881da413302074c40611ed179b447dc8c0b5d Mon Sep 17 00:00:00 2001 From: Hermes Bot Date: Thu, 15 Oct 2026 21:10:40 +0000 Subject: [PATCH] =?UTF-8?q?fix(pont-thermique):=20reprendre=20la=20ligne?= =?UTF-8?q?=20PT=20du=20DPE=20si=20seul=20le=20dormant=20diff=C3=A8re=20(#?= =?UTF-8?q?296)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En mode bug_for_bug_compat, pour une liaison menuiserie / mur, la ligne tv_pont_thermique_id exportée par le DPE est reprise lorsqu'elle ne diffère de la ligne recalculée que par la largeur du dormant et que son k est égal au k exporté. Comportement normatif inchangé hors bug_for_bug_compat. Appliqué au legacy et à la nouvelle architecture. --- src/3.4_pont_thermique.js | 55 +++++- src/3.4_pont_thermique.spec.js | 180 +++++++++++++++++- .../enveloppe/pontThermiqueTv.store.js | 54 +++++- .../enveloppe/pontThermiqueTv.store.spec.js | 26 +++ src/features/engine/domain/engine.service.js | 13 +- .../engine/domain/engine.service.spec.js | 10 +- .../deperdition-enveloppe.service.js | 4 +- .../deperdition-enveloppe.service.spec.js | 15 +- .../deperdition-pont-thermique.service.js | 87 ++++++++- ...deperdition-pont-thermique.service.spec.js | 166 +++++++++++++++- 10 files changed, 581 insertions(+), 29 deletions(-) diff --git a/src/3.4_pont_thermique.js b/src/3.4_pont_thermique.js index 33ebf4dbe..a9c709611 100644 --- a/src/3.4_pont_thermique.js +++ b/src/3.4_pont_thermique.js @@ -30,6 +30,53 @@ function defaultValue(type_liaison, pt_di, de) { return pourcentageValeurPontThermique(type_liaison, de, k, pt_di); } +/** + * Mode bug_for_bug_compat uniquement - pont thermique « menuiserie / mur » + * + * Certains logiciels (DPEWIN 3.1.x / 5.1.6, moteur 3cl_tribu_1.4.25.1) exportent une largeur de dormant + * (ex : 5 cm) sur la menuiserie alors que le tv_pont_thermique_id et le k exportés pour le pont thermique + * correspondent à la ligne d'une autre largeur de dormant (ex : 10 cm). + * Normativement la ligne recalculée est la bonne (largeur du dormant « arrondie à la valeur la plus proche »), + * mais pour rester fidèle au calcul certifié, on reprend la ligne exportée par le DPE si : + * - elle ne diffère de la ligne recalculée que par la largeur du dormant + * (même type de liaison, isolation du mur, type de pose et présence de retour d'isolation), + * - son k est égal au k exporté dans les données intermédiaires du pont thermique. + * + * @see Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4.5 Menuiserie / mur + * @see https://github.com/Open3CL/engine/issues/296 + * + * @param row {object|undefined} ligne de la table pont_thermique recalculée + * @param tv_id_dpe {string|number|undefined} tv_pont_thermique_id exporté par le DPE + * @param pt_di {Donnee_intermediaire} données intermédiaires exportées du pont thermique + * @returns {object|undefined} ligne à utiliser pour le pont thermique + */ +export function ligneDpeSiSeulDormantDiffere(row, tv_id_dpe, pt_di) { + if (!row || isNil(tv_id_dpe) || isNil(pt_di?.k)) { + return row; + } + + const rowDpe = tv('pont_thermique', { tv_pont_thermique_id: tv_id_dpe }); + + if ( + rowDpe && + String(rowDpe.tv_pont_thermique_id) === String(tv_id_dpe) && + rowDpe.enum_type_liaison_id === row.enum_type_liaison_id && + rowDpe.largeur_dormant && + rowDpe.largeur_dormant !== row.largeur_dormant && + rowDpe.isolation_mur === row.isolation_mur && + rowDpe.type_pose === row.type_pose && + rowDpe.presence_retour_isolation === row.presence_retour_isolation && + Number(rowDpe.k) === Number(pt_di.k) + ) { + console.warn( + `Pont thermique menuiserie / mur : reprise de la ligne tv_pont_thermique_id = ${tv_id_dpe} du DPE (largeur de dormant ${rowDpe.largeur_dormant} au lieu de ${row.largeur_dormant})` + ); + return rowDpe; + } + + return row; +} + function tv_k(pt_di, di, de, du, pc_id, logement) { const enveloppe = logement.enveloppe; @@ -41,6 +88,7 @@ function tv_k(pt_di, di, de, du, pc_id, logement) { const porte_list = enveloppe.porte_collection.porte || []; const type_liaison = requestInput(de, du, 'type_liaison'); + const tv_id_dpe = de.tv_pont_thermique_id; if (!de.reference_1) { console.warn(`BUG: pas de reference pour le pont thermique ${de.description}...`); @@ -418,7 +466,12 @@ function tv_k(pt_di, di, de, du, pc_id, logement) { } } - const row = tv('pont_thermique', matcher); + let row = tv('pont_thermique', matcher); + + if (bug_for_bug_compat && type_liaison === 'menuiserie / mur') { + row = ligneDpeSiSeulDormantDiffere(row, tv_id_dpe, pt_di); + } + if (row) { di.k = Number(row.k); de.tv_pont_thermique_id = Number(row.tv_pont_thermique_id); diff --git a/src/3.4_pont_thermique.spec.js b/src/3.4_pont_thermique.spec.js index 8bf92fb31..2a03fbe6e 100644 --- a/src/3.4_pont_thermique.spec.js +++ b/src/3.4_pont_thermique.spec.js @@ -36,7 +36,8 @@ vi.mock('./utils.js', () => ({ } })); -const { default: calc_pont_thermique } = await import('./3.4_pont_thermique.js'); +const { default: calc_pont_thermique, ligneDpeSiSeulDormantDiffere } = + await import('./3.4_pont_thermique.js'); const { tv } = await import('./utils.js'); /** Enveloppe minimale avec collections contrôlées. */ @@ -752,7 +753,9 @@ describe('calc_pont_thermique - liaison menuiserie / mur', () => { calc_pont_thermique(pt, '1', logement({ mur, bv: [menuiserie] })); - expect(tv).toHaveBeenLastCalledWith( + // 3e appel tv : vérification de la ligne exportée (issue #296), sans effet ici + expect(tv).toHaveBeenNthCalledWith( + 2, 'pont_thermique', expect.objectContaining({ type_pose: 'nu extérieur' }) ); @@ -769,7 +772,8 @@ describe('calc_pont_thermique - liaison menuiserie / mur', () => { calc_pont_thermique(pt, '1', logement({ mur, bv: [menuiserie] })); - expect(tv).toHaveBeenLastCalledWith( + expect(tv).toHaveBeenNthCalledWith( + 2, 'pont_thermique', expect.objectContaining({ type_pose: 'tunnel' }) ); @@ -1017,3 +1021,173 @@ describe('calc_pont_thermique - plancher sur paroi non déperditive (issue #212) expect(pt.donnee_intermediaire.k).toBe(0.71); }); }); + +/** + * Reprise de la ligne « menuiserie / mur » exportée par le DPE quand seule la largeur du dormant + * diffère de la ligne recalculée (compatibilité bug uniquement). + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4.5 + * @see https://github.com/Open3CL/engine/issues/296 + */ +describe('calc_pont_thermique - ligne DPE reprise si seul le dormant diffère (issue #296)', () => { + /** Ligne recalculée : dormant 5 cm. */ + const ligneRecalculee = { + tv_pont_thermique_id: '71', + enum_type_liaison_id: '5', + isolation_mur: 'Non isolé', + type_pose: 'Nu Extérieur', + largeur_dormant: '5', + k: '0.43' + }; + /** Ligne exportée par le DPE : dormant 10 cm, mêmes autres critères. */ + const ligneDpe = { + ...ligneRecalculee, + tv_pont_thermique_id: '72', + largeur_dormant: '10', + k: '0.29' + }; + + function cas(k = 0.29) { + const mur = [ + { + donnee_entree: { + reference: 'M1', + enum_type_adjacence_id: '1', + enum_materiaux_structure_mur_id: '1', + type_isolation: 'non isolé' + }, + donnee_utilisateur: {} + } + ]; + const baie = { + donnee_entree: { + reference: 'B1', + type_pose: 'nu extérieur', + presence_retour_isolation: 0, + largeur_dormant: 5 + }, + donnee_utilisateur: {} + }; + const pt = pontThermique( + { + type_liaison: 'menuiserie / mur', + enum_type_liaison_id: '5', + reference_1: 'B1', + reference_2: 'M1', + description: 'menuiserie / mur', + tv_pont_thermique_id: '72' + }, + k + ); + return { pt, log: logement({ mur, bv: [baie] }) }; + } + + /** Mock de la table : recherche par identifiant → ligne DPE, sinon ligne recalculée. */ + function mockTable(dpe = ligneDpe) { + tv.mockImplementation((table, matcher) => + matcher.tv_pont_thermique_id ? dpe : ligneRecalculee + ); + } + + test('cas 2375E2293948T : dormant exporté 5 mais ligne DPE « dormant 10 » avec k exporté : ligne DPE reprise', () => { + state.bug = true; + mockTable(); + const { pt, log } = cas(); + + calc_pont_thermique(pt, '1', log); + + expect(tv).toHaveBeenCalledWith( + 'pont_thermique', + expect.objectContaining({ largeur_dormant: 5 }) + ); + expect(tv).toHaveBeenLastCalledWith('pont_thermique', { tv_pont_thermique_id: '72' }); + expect(pt.donnee_intermediaire.k).toBe(0.29); + expect(pt.donnee_entree.tv_pont_thermique_id).toBe(72); + expect(warnSpy).toHaveBeenCalled(); + }); + + test('sans compatibilité bug : ligne recalculée conservée (règle normative), table non relue', () => { + mockTable(); + const { pt, log } = cas(); + + calc_pont_thermique(pt, '1', log); + + expect(tv).toHaveBeenCalledTimes(1); + expect(pt.donnee_intermediaire.k).toBe(0.43); + expect(pt.donnee_entree.tv_pont_thermique_id).toBe(71); + }); + + test('compatibilité bug mais k exporté différent du k de la ligne DPE : ligne recalculée conservée', () => { + state.bug = true; + mockTable(); + const { pt, log } = cas(0.43); + + calc_pont_thermique(pt, '1', log); + + expect(pt.donnee_intermediaire.k).toBe(0.43); + expect(pt.donnee_entree.tv_pont_thermique_id).toBe(71); + }); + + test('compatibilité bug, liaison autre que menuiserie / mur : aucune relecture de la ligne DPE', () => { + state.bug = true; + tv.mockReturnValue({ k: '0.5', tv_pont_thermique_id: '10' }); + const pt = pontThermique( + { + type_liaison: 'refend / mur', + reference_1: 'M1', + description: 'refend', + tv_pont_thermique_id: '72' + }, + 0.29 + ); + + calc_pont_thermique(pt, '1', logement()); + + expect(tv).toHaveBeenCalledTimes(1); + expect(pt.donnee_intermediaire.k).toBe(0.5); + }); + + describe('ligneDpeSiSeulDormantDiffere', () => { + test('ligne DPE ne différant que par le dormant et k identique : ligne DPE retournée', () => { + tv.mockReturnValue(ligneDpe); + + expect(ligneDpeSiSeulDormantDiffere(ligneRecalculee, 72, { k: 0.29 })).toBe(ligneDpe); + expect(tv).toHaveBeenCalledWith('pont_thermique', { tv_pont_thermique_id: 72 }); + }); + + test('aucune ligne recalculée : rien n’est repris', () => { + expect(ligneDpeSiSeulDormantDiffere(undefined, 72, { k: 0.29 })).toBeUndefined(); + expect(tv).not.toHaveBeenCalled(); + }); + + test.each([ + ['tv_pont_thermique_id absent', undefined, { k: 0.29 }], + ['tv_pont_thermique_id nul', null, { k: 0.29 }], + ['k exporté absent', 72, {}], + ['données intermédiaires absentes', 72, undefined] + ])('%s : ligne recalculée conservée sans relire la table', (_, id, di) => { + expect(ligneDpeSiSeulDormantDiffere(ligneRecalculee, id, di)).toBe(ligneRecalculee); + expect(tv).not.toHaveBeenCalled(); + }); + + test.each([ + ['ligne DPE introuvable', undefined], + [ + 'identifiant retourné différent (correspondance partielle)', + { ...ligneDpe, tv_pont_thermique_id: '720' } + ], + ['type de liaison différent', { ...ligneDpe, enum_type_liaison_id: '1' }], + ['largeur de dormant absente', { ...ligneDpe, largeur_dormant: undefined }], + ['même largeur de dormant', { ...ligneDpe, largeur_dormant: '5' }], + ['isolation du mur différente', { ...ligneDpe, isolation_mur: 'ITI' }], + ['type de pose différent', { ...ligneDpe, type_pose: 'Tunnel' }], + ['retour d’isolation différent', { ...ligneDpe, presence_retour_isolation: '1' }], + ['k de la ligne DPE différent du k exporté', { ...ligneDpe, k: '0.25' }] + ])('%s : ligne recalculée conservée', (_, dpe) => { + tv.mockReturnValue(dpe); + + expect(ligneDpeSiSeulDormantDiffere(ligneRecalculee, '72', { k: '0.29' })).toBe( + ligneRecalculee + ); + }); + }); +}); diff --git a/src/features/dpe/infrastructure/enveloppe/pontThermiqueTv.store.js b/src/features/dpe/infrastructure/enveloppe/pontThermiqueTv.store.js index 2aa5a7dcc..aa3f64b9e 100644 --- a/src/features/dpe/infrastructure/enveloppe/pontThermiqueTv.store.js +++ b/src/features/dpe/infrastructure/enveloppe/pontThermiqueTv.store.js @@ -25,6 +25,18 @@ export class PontThermiqueTvStore extends TvStore { return parseFloat(k); } + /** + * Ligne de la table pont_thermique via tv_pont_thermique_id + * + * @param tvPontThermiqueId {number|string} + * @return {object|undefined} + */ + getById(tvPontThermiqueId) { + return tv['pont_thermique'].find( + (v) => parseInt(v.tv_pont_thermique_id) === parseInt(tvPontThermiqueId) + ); + } + /** * Valeur du pont thermique (W/(m.K)) * @@ -90,14 +102,12 @@ export class PontThermiqueTvStore extends TvStore { presenceRetourIsolation, largeurDormant ) { - const k = tv['pont_thermique'].find( - (v) => - parseInt(v.enum_type_liaison_id) === enumTypeLiaisonId && - v.isolation_mur.toLowerCase() === isolationMur.toLowerCase() && - (!v.enum_type_pose_id || parseInt(v.enum_type_pose_id) === typePose) && - (!v.presence_retour_isolation || - parseInt(v.presence_retour_isolation) === presenceRetourIsolation) && - (!v.largeur_dormant || !largeurDormant || parseInt(v.largeur_dormant) === largeurDormant) + const k = this.getForMenuiserie( + enumTypeLiaisonId, + isolationMur, + typePose, + presenceRetourIsolation, + largeurDormant )?.k; if (!k) { @@ -109,4 +119,32 @@ export class PontThermiqueTvStore extends TvStore { return parseFloat(k); } + + /** + * Ligne de la table pont_thermique pour une liaison menuiserie / mur + * + * @param enumTypeLiaisonId {number} + * @param isolationMur {string} + * @param typePose {number} + * @param presenceRetourIsolation {number} + * @param largeurDormant {number} + * @return {object|undefined} + */ + getForMenuiserie( + enumTypeLiaisonId, + isolationMur, + typePose, + presenceRetourIsolation, + largeurDormant + ) { + return tv['pont_thermique'].find( + (v) => + parseInt(v.enum_type_liaison_id) === enumTypeLiaisonId && + v.isolation_mur.toLowerCase() === isolationMur.toLowerCase() && + (!v.enum_type_pose_id || parseInt(v.enum_type_pose_id) === typePose) && + (!v.presence_retour_isolation || + parseInt(v.presence_retour_isolation) === presenceRetourIsolation) && + (!v.largeur_dormant || !largeurDormant || parseInt(v.largeur_dormant) === largeurDormant) + ); + } } diff --git a/src/features/dpe/infrastructure/enveloppe/pontThermiqueTv.store.spec.js b/src/features/dpe/infrastructure/enveloppe/pontThermiqueTv.store.spec.js index ff0f5af53..9886e1b62 100755 --- a/src/features/dpe/infrastructure/enveloppe/pontThermiqueTv.store.spec.js +++ b/src/features/dpe/infrastructure/enveloppe/pontThermiqueTv.store.spec.js @@ -131,4 +131,30 @@ describe('Lecture des tables de valeurs', () => { ).toBe(expected); } ); + + describe('lecture des lignes de la table (issue #296)', () => { + test.each([ + { label: 'identifiant numérique', id: 72, expected: '72' }, + { label: 'identifiant chaîne', id: '72', expected: '72' } + ])('ligne via tv_pont_thermique_id ($label)', ({ id, expected }) => { + const ligne = tvStore.getById(id); + expect(ligne.tv_pont_thermique_id).toBe(expected); + expect(ligne.largeur_dormant).toBe('10'); + }); + + test('tv_pont_thermique_id inexistant : aucune ligne', () => { + expect(tvStore.getById(0)).toBeUndefined(); + }); + + test('ligne menuiserie / mur : même critère que getKForMenuiserie', () => { + const ligne = tvStore.getForMenuiserie(5, 'Non isolé', 1, undefined, 5); + expect(ligne.tv_pont_thermique_id).toBe('71'); + expect(ligne.largeur_dormant).toBe('5'); + expect(parseFloat(ligne.k)).toBe(tvStore.getKForMenuiserie(5, 'Non isolé', 1, undefined, 5)); + }); + + test('ligne menuiserie / mur introuvable : undefined', () => { + expect(tvStore.getForMenuiserie(5, 'undefined', 1, 1, 5)).toBeUndefined(); + }); + }); }); diff --git a/src/features/engine/domain/engine.service.js b/src/features/engine/domain/engine.service.js index 4dc091d15..b112be0a5 100755 --- a/src/features/engine/domain/engine.service.js +++ b/src/features/engine/domain/engine.service.js @@ -148,9 +148,16 @@ export class EngineService { dpe.logement.enveloppe.plancher_haut_collection.plancher_haut?.map( (m) => delete m.donnee_intermediaire ); - dpe.logement.enveloppe.pont_thermique_collection.pont_thermique?.map( - (m) => delete m.donnee_intermediaire - ); + /** + * Issue #296 : seul le k exporté du pont thermique est conservé. Il n'est lu qu'en mode + * bug_for_bug_compat (reprise de la ligne « menuiserie / mur » du DPE) et les données + * intermédiaires sont intégralement recalculées par DeperditionEnveloppeService. + */ + dpe.logement.enveloppe.pont_thermique_collection.pont_thermique?.map((m) => { + if (m.donnee_intermediaire) { + m.donnee_intermediaire = { k: m.donnee_intermediaire.k }; + } + }); dpe.logement.enveloppe.porte_collection.porte?.map((m) => delete m.donnee_intermediaire); dpe.logement.climatisation_collection.climatisation?.map((m) => delete m.donnee_intermediaire); dpe.logement.ventilation_collection.ventilation?.map((m) => delete m.donnee_intermediaire); diff --git a/src/features/engine/domain/engine.service.spec.js b/src/features/engine/domain/engine.service.spec.js index 73eac3708..4d4c54b9b 100755 --- a/src/features/engine/domain/engine.service.spec.js +++ b/src/features/engine/domain/engine.service.spec.js @@ -88,7 +88,9 @@ function dpeComplet() { ets_collection: { ets: { donnee_intermediaire: {} } }, plancher_bas_collection: { plancher_bas: [{ donnee_intermediaire: {} }] }, plancher_haut_collection: { plancher_haut: [{ donnee_intermediaire: {} }] }, - pont_thermique_collection: { pont_thermique: [{ donnee_intermediaire: {} }] }, + pont_thermique_collection: { + pont_thermique: [{ donnee_intermediaire: { k: 0.29, autre: 1 } }, {}] + }, porte_collection: { porte: [{ donnee_intermediaire: {} }] } }, climatisation_collection: { climatisation: [{ donnee_intermediaire: {} }] }, @@ -200,7 +202,11 @@ describe('EngineService (orchestration de la méthode 3CL)', () => { expect(env.ets_collection.ets.donnee_intermediaire).toBeUndefined(); expect(env.plancher_bas_collection.plancher_bas[0].donnee_intermediaire).toBeUndefined(); expect(env.plancher_haut_collection.plancher_haut[0].donnee_intermediaire).toBeUndefined(); - expect(env.pont_thermique_collection.pont_thermique[0].donnee_intermediaire).toBeUndefined(); + // Issue #296 : seul le k exporté du pont thermique est conservé (lu en bug_for_bug_compat) + expect(env.pont_thermique_collection.pont_thermique[0].donnee_intermediaire).toEqual({ + k: 0.29 + }); + expect(env.pont_thermique_collection.pont_thermique[1].donnee_intermediaire).toBeUndefined(); expect(env.porte_collection.porte[0].donnee_intermediaire).toBeUndefined(); expect( diff --git a/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.js b/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.js index 6ece05714..cfa75eed8 100755 --- a/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.js +++ b/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.js @@ -266,10 +266,12 @@ export class DeperditionEnveloppeService { } enveloppe.pont_thermique_collection.pont_thermique?.forEach((pt) => { + // Le k exporté par le DPE n'est utilisé qu'en mode bug_for_bug_compat (issue #296) pt.donnee_intermediaire = this.#deperditionPontThermiqueService.execute( ctx, enveloppe, - pt.donnee_entree + pt.donnee_entree, + pt.donnee_intermediaire?.k ); deperditions.deperdition_pont_thermique += pt.donnee_entree.l * diff --git a/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js b/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js index 05f6e3790..02497c298 100644 --- a/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js +++ b/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js @@ -135,7 +135,10 @@ describe('DeperditionEnveloppeService (§3 déperditions de l’enveloppe)', () .mockReturnValueOnce({ b: 0, u_menuiserie: 2 }); env.pont_thermique_collection.pont_thermique = [ - { donnee_entree: { l: 10, pourcentage_valeur_pont_thermique: 0.5 } }, + { + donnee_entree: { l: 10, pourcentage_valeur_pont_thermique: 0.5 }, + donnee_intermediaire: { k: 0.29 } + }, { donnee_entree: { l: 4 } } ]; pontThermiqueService.execute.mockReturnValue({ k: 0.5 }); @@ -166,7 +169,15 @@ describe('DeperditionEnveloppeService (§3 déperditions de l’enveloppe)', () expect(pontThermiqueService.execute).toHaveBeenCalledWith( ctx, env, - env.pont_thermique_collection.pont_thermique[0].donnee_entree + env.pont_thermique_collection.pont_thermique[0].donnee_entree, + 0.29 + ); + // Issue #296 : le k exporté est transmis (undefined si le DPE n'en exporte pas) + expect(pontThermiqueService.execute).toHaveBeenCalledWith( + ctx, + env, + env.pont_thermique_collection.pont_thermique[1].donnee_entree, + undefined ); // Surfaces transmises à la ventilation : diff --git a/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.js b/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.js index c29ff85f7..6e559650f 100755 --- a/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.js +++ b/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.js @@ -1,7 +1,7 @@ import { inject } from 'dioma'; import { DeperditionService } from '../deperdition.service.js'; import { logger } from '../../../../../core/util/logger/log-service.js'; -import { compareReferences } from '../../../../../utils.js'; +import { bug_for_bug_compat, compareReferences } from '../../../../../utils.js'; import { DeperditionMurService } from '../mur/deperdition-mur.service.js'; import { PontThermiqueTvStore } from '../../../../dpe/infrastructure/enveloppe/pontThermiqueTv.store.js'; import enums from '../../../../../enums.js'; @@ -56,9 +56,10 @@ export class DeperditionPontThermiqueService extends DeperditionService { * @param ctx {Contexte} * @param enveloppe {Enveloppe} * @param pontThermiqueDE {PontThermiqueDE} + * @param kExporte {number|string|undefined} k exporté par le DPE (utilisé en mode bug_for_bug_compat, issue #296) * @return {PontThermiqueDI} */ - execute(ctx, enveloppe, pontThermiqueDE) { + execute(ctx, enveloppe, pontThermiqueDE, kExporte = undefined) { /** @type {PontThermiqueDI} */ const di = { k: undefined @@ -76,7 +77,7 @@ export class DeperditionPontThermiqueService extends DeperditionService { } } else { // valeur forfaitaire - di.k = this.k(ctx, pontThermiqueDE, enveloppe); + di.k = this.k(ctx, pontThermiqueDE, enveloppe, kExporte); } if (di.k === undefined) { @@ -95,9 +96,10 @@ export class DeperditionPontThermiqueService extends DeperditionService { * @param ctx {Contexte} * @param pontThermiqueDE {PontThermiqueDE} * @param enveloppe {Enveloppe} + * @param kExporte {number|string|undefined} k exporté par le DPE (mode bug_for_bug_compat, issue #296) * @return {number|undefined} */ - k(ctx, pontThermiqueDE, enveloppe) { + k(ctx, pontThermiqueDE, enveloppe, kExporte = undefined) { if (!pontThermiqueDE.reference_1) { logger.error(` Impossible de trouver un mur ayant pour référence '${pontThermiqueDE.reference_1}' ou '${pontThermiqueDE.reference_2}'. @@ -129,7 +131,7 @@ export class DeperditionPontThermiqueService extends DeperditionService { } // 'menuiserie / mur' case 5: { - return this.pontThermiqueMenuiserieMur(pontThermiqueDE, enveloppe, isolationMur); + return this.pontThermiqueMenuiserieMur(pontThermiqueDE, enveloppe, isolationMur, kExporte); } //'plancher intermédiaire lourd / mur' // 'refend / mur' @@ -233,9 +235,10 @@ export class DeperditionPontThermiqueService extends DeperditionService { * @param pontThermiqueDE {PontThermiqueDE} * @param enveloppe {Enveloppe} * @param isolationMur {number} + * @param kExporte {number|string|undefined} k exporté par le DPE (mode bug_for_bug_compat, issue #296) * @return {number|undefined} */ - pontThermiqueMenuiserieMur(pontThermiqueDE, enveloppe, isolationMur) { + pontThermiqueMenuiserieMur(pontThermiqueDE, enveloppe, isolationMur, kExporte = undefined) { const menuiserieDE = this.menuiserie(pontThermiqueDE, enveloppe); if (!menuiserieDE) { @@ -259,13 +262,83 @@ export class DeperditionPontThermiqueService extends DeperditionService { const largeurDormant = menuiserieDE.largeur_dormant; const presenceRetourIsolation = menuiserieDE.presence_retour_isolation; - return this.tvStore.getKForMenuiserie( + const k = this.tvStore.getKForMenuiserie( 5, enums.type_isolation[isolationMur], typePose, presenceRetourIsolation, largeurDormant ); + + if (bug_for_bug_compat && k !== undefined) { + const ligneRecalculee = this.tvStore.getForMenuiserie( + 5, + enums.type_isolation[isolationMur], + typePose, + presenceRetourIsolation, + largeurDormant + ); + const ligneDpe = this.ligneDpeSiSeulDormantDiffere( + ligneRecalculee, + pontThermiqueDE.tv_pont_thermique_id, + kExporte + ); + if (ligneDpe) { + return parseFloat(ligneDpe.k); + } + } + + return k; + } + + /** + * Mode bug_for_bug_compat uniquement - pont thermique « menuiserie / mur » + * + * Certains logiciels (DPEWIN 3.1.x / 5.1.6, moteur 3cl_tribu_1.4.25.1) exportent une largeur de dormant + * (ex : 5 cm) sur la menuiserie alors que le tv_pont_thermique_id et le k exportés pour le pont thermique + * correspondent à la ligne d'une autre largeur de dormant (ex : 10 cm). + * Normativement la ligne recalculée est la bonne, mais pour rester fidèle au calcul certifié on reprend + * la ligne exportée par le DPE si elle ne diffère de la ligne recalculée que par la largeur du dormant + * et si son k est égal au k exporté. + * + * @see Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4.5 Menuiserie / mur + * @see https://github.com/Open3CL/engine/issues/296 + * + * @param ligneRecalculee {object|undefined} ligne de la table pont_thermique recalculée + * @param tvPontThermiqueId {number|string|undefined} tv_pont_thermique_id exporté par le DPE + * @param kExporte {number|string|undefined} k exporté par le DPE + * @return {object|undefined} ligne du DPE à reprendre, undefined sinon + */ + ligneDpeSiSeulDormantDiffere(ligneRecalculee, tvPontThermiqueId, kExporte) { + if ( + !ligneRecalculee || + tvPontThermiqueId === undefined || + tvPontThermiqueId === null || + kExporte === undefined || + kExporte === null + ) { + return undefined; + } + + const ligneDpe = this.tvStore.getById(tvPontThermiqueId); + + if ( + ligneDpe && + ligneDpe.enum_type_liaison_id === ligneRecalculee.enum_type_liaison_id && + ligneDpe.largeur_dormant && + ligneDpe.largeur_dormant !== ligneRecalculee.largeur_dormant && + ligneDpe.isolation_mur === ligneRecalculee.isolation_mur && + ligneDpe.enum_type_pose_id === ligneRecalculee.enum_type_pose_id && + ligneDpe.presence_retour_isolation === ligneRecalculee.presence_retour_isolation && + Number(ligneDpe.k) === Number(kExporte) + ) { + logger.warn( + `Pont thermique menuiserie / mur : reprise de la ligne tv_pont_thermique_id = ${tvPontThermiqueId} du DPE (largeur de dormant ${ligneDpe.largeur_dormant} au lieu de ${ligneRecalculee.largeur_dormant})` + ); + return ligneDpe; + } + + return undefined; } /** diff --git a/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.spec.js b/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.spec.js index ab41a3757..b17ac1898 100644 --- a/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.spec.js +++ b/src/features/engine/domain/enveloppe/pont_thermique/deperdition-pont-thermique.service.spec.js @@ -1,5 +1,19 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { DeperditionPontThermiqueService } from './deperdition-pont-thermique.service.js'; + +/** + * `bug_for_bug_compat` est exposé via un accesseur adossé à un état hoisté afin de pouvoir + * l'activer dans certains tests (issue #296), tout en restant désactivé par défaut. + */ +const state = vi.hoisted(() => ({ bug: false })); + +vi.mock('../../../../../utils.js', async (importOriginal) => ({ + ...(await importOriginal()), + get bug_for_bug_compat() { + return state.bug; + } +})); + +const { DeperditionPontThermiqueService } = await import('./deperdition-pont-thermique.service.js'); /** * Tests unitaires isolés de `DeperditionPontThermiqueService` (nouvelle architecture). @@ -24,8 +38,11 @@ beforeEach(() => { getKForPlancher: vi.fn(() => 0.71), getKForMur: vi.fn(() => 0.82), getKForMenuiserie: vi.fn(() => 0.3), - getKForMurById: vi.fn(() => 0.5) + getKForMurById: vi.fn(() => 0.5), + getForMenuiserie: vi.fn(), + getById: vi.fn() }; + state.bug = false; murService = { typeIsolation: vi.fn(() => 3) }; plancherHautService = { typeIsolation: vi.fn(() => 4) }; plancherBasService = { typeIsolation: vi.fn(() => 4) }; @@ -123,3 +140,148 @@ describe('DeperditionPontThermiqueService - adjacence du plancher (issue #212)', expect(service.execute(ctx, enveloppe('1', '14'), pontThermique('1')).k).toBe(0); }); }); + +/** + * Reprise de la ligne « menuiserie / mur » exportée par le DPE quand seule la largeur du dormant + * diffère de la ligne recalculée (compatibilité bug uniquement). + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §3.4.5 + * @see https://github.com/Open3CL/engine/issues/296 + */ +describe('DeperditionPontThermiqueService - ligne DPE reprise si seul le dormant diffère (issue #296)', () => { + /** Ligne recalculée : dormant 5 cm. */ + const ligneRecalculee = { + tv_pont_thermique_id: '71', + enum_type_liaison_id: '5', + isolation_mur: 'Non isolé', + enum_type_pose_id: '1', + type_pose: 'Nu Extérieur', + largeur_dormant: '5', + k: '0.43' + }; + /** Ligne exportée par le DPE : dormant 10 cm, mêmes autres critères. */ + const ligneDpe = { + ...ligneRecalculee, + tv_pont_thermique_id: '72', + largeur_dormant: '10', + k: '0.29' + }; + + function enveloppeMenuiserie() { + return { + mur_collection: { + mur: [ + { + donnee_entree: { + reference: 'M1', + enum_type_adjacence_id: '1', + enum_materiaux_structure_mur_id: '1' + } + } + ] + }, + baie_vitree_collection: { + baie_vitree: [ + { + donnee_entree: { + reference: 'B1', + enum_type_vitrage_id: '2', + enum_type_pose_id: '1', + largeur_dormant: 5 + } + } + ] + } + }; + } + + function ptMenuiserie() { + return { + enum_methode_saisie_pont_thermique_id: '1', + enum_type_liaison_id: '5', + reference_1: 'B1', + reference_2: 'M1', + description: 'menuiserie / mur', + tv_pont_thermique_id: 72 + }; + } + + beforeEach(() => { + murService.typeIsolation.mockReturnValue(2); + tvStore.getKForMenuiserie.mockReturnValue(0.43); + tvStore.getForMenuiserie.mockReturnValue(ligneRecalculee); + tvStore.getById.mockReturnValue(ligneDpe); + }); + + test('cas 2375E2293948T : dormant exporté 5 mais ligne DPE « dormant 10 » avec k exporté : ligne DPE reprise', () => { + state.bug = true; + + const di = service.execute(ctx, enveloppeMenuiserie(), ptMenuiserie(), 0.29); + + expect(tvStore.getForMenuiserie).toHaveBeenCalledWith(5, expect.anything(), 1, undefined, 5); + expect(tvStore.getById).toHaveBeenCalledWith(72); + expect(di.k).toBe(0.29); + }); + + test('sans compatibilité bug : ligne recalculée conservée (règle normative), table non relue', () => { + const di = service.execute(ctx, enveloppeMenuiserie(), ptMenuiserie(), 0.29); + + expect(di.k).toBe(0.43); + expect(tvStore.getForMenuiserie).not.toHaveBeenCalled(); + expect(tvStore.getById).not.toHaveBeenCalled(); + }); + + test('compatibilité bug mais k exporté différent du k de la ligne DPE : ligne recalculée conservée', () => { + state.bug = true; + + expect(service.execute(ctx, enveloppeMenuiserie(), ptMenuiserie(), 0.43).k).toBe(0.43); + }); + + test('compatibilité bug sans k exporté : ligne recalculée conservée', () => { + state.bug = true; + + expect(service.execute(ctx, enveloppeMenuiserie(), ptMenuiserie()).k).toBe(0.43); + expect(tvStore.getById).not.toHaveBeenCalled(); + }); + + test('compatibilité bug, aucune ligne recalculée : pas de reprise (repli habituel)', () => { + state.bug = true; + tvStore.getKForMenuiserie.mockReturnValue(undefined); + + const di = service.execute(ctx, enveloppeMenuiserie(), ptMenuiserie(), 0.29); + + expect(tvStore.getById).not.toHaveBeenCalled(); + expect(di.k).toBe(0.5); // valeur par défaut via tv_pont_thermique_id + }); + + describe('ligneDpeSiSeulDormantDiffere', () => { + test('ligne DPE ne différant que par le dormant et k identique : ligne DPE retournée', () => { + expect(service.ligneDpeSiSeulDormantDiffere(ligneRecalculee, '72', '0.29')).toBe(ligneDpe); + }); + + test.each([ + ['aucune ligne recalculée', undefined, 72, 0.29], + ['tv_pont_thermique_id absent', ligneRecalculee, undefined, 0.29], + ['tv_pont_thermique_id nul', ligneRecalculee, null, 0.29], + ['k exporté absent', ligneRecalculee, 72, undefined], + ['k exporté nul', ligneRecalculee, 72, null] + ])('%s : aucune reprise, table non relue', (_, ligne, id, k) => { + expect(service.ligneDpeSiSeulDormantDiffere(ligne, id, k)).toBeUndefined(); + expect(tvStore.getById).not.toHaveBeenCalled(); + }); + + test.each([ + ['ligne DPE introuvable', undefined], + ['type de liaison différent', { ...ligneDpe, enum_type_liaison_id: '1' }], + ['largeur de dormant absente', { ...ligneDpe, largeur_dormant: undefined }], + ['même largeur de dormant', { ...ligneDpe, largeur_dormant: '5' }], + ['isolation du mur différente', { ...ligneDpe, isolation_mur: 'ITI' }], + ['type de pose différent', { ...ligneDpe, enum_type_pose_id: '3' }], + ['retour d’isolation différent', { ...ligneDpe, presence_retour_isolation: '1' }], + ['k de la ligne DPE différent du k exporté', { ...ligneDpe, k: '0.25' }] + ])('%s : aucune reprise', (_, dpe) => { + tvStore.getById.mockReturnValue(dpe); + + expect(service.ligneDpeSiSeulDormantDiffere(ligneRecalculee, 72, 0.29)).toBeUndefined(); + }); + }); +});