feat: étend le référentiel au parc entier et à la suppression des lots

Trois manques que la saisie a fait apparaître :

Le tableau porte une colonne immeuble, la liste ne peut donc plus être
suspendue à un immeuble choisi d'avance : elle renvoie tout le parc, chaque
ligne emportant de quoi nommer son immeuble sans requête de plus.

Un lot qu'aucune ligne de compte rendu ne mentionne ne décrit rien : il
encombre la saisie et doit pouvoir disparaître. La garde est côté serveur —
un lot porteur de revenus ou de dépenses est refusé, ses montants
partiraient avec lui.

Le nom d'usage s'enregistre, et la réponse recalcule les compteurs de
l'immeuble plutôt que de les laisser à zéro : elle remplace l'immeuble dans
les listes du client, qui le croirait vide de lots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 15:41:24 +02:00
parent 9f5f46a93a
commit fdffe27f06
2 changed files with 290 additions and 35 deletions

View File

@@ -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,
)