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>
528 lines
18 KiB
Python
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)]
|