feat: init plesna-gerance - extracteur de comptes rendus de gerance

- Backend Python (uv + click + FastAPI)
  - CLI: plesna-gerance extract <pdf> pour extraire les donnees
  - CLI: plesna-gerance serve pour lancer le serveur API
  - API REST: POST /api/extract pour upload et extraction de PDF
  - Parsers modulaires: metadata, locataires, operations
  - Utilise pdftotext (poppler-utils) pour l'extraction de texte

- Frontend Vue.js + Tailwind CSS
  - Interface split-screen: PDF a gauche, donnees a droite
  - Preview PDF avec zoom et navigation pages (pdf.js)
  - Visualisation structuree des donnees extraites
  - Sections depliables: metadata, locataires, operations
  - Drag & drop pour upload de PDF
  - Extraction automatique a la selection du fichier
This commit is contained in:
2026-01-18 05:36:27 +01:00
commit 6bad5fbaf9
34 changed files with 5692 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
"""
Plesna Gérance - Extracteur de comptes rendus de gérance Oralia/ICS.
Ce package extrait les informations structurées des PDFs de comptes rendus
de gérance générés par le logiciel de gestion immobilière Oralia/ICS.
"""
__version__ = "0.1.0"
from .extractor import extract_compte_rendu
__all__ = ["extract_compte_rendu", "__version__"]

View File

@@ -0,0 +1,5 @@
"""API FastAPI pour l'extraction de comptes rendus de gérance."""
from .app import app
__all__ = ["app"]

View File

