diff --git a/src/plesna_gerance/extractor.py b/src/plesna_gerance/extractor.py index a3517c5..0db43fa 100644 --- a/src/plesna_gerance/extractor.py +++ b/src/plesna_gerance/extractor.py @@ -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), diff --git a/src/plesna_gerance/parsers/__init__.py b/src/plesna_gerance/parsers/__init__.py index c230000..0460fd3 100644 --- a/src/plesna_gerance/parsers/__init__.py +++ b/src/plesna_gerance/parsers/__init__.py @@ -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", ] diff --git a/src/plesna_gerance/parsers/locataires.py b/src/plesna_gerance/parsers/locataires.py deleted file mode 100644 index 57d2490..0000000 --- a/src/plesna_gerance/parsers/locataires.py +++ /dev/null @@ -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 diff --git a/src/plesna_gerance/parsers/operations.py b/src/plesna_gerance/parsers/operations.py deleted file mode 100644 index cf99dd5..0000000 --- a/src/plesna_gerance/parsers/operations.py +++ /dev/null @@ -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"(?= 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 diff --git a/src/plesna_gerance/parsers/operations_table.py b/src/plesna_gerance/parsers/operations_table.py index 3bb8e11..f14a9c9 100644 --- a/src/plesna_gerance/parsers/operations_table.py +++ b/src/plesna_gerance/parsers/operations_table.py @@ -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, } diff --git a/src/plesna_gerance/parsers/pdf.py b/src/plesna_gerance/parsers/pdf.py index ab2d964..aaf2ce5 100644 --- a/src/plesna_gerance/parsers/pdf.py +++ b/src/plesna_gerance/parsers/pdf.py @@ -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 diff --git a/src/plesna_gerance/utils/lots.py b/src/plesna_gerance/utils/lots.py index c09ffad..e81baca 100644 --- a/src/plesna_gerance/utils/lots.py +++ b/src/plesna_gerance/utils/lots.py @@ -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. diff --git a/tests/test_extractor.py b/tests/test_extractor.py new file mode 100644 index 0000000..432a830 --- /dev/null +++ b/tests/test_extractor.py @@ -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 diff --git a/tests/test_lots.py b/tests/test_lots.py index d7d64b9..89d1a82 100644 --- a/tests/test_lots.py +++ b/tests/test_lots.py @@ -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():