feat: resume l'annee civile sur l'accueil

L'accueil ne decrivait qu'un document : les quatre tuiles lisaient le
dernier compte rendu importe, sans rien dire de ce que contient la base.

Il resume desormais une annee civile, avec un selecteur des annees
presentes. L'annee par defaut est celle du dernier compte rendu et non
l'annee en cours : la fenetre etait calee sur `date.today()`, si bien que
sans import depuis quelques mois l'ecran se serait vide alors que la base
est pleine.

Ce que les tuiles annoncent :

- recettes facturees, report exclu, avec l'encaisse et le recouvrement ;
- depenses, debit et credits recus ;
- net reverse, rapproche des soldes annonces par les comptes rendus ;
- restant du, date, car un stock ne se cumule pas d'un mois sur l'autre.

Le net reverse vaut « encaisse - debit + credit », et cette egalite tombe
au centime sur le solde extrait du PDF pour avril, mai et juin 2026. Elle
s'ecarte de 288,52 EUR en fevrier et de 1 188,03 EUR en mars, les deux mois
dont l'extraction a par ailleurs des defauts. L'ecart est donc affiche et
jamais lisse : c'est le seul controle de bout en bout dont on dispose sur
la qualite d'une extraction. Les regles vivent dans services/tresorerie.py,
a cote de celles des revenus.

Le lot compte est celui qui a ete facture dans l'annee : la table `lots`
retient deux ecritures par lot (« 0001 » et « 01 »), sequelle de la
normalisation des numeros, et en annoncerait 40 la ou il y en a 20.

Le graphique passe du vert et rouge au bleu et ambre : sous deuteranopie,
green-400 et red-400 ne se separent qu'a un delta E de 7,9, sous le seuil
de 8. La paire retenue tient a 30,2.

`/recent-revenus` disparait — c'etait le dernier agregat a compter les
lignes de report — et `/immeubles-shortcuts` est borne a la meme annee que
le reste de l'ecran, deux perimetres sur un ecran donnant deux montants
sans que rien ne les distingue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 13:51:59 +02:00
parent 424abbaf07
commit 9c2d4f7f82
12 changed files with 1068 additions and 853 deletions

View File