@@ -0,0 +1,122 @@
"""Application FastAPI pour l'extraction de comptes rendus de gérance."""
import tempfile
from pathlib import Path
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from .. import __version__
from ..extractor import extract_compte_rendu
app = FastAPI(
title="Plesna Gérance API",
description="API pour extraire les informations structurées des PDFs de comptes rendus de gérance Oralia/ICS.",
version=__version__,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
# Determine the frontend dist path
FRONTEND_DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist"
@app.get("/api", tags=["health"])
async def api_root() -> dict:
"""Endpoint racine de l'API - informations."""
return {
"name": "Plesna Gérance API",
"version": __version__,
"docs": "/api/docs",
}
@app.get("/api/health", tags=["health"])
async def health() -> dict:
"""Vérification de l'état du serveur."""
return {"status": "ok"}
@app.post("/api/extract", tags=["extraction"])
async def extract_pdf(
file: UploadFile = File(..., description="Fichier PDF de compte rendu de gérance"),
) -> JSONResponse:
"""Extrait les données d'un PDF de compte rendu de gérance.
Upload un fichier PDF et retourne les données structurées en JSON.
- **file**: Fichier PDF à analyser (Content-Type: multipart/form-data)
Retourne un objet JSON contenant:
- **source_file**: Nom du fichier uploadé
- **data**: Données extraites (metadata, situation_locataires, recapitulatif_operations)
"""
# Validation du type de fichier
if not file.filename:
raise HTTPException(status_code=400, detail="Nom de fichier manquant")
if not file.filename.lower().endswith(".pdf"):
raise HTTPException(
status_code=400,
detail=f"Le fichier doit être un PDF. Reçu: {file.filename}",
)
# Validation du content-type (si fourni)
if file.content_type and file.content_type not in (
"application/pdf",
"application/octet-stream",
):
raise HTTPException(
status_code=400,
detail=f"Content-Type invalide. Attendu: application/pdf, reçu: {file.content_type}",
)
tmp_path: Path | None = None
# Sauvegarde temporaire du fichier uploadé
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file:
content = await file.read()
tmp_file.write(content)
tmp_path = Path(tmp_file.name)
# Extraction des données
try:
data = extract_compte_rendu(str(tmp_path))
except Exception as e:
raise HTTPException(
status_code=422,
detail=f"Erreur lors de l'extraction du PDF: {str(e)}",
)
return JSONResponse(
content={
"source_file": file.filename,
"data": data,
}
)
finally:
# Nettoyage du fichier temporaire
if tmp_path and tmp_path.exists():
tmp_path.unlink()
# Mount static files for production (if dist exists)
if FRONTEND_DIST.exists():
# Serve static assets
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
# Catch-all route for SPA - must be last
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(full_path: str):
"""Serve the SPA for all non-API routes."""
# If requesting a file that exists, serve it
file_path = FRONTEND_DIST / full_path
if file_path.is_file():
return FileResponse(file_path)
# Otherwise serve index.html for SPA routing
return FileResponse(FRONTEND_DIST / "index.html")

157
src/plesna_gerance/cli.py Normal file
View File

@@ -0,0 +1,157 @@
"""Interface en ligne de commande pour plesna-gerance."""
import json
import sys
from pathlib import Path
import click
from . import __version__
from .extractor import extract_compte_rendu
@click.group()
@click.version_option(version=__version__, prog_name="plesna-gerance")
def main() -> None:
"""Extracteur de comptes rendus de gérance Oralia/ICS.
Extrait les informations structurées des PDFs de comptes rendus
de gérance et les convertit en JSON.
"""
pass
@main.command()
@click.argument(
"pdf_files",
nargs=-1,
required=True,
type=click.Path(exists=True, dir_okay=False, path_type=Path),
)
@click.option(
"--output",
"-o",
type=click.Path(dir_okay=False, path_type=Path),
help="Fichier de sortie JSON (défaut: stdout)",
)
@click.option(
"--pretty/--compact",
default=True,
help="Formatage JSON indenté (défaut) ou compact",
)
def extract(pdf_files: tuple[Path, ...], output: Path | None, pretty: bool) -> None:
"""Extrait les données d'un ou plusieurs PDFs de gérance.
Exemples:
plesna-gerance extract document.pdf
plesna-gerance extract *.pdf -o results.json
plesna-gerance extract doc1.pdf doc2.pdf --compact
"""
results: list[dict] = []
for pdf_path in pdf_files:
if pdf_path.suffix.lower() != ".pdf":
click.echo(
f"Attention: {pdf_path} n'est pas un PDF, ignoré.",
err=True,
)
continue
click.echo(f"Traitement de {pdf_path}...", err=True)
try:
data = extract_compte_rendu(str(pdf_path))
results.append({"source_file": pdf_path.name, "data": data})
except Exception as e:
click.echo(f"Erreur: {pdf_path}: {e}", err=True)
if click.get_current_context().obj and click.get_current_context().obj.get(
"debug"
):
import traceback
traceback.print_exc()
if not results:
click.echo("Aucun fichier traité.", err=True)
sys.exit(1)
# Formatage de la sortie
if len(results) == 1:
output_data = results[0]
else:
output_data = {"documents": results}
indent = 2 if pretty else None
json_output = json.dumps(output_data, ensure_ascii=False, indent=indent)
if output:
output.write_text(json_output, encoding="utf-8")
click.echo(f"Résultat écrit dans {output}", err=True)
else:
click.echo(json_output)
@main.command()
@click.option(
"--host",
"-h",
default="127.0.0.1",
help="Adresse d'écoute (défaut: 127.0.0.1)",
)
@click.option(
"--port",
"-p",
default=8000,
type=int,
help="Port d'écoute (défaut: 8000)",
)
@click.option(
"--reload",
is_flag=True,
help="Activer le rechargement automatique (développement)",
)
def serve(host: str, port: int, reload: bool) -> None:
"""Démarre le serveur API FastAPI.
Le serveur expose une API REST pour extraire les données des PDFs
de comptes rendus de gérance via upload.
Exemples:
plesna-gerance serve
plesna-gerance serve --port 3000
plesna-gerance serve --host 0.0.0.0 --port 8080
plesna-gerance serve --reload # Mode développement
Documentation API disponible sur:
http://localhost:8000/api/docs (Swagger UI)
http://localhost:8000/api/redoc (ReDoc)
Interface web disponible sur:
http://localhost:8000/
"""
import uvicorn
click.echo(f"Demarrage du serveur sur http://{host}:{port}", err=True)
click.echo(f"Interface web: http://{host}:{port}/", err=True)
click.echo(f"Documentation API: http://{host}:{port}/api/docs", err=True)
uvicorn.run(
"plesna_gerance.api:app",
host=host,
port=port,
reload=reload,
)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,34 @@
"""Orchestrateur principal pour l'extraction des comptes rendus de gérance."""
from .parsers.pdf import extract_text_from_pdf
from .parsers.metadata import extract_metadata
from .parsers.locataires import extract_situation_locataires
from .parsers.operations import extract_recapitulatif_operations
def extract_compte_rendu(pdf_path: str) -> dict:
"""Extrait toutes les informations d'un PDF de compte rendu de gérance.
Cette fonction orchestre l'extraction complète des données structurées
depuis un PDF de compte rendu de gérance Oralia/ICS.
Args:
pdf_path: Chemin vers le fichier PDF à traiter
Returns:
Dictionnaire contenant:
- metadata: informations sur l'éditeur, destinataire, immeuble, solde
- situation_locataires: détail par lot des loyers et charges
- recapitulatif_operations: dépenses et opérations par catégorie
Raises:
subprocess.CalledProcessError: Si pdftotext échoue
FileNotFoundError: Si le fichier PDF n'existe pas
"""
text = extract_text_from_pdf(pdf_path)
return {
"metadata": extract_metadata(text),
"situation_locataires": extract_situation_locataires(text),
"recapitulatif_operations": extract_recapitulatif_operations(text),
}

View File

@@ -0,0 +1,13 @@
"""Parsers pour les différentes sections des PDFs de gérance."""
from .pdf import extract_text_from_pdf
from .metadata import extract_metadata
from .locataires import extract_situation_locataires
from .operations import extract_recapitulatif_operations
__all__ = [
"extract_text_from_pdf",
"extract_metadata",
"extract_situation_locataires",
"extract_recapitulatif_operations",
]

View File

@@ -0,0 +1,303 @@
"""Extraction de la situation des locataires."""
import re
from ..utils.dates import parse_french_date
from ..utils.amounts import extract_amounts_from_line
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 et type
- locataire: nom
- lignes: détail des loyers, charges, etc.
- totaux: sommes par catégorie
"""
situations: list[dict] = []
# 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{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 = 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

@@ -0,0 +1,125 @@
"""Extraction des métadonnées du document de gérance."""
import re
from ..utils.dates import parse_french_date
from ..utils.amounts import parse_amount
def extract_metadata(text: str) -> dict:
"""Extrait les métadonnées du document.
Args:
text: Texte complet du PDF
Returns:
Dictionnaire contenant:
- editeur: informations sur la société de gestion
- destinataire: informations sur le propriétaire
- interlocuteur: contact chez le gestionnaire
- document: référence et date
- immeuble: adresse et code de l'immeuble
- solde: solde créditeur/débiteur
"""
# Éditeur
editeur = {
"nom": "ROSIER-MODICA",
"adresse": "9 rue Juliette Récamier, 69455 Lyon Cedex 06",
"telephone": None,
"fax": None,
"siret": None,
"capital": None,
}
m = re.search(r"Téléphone\s*:\s*([\d.]+)", text)
if m:
editeur["telephone"] = m.group(1)
m = re.search(r"Fax\s*:\s*([\d.]+)", text)
if m:
editeur["fax"] = m.group(1)
m = re.search(r"Siret\s*:\s*(\d+)", text)
if m:
editeur["siret"] = m.group(1)
m = re.search(r"Capital de ([\d\s]+) euros", text)
if m:
editeur["capital"] = m.group(1).replace(" ", "")
# Destinataire
destinataire = {"nom": "", "adresse": ""}
m = re.search(r"(S\.?C\.?I\.?\s+\w+)", text)
if m:
destinataire["nom"] = m.group(1).strip()
# Interlocuteur
interlocuteur = {"nom": "", "telephone": None, "email": None}
m = re.search(r"Votre interlocuteur\s+([A-Z]+\s+\w+)", text)
if m:
interlocuteur["nom"] = m.group(1)
m = re.search(r"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", text)
if m:
interlocuteur["email"] = m.group(1)
# Document
document = {"reference": "", "date": "", "type": "COMPTE RENDU DE GESTION"}
m = re.search(r"REFERENCES\s+(\d+)", text)
if m:
document["reference"] = m.group(1)
m = re.search(r"Lyon le (\d{2}/\d{2}/\d{4})", text)
if m:
document["date"] = parse_french_date(m.group(1))
# Immeuble
immeuble = {"code": "", "adresse": "", "ville": "", "code_postal": ""}
# Pattern: "Immeuble : 33689020" suivi de l'adresse puis code postal + ville
m = re.search(r"Immeuble\s*:\s*(\d+)", text)
if m:
immeuble["code"] = m.group(1)
# Chercher l'adresse et ville/CP après "Immeuble : CODE"
# Format: "Immeuble : 33689020\n4 RUE SERVIENT\n...69003 LYON"
m = re.search(
r"Immeuble\s*:\s*\d+\s*\n\s*(\d+[^\n]+)\n.*?(\d{5})\s+([A-Z]+)\s*\n",
text,
re.DOTALL,
)
if m:
immeuble["adresse"] = m.group(1).strip()
immeuble["code_postal"] = m.group(2)
immeuble["ville"] = m.group(3)
else:
# Format alternatif sur même ligne avec espaces
m = re.search(r"Immeuble\s*:\s*\d+\s+(\d+[A-Z\s]+?)\s+(\d{5})\s+([A-Z]+)", text)
if m:
immeuble["adresse"] = m.group(1).strip()
immeuble["code_postal"] = m.group(2)
immeuble["ville"] = m.group(3)
else:
# Alternative: chercher dans le récap
m = re.search(r"Solde au \d+\.\d+\.\d+\s+(.+?)\s+TOTAUX", text)
if m:
immeuble["adresse"] = m.group(1).strip()
# Solde
solde = {"montant": 0.0, "type": "crediteur", "date_arrete": ""}
m = re.search(r"solde (créditeur|débiteur)[^\d]*([\d\s,\.]+)€", text, re.IGNORECASE)
if m:
solde["type"] = m.group(1).lower()
solde["montant"] = parse_amount(m.group(2))
m = re.search(r"Solde créditeur en Euros au (\d{2}\.\d{2}\.\d{4})", text)
if m:
solde["date_arrete"] = parse_french_date(m.group(1))
return {
"editeur": editeur,
"destinataire": destinataire,
"interlocuteur": interlocuteur,
"document": document,
"immeuble": immeuble,
"solde": solde,
}

View File

@@ -0,0 +1,170 @@
"""Extraction du récapitulatif des opérations."""
import re
from ..utils.amounts import parse_amount
def extract_recapitulatif_operations(text: str) -> list[dict]:
"""Extrait le récapitulatif des opérations.
Args:
text: Texte complet du PDF
Returns:
Liste des catégories d'opérations, chacune contenant:
- categorie: nom de la catégorie
- operations: liste des opérations avec type, fournisseur, description, montants
"""
categories: list[dict] = []
# Catégories à identifier
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: dict | None = None
current_fournisseur: str | None = None
current_lot: str | None = None
current_type_op: 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 = None
for kw, _cat_id in cat_keywords.items():
if kw in stripped:
found_cat = kw
# 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:
if current_cat and current_cat["operations"]:
categories.append(current_cat)
current_cat = {"categorie": found_cat, "operations": []}
current_fournisseur = None
current_type_op = None
continue
if not current_cat:
continue
# Type d'opération (italique/description au début)
type_op_patterns = [
"Nettoyage",
"Electricité",
"Contrat",
"Travaux",
"Frais",
"Honoraires",
"TVA",
"Plaques",
"Curage",
"Lavage",
"Reglt",
]
for top in type_op_patterns:
if stripped.startswith(top):
current_type_op = 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()
if description and not description.startswith("Totaux"):
operation = {
"type_operation": current_type_op or "",
"fournisseur": current_fournisseur,
"description": description,
"lot_concerne": current_lot,
"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,
},
}
current_cat["operations"].append(operation)
if current_cat and current_cat["operations"]:
categories.append(current_cat)
return categories

View File

@@ -0,0 +1,28 @@
"""Extraction de texte depuis les PDFs."""
import subprocess
def extract_text_from_pdf(pdf_path: str) -> str:
"""Extrait le texte du PDF via pdftotext.
Nécessite pdftotext (poppler-utils) installé sur le système:
- Ubuntu/Debian: sudo apt-get install poppler-utils
- macOS: brew install poppler
Args:
pdf_path: Chemin vers le fichier PDF
Returns:
Texte extrait du PDF avec mise en page préservée
Raises:
subprocess.CalledProcessError: Si pdftotext échoue
"""
result = subprocess.run(
["pdftotext", "-layout", pdf_path, "-"],
capture_output=True,
text=True,
check=True,
)
return result.stdout

View File

@@ -0,0 +1,6 @@
"""Utilitaires pour le parsing des données de gérance."""
from .dates import parse_french_date
from .amounts import parse_amount, extract_amounts_from_line
__all__ = ["parse_french_date", "parse_amount", "extract_amounts_from_line"]

View File

@@ -0,0 +1,69 @@
"""Utilitaires pour le parsing des montants."""
import re
def parse_amount(text: str) -> float:
"""Parse un montant français en float.
Gère les formats:
- 123,45
- 123.45
- 1 234,56 (avec espace comme séparateur de milliers)
- 1.234,56 (avec point comme séparateur de milliers)
Args:
text: Texte contenant un montant
Returns:
Montant en float, ou 0.0 si parsing impossible
"""
if not text:
return 0.0
text = text.strip().replace(" ", "").replace("\u00a0", "")
text = text.replace("", "").replace(",", ".")
# Gérer les séparateurs de milliers (ex: 1.234,56 -> 1234.56)
if text.count(".") > 1:
parts = text.rsplit(".", 1)
text = parts[0].replace(".", "") + "." + parts[1]
try:
return float(text)
except ValueError:
return 0.0
def extract_amounts_from_line(line: str) -> list[float]:
"""Extrait les montants d'une ligne en évitant les dates.
Les montants sont au format: 123.45 ou 1 234,56 ou -45.67
Les dates sont au format: 01.01.25 (exclues automatiquement)
Args:
line: Ligne de texte à analyser
Returns:
Liste des montants trouvés
"""
# Supprimer les dates du format DD.MM.YY pour éviter confusion
line_clean = re.sub(r"\b\d{2}\.\d{2}\.\d{2}\b", " DATE ", line)
amounts: list[float] = []
# Pattern plus strict: au moins un chiffre, optionnellement espace+chiffres,
# puis séparateur décimal et 2 chiffres
pattern = r"-?(\d{1,3}(?:[\s\u00a0]?\d{3})*)[,\.](\d{2})(?!\d)"
for match in re.finditer(pattern, line_clean):
full_match = match.group(0)
# Reconstruire le nombre
integer_part = match.group(1).replace(" ", "").replace("\u00a0", "")
decimal_part = match.group(2)
amount_str = f"{integer_part}.{decimal_part}"
if full_match.startswith("-"):
amount_str = "-" + amount_str
try:
amounts.append(float(amount_str))
except ValueError:
pass
return amounts

View File

@@ -0,0 +1,42 @@
"""Utilitaires pour le parsing des dates."""
import re
def parse_french_date(date_str: str) -> str:
"""Convertit une date française en format ISO (YYYY-MM-DD).
Formats supportés:
- DD.MM.YY (ex: 01.01.25 -> 2025-01-01)
- DD/MM/YYYY (ex: 01/01/2025 -> 2025-01-01)
- DD.MM.YYYY (ex: 01.01.2025 -> 2025-01-01)
Args:
date_str: Date au format français
Returns:
Date au format ISO ou chaîne originale si format non reconnu
"""
if not date_str:
return ""
# Format DD.MM.YY
m = re.match(r"(\d{2})\.(\d{2})\.(\d{2})$", date_str.strip())
if m:
d, mo, y = m.groups()
year = f"20{y}" if int(y) < 50 else f"19{y}"
return f"{year}-{mo}-{d}"
# Format DD/MM/YYYY
m = re.match(r"(\d{2})/(\d{2})/(\d{4})$", date_str.strip())
if m:
d, mo, y = m.groups()
return f"{y}-{mo}-{d}"
# Format DD.MM.YYYY
m = re.match(r"(\d{2})\.(\d{2})\.(\d{4})$", date_str.strip())
if m:
d, mo, y = m.groups()
return f"{y}-{mo}-{d}"
return date_str