diff --git a/src/4_ventilation.js b/src/4_ventilation.js index e8e9be09b..3d0f63b2b 100644 --- a/src/4_ventilation.js +++ b/src/4_ventilation.js @@ -17,6 +17,18 @@ function tv_debits_ventilation(di, de, du) { } } +/** + * Vrai si le DPE comporte au moins une menuiserie (baie ou porte) et qu'aucune n'exporte + * le champ `presence_joint` (issue #294). + */ +export function presenceJointNonRenseignee(porte_list, bv_list) { + const menuiseries = (porte_list || []).concat(bv_list || []); + return ( + menuiseries.length > 0 && + menuiseries.every((m) => [undefined, null, ''].includes(m.donnee_entree?.presence_joint)) + ); +} + function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) { const surfaces = mur_list.concat(ph_list); const surface_isolee = surfaces.reduce((acc, s) => { @@ -82,14 +94,23 @@ function tv_q4pa_conv(di, de, cg, mur_list, ph_list, porte_list, bv_list) { if (rowQ4paConv.isolation_surfaces !== isolation_surfaces) { console.error( `Calcul de hperm pour la ventilation ${de.description}. Le DPE considère isolation_surfaces = ${rowQ4paConv.isolation_surfaces} - alors que ce devrait être isolation_surfaces = ${isolation_surfaces}. La valeur du DPE isolation_surfaces = ${rowQ4paConv.isolation_surfaces} est utilisée.` + alors que ce devrait être isolation_surfaces = ${isolation_surfaces}. La valeur recalculée isolation_surfaces = ${isolation_surfaces} est utilisée.` ); } - if (rowQ4paConv.presence_joints_menuiserie !== pjt) { + /** + * Issue #294 : aucune menuiserie n'exporte `presence_joint`. Aucune donnée ne contredit alors + * la ligne q4pa_conv retenue par le logiciel (ex. ligne 12 « avant 1948 avec joints », 2,5) : + * on reprend sa valeur `presence_joints_menuiserie` au lieu de considérer les menuiseries + * sans joint. + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §4 + */ + if (presenceJointNonRenseignee(porte_list, bv_list)) { + pjt = rowQ4paConv.presence_joints_menuiserie === '1' ? '1' : '0'; + } else if (rowQ4paConv.presence_joints_menuiserie !== pjt) { console.error( `Calcul de hperm pour la ventilation ${de.description}. Le DPE considère presence_joints_menuiserie = ${rowQ4paConv.presence_joints_menuiserie} - alors que ce devrait être presence_joints_menuiserie = ${pjt}. La valeur du DPE presence_joints_menuiserie = ${rowQ4paConv.presence_joints_menuiserie} est utilisée.` + alors que ce devrait être presence_joints_menuiserie = ${pjt}. La valeur recalculée presence_joints_menuiserie = ${pjt} est utilisée.` ); } } diff --git a/src/4_ventilation.spec.js b/src/4_ventilation.spec.js index e7c065c46..30e804476 100644 --- a/src/4_ventilation.spec.js +++ b/src/4_ventilation.spec.js @@ -19,7 +19,11 @@ vi.mock('./5_conso_ventilation.js', () => ({ default: vi.fn() })); -const { default: calc_ventilation, calc_hperm } = await import('./4_ventilation.js'); +const { + default: calc_ventilation, + calc_hperm, + presenceJointNonRenseignee +} = await import('./4_ventilation.js'); const { tv } = await import('./utils.js'); const { default: calc_pvent } = await import('./5_conso_ventilation.js'); @@ -404,4 +408,101 @@ describe('calc_ventilation - compatibilité bug_for_bug_compat activée', () => expect(errorSpy).not.toHaveBeenCalled(); errorSpy.mockRestore(); }); + + /** + * Issue #294 : presence_joint absent sur toutes les menuiseries => la valeur + * presence_joints_menuiserie de la ligne q4pa_conv du DPE est reprise. + */ + describe('presence_joint non exporté (issue #294)', () => { + /** Avant 1948, surfaces non isolées : seul le critère des joints départage les lignes 1 et 12. */ + const murNonIsole = [ + { + donnee_intermediaire: { b: 1 }, + donnee_entree: { enum_type_isolation_id: '2', surface_paroi_opaque: 100 } + } + ]; + const vtLigne12 = () => ({ + donnee_entree: { enum_type_ventilation_id: '1', tv_q4pa_conv_id: 12, description: 'v' } + }); + const ROW_12 = { + q4pa_conv: '2.5', + tv_q4pa_conv_id: '12', + isolation_surfaces: '0', + presence_joints_menuiserie: '1' + }; + + test('aucune menuiserie ne renseigne le champ : joints du DPE repris, sans message', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { calc_ventilation_bug, tvBug } = await chargerAvecBug(ROW_12); + const baies = [{ donnee_entree: { surface_totale_baie: 10 } }]; + const portes = [{ donnee_entree: { surface_porte: 2, presence_joint: null } }]; + + calc_ventilation_bug(vtLigne12(), CG, 'th', 80, 100, murNonIsole, [], portes, baies); + + expect(tvBug).toHaveBeenLastCalledWith( + 'q4pa_conv', + expect.objectContaining({ isolation_surfaces: '0', presence_joints_menuiserie: '1' }) + ); + expect(errorSpy).not.toHaveBeenCalled(); + errorSpy.mockRestore(); + }); + + test('ligne du DPE sans joints : la valeur "0" est conservée', async () => { + const { calc_ventilation_bug, tvBug } = await chargerAvecBug({ + ...ROW_12, + tv_q4pa_conv_id: '1', + presence_joints_menuiserie: undefined + }); + const baies = [{ donnee_entree: { surface_totale_baie: 10 } }]; + + calc_ventilation_bug(vtLigne12(), CG, 'th', 80, 100, murNonIsole, [], [], baies); + + expect(tvBug).toHaveBeenLastCalledWith( + 'q4pa_conv', + expect.objectContaining({ presence_joints_menuiserie: '0' }) + ); + }); + + test('presence_joint renseigné à 0 : la valeur recalculée prime et l’écart est signalé', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { calc_ventilation_bug, tvBug } = await chargerAvecBug(ROW_12); + const baies = [{ donnee_entree: { surface_totale_baie: 10, presence_joint: 0 } }]; + + calc_ventilation_bug(vtLigne12(), CG, 'th', 80, 100, murNonIsole, [], [], baies); + + expect(tvBug).toHaveBeenLastCalledWith( + 'q4pa_conv', + expect.objectContaining({ presence_joints_menuiserie: '0' }) + ); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('La valeur recalculée presence_joints_menuiserie = 0 est utilisée') + ); + errorSpy.mockRestore(); + }); + }); +}); + +describe('presenceJointNonRenseignee', () => { + test('aucune menuiserie : faux', () => { + expect(presenceJointNonRenseignee([], [])).toBe(false); + expect(presenceJointNonRenseignee(undefined, undefined)).toBe(false); + }); + + test('champ absent, null ou vide sur toutes les menuiseries : vrai', () => { + expect( + presenceJointNonRenseignee( + [{ donnee_entree: { presence_joint: '' } }], + [{ donnee_entree: {} }, { donnee_entree: { presence_joint: null } }] + ) + ).toBe(true); + }); + + test('au moins une menuiserie renseignée (même à 0) : faux', () => { + expect( + presenceJointNonRenseignee( + [{ donnee_entree: { presence_joint: 0 } }], + [{ donnee_entree: {} }] + ) + ).toBe(false); + }); }); diff --git a/src/features/dpe/infrastructure/tv.store.js b/src/features/dpe/infrastructure/tv.store.js index be133b309..4bd3d503e 100755 --- a/src/features/dpe/infrastructure/tv.store.js +++ b/src/features/dpe/infrastructure/tv.store.js @@ -340,6 +340,16 @@ export class TvStore { return debitsVentilation; } + /** + * Ligne de la table q4pa_conv par son identifiant + * @param tvQ4paConvId {number|string|undefined} + * @return {object|undefined} + */ + getQ4paConvById(tvQ4paConvId) { + if (tvQ4paConvId === undefined || tvQ4paConvId === null) return; + return tv['q4pa_conv'].find((v) => v.tv_q4pa_conv_id === String(tvQ4paConvId)); + } + /** * Valeur conventionnelle de la perméabilité sous 4Pa * @see Chapitre 4 - Calcul des déperditions par renouvellement d’air diff --git a/src/features/dpe/infrastructure/tv.store.spec.js b/src/features/dpe/infrastructure/tv.store.spec.js index eef4c5cad..414877a02 100755 --- a/src/features/dpe/infrastructure/tv.store.spec.js +++ b/src/features/dpe/infrastructure/tv.store.spec.js @@ -607,6 +607,17 @@ describe('Lecture des tables de valeurs', () => { const ug = tvStore.getQ4paConv('2', TypeHabitation.MAISON, '0', '1'); expect(ug).toBeUndefined(); }); + + test('lecture par identifiant (issue #294)', () => { + expect(tvStore.getQ4paConvById(12)).toMatchObject({ + q4pa_conv: '2.5', + presence_joints_menuiserie: '1' + }); + expect(tvStore.getQ4paConvById('1')).toMatchObject({ q4pa_conv: '4.6' }); + expect(tvStore.getQ4paConvById(undefined)).toBeUndefined(); + expect(tvStore.getQ4paConvById(null)).toBeUndefined(); + expect(tvStore.getQ4paConvById(999)).toBeUndefined(); + }); }); test('lecture des valeurs de tempBase', () => { diff --git a/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.js b/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.js index 24066f396..6ece05714 100755 --- a/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.js +++ b/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.js @@ -305,7 +305,8 @@ export class DeperditionEnveloppeService { this.#surfaceIsolee, this.#surfaceNonIsolee, this.#surfaceMenuiserieAvecJoint, - this.#surfaceMenuiserieSansJoint + this.#surfaceMenuiserieSansJoint, + this.#presenceJointNonRenseignee(logement.enveloppe) ); deperditions.hvent += ventilation.donnee_intermediaire.hvent; deperditions.hperm += ventilation.donnee_intermediaire.hperm; @@ -313,4 +314,22 @@ export class DeperditionEnveloppeService { return deperditions; } + + /** + * Vrai si le logement comporte au moins une menuiserie (baie ou porte) et qu'aucune n'exporte + * le champ presence_joint (issue #294). + * + * @param enveloppe {Enveloppe} + * @return {boolean} + */ + #presenceJointNonRenseignee(enveloppe) { + const menuiseries = [ + ...(enveloppe?.porte_collection?.porte || []), + ...(enveloppe?.baie_vitree_collection?.baie_vitree || []) + ]; + return ( + menuiseries.length > 0 && + menuiseries.every((m) => [undefined, null, ''].includes(m.donnee_entree?.presence_joint)) + ); + } } 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 e560e04ae..05f6e3790 100644 --- a/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js +++ b/src/features/engine/domain/enveloppe/deperdition-enveloppe.service.spec.js @@ -173,7 +173,17 @@ describe('DeperditionEnveloppeService (§3 déperditions de l’enveloppe)', () // déperditive = mur 10 + porte 2 + plancher haut 30 + baie 4 = 46 // isolée = mur 5 + plancher haut 7 = 12 ; non isolée = mur 10 + plancher haut 30 = 40 // menuiseries avec joint = porte 2 + baie 4 = 6 ; sans joint = porte 3 + baie 6 = 9 - expect(ventilationService.execute).toHaveBeenCalledWith(ctx, { id: 'v' }, 46, 12, 40, 6, 9); + // presence_joint renseigné sur au moins une menuiserie => indicateur #294 à false + expect(ventilationService.execute).toHaveBeenCalledWith( + ctx, + { id: 'v' }, + 46, + 12, + 40, + 6, + 9, + false + ); }); test('les surfaces sont réinitialisées à chaque calcul', () => { @@ -186,7 +196,18 @@ describe('DeperditionEnveloppeService (§3 déperditions de l’enveloppe)', () service.deperditions(ctx, logement); service.deperditions(ctx, logement); - expect(ventilationService.execute).toHaveBeenLastCalledWith(ctx, {}, 2, 0, 0, 0, 2); + // aucune menuiserie ne renseigne presence_joint => indicateur #294 à true + expect(ventilationService.execute).toHaveBeenLastCalledWith(ctx, {}, 2, 0, 0, 0, 2, true); + }); + + test('logement sans menuiserie : indicateur presence_joint non renseigné à false (issue #294)', () => { + const logement = logementVide(); + logement.ventilation_collection.ventilation = [{ donnee_entree: {} }]; + ventilationService.execute.mockReturnValue({ hvent: 0, hperm: 0 }); + + service.deperditions(ctx, logement); + + expect(ventilationService.execute).toHaveBeenLastCalledWith(ctx, {}, 0, 0, 0, 0, 0, false); }); describe('espaces tampons solarisés', () => { diff --git a/src/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.js b/src/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.js index f46adf144..668326769 100644 --- a/src/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.js +++ b/src/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.js @@ -1,6 +1,7 @@ import { DeperditionService } from '../deperdition.service.js'; import { logger } from '../../../../../core/util/logger/log-service.js'; +import { bug_for_bug_compat } from '../../../../../utils.js'; import { TypeHabitation } from '../../../../dpe/domain/models/type-habitation.model.js'; import { TypeVentilation } from '../../../../dpe/domain/models/type-ventilation.model.js'; @@ -22,6 +23,7 @@ export class DeperditionVentilationService extends DeperditionService { * @param surfaceNonIsolee {number} * @param surfaceMenuiserieAvecJoint {number} * @param surfaceMenuiserieSansJoint {number} + * @param presenceJointNonRenseignee {boolean} aucune menuiserie n'exporte presence_joint (issue #294) * @return {VentilationDI} */ execute( @@ -31,7 +33,8 @@ export class DeperditionVentilationService extends DeperditionService { surfaceIsolee, surfaceNonIsolee, surfaceMenuiserieAvecJoint, - surfaceMenuiserieSansJoint + surfaceMenuiserieSansJoint, + presenceJointNonRenseignee = false ) { const q4paConv = this.q4paConv( ventilationDE, @@ -39,7 +42,8 @@ export class DeperditionVentilationService extends DeperditionService { surfaceIsolee, surfaceNonIsolee, surfaceMenuiserieAvecJoint, - surfaceMenuiserieSansJoint + surfaceMenuiserieSansJoint, + presenceJointNonRenseignee ); const debitsVentilation = this.#debitsVentilation(ventilationDE.enum_type_ventilation_id); @@ -62,6 +66,7 @@ export class DeperditionVentilationService extends DeperditionService { * @param surfaceNonIsolee {number} * @param surfaceMenuiserieAvecJoint {number} * @param surfaceMenuiserieSansJoint {number} + * @param presenceJointNonRenseignee {boolean} * @return {number} */ q4paConv( @@ -70,7 +75,8 @@ export class DeperditionVentilationService extends DeperditionService { surfaceIsolee, surfaceNonIsolee, surfaceMenuiserieAvecJoint, - surfaceMenuiserieSansJoint + surfaceMenuiserieSansJoint, + presenceJointNonRenseignee = false ) { // Pour les bâtiments qui ont fait l’objet d’une mesure d’étanchéité à l’air moins de deux ans avant le diagnostic, la valeur // mesurée de Q4Paconv/m² peut être saisie. @@ -92,6 +98,15 @@ export class DeperditionVentilationService extends DeperditionService { // (si les menuiseries représentant plus de 50% de la surface totale possèdent des joints) => valeur forfaitaire. if (surfaceMenuiserieAvecJoint > surfaceMenuiserieSansJoint) { presenceJointsMenuiserie = '1'; + } else if ( + bug_for_bug_compat && + presenceJointNonRenseignee && + this.tvStore.getQ4paConvById(ventilationDE.tv_q4pa_conv_id) + ?.presence_joints_menuiserie === '1' + ) { + // Issue #294 : aucune menuiserie n'exporte presence_joint, rien ne contredit la ligne + // q4pa_conv « avant 1948 avec joints » retenue par le DPE => on la reprend. + presenceJointsMenuiserie = '1'; } } } else if (enumPeriodeConstructionId === '2') { diff --git a/src/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.spec.js b/src/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.spec.js new file mode 100644 index 000000000..f227d59f7 --- /dev/null +++ b/src/features/engine/domain/enveloppe/ventilation/deperdition-ventilation.service.spec.js @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +/** `bug_for_bug_compat` (utils.js) piloté par le test. */ +const state = vi.hoisted(() => ({ bug: false })); +vi.mock('../../../../../utils.js', async (importOriginal) => ({ + ...(await importOriginal()), + get bug_for_bug_compat() { + return state.bug; + } +})); + +const { DeperditionVentilationService } = await import('./deperdition-ventilation.service.js'); + +/** + * Tests unitaires isolés du calcul de Q4Paconv (`DeperditionVentilationService.q4paConv`). + * Le store des tables de valeurs est remplacé par un double de test. + * + * @see : Methode_de_calcul_3CL_DPE_2021-338.pdf - §4 + */ + +/** @type {DeperditionVentilationService} */ +let service; +let tvStore; + +const ctx = { enumPeriodeConstructionId: '1', typeHabitation: 'APPARTEMENT' }; + +describe('DeperditionVentilationService.q4paConv', () => { + beforeEach(() => { + tvStore = { + getQ4paConv: vi.fn().mockReturnValue({ q4pa_conv: '4.6' }), + getQ4paConvById: vi.fn() + }; + service = new DeperditionVentilationService(tvStore); + state.bug = false; + }); + + test('Q4Pa mesuré saisi : utilisé directement', () => { + expect(service.q4paConv({ q4pa_conv_saisi: 1.7 }, ctx, 0, 100, 0, 10, true)).toBe(1.7); + expect(tvStore.getQ4paConv).not.toHaveBeenCalled(); + }); + + test('avant 1948, menuiseries majoritairement avec joints : ligne « avec joints »', () => { + service.q4paConv({}, ctx, 0, 100, 8, 2, false); + expect(tvStore.getQ4paConv).toHaveBeenCalledWith('1', 'APPARTEMENT', '0', '1'); + }); + + test('avant 1948, menuiseries sans joint : ligne sans critère de joints', () => { + service.q4paConv({ tv_q4pa_conv_id: 12 }, ctx, 0, 100, 0, 10, false); + expect(tvStore.getQ4paConv).toHaveBeenCalledWith('1', 'APPARTEMENT', '0', undefined); + }); + + describe('presence_joint non exporté (issue #294)', () => { + test('hors bug_for_bug_compat : la ligne du DPE est ignorée', () => { + state.bug = false; + tvStore.getQ4paConvById.mockReturnValue({ presence_joints_menuiserie: '1' }); + + service.q4paConv({ tv_q4pa_conv_id: 12 }, ctx, 0, 100, 0, 10, true); + + expect(tvStore.getQ4paConv).toHaveBeenCalledWith('1', 'APPARTEMENT', '0', undefined); + }); + + test('bug_for_bug_compat et ligne DPE « avec joints » : joints repris', () => { + state.bug = true; + tvStore.getQ4paConvById.mockReturnValue({ presence_joints_menuiserie: '1' }); + + service.q4paConv({ tv_q4pa_conv_id: 12 }, ctx, 0, 100, 0, 10, true); + + expect(tvStore.getQ4paConvById).toHaveBeenCalledWith(12); + expect(tvStore.getQ4paConv).toHaveBeenCalledWith('1', 'APPARTEMENT', '0', '1'); + }); + + test('bug_for_bug_compat mais presence_joint renseigné : pas de reprise', () => { + state.bug = true; + tvStore.getQ4paConvById.mockReturnValue({ presence_joints_menuiserie: '1' }); + + service.q4paConv({ tv_q4pa_conv_id: 12 }, ctx, 0, 100, 0, 10, false); + + expect(tvStore.getQ4paConv).toHaveBeenCalledWith('1', 'APPARTEMENT', '0', undefined); + }); + + test('bug_for_bug_compat et ligne DPE sans joints : pas de reprise', () => { + state.bug = true; + tvStore.getQ4paConvById.mockReturnValue({ presence_joints_menuiserie: '' }); + + service.q4paConv({ tv_q4pa_conv_id: 1 }, ctx, 0, 100, 0, 10, true); + + expect(tvStore.getQ4paConv).toHaveBeenCalledWith('1', 'APPARTEMENT', '0', undefined); + }); + + test('surfaces majoritairement isolées : le critère des joints ne s’applique pas', () => { + state.bug = true; + tvStore.getQ4paConvById.mockReturnValue({ presence_joints_menuiserie: '1' }); + + service.q4paConv({ tv_q4pa_conv_id: 12 }, ctx, 100, 0, 0, 10, true); + + expect(tvStore.getQ4paConvById).not.toHaveBeenCalled(); + expect(tvStore.getQ4paConv).toHaveBeenCalledWith('1', 'APPARTEMENT', '1', undefined); + }); + }); +});