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:
@@ -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"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
79
src/plesna_gerance/utils/lots.py
Normal file
79
src/plesna_gerance/utils/lots.py
Normal 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
|
||||
@@ -53,7 +53,7 @@ def sample_data():
|
||||
},
|
||||
"situation_locataires": [
|
||||
{
|
||||
"lot": {"numero": "001", "type": "Appartement"},
|
||||
"lot": {"numero": "01", "type": "Appartement"},
|
||||
"locataire": {"nom": "DUPONT"},
|
||||
"lignes": [
|
||||
{
|
||||
|
||||
@@ -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
121
tests/test_lots.py
Normal 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
|
||||
Reference in New Issue
Block a user