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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
512
src/plesna_gerance/api/routes/revenus.py
Normal file
512
src/plesna_gerance/api/routes/revenus.py
Normal 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
|
||||
Reference in New Issue
Block a user