diff --git a/src/plesna_gerance/api/app.py b/src/plesna_gerance/api/app.py index 27b048c..66a0144 100644 --- a/src/plesna_gerance/api/app.py +++ b/src/plesna_gerance/api/app.py @@ -18,6 +18,7 @@ from .routes import ( documents_router, extraction_router, ia_router, + referentiel_router, revenus_router, tags_router, ) @@ -52,6 +53,7 @@ app.include_router(tags_router) app.include_router(analytics_router) app.include_router(dashboard_router) app.include_router(revenus_router) +app.include_router(referentiel_router) if FEATURE_IA: app.include_router(ia_router) app.include_router(config_router) diff --git a/src/plesna_gerance/api/routes/__init__.py b/src/plesna_gerance/api/routes/__init__.py index 0b4255e..51bd5ab 100644 --- a/src/plesna_gerance/api/routes/__init__.py +++ b/src/plesna_gerance/api/routes/__init__.py @@ -6,6 +6,7 @@ from .dashboard import router as dashboard_router from .documents import router as documents_router from .extraction import router as extraction_router from .ia import router as ia_router +from .referentiel import router as referentiel_router from .revenus import router as revenus_router from .tags import router as tags_router @@ -16,6 +17,7 @@ __all__ = [ "analytics_router", "dashboard_router", "revenus_router", + "referentiel_router", "ia_router", "config_router", ] diff --git a/src/plesna_gerance/api/routes/analytics.py b/src/plesna_gerance/api/routes/analytics.py index a1500b1..cb02d86 100644 --- a/src/plesna_gerance/api/routes/analytics.py +++ b/src/plesna_gerance/api/routes/analytics.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from ...database import get_session from ...database.models import Depense, Document, Immeuble, Lot, Tag +from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche from ..schemas.models import ( CategorySummary, DepenseDetail, @@ -77,11 +78,15 @@ async def list_lots( - **immeuble_id**: ID de l'immeuble pour filtrer (optionnel) """ - stmt = ( - select(Lot, Immeuble.code.label("immeuble_code")) - .join(Immeuble, Lot.immeuble_id == Immeuble.id) - .order_by(Immeuble.code, Lot.numero) - ) + stmt = joindre_fiche( + select( + Lot.id, + Lot.numero, + Lot.immeuble_id, + TYPE_LOT_EFFECTIF.label("type"), + Immeuble.code.label("immeuble_code"), + ).join(Immeuble, Lot.immeuble_id == Immeuble.id) + ).order_by(Immeuble.code, Lot.numero) if immeuble_id is not None: stmt = stmt.where(Lot.immeuble_id == immeuble_id) @@ -91,10 +96,10 @@ async def list_lots( return [ LotResponse( - id=row.Lot.id, - numero=row.Lot.numero, - type=row.Lot.type, - immeuble_id=row.Lot.immeuble_id, + id=row.id, + numero=row.numero, + type=row.type, + immeuble_id=row.immeuble_id, immeuble_code=row.immeuble_code, ) for row in rows diff --git a/src/plesna_gerance/api/routes/referentiel.py b/src/plesna_gerance/api/routes/referentiel.py new file mode 100644 index 0000000..0b2cc8b --- /dev/null +++ b/src/plesna_gerance/api/routes/referentiel.py @@ -0,0 +1,148 @@ +"""Référentiel des logements — caractéristiques saisies à la main. + +Ces données ne viennent pas des PDF : elles décrivent le bien (surface, étage, +DPE, chauffage) et donnent au référentiel une source de vérité indépendante de +l'extraction. +""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from ...database import get_session +from ...database.models import Depense, Immeuble, Lot, LotCaracteristiques, Revenu +from ...services.referentiel import type_effectif +from ...utils.logements import delta_surface, dpe_echeance, type_en_ecart +from ..schemas.models import ( + CaracteristiquesBody, + CaracteristiquesResponse, + LotReferentielResponse, +) + +router = APIRouter(prefix="/api", tags=["referentiel"]) + +#: Champs de la fiche, dans l'ordre de saisie. Sert à recopier le corps de la +#: requête vers le modèle sans énumérer les champs à chaque fois. +CHAMPS_FICHE = tuple(CaracteristiquesBody.model_fields) + + +def _caracteristiques_response( + fiche: LotCaracteristiques | None, +) -> CaracteristiquesResponse | None: + """Fiche augmentée de ses valeurs dérivées, ou None si elle n'existe pas.""" + if fiche is None: + return None + + return CaracteristiquesResponse( + **{champ: getattr(fiche, champ) for champ in CHAMPS_FICHE}, + dpe_echeance=dpe_echeance(fiche.dpe_date_realisation), + delta_surface=delta_surface(fiche.surface, fiche.surface_impots), + updated_at=fiche.updated_at, + ) + + +def _lot_response( + lot: Lot, + immeuble_code: str | None, + nb_revenus: int = 0, + nb_depenses: int = 0, +) -> LotReferentielResponse: + """Assemble la ligne de référentiel d'un lot.""" + fiche = lot.caracteristiques + + return LotReferentielResponse( + id=lot.id, + numero=lot.numero, + immeuble_id=lot.immeuble_id, + immeuble_code=immeuble_code, + type_extrait=lot.type, + type_effectif=type_effectif(lot), + type_ecart=type_en_ecart(lot.type, fiche.type if fiche else None), + caracteristiques=_caracteristiques_response(fiche), + nb_revenus=nb_revenus, + nb_depenses=nb_depenses, + ) + + +@router.get( + "/immeubles/{immeuble_id}/lots/referentiel", + response_model=list[LotReferentielResponse], +) +async def list_lots_referentiel( + immeuble_id: int, + session: Session = Depends(get_session), +) -> list[LotReferentielResponse]: + """Liste les lots d'un immeuble avec leur fiche de caractéristiques. + + - **immeuble_id**: ID de l'immeuble + + Les lots sans fiche sont renvoyés avec `caracteristiques` à `null` : le + tableau de saisie doit montrer les lignes vides autant que les remplies. + """ + immeuble = session.get(Immeuble, immeuble_id) + if immeuble is None: + raise HTTPException(status_code=404, detail="Immeuble introuvable.") + + # Sous-requêtes corrélées plutôt que des jointures : compter revenus et + # dépenses dans la même jointure multiplierait les lignes entre elles. + nb_revenus = ( + select(func.count(Revenu.id)) + .where(Revenu.lot_id == Lot.id) + .correlate(Lot) + .scalar_subquery() + ) + nb_depenses = ( + select(func.count(Depense.id)) + .where(Depense.lot_id == Lot.id) + .correlate(Lot) + .scalar_subquery() + ) + + stmt = ( + select(Lot, nb_revenus.label("nb_revenus"), nb_depenses.label("nb_depenses")) + .where(Lot.immeuble_id == immeuble_id) + .order_by(Lot.numero) + ) + + return [ + _lot_response( + row.Lot, + immeuble.code, + nb_revenus=row.nb_revenus or 0, + nb_depenses=row.nb_depenses or 0, + ) + for row in session.execute(stmt).all() + ] + + +@router.put("/lots/{lot_id}/caracteristiques", response_model=LotReferentielResponse) +async def upsert_caracteristiques( + lot_id: int, + body: CaracteristiquesBody, + session: Session = Depends(get_session), +) -> LotReferentielResponse: + """Enregistre la fiche d'un lot, en la créant si elle n'existe pas encore. + + - **lot_id**: ID du lot + + Le corps décrit la fiche complète : un champ omis ou vidé efface la valeur + précédente, pour qu'une correction dans le tableau ne laisse pas de reste. + """ + lot = session.get(Lot, lot_id) + if lot is None: + raise HTTPException(status_code=404, detail="Lot introuvable.") + + fiche = lot.caracteristiques + if fiche is None: + fiche = LotCaracteristiques(lot_id=lot.id) + session.add(fiche) + lot.caracteristiques = fiche + + for champ in CHAMPS_FICHE: + setattr(fiche, champ, getattr(body, champ)) + + session.commit() + session.refresh(lot) + + immeuble = session.get(Immeuble, lot.immeuble_id) + return _lot_response(lot, immeuble.code if immeuble else None) diff --git a/src/plesna_gerance/api/routes/revenus.py b/src/plesna_gerance/api/routes/revenus.py index 944e8cd..017b351 100644 --- a/src/plesna_gerance/api/routes/revenus.py +++ b/src/plesna_gerance/api/routes/revenus.py @@ -15,6 +15,7 @@ from ...database.models import ( Lot, Revenu, ) +from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche from ...services.revenus_query import ( est_flux, flux_par, @@ -390,26 +391,28 @@ async def get_revenus_by_lot( flux = flux_par(Revenu.lot_id) dette = restant_du_par(Revenu.lot_id) stmt = ( - select( - Lot.id, - Lot.numero, - Lot.type, - Immeuble.code, - func.max(Locataire.nom).label("locataire_nom"), - flux.c.facture, - flux.c.encaisse, - dette.c.restant_du, - func.max(Document.date).label("derniere_date"), + joindre_fiche( + select( + Lot.id, + Lot.numero, + TYPE_LOT_EFFECTIF.label("type"), + Immeuble.code, + func.max(Locataire.nom).label("locataire_nom"), + flux.c.facture, + flux.c.encaisse, + dette.c.restant_du, + func.max(Document.date).label("derniere_date"), + ) + .join(Immeuble, Lot.immeuble_id == Immeuble.id) + .join(Revenu, Revenu.lot_id == Lot.id) + .join(Document, Revenu.document_id == Document.id) + .outerjoin( + Locataire, + and_(Locataire.lot_id == Lot.id, Locataire.date_fin.is_(None)), + ) + .outerjoin(flux, flux.c.cle == Lot.id) + .outerjoin(dette, dette.c.cle == Lot.id) ) - .join(Immeuble, Lot.immeuble_id == Immeuble.id) - .join(Revenu, Revenu.lot_id == Lot.id) - .join(Document, Revenu.document_id == Document.id) - .outerjoin( - Locataire, - and_(Locataire.lot_id == Lot.id, Locataire.date_fin.is_(None)), - ) - .outerjoin(flux, flux.c.cle == Lot.id) - .outerjoin(dette, dette.c.cle == Lot.id) .group_by(Lot.id) .order_by(desc("restant_du"), desc("facture")) .limit(limit) diff --git a/src/plesna_gerance/api/schemas/__init__.py b/src/plesna_gerance/api/schemas/__init__.py index 85aa88d..791bd77 100644 --- a/src/plesna_gerance/api/schemas/__init__.py +++ b/src/plesna_gerance/api/schemas/__init__.py @@ -1,10 +1,13 @@ """Pydantic schemas for API request/response models.""" from .models import ( + CaracteristiquesBody, + CaracteristiquesResponse, DepenseDetail, DepensesSummary, DocumentSummary, ImmeubleResponse, + LotReferentielResponse, LotResponse, PredictTagsRequest, SaveRequest, @@ -20,4 +23,7 @@ __all__ = [ "DepensesSummary", "ImmeubleResponse", "LotResponse", + "CaracteristiquesBody", + "CaracteristiquesResponse", + "LotReferentielResponse", ] diff --git a/src/plesna_gerance/api/schemas/models.py b/src/plesna_gerance/api/schemas/models.py index 90a0cb6..3c3ff5e 100644 --- a/src/plesna_gerance/api/schemas/models.py +++ b/src/plesna_gerance/api/schemas/models.py @@ -1,9 +1,11 @@ """Pydantic models for API requests and responses.""" -from datetime import date +from datetime import date, datetime from typing import Any -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator + +from ...utils.logements import DPE_CLASSES # ============================================================ # Requests @@ -180,3 +182,82 @@ class DepensesSummary(BaseModel): by_tag: list[TagSummary] by_month: list[MonthlySummary] by_fournisseur: list[FournisseurSummary] + + +# ============================================================ +# Référentiel des logements +# ============================================================ + + +class CaracteristiquesBody(BaseModel): + """Caractéristiques d'un logement telles que saisies. + + Tous les champs sont optionnels : la fiche se remplit progressivement, et + une fiche partielle vaut mieux qu'une fiche refusée. + """ + + bat: str | None = None + etage: str | None = None + type: str | None = None + surface: float | None = Field(None, ge=0) + surface_date_diag: date | None = None + chauffage: str | None = None + dpe_classe: str | None = None + dpe_date_realisation: date | None = None + numero_fiscal: str | None = None + surface_impots: float | None = Field(None, ge=0) + note_impots: str | None = None + + @field_validator("dpe_classe") + @classmethod + def _classe_connue(cls, value: str | None) -> str | None: + """Refuse une classe hors A-G, faute de quoi les KPI DPE mentiraient.""" + if value is None or value == "": + return None + classe = value.strip().upper() + if classe not in DPE_CLASSES: + raise ValueError(f"Classe DPE inconnue : {value} (attendu A-G)") + return classe + + @field_validator( + "bat", "etage", "type", "chauffage", "numero_fiscal", "note_impots" + ) + @classmethod + def _texte_vide_vaut_absent(cls, value: str | None) -> str | None: + """Un champ vide dans le tableau doit effacer la valeur, pas la figer.""" + if value is None: + return None + value = value.strip() + return value or None + + +class CaracteristiquesResponse(CaracteristiquesBody): + """Caractéristiques saisies, augmentées de leurs valeurs dérivées.""" + + #: Péremption du DPE, déduite de la date de réalisation (+10 ans). + dpe_echeance: date | None = None + #: Surface impôts moins surface mesurée ; None si une des deux manque. + delta_surface: float | None = None + updated_at: datetime | None = None + + +class LotReferentielResponse(BaseModel): + """Un lot et sa fiche, tels qu'affichés dans le tableau du référentiel.""" + + id: int + numero: str + immeuble_id: int + immeuble_code: str | None + + #: Type de lot vu par l'extraction PDF, conservé tel quel. + type_extrait: str | None + #: Type retenu : celui de la fiche s'il existe, sinon celui du PDF. + type_effectif: str | None + #: Vrai quand les deux sources se contredisent (comparaison normalisée). + type_ecart: bool = False + + caracteristiques: CaracteristiquesResponse | None = None + + #: Rattachements existants : un lot qui en a n'est pas supprimable. + nb_revenus: int = 0 + nb_depenses: int = 0 diff --git a/src/plesna_gerance/database/__init__.py b/src/plesna_gerance/database/__init__.py index 61f0678..30c6a59 100644 --- a/src/plesna_gerance/database/__init__.py +++ b/src/plesna_gerance/database/__init__.py @@ -2,7 +2,17 @@ from . import storage from .connection import get_engine, get_session, get_session_factory, init_db -from .models import Base, Depense, Document, Immeuble, Locataire, Lot, Revenu, Setting +from .models import ( + Base, + Depense, + Document, + Immeuble, + Locataire, + Lot, + LotCaracteristiques, + Revenu, + Setting, +) from .service import DatabaseService, DuplicateDocumentError __all__ = [ @@ -14,6 +24,7 @@ __all__ = [ "Document", "Immeuble", "Lot", + "LotCaracteristiques", "Locataire", "Revenu", "Depense", diff --git a/src/plesna_gerance/database/models.py b/src/plesna_gerance/database/models.py index dcd7e4b..7187ea8 100644 --- a/src/plesna_gerance/database/models.py +++ b/src/plesna_gerance/database/models.py @@ -103,11 +103,58 @@ class Lot(Base): ) revenus = relationship("Revenu", back_populates="lot") depenses = relationship("Depense", back_populates="lot") + caracteristiques = relationship( + "LotCaracteristiques", + back_populates="lot", + uselist=False, + cascade="all, delete-orphan", + ) def __repr__(self) -> str: return f"" +class LotCaracteristiques(Base): + """Caractéristiques d'un logement, saisies à la main. + + Table séparée de `lots` à dessein : `lots` porte ce que l'extraction PDF + sait d'un lot, celle-ci ce que le propriétaire en sait. Une ré-extraction ne + peut donc structurellement pas écraser la saisie, et l'écart entre les deux + sources (le type de lot) reste calculable au lieu d'être perdu. + """ + + __tablename__ = "lot_caracteristiques" + + id = Column(Integer, primary_key=True, autoincrement=True) + lot_id = Column(Integer, ForeignKey("lots.id"), nullable=False, unique=True) + + # Description physique + bat = Column(String(50), nullable=True) # "Rue", "Cour" + etage = Column(String(20), nullable=True) # "RC", "1", "SS", "Combles" + type = Column(String(100), nullable=True) # prioritaire sur Lot.type + surface = Column(Float, nullable=True) # m², mesure Oralia + surface_date_diag = Column(Date, nullable=True) + chauffage = Column(String(100), nullable=True) + + # Réglementaire + dpe_classe = Column(String(1), nullable=True) # A..G + dpe_date_realisation = Column(Date, nullable=True) # échéance = +10 ans + + # Rapprochement avec les impôts + numero_fiscal = Column(String(50), nullable=True) # clé de recherche impots.gouv + surface_impots = Column(Float, nullable=True) # m² déclarés + note_impots = Column(Text, nullable=True) + + created_at = Column(DateTime, default=_utcnow) + updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow) + + # Relations + lot = relationship("Lot", back_populates="caracteristiques") + + def __repr__(self) -> str: + return f"" + + class Locataire(Base): """Table des locataires avec historique.""" diff --git a/src/plesna_gerance/services/referentiel.py b/src/plesna_gerance/services/referentiel.py new file mode 100644 index 0000000..f1305c1 --- /dev/null +++ b/src/plesna_gerance/services/referentiel.py @@ -0,0 +1,34 @@ +"""Arbitrage entre ce que dit le PDF d'un lot et ce que sa fiche en dit. + +Le type d'un lot est connu de deux sources : l'extraction PDF, qui le remplit +parfois mal et souvent pas du tout, et la fiche saisie à la main, qui fait foi. +La règle est donc « la fiche gagne, le PDF comble les trous » — et elle vit ici, +en un seul endroit, sous ses deux formes : une expression SQL pour les +agrégats, une fonction Python pour l'ORM. Les faire diverger reviendrait à +afficher deux types différents pour un même lot selon la page consultée. + +Le désaccord entre les deux sources n'est jamais résolu en silence : il reste +visible via `type_en_ecart` (voir `utils.logements`). +""" + +from sqlalchemy import Select, func + +from ..database.models import Lot, LotCaracteristiques + +#: Type de lot retenu, en SQL. Requiert la jointure de `joindre_fiche`. +TYPE_LOT_EFFECTIF = func.coalesce(LotCaracteristiques.type, Lot.type) + + +def joindre_fiche(stmt: Select) -> Select: + """Ajoute à une requête sur `Lot` la jointure vers sa fiche. + + La relation est 1↔1 : la jointure ne multiplie aucune ligne, elle peut donc + s'ajouter à une requête agrégée sans fausser les totaux. + """ + return stmt.outerjoin(LotCaracteristiques, LotCaracteristiques.lot_id == Lot.id) + + +def type_effectif(lot: Lot) -> str | None: + """Type de lot retenu, depuis un objet chargé par l'ORM.""" + fiche = lot.caracteristiques + return (fiche.type if fiche else None) or lot.type diff --git a/src/plesna_gerance/utils/logements.py b/src/plesna_gerance/utils/logements.py new file mode 100644 index 0000000..240f465 --- /dev/null +++ b/src/plesna_gerance/utils/logements.py @@ -0,0 +1,88 @@ +"""Valeurs dérivées des caractéristiques d'un logement. + +Ni l'échéance du DPE ni l'écart de surface ne sont stockés : ce sont des +conséquences de valeurs saisies, et une colonne dérivée finit toujours par +mentir après une correction. Elles se calculent ici, en un seul endroit, pour +que l'API et les futurs KPI donnent le même résultat. +""" + +import re +from datetime import date + +#: Durée de validité d'un DPE réalisé après la réforme de 2021 (10 ans). +DPE_VALIDITE_ANNEES = 10 + +#: Classes possibles d'un DPE, de la plus performante à la moins performante. +DPE_CLASSES = ("A", "B", "C", "D", "E", "F", "G") + + +def dpe_echeance(date_realisation: date | None) -> date | None: + """Date de péremption d'un DPE réalisé à `date_realisation`. + + Args: + date_realisation: Date de réalisation du diagnostic, ou None + + Returns: + Date de fin de validité, ou None si la date de réalisation manque + """ + if date_realisation is None: + return None + + annee = date_realisation.year + DPE_VALIDITE_ANNEES + try: + return date_realisation.replace(year=annee) + except ValueError: + # 29 février d'une année bissextile vers une année qui ne l'est pas. + return date(annee, 2, 28) + + +def delta_surface(surface: float | None, surface_impots: float | None) -> float | None: + """Écart entre la surface déclarée aux impôts et la surface mesurée. + + Signe positif : les impôts retiennent plus de surface que la mesure Oralia. + + Args: + surface: Surface mesurée (m²) + surface_impots: Surface déclarée aux impôts (m²) + + Returns: + L'écart en m², ou None si une des deux surfaces manque + """ + if surface is None or surface_impots is None: + return None + + return round(surface_impots - surface, 2) + + +def _type_comparable(valeur: str | None) -> str: + """Écriture normalisée d'un type de lot, pour comparaison seulement.""" + if not valeur: + return "" + return re.sub(r"[^a-z0-9]+", " ", valeur.lower()).strip() + + +def type_en_ecart(type_extrait: str | None, type_saisi: str | None) -> bool: + """Le PDF et la fiche annoncent-ils deux types de lot différents ? + + La comparaison ignore casse et ponctuation : le PDF écrit + "Loc. Commercial" là où une saisie donne "Loc, Commercial" ou + "loc commercial". Signaler ces trois-là comme un désaccord noierait le seul + écart qui compte, celui où les deux sources ne parlent pas du même logement. + + Un type absent d'un côté n'est pas un écart : c'est une information qui + manque, pas une contradiction. + + Args: + type_extrait: Type de lot vu par l'extraction PDF + type_saisi: Type de lot saisi dans la fiche + + Returns: + True si les deux valeurs sont renseignées et se contredisent + """ + extrait = _type_comparable(type_extrait) + saisi = _type_comparable(type_saisi) + + if not extrait or not saisi: + return False + + return extrait != saisi diff --git a/tests/test_logements.py b/tests/test_logements.py new file mode 100644 index 0000000..069bb64 --- /dev/null +++ b/tests/test_logements.py @@ -0,0 +1,57 @@ +"""Tests des valeurs derivees des caracteristiques d'un logement. + +Echeance de DPE et ecart de surface ne sont pas stockes : ils se recalculent a +chaque lecture. Ces tests figent les regles de calcul, seul endroit ou une +erreur passerait inapercue puisqu'aucune donnee saisie ne la contredirait. +""" + +from datetime import date + +from plesna_gerance.utils.logements import ( + delta_surface, + dpe_echeance, + type_en_ecart, +) + + +def test_un_dpe_vaut_dix_ans(): + assert dpe_echeance(date(2021, 2, 8)) == date(2031, 2, 8) + + +def test_un_dpe_du_29_fevrier_expire_le_28(): + """2020 est bissextile, 2030 non : la date doit reculer, pas exploser.""" + assert dpe_echeance(date(2020, 2, 29)) == date(2030, 2, 28) + + +def test_sans_date_de_dpe_pas_d_echeance(): + assert dpe_echeance(None) is None + + +def test_ecart_de_surface_positif_quand_les_impots_en_retiennent_plus(): + assert delta_surface(100.0, 119.0) == 19.0 + + +def test_ecart_de_surface_negatif_quand_la_mesure_est_plus_grande(): + assert delta_surface(148.0, 146.0) == -2.0 + + +def test_une_seule_surface_ne_donne_aucun_ecart(): + """Un ecart de 0 affirmerait que les deux sources concordent.""" + assert delta_surface(148.0, None) is None + assert delta_surface(None, 146.0) is None + + +def test_meme_type_ecrit_autrement_n_est_pas_un_ecart(): + assert not type_en_ecart("Loc. Commercial", "Loc, Commercial") + assert not type_en_ecart("Appartement T3", "appartement t3") + + +def test_deux_types_differents_sont_un_ecart(): + assert type_en_ecart("Appartement T3", "Appartement T2") + + +def test_un_type_absent_n_est_pas_un_ecart(): + """Le PDF laisse souvent le type vide : ce n'est pas une contradiction.""" + assert not type_en_ecart(None, "Appartement T3") + assert not type_en_ecart("Appartement T3", None) + assert not type_en_ecart("", "") diff --git a/tests/test_referentiel_api.py b/tests/test_referentiel_api.py new file mode 100644 index 0000000..a17cb89 --- /dev/null +++ b/tests/test_referentiel_api.py @@ -0,0 +1,205 @@ +"""Tests du referentiel des logements. + +Ces caracteristiques sont saisies a la main : elles n'ont aucune autre source +que l'utilisateur, donc rien ne les reconstituerait si un enregistrement les +perdait. Les tests portent sur ce qui menace cette saisie : l'ecraser depuis un +PDF, la vider a moitie, ou la laisser contredire l'extraction en silence. +""" + +import pytest + +from plesna_gerance.database.models import Depense, Immeuble, Lot +from plesna_gerance.database.service import DatabaseService + + +@pytest.fixture +def immeuble_et_lot(db_session, sample_data): + """Un immeuble et son lot 01, tels que l'extraction les cree.""" + DatabaseService(db_session).save_document(data=sample_data) + + immeuble = db_session.query(Immeuble).filter(Immeuble.code == "IMM1").one() + lot = db_session.query(Lot).filter(Lot.immeuble_id == immeuble.id).one() + return immeuble, lot + + +FICHE = { + "bat": "Rue", + "etage": "RC", + "type": "Loc. Commercial", + "surface": 148.0, + "surface_date_diag": "2019-06-01", + "chauffage": "Electrique", + "dpe_classe": "C", + "dpe_date_realisation": "2021-02-08", + "numero_fiscal": "690123456789", + "surface_impots": 146.0, + "note_impots": "Surface relevee sur l'avis 2024", +} + + +def test_liste_les_lots_sans_fiche(api_client, immeuble_et_lot): + """Un lot jamais decrit doit apparaitre, sinon il n'est pas saisissable.""" + immeuble, lot = immeuble_et_lot + + response = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel") + + assert response.status_code == 200 + lignes = response.json() + assert len(lignes) == 1 + assert lignes[0]["id"] == lot.id + assert lignes[0]["numero"] == "01" + assert lignes[0]["caracteristiques"] is None + assert lignes[0]["type_extrait"] == "Appartement" + assert lignes[0]["type_effectif"] == "Appartement" + assert lignes[0]["type_ecart"] is False + + +def test_liste_un_immeuble_inconnu_donne_404(api_client): + assert api_client.get("/api/immeubles/99999/lots/referentiel").status_code == 404 + + +def test_enregistre_puis_relit_une_fiche(api_client, immeuble_et_lot): + immeuble, lot = immeuble_et_lot + + response = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE) + + assert response.status_code == 200 + fiche = response.json()["caracteristiques"] + assert fiche["surface"] == 148.0 + assert fiche["etage"] == "RC" + assert fiche["numero_fiscal"] == "690123456789" + + relu = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json()[0] + assert relu["caracteristiques"]["surface"] == 148.0 + assert relu["caracteristiques"]["dpe_classe"] == "C" + + +def test_expose_les_valeurs_derivees(api_client, immeuble_et_lot): + """L'echeance du DPE et l'ecart de surface arrivent calcules.""" + _, lot = immeuble_et_lot + + fiche = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE).json()[ + "caracteristiques" + ] + + assert fiche["dpe_echeance"] == "2031-02-08" + assert fiche["delta_surface"] == -2.0 + + +def test_une_seconde_ecriture_met_a_jour_sans_dupliquer(api_client, immeuble_et_lot): + immeuble, lot = immeuble_et_lot + api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE) + + api_client.put( + f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "surface": 150.0} + ) + + lignes = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json() + assert len(lignes) == 1 + assert lignes[0]["caracteristiques"]["surface"] == 150.0 + + +def test_un_champ_vide_efface_la_valeur(api_client, immeuble_et_lot): + """Corriger une erreur de saisie doit pouvoir revenir a « inconnu ».""" + _, lot = immeuble_et_lot + api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE) + + fiche = api_client.put( + f"/api/lots/{lot.id}/caracteristiques", + json={**FICHE, "etage": " ", "surface": None}, + ).json()["caracteristiques"] + + assert fiche["etage"] is None + assert fiche["surface"] is None + assert fiche["delta_surface"] is None + + +def test_refuse_une_classe_dpe_inconnue(api_client, immeuble_et_lot): + _, lot = immeuble_et_lot + + response = api_client.put( + f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "dpe_classe": "Z"} + ) + + assert response.status_code == 422 + + +def test_refuse_une_surface_negative(api_client, immeuble_et_lot): + _, lot = immeuble_et_lot + + response = api_client.put( + f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "surface": -10} + ) + + assert response.status_code == 422 + + +def test_ecrire_sur_un_lot_inconnu_donne_404(api_client): + assert ( + api_client.put("/api/lots/99999/caracteristiques", json=FICHE).status_code + == 404 + ) + + +def test_signale_un_desaccord_de_type_sans_effacer_le_pdf(api_client, immeuble_et_lot): + """Les deux types restent lisibles : la fiche tranche, le PDF reste visible.""" + _, lot = immeuble_et_lot + + ligne = api_client.put( + f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "type": "Appartement T3"} + ).json() + + assert ligne["type_extrait"] == "Appartement" + assert ligne["type_effectif"] == "Appartement T3" + assert ligne["type_ecart"] is True + + +def test_le_type_saisi_prime_dans_la_liste_des_lots(api_client, immeuble_et_lot): + """La priorite du referentiel vaut partout, pas seulement sur sa page.""" + _, lot = immeuble_et_lot + api_client.put( + f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "type": "Appartement T3"} + ) + + lots = api_client.get("/api/lots").json() + + assert [ligne["type"] for ligne in lots] == ["Appartement T3"] + + +def test_une_re_extraction_ne_touche_pas_la_fiche( + api_client, immeuble_et_lot, db_session, sample_data +): + """Le point critique : reimporter le PDF ne doit rien perdre de la saisie.""" + immeuble, lot = immeuble_et_lot + api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE) + + DatabaseService(db_session).save_document(data=sample_data, overwrite=True) + + lignes = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json() + fiche = next(ligne for ligne in lignes if ligne["id"] == lot.id)["caracteristiques"] + assert fiche is not None, "la re-extraction a perdu la fiche du lot" + assert fiche["surface"] == 148.0 + + +def test_compte_les_rattachements_du_lot(api_client, immeuble_et_lot): + """Un lot avec des revenus n'est pas un orphelin : la liste doit le dire.""" + immeuble, lot = immeuble_et_lot + + ligne = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json()[0] + + assert ligne["nb_revenus"] == 1 + # La depense de la fixture porte sur l'immeuble (lot_id NULL). La compter + # ici rendrait tout lot de l'immeuble faussement non supprimable. + assert ligne["nb_depenses"] == 0 + + +def test_compte_les_depenses_propres_au_lot(api_client, immeuble_et_lot, db_session): + """Une depense rattachee au lot, elle, doit bien remonter sur sa ligne.""" + immeuble, lot = immeuble_et_lot + depense = db_session.query(Depense).one() + depense.lot_id = lot.id + db_session.commit() + + ligne = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json()[0] + + assert ligne["nb_depenses"] == 1