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:
97
src/plesna_gerance/services/tresorerie.py
Normal file
97
src/plesna_gerance/services/tresorerie.py
Normal 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)
|
||||
Reference in New Issue
Block a user