Compare commits

..

8 Commits

Author SHA1 Message Date
11bd3ca539 fix: aligne le dashboard sur la règle flux / stock
All checks were successful
Build and Publish Docker Image / Build App Image (push) Successful in 22s
Build and Publish Docker Image / Build Summary (push) Successful in 3s
L'accueil et la page Revenus calculaient leurs totaux chacun de leur
côté : le premier lisait le dernier compte rendu, la seconde cumulait
tout. Les deux écrans affichaient donc deux impayés différents pour la
même notion — 49 374 € contre 247 354 €.

Les raccourcis immeubles cumulaient la dette, et les courbes mensuelles
réintégraient le report chaque mois, ce qui rendait deux mois
incomparables. Tous passent par les mêmes règles que la page Revenus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 10:50:15 +02:00
3e2d103929 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>
2026-07-26 10:50:15 +02:00
1c817a4ac9 refactor: supprime l'endpoint /api/dashboard/stats inutilisé
Aucun appelant : le tableau de bord se construit sur financial-summary,
monthly-trends, recent-revenus et immeubles-shortcuts. Les compteurs
qu'il exposait restent disponibles en ligne de commande via
`plesna-gerance db-info`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:57:06 +02:00
5a132b1258 fix: renvoie un 404 sur une URL d'API inconnue
L'API et l'interface partagent le même port, et la route attrape-tout qui
sert le SPA passait avant le 404 : n'importe quelle URL /api/* erronée
repartait en index.html avec un 200. Côté client, une faute de frappe
dans une URL ne ressemblait pas à une erreur mais à une réponse vide, et
le vrai motif n'apparaissait qu'en inspectant le corps de la réponse.

