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:
12
src/plesna_gerance/__init__.py
Normal file
12
src/plesna_gerance/__init__.py
Normal 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__"]
|
||||
5
src/plesna_gerance/api/__init__.py
Normal file
5
src/plesna_gerance/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""API FastAPI pour l'extraction de comptes rendus de gérance."""
|
||||
|
||||
from .app import app
|
||||
|
||||
__all__ = ["app"]
|
||||
122
src/plesna_gerance/api/app.py
Normal file
122
src/plesna_gerance/api/app.py
Normal 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
157
src/plesna_gerance/cli.py
Normal 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()
|
||||
34
src/plesna_gerance/extractor.py
Normal file
34
src/plesna_gerance/extractor.py
Normal 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),
|
||||
}
|
||||
13
src/plesna_gerance/parsers/__init__.py
Normal file
13
src/plesna_gerance/parsers/__init__.py
Normal 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",
|
||||
]
|
||||
303
src/plesna_gerance/parsers/locataires.py
Normal file
303
src/plesna_gerance/parsers/locataires.py
Normal 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
|
||||
125
src/plesna_gerance/parsers/metadata.py
Normal file
125
src/plesna_gerance/parsers/metadata.py
Normal 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,
|
||||
}
|
||||
170
src/plesna_gerance/parsers/operations.py
Normal file
170
src/plesna_gerance/parsers/operations.py
Normal 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
|
||||
28
src/plesna_gerance/parsers/pdf.py
Normal file
28
src/plesna_gerance/parsers/pdf.py
Normal 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
|
||||
6
src/plesna_gerance/utils/__init__.py
Normal file
6
src/plesna_gerance/utils/__init__.py
Normal 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"]
|
||||
69
src/plesna_gerance/utils/amounts.py
Normal file
69
src/plesna_gerance/utils/amounts.py
Normal 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
|
||||
42
src/plesna_gerance/utils/dates.py
Normal file
42
src/plesna_gerance/utils/dates.py
Normal 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
|
||||
Reference in New Issue
Block a user