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>
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
63
tests/test_extractor.py
Normal file
63
tests/test_extractor.py
Normal 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
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user