feat: rassemble sur un lot ce que les comptes rendus en disent
Les pages existantes agrègent le parc ; aucune ne descend à un lot pour
remettre ses lignes bout à bout. `/api/lots/{id}/analyse` renvoie son
identité (fiche saisie comprise, trous laissés visibles), ses totaux, sa
chronologie de recettes et de dépenses, et les entreprises intervenues.
Deux limites sont assumées plutôt que contournées :
- les dépenses d'un lot sont celles que le compte rendu lui impute. Aucune
clé de répartition n'existe en base — ni tantièmes, ni surfaces complètes
— donc les charges d'immeuble ne sont pas ventilées : elles sont exposées
à part, et le solde d'un lot n'est pas un résultat net ;
- les lignes sont rendues telles qu'extraites, sans regroupement ni
dédoublonnage. Un acompte et son solde restent deux lignes.
Les totaux passent par `flux_par` et `restant_du_par` au lieu de resommer
sur place : ces fonctions portent la distinction entre ce qui se cumule et
ce qui est une photo, et la rejouer à la main ferait diverger cette page de
la page Recettes.
Le montant d'un intervenant est net du crédit, comme chaque ligne de la
chronologie, pour que le détail d'une entreprise retrouve son total — un
avoir rend d'ailleurs ce montant négatif, ce que le compte rendu porte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ from .routes import (
|
||||
documents_router,
|
||||
extraction_router,
|
||||
ia_router,
|
||||
lot_analyse_router,
|
||||
referentiel_router,
|
||||
revenus_router,
|
||||
tags_router,
|
||||
@@ -54,6 +55,7 @@ app.include_router(analytics_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(revenus_router)
|
||||
app.include_router(referentiel_router)
|
||||
app.include_router(lot_analyse_router)
|
||||
if FEATURE_IA:
|
||||
app.include_router(ia_router)
|
||||
app.include_router(config_router)
|
||||
|
||||
@@ -6,6 +6,7 @@ from .dashboard import router as dashboard_router
|
||||
from .documents import router as documents_router
|
||||
from .extraction import router as extraction_router
|
||||
from .ia import router as ia_router
|
||||
from .lot_analyse import router as lot_analyse_router
|
||||
from .referentiel import router as referentiel_router
|
||||
from .revenus import router as revenus_router
|
||||
from .tags import router as tags_router
|
||||
@@ -18,6 +19,7 @@ __all__ = [
|
||||
"dashboard_router",
|
||||
"revenus_router",
|
||||
"referentiel_router",
|
||||
"lot_analyse_router",
|
||||
"ia_router",
|
||||
"config_router",
|
||||
]
|
||||
|
||||
363
src/plesna_gerance/api/routes/lot_analyse.py
Normal file
363
src/plesna_gerance/api/routes/lot_analyse.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Vue d'un lot : ce que les comptes rendus disent de lui, et rien de plus.
|
||||
|
||||
Les autres pages agrègent le parc ; celle-ci descend à un lot et remet ses
|
||||
lignes bout à bout — loyers facturés, règlements, interventions — dans l'ordre
|
||||
où les comptes rendus les ont portées.
|
||||
|
||||
Deux limites sont assumées plutôt que contournées :
|
||||
|
||||
- **les dépenses d'un lot sont celles que le compte rendu lui impute**, pas une
|
||||
quote-part des charges d'immeuble. Aucune clé de répartition n'existe en base
|
||||
(ni tantièmes, ni surfaces complètes) : en inventer une donnerait des montants
|
||||
qu'aucun document ne justifie. Le solde d'un lot n'est donc pas un résultat
|
||||
net, et `depenses_immeuble_non_reparties` rappelle ce qui reste dehors ;
|
||||
- **les lignes sont rendues telles qu'extraites**, sans regroupement ni
|
||||
dédoublonnage. Un acompte et son solde restent deux lignes, parce que le
|
||||
compte rendu les porte ainsi.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...database import get_session
|
||||
from ...database.models import (
|
||||
Depense,
|
||||
Document,
|
||||
Immeuble,
|
||||
Locataire,
|
||||
Lot,
|
||||
Revenu,
|
||||
Tag,
|
||||
)
|
||||
from ...services.referentiel import type_effectif
|
||||
from ...services.revenus_query import (
|
||||
TYPE_LIGNE_REPORT,
|
||||
flux_par,
|
||||
restant_du_par,
|
||||
taux_de_recouvrement,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["lots"])
|
||||
|
||||
|
||||
class LotIdentite(BaseModel):
|
||||
"""Qui est ce lot : son rattachement, et ce que sa fiche en dit."""
|
||||
|
||||
id: int
|
||||
numero: str
|
||||
immeuble_id: int
|
||||
immeuble_code: str | None
|
||||
immeuble_denomination: str | None
|
||||
type_effectif: str | None
|
||||
|
||||
# Fiche saisie : `null` tant qu'elle ne l'est pas. La page montre le trou
|
||||
# plutôt que de le combler.
|
||||
surface: float | None = None
|
||||
etage: str | None = None
|
||||
bat: str | None = None
|
||||
chauffage: str | None = None
|
||||
dpe_classe: str | None = None
|
||||
|
||||
#: Noms portés par les comptes rendus. Les dates d'entrée et de sortie ne
|
||||
#: sont pas extraites : l'ordre n'a pas de sens ici.
|
||||
locataires: list[str] = []
|
||||
|
||||
|
||||
class LotChiffres(BaseModel):
|
||||
"""Totaux du lot sur tout son historique."""
|
||||
|
||||
# Recettes. `facture` exclut les reports de solde, `encaisse` les inclut :
|
||||
# un règlement de vieille dette est bien un encaissement de la période.
|
||||
facture: float = 0.0
|
||||
encaisse: float = 0.0
|
||||
loyers: float = 0.0
|
||||
provisions: float = 0.0
|
||||
#: Dette du lot au dernier compte rendu de son immeuble (photo, non cumulée).
|
||||
restant_du: float = 0.0
|
||||
taux_recouvrement: float = 100.0
|
||||
|
||||
# Dépenses imputées au lot par le compte rendu.
|
||||
depenses_debit: float = 0.0
|
||||
depenses_credit: float = 0.0
|
||||
depenses_deductible: float = 0.0
|
||||
depenses_locatif: float = 0.0
|
||||
nb_operations: int = 0
|
||||
|
||||
#: Encaissé moins décaissé sur le lot. Pas un résultat : les charges
|
||||
#: d'immeuble n'y sont pas (voir le module).
|
||||
solde: float = 0.0
|
||||
|
||||
#: Charges de l'immeuble non imputées à un lot, sur toute la période.
|
||||
#: Affiché comme contexte, jamais ventilé.
|
||||
depenses_immeuble_non_reparties: float = 0.0
|
||||
|
||||
|
||||
class LigneChronologie(BaseModel):
|
||||
"""Une ligne de compte rendu concernant le lot, recette ou dépense."""
|
||||
|
||||
date: date
|
||||
document_id: int
|
||||
nature: str # "recette" | "depense"
|
||||
libelle: str
|
||||
categorie: str | None = None
|
||||
|
||||
# Dépense
|
||||
fournisseur: str | None = None
|
||||
tag: str | None = None
|
||||
|
||||
# Recette
|
||||
type_ligne: str | None = None
|
||||
periode_debut: date | None = None
|
||||
periode_fin: date | None = None
|
||||
regle: float | None = None
|
||||
impaye: float | None = None
|
||||
|
||||
#: Montant de la ligne : total facturé pour une recette, débit net de crédit
|
||||
#: pour une dépense.
|
||||
montant: float = 0.0
|
||||
#: Report du solde antérieur : déjà compté par un compte rendu précédent, il
|
||||
#: s'affiche mais n'entre dans aucun cumul.
|
||||
est_report: bool = False
|
||||
|
||||
|
||||
class Intervenant(BaseModel):
|
||||
"""Une entreprise intervenue sur le lot."""
|
||||
|
||||
fournisseur: str
|
||||
nb_interventions: int
|
||||
#: Somme des lignes du fournisseur, crédit déduit — le total que le détail
|
||||
#: déplié doit retrouver ligne à ligne.
|
||||
montant: float
|
||||
derniere_date: date | None = None
|
||||
|
||||
|
||||
class LotAnalyseResponse(BaseModel):
|
||||
"""Fiche complète d'un lot."""
|
||||
|
||||
identite: LotIdentite
|
||||
chiffres: LotChiffres
|
||||
chronologie: list[LigneChronologie]
|
||||
intervenants: list[Intervenant]
|
||||
|
||||
|
||||
def _identite(session: Session, lot: Lot) -> LotIdentite:
|
||||
"""Identité du lot, fiche saisie comprise quand elle existe."""
|
||||
immeuble = session.get(Immeuble, lot.immeuble_id)
|
||||
fiche = lot.caracteristiques
|
||||
|
||||
noms = session.execute(
|
||||
select(Locataire.nom).where(Locataire.lot_id == lot.id).order_by(Locataire.nom)
|
||||
).scalars()
|
||||
|
||||
return LotIdentite(
|
||||
id=lot.id,
|
||||
numero=lot.numero,
|
||||
immeuble_id=lot.immeuble_id,
|
||||
immeuble_code=immeuble.code if immeuble else None,
|
||||
immeuble_denomination=immeuble.denomination if immeuble else None,
|
||||
type_effectif=type_effectif(lot),
|
||||
surface=fiche.surface if fiche else None,
|
||||
etage=fiche.etage if fiche else None,
|
||||
bat=fiche.bat if fiche else None,
|
||||
chauffage=fiche.chauffage if fiche else None,
|
||||
dpe_classe=fiche.dpe_classe if fiche else None,
|
||||
locataires=list(noms),
|
||||
)
|
||||
|
||||
|
||||
def _chiffres(session: Session, lot: Lot) -> LotChiffres:
|
||||
"""Totaux du lot, en réutilisant les règles flux/stock des revenus.
|
||||
|
||||
Passer par `flux_par` et `restant_du_par` plutôt que de resommer ici : ces
|
||||
fonctions portent la distinction entre ce qui se cumule et ce qui est une
|
||||
photo, et la rejouer à la main la ferait diverger de la page Recettes.
|
||||
"""
|
||||
flux = flux_par(Revenu.lot_id)
|
||||
dette = restant_du_par(Revenu.lot_id)
|
||||
|
||||
recettes = session.execute(
|
||||
select(
|
||||
flux.c.facture,
|
||||
flux.c.encaisse,
|
||||
flux.c.facture_regle,
|
||||
flux.c.loyers,
|
||||
flux.c.provisions,
|
||||
).where(flux.c.cle == lot.id)
|
||||
).first()
|
||||
|
||||
restant_du = session.execute(
|
||||
select(dette.c.restant_du).where(dette.c.cle == lot.id)
|
||||
).scalar()
|
||||
|
||||
depenses = session.execute(
|
||||
select(
|
||||
func.coalesce(func.sum(Depense.debit), 0.0),
|
||||
func.coalesce(func.sum(Depense.credit), 0.0),
|
||||
func.coalesce(func.sum(Depense.deductible), 0.0),
|
||||
func.coalesce(func.sum(Depense.locatif), 0.0),
|
||||
func.count(Depense.id),
|
||||
).where(Depense.lot_id == lot.id)
|
||||
).one()
|
||||
debit, credit, deductible, locatif, nb_operations = depenses
|
||||
|
||||
# Charges de l'immeuble laissées hors des lots, pour situer le solde.
|
||||
commun = session.execute(
|
||||
select(func.coalesce(func.sum(Depense.debit), 0.0)).where(
|
||||
Depense.immeuble_id == lot.immeuble_id, Depense.lot_id.is_(None)
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
encaisse = (recettes.encaisse if recettes else 0.0) or 0.0
|
||||
|
||||
return LotChiffres(
|
||||
facture=(recettes.facture if recettes else 0.0) or 0.0,
|
||||
encaisse=encaisse,
|
||||
loyers=(recettes.loyers if recettes else 0.0) or 0.0,
|
||||
provisions=(recettes.provisions if recettes else 0.0) or 0.0,
|
||||
restant_du=restant_du or 0.0,
|
||||
taux_recouvrement=taux_de_recouvrement(
|
||||
recettes.facture if recettes else None,
|
||||
recettes.facture_regle if recettes else None,
|
||||
),
|
||||
depenses_debit=debit,
|
||||
depenses_credit=credit,
|
||||
depenses_deductible=deductible,
|
||||
depenses_locatif=locatif,
|
||||
nb_operations=nb_operations,
|
||||
solde=round(encaisse - debit + credit, 2),
|
||||
depenses_immeuble_non_reparties=commun,
|
||||
)
|
||||
|
||||
|
||||
def _libelle_recette(revenu: Revenu) -> str:
|
||||
"""Ce que la ligne de recette dit d'elle-même.
|
||||
|
||||
Le libellé d'une ligne « divers » porte le motif réel (régularisation,
|
||||
ordures ménagères…) : le préférer au type générique, qui n'apprendrait rien.
|
||||
"""
|
||||
return revenu.divers_libelle or revenu.type_ligne
|
||||
|
||||
|
||||
def _chronologie(session: Session, lot: Lot) -> list[LigneChronologie]:
|
||||
"""Recettes et dépenses du lot, remises dans l'ordre des comptes rendus."""
|
||||
lignes: list[LigneChronologie] = []
|
||||
|
||||
revenus = session.execute(
|
||||
select(Revenu, Document.date)
|
||||
.join(Document, Revenu.document_id == Document.id)
|
||||
.where(Revenu.lot_id == lot.id)
|
||||
).all()
|
||||
|
||||
for revenu, date_document in revenus:
|
||||
lignes.append(
|
||||
LigneChronologie(
|
||||
date=date_document,
|
||||
document_id=revenu.document_id,
|
||||
nature="recette",
|
||||
libelle=_libelle_recette(revenu),
|
||||
type_ligne=revenu.type_ligne,
|
||||
periode_debut=revenu.periode_debut,
|
||||
periode_fin=revenu.periode_fin,
|
||||
montant=revenu.total or 0.0,
|
||||
regle=revenu.regles or 0.0,
|
||||
impaye=revenu.impayes or 0.0,
|
||||
est_report=revenu.type_ligne == TYPE_LIGNE_REPORT,
|
||||
)
|
||||
)
|
||||
|
||||
depenses = session.execute(
|
||||
select(Depense, Document.date, Tag.nom)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.outerjoin(Tag, Tag.id == Depense.tag_id)
|
||||
.where(Depense.lot_id == lot.id)
|
||||
).all()
|
||||
|
||||
for depense, date_document, tag in depenses:
|
||||
lignes.append(
|
||||
LigneChronologie(
|
||||
date=date_document,
|
||||
document_id=depense.document_id,
|
||||
nature="depense",
|
||||
libelle=depense.description or depense.sous_categorie or "",
|
||||
categorie=depense.sous_categorie,
|
||||
fournisseur=depense.fournisseur,
|
||||
tag=tag,
|
||||
montant=round((depense.debit or 0.0) - (depense.credit or 0.0), 2),
|
||||
)
|
||||
)
|
||||
|
||||
# Tri en Python : les deux sources sont déjà chargées et un lot en porte
|
||||
# quelques dizaines de lignes. Les recettes d'abord à date égale, parce
|
||||
# qu'un compte rendu présente la situation locative avant les opérations.
|
||||
lignes.sort(key=lambda ligne: (ligne.date, ligne.nature != "recette"))
|
||||
return lignes
|
||||
|
||||
|
||||
def _intervenants(session: Session, lot: Lot) -> list[Intervenant]:
|
||||
"""Entreprises intervenues sur le lot, la plus engagée en tête.
|
||||
|
||||
Simple regroupement sur le fournisseur porté par chaque opération : rien
|
||||
n'est rapproché ni déduit au-delà de ce que la colonne contient.
|
||||
|
||||
Le montant est net du crédit, comme celui de chaque ligne de la
|
||||
chronologie : déplier une entreprise doit retrouver son total, pas un autre
|
||||
chiffre. Un avoir (« Remise état des lieux ») rend d'ailleurs ce montant
|
||||
négatif, ce qui est bien ce que le compte rendu porte.
|
||||
"""
|
||||
montant_net = func.coalesce(
|
||||
func.sum(
|
||||
func.coalesce(Depense.debit, 0.0) - func.coalesce(Depense.credit, 0.0)
|
||||
),
|
||||
0.0,
|
||||
)
|
||||
|
||||
rows = session.execute(
|
||||
select(
|
||||
Depense.fournisseur,
|
||||
func.count(Depense.id),
|
||||
montant_net,
|
||||
func.max(Document.date),
|
||||
)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.where(Depense.lot_id == lot.id, Depense.fournisseur.is_not(None))
|
||||
.group_by(Depense.fournisseur)
|
||||
.order_by(montant_net.desc())
|
||||
).all()
|
||||
|
||||
return [
|
||||
Intervenant(
|
||||
fournisseur=fournisseur,
|
||||
nb_interventions=nb,
|
||||
montant=montant,
|
||||
derniere_date=derniere_date,
|
||||
)
|
||||
for fournisseur, nb, montant, derniere_date in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/lots/{lot_id}/analyse", response_model=LotAnalyseResponse)
|
||||
async def analyser_lot(
|
||||
lot_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
) -> LotAnalyseResponse:
|
||||
"""Tout ce que les comptes rendus portent sur un lot.
|
||||
|
||||
- **lot_id**: ID du lot
|
||||
|
||||
Sans borne de période : l'historique est court et le montrer entier évite
|
||||
qu'un filtre par défaut cache des opérations sans le dire.
|
||||
"""
|
||||
lot = session.get(Lot, lot_id)
|
||||
if lot is None:
|
||||
raise HTTPException(status_code=404, detail="Lot introuvable.")
|
||||
|
||||
return LotAnalyseResponse(
|
||||
identite=_identite(session, lot),
|
||||
chiffres=_chiffres(session, lot),
|
||||
chronologie=_chronologie(session, lot),
|
||||
intervenants=_intervenants(session, lot),
|
||||
)
|
||||
Reference in New Issue
Block a user