refactor: retire les parseurs texte de repli

Les parseurs géométriques (par cellules de tableau) traitent les 5 PDF du
corpus sans jamais déclencher le repli : les parseurs texte étaient 586
lignes de regex fragiles, non testées, à maintenir à chaque évolution du
format de sortie. Les références golden sont inchangées après leur
suppression — l'extraction produit exactement la même chose.

Le parseur géométrique devient donc le seul chemin : ce qu'il ne lit pas
est perdu. L'orchestrateur distingue maintenant les deux cas :

- aucun lot lu -> ExtractionError (422 côté API). Un compte rendu sans
  lot n'existe pas : c'est le tableau qui n'a pas été reconnu, et mieux
  vaut échouer que d'enregistrer un document vide découvert bien plus
  tard, au moment de relire les chiffres ;
- aucune opération -> accepté. Un mois sans dépense reste plausible.

_extract_lot_code_from_description était la seule fonction encore
utilisée : elle rejoint utils.lots sous le nom
extract_lot_numero_from_description, à côté de normalize_lot_numero.
extract_text_from_pdf, sans appelant, disparaît au passage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 09:44:20 +02:00
parent 1b319dff4e
commit ea5bdac18a
9 changed files with 122 additions and 635 deletions

View File

@@ -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),

View File

@@ -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",
]

View File

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

View File

@@ -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"(?<!\d)(\d{1,3}(?:[\s\u00a0]?\d{3})*[,\.]\d{2})(?!\d)"
amounts = re.findall(amounts_pattern, stripped)
if amounts and len(amounts) >= 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

View File

@@ -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,
}

View File

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

View File

@@ -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.