feat: add rental income dashboard with comprehensive analytics

- Add new /api/revenus endpoints for rental income data
  - GET /summary: KPIs, monthly trends, breakdown by property, top unpaid
  - GET /details: detailed rental records with filtering
  - GET /by-lot: revenue aggregation by unit
  - GET /immeubles: properties list with revenue stats

- Add RevenusPage with full dashboard layout
  - 6 KPI cards: total revenue, payments, unpaid, collection rate, active tenants, occupied units
  - Monthly evolution chart (rent, payments, unpaid)
  - Property breakdown donut chart
  - Top unpaid tenants list
  - Detailed revenue table with pagination and CSV export

- Add filters panel (property, type, dates, unpaid only, history months)
- Display property address instead of code in all components
- Add navigation link in header (Revenus)
This commit is contained in:
2026-01-20 10:05:57 +01:00
parent 281f69f39e
commit 981fabcc27
12 changed files with 1578 additions and 1 deletions

View File

@@ -14,6 +14,7 @@ from .routes import (
tags_router,
analytics_router,
dashboard_router,
revenus_router,
)
app = FastAPI(
@@ -39,6 +40,7 @@ app.include_router(documents_router)
app.include_router(tags_router)
app.include_router(analytics_router)
app.include_router(dashboard_router)
app.include_router(revenus_router)
# Health check endpoints (keep in main app)

View File

@@ -5,6 +5,7 @@ from .documents import router as documents_router
from .tags import router as tags_router
from .analytics import router as analytics_router
from .dashboard import router as dashboard_router
from .revenus import router as revenus_router
__all__ = [
"extraction_router",
@@ -12,4 +13,5 @@ __all__ = [
"tags_router",
"analytics_router",
"dashboard_router",
"revenus_router",
]

View File

@@ -0,0 +1,512 @@
"""Revenus routes - Dedicated endpoints for rental income analytics."""
from datetime import date, timedelta
from collections import defaultdict
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from sqlalchemy import select, func, desc, and_
from pydantic import BaseModel
from ...database import get_session
from ...database.models import (
Document,
Immeuble,
Lot,
Locataire,
Revenu,
)
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_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]
# ============================================================
# 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.
"""
# Base filters
filters = []
if immeuble_id:
filters.append(Lot.immeuble_id == immeuble_id)
# Calculate date range
today = date.today()
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
# ========== KPIs ==========
kpi_stmt = select(
func.sum(Revenu.total).label("total_revenus"),
func.sum(Revenu.loyers).label("total_loyers"),
func.sum(Revenu.taxes).label("total_taxes"),
func.sum(Revenu.provisions).label("total_provisions"),
func.sum(Revenu.regles).label("total_regles"),
func.sum(Revenu.impayes).label("total_impayes"),
).join(Lot, Revenu.lot_id == Lot.id)
if filters:
kpi_stmt = kpi_stmt.where(and_(*filters))
kpi_result = session.execute(kpi_stmt).first()
total_revenus = kpi_result.total_revenus or 0.0
total_regles = kpi_result.total_regles or 0.0
taux_recouvrement = (
(total_regles / total_revenus * 100) if total_revenus > 0 else 100.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=total_revenus,
total_loyers=kpi_result.total_loyers or 0.0,
total_taxes=kpi_result.total_taxes or 0.0,
total_provisions=kpi_result.total_provisions or 0.0,
total_regles=total_regles,
total_impayes=kpi_result.total_impayes or 0.0,
taux_recouvrement=round(taux_recouvrement, 1),
nb_locataires_actifs=nb_locataires,
nb_lots_occupes=nb_lots,
)
# ========== Monthly evolution ==========
monthly_stmt = (
select(
func.strftime("%Y-%m", Document.date).label("month"),
func.sum(Revenu.loyers).label("loyers"),
func.sum(Revenu.taxes).label("taxes"),
func.sum(Revenu.provisions).label("provisions"),
func.sum(Revenu.total).label("total"),
func.sum(Revenu.regles).label("regles"),
func.sum(Revenu.impayes).label("impayes"),
)
.join(Document, Revenu.document_id == Document.id)
.join(Lot, Revenu.lot_id == Lot.id)
.where(Document.date >= start_date)
.group_by(func.strftime("%Y-%m", Document.date))
.order_by(func.strftime("%Y-%m", Document.date))
)
if immeuble_id:
monthly_stmt = monthly_stmt.where(Lot.immeuble_id == immeuble_id)
monthly_data = []
for row in session.execute(monthly_stmt):
monthly_data.append(
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,
)
)
# ========== By Immeuble ==========
immeuble_stmt = (
select(
Immeuble.id,
Immeuble.code,
Immeuble.adresse,
Immeuble.ville,
func.count(func.distinct(Lot.id)).label("nb_lots"),
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
func.sum(Revenu.total).label("total_revenus"),
func.sum(Revenu.regles).label("total_regles"),
func.sum(Revenu.impayes).label("total_impayes"),
)
.join(Lot, Lot.immeuble_id == Immeuble.id)
.join(Revenu, Revenu.lot_id == Lot.id)
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
.group_by(Immeuble.id)
.order_by(desc("total_revenus"))
)
if immeuble_id:
immeuble_stmt = immeuble_stmt.where(Immeuble.id == immeuble_id)
by_immeuble = []
for row in session.execute(immeuble_stmt):
rev = row.total_revenus or 0.0
reg = row.total_regles or 0.0
taux = (reg / rev * 100) if rev > 0 else 100.0
by_immeuble.append(
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=rev,
total_regles=reg,
total_impayes=row.total_impayes or 0.0,
taux_recouvrement=round(taux, 1),
)
)
# ========== Top impayes by locataire ==========
impayes_stmt = (
select(
Locataire.id,
Locataire.nom,
Locataire.date_debut,
Lot.numero,
Immeuble.code,
Immeuble.adresse,
func.sum(Revenu.total).label("total_revenus"),
func.sum(Revenu.regles).label("total_regles"),
func.sum(Revenu.impayes).label("total_impayes"),
func.count(Revenu.id).label("nb_mois"),
)
.join(Lot, Revenu.lot_id == Lot.id)
.join(Locataire, Revenu.locataire_id == Locataire.id)
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
.group_by(Locataire.id)
.having(func.sum(Revenu.impayes) > 0)
.order_by(desc("total_impayes"))
.limit(10)
)
if immeuble_id:
impayes_stmt = impayes_stmt.where(Lot.immeuble_id == immeuble_id)
top_impayes = []
for row in session.execute(impayes_stmt):
top_impayes.append(
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.total_revenus or 0.0,
total_regles=row.total_regles or 0.0,
total_impayes=row.total_impayes or 0.0,
nb_mois=row.nb_mois or 0,
)
)
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."""
stmt = (
select(
Lot.id,
Lot.numero,
Lot.type,
Immeuble.code,
func.max(Locataire.nom).label("locataire_nom"),
func.sum(Revenu.total).label("total_revenus"),
func.sum(Revenu.regles).label("total_regles"),
func.sum(Revenu.impayes).label("total_impayes"),
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)),
)
.group_by(Lot.id)
.order_by(desc("total_impayes"), desc("total_revenus"))
.limit(limit)
)
if immeuble_id:
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
results = []
for row in session.execute(stmt):
results.append(
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.total_revenus or 0.0,
total_regles=row.total_regles or 0.0,
total_impayes=row.total_impayes or 0.0,
derniere_date=str(row.derniere_date) if row.derniere_date else None,
)
)
return results
@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_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."""
stmt = (
select(
Immeuble.id,
Immeuble.code,
Immeuble.adresse,
Immeuble.ville,
func.count(func.distinct(Lot.id)).label("nb_lots"),
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
func.sum(Revenu.total).label("total_revenus"),
func.sum(Revenu.regles).label("total_regles"),
func.sum(Revenu.impayes).label("total_impayes"),
)
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
.outerjoin(Revenu, Revenu.lot_id == Lot.id)
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
.group_by(Immeuble.id)
.order_by(Immeuble.code)
)
results = []
for row in session.execute(stmt):
rev = row.total_revenus or 0.0
reg = row.total_regles or 0.0
taux = (reg / rev * 100) if rev > 0 else 100.0
results.append(
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=rev,
total_regles=reg,
total_impayes=row.total_impayes or 0.0,
taux_recouvrement=round(taux, 1),
)
)
return results