feat: init plesna-gerance - extracteur de comptes rendus de gerance
- Backend Python (uv + click + FastAPI) - CLI: plesna-gerance extract <pdf> pour extraire les donnees - CLI: plesna-gerance serve pour lancer le serveur API - API REST: POST /api/extract pour upload et extraction de PDF - Parsers modulaires: metadata, locataires, operations - Utilise pdftotext (poppler-utils) pour l'extraction de texte - Frontend Vue.js + Tailwind CSS - Interface split-screen: PDF a gauche, donnees a droite - Preview PDF avec zoom et navigation pages (pdf.js) - Visualisation structuree des donnees extraites - Sections depliables: metadata, locataires, operations - Drag & drop pour upload de PDF - Extraction automatique a la selection du fichier
This commit is contained in:
303
src/plesna_gerance/parsers/locataires.py
Normal file
303
src/plesna_gerance/parsers/locataires.py
Normal file
@@ -0,0 +1,303 @@
|
||||
"""Extraction de la situation des locataires."""
|
||||
|
||||
import re
|
||||
|
||||
from ..utils.dates import parse_french_date
|
||||
from ..utils.amounts import extract_amounts_from_line
|
||||
|
||||
|
||||
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 et type
|
||||
- locataire: nom
|
||||
- lignes: détail des loyers, charges, etc.
|
||||
- totaux: sommes par catégorie
|
||||
"""
|
||||
situations: list[dict] = []
|
||||
|
||||
# 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{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 = 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
|
||||
Reference in New Issue
Block a user