"""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)]