Files
pdf_oralia_vibe/src/plesna_gerance/parsers/locataires.py
Bertrand Benjamin 420a856e41 feat: normalise les numéros de lot sur 2 chiffres
Les PDF et les saisies manuelles écrivent le même lot de plusieurs façons
("6", "06", "0006"), et chaque forme créait jusqu'ici un lot distinct en
base. utils/lots.py fixe la forme canonique sur 2 chiffres et sert de point
d'entrée unique pour la normalisation.

Elle est appliquée à la source dans les parseurs (locataires texte et
tableau, codes lot des opérations), et en dernier recours dans
get_or_create_lot et save_document, pour couvrir les extractions éditées à
la main via l'API. Les numéros à plus de 2 chiffres significatifs ne sont
pas tronqués.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 11:46:33 +02:00

378 lines
15 KiB
Python

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