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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..utils.amounts import parse_amount
|
from ..utils.amounts import parse_amount
|
||||||
|
from ..utils.lots import (
|
||||||
|
LOT_NUMERO_INCONNU,
|
||||||
|
normalize_extraction_lots,
|
||||||
|
normalize_lot_numero,
|
||||||
|
)
|
||||||
from . import storage
|
from . import storage
|
||||||
from .models import Depense, Document, Immeuble, Locataire, Lot, Revenu, Tag
|
from .models import Depense, Document, Immeuble, Locataire, Lot, Revenu, Tag
|
||||||
|
|
||||||
@@ -55,7 +60,12 @@ class DatabaseService:
|
|||||||
def get_or_create_lot(
|
def get_or_create_lot(
|
||||||
self, immeuble_id: int, numero: str, lot_type: str = None
|
self, immeuble_id: int, numero: str, lot_type: str = None
|
||||||
) -> Lot:
|
) -> 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)
|
stmt = select(Lot).where(Lot.immeuble_id == immeuble_id, Lot.numero == numero)
|
||||||
lot = self.session.execute(stmt).scalar_one_or_none()
|
lot = self.session.execute(stmt).scalar_one_or_none()
|
||||||
|
|
||||||
@@ -134,6 +144,9 @@ class DatabaseService:
|
|||||||
Raises:
|
Raises:
|
||||||
DuplicateDocumentError: If document already exists and overwrite=False
|
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", {})
|
metadata = data.get("metadata", {})
|
||||||
doc_info = metadata.get("document", {})
|
doc_info = metadata.get("document", {})
|
||||||
immeuble_info = metadata.get("immeuble", {})
|
immeuble_info = metadata.get("immeuble", {})
|
||||||
@@ -247,7 +260,7 @@ class DatabaseService:
|
|||||||
# Get or create lot
|
# Get or create lot
|
||||||
lot = self.get_or_create_lot(
|
lot = self.get_or_create_lot(
|
||||||
immeuble_id=immeuble.id,
|
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"),
|
lot_type=lot_info.get("type"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import re
|
|||||||
|
|
||||||
from ..utils.amounts import extract_amounts_from_line
|
from ..utils.amounts import extract_amounts_from_line
|
||||||
from ..utils.dates import parse_french_date
|
from ..utils.dates import parse_french_date
|
||||||
|
from ..utils.lots import normalize_lot_numero
|
||||||
|
|
||||||
|
|
||||||
def _preprocess_locataires_text(text: str) -> str:
|
def _preprocess_locataires_text(text: str) -> str:
|
||||||
@@ -84,7 +85,7 @@ def extract_situation_locataires(text: str) -> list[dict]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Liste des situations par lot, chacune contenant:
|
Liste des situations par lot, chacune contenant:
|
||||||
- lot: numéro et type
|
- lot: numéro (2 chiffres) et type
|
||||||
- locataire: nom
|
- locataire: nom
|
||||||
- lignes: détail des loyers, charges, etc.
|
- lignes: détail des loyers, charges, etc.
|
||||||
- totaux: sommes par catégorie
|
- 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)
|
# Nouveau lot (peut avoir les données de loyer sur la même ligne)
|
||||||
lot_match = re.match(
|
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,
|
line_stripped,
|
||||||
)
|
)
|
||||||
if lot_match:
|
if lot_match:
|
||||||
if current_lot:
|
if current_lot:
|
||||||
situations.append(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)
|
lot_type = lot_match.group(2)
|
||||||
|
|
||||||
current_lot = {
|
current_lot = {
|
||||||
|
|||||||
@@ -26,12 +26,13 @@ import pdfplumber
|
|||||||
|
|
||||||
from ..utils.amounts import extract_amounts_from_line
|
from ..utils.amounts import extract_amounts_from_line
|
||||||
from ..utils.dates import parse_french_date
|
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.
|
# Tolérance verticale (points PDF) pour regrouper les mots d'une même ligne visuelle.
|
||||||
_Y_TOL = 3.0
|
_Y_TOL = 3.0
|
||||||
|
|
||||||
_LOT_RE = re.compile(
|
_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)"
|
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})")
|
_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:
|
def _new_lot(numero: str, lot_type: str) -> dict:
|
||||||
return {
|
return {
|
||||||
"lot": {"numero": numero, "type": lot_type},
|
"lot": {"numero": normalize_lot_numero(numero), "type": lot_type},
|
||||||
"locataire": {"nom": ""},
|
"locataire": {"nom": ""},
|
||||||
"lignes": [],
|
"lignes": [],
|
||||||
"totaux": {
|
"totaux": {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from ..utils.amounts import parse_amount
|
from ..utils.amounts import parse_amount
|
||||||
|
from ..utils.lots import normalize_lot_numero
|
||||||
|
|
||||||
|
|
||||||
def _extract_lot_code_from_description(description: str) -> str | None:
|
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ù:
|
Les codes lots suivent le format: {Lettre}{Numéro} où:
|
||||||
- La lettre identifie l'immeuble (M=Marietton, S=Servient, B=Bloch, etc.)
|
- 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:
|
Exemples:
|
||||||
- "M06 - Commande moteur pompe" -> "0006"
|
- "M06 - Commande moteur pompe" -> "06"
|
||||||
- "S05 - Mise en service" -> "0005"
|
- "S05 - Mise en service" -> "05"
|
||||||
- "B01 - Plaques" -> "0001"
|
- "B01 - Plaques" -> "01"
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
description: Description de l'opération
|
description: Description de l'opération
|
||||||
|
|
||||||
Returns:
|
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:
|
if not description:
|
||||||
return None
|
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).
|
# code) et "S01 SOLDE ..." (code lot non suivi d'un tiret).
|
||||||
match = re.search(r"\b[A-Z]\s*(\d{1,2})(?=[\s-]|$)", description)
|
match = re.search(r"\b[A-Z]\s*(\d{1,2})(?=[\s-]|$)", description)
|
||||||
if match:
|
if match:
|
||||||
lot_num = match.group(1)
|
return normalize_lot_numero(match.group(1))
|
||||||
# Formater sur 4 chiffres (ex: "6" -> "0006", "12" -> "0012")
|
|
||||||
return lot_num.zfill(4)
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ Tables et colonnes :
|
|||||||
3. **lots** — Lots (appartements, locaux commerciaux) au sein d'un immeuble
|
3. **lots** — Lots (appartements, locaux commerciaux) au sein d'un immeuble
|
||||||
- id (INTEGER, PK)
|
- id (INTEGER, PK)
|
||||||
- immeuble_id (INTEGER, FK → immeubles.id)
|
- 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.)
|
- type (VARCHAR(50)) — type de lot ("Appartement", "Loc. Commercial", etc.)
|
||||||
- created_at (DATETIME)
|
- created_at (DATETIME)
|
||||||
Contrainte : (immeuble_id, numero) est unique
|
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": [
|
"situation_locataires": [
|
||||||
{
|
{
|
||||||
"lot": {"numero": "001", "type": "Appartement"},
|
"lot": {"numero": "01", "type": "Appartement"},
|
||||||
"locataire": {"nom": "DUPONT"},
|
"locataire": {"nom": "DUPONT"},
|
||||||
"lignes": [
|
"lignes": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ def test_save_document_creates_full_graph(db_session, sample_data):
|
|||||||
|
|
||||||
# Immeuble / lot / locataire créés
|
# Immeuble / lot / locataire créés
|
||||||
assert db_session.query(Immeuble).filter_by(code="IMM1").count() == 1
|
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
|
assert db_session.query(Locataire).filter_by(nom="DUPONT").count() == 1
|
||||||
|
|
||||||
# Revenu et dépense rattachés
|
# 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