fix: distingue les montants cumulables des soldes à date
Chaque compte rendu reporte la dette du précédent dans une ligne solde_anterieur. Les agrégats sommaient ces lignes comme le reste : la même créance était recomptée à chaque document, et un remboursement ne pouvait jamais s'inscrire — une dette soldée restait affichée à vie. Sur la base réelle, la page Revenus annonçait ainsi 247 354 € d'impayés pour une dette de 49 374 €, et désignait comme deuxième et troisième débiteurs deux locataires à jour depuis avril (SURBECK 690,10 € et GUINAIS 445,81 €, tous deux soldés). Deux natures cohabitent, et c'est la colonne qui la porte, pas la ligne : une ligne de report a un `total` déjà compté le mois d'avant, mais ses `regles` sont un encaissement bien réel de la période. Écarter la ligne entière ferait disparaître de l'argent reçu (1 298,81 € ici). - flux (facturé, encaissé) : cumulés sur la période, report exclu ; - stock (restant dû) : lu dans le dernier compte rendu de chaque immeuble ; - taux de recouvrement : réglé sur facturé, report exclu des deux côtés, sans quoi rattraper une vieille dette ferait dépasser 100 %. Résultat : 85 748 € facturés, 98,2 % de recouvrement, 49 374 € encore dus. Les règles vivent dans services/revenus_query.py, pour que le dashboard s'y branche au lieu de les réinventer. /summary borne désormais tous ses blocs à la période demandée : les KPIs et by_immeuble ignoraient `months` alors que by_month le respectait. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,12 @@ from ...database.models import (
|
||||
Lot,
|
||||
Revenu,
|
||||
)
|
||||
from ...services.revenus_query import (
|
||||
est_flux,
|
||||
flux_par,
|
||||
restant_du_par,
|
||||
taux_de_recouvrement,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/revenus", tags=["revenus"])
|
||||
|
||||
@@ -132,27 +138,6 @@ class RevenusSummaryResponse(BaseModel):
|
||||
# ============================================================
|
||||
|
||||
|
||||
def _revenus_par_immeuble():
|
||||
"""Sous-requete : montants des revenus agreges par immeuble.
|
||||
|
||||
Agreger AVANT de joindre les locataires est ce qui garantit la justesse des
|
||||
totaux : un lot relouee plusieurs fois a plusieurs locataires, et une
|
||||
jointure directe revenus x locataires compterait chaque revenu autant de
|
||||
fois qu'il y a eu d'occupants.
|
||||
"""
|
||||
return (
|
||||
select(
|
||||
Lot.immeuble_id.label("immeuble_id"),
|
||||
func.sum(Revenu.total).label("total_revenus"),
|
||||
func.sum(Revenu.regles).label("total_regles"),
|
||||
func.sum(Revenu.impayes).label("total_impayes"),
|
||||
)
|
||||
.join(Revenu, Revenu.lot_id == Lot.id)
|
||||
.group_by(Lot.immeuble_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
|
||||
def _effectifs_par_immeuble():
|
||||
"""Sous-requete : nombre de lots et de locataires par immeuble."""
|
||||
return (
|
||||
@@ -169,9 +154,6 @@ def _effectifs_par_immeuble():
|
||||
|
||||
def _immeuble_response(row) -> RevenuByImmeuble:
|
||||
"""Construit la reponse d'un immeuble a partir d'une ligne agregee."""
|
||||
revenus = row.total_revenus or 0.0
|
||||
regles = row.total_regles or 0.0
|
||||
taux = (regles / revenus * 100) if revenus > 0 else 100.0
|
||||
return RevenuByImmeuble(
|
||||
immeuble_id=row.id,
|
||||
immeuble_code=row.code,
|
||||
@@ -179,10 +161,39 @@ def _immeuble_response(row) -> RevenuByImmeuble:
|
||||
ville=row.ville,
|
||||
nb_lots=row.nb_lots or 0,
|
||||
nb_locataires=row.nb_locataires or 0,
|
||||
total_revenus=revenus,
|
||||
total_regles=regles,
|
||||
total_impayes=row.total_impayes or 0.0,
|
||||
taux_recouvrement=round(taux, 1),
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
@@ -202,35 +213,32 @@ async def get_revenus_summary(
|
||||
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)
|
||||
# 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)
|
||||
|
||||
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
|
||||
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 = (
|
||||
@@ -253,79 +261,73 @@ async def get_revenus_summary(
|
||||
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),
|
||||
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(
|
||||
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"),
|
||||
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)
|
||||
.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))
|
||||
.group_by(mois)
|
||||
.order_by(mois)
|
||||
)
|
||||
|
||||
if immeuble_id:
|
||||
monthly_stmt = monthly_stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
monthly_stmt = monthly_stmt.where(Document.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,
|
||||
)
|
||||
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 portant des revenus apparaissent dans ce resume.
|
||||
montants = _revenus_par_immeuble()
|
||||
effectifs = _effectifs_par_immeuble()
|
||||
immeuble_stmt = (
|
||||
select(
|
||||
Immeuble.id,
|
||||
Immeuble.code,
|
||||
Immeuble.adresse,
|
||||
Immeuble.ville,
|
||||
effectifs.c.nb_lots,
|
||||
effectifs.c.nb_locataires,
|
||||
montants.c.total_revenus,
|
||||
montants.c.total_regles,
|
||||
montants.c.total_impayes,
|
||||
)
|
||||
.join(montants, montants.c.immeuble_id == Immeuble.id)
|
||||
.outerjoin(effectifs, effectifs.c.immeuble_id == Immeuble.id)
|
||||
.order_by(desc(montants.c.total_revenus))
|
||||
)
|
||||
|
||||
# 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)]
|
||||
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,
|
||||
@@ -334,39 +336,41 @@ async def get_revenus_summary(
|
||||
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"),
|
||||
flux_locataire.c.facture,
|
||||
flux_locataire.c.encaisse,
|
||||
dette.c.restant_du,
|
||||
func.count(func.distinct(Revenu.document_id)).label("nb_mois"),
|
||||
)
|
||||
.join(Lot, Revenu.lot_id == Lot.id)
|
||||
.join(Locataire, Revenu.locataire_id == Locataire.id)
|
||||
.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)
|
||||
.having(func.sum(Revenu.impayes) > 0)
|
||||
.order_by(desc("total_impayes"))
|
||||
.order_by(desc("restant_du"))
|
||||
.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,
|
||||
)
|
||||
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,
|
||||
@@ -383,6 +387,8 @@ async def get_revenus_by_lot(
|
||||
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 = (
|
||||
select(
|
||||
Lot.id,
|
||||
@@ -390,9 +396,9 @@ async def get_revenus_by_lot(
|
||||
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"),
|
||||
flux.c.facture,
|
||||
flux.c.encaisse,
|
||||
dette.c.restant_du,
|
||||
func.max(Document.date).label("derniere_date"),
|
||||
)
|
||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
@@ -402,31 +408,30 @@ async def get_revenus_by_lot(
|
||||
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("total_impayes"), desc("total_revenus"))
|
||||
.order_by(desc("restant_du"), desc("facture"))
|
||||
.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 [
|
||||
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,
|
||||
)
|
||||
|
||||
return results
|
||||
for row in session.execute(stmt)
|
||||
]
|
||||
|
||||
|
||||
@router.get("/details", response_model=list[RevenuDetailResponse])
|
||||
@@ -514,23 +519,6 @@ async def get_immeubles_with_revenus(
|
||||
) -> list[RevenuByImmeuble]:
|
||||
"""Retourne la liste des immeubles avec leurs stats de revenus."""
|
||||
# Tous les immeubles sont listes, y compris ceux sans aucun revenu.
|
||||
montants = _revenus_par_immeuble()
|
||||
effectifs = _effectifs_par_immeuble()
|
||||
stmt = (
|
||||
select(
|
||||
Immeuble.id,
|
||||
Immeuble.code,
|
||||
Immeuble.adresse,
|
||||
Immeuble.ville,
|
||||
effectifs.c.nb_lots,
|
||||
effectifs.c.nb_locataires,
|
||||
montants.c.total_revenus,
|
||||
montants.c.total_regles,
|
||||
montants.c.total_impayes,
|
||||
)
|
||||
.outerjoin(montants, montants.c.immeuble_id == Immeuble.id)
|
||||
.outerjoin(effectifs, effectifs.c.immeuble_id == Immeuble.id)
|
||||
.order_by(Immeuble.code)
|
||||
)
|
||||
stmt = _immeuble_stmt().order_by(Immeuble.code)
|
||||
|
||||
return [_immeuble_response(row) for row in session.execute(stmt)]
|
||||
|
||||
Reference in New Issue
Block a user