Les URL commençant par /api/ sont désormais exclues de l'attrape-tout et
retombent sur un 404 JSON. Les routes de l'interface restent servies par
index.html.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:57:06 +02:00
49e060e6e4 refactor: supprime le code mort et unifie les routes de tags
Code sans aucun appelant, retiré : DatabaseService.get_revenus_summary
(annoté list[dict] alors qu'il retournait un dict), get_depenses_summary,
get_tag_by_id et storage.file_exists.

Les tags étaient gérés à deux adresses : /api/tags (lecture, appelée par
trois composants) et /api/config/tags (lecture, création, renommage,
appelée par la seule page de configuration). Tout est regroupé sur
/api/tags, dans le module qui leur est dédié ; config.py ne garde que les
settings et ConfigPage est recâblée. Ces routes n'avaient aucun test :
sept en couvrent maintenant la création, le renommage, l'unicité et les
noms vides.

/api/stats disparaît également : sous-ensemble de /api/dashboard/stats,
il n'était appelé par personne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:44:28 +02:00
ea5bdac18a refactor: retire les parseurs texte de repli
Les parseurs géométriques (par cellules de tableau) traitent les 5 PDF du
corpus sans jamais déclencher le repli : les parseurs texte étaient 586
lignes de regex fragiles, non testées, à maintenir à chaque évolution du
format de sortie. Les références golden sont inchangées après leur
suppression — l'extraction produit exactement la même chose.

Le parseur géométrique devient donc le seul chemin : ce qu'il ne lit pas
est perdu. L'orchestrateur distingue maintenant les deux cas :

- aucun lot lu -> ExtractionError (422 côté API). Un compte rendu sans
  lot n'existe pas : c'est le tableau qui n'a pas été reconnu, et mieux
  vaut échouer que d'enregistrer un document vide découvert bien plus
  tard, au moment de relire les chiffres ;
- aucune opération -> accepté. Un mois sans dépense reste plausible.

_extract_lot_code_from_description était la seule fonction encore
utilisée : elle rejoint utils.lots sous le nom
extract_lot_numero_from_description, à côté de normalize_lot_numero.
extract_text_from_pdf, sans appelant, disparaît au passage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:44:20 +02:00
1b319dff4e fix: corrige le sur-comptage des revenus par immeuble
Les agrégats par immeuble joignaient revenus et locataires à partir du
lot. Comme un lot accumule ses occupants successifs, chaque ligne de
revenu était comptée autant de fois qu'il y a eu de locataires : aucune
erreur levée, juste des montants faux qui dérivent avec l'ancienneté du
parc.

Sur la base réelle (40 lots, 46 locataires), /api/revenus/immeubles et
le bloc by_immeuble de /api/revenus/summary annonçaient :

    total   348 111,95 € au lieu de 332 899,45 €   (+4,6 %)
    réglés  100 697,66 € au lieu de  85 545,16 €   (+17,7 %)
    taux           28,9 % au lieu de       25,7 %

Les montants sont désormais agrégés par immeuble AVANT la jointure des
locataires, qui ne sert plus qu'au dénombrement. Les deux endpoints
partagent ces sous-requêtes et la construction de la réponse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:44:11 +02:00
2ac7855cd5 test: fige l'extraction des PDF réels en références golden
Les parseurs sont la partie la plus exposée aux régressions silencieuses :
un décalage de colonne ne lève aucune exception, il produit des montants
faux. Rien ne les couvrait jusqu'ici.

Chaque PDF du corpus local produit une empreinte (structure des lots,
totaux par lot, montants par catégorie, détail de chaque opération)
comparée à une référence figée. Les libellés sensibles — locataire,
fournisseur, description — sont réduits à un hash court : un changement
reste détecté sans recopier la donnée.

Ni les PDF (`data/`) ni les références (`tests/golden/`) ne sont
versionnés : la suite se saute d'elle-même là où le corpus est absent.
Régénération après un changement volontaire de parseur :

    uv run pytest tests/test_parsers_golden.py --regen-golden

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 09:43:16 +02:00
26 changed files with 1217 additions and 1071 deletions

View File

@@ -220,7 +220,7 @@ const editInput = ref(null)
async function loadTags() {
try {
const resp = await fetch(`${API}/api/config/tags`)
const resp = await fetch(`${API}/api/tags`)
tags.value = await resp.json()
} catch (e) {
console.error('Failed to load tags', e)
@@ -248,7 +248,7 @@ async function confirmRenameTag(tagId) {
if (!nom) return
tagMessage.value = ''
try {
const resp = await fetch(`${API}/api/config/tags/${tagId}`, {
const resp = await fetch(`${API}/api/tags/${tagId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nom }),
@@ -274,7 +274,7 @@ async function createTag() {
if (!nom) return
tagMessage.value = ''
try {
const resp = await fetch(`${API}/api/config/tags`, {
const resp = await fetch(`${API}/api/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nom }),

View File

@@ -3,7 +3,7 @@
import mimetypes
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
@@ -87,6 +87,13 @@ if FRONTEND_DIST.exists():
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(full_path: str):
"""Serve the SPA for all non-API routes."""
# Une URL d'API inconnue doit se dire inconnue. Sans cette garde, elle
# tomberait dans le catch-all et repartirait en index.html avec un 200 :
# côté client, une faute de frappe dans une URL ne ressemblerait plus à
# une erreur mais à une réponse vide.
if full_path == "api" or full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="Endpoint inconnu")
# If requesting a file that exists (and stays within dist), serve it
file_path = (FRONTEND_DIST / full_path).resolve()
if file_path.is_file() and file_path.is_relative_to(_dist_root):

View File

@@ -4,8 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ...database import DatabaseService, get_session
from ...database.models import Tag
from ...database import get_session
from ...services.settings_service import (
SETTINGS_REGISTRY,
delete_setting,
@@ -25,14 +24,6 @@ class SettingUpdate(BaseModel):
value: str
class TagCreate(BaseModel):
nom: str
class TagUpdate(BaseModel):
nom: str
# ============================================================
# Settings endpoints
# ============================================================
@@ -71,64 +62,3 @@ async def reset_setting(
# Return the resolved value after deletion
all_settings = get_all_settings(session)
return all_settings[key]
# ============================================================
# Tags endpoints
# ============================================================
@router.get("/tags")
async def list_tags(
session: Session = Depends(get_session),
) -> list[dict]:
"""Liste tous les tags."""
db_service = DatabaseService(session)
tags = db_service.list_tags()
return [{"id": tag.id, "nom": tag.nom} for tag in tags]
@router.post("/tags", status_code=201)
async def create_tag(
body: TagCreate,
session: Session = Depends(get_session),
) -> dict:
"""Crée un nouveau tag (validation unicité)."""
nom = body.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Le nom du tag ne peut pas être vide.")
existing = session.query(Tag).filter(Tag.nom == nom).first()
if existing:
raise HTTPException(status_code=409, detail=f"Le tag '{nom}' existe déjà.")
tag = Tag(nom=nom)
session.add(tag)
session.commit()
session.refresh(tag)
return {"id": tag.id, "nom": tag.nom}
@router.put("/tags/{tag_id}")
async def rename_tag(
tag_id: int,
body: TagUpdate,
session: Session = Depends(get_session),
) -> dict:
"""Renomme un tag (validation unicité)."""
nom = body.nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Le nom du tag ne peut pas être vide.")
tag = session.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag introuvable.")
existing = session.query(Tag).filter(Tag.nom == nom, Tag.id != tag_id).first()
if existing:
raise HTTPException(status_code=409, detail=f"Le tag '{nom}' existe déjà.")
tag.nom = nom
session.commit()
session.refresh(tag)
return {"id": tag.id, "nom": tag.nom}

View File

@@ -17,6 +17,7 @@ from ...database.models import (
Lot,
Revenu,
)
from ...services.revenus_query import est_flux, flux_par, restant_du_par
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@@ -85,53 +86,11 @@ class ImmeubleShortcutResponse(BaseModel):
total_impayes: float
class DashboardStatsResponse(BaseModel):
"""Stats enrichies pour le dashboard."""
documents: int
immeubles: int
lots: int
locataires: int
total_revenus: float
total_depenses: float
total_impayes: float
# ============================================================
# Endpoints
# ============================================================
@router.get("/stats", response_model=DashboardStatsResponse)
async def get_dashboard_stats(
session: Session = Depends(get_session),
) -> DashboardStatsResponse:
"""Retourne les statistiques enrichies pour le dashboard.
Inclut les compteurs et les totaux financiers.
"""
# Compteurs
documents_count = session.execute(select(func.count(Document.id))).scalar() or 0
immeubles_count = session.execute(select(func.count(Immeuble.id))).scalar() or 0
lots_count = session.execute(select(func.count(Lot.id))).scalar() or 0
locataires_count = session.execute(select(func.count(Locataire.id))).scalar() or 0
# Totaux financiers
total_revenus = session.execute(select(func.sum(Revenu.total))).scalar() or 0.0
total_depenses = session.execute(select(func.sum(Depense.debit))).scalar() or 0.0
total_impayes = session.execute(select(func.sum(Revenu.impayes))).scalar() or 0.0
return DashboardStatsResponse(
documents=documents_count,
immeubles=immeubles_count,
lots=lots_count,
locataires=locataires_count,
total_revenus=total_revenus,
total_depenses=total_depenses,
total_impayes=total_impayes,
)
@router.get("/financial-summary", response_model=FinancialSummaryResponse)
async def get_financial_summary(
session: Session = Depends(get_session),
@@ -156,10 +115,13 @@ async def get_financial_summary(
last_document_date = str(last_doc.date)
last_document_reference = last_doc.reference
# Revenus du dernier document
# 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)
select(func.sum(Revenu.total))
.where(Revenu.document_id == last_doc.id)
.where(est_flux())
).scalar()
or 0.0
)
@@ -195,11 +157,13 @@ async def get_financial_summary(
impayes_by_month: dict[str, float] = defaultdict(float)
depenses_by_month: dict[str, float] = defaultdict(float)
# Recuperer revenus et impayes par mois
# 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 = (
select(
Document.date,
func.sum(Revenu.total).label("total"),
func.sum(Revenu.total).filter(est_flux()).label("total"),
func.sum(Revenu.impayes).label("impayes"),
)
.join(Revenu, Revenu.document_id == Document.id)
@@ -319,9 +283,10 @@ async def get_monthly_trends(
today = date.today()
start_date = (today.replace(day=1) - timedelta(days=months * 31)).replace(day=1)
# Recuperer tous les revenus depuis start_date
# 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).label("total"))
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)
@@ -375,51 +340,55 @@ async def get_immeubles_shortcuts(
- **limit**: Nombre maximum d'immeubles (defaut: 5)
"""
# Requete pour les immeubles avec stats
# Effectifs et activite, puis flux et restant du : trois granularites
# differentes, jointes plutot que melangees pour ne pas se multiplier.
effectifs = (
select(
Lot.immeuble_id.label("immeuble_id"),
func.count(func.distinct(Lot.id)).label("nb_lots"),
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
)
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
.group_by(Lot.immeuble_id)
.subquery()
)
activite = (
select(
Document.immeuble_id.label("immeuble_id"),
func.count(Document.id).label("nb_documents"),
)
.group_by(Document.immeuble_id)
.subquery()
)
flux = flux_par(Document.immeuble_id)
stock = restant_du_par(Document.immeuble_id)
stmt = (
select(
Immeuble,
func.count(func.distinct(Lot.id)).label("nb_lots"),
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
func.count(func.distinct(Document.id)).label("nb_documents"),
effectifs.c.nb_lots,
effectifs.c.nb_locataires,
flux.c.facture,
stock.c.restant_du,
)
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
.outerjoin(Document, Document.immeuble_id == Immeuble.id)
.group_by(Immeuble.id)
.order_by(desc("nb_documents"))
.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))
.limit(limit)
)
result = session.execute(stmt)
immeubles = result.all()
# Pour chaque immeuble, recuperer les totaux revenus/impayes
shortcuts = []
for row in immeubles:
immeuble = row.Immeuble
# Revenus de cet immeuble
revenus_stmt = (
select(func.sum(Revenu.total), func.sum(Revenu.impayes))
.join(Lot, Revenu.lot_id == Lot.id)
.where(Lot.immeuble_id == immeuble.id)
return [
ImmeubleShortcutResponse(
id=row.Immeuble.id,
code=row.Immeuble.code,
adresse=row.Immeuble.adresse,
ville=row.Immeuble.ville,
nb_lots=row.nb_lots or 0,
nb_locataires=row.nb_locataires or 0,
total_revenus=row.facture or 0.0,
total_impayes=row.restant_du or 0.0,
)
rev_result = session.execute(revenus_stmt).first()
total_revenus = rev_result[0] or 0.0 if rev_result else 0.0
total_impayes = rev_result[1] or 0.0 if rev_result else 0.0
shortcuts.append(
ImmeubleShortcutResponse(
id=immeuble.id,
code=immeuble.code,
adresse=immeuble.adresse,
ville=immeuble.ville,
nb_lots=row.nb_lots or 0,
nb_locataires=row.nb_locataires or 0,
total_revenus=total_revenus,
total_impayes=total_impayes,
)
)
return shortcuts
for row in session.execute(stmt)
]

View File

@@ -6,11 +6,9 @@ from datetime import datetime
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse, Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from ...database import DatabaseService, get_session, storage
from ...database.models import Depense, Document, Immeuble, Locataire, Lot, Revenu
from ...database.service import DuplicateDocumentError
from ...extractor import extract_compte_rendu
from ...utils.canonical import canonical_copy
@@ -149,24 +147,6 @@ async def save_document_with_pdf(
)
@router.get("/stats")
async def get_stats(
session: Session = Depends(get_session),
) -> dict:
"""Retourne les statistiques globales de la base de donnees.
Compteurs pour chaque table principale.
"""
return {
"documents": session.execute(select(func.count(Document.id))).scalar() or 0,
"immeubles": session.execute(select(func.count(Immeuble.id))).scalar() or 0,
"lots": session.execute(select(func.count(Lot.id))).scalar() or 0,
"locataires": session.execute(select(func.count(Locataire.id))).scalar() or 0,
"revenus": session.execute(select(func.count(Revenu.id))).scalar() or 0,
"depenses": session.execute(select(func.count(Depense.id))).scalar() or 0,
}
@router.get("/documents", response_model=list[DocumentSummary])
async def list_documents(
limit: int = 100,

View File

@@ -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"])
@@ -127,6 +133,70 @@ class RevenusSummaryResponse(BaseModel):
top_impayes: list[RevenuByLocataire]
# ============================================================
# Agregats par immeuble
# ============================================================
def _effectifs_par_immeuble():
"""Sous-requete : nombre de lots et de locataires par immeuble."""
return (
select(
Lot.immeuble_id.label("immeuble_id"),
func.count(func.distinct(Lot.id)).label("nb_lots"),
func.count(func.distinct(Locataire.id)).label("nb_locataires"),
)
.outerjoin(Locataire, Locataire.lot_id == Lot.id)
.group_by(Lot.immeuble_id)
.subquery()
)
def _immeuble_response(row) -> RevenuByImmeuble:
"""Construit la reponse d'un immeuble a partir d'une ligne agregee."""
return 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=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)
)
# ============================================================
# Endpoints
# ============================================================
@@ -143,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 = (
@@ -194,96 +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 ==========
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"))
)
# 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 = []
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),
)
)
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,
@@ -292,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,
@@ -341,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,
@@ -348,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)
@@ -360,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])
@@ -471,43 +518,7 @@ 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)
)
# Tous les immeubles sont listes, y compris ceux sans aucun revenu.
stmt = _immeuble_stmt().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
return [_immeuble_response(row) for row in session.execute(stmt)]

View File

@@ -1,15 +1,34 @@
"""Tags routes - Tag management and prediction."""
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ...database import DatabaseService, get_session
from ...database.models import Tag
from ...services.tag_predictor import TagPredictor
from ..schemas import PredictTagsRequest
router = APIRouter(prefix="/api", tags=["tags"])
class TagBody(BaseModel):
"""Corps de requete pour creer ou renommer un tag."""
nom: str
def _tag_dict(tag: Tag) -> dict:
return {"id": tag.id, "nom": tag.nom}
def _nom_valide(nom: str) -> str:
nom = nom.strip()
if not nom:
raise HTTPException(status_code=400, detail="Le nom du tag ne peut pas être vide.")
return nom
@router.get("/tags")
async def list_tags(
session: Session = Depends(get_session),
@@ -21,7 +40,47 @@ async def list_tags(
db_service = DatabaseService(session)
tags = db_service.list_tags()
return [{"id": tag.id, "nom": tag.nom} for tag in tags]
return [_tag_dict(tag) for tag in tags]
@router.post("/tags", status_code=201)
async def create_tag(
body: TagBody,
session: Session = Depends(get_session),
) -> dict:
"""Cree un nouveau tag (validation unicite)."""
nom = _nom_valide(body.nom)
if session.query(Tag).filter(Tag.nom == nom).first():
raise HTTPException(status_code=409, detail=f"Le tag '{nom}' existe déjà.")
tag = Tag(nom=nom)
session.add(tag)
session.commit()
session.refresh(tag)
return _tag_dict(tag)
@router.put("/tags/{tag_id}")
async def rename_tag(
tag_id: int,
body: TagBody,
session: Session = Depends(get_session),
) -> dict:
"""Renomme un tag (validation unicite)."""
nom = _nom_valide(body.nom)
tag = session.query(Tag).filter(Tag.id == tag_id).first()
if not tag:
raise HTTPException(status_code=404, detail="Tag introuvable.")
if session.query(Tag).filter(Tag.nom == nom, Tag.id != tag_id).first():
raise HTTPException(status_code=409, detail=f"Le tag '{nom}' existe déjà.")
tag.nom = nom
session.commit()
session.refresh(tag)
return _tag_dict(tag)
@router.post("/predict-tags")

View File

@@ -403,77 +403,8 @@ class DatabaseService:
if depense.tag_id is not None
]
def get_revenus_summary(
self, immeuble_id: int = None, year: int = None
) -> list[dict]:
"""Get revenue summary grouped by period."""
stmt = select(Revenu)
if immeuble_id:
stmt = stmt.join(Lot).where(Lot.immeuble_id == immeuble_id)
if year:
stmt = stmt.where(
Revenu.periode_debut >= date(year, 1, 1),
Revenu.periode_debut <= date(year, 12, 31),
)
result = self.session.execute(stmt)
revenus = result.scalars().all()
# Aggregate
total_loyers = sum(r.loyers for r in revenus)
total_regles = sum(r.regles for r in revenus)
total_impayes = sum(r.impayes for r in revenus)
return {
"total_loyers": total_loyers,
"total_regles": total_regles,
"total_impayes": total_impayes,
"count": len(revenus),
}
def get_depenses_summary(self, immeuble_id: int = None, year: int = None) -> dict:
"""Get expenses summary grouped by category."""
stmt = select(Depense)
if immeuble_id:
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
if year:
stmt = stmt.join(Document).where(
Document.date >= date(year, 1, 1), Document.date <= date(year, 12, 31)
)
result = self.session.execute(stmt)
depenses = result.scalars().all()
# Aggregate by category
by_category = {}
for d in depenses:
cat = d.categorie or "AUTRE"
if cat not in by_category:
by_category[cat] = {"debit": 0.0, "credit": 0.0, "count": 0}
by_category[cat]["debit"] += d.debit
by_category[cat]["credit"] += d.credit
by_category[cat]["count"] += 1
total_debit = sum(d.debit for d in depenses)
total_credit = sum(d.credit for d in depenses)
return {
"by_category": by_category,
"total_debit": total_debit,
"total_credit": total_credit,
"count": len(depenses),
}
def list_tags(self) -> list[Tag]:
"""List all available tags."""
stmt = select(Tag).order_by(Tag.nom)
result = self.session.execute(stmt)
return list(result.scalars().all())
def get_tag_by_id(self, tag_id: int) -> Tag | None:
"""Get a tag by ID."""
return self.session.get(Tag, tag_id)

View File

@@ -189,19 +189,6 @@ def get_absolute_path(relative_path: str, storage_root: Path | None = None) -> P
return storage_root / relative_path
def file_exists(relative_path: str, storage_root: Path | None = None) -> bool:
"""Check if a file exists in storage.
Args:
relative_path: Relative path stored in database.
storage_root: Optional custom storage root.
Returns:
True if file exists.
"""
return get_absolute_path(relative_path, storage_root).exists()
def read_pdf(relative_path: str, storage_root: Path | None = None) -> bytes:
"""Read PDF content from storage.

View File

@@ -1,15 +1,13 @@
"""Orchestrateur principal pour l'extraction des comptes rendus de gérance."""
import logging
from .parsers.locataires import extract_situation_locataires
from .parsers.locataires_table import extract_situation_locataires_from_pdf
from .parsers.metadata import extract_metadata
from .parsers.operations import extract_recapitulatif_operations
from .parsers.operations_table import extract_recapitulatif_operations_from_pdf
from .parsers.pdf import read_pdf
logger = logging.getLogger(__name__)
class ExtractionError(Exception):
"""Levée quand un PDF ne livre pas les données attendues d'un compte rendu."""
def extract_compte_rendu(pdf_path: str) -> dict:
@@ -29,28 +27,23 @@ def extract_compte_rendu(pdf_path: str) -> dict:
Raises:
FileNotFoundError: Si le fichier PDF n'existe pas
ExtractionError: Si aucun lot n'a pu être lu
"""
content = read_pdf(pdf_path)
# Extraction des locataires par cellules de tableau (géométrique) : robuste aux
# colonnes vides et aux lignes mal alignées. Repli sur l'ancien parseur texte si
# le tableau n'a pas de filets détectables ou en cas d'erreur inattendue.
try:
situation = extract_situation_locataires_from_pdf(pdf_path)
except Exception:
logger.exception("Extraction locataires par cellules échouée, repli sur le parseur texte")
situation = []
# Un compte rendu sans aucun lot n'existe pas : c'est le signe que le tableau
# n'a pas été reconnu (PDF d'un autre type, mise en page inconnue, scan
# image). Échouer ici vaut mieux qu'enregistrer un document vide, qui ne se
# découvrirait qu'au moment de relire les chiffres.
situation = extract_situation_locataires_from_pdf(pdf_path)
if not situation:
situation = extract_situation_locataires(content.text)
raise ExtractionError(
"Aucun lot n'a pu être lu dans ce PDF : le tableau « situation des "
"locataires » est absent ou dans un format non reconnu."
)
# Opérations par cellules de tableau, même repli sur le parseur texte.
try:
operations = extract_recapitulatif_operations_from_pdf(pdf_path)
except Exception:
logger.exception("Extraction opérations par cellules échouée, repli sur le parseur texte")
operations = []
if not operations:
operations = extract_recapitulatif_operations(content.text)
# À l'inverse, un mois sans aucune dépense reste plausible : liste vide admise.
operations = extract_recapitulatif_operations_from_pdf(pdf_path)
return {
"metadata": extract_metadata(content.text, content.words),

View File

@@ -1,15 +1,14 @@
"""Parsers pour les différentes sections des PDFs de gérance."""
from .locataires import extract_situation_locataires
from .locataires_table import extract_situation_locataires_from_pdf
from .metadata import extract_metadata
from .operations import extract_recapitulatif_operations
from .pdf import PdfContent, extract_text_from_pdf, read_pdf
from .operations_table import extract_recapitulatif_operations_from_pdf
from .pdf import PdfContent, read_pdf
__all__ = [
"PdfContent",
"read_pdf",
"extract_text_from_pdf",
"extract_metadata",
"extract_situation_locataires",
"extract_recapitulatif_operations",
"extract_situation_locataires_from_pdf",
"extract_recapitulatif_operations_from_pdf",
]

View File

@@ -1,377 +0,0 @@
"""Extraction de la situation des locataires."""
import re
from ..utils.amounts import extract_amounts_from_line
from ..utils.dates import parse_french_date
from ..utils.lots import normalize_lot_numero
def _preprocess_locataires_text(text: str) -> str:
"""Prétraite le texte pour fusionner les sections sur plusieurs pages.
Supprime les éléments répétés sur chaque page pour permettre une extraction
continue des locataires dont les données s'étalent sur plusieurs pages.
Args:
text: Texte brut du PDF
Returns:
Texte nettoyé avec une seule section SITUATION DES LOCATAIRES
"""
lines = text.split("\n")
cleaned_lines = []
first_situation_found = False
# Patterns à ignorer (en-têtes répétés sur chaque page)
skip_patterns = [
r"^\s*ROSIER-MODICA\s*$",
r"^\s*9 rue Juliette Récamier\s*$",
r"^\s*69455 Lyon Cedex 06\s*$",
r"^\s*S\.C\.I\.\s*PLESNA\s*$",
r"^\s*Immeuble\s*:\s*\d+\s*$",
r"^\s*\d+\s*RUE\s+", # Adresse immeuble
r"^\s*69\d{3}\s+LYON\s*$", # Code postal + ville
r"^\s*Lyon le \d{2}/\d{2}/\d{4}\s*$", # Date
r"^\s*Powered by ICS\s*$",
r"^\s*\d+\s*/\s*\d+\s*$", # Numéro de page (ex: 2/5)
r"^\s*Capital de", # Pied de page
r"^\s*Garantie de", # Pied de page
]
# Pattern pour l'en-tête de colonnes
header_pattern = r"^\s*Locataires\s+Période\s+Loyers"
for line in lines:
stripped = line.strip()
# Ignorer les lignes vides
if not stripped:
cleaned_lines.append(line)
continue
# Vérifier si c'est un pattern à ignorer
should_skip = False
for pattern in skip_patterns:
if re.match(pattern, stripped, re.IGNORECASE):
should_skip = True
break
if should_skip:
continue
# Gérer SITUATION DES LOCATAIRES
if "SITUATION DES LOCATAIRES" in stripped:
if not first_situation_found:
first_situation_found = True
cleaned_lines.append(line)
# Ignorer les occurrences suivantes
continue
# Ignorer les en-têtes de colonnes répétés
if re.match(header_pattern, stripped):
continue
cleaned_lines.append(line)
return "\n".join(cleaned_lines)
def extract_situation_locataires(text: str) -> list[dict]:
"""Extrait la situation des locataires.
Args:
text: Texte complet du PDF
Returns:
Liste des situations par lot, chacune contenant:
- lot: numéro (2 chiffres) et type
- locataire: nom
- lignes: détail des loyers, charges, etc.
- totaux: sommes par catégorie
"""
situations: list[dict] = []
# Prétraiter le texte pour gérer les lots sur plusieurs pages
text = _preprocess_locataires_text(text)
# Trouver toutes les sections "SITUATION DES LOCATAIRES"
sections = text.split("SITUATION DES LOCATAIRES")
for section in sections[1:]: # Skip avant le premier titre
# Couper à la fin de la section
section = section.split("RECAPITULATIF")[0]
section = section.split("VOTRE PATRIMOINE")[0]
lines = section.split("\n")
current_lot: dict | None = None
for line in lines:
line_stripped = line.strip()
if not line_stripped:
continue
# Nouveau lot (peut avoir les données de loyer sur la même ligne)
lot_match = re.match(
r"Lot\s+(\d{1,4})\s+(Loc\.\s*Commercial|Appartement\s+T\d|Studio|Garage|Cave|Parking)",
line_stripped,
)
if lot_match:
if current_lot:
situations.append(current_lot)
lot_num = normalize_lot_numero(lot_match.group(1))
lot_type = lot_match.group(2)
current_lot = {
"lot": {"numero": lot_num, "type": lot_type},
"locataire": {"nom": ""},
"lignes": [],
"totaux": {
"solde_anterieur": 0.0,
"loyers": 0.0,
"taxes": 0.0,
"provisions": 0.0,
"divers": 0.0,
"total": 0.0,
"regles": 0.0,
"impayes": 0.0,
},
}
# Chercher le reste de la ligne après le type de lot
remaining = line_stripped[lot_match.end() :].strip()
# Vérifier si il y a une période de loyer sur la même ligne
loyer_inline = re.search(
r"Du\s+(\d{2}\.\d{2}\.\d{2})\s+Au\s+(\d{2}\.\d{2}\.\d{2})",
remaining,
)
if loyer_inline:
# Le nom du locataire sera sur la ligne suivante
# Extraire la ligne de loyer
debut = parse_french_date(loyer_inline.group(1))
fin = parse_french_date(loyer_inline.group(2))
amounts = extract_amounts_from_line(remaining)
ligne = {
"type": "loyer",
"periode": {"debut": debut, "fin": fin},
"loyers": amounts[0] if len(amounts) > 0 else 0.0,
"taxes": amounts[1] if len(amounts) > 1 else 0.0,
"provisions": amounts[2] if len(amounts) > 2 else 0.0,
"divers": {"montant": 0.0, "libelle": None},
"total": amounts[3] if len(amounts) > 3 else 0.0,
"regles": amounts[4] if len(amounts) > 4 else 0.0,
"impayes": amounts[5] if len(amounts) > 5 else 0.0,
}
current_lot["lignes"].append(ligne)
else:
# Chercher le nom du locataire (avant Du ou avant les espaces multiples)
name_match = re.match(
r"([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][A-Za-zàâäéèêëïîôùûüç\-\s]+?)(?:\s{2,}|$)",
remaining,
)
if name_match:
current_lot["locataire"]["nom"] = name_match.group(1).strip()
continue
if not current_lot:
continue
# Mise à jour du nom si trouvé sur ligne séparée
if not current_lot["locataire"]["nom"]:
# Exclure les faux positifs
excluded = [
"Solde",
"Du ",
"Totaux",
"Powered by",
"SITUATION",
"RECAPITULATIF",
"Locataires",
"Période",
"Rappel",
]
if not any(x in line_stripped for x in excluded):
name_match = re.match(
r"^([A-ZÀÂÄÉÈÊËÏÎÔÙÛÜ][A-Za-zàâäéèêëïîôùûüç\-\s]+?)(?:\s{2,}|$)",
line_stripped,
)
if name_match:
current_lot["locataire"]["nom"] = name_match.group(1).strip()
continue
# Solde Antérieur
if "Solde Antérieur" in line_stripped:
amounts = extract_amounts_from_line(line_stripped)
if amounts:
montant = amounts[0]
ligne = {
"type": "solde_anterieur",
"periode": {"debut": None, "fin": None},
"loyers": montant,
"taxes": 0.0,
"provisions": 0.0,
"divers": {"montant": 0.0, "libelle": None},
"total": amounts[1] if len(amounts) > 1 else montant,
"regles": amounts[2] if len(amounts) > 2 else 0.0,
"impayes": amounts[3] if len(amounts) > 3 else 0.0,
}
current_lot["lignes"].append(ligne)
current_lot["totaux"]["solde_anterieur"] = montant
continue
# Ligne de loyer: Du DD.MM.YY Au DD.MM.YY (sur ligne séparée)
loyer_match = re.search(
r"Du\s+(\d{2}\.\d{2}\.\d{2})\s+Au\s+(\d{2}\.\d{2}\.\d{2})",
line_stripped,
)
if loyer_match and "Rappel" not in line_stripped:
debut = parse_french_date(loyer_match.group(1))
fin = parse_french_date(loyer_match.group(2))
amounts = extract_amounts_from_line(line_stripped)
# Chercher un libellé divers
divers_patterns = [
("Complément", "Complément"),
("Ordures", "Ordures ménagères"),
("Contrat entretien", "Contrat entretien chaudière"),
("Divers locatifs", "Divers locatifs"),
]
divers_match = None
divers_libelle = None
for pattern, libelle in divers_patterns:
if pattern in line_stripped:
divers_match = pattern
divers_libelle = libelle
break
# Déterminer si c'est une ligne purement "divers"
is_divers_line = False
if divers_match:
# Trouver la position du libellé divers et du premier montant
divers_pos = line_stripped.find(divers_match)
# Chercher le premier montant après "Au DD.MM.YY"
after_date = line_stripped[loyer_match.end() :]
first_amount_match = re.search(r"\d+[,\.]\d{2}", after_date)
if first_amount_match:
first_amount_pos = (
loyer_match.end() + first_amount_match.start()
)
# Si le libellé divers est AVANT le premier montant, c'est une ligne divers
is_divers_line = divers_pos < first_amount_pos
if is_divers_line:
# Ligne de type divers uniquement
ligne = {
"type": "divers",
"periode": {"debut": debut, "fin": fin},
"loyers": 0.0,
"taxes": 0.0,
"provisions": 0.0,
"divers": {
"montant": amounts[0] if len(amounts) > 0 else 0.0,
"libelle": divers_libelle,
},
"total": amounts[1] if len(amounts) > 1 else 0.0,
"regles": amounts[2] if len(amounts) > 2 else 0.0,
"impayes": amounts[3] if len(amounts) > 3 else 0.0,
}
else:
# Ligne de loyer normale
ligne = {
"type": "loyer",
"periode": {"debut": debut, "fin": fin},
"loyers": amounts[0] if len(amounts) > 0 else 0.0,
"taxes": amounts[1] if len(amounts) > 1 else 0.0,
"provisions": amounts[2] if len(amounts) > 2 else 0.0,
"divers": {"montant": 0.0, "libelle": None},
"total": 0.0,
"regles": 0.0,
"impayes": 0.0,
}
# Vérifier si il y a aussi un divers sur cette ligne (après les montants loyer)
if divers_match and len(amounts) > 3:
ligne["divers"] = {
"montant": amounts[3],
"libelle": divers_libelle,
}
ligne["total"] = amounts[4] if len(amounts) > 4 else 0.0
ligne["regles"] = amounts[5] if len(amounts) > 5 else 0.0
ligne["impayes"] = amounts[6] if len(amounts) > 6 else 0.0
else:
ligne["total"] = amounts[3] if len(amounts) > 3 else 0.0
ligne["regles"] = amounts[4] if len(amounts) > 4 else 0.0
ligne["impayes"] = amounts[5] if len(amounts) > 5 else 0.0
current_lot["lignes"].append(ligne)
continue
# Rappel de Loyer
rappel_match = re.search(
r"Rappel de Loyer\s+Du\s+(\d{2}\.\d{2}\.\d{2})\s+Au\s+(\d{2}\.\d{2}\.\d{2})",
line_stripped,
)
if rappel_match:
amounts = extract_amounts_from_line(line_stripped)
montant = amounts[0] if amounts else 0.0
current_lot["lignes"].append(
{
"type": "rappel_loyer",
"periode": {
"debut": parse_french_date(rappel_match.group(1)),
"fin": parse_french_date(rappel_match.group(2)),
},
"loyers": montant,
"taxes": 0.0,
"provisions": 0.0,
"divers": {"montant": 0.0, "libelle": None},
"total": amounts[1] if len(amounts) > 1 else montant,
"regles": amounts[2] if len(amounts) > 2 else 0.0,
"impayes": amounts[3] if len(amounts) > 3 else 0.0,
}
)
continue
# Ligne Totaux (pas TOTAUX généraux)
if line_stripped.startswith("Totaux") and "TOTAUX" not in line_stripped:
amounts = extract_amounts_from_line(line_stripped)
if len(amounts) >= 6:
# Déterminer si le premier est un solde antérieur
idx = 0
if (
current_lot["totaux"]["solde_anterieur"] > 0
and len(amounts) >= 7
):
idx = 1 # Skip le solde antérieur répété
current_lot["totaux"]["loyers"] = (
amounts[idx] if idx < len(amounts) else 0.0
)
current_lot["totaux"]["taxes"] = (
amounts[idx + 1] if idx + 1 < len(amounts) else 0.0
)
current_lot["totaux"]["provisions"] = (
amounts[idx + 2] if idx + 2 < len(amounts) else 0.0
)
current_lot["totaux"]["divers"] = (
amounts[idx + 3] if idx + 3 < len(amounts) else 0.0
)
current_lot["totaux"]["total"] = (
amounts[idx + 4] if idx + 4 < len(amounts) else 0.0
)
current_lot["totaux"]["regles"] = (
amounts[idx + 5] if idx + 5 < len(amounts) else 0.0
)
if idx + 6 < len(amounts):
current_lot["totaux"]["impayes"] = amounts[idx + 6]
if current_lot:
situations.append(current_lot)
return situations

View File

@@ -1,209 +0,0 @@
"""Extraction du récapitulatif des opérations."""
import re
from ..utils.amounts import parse_amount
from ..utils.lots import normalize_lot_numero
def _extract_lot_code_from_description(description: str) -> str | None:
"""Extrait le code lot depuis la description de l'opération.
Les codes lots suivent le format: {Lettre}{Numéro} où:
- La lettre identifie l'immeuble (M=Marietton, S=Servient, B=Bloch, etc.)
- Le numéro correspond au lot (ex: 06 -> lot 06)
Exemples:
- "M06 - Commande moteur pompe" -> "06"
- "S05 - Mise en service" -> "05"
- "B01 - Plaques" -> "01"
Args:
description: Description de l'opération
Returns:
Code lot au format 2 chiffres (ex: "06") ou None si non trouvé
"""
if not description:
return None
# Pattern: lettre majuscule + (espace optionnelle) + 1-2 chiffres, terminé par
# un espace, un tiret ou la fin. Gère "S10 - ...", "S 17 - ..." (espace dans le
# code) et "S01 SOLDE ..." (code lot non suivi d'un tiret).
match = re.search(r"\b[A-Z]\s*(\d{1,2})(?=[\s-]|$)", description)
if match:
return normalize_lot_numero(match.group(1))
return None
def extract_recapitulatif_operations(text: str) -> list[dict]:
"""Extrait le récapitulatif des opérations.
Args:
text: Texte complet du PDF
Returns:
Liste plate des opérations, chacune contenant:
- categorie: catégorie normalisée (ex: DEPENSES_LOCATIVES)
- sous_categorie: type d'opération (ex: Contrat entreprise nettoyage)
- fournisseur: nom du fournisseur
- description: description de l'opération
- lot_concerne: lot concerné si applicable
- montants: dict avec debit, credit, tva, locatif, deductible
"""
operations: list[dict] = []
# Catégories à identifier (libellé PDF -> format normalisé)
cat_keywords = {
"DEPENSES LOCATIVES": "DEPENSES_LOCATIVES",
"DEPENSES DEDUCTIBLES": "DEPENSES_DEDUCTIBLES",
"DEPENSES NON RECUPERABLES": "DEPENSES_NON_RECUPERABLES",
"DEPENSES RECUPERABLES PAR LOT": "DEPENSES_RECUPERABLES",
"HONORAIRES DE GESTION": "HONORAIRES_DE_GESTION",
"DIVERS": "DIVERS",
}
# Trouver les sections RECAPITULATIF
sections = text.split("RECAPITULATIF DES OPERATIONS")
for section in sections[1:]:
section = section.split("VOTRE PATRIMOINE")[0]
section = section.split("Solde créditeur en Euros")[0]
lines = section.split("\n")
current_cat_normalized: str | None = None
current_fournisseur: str | None = None
current_lot: str | None = None
current_sous_cat: str | None = None
for line in lines:
stripped = line.strip()
if not stripped:
continue
# Ignorer les lignes de totaux et headers
if any(
x in stripped
for x in [
"Totaux Généraux",
"TOTAL DES REGLEMENTS",
"Débits",
"Crédits",
"Dont T.V.A.",
]
):
continue
if stripped.startswith("TOTAUX"):
continue
# Détecter une catégorie
found_cat_normalized = None
for kw, cat_normalized in cat_keywords.items():
if kw in stripped:
found_cat_normalized = cat_normalized
# Extraire le lot si présent
lot_match = re.search(r"(?:LOT|/LOT)\s+(.+?)(?:\s{2,}|$)", stripped)
if lot_match:
current_lot = lot_match.group(1).strip()
else:
current_lot = None
break
if found_cat_normalized:
current_cat_normalized = found_cat_normalized
current_fournisseur = None
current_sous_cat = None
continue
if not current_cat_normalized:
continue
# Type d'opération / sous-catégorie (ex: "Contrat entreprise nettoyage")
sous_cat_patterns = [
"Nettoyage",
"Electricité",
"Contrat",
"Travaux",
"Frais",
"Honoraires",
"TVA",
"Plaques",
"Curage",
"Lavage",
"Reglt",
]
for pattern in sous_cat_patterns:
if stripped.startswith(pattern):
current_sous_cat = stripped.split(" ")[0].strip()
break
# Fournisseur (NOM EN MAJUSCULES)
fournisseur_match = re.match(
r"^([A-Z][A-Z\s\-\(\)]+?)(?:\s{2,}|$)", stripped
)
if fournisseur_match:
potential = fournisseur_match.group(1).strip()
if len(potential) > 3 and not any(
x in potential
for x in [
"TOTAUX",
"DEPENSES",
"HONORAIRES",
"DIVERS",
"TOTAL",
"TVA",
]
):
current_fournisseur = potential
# Extraire les montants (séparés par des espaces à la fin de ligne)
# Exclure les années (4 chiffres sans décimale)
amounts_pattern = r"(?<!\d)(\d{1,3}(?:[\s\u00a0]?\d{3})*[,\.]\d{2})(?!\d)"
amounts = re.findall(amounts_pattern, stripped)
if amounts and len(amounts) >= 1:
amounts_float = [parse_amount(a) for a in amounts]
# Extraire la description (avant le premier montant)
first_amount_match = re.search(amounts_pattern, stripped)
if first_amount_match:
description = stripped[: first_amount_match.start()].strip()
else:
description = stripped
# Nettoyer la description
if current_fournisseur and description.startswith(current_fournisseur):
description = description[len(current_fournisseur) :].strip()
# Réduire les espaces multiples (séparateurs de colonnes) en un seul
description = re.sub(r"\s{2,}", " ", description).strip()
if description and not description.startswith("Totaux"):
# Extraire le numéro de lot depuis la description (ex: M06 -> 0006)
lot_numero = _extract_lot_code_from_description(description)
operation = {
"categorie": current_cat_normalized,
"sous_categorie": current_sous_cat or "",
"fournisseur": current_fournisseur,
"description": description,
"lot_concerne": current_lot,
"lot_numero": lot_numero,
"montants": {
"debit": amounts_float[0]
if len(amounts_float) > 0
else 0.0,
"credit": 0.0,
"tva": amounts_float[1] if len(amounts_float) > 1 else 0.0,
"locatif": amounts_float[2]
if len(amounts_float) > 2
else 0.0,
"deductible": amounts_float[3]
if len(amounts_float) > 3
else 0.0,
},
}
operations.append(operation)
return operations

View File

@@ -20,7 +20,7 @@ from unicodedata import normalize as _normalize
import pdfplumber
from ..utils.amounts import extract_amounts_from_line
from .operations import _extract_lot_code_from_description
from ..utils.lots import extract_lot_numero_from_description
_Y_TOL = 3.0
@@ -218,7 +218,7 @@ def extract_recapitulatif_operations_from_pdf(pdf_path: str) -> list[dict]:
"fournisseur": current_fournisseur,
"description": desc,
"lot_concerne": None,
"lot_numero": _extract_lot_code_from_description(desc),
"lot_numero": extract_lot_numero_from_description(desc),
"montants": {k: (montants[k] or 0.0) for k in _AMOUNT_KEYS},
"_block": block_id,
}

View File

@@ -57,18 +57,3 @@ def read_pdf(pdf_path: str) -> PdfContent:
]
return PdfContent(text="\n".join(text_parts), words=header_words)
def extract_text_from_pdf(pdf_path: str) -> str:
"""Extrait le texte du PDF avec mise en page préservée.
Conserve la signature historique (retourne une chaîne) pour la CLI et
les parseurs qui ne consomment que le texte.
Args:
pdf_path: Chemin vers le fichier PDF
Returns:
Texte extrait du PDF avec mise en page préservée
"""
return read_pdf(pdf_path).text

View File

@@ -0,0 +1,138 @@
"""Règles d'agrégation des revenus : ce qui se cumule et ce qui ne se cumule pas.
Deux natures de grandeurs cohabitent dans la table ``revenus``, et les
confondre fausse tous les totaux :
- un **flux** est un événement daté (un loyer facturé en mars, un paiement reçu
en avril). Il s'additionne dans le temps et entre les lots ;
- un **stock** est une photo à un instant (ce qui reste dû au 22/06). Il
s'additionne entre les lots à une même date, jamais dans le temps :
additionner deux photos du même solde compte deux fois la même dette.
Chaque compte rendu reporte la dette du précédent dans une ligne
``solde_anterieur``. Cumuler ces lignes sur une période revient donc à recompter
la même créance autant de fois qu'il y a de documents — et rend surtout
impossible d'enregistrer un remboursement : une dette soldée resterait dans le
total à vie.
Attention, **c'est la colonne qui porte la nature, pas la ligne**. Une ligne de
report contient les deux : sa colonne ``total`` est un stock déjà compté le mois
précédent, mais sa colonne ``regles`` est un encaissement bien réel de la
période. Écarter la ligne entière ferait disparaître de l'argent reçu.
"""
from datetime import date
from sqlalchemy import and_, func, select
from ..database.models import Document, Revenu
#: Type des lignes qui reportent le solde du compte rendu précédent.
TYPE_LIGNE_REPORT = "solde_anterieur"
def est_flux():
"""Condition : la ligne décrit un événement de la période, pas un report."""
return Revenu.type_ligne != TYPE_LIGNE_REPORT
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 derniers_comptes_rendus(
date_debut: date | None = None, date_fin: date | None = None
):
"""Sous-requête : date du dernier compte rendu de chaque immeuble.
Le dernier compte rendu est retenu **par immeuble** : avec plusieurs
immeubles, le dernier document tous immeubles confondus n'en décrirait
qu'un seul et les autres perdraient leur solde.
"""
stmt = select(
Document.immeuble_id.label("immeuble_id"),
func.max(Document.date).label("date"),
).group_by(Document.immeuble_id)
return _borner(stmt, date_debut, date_fin).subquery()
def restant_du_par(
cle, date_debut: date | None = None, date_fin: date | None = None
):
"""Sous-requête : restant dû (stock) regroupé par `cle`.
Seules les lignes du dernier compte rendu de chaque immeuble sont lues :
c'est la seule photo à jour. Un lot absent de ce compte rendu est sorti de
la gestion et ne compte plus — les présences observées sont contiguës, une
absence n'est jamais un simple trou.
Args:
cle: colonne de regroupement (``Revenu.lot_id``, ``Document.immeuble_id``…)
date_debut: borne basse optionnelle sur la date du document
date_fin: borne haute optionnelle
Returns:
Sous-requête exposant `cle` et ``restant_du``.
"""
derniers = derniers_comptes_rendus(date_debut, date_fin)
return (
select(
cle.label("cle"),
func.sum(Revenu.impayes).label("restant_du"),
)
.join(Document, Revenu.document_id == Document.id)
.join(
derniers,
and_(
derniers.c.immeuble_id == Document.immeuble_id,
derniers.c.date == Document.date,
),
)
.group_by(cle)
.subquery()
)
def flux_par(cle, date_debut: date | None = None, date_fin: date | None = None):
"""Sous-requête : montants cumulables (flux) regroupés par `cle`.
Expose :
- ``facture`` : ce qui a été facturé sur la période, report exclu ;
- ``encaisse`` : tout ce qui a été reçu, **y compris** les règlements de
dettes anciennes portés par les lignes de report ;
- ``facture_regle`` : la part de ``facture`` qui a été réglée. Sert au taux
de recouvrement, qui doit comparer un périmètre homogène : rapporter
``encaisse`` à ``facture`` ferait dépasser 100 % dès qu'une vieille dette
est rattrapée.
"""
flux = est_flux()
stmt = (
select(
cle.label("cle"),
func.sum(Revenu.loyers).filter(flux).label("loyers"),
func.sum(Revenu.taxes).filter(flux).label("taxes"),
func.sum(Revenu.provisions).filter(flux).label("provisions"),
func.sum(Revenu.total).filter(flux).label("facture"),
func.sum(Revenu.regles).label("encaisse"),
func.sum(Revenu.regles).filter(flux).label("facture_regle"),
)
.join(Document, Revenu.document_id == Document.id)
.group_by(cle)
)
return _borner(stmt, date_debut, date_fin).subquery()
def taux_de_recouvrement(facture: float | None, facture_regle: float | None) -> float:
"""Part du facturé qui a été réglée, en pourcentage.
Sans rien de facturé, il n'y a rien à recouvrer : le taux vaut 100 %.
"""
facture = facture or 0.0
if facture <= 0:
return 100.0
return round((facture_regle or 0.0) / facture * 100, 1)

View File

@@ -48,6 +48,36 @@ def normalize_lot_numero(value: str | int | None) -> str | None:
return significant.zfill(LOT_NUMERO_WIDTH)
def extract_lot_numero_from_description(description: str) -> str | None:
"""Extrait le numéro de lot depuis la description d'une opération.
Les codes lots suivent le format {Lettre}{Numéro} où la lettre identifie
l'immeuble (M=Marietton, S=Servient, B=Bloch…) et le numéro le lot.
Exemples:
- "M06 - Commande moteur pompe" -> "06"
- "S05 - Mise en service" -> "05"
- "B01 - Plaques" -> "01"
Args:
description: Description de l'opération
Returns:
Numéro sur 2 chiffres (ex: "06") ou None si non trouvé
"""
if not description:
return None
# Pattern: lettre majuscule + (espace optionnelle) + 1-2 chiffres, terminé par
# un espace, un tiret ou la fin. Gère "S10 - ...", "S 17 - ..." (espace dans le
# code) et "S01 SOLDE ..." (code lot non suivi d'un tiret).
match = re.search(r"\b[A-Z]\s*(\d{1,2})(?=[\s-]|$)", description)
if match:
return normalize_lot_numero(match.group(1))
return None
def normalize_extraction_lots(data: dict[str, Any]) -> dict[str, Any]:
"""Normalise, sur place, tous les numéros de lot d'une extraction.

View File

@@ -5,6 +5,22 @@ import pytest
from plesna_gerance.database import connection
def pytest_addoption(parser):
"""Ajoute --regen-golden pour refiger les références des parseurs."""
parser.addoption(
"--regen-golden",
action="store_true",
default=False,
help="Réécrit les références de tests/golden au lieu de les comparer.",
)
@pytest.fixture
def regen_golden(request) -> bool:
"""Vrai quand la suite est lancée avec --regen-golden."""
return request.config.getoption("--regen-golden")
@pytest.fixture
def db_session(tmp_path, monkeypatch):
"""Session SQLAlchemy sur une base SQLite temporaire et isolée.
@@ -28,6 +44,27 @@ def db_session(tmp_path, monkeypatch):
connection.reset_connection()
@pytest.fixture
def api_client(db_session):
"""Client HTTP sur l'application FastAPI, branché sur la base de test.
La dépendance `get_session` est surchargée pour partager la session du test :
les données créées dans le test sont visibles par les endpoints, sans passer
par la base réelle de l'utilisateur.
"""
from fastapi.testclient import TestClient
from plesna_gerance.api.app import app
from plesna_gerance.database import get_session
app.dependency_overrides[get_session] = lambda: db_session
try:
with TestClient(app) as client:
yield client
finally:
app.dependency_overrides.clear()
@pytest.fixture
def sample_data():
"""Données extraites minimales mais complètes pour save_document."""

View File

@@ -0,0 +1,101 @@
"""Le dashboard et la page Revenus doivent annoncer les memes chiffres.
Les deux ecrans calculaient leurs totaux chacun de leur cote, avec des regles
differentes : l'accueil lisait le dernier compte rendu, la page Revenus cumulait
tout. Ils affichaient donc deux montants d'impayes pour la meme notion. Ces
tests verrouillent leur accord.
"""
import copy
from datetime import date, timedelta
import pytest
from plesna_gerance.database.service import DatabaseService
def _mois_glissant(recul: int) -> str:
jour = date.today().replace(day=15)
for _ in range(recul):
jour = (jour.replace(day=1) - timedelta(days=1)).replace(day=15)
return jour.isoformat()
@pytest.fixture
def deux_comptes_rendus(db_session, sample_data):
"""Un impaye de 300 ne le mois dernier, reporte et non regle ce mois-ci."""
service = DatabaseService(db_session)
premier = copy.deepcopy(sample_data)
premier["metadata"]["document"]["reference"] = "M1"
premier["metadata"]["document"]["date"] = _mois_glissant(1)
premier["situation_locataires"][0]["lignes"] = [
{
"type": "loyer",
"periode": {"debut": None, "fin": None},
"loyers": 800.0,
"total": 800.0,
"regles": 500.0,
"impayes": 300.0,
}
]
service.save_document(data=premier)
second = copy.deepcopy(sample_data)
second["metadata"]["document"]["reference"] = "M2"
second["metadata"]["document"]["date"] = _mois_glissant(0)
second["situation_locataires"][0]["lignes"] = [
{
"type": "solde_anterieur",
"periode": {"debut": None, "fin": None},
"loyers": 300.0,
"total": 300.0,
"regles": 0.0,
"impayes": 300.0,
},
{
"type": "loyer",
"periode": {"debut": None, "fin": None},
"loyers": 800.0,
"total": 800.0,
"regles": 800.0,
"impayes": 0.0,
},
]
service.save_document(data=second)
return db_session
def test_les_deux_ecrans_annoncent_le_meme_impaye(api_client, deux_comptes_rendus):
"""300 dus, vus depuis l'accueil comme depuis la page Revenus."""
accueil = api_client.get("/api/dashboard/financial-summary").json()
revenus = api_client.get("/api/revenus/summary").json()
assert accueil["impayes"] == 300.0
assert revenus["kpis"]["total_impayes"] == 300.0
def test_le_revenu_du_dernier_compte_rendu_exclut_le_report(
api_client, deux_comptes_rendus
):
"""Le mois vaut son loyer de 800, pas 1100 report compris."""
accueil = api_client.get("/api/dashboard/financial-summary").json()
assert accueil["revenus"] == 800.0
def test_les_raccourcis_immeubles_ne_cumulent_pas_la_dette(
api_client, deux_comptes_rendus
):
"""Le raccourci montre la dette en cours, pas sa somme mois apres mois."""
(raccourci,) = api_client.get("/api/dashboard/immeubles-shortcuts").json()
assert raccourci["total_impayes"] == 300.0
assert raccourci["total_revenus"] == 1600.0 # 800 + 800, report exclu
def test_la_tendance_mensuelle_reste_comparable(api_client, deux_comptes_rendus):
"""Chaque mois pese son loyer, sinon le second parait meilleur qu'il n'est."""
tendances = api_client.get("/api/dashboard/monthly-trends").json()
assert [point["revenus"] for point in tendances] == [800.0, 800.0]

63
tests/test_extractor.py Normal file
View File

@@ -0,0 +1,63 @@
"""Tests de l'orchestrateur d'extraction.
Depuis la suppression des parseurs texte de repli, le parseur geometrique est le
seul chemin : ce qu'il ne lit pas est definitivement perdu. L'orchestrateur doit
donc distinguer un PDF illisible d'un mois calme.
"""
import pytest
from plesna_gerance import extractor
from plesna_gerance.extractor import ExtractionError, extract_compte_rendu
from plesna_gerance.parsers.pdf import PdfContent
_UN_LOT = [{"lot": {"numero": "01", "type": "Appartement T2"}, "lignes": []}]
@pytest.fixture
def parseurs(monkeypatch):
"""Neutralise la lecture PDF et pilote ce que renvoie chaque parseur."""
def configurer(situation, operations):
monkeypatch.setattr(
extractor, "read_pdf", lambda _: PdfContent(text="", words=[])
)
monkeypatch.setattr(extractor, "extract_metadata", lambda *_: {})
monkeypatch.setattr(
extractor, "extract_situation_locataires_from_pdf", lambda _: situation
)
monkeypatch.setattr(
extractor, "extract_recapitulatif_operations_from_pdf", lambda _: operations
)
return configurer
def test_sans_aucun_lot_l_extraction_echoue(parseurs):
"""Un PDF dont le tableau des locataires est illisible doit lever."""
parseurs(situation=[], operations=[{"categorie": "DIVERS"}])
with pytest.raises(ExtractionError, match="Aucun lot"):
extract_compte_rendu("document.pdf")
def test_sans_operation_l_extraction_reussit(parseurs):
"""Un mois sans depense est plausible : la liste vide est acceptee."""
parseurs(situation=_UN_LOT, operations=[])
resultat = extract_compte_rendu("document.pdf")
assert resultat["situation_locataires"] == _UN_LOT
assert resultat["recapitulatif_operations"] == []
def test_extraction_complete(parseurs):
"""Cas nominal : les deux sections sont remontees telles quelles."""
operations = [{"categorie": "DEPENSES_LOCATIVES"}]
parseurs(situation=_UN_LOT, operations=operations)
resultat = extract_compte_rendu("document.pdf")
assert resultat["situation_locataires"] == _UN_LOT
assert resultat["recapitulatif_operations"] == operations
assert "metadata" in resultat

View File

@@ -2,8 +2,11 @@
import pytest
from plesna_gerance.parsers.operations import _extract_lot_code_from_description
from plesna_gerance.utils.lots import normalize_extraction_lots, normalize_lot_numero
from plesna_gerance.utils.lots import (
extract_lot_numero_from_description,
normalize_extraction_lots,
normalize_lot_numero,
)
@pytest.mark.parametrize(
@@ -44,8 +47,8 @@ def test_normalize_lot_numero(raw, expected):
("", None),
],
)
def test_extract_lot_code_from_description(description, expected):
assert _extract_lot_code_from_description(description) == expected
def test_extract_lot_numero_from_description(description, expected):
assert extract_lot_numero_from_description(description) == expected
def test_normalize_extraction_lots():

View File

@@ -0,0 +1,155 @@
"""Tests de non-regression des parseurs sur les PDF reels.
Les parseurs sont le coeur du produit et la partie la plus exposee aux
regressions silencieuses : un decalage de colonne ne leve aucune exception, il
produit juste des montants faux. Ces tests figent, pour chaque PDF du corpus
local, une **empreinte** de l'extraction (structure des lots, totaux par lot,
montants par categorie d'operation) et signalent tout ecart.
Les comptes rendus contiennent des donnees personnelles : ni les PDF (`data/`)
ni les references generees (`tests/golden/`) ne sont versionnes. La suite se
saute donc d'elle-meme la ou le corpus est absent, CI comprise. Les libelles
sensibles (locataire, fournisseur, description) sont reduits a une empreinte
courte : un changement reste detecte, sans recopier la donnee.
Regenerer les references apres un changement volontaire de parseur :
uv run pytest tests/test_parsers_golden.py --regen-golden
Puis relire le `git diff`... qui n'existe pas ici : comparer a la main la sortie
avant/apres, ou versionner temporairement le dossier pour l'inspecter.
"""
import hashlib
import json
from pathlib import Path
import pytest
from plesna_gerance.parsers.locataires_table import (
extract_situation_locataires_from_pdf,
)
from plesna_gerance.parsers.operations_table import (
extract_recapitulatif_operations_from_pdf,
)
_RACINE = Path(__file__).resolve().parent.parent
_CORPUS = _RACINE / "data" / "documents"
_GOLDEN = Path(__file__).resolve().parent / "golden"
_MONTANTS_OPERATION = ("debit", "credit", "tva", "locatif", "deductible")
def _pdfs() -> list[Path]:
"""PDF du corpus local, tries pour un ordre de test stable."""
if not _CORPUS.is_dir():
return []
return sorted(_CORPUS.rglob("*.pdf"))
def _empreinte_libelle(valeur: str | None) -> str | None:
"""Empreinte courte d'un libelle sensible (nom, fournisseur, description).
Detecte toute modification du libelle sans en conserver le contenu.
"""
if not valeur:
return None
normalise = " ".join(valeur.split())
return hashlib.sha1(normalise.encode("utf-8")).hexdigest()[:8]
def _empreinte_lot(situation: dict) -> dict:
types_lignes: dict[str, int] = {}
for ligne in situation.get("lignes", []):
type_ligne = ligne.get("type", "?")
types_lignes[type_ligne] = types_lignes.get(type_ligne, 0) + 1
return {
"numero": situation.get("lot", {}).get("numero"),
"type": situation.get("lot", {}).get("type"),
"locataire": _empreinte_libelle(situation.get("locataire", {}).get("nom")),
"nb_lignes": len(situation.get("lignes", [])),
"types_lignes": dict(sorted(types_lignes.items())),
"totaux": situation.get("totaux", {}),
}
def _empreinte_operation(operation: dict) -> dict:
return {
"categorie": operation.get("categorie"),
"sous_categorie": _empreinte_libelle(operation.get("sous_categorie")),
"fournisseur": _empreinte_libelle(operation.get("fournisseur")),
"description": _empreinte_libelle(operation.get("description")),
"lot_numero": operation.get("lot_numero"),
"montants": operation.get("montants", {}),
}
def _empreinte(situations: list[dict], operations: list[dict]) -> dict:
"""Empreinte complete d'une extraction, comparable d'une execution a l'autre."""
par_categorie: dict[str, dict] = {}
for operation in operations:
categorie = operation.get("categorie") or "SANS_CATEGORIE"
agrege = par_categorie.setdefault(
categorie, {"nb": 0, **{cle: 0.0 for cle in _MONTANTS_OPERATION}}
)
agrege["nb"] += 1
for cle in _MONTANTS_OPERATION:
agrege[cle] = round(
agrege[cle] + (operation.get("montants", {}).get(cle) or 0.0), 2
)
return {
"situation_locataires": {
"nb_lots": len(situations),
"lots": [_empreinte_lot(situation) for situation in situations],
},
"recapitulatif_operations": {
"nb_operations": len(operations),
"par_categorie": dict(sorted(par_categorie.items())),
"operations": [_empreinte_operation(op) for op in operations],
},
}
@pytest.mark.skipif(not _pdfs(), reason="corpus PDF local absent (data/documents)")
@pytest.mark.parametrize("pdf", _pdfs(), ids=lambda p: p.stem)
def test_extraction_conforme_a_la_reference(pdf: Path, regen_golden: bool):
"""L'extraction d'un PDF reel reste identique a sa reference figee."""
obtenue = _empreinte(
extract_situation_locataires_from_pdf(str(pdf)),
extract_recapitulatif_operations_from_pdf(str(pdf)),
)
reference = _GOLDEN / f"{pdf.stem}.json"
if regen_golden:
_GOLDEN.mkdir(parents=True, exist_ok=True)
reference.write_text(
json.dumps(obtenue, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
pytest.skip(f"reference regeneree : {reference.name}")
if not reference.exists():
pytest.fail(
f"Reference absente pour {pdf.name}. "
"Generer avec : uv run pytest tests/test_parsers_golden.py --regen-golden"
)
assert obtenue == json.loads(reference.read_text(encoding="utf-8"))
@pytest.mark.skipif(not _pdfs(), reason="corpus PDF local absent (data/documents)")
def test_le_parseur_geometrique_couvre_tout_le_corpus():
"""Aucun PDF du corpus ne ressort vide du parseur geometrique.
Verrouille la decision de supprimer les parseurs texte de repli : le jour ou
un PDF echappe au parseur par cellules, ce test le signale plutot que de
laisser passer un document vide.
"""
vides = [
pdf.name
for pdf in _pdfs()
if not extract_situation_locataires_from_pdf(str(pdf))
]
assert vides == []

View File

@@ -0,0 +1,107 @@
"""Tests des agregats de revenus par immeuble.
Ces endpoints joignent revenus et locataires a partir du lot. Comme un lot
accumule les locataires successifs, une jointure naive multiplie chaque ligne de
revenu par le nombre d'occupants passes et gonfle silencieusement les totaux :
aucune erreur, juste des montants faux qui derivent avec l'anciennete du parc.
"""
import copy
from datetime import date, timedelta
import pytest
from plesna_gerance.database.models import Locataire, Revenu
from plesna_gerance.database.service import DatabaseService
def _mois_glissant(recul: int) -> str:
"""Date du 15 du mois, `recul` mois en arriere.
Les resumes ne portent que sur les derniers mois : des dates fixes
sortiraient de la fenetre avec le temps et videraient les tests de leur
substance sans jamais les faire echouer.
"""
jour = date.today().replace(day=15)
for _ in range(recul):
jour = (jour.replace(day=1) - timedelta(days=1)).replace(day=15)
return jour.isoformat()
@pytest.fixture
def lot_a_deux_locataires(db_session, sample_data):
"""Un lot occupe successivement par deux locataires, un revenu chacun.
Reproduit la situation courante d'une relocation : meme immeuble, meme lot,
deux noms differents sur deux comptes rendus.
"""
service = DatabaseService(db_session)
premier = copy.deepcopy(sample_data)
premier["metadata"]["document"]["date"] = _mois_glissant(2)
service.save_document(data=premier)
suivant = copy.deepcopy(sample_data)
suivant["metadata"]["document"]["reference"] = "REF002"
suivant["metadata"]["document"]["date"] = _mois_glissant(1)
suivant["situation_locataires"][0]["locataire"]["nom"] = "MARTIN"
suivant["situation_locataires"][0]["lignes"][0]["loyers"] = 600.0
suivant["situation_locataires"][0]["lignes"][0]["total"] = 600.0
suivant["situation_locataires"][0]["lignes"][0]["regles"] = 400.0
suivant["situation_locataires"][0]["lignes"][0]["impayes"] = 200.0
service.save_document(data=suivant)
# Le decor doit bien etre celui qu'on veut tester, sinon le test ne prouve rien.
assert db_session.query(Locataire).count() == 2
assert db_session.query(Revenu).count() == 2
return db_session
#: Le decor : 500 puis 600 factures, 500 puis 400 regles, 200 encore dus.
FACTURE = 1100.0
ENCAISSE = 900.0
RESTANT_DU = 200.0
def test_immeubles_ne_compte_pas_les_revenus_en_double(
api_client, lot_a_deux_locataires
):
"""/api/revenus/immeubles somme chaque revenu une seule fois."""
response = api_client.get("/api/revenus/immeubles")
assert response.status_code == 200
(immeuble,) = response.json()
assert immeuble["total_revenus"] == FACTURE
assert immeuble["total_regles"] == ENCAISSE
assert immeuble["total_impayes"] == RESTANT_DU
def test_summary_ne_compte_pas_les_revenus_en_double(
api_client, lot_a_deux_locataires
):
"""Le bloc by_immeuble de /api/revenus/summary somme comme les KPIs."""
response = api_client.get("/api/revenus/summary")
assert response.status_code == 200
corps = response.json()
(immeuble,) = corps["by_immeuble"]
assert immeuble["total_revenus"] == FACTURE
assert immeuble["total_regles"] == ENCAISSE
assert immeuble["total_impayes"] == RESTANT_DU
# Les deux blocs doivent raconter la meme chose.
assert corps["kpis"]["total_revenus"] == immeuble["total_revenus"]
assert corps["kpis"]["total_impayes"] == immeuble["total_impayes"]
def test_immeubles_compte_lots_et_locataires(api_client, lot_a_deux_locataires):
"""Les effectifs restent justes : un lot, ses deux occupants successifs."""
(immeuble,) = api_client.get("/api/revenus/immeubles").json()
assert immeuble["nb_lots"] == 1
assert immeuble["nb_locataires"] == 2
def test_taux_recouvrement_reste_coherent(api_client, lot_a_deux_locataires):
"""Le taux se deduit des totaux : il doit suivre la meme correction."""
(immeuble,) = api_client.get("/api/revenus/immeubles").json()
assert immeuble["taux_recouvrement"] == round(ENCAISSE / FACTURE * 100, 1)

View File

@@ -0,0 +1,148 @@
"""Tests de la distinction flux / stock dans les agregats de revenus.
Chaque compte rendu reporte la dette du precedent dans une ligne
`solde_anterieur`. Cumuler ces lignes recompte la meme creance a chaque
document et, surtout, empeche un remboursement de s'inscrire : une dette soldee
resterait affichee a vie.
Le decor rejoue le cycle observe en production : un locataire laisse un impaye,
le compte rendu suivant le reporte, il le solde, puis le reporte disparait.
"""
import copy
from datetime import date, timedelta
import pytest
from plesna_gerance.database.service import DatabaseService
def _mois_glissant(recul: int) -> str:
jour = date.today().replace(day=15)
for _ in range(recul):
jour = (jour.replace(day=1) - timedelta(days=1)).replace(day=15)
return jour.isoformat()
def _ligne(type_ligne: str, **montants) -> dict:
base = {
"type": type_ligne,
"periode": {"debut": None, "fin": None},
"loyers": 0.0,
"taxes": 0.0,
"provisions": 0.0,
"total": 0.0,
"regles": 0.0,
"impayes": 0.0,
}
base.update(montants)
return base
def _compte_rendu(sample_data: dict, reference: str, recul: int, lignes: list) -> dict:
data = copy.deepcopy(sample_data)
data["metadata"]["document"]["reference"] = reference
data["metadata"]["document"]["date"] = _mois_glissant(recul)
data["situation_locataires"][0]["lignes"] = lignes
data["recapitulatif_operations"] = []
return data
@pytest.fixture
def dette_reportee_puis_soldee(db_session, sample_data):
"""Trois mois : un impaye nait, il est reporte, il est solde.
Mois 1 : loyer de 800 dont 300 impayes.
Mois 2 : les 300 sont reportes et regles ; loyer de 800 regle en entier.
Mois 3 : plus aucun report ; loyer de 800 regle en entier.
"""
service = DatabaseService(db_session)
service.save_document(
data=_compte_rendu(
sample_data,
"M1",
2,
[_ligne("loyer", loyers=800.0, total=800.0, regles=500.0, impayes=300.0)],
)
)
service.save_document(
data=_compte_rendu(
sample_data,
"M2",
1,
[
_ligne("solde_anterieur", loyers=300.0, total=300.0, regles=300.0),
_ligne("loyer", loyers=800.0, total=800.0, regles=800.0),
],
)
)
service.save_document(
data=_compte_rendu(
sample_data,
"M3",
0,
[_ligne("loyer", loyers=800.0, total=800.0, regles=800.0)],
)
)
return db_session
def test_le_restant_du_est_celui_du_dernier_compte_rendu(
api_client, dette_reportee_puis_soldee
):
"""La dette soldee disparait : le cumul afficherait encore 300."""
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
assert kpis["total_impayes"] == 0.0
def test_le_facture_ignore_le_report(api_client, dette_reportee_puis_soldee):
"""3 loyers de 800 : le report de 300 n'est pas un revenu de plus."""
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
assert kpis["total_revenus"] == 2400.0
def test_l_encaisse_retient_le_reglement_d_une_vieille_dette(
api_client, dette_reportee_puis_soldee
):
"""Les 300 regles sur la ligne de report sont de l'argent bien recu.
C'est ce qui interdit d'ecarter la ligne de report en bloc : sa colonne
`total` est un stock deja compte, mais sa colonne `regles` est un flux.
"""
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
assert kpis["total_regles"] == 500.0 + 300.0 + 800.0 + 800.0
def test_le_taux_compare_un_perimetre_homogene(
api_client, dette_reportee_puis_soldee
):
"""Regle sur facture, report exclu des deux cotes : 2100 / 2400.
Rapporter l'encaisse (2400, rattrapage compris) au facture ferait afficher
un taux de 100 % alors qu'un impaye est ne sur la periode.
"""
kpis = api_client.get("/api/revenus/summary").json()["kpis"]
assert kpis["taux_recouvrement"] == round(2100.0 / 2400.0 * 100, 1)
def test_le_classement_des_impayes_oublie_qui_a_paye(
api_client, dette_reportee_puis_soldee
):
"""Un locataire a jour ne doit plus figurer parmi les debiteurs."""
top = api_client.get("/api/revenus/summary").json()["top_impayes"]
assert top == []
def test_les_mois_restent_comparables(api_client, dette_reportee_puis_soldee):
"""Chaque mois vaut son loyer : le report ne gonfle pas le mois 2."""
by_month = api_client.get("/api/revenus/summary").json()["by_month"]
assert [point["total"] for point in by_month] == [800.0, 800.0, 800.0]
# L'impaye reste le solde constate ce mois-la, pas un cumul.
assert [point["impayes"] for point in by_month] == [300.0, 0.0, 0.0]

41
tests/test_routage_spa.py Normal file
View File

@@ -0,0 +1,41 @@
"""Tests du partage des URL entre l'API et le SPA.
L'application sert l'API et l'interface sur le meme port : une route attrape
tout ce qui ne correspond a aucun endpoint pour le renvoyer au routeur Vue. Elle
ne doit pas avaler les URL d'API, sous peine de transformer une adresse erronee
en page HTML repondue avec un 200.
"""
import pytest
from plesna_gerance.api.app import FRONTEND_DIST
def test_une_url_d_api_inconnue_donne_404(api_client):
response = api_client.get("/api/nexiste-pas")
assert response.status_code == 404
assert response.headers["content-type"].startswith("application/json")
def test_une_url_d_api_inconnue_ne_renvoie_pas_le_spa(api_client):
"""Meme forme que les vraies routes : le prefixe seul ne suffit pas."""
response = api_client.get("/api/revenus/inconnu")
assert response.status_code == 404
def test_les_routes_d_api_existantes_repondent(api_client):
assert api_client.get("/api/health").status_code == 200
assert api_client.get("/api/tags").status_code == 200
@pytest.mark.skipif(
not FRONTEND_DIST.exists(), reason="frontend non construit (frontend/dist)"
)
def test_une_route_du_spa_renvoie_l_interface(api_client):
"""Les URL de l'interface restent servies par index.html."""
response = api_client.get("/documents")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/html")

58
tests/test_tags_api.py Normal file
View File

@@ -0,0 +1,58 @@
"""Tests des endpoints de gestion des tags.
Lecture, creation et renommage vivaient a deux adresses differentes
(`/api/tags` et `/api/config/tags`) : ces tests verrouillent l'adresse unique
retenue, celle que consomment desormais toutes les pages.
"""
def test_liste_les_tags_predefinis(api_client):
"""La base initialisee est deja pourvue de ses tags de depart."""
response = api_client.get("/api/tags")
assert response.status_code == 200
tags = response.json()
assert tags, "la base devrait etre amorcee avec des tags predefinis"
assert {"id", "nom"} == set(tags[0])
def test_cree_un_tag(api_client):
response = api_client.post("/api/tags", json={"nom": "Ravalement"})
assert response.status_code == 201
assert response.json()["nom"] == "Ravalement"
assert "Ravalement" in [tag["nom"] for tag in api_client.get("/api/tags").json()]
def test_refuse_un_tag_en_double(api_client):
api_client.post("/api/tags", json={"nom": "Ravalement"})
response = api_client.post("/api/tags", json={"nom": "Ravalement"})
assert response.status_code == 409
def test_refuse_un_nom_vide(api_client):
assert api_client.post("/api/tags", json={"nom": " "}).status_code == 400
def test_renomme_un_tag(api_client):
tag_id = api_client.post("/api/tags", json={"nom": "Ravalement"}).json()["id"]
response = api_client.put(f"/api/tags/{tag_id}", json={"nom": "Facade"})
assert response.status_code == 200
assert response.json() == {"id": tag_id, "nom": "Facade"}
def test_renommer_un_tag_inconnu_donne_404(api_client):
assert api_client.put("/api/tags/99999", json={"nom": "Facade"}).status_code == 404
def test_refuse_un_renommage_vers_un_nom_pris(api_client):
api_client.post("/api/tags", json={"nom": "Ravalement"})
autre_id = api_client.post("/api/tags", json={"nom": "Toiture"}).json()["id"]
response = api_client.put(f"/api/tags/{autre_id}", json={"nom": "Ravalement"})
assert response.status_code == 409