From 420a856e41c3680b30c9ed669aabf29ec4997ea4 Mon Sep 17 00:00:00 2001 From: Bertrand Benjamin Date: Sat, 25 Jul 2026 11:46:33 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20normalise=20les=20num=C3=A9ros=20de=20l?= =?UTF-8?q?ot=20sur=202=20chiffres?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/plesna_gerance/database/service.py | 17 ++- src/plesna_gerance/parsers/locataires.py | 7 +- .../parsers/locataires_table.py | 5 +- src/plesna_gerance/parsers/operations.py | 15 +-- src/plesna_gerance/services/sql_executor.py | 2 +- src/plesna_gerance/utils/lots.py | 79 ++++++++++++ tests/conftest.py | 2 +- tests/test_database_service.py | 2 +- tests/test_lots.py | 121 ++++++++++++++++++ 9 files changed, 232 insertions(+), 18 deletions(-) create mode 100644 src/plesna_gerance/utils/lots.py create mode 100644 tests/test_lots.py diff --git a/src/plesna_gerance/database/service.py b/src/plesna_gerance/database/service.py index 385565e..f2f6db7 100644 --- a/src/plesna_gerance/database/service.py +++ b/src/plesna_gerance/database/service.py @@ -8,6 +8,11 @@ from sqlalchemy import select from sqlalchemy.orm import Session from ..utils.amounts import parse_amount +from ..utils.lots import ( + LOT_NUMERO_INCONNU, + normalize_extraction_lots, + normalize_lot_numero, +) from . import storage from .models import Depense, Document, Immeuble, Locataire, Lot, Revenu, Tag @@ -55,7 +60,12 @@ class DatabaseService: def get_or_create_lot( self, immeuble_id: int, numero: str, lot_type: str = None ) -> Lot: - """Get existing lot or create new one.""" + """Get existing lot or create new one. + + Le numero est normalise sur 2 chiffres pour qu'un meme lot saisi "6", + "06" ou "0006" ne soit pas duplique. + """ + numero = normalize_lot_numero(numero) or LOT_NUMERO_INCONNU stmt = select(Lot).where(Lot.immeuble_id == immeuble_id, Lot.numero == numero) lot = self.session.execute(stmt).scalar_one_or_none() @@ -134,6 +144,9 @@ class DatabaseService: Raises: DuplicateDocumentError: If document already exists and overwrite=False """ + # Uniformiser les numéros de lot avant toute persistance (JSON + tables) + normalize_extraction_lots(data) + metadata = data.get("metadata", {}) doc_info = metadata.get("document", {}) immeuble_info = metadata.get("immeuble", {}) @@ -247,7 +260,7 @@ class DatabaseService: # Get or create lot lot = self.get_or_create_lot( immeuble_id=immeuble.id, - numero=lot_info.get("numero", "0000"), + numero=lot_info.get("numero") or LOT_NUMERO_INCONNU, lot_type=lot_info.get("type"), ) diff --git a/src/plesna_gerance/parsers/locataires.py b/src/plesna_gerance/parsers/locataires.py index 8cd70ba..57d2490 100644 --- a/src/plesna_gerance/parsers/locataires.py +++ b/src/plesna_gerance/parsers/locataires.py @@ -4,6 +4,7 @@ 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: @@ -84,7 +85,7 @@ def extract_situation_locataires(text: str) -> list[dict]: Returns: Liste des situations par lot, chacune contenant: - - lot: numéro et type + - lot: numéro (2 chiffres) et type - locataire: nom - lignes: détail des loyers, charges, etc. - totaux: sommes par catégorie @@ -112,14 +113,14 @@ def extract_situation_locataires(text: str) -> list[dict]: # 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)", + 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 = lot_match.group(1) + lot_num = normalize_lot_numero(lot_match.group(1)) lot_type = lot_match.group(2) current_lot = { diff --git a/src/plesna_gerance/parsers/locataires_table.py b/src/plesna_gerance/parsers/locataires_table.py index 39e83cd..3ea8651 100644 --- a/src/plesna_gerance/parsers/locataires_table.py +++ b/src/plesna_gerance/parsers/locataires_table.py @@ -26,12 +26,13 @@ import pdfplumber from ..utils.amounts import extract_amounts_from_line from ..utils.dates import parse_french_date +from ..utils.lots import normalize_lot_numero # Tolérance verticale (points PDF) pour regrouper les mots d'une même ligne visuelle. _Y_TOL = 3.0 _LOT_RE = re.compile( - r"^Lot\s+(\d{4})\s+" + r"^Lot\s+(\d{1,4})\s+" r"(Loc\.\s*Commercial|Appartement\s+T\d|Studio|Garage|Cave|Parking)" ) _PERIODE_RE = re.compile(r"Du\s+\d{2}\.\d{2}\.\d{2}\s+Au\s+(\d{2}\.\d{2}\.\d{2})") @@ -149,7 +150,7 @@ def _rows_from_page(page) -> list[dict]: def _new_lot(numero: str, lot_type: str) -> dict: return { - "lot": {"numero": numero, "type": lot_type}, + "lot": {"numero": normalize_lot_numero(numero), "type": lot_type}, "locataire": {"nom": ""}, "lignes": [], "totaux": { diff --git a/src/plesna_gerance/parsers/operations.py b/src/plesna_gerance/parsers/operations.py index f25a168..cf99dd5 100644 --- a/src/plesna_gerance/parsers/operations.py +++ b/src/plesna_gerance/parsers/operations.py @@ -3,6 +3,7 @@ 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: @@ -10,18 +11,18 @@ def _extract_lot_code_from_description(description: str) -> str | None: 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 0006) + - Le numéro correspond au lot (ex: 06 -> lot 06) Exemples: - - "M06 - Commande moteur pompe" -> "0006" - - "S05 - Mise en service" -> "0005" - - "B01 - Plaques" -> "0001" + - "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 4 chiffres (ex: "0006") ou None si non trouvé + Code lot au format 2 chiffres (ex: "06") ou None si non trouvé """ if not description: return None @@ -31,9 +32,7 @@ def _extract_lot_code_from_description(description: str) -> str | None: # 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: - lot_num = match.group(1) - # Formater sur 4 chiffres (ex: "6" -> "0006", "12" -> "0012") - return lot_num.zfill(4) + return normalize_lot_numero(match.group(1)) return None diff --git a/src/plesna_gerance/services/sql_executor.py b/src/plesna_gerance/services/sql_executor.py index 0d3ea60..be0e255 100644 --- a/src/plesna_gerance/services/sql_executor.py +++ b/src/plesna_gerance/services/sql_executor.py @@ -132,7 +132,7 @@ Tables et colonnes : 3. **lots** — Lots (appartements, locaux commerciaux) au sein d'un immeuble - id (INTEGER, PK) - immeuble_id (INTEGER, FK → immeubles.id) - - numero (VARCHAR(10)) — numéro du lot dans l'immeuble + - numero (VARCHAR(10)) — numéro du lot dans l'immeuble, sur 2 chiffres ("01", "17") - type (VARCHAR(50)) — type de lot ("Appartement", "Loc. Commercial", etc.) - created_at (DATETIME) Contrainte : (immeuble_id, numero) est unique diff --git a/src/plesna_gerance/utils/lots.py b/src/plesna_gerance/utils/lots.py new file mode 100644 index 0000000..c09ffad --- /dev/null +++ b/src/plesna_gerance/utils/lots.py @@ -0,0 +1,79 @@ +"""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 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 diff --git a/tests/conftest.py b/tests/conftest.py index 62350b1..e53c9f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -53,7 +53,7 @@ def sample_data(): }, "situation_locataires": [ { - "lot": {"numero": "001", "type": "Appartement"}, + "lot": {"numero": "01", "type": "Appartement"}, "locataire": {"nom": "DUPONT"}, "lignes": [ { diff --git a/tests/test_database_service.py b/tests/test_database_service.py index 313fbfd..db8618a 100644 --- a/tests/test_database_service.py +++ b/tests/test_database_service.py @@ -27,7 +27,7 @@ def test_save_document_creates_full_graph(db_session, sample_data): # Immeuble / lot / locataire créés assert db_session.query(Immeuble).filter_by(code="IMM1").count() == 1 - assert db_session.query(Lot).filter_by(numero="001").count() == 1 + assert db_session.query(Lot).filter_by(numero="01").count() == 1 assert db_session.query(Locataire).filter_by(nom="DUPONT").count() == 1 # Revenu et dépense rattachés diff --git a/tests/test_lots.py b/tests/test_lots.py new file mode 100644 index 0000000..d7d64b9 --- /dev/null +++ b/tests/test_lots.py @@ -0,0 +1,121 @@ +"""Tests de la normalisation des numéros de lot.""" + +import pytest + +from plesna_gerance.parsers.operations import _extract_lot_code_from_description +from plesna_gerance.utils.lots import normalize_extraction_lots, normalize_lot_numero + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("0006", "06"), + ("06", "06"), + ("6", "06"), + (6, "06"), + ("0020", "20"), + ("20", "20"), + ("Lot 6", "06"), + (" 7 ", "07"), + ("0000", "00"), + ("0", "00"), + # Plus de 2 chiffres significatifs : on ne tronque pas + ("0123", "123"), + # Rien d'exploitable + (None, None), + ("", None), + ("abc", None), + ], +) +def test_normalize_lot_numero(raw, expected): + assert normalize_lot_numero(raw) == expected + + +@pytest.mark.parametrize( + "description,expected", + [ + ("M06 - Commande moteur pompe", "06"), + ("S05 - Mise en service", "05"), + ("B01 - Plaques", "01"), + ("S10 - Reparation", "10"), + ("S 17 - Espace dans le code", "17"), + ("S01 SOLDE LOCATAIRE", "01"), + ("Nettoyage immeuble", None), + ("", None), + ], +) +def test_extract_lot_code_from_description(description, expected): + assert _extract_lot_code_from_description(description) == expected + + +def test_normalize_extraction_lots(): + data = { + "situation_locataires": [ + {"lot": {"numero": "0006", "type": "Appartement T3"}}, + {"lot": {"numero": "7", "type": "Studio"}}, + {"lot": {"numero": None, "type": "Garage"}}, + ], + "recapitulatif_operations": [ + {"lot_numero": "0013"}, + {"lot_numero": "1"}, + {"lot_numero": None}, + ], + } + + normalize_extraction_lots(data) + + assert [s["lot"]["numero"] for s in data["situation_locataires"]] == [ + "06", + "07", + None, + ] + assert [o["lot_numero"] for o in data["recapitulatif_operations"]] == [ + "13", + "01", + None, + ] + + +def test_normalize_extraction_lots_tolerates_missing_sections(): + # Une extraction partielle ne doit pas lever + assert normalize_extraction_lots({}) == {} + assert normalize_extraction_lots({"situation_locataires": None}) == { + "situation_locataires": None + } + + +def test_get_or_create_lot_deduplicates_formats(db_session): + """"0006", "06" et "6" désignent le même lot : un seul enregistrement.""" + from plesna_gerance.database.models import Immeuble, Lot + from plesna_gerance.database.service import DatabaseService + + service = DatabaseService(db_session) + immeuble = service.get_or_create_immeuble(code="IMM1") + + lots = [ + service.get_or_create_lot(immeuble_id=immeuble.id, numero=numero) + for numero in ("0006", "06", "6") + ] + + assert {lot.id for lot in lots} == {lots[0].id} + assert lots[0].numero == "06" + assert db_session.query(Lot).filter_by(immeuble_id=immeuble.id).count() == 1 + assert db_session.query(Immeuble).count() == 1 + + +def test_save_document_normalizes_lot_numero(db_session, sample_data): + from plesna_gerance.database.models import Lot + from plesna_gerance.database.service import DatabaseService + + sample_data["situation_locataires"][0]["lot"]["numero"] = "0001" + sample_data["recapitulatif_operations"][0]["lot_numero"] = "1" + + service = DatabaseService(db_session) + doc = service.save_document(data=sample_data) + + # Le lot du locataire et celui de l'opération sont le même + assert db_session.query(Lot).count() == 1 + assert db_session.query(Lot).first().numero == "01" + # Le JSON persisté est normalisé lui aussi + assert '"numero": "01"' in doc.json_data + assert '"lot_numero": "01"' in doc.json_data