@@ -1,9 +1,8 @@
"""Dashboard routes - Aggregated data for the home page."""
"""Dashboard routes - Vue annuelle des revenus et des depenses."""
from collections import defaultdict
from datetime import date, timedelta
from datetime import date
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import desc, func, select
from sqlalchemy.orm import Session
@@ -17,7 +16,13 @@ from ...database.models import (
Lot,
Revenu,
)
from ...services.revenus_query import est_flux, flux_par, restant_du_par
from ...services.revenus_query import (
est_flux,
flux_par,
restant_du_par,
taux_de_recouvrement,
)
from ...services.tresorerie import depenses_par, net_reverse, somme_soldes_annonces
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@@ -27,50 +32,52 @@ router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
# ============================================================
class MonthlyDataPoint(BaseModel):
"""Point de donnees mensuel pour sparkline."""
class PerimetreResponse(BaseModel):
"""De quoi l'annee affichee est faite."""
month: str
value: float
nb_comptes_rendus: int
premiere_date: str | None = None
derniere_date: str | None = None
nb_immeubles: int
#: Lots effectivement factures dans l'annee. La table `lots` en contient
#: davantage : d'anciennes ecritures de numeros y subsistent sans revenu.
nb_lots_factures: int
class FinancialSummaryResponse(BaseModel):
"""Resume financier du dernier document avec historique pour sparklines."""
class MoisResponse(BaseModel):
"""Un mois de l'annee, tel qu'il se compare aux autres."""
# Valeurs du dernier document
last_document_date: str | None = None
last_document_reference: str | None = None
revenus: float
impayes: float
mois: str # "2026-06"
facture: float
encaisse: float
depenses: float
solde: float
# Historique pour sparklines (6 derniers mois)
revenus_history: list[MonthlyDataPoint] = []
impayes_history: list[MonthlyDataPoint] = []
depenses_history: list[MonthlyDataPoint] = []
solde_history: list[MonthlyDataPoint] = []
net: float
class RecentRevenuResponse(BaseModel):
"""Revenu recent avec details."""
class ResumeAnnuelResponse(BaseModel):
"""Revenus et depenses de l'annee civile, avec leur contrepartie annoncee."""
id: int
document_date: str
locataire_nom: str
lot_numero: str
immeuble_code: str
total: float
impayes: float
type_ligne: str
annee: int
annees_disponibles: list[int]
perimetre: PerimetreResponse
# Flux cumules sur l'annee, report exclu (cf. services/revenus_query)
facture: float
encaisse: float
taux_recouvrement: float
depenses_debit: float
depenses_credit: float
class MonthlyTrendResponse(BaseModel):
"""Tendance mensuelle pour graphique."""
# Ce qui revient au proprietaire, et l'ecart avec les soldes des CR
net_reverse: float
soldes_annonces: float
ecart_soldes: float
month: str # "2024-01"
revenus: float
depenses: float
solde: float
# Stock : photo du dernier compte rendu de l'annee, non cumulable
restant_du: float
restant_du_date: str | None = None
par_mois: list[MoisResponse] = []
class ImmeubleShortcutResponse(BaseModel):
@@ -91,255 +98,200 @@ class ImmeubleShortcutResponse(BaseModel):
# ============================================================
@router.get("/financial-summary", response_model=FinancialSummaryResponse)
async def get_financial_summary(
session: Session = Depends(get_session),
) -> FinancialSummaryResponse:
"""Retourne le resume financier du dernier document avec historique pour sparklines.
def _annees_disponibles(session: Session) -> list[int]:
"""Annees ayant au moins un compte rendu, de la plus recente a la plus ancienne."""
# `select(colonne).distinct()` plutot que `func.distinct(colonne)` : le
# second perd le type de la colonne et rendrait les dates sous forme de
# chaines, sans annee a lire.
dates = session.execute(select(Document.date).distinct()).scalars().all()
return sorted({jour.year for jour in dates}, reverse=True)
- Valeurs principales basees sur le dernier document importe
- Historique sur 6 mois pour les sparklines
def _annee_par_defaut(session: Session, annee: int | None) -> tuple[int, list[int]]:
"""Annee a afficher et annees proposables.
Le defaut est l'annee du dernier compte rendu, et non l'annee en cours :
sans import depuis quelques mois, un accueil cale sur la date du jour se
viderait alors que la base est pleine.
"""
# Recuperer le dernier document
last_doc_stmt = select(Document).order_by(desc(Document.date)).limit(1)
last_doc = session.execute(last_doc_stmt).scalar()
annees = _annees_disponibles(session)
if annee is None:
annee = annees[0] if annees else date.today().year
return annee, annees
# Valeurs du dernier document
revenus = 0.0
impayes = 0.0
depenses = 0.0
last_document_date = None
last_document_reference = None
if last_doc:
last_document_date = str(last_doc.date)
last_document_reference = last_doc.reference
@router.get("/resume-annuel", response_model=ResumeAnnuelResponse)
async def get_resume_annuel(
annee: int | None = Query(None, description="Annee civile a afficher"),
session: Session = Depends(get_session),
) -> ResumeAnnuelResponse:
"""Retourne les revenus et depenses d'une annee civile.
# Revenus du dernier document : le solde reporte du mois precedent n'est
# pas un revenu du mois, il est deja compte dans les impayes.
revenus = (
session.execute(
select(func.sum(Revenu.total))
.where(Revenu.document_id == last_doc.id)
.where(est_flux())
).scalar()
or 0.0
Les montants cumulables (facture, encaisse, depenses) sont bornes a l'annee ;
le restant du est lu dans le dernier compte rendu de la periode, car un stock
ne s'additionne pas dans le temps (cf. services/revenus_query).
"""
annee, annees = _annee_par_defaut(session, annee)
debut = date(annee, 1, 1)
fin = date(annee, 12, 31)
perimetre = _perimetre(session, debut, fin)
# ========== Flux de l'annee ==========
flux = flux_par(Document.immeuble_id, debut, fin)
totaux = session.execute(
select(
func.sum(flux.c.facture).label("facture"),
func.sum(flux.c.encaisse).label("encaisse"),
func.sum(flux.c.facture_regle).label("facture_regle"),
)
).one()
# Impayes du dernier document
impayes = (
session.execute(
select(func.sum(Revenu.impayes)).where(
Revenu.document_id == last_doc.id
)
).scalar()
or 0.0
depenses = depenses_par(Document.immeuble_id, debut, fin)
cumul = session.execute(
select(
func.sum(depenses.c.debit).label("debit"),
func.sum(depenses.c.credit).label("credit"),
)
).one()
# Depenses du dernier document
depenses = (
session.execute(
select(func.sum(Depense.debit)).where(
Depense.document_id == last_doc.id
)
).scalar()
or 0.0
)
facture = totaux.facture or 0.0
encaisse = totaux.encaisse or 0.0
debit = cumul.debit or 0.0
credit = cumul.credit or 0.0
solde = revenus - depenses
net = net_reverse(encaisse, debit, credit)
annonces = somme_soldes_annonces(session, debut, fin)
# Historique sur 6 mois pour sparklines
today = date.today()
start_date = (today.replace(day=1) - timedelta(days=6 * 31)).replace(day=1)
# ========== Restant du (stock) ==========
stock = restant_du_par(Document.immeuble_id, debut, fin)
restant_du = session.execute(select(func.sum(stock.c.restant_du))).scalar() or 0.0
# Revenus par mois
revenus_by_month: dict[str, float] = defaultdict(float)
impayes_by_month: dict[str, float] = defaultdict(float)
depenses_by_month: dict[str, float] = defaultdict(float)
return ResumeAnnuelResponse(
annee=annee,
annees_disponibles=annees,
perimetre=perimetre,
facture=round(facture, 2),
encaisse=round(encaisse, 2),
taux_recouvrement=taux_de_recouvrement(facture, totaux.facture_regle),
depenses_debit=round(debit, 2),
depenses_credit=round(credit, 2),
net_reverse=net,
soldes_annonces=annonces,
ecart_soldes=round(net - annonces, 2),
restant_du=round(restant_du, 2),
restant_du_date=perimetre.derniere_date,
par_mois=_par_mois(session, debut, fin),
)
# Recuperer revenus et impayes par mois. Le revenu du mois exclut le report
# pour que les mois soient comparables ; l'impaye reste le solde constate ce
# mois-la, de sorte que la courbe suive la dette au lieu de l'empiler.
revenus_stmt = (
def _perimetre(session: Session, debut: date, fin: date) -> PerimetreResponse:
"""Ce que couvre l'annee : combien de comptes rendus, sur quoi, jusqu'a quand."""
documents = session.execute(
select(
func.count(Document.id).label("nb"),
func.min(Document.date).label("premiere"),
func.max(Document.date).label("derniere"),
func.count(func.distinct(Document.immeuble_id)).label("nb_immeubles"),
).where(Document.date.between(debut, fin))
).one()
# Les lots comptes sont ceux qui ont ete factures dans l'annee : un lot sorti
# de la gestion ne fait plus partie du perimetre qu'on resume, et la table
# `lots` retient par ailleurs d'anciennes ecritures de numeros sans revenu.
nb_lots_factures = (
session.execute(
select(func.count(func.distinct(Revenu.lot_id)))
.join(Document, Revenu.document_id == Document.id)
.where(Document.date.between(debut, fin))
).scalar()
or 0
)
return PerimetreResponse(
nb_comptes_rendus=documents.nb or 0,
premiere_date=str(documents.premiere) if documents.premiere else None,
derniere_date=str(documents.derniere) if documents.derniere else None,
nb_immeubles=documents.nb_immeubles or 0,
nb_lots_factures=nb_lots_factures,
)
def _par_mois(session: Session, debut: date, fin: date) -> list[MoisResponse]:
"""Detail mensuel de l'annee, un point par mois ayant un compte rendu.
Les mois sans compte rendu sont omis plutot que mis a zero : un mois vide
est un mois non importe, pas un mois sans loyer, et une barre a zero le
ferait lire comme une chute d'activite.
L'encaisse retient toutes les lignes, report compris : regler une vieille
dette est bien un encaissement du mois. Le facture, lui, exclut le report,
sans quoi les mois cesseraient d'etre comparables.
"""
revenus = session.execute(
select(
Document.date,
func.sum(Revenu.total).filter(est_flux()).label("total"),
func.sum(Revenu.impayes).label("impayes"),
func.sum(Revenu.total).filter(est_flux()).label("facture"),
func.sum(Revenu.regles).label("encaisse"),
)
.join(Revenu, Revenu.document_id == Document.id)
.where(Document.date >= start_date)
.where(Document.date.between(debut, fin))
.group_by(Document.date)
)
for row in session.execute(revenus_stmt):
month_key = row.date.strftime("%Y-%m")
revenus_by_month[month_key] += row.total or 0.0
impayes_by_month[month_key] += row.impayes or 0.0
).all()
# Recuperer depenses par mois
depenses_stmt = (
select(Document.date, func.sum(Depense.debit).label("total"))
.join(Depense, Depense.document_id == Document.id)
.where(Document.date >= start_date)
.group_by(Document.date)
)
for row in session.execute(depenses_stmt):
month_key = row.date.strftime("%Y-%m")
depenses_by_month[month_key] += row.total or 0.0
# Generer les 6 derniers mois
all_months = []
current = today.replace(day=1)
for _ in range(6):
all_months.insert(0, current.strftime("%Y-%m"))
current = (current - timedelta(days=1)).replace(day=1)
# Construire les listes d'historique
revenus_history = [
MonthlyDataPoint(month=m, value=revenus_by_month.get(m, 0.0))
for m in all_months
]
impayes_history = [
MonthlyDataPoint(month=m, value=impayes_by_month.get(m, 0.0))
for m in all_months
]
depenses_history = [
MonthlyDataPoint(month=m, value=depenses_by_month.get(m, 0.0))
for m in all_months
]
solde_history = [
MonthlyDataPoint(
month=m,
value=revenus_by_month.get(m, 0.0) - depenses_by_month.get(m, 0.0),
)
for m in all_months
]
return FinancialSummaryResponse(
last_document_date=last_document_date,
last_document_reference=last_document_reference,
revenus=revenus,
impayes=impayes,
depenses=depenses,
solde=solde,
revenus_history=revenus_history,
impayes_history=impayes_history,
depenses_history=depenses_history,
solde_history=solde_history,
)
@router.get("/recent-revenus", response_model=list[RecentRevenuResponse])
async def get_recent_revenus(
limit: int = 10,
session: Session = Depends(get_session),
) -> list[RecentRevenuResponse]:
"""Retourne les derniers revenus/loyers enregistres.
- **limit**: Nombre maximum de resultats (defaut: 10)
"""
stmt = (
depenses = session.execute(
select(
Revenu,
Document.date.label("document_date"),
Locataire.nom.label("locataire_nom"),
Lot.numero.label("lot_numero"),
Immeuble.code.label("immeuble_code"),
Document.date,
func.sum(Depense.debit).label("debit"),
func.sum(Depense.credit).label("credit"),
)
.join(Document, Revenu.document_id == Document.id)
.join(Locataire, Revenu.locataire_id == Locataire.id)
.join(Lot, Revenu.lot_id == Lot.id)
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
.order_by(desc(Document.date), desc(Revenu.id))
.limit(limit)
)
result = session.execute(stmt)
rows = result.all()
return [
RecentRevenuResponse(
id=row.Revenu.id,
document_date=str(row.document_date),
locataire_nom=row.locataire_nom,
lot_numero=row.lot_numero,
immeuble_code=row.immeuble_code,
total=row.Revenu.total or 0.0,
impayes=row.Revenu.impayes or 0.0,
type_ligne=row.Revenu.type_ligne or "",
)
for row in rows
]
@router.get("/monthly-trends", response_model=list[MonthlyTrendResponse])
async def get_monthly_trends(
months: int = 6,
session: Session = Depends(get_session),
) -> list[MonthlyTrendResponse]:
"""Retourne les tendances mensuelles pour le graphique.
- **months**: Nombre de mois a inclure (defaut: 6)
"""
today = date.today()
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
# Recuperer tous les revenus depuis start_date, report exclu : la courbe
# compare des mois entre eux, pas des soldes cumules.
revenus_stmt = (
select(Document.date, func.sum(Revenu.total).filter(est_flux()).label("total"))
.join(Revenu, Revenu.document_id == Document.id)
.where(Document.date >= start_date)
.group_by(Document.date)
)
revenus_result = session.execute(revenus_stmt)
revenus_by_month: dict[str, float] = defaultdict(float)
for row in revenus_result:
month_key = row.date.strftime("%Y-%m")
revenus_by_month[month_key] += row.total or 0.0
# Recuperer toutes les depenses depuis start_date
depenses_stmt = (
select(Document.date, func.sum(Depense.debit).label("total"))
.join(Depense, Depense.document_id == Document.id)
.where(Document.date >= start_date)
.where(Document.date.between(debut, fin))
.group_by(Document.date)
)
).all()
depenses_result = session.execute(depenses_stmt)
depenses_by_month: dict[str, float] = defaultdict(float)
for row in depenses_result:
month_key = row.date.strftime("%Y-%m")
depenses_by_month[month_key] += row.total or 0.0
# Combiner et trier
all_months = sorted(set(revenus_by_month.keys()) | set(depenses_by_month.keys()))
# Limiter aux derniers mois demandes
all_months = all_months[-months:]
mois: dict[str, dict[str, float]] = {}
for ligne in revenus:
cumul = mois.setdefault(ligne.date.strftime("%Y-%m"), {})
cumul["facture"] = cumul.get("facture", 0.0) + (ligne.facture or 0.0)
cumul["encaisse"] = cumul.get("encaisse", 0.0) + (ligne.encaisse or 0.0)
for ligne in depenses:
cumul = mois.setdefault(ligne.date.strftime("%Y-%m"), {})
cumul["debit"] = cumul.get("debit", 0.0) + (ligne.debit or 0.0)
cumul["credit"] = cumul.get("credit", 0.0) + (ligne.credit or 0.0)
return [
MonthlyTrendResponse(
month=month,
revenus=revenus_by_month.get(month, 0.0),
depenses=depenses_by_month.get(month, 0.0),
solde=revenus_by_month.get(month, 0.0) - depenses_by_month.get(month, 0.0),
MoisResponse(
mois=cle,
facture=round(cumul.get("facture", 0.0), 2),
encaisse=round(cumul.get("encaisse", 0.0), 2),
depenses=round(cumul.get("debit", 0.0), 2),
net=net_reverse(
cumul.get("encaisse"), cumul.get("debit"), cumul.get("credit")
),
)
for month in all_months
for cle, cumul in sorted(mois.items())
]
@router.get("/immeubles-shortcuts", response_model=list[ImmeubleShortcutResponse])
async def get_immeubles_shortcuts(
annee: int | None = Query(None, description="Annee civile a afficher"),
limit: int = 5,
session: Session = Depends(get_session),
) -> list[ImmeubleShortcutResponse]:
"""Retourne les immeubles pour acces rapide avec stats.
Trie par nombre de documents (plus actifs en premier).
Trie par nombre de documents (plus actifs en premier). Borne sur la meme
annee que le resume : deux perimetres sur un meme ecran donneraient deux
montants de revenus sans que rien ne les distingue.
- **limit**: Nombre maximum d'immeubles (defaut: 5)
"""
annee, _ = _annee_par_defaut(session, annee)
debut = date(annee, 1, 1)
fin = date(annee, 12, 31)
# Effectifs et activite, puis flux et restant du : trois granularites
# differentes, jointes plutot que melangees pour ne pas se multiplier.
effectifs = (
@@ -357,12 +309,15 @@ async def get_immeubles_shortcuts(
Document.immeuble_id.label("immeuble_id"),
func.count(Document.id).label("nb_documents"),
)
.where(Document.date.between(debut, fin))
.group_by(Document.immeuble_id)
.subquery()
)
flux = flux_par(Document.immeuble_id)
stock = restant_du_par(Document.immeuble_id)
flux = flux_par(Document.immeuble_id, debut, fin)
stock = restant_du_par(Document.immeuble_id, debut, fin)
# Jointure fermee sur l'activite : un immeuble sans compte rendu cette
# annee-la n'a rien a montrer et sortirait avec des montants vides.
stmt = (
select(
Immeuble,
@@ -371,8 +326,8 @@ async def get_immeubles_shortcuts(
flux.c.facture,
stock.c.restant_du,
)
.join(activite, activite.c.immeuble_id == Immeuble.id)
.outerjoin(effectifs, effectifs.c.immeuble_id == Immeuble.id)
.outerjoin(activite, activite.c.immeuble_id == Immeuble.id)
.outerjoin(flux, flux.c.cle == Immeuble.id)
.outerjoin(stock, stock.c.cle == Immeuble.id)
.order_by(desc(activite.c.nb_documents))

View File

@@ -0,0 +1,97 @@
"""Ce qui est réellement reversé au propriétaire, et son rapprochement.
Le compte rendu de gérance se termine par un solde : ce que le gérant verse au
propriétaire pour la période (créditeur) ou lui réclame (débiteur). Ce montant
est extrait du PDF, il ne se recalcule pas.
Il se **retrouve** en revanche à partir des lignes, et c'est tout l'intérêt :
net reversé = encaissé débit + crédit
L'encaissé, pas le facturé : un loyer appelé mais impayé n'est pas versé. Le
crédit des opérations, souvent nul, porte les régularisations en faveur du
propriétaire ; l'oublier décale le rapprochement d'autant.
Sur la base réelle, cette égalité tombe au centime pour avril, mai et juin 2026,
et s'écarte pour février et mars — les deux mois dont l'extraction a par ailleurs
des défauts. L'écart entre le net recalculé et le solde annoncé est donc affiché,
jamais lissé : c'est le seul contrôle de bout en bout dont on dispose sur la
qualité d'une extraction, et un écart qui apparaît est une anomalie à instruire.
"""
from datetime import date
from unicodedata import combining, normalize
from sqlalchemy import func, select
from ..database.models import Depense, Document
#: Un solde créditeur est versé au propriétaire, un solde débiteur lui est
#: réclamé. La base stocke les deux formes « créditeur » et « crediteur » selon
#: que le parser a lu le PDF ou est retombé sur sa valeur par défaut.
SOLDE_CREDITEUR = "crediteur"
def _sans_accents(valeur: str) -> str:
return "".join(c for c in normalize("NFD", valeur) if not combining(c))
def est_crediteur(solde_type: str | None) -> bool:
"""Le solde est-il en faveur du propriétaire ?"""
if not solde_type:
return False
return _sans_accents(solde_type).lower() == SOLDE_CREDITEUR
def solde_signe(montant: float | None, solde_type: str | None) -> float:
"""Solde d'un compte rendu, compté positivement quand il est versé."""
montant = montant or 0.0
return montant if est_crediteur(solde_type) else -montant
def _borner(stmt, date_debut: date | None, date_fin: date | None):
if date_debut is not None:
stmt = stmt.where(Document.date >= date_debut)
if date_fin is not None:
stmt = stmt.where(Document.date <= date_fin)
return stmt
def depenses_par(cle, date_debut: date | None = None, date_fin: date | None = None):
"""Sous-requête : débit et crédit des opérations, regroupés par `cle`.
Les dépenses sont des flux, elles se cumulent sans précaution particulière —
contrairement aux revenus, aucune ligne ne reporte le mois précédent.
"""
stmt = (
select(
cle.label("cle"),
func.sum(Depense.debit).label("debit"),
func.sum(Depense.credit).label("credit"),
)
.join(Document, Depense.document_id == Document.id)
.group_by(cle)
)
return _borner(stmt, date_debut, date_fin).subquery()
def somme_soldes_annonces(
session, date_debut: date | None = None, date_fin: date | None = None
) -> float:
"""Somme signée des soldes annoncés par les comptes rendus de la période.
Les soldes sont signés en Python plutôt qu'en SQL pour que `solde_signe`
reste la seule règle : reconnaître un créditeur en base demanderait d'y
redire quelles orthographes acceptées, et les deux versions divergeraient.
Le volume s'y prête, un compte rendu par immeuble et par mois.
"""
stmt = select(Document.solde_montant, Document.solde_type)
lignes = session.execute(_borner(stmt, date_debut, date_fin)).all()
return round(sum(solde_signe(montant, type_) for montant, type_ in lignes), 2)
def net_reverse(
encaisse: float | None, debit: float | None, credit: float | None
) -> float:
"""Ce qui revient au propriétaire sur la période."""
return round((encaisse or 0.0) - (debit or 0.0) + (credit or 0.0), 2)