diff --git a/src/plesna_gerance/api/routes/referentiel.py b/src/plesna_gerance/api/routes/referentiel.py index 0b2cc8b..ce749a9 100644 --- a/src/plesna_gerance/api/routes/referentiel.py +++ b/src/plesna_gerance/api/routes/referentiel.py @@ -5,7 +5,7 @@ DPE, chauffage) et donnent au référentiel une source de vérité indépendante l'extraction. """ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.orm import Session @@ -16,6 +16,8 @@ from ...utils.logements import delta_surface, dpe_echeance, type_en_ecart from ..schemas.models import ( CaracteristiquesBody, CaracteristiquesResponse, + ImmeubleBody, + ImmeubleResponse, LotReferentielResponse, ) @@ -44,8 +46,9 @@ def _caracteristiques_response( def _lot_response( lot: Lot, immeuble_code: str | None, - nb_revenus: int = 0, - nb_depenses: int = 0, + nb_revenus: int, + nb_depenses: int, + immeuble_denomination: str | None = None, ) -> LotReferentielResponse: """Assemble la ligne de référentiel d'un lot.""" fiche = lot.caracteristiques @@ -55,6 +58,7 @@ def _lot_response( numero=lot.numero, immeuble_id=lot.immeuble_id, immeuble_code=immeuble_code, + immeuble_denomination=immeuble_denomination, type_extrait=lot.type, type_effectif=type_effectif(lot), type_ecart=type_en_ecart(lot.type, fiche.type if fiche else None), @@ -64,27 +68,16 @@ def _lot_response( ) -@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. +def _requete_lignes(): + """Requête des lots avec le nombre de revenus et de dépenses rattachés. - - **immeuble_id**: ID de l'immeuble + Partagée par la liste et l'enregistrement : une ligne renvoyée après + écriture doit être comptée comme celle du tableau, sinon un lot bien occupé + se met à passer pour inutilisé dès qu'on le décrit. - Les lots sans fiche sont renvoyés avec `caracteristiques` à `null` : le - tableau de saisie doit montrer les lignes vides autant que les remplies. + 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. """ - 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) @@ -98,16 +91,41 @@ async def list_lots_referentiel( .scalar_subquery() ) + return select(Lot, nb_revenus.label("nb_revenus"), nb_depenses.label("nb_depenses")) + + +@router.get("/lots/referentiel", response_model=list[LotReferentielResponse]) +async def list_lots_referentiel( + immeuble_id: int | None = Query(None, description="Filtrer par immeuble"), + session: Session = Depends(get_session), +) -> list[LotReferentielResponse]: + """Liste les lots avec leur fiche de caractéristiques. + + - **immeuble_id**: ID de l'immeuble pour restreindre la liste (optionnel) + + Tout le parc par défaut : le tableau de saisie porte une colonne immeuble, + et comparer deux immeubles au m² n'a de sens que s'ils s'affichent ensemble. + + Les lots sans fiche sont renvoyés avec `caracteristiques` à `null` : le + tableau doit montrer les lignes vides autant que les remplies. + """ + if immeuble_id is not None and session.get(Immeuble, immeuble_id) is None: + raise HTTPException(status_code=404, detail="Immeuble introuvable.") + stmt = ( - select(Lot, nb_revenus.label("nb_revenus"), nb_depenses.label("nb_depenses")) - .where(Lot.immeuble_id == immeuble_id) - .order_by(Lot.numero) + _requete_lignes() + .add_columns(Immeuble.code, Immeuble.denomination) + .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) return [ _lot_response( row.Lot, - immeuble.code, + row.code, + immeuble_denomination=row.denomination, nb_revenus=row.nb_revenus or 0, nb_depenses=row.nb_depenses or 0, ) @@ -142,7 +160,89 @@ async def upsert_caracteristiques( setattr(fiche, champ, getattr(body, champ)) session.commit() - session.refresh(lot) + row = session.execute(_requete_lignes().where(Lot.id == lot_id)).one() immeuble = session.get(Immeuble, lot.immeuble_id) - return _lot_response(lot, immeuble.code if immeuble else None) + return _lot_response( + row.Lot, + immeuble.code if immeuble else None, + immeuble_denomination=immeuble.denomination if immeuble else None, + nb_revenus=row.nb_revenus or 0, + nb_depenses=row.nb_depenses or 0, + ) + + +@router.delete("/lots/{lot_id}", status_code=204) +async def supprimer_lot( + lot_id: int, + session: Session = Depends(get_session), +) -> None: + """Supprime un lot que rien ne rattache à un document. + + - **lot_id**: ID du lot + + Sert à nettoyer les lots laissés par un ancien format de numérotation, qui + encombrent le tableau de saisie sans rien décrire. Un lot qui porte des + revenus ou des dépenses est refusé : le supprimer emporterait des montants + du compte rendu, et un compte faux est pire qu'une ligne en trop. + """ + lot = session.get(Lot, lot_id) + if lot is None: + raise HTTPException(status_code=404, detail="Lot introuvable.") + + row = session.execute(_requete_lignes().where(Lot.id == lot_id)).one() + if row.nb_revenus or row.nb_depenses: + raise HTTPException( + status_code=409, + detail=( + f"Le lot {lot.numero} porte {row.nb_revenus} revenu(s) et " + f"{row.nb_depenses} dépense(s) : il ne peut pas être supprimé." + ), + ) + + # Les locataires du lot partent avec lui (cascade). Un locataire qui aurait + # encore des revenus les rattacherait au lot, deja refuse ci-dessus. + session.delete(lot) + session.commit() + + +@router.put("/immeubles/{immeuble_id}", response_model=ImmeubleResponse) +async def renommer_immeuble( + immeuble_id: int, + body: ImmeubleBody, + session: Session = Depends(get_session), +) -> ImmeubleResponse: + """Donne à l'immeuble son nom d'usage. + + - **immeuble_id**: ID de l'immeuble + + Le code de gestion ("33689020") vient des PDF et ne se remplace pas ; la + dénomination ("Servient") s'affiche à sa place partout où l'immeuble est + cité. + """ + immeuble = session.get(Immeuble, immeuble_id) + if immeuble is None: + raise HTTPException(status_code=404, detail="Immeuble introuvable.") + + immeuble.denomination = body.denomination + session.commit() + + # Compteurs recalcules plutot que laisses a zero : la reponse remplace + # l'immeuble dans les listes du client, qui le croirait vide de lots. + nb_lots = session.execute( + select(func.count(Lot.id)).where(Lot.immeuble_id == immeuble.id) + ).scalar_one() + nb_depenses = session.execute( + select(func.count(Depense.id)).where(Depense.immeuble_id == immeuble.id) + ).scalar_one() + + return ImmeubleResponse( + id=immeuble.id, + code=immeuble.code, + denomination=immeuble.denomination, + adresse=immeuble.adresse, + ville=immeuble.ville, + code_postal=immeuble.code_postal, + nb_lots=nb_lots, + nb_depenses=nb_depenses, + ) diff --git a/tests/test_referentiel_api.py b/tests/test_referentiel_api.py index a17cb89..4a83a7f 100644 --- a/tests/test_referentiel_api.py +++ b/tests/test_referentiel_api.py @@ -8,7 +8,12 @@ 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.models import ( + Depense, + Immeuble, + Lot, + LotCaracteristiques, +) from plesna_gerance.database.service import DatabaseService @@ -41,7 +46,7 @@ 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") + response = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}") assert response.status_code == 200 lignes = response.json() @@ -55,7 +60,36 @@ def test_liste_les_lots_sans_fiche(api_client, immeuble_et_lot): def test_liste_un_immeuble_inconnu_donne_404(api_client): - assert api_client.get("/api/immeubles/99999/lots/referentiel").status_code == 404 + assert api_client.get("/api/lots/referentiel?immeuble_id=99999").status_code == 404 + + +def test_liste_tout_le_parc_par_defaut(api_client, immeuble_et_lot, db_session): + """Le tableau porte une colonne immeuble : il les montre donc tous. + + Restreindre a un immeuble par defaut obligerait a le choisir avant de voir + quoi que ce soit, alors que le parc entier tient dans un ecran. + """ + autre = Immeuble(code="IMM2", denomination="Marietton") + db_session.add(autre) + db_session.flush() + db_session.add(Lot(immeuble_id=autre.id, numero="01")) + db_session.commit() + + lignes = api_client.get("/api/lots/referentiel").json() + + assert len(lignes) == 2 + assert {ligne["immeuble_code"] for ligne in lignes} == {"IMM1", "IMM2"} + + +def test_expose_le_nom_d_usage_de_l_immeuble(api_client, immeuble_et_lot): + """Chaque ligne porte de quoi nommer son immeuble sans requete de plus.""" + immeuble, _ = immeuble_et_lot + api_client.put(f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"}) + + ligne = api_client.get("/api/lots/referentiel").json()[0] + + assert ligne["immeuble_denomination"] == "Servient" + assert ligne["immeuble_code"] == "IMM1" def test_enregistre_puis_relit_une_fiche(api_client, immeuble_et_lot): @@ -69,7 +103,7 @@ def test_enregistre_puis_relit_une_fiche(api_client, immeuble_et_lot): assert fiche["etage"] == "RC" assert fiche["numero_fiscal"] == "690123456789" - relu = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json()[0] + relu = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0] assert relu["caracteristiques"]["surface"] == 148.0 assert relu["caracteristiques"]["dpe_classe"] == "C" @@ -94,7 +128,7 @@ def test_une_seconde_ecriture_met_a_jour_sans_dupliquer(api_client, immeuble_et_ f"/api/lots/{lot.id}/caracteristiques", json={**FICHE, "surface": 150.0} ) - lignes = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json() + lignes = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json() assert len(lignes) == 1 assert lignes[0]["caracteristiques"]["surface"] == 150.0 @@ -175,7 +209,7 @@ def test_une_re_extraction_ne_touche_pas_la_fiche( DatabaseService(db_session).save_document(data=sample_data, overwrite=True) - lignes = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json() + lignes = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").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 @@ -185,7 +219,7 @@ 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] + ligne = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0] assert ligne["nb_revenus"] == 1 # La depense de la fixture porte sur l'immeuble (lot_id NULL). La compter @@ -193,6 +227,21 @@ def test_compte_les_rattachements_du_lot(api_client, immeuble_et_lot): assert ligne["nb_depenses"] == 0 +def test_l_enregistrement_renvoie_les_memes_comptes_que_la_liste( + api_client, immeuble_et_lot +): + """Décrire un lot occupé ne doit pas le faire passer pour inutilisé. + + La réponse du PUT remplace la ligne dans le tableau : si elle rapporte zéro + revenu, le lot se pare d'un « inutilisé » que la liste dément au rechargement. + """ + _, lot = immeuble_et_lot + + ligne = api_client.put(f"/api/lots/{lot.id}/caracteristiques", json=FICHE).json() + + assert ligne["nb_revenus"] == 1 + + 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 @@ -200,6 +249,112 @@ def test_compte_les_depenses_propres_au_lot(api_client, immeuble_et_lot, db_sess depense.lot_id = lot.id db_session.commit() - ligne = api_client.get(f"/api/immeubles/{immeuble.id}/lots/referentiel").json()[0] + ligne = api_client.get(f"/api/lots/referentiel?immeuble_id={immeuble.id}").json()[0] assert ligne["nb_depenses"] == 1 + + +# ============================================================ +# Suppression des lots sans rattachement +# ============================================================ + + +def test_supprime_un_lot_inutilise(api_client, immeuble_et_lot, db_session): + """Les lots d'un ancien format de numerotation doivent pouvoir disparaitre.""" + immeuble, _ = immeuble_et_lot + orphelin = Lot(immeuble_id=immeuble.id, numero="0001", type="Appartement T1") + db_session.add(orphelin) + db_session.commit() + + response = api_client.delete(f"/api/lots/{orphelin.id}") + + assert response.status_code == 204 + numeros = [ + ligne["numero"] + for ligne in api_client.get( + f"/api/lots/referentiel?immeuble_id={immeuble.id}" + ).json() + ] + assert numeros == ["01"] + + +def test_supprimer_un_lot_emporte_sa_fiche(api_client, immeuble_et_lot, db_session): + """Sans cascade, la fiche resterait en base sans lot pour la porter.""" + immeuble, _ = immeuble_et_lot + orphelin = Lot(immeuble_id=immeuble.id, numero="0001") + db_session.add(orphelin) + db_session.commit() + api_client.put(f"/api/lots/{orphelin.id}/caracteristiques", json=FICHE) + + api_client.delete(f"/api/lots/{orphelin.id}") + + assert db_session.query(LotCaracteristiques).count() == 0 + + +def test_refuse_de_supprimer_un_lot_avec_des_revenus(api_client, immeuble_et_lot): + """Le refus protege des montants : les perdre fausserait les comptes.""" + _, lot = immeuble_et_lot + + response = api_client.delete(f"/api/lots/{lot.id}") + + assert response.status_code == 409 + assert "revenu" in response.json()["detail"] + + +def test_refuse_de_supprimer_un_lot_avec_des_depenses( + api_client, immeuble_et_lot, db_session +): + """Une depense seule suffit a retenir le lot, meme sans aucun revenu.""" + immeuble, _ = immeuble_et_lot + lot_charge = Lot(immeuble_id=immeuble.id, numero="0002") + db_session.add(lot_charge) + db_session.commit() + depense = db_session.query(Depense).one() + depense.lot_id = lot_charge.id + db_session.commit() + + response = api_client.delete(f"/api/lots/{lot_charge.id}") + + assert response.status_code == 409 + + +def test_supprimer_un_lot_inconnu_donne_404(api_client): + assert api_client.delete("/api/lots/99999").status_code == 404 + + +# ============================================================ +# Nom d'usage de l'immeuble +# ============================================================ + + +def test_nomme_un_immeuble(api_client, immeuble_et_lot): + immeuble, _ = immeuble_et_lot + + response = api_client.put( + f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"} + ) + + assert response.status_code == 200 + assert response.json()["denomination"] == "Servient" + assert api_client.get("/api/immeubles").json()[0]["denomination"] == "Servient" + # La reponse remplace l'immeuble dans les listes du client : des compteurs a + # zero le feraient passer pour un immeuble sans lot. + assert response.json()["nb_lots"] == 1 + + +def test_effacer_le_nom_rend_l_immeuble_a_son_code(api_client, immeuble_et_lot): + immeuble, _ = immeuble_et_lot + api_client.put(f"/api/immeubles/{immeuble.id}", json={"denomination": "Servient"}) + + response = api_client.put( + f"/api/immeubles/{immeuble.id}", json={"denomination": " "} + ) + + assert response.json()["denomination"] is None + assert response.json()["code"] == "IMM1" + + +def test_nommer_un_immeuble_inconnu_donne_404(api_client): + response = api_client.put("/api/immeubles/99999", json={"denomination": "Servient"}) + + assert response.status_code == 404