feat: normalise les numéros de lot sur 2 chiffres

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>
This commit is contained in:
2026-07-25 11:46:33 +02:00
parent 0e30ed8501
commit 420a856e41
9 changed files with 232 additions and 18 deletions

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,7 @@ def sample_data():
},
"situation_locataires": [
{
"lot": {"numero": "001", "type": "Appartement"},
"lot": {"numero": "01", "type": "Appartement"},
"locataire": {"nom": "DUPONT"},
"lignes": [
{

View File

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

121
tests/test_lots.py Normal file
View File

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