Files
pdf_oralia_vibe/src/plesna_gerance/api/routes/revenus.py
Bertrand Benjamin 5a08b0c7e5 feat: décrit les logements dans un référentiel saisi à la main
Les lots n'étaient connus que par l'extraction PDF : un numéro, un type
souvent vide, et rien sur le bien lui-même. Cette table de caractéristiques
(surface, étage, bâtiment, chauffage, DPE, rapprochement impôts) donne au
référentiel une source de vérité indépendante des comptes rendus.

Table séparée de `lots` à dessein : une ré-extraction ne peut alors pas
écraser la saisie, et le désaccord sur le type de lot reste visible au lieu
d'être arbitré en silence. La fiche gagne, le PDF comble les trous.

Échéance du DPE et écart de surface ne sont pas stockés mais calculés : une
colonne dérivée finirait par mentir après une correction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 18:00:42 +02:00

528 lines
18 KiB
Python

"""Revenus routes - Dedicated endpoints for rental income analytics."""
from datetime import date, timedelta
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import and_, desc, func, select
from sqlalchemy.orm import Session
from ...database import get_session
from ...database.models import (
Document,
Immeuble,
Locataire,
Lot,
Revenu,
)
from ...services.referentiel import TYPE_LOT_EFFECTIF, joindre_fiche
from ...services.revenus_query import (
est_flux,
flux_par,
restant_du_par,
taux_de_recouvrement,
)
router = APIRouter(prefix="/api/revenus", tags=["revenus"])
# ============================================================
# Response models
# ============================================================
class RevenuKpiResponse(BaseModel):
"""KPIs globaux des revenus locatifs."""
total_revenus: float
total_loyers: float
total_taxes: float
total_provisions: float
total_regles: float
total_impayes: float
taux_recouvrement: float # Pourcentage regles/total
nb_locataires_actifs: int
nb_lots_occupes: int
class RevenuMonthlyPoint(BaseModel):
"""Point mensuel pour graphiques."""
month: str # "2024-01"
loyers: float
taxes: float
provisions: float
total: float
regles: float
impayes: float
class RevenuByImmeuble(BaseModel):
"""Revenus agreges par immeuble."""
immeuble_id: int
immeuble_code: str
adresse: str | None
ville: str | None
nb_lots: int
nb_locataires: int
total_revenus: float
total_regles: float
total_impayes: float
taux_recouvrement: float
class RevenuByLot(BaseModel):
"""Revenus agreges par lot."""
lot_id: int
lot_numero: str
lot_type: str | None
immeuble_code: str
locataire_nom: str | None
total_revenus: float
total_regles: float
total_impayes: float
derniere_date: str | None
class RevenuByLocataire(BaseModel):
"""Revenus agreges par locataire."""
locataire_id: int
locataire_nom: str
lot_numero: str
immeuble_code: str
immeuble_adresse: str | None
date_debut: str | None
total_revenus: float
total_regles: float
total_impayes: float
nb_mois: int
class RevenuDetailResponse(BaseModel):
"""Detail d'un revenu."""
id: int
document_id: int
document_date: str
document_reference: str | None
immeuble_code: str
immeuble_adresse: str | None
lot_numero: str
locataire_nom: str
type_ligne: str
periode_debut: str | None
periode_fin: str | None
loyers: float
taxes: float
provisions: float
divers_montant: float
divers_libelle: str | None
total: float
regles: float
impayes: float
class RevenusSummaryResponse(BaseModel):
"""Resume complet des revenus pour le dashboard."""
kpis: RevenuKpiResponse
by_month: list[RevenuMonthlyPoint]
by_immeuble: list[RevenuByImmeuble]
top_impayes: list[RevenuByLocataire]
# ============================================================
# Agregats par immeuble
# ============================================================
def _effectifs_par_immeuble():
"""Sous-requete : nombre de lots et de locataires par immeuble."""
return (
select(
Lot.immeuble_id.label("immeuble_id"),
func.count(func.distinct(Lot.id)).label("nb_lots"),
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
)
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
.group_by(Lot.immeuble_id)
.subquery()
)
def _immeuble_response(row) -> RevenuByImmeuble:
"""Construit la reponse d'un immeuble a partir d'une ligne agregee."""
return RevenuByImmeuble(
immeuble_id=row.id,
immeuble_code=row.code,
adresse=row.adresse,
ville=row.ville,
nb_lots=row.nb_lots or 0,
nb_locataires=row.nb_locataires or 0,
total_revenus=row.facture or 0.0,
total_regles=row.encaisse or 0.0,
total_impayes=row.restant_du or 0.0,
taux_recouvrement=taux_de_recouvrement(row.facture, row.facture_regle),
)
def _immeuble_stmt(date_debut: date | None = None):
"""Requete des immeubles avec leurs flux, leur restant du et leurs effectifs.
Les trois sous-requetes sont jointes plutot que calculees d'un bloc : chacune
a sa propre granularite (une ligne par revenu, par lot, par locataire) et les
melanger multiplierait les lignes entre elles.
"""
flux = flux_par(Document.immeuble_id, date_debut)
stock = restant_du_par(Document.immeuble_id, date_debut)
effectifs = _effectifs_par_immeuble()
return (
select(
Immeuble.id,
Immeuble.code,
Immeuble.adresse,
Immeuble.ville,
effectifs.c.nb_lots,
effectifs.c.nb_locataires,
flux.c.facture,
flux.c.encaisse,
flux.c.facture_regle,
stock.c.restant_du,
)
.outerjoin(effectifs, effectifs.c.immeuble_id == Immeuble.id)
.outerjoin(flux, flux.c.cle == Immeuble.id)
.outerjoin(stock, stock.c.cle == Immeuble.id)
)
# ============================================================
# Endpoints
# ============================================================
@router.get("/summary", response_model=RevenusSummaryResponse)
async def get_revenus_summary(
months: int = Query(12, description="Nombre de mois d'historique"),
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
session: Session = Depends(get_session),
) -> RevenusSummaryResponse:
"""Retourne un resume complet des revenus locatifs pour le dashboard.
Inclut les KPIs, l'evolution mensuelle, la repartition par immeuble
et les locataires avec le plus d'impayes.
"""
# Calculate date range
today = date.today()
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
# ========== KPIs ==========
# Les montants factures se cumulent sur la periode ; le restant du est lu
# dans le dernier compte rendu, sans quoi une meme dette serait recomptee a
# chaque document et un remboursement ne s'y verrait jamais.
flux = flux_par(Document.immeuble_id, start_date)
stock = restant_du_par(Document.immeuble_id, start_date)
flux_stmt = select(
func.sum(flux.c.loyers).label("loyers"),
func.sum(flux.c.taxes).label("taxes"),
func.sum(flux.c.provisions).label("provisions"),
func.sum(flux.c.facture).label("facture"),
func.sum(flux.c.encaisse).label("encaisse"),
func.sum(flux.c.facture_regle).label("facture_regle"),
)
stock_stmt = select(func.sum(stock.c.restant_du))
if immeuble_id:
flux_stmt = flux_stmt.where(flux.c.cle == immeuble_id)
stock_stmt = stock_stmt.where(stock.c.cle == immeuble_id)
totaux = session.execute(flux_stmt).first()
restant_du = session.execute(stock_stmt).scalar() or 0.0
# Count active locataires and occupied lots
locataires_stmt = (
select(func.count(func.distinct(Locataire.id)))
.join(Lot, Locataire.lot_id == Lot.id)
.where(Locataire.date_fin.is_(None))
)
if immeuble_id:
locataires_stmt = locataires_stmt.where(Lot.immeuble_id == immeuble_id)
nb_locataires = session.execute(locataires_stmt).scalar() or 0
lots_stmt = (
select(func.count(func.distinct(Revenu.lot_id)))
.join(Lot, Revenu.lot_id == Lot.id)
.join(Document, Revenu.document_id == Document.id)
.where(Document.date >= start_date)
)
if immeuble_id:
lots_stmt = lots_stmt.where(Lot.immeuble_id == immeuble_id)
nb_lots = session.execute(lots_stmt).scalar() or 0
kpis = RevenuKpiResponse(
total_revenus=totaux.facture or 0.0,
total_loyers=totaux.loyers or 0.0,
total_taxes=totaux.taxes or 0.0,
total_provisions=totaux.provisions or 0.0,
total_regles=totaux.encaisse or 0.0,
total_impayes=restant_du,
taux_recouvrement=taux_de_recouvrement(totaux.facture, totaux.facture_regle),
nb_locataires_actifs=nb_locataires,
nb_lots_occupes=nb_lots,
)
# ========== Monthly evolution ==========
# Les montants du mois sont des flux, report exclu, pour que deux mois
# soient comparables. L'impaye, lui, reste le solde constate ce mois-la :
# la courbe montre l'evolution de la dette, pas son accumulation.
mois = func.strftime("%Y-%m", Document.date)
flux_mois = est_flux()
monthly_stmt = (
select(
mois.label("month"),
func.sum(Revenu.loyers).filter(flux_mois).label("loyers"),
func.sum(Revenu.taxes).filter(flux_mois).label("taxes"),
func.sum(Revenu.provisions).filter(flux_mois).label("provisions"),
func.sum(Revenu.total).filter(flux_mois).label("total"),
func.sum(Revenu.regles).label("regles"),
func.sum(Revenu.impayes).label("impayes"),
)
.join(Document, Revenu.document_id == Document.id)
.where(Document.date >= start_date)
.group_by(mois)
.order_by(mois)
)
if immeuble_id:
monthly_stmt = monthly_stmt.where(Document.immeuble_id == immeuble_id)
monthly_data = [
RevenuMonthlyPoint(
month=row.month,
loyers=row.loyers or 0.0,
taxes=row.taxes or 0.0,
provisions=row.provisions or 0.0,
total=row.total or 0.0,
regles=row.regles or 0.0,
impayes=row.impayes or 0.0,
)
for row in session.execute(monthly_stmt)
]
# ========== By Immeuble ==========
# Seuls les immeubles ayant produit des revenus sur la periode y figurent.
immeuble_stmt = _immeuble_stmt(start_date).order_by(desc("facture"))
if immeuble_id:
immeuble_stmt = immeuble_stmt.where(Immeuble.id == immeuble_id)
by_immeuble = [
_immeuble_response(row)
for row in session.execute(immeuble_stmt)
if row.facture is not None
]
# ========== Top impayes by locataire ==========
# Le classement porte sur la dette encore due au dernier compte rendu. Sur
# un cumul, un locataire ayant solde son retard resterait affiche comme
# debiteur indefiniment : le remboursement ne s'y inscrivait jamais.
dette = restant_du_par(Revenu.locataire_id, start_date)
flux_locataire = flux_par(Revenu.locataire_id, start_date)
impayes_stmt = (
select(
Locataire.id,
Locataire.nom,
Locataire.date_debut,
Lot.numero,
Immeuble.code,
Immeuble.adresse,
flux_locataire.c.facture,
flux_locataire.c.encaisse,
dette.c.restant_du,
func.count(func.distinct(Revenu.document_id)).label("nb_mois"),
)
.select_from(Locataire)
.join(dette, dette.c.cle == Locataire.id)
.join(Lot, Locataire.lot_id == Lot.id)
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
.outerjoin(flux_locataire, flux_locataire.c.cle == Locataire.id)
.outerjoin(Revenu, Revenu.locataire_id == Locataire.id)
.where(dette.c.restant_du > 0)
.group_by(Locataire.id)
.order_by(desc("restant_du"))
.limit(10)
)
if immeuble_id:
impayes_stmt = impayes_stmt.where(Lot.immeuble_id == immeuble_id)
top_impayes = [
RevenuByLocataire(
locataire_id=row.id,
locataire_nom=row.nom,
lot_numero=row.numero,
immeuble_code=row.code,
immeuble_adresse=row.adresse,
date_debut=str(row.date_debut) if row.date_debut else None,
total_revenus=row.facture or 0.0,
total_regles=row.encaisse or 0.0,
total_impayes=row.restant_du or 0.0,
nb_mois=row.nb_mois or 0,
)
for row in session.execute(impayes_stmt)
]
return RevenusSummaryResponse(
kpis=kpis,
by_month=monthly_data,
by_immeuble=by_immeuble,
top_impayes=top_impayes,
)
@router.get("/by-lot", response_model=list[RevenuByLot])
async def get_revenus_by_lot(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
limit: int = Query(50, description="Limite de resultats"),
session: Session = Depends(get_session),
) -> list[RevenuByLot]:
"""Retourne les revenus agreges par lot."""
flux = flux_par(Revenu.lot_id)
dette = restant_du_par(Revenu.lot_id)
stmt = (
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)
)
.group_by(Lot.id)
.order_by(desc("restant_du"), desc("facture"))
.limit(limit)
)
if immeuble_id:
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
return [
RevenuByLot(
lot_id=row.id,
lot_numero=row.numero,
lot_type=row.type,
immeuble_code=row.code,
locataire_nom=row.locataire_nom,
total_revenus=row.facture or 0.0,
total_regles=row.encaisse or 0.0,
total_impayes=row.restant_du or 0.0,
derniere_date=str(row.derniere_date) if row.derniere_date else None,
)
for row in session.execute(stmt)
]
@router.get("/details", response_model=list[RevenuDetailResponse])
async def get_revenus_details(
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
lot_id: int | None = Query(None, description="Filtrer par lot"),
locataire_id: int | None = Query(None, description="Filtrer par locataire"),
type_ligne: str | None = Query(None, description="Filtrer par type de ligne"),
date_debut: date | None = Query(None, description="Date de debut"),
date_fin: date | None = Query(None, description="Date de fin"),
impayes_only: bool = Query(False, description="Uniquement les impayes"),
limit: int = Query(100, description="Limite de resultats"),
offset: int = Query(0, description="Offset pour pagination"),
session: Session = Depends(get_session),
) -> list[RevenuDetailResponse]:
"""Retourne la liste detaillee des revenus avec filtres."""
stmt = (
select(
Revenu,
Document.date.label("document_date"),
Document.reference.label("document_reference"),
Immeuble.code.label("immeuble_code"),
Immeuble.adresse.label("immeuble_adresse"),
Lot.numero.label("lot_numero"),
Locataire.nom.label("locataire_nom"),
)
.join(Document, Revenu.document_id == Document.id)
.join(Lot, Revenu.lot_id == Lot.id)
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
.join(Locataire, Revenu.locataire_id == Locataire.id)
.order_by(desc(Document.date), desc(Revenu.id))
)
# Apply filters
if immeuble_id:
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
if lot_id:
stmt = stmt.where(Revenu.lot_id == lot_id)
if locataire_id:
stmt = stmt.where(Revenu.locataire_id == locataire_id)
if type_ligne:
stmt = stmt.where(Revenu.type_ligne == type_ligne)
if date_debut:
stmt = stmt.where(Document.date >= date_debut)
if date_fin:
stmt = stmt.where(Document.date <= date_fin)
if impayes_only:
stmt = stmt.where(Revenu.impayes > 0)
stmt = stmt.limit(limit).offset(offset)
results = []
for row in session.execute(stmt):
rev = row.Revenu
results.append(
RevenuDetailResponse(
id=rev.id,
document_id=rev.document_id,
document_date=str(row.document_date),
document_reference=row.document_reference,
immeuble_code=row.immeuble_code,
immeuble_adresse=row.immeuble_adresse,
lot_numero=row.lot_numero,
locataire_nom=row.locataire_nom,
type_ligne=rev.type_ligne or "",
periode_debut=str(rev.periode_debut) if rev.periode_debut else None,
periode_fin=str(rev.periode_fin) if rev.periode_fin else None,
loyers=rev.loyers or 0.0,
taxes=rev.taxes or 0.0,
provisions=rev.provisions or 0.0,
divers_montant=rev.divers_montant or 0.0,
divers_libelle=rev.divers_libelle,
total=rev.total or 0.0,
regles=rev.regles or 0.0,
impayes=rev.impayes or 0.0,
)
)
return results
@router.get("/immeubles", response_model=list[RevenuByImmeuble])
async def get_immeubles_with_revenus(
session: Session = Depends(get_session),
) -> list[RevenuByImmeuble]:
"""Retourne la liste des immeubles avec leurs stats de revenus."""
# Tous les immeubles sont listes, y compris ceux sans aucun revenu.
stmt = _immeuble_stmt().order_by(Immeuble.code)
return [_immeuble_response(row) for row in session.execute(stmt)]