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>
110 lines
3.4 KiB
Python
110 lines
3.4 KiB
Python
"""Utilitaires pour la normalisation des numéros de lot."""
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
#: Largeur canonique d'un numéro de lot (ex: "06", "17").
|
|
LOT_NUMERO_WIDTH = 2
|
|
|
|
#: Numéro utilisé quand aucun lot n'est identifiable.
|
|
LOT_NUMERO_INCONNU = "00"
|
|
|
|
|
|
def normalize_lot_numero(value: str | int | None) -> str | None:
|
|
"""Normalise un numéro de lot sur exactement 2 chiffres.
|
|
|
|
Les PDF et les saisies manuelles utilisent des formats variés ("6", "06",
|
|
"0006"). Tous doivent aboutir au même numéro canonique, sinon un même lot
|
|
est dupliqué en base.
|
|
|
|
Gère:
|
|
- "0006" -> "06"
|
|
- "6" -> "06"
|
|
- "06" -> "06"
|
|
- "0020" -> "20"
|
|
- "0000" -> "00"
|
|
|
|
Les numéros comportant plus de 2 chiffres significatifs (ex: "0123") sont
|
|
conservés sans leurs zéros de tête ("123") : les tronquer perdrait de
|
|
l'information.
|
|
|
|
Args:
|
|
value: Numéro de lot dans un format quelconque
|
|
|
|
Returns:
|
|
Numéro sur 2 chiffres, ou None si aucun chiffre n'est présent
|
|
"""
|
|
if value is None:
|
|
return None
|
|
|
|
digits = re.sub(r"\D", "", str(value))
|
|
if not digits:
|
|
return None
|
|
|
|
significant = digits.lstrip("0")
|
|
if not significant:
|
|
return LOT_NUMERO_INCONNU
|
|
|
|
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.
|
|
|
|
Couvre les deux emplacements où un numéro de lot apparaît:
|
|
- ``situation_locataires[].lot.numero``
|
|
- ``recapitulatif_operations[].lot_numero``
|
|
|
|
Utile pour les extractions éditées à la main via l'API, où le numéro saisi
|
|
peut arriver sous n'importe quelle forme.
|
|
|
|
Args:
|
|
data: Extraction (metadata, situation_locataires, recapitulatif_operations)
|
|
|
|
Returns:
|
|
Le même dict, numéros de lot normalisés
|
|
"""
|
|
if not isinstance(data, dict):
|
|
return data
|
|
|
|
for situation in data.get("situation_locataires") or []:
|
|
lot = situation.get("lot")
|
|
if isinstance(lot, dict) and lot.get("numero") is not None:
|
|
lot["numero"] = normalize_lot_numero(lot["numero"])
|
|
|
|
for operation in data.get("recapitulatif_operations") or []:
|
|
if isinstance(operation, dict) and operation.get("lot_numero") is not None:
|
|
operation["lot_numero"] = normalize_lot_numero(operation["lot_numero"])
|
|
|
|
return data
|