"""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