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>
378 lines
15 KiB
Python
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
|