feat: donne aux immeubles un nom d'usage

Les comptes rendus n'identifient un immeuble que par son code de gestion
(« 33689020 »), illisible partout où il s'affiche. La dénomination
(« Servient ») le remplace à l'écran sans toucher au code, qui reste la clé
venue des PDF.

La colonne s'ajoute à une table déjà installée : elle passe donc par le
rattrapage de schéma, qui accepte désormais l'absence de valeur de
rattrapage. Déduire un nom d'usage du code en inventerait un.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 15:40:27 +02:00
parent 5a08b0c7e5
commit 9f5f46a93a
6 changed files with 71 additions and 11 deletions

View File

@@ -59,6 +59,7 @@ async def list_immeubles(
ImmeubleResponse( ImmeubleResponse(
id=row.Immeuble.id, id=row.Immeuble.id,
code=row.Immeuble.code, code=row.Immeuble.code,
denomination=row.Immeuble.denomination,
adresse=row.Immeuble.adresse, adresse=row.Immeuble.adresse,
ville=row.Immeuble.ville, ville=row.Immeuble.ville,
code_postal=row.Immeuble.code_postal, code_postal=row.Immeuble.code_postal,

View File

@@ -6,6 +6,7 @@ from .models import (
DepenseDetail, DepenseDetail,
DepensesSummary, DepensesSummary,
DocumentSummary, DocumentSummary,
ImmeubleBody,
ImmeubleResponse, ImmeubleResponse,
LotReferentielResponse, LotReferentielResponse,
LotResponse, LotResponse,
@@ -21,6 +22,7 @@ __all__ = [
"DocumentSummary", "DocumentSummary",
"DepenseDetail", "DepenseDetail",
"DepensesSummary", "DepensesSummary",
"ImmeubleBody",
"ImmeubleResponse", "ImmeubleResponse",
"LotResponse", "LotResponse",
"CaracteristiquesBody", "CaracteristiquesBody",

View File

@@ -72,6 +72,7 @@ class ImmeubleResponse(BaseModel):
id: int id: int
code: str code: str
denomination: str | None = None
adresse: str | None adresse: str | None
ville: str | None ville: str | None
code_postal: str | None code_postal: str | None
@@ -189,6 +190,20 @@ class DepensesSummary(BaseModel):
# ============================================================ # ============================================================
class ImmeubleBody(BaseModel):
"""Ce qui se saisit sur un immeuble : son nom d'usage."""
denomination: str | None = None
@field_validator("denomination")
@classmethod
def _texte_vide_vaut_absent(cls, value: str | None) -> str | None:
"""Effacer le nom doit rendre l'immeuble à son code, pas le nommer « »."""
if value is None:
return None
return value.strip() or None
class CaracteristiquesBody(BaseModel): class CaracteristiquesBody(BaseModel):
"""Caractéristiques d'un logement telles que saisies. """Caractéristiques d'un logement telles que saisies.
@@ -248,6 +263,8 @@ class LotReferentielResponse(BaseModel):
numero: str numero: str
immeuble_id: int immeuble_id: int
immeuble_code: str | None immeuble_code: str | None
#: Nom d'usage de l'immeuble ; le tableau retombe sur le code s'il manque.
immeuble_denomination: str | None = None
#: Type de lot vu par l'extraction PDF, conservé tel quel. #: Type de lot vu par l'extraction PDF, conservé tel quel.
type_extrait: str | None type_extrait: str | None

View File

@@ -96,14 +96,19 @@ def init_db(db_path: Path | None = None) -> Path:
#: Colonnes ajoutees apres coup, par table : nom -> (definition SQL, valeur de #: Colonnes ajoutees apres coup, par table : nom -> (definition SQL, valeur de
#: rattrapage pour les lignes existantes). `create_all` ne modifie pas une table #: rattrapage pour les lignes existantes, ou None quand il n'y a rien de vrai a
#: deja presente, et le projet n'utilise pas d'outil de migration : sans ce #: y mettre). `create_all` ne modifie pas une table deja presente, et le projet
#: rattrapage, une base installee cesserait de fonctionner apres mise a jour. #: n'utilise pas d'outil de migration : sans ce rattrapage, une base installee
#: cesserait de fonctionner apres mise a jour.
_ADDED_COLUMNS = { _ADDED_COLUMNS = {
"documents": { "documents": {
# Les documents deja en base ont ete extraits lors de leur import. # Les documents deja en base ont ete extraits lors de leur import.
"extracted_at": ("DATETIME", "created_at"), "extracted_at": ("DATETIME", "created_at"),
}, },
"immeubles": {
# Le nom d'usage se saisit : le deduire du code inventerait un nom.
"denomination": ("VARCHAR(100)", None),
},
} }
@@ -119,7 +124,10 @@ def _apply_schema_updates(engine):
for name, (definition, backfill) in columns.items(): for name, (definition, backfill) in columns.items():
if name in existing: if name in existing:
continue continue
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")) conn.execute(
text(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
)
if backfill is not None:
conn.execute(text(f"UPDATE {table} SET {name} = {backfill}")) conn.execute(text(f"UPDATE {table} SET {name} = {backfill}"))

View File

@@ -65,6 +65,9 @@ class Immeuble(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(20), unique=True, nullable=False, index=True) code = Column(String(20), unique=True, nullable=False, index=True)
#: Nom d'usage ("Servient"), saisi : les PDF ne donnent qu'un code de
#: gestion, illisible partout où l'immeuble est cité.
denomination = Column(String(100), nullable=True)
adresse = Column(String(255), nullable=True) adresse = Column(String(255), nullable=True)
ville = Column(String(100), nullable=True) ville = Column(String(100), nullable=True)
code_postal = Column(String(10), nullable=True) code_postal = Column(String(10), nullable=True)

View File

@@ -24,11 +24,7 @@ def test_ajoute_extracted_at_a_une_base_existante(tmp_path, monkeypatch):
importe_le = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc) importe_le = datetime(2026, 3, 1, 10, 0, tzinfo=timezone.utc)
with engine.begin() as conn: with engine.begin() as conn:
conn.execute(text("ALTER TABLE documents DROP COLUMN extracted_at")) conn.execute(text("ALTER TABLE documents DROP COLUMN extracted_at"))
conn.execute( conn.execute(text("INSERT INTO immeubles (code) VALUES ('IMM1');"))
text(
"INSERT INTO immeubles (code) VALUES ('IMM1');"
)
)
conn.execute( conn.execute(
text( text(
"INSERT INTO documents (reference, date, immeuble_id, json_data," "INSERT INTO documents (reference, date, immeuble_id, json_data,"
@@ -55,6 +51,37 @@ def test_ajoute_extracted_at_a_une_base_existante(tmp_path, monkeypatch):
connection.reset_connection() connection.reset_connection()
def test_ajoute_denomination_a_une_base_existante(tmp_path, monkeypatch):
"""La colonne arrive vide : deduire un nom d'usage du code l'inventerait."""
db_path = tmp_path / "ancienne.sqlite"
monkeypatch.setenv("PLESNA_DB_PATH", str(db_path))
monkeypatch.setenv("PLESNA_STORAGE_PATH", str(tmp_path / "documents"))
connection.reset_connection()
connection.init_db(db_path)
engine = connection.get_engine(db_path)
with engine.begin() as conn:
conn.execute(text("ALTER TABLE immeubles DROP COLUMN denomination"))
conn.execute(text("INSERT INTO immeubles (code) VALUES ('33689020')"))
connection.reset_connection()
connection.init_db(db_path)
with connection.get_engine(db_path).begin() as conn:
colonnes = {
row[1] for row in conn.execute(text("PRAGMA table_info(immeubles)"))
}
assert "denomination" in colonnes
denomination, code = conn.execute(
text("SELECT denomination, code FROM immeubles")
).one()
assert denomination is None
assert code == "33689020"
connection.reset_connection()
def test_rattrapage_idempotent(tmp_path, monkeypatch): def test_rattrapage_idempotent(tmp_path, monkeypatch):
"""Relancer init_db sur une base a jour ne doit rien casser.""" """Relancer init_db sur une base a jour ne doit rien casser."""
db_path = tmp_path / "a_jour.sqlite" db_path = tmp_path / "a_jour.sqlite"
@@ -67,7 +94,9 @@ def test_rattrapage_idempotent(tmp_path, monkeypatch):
connection.init_db(db_path) connection.init_db(db_path)
with connection.get_engine(db_path).begin() as conn: with connection.get_engine(db_path).begin() as conn:
colonnes = [row[1] for row in conn.execute(text("PRAGMA table_info(documents)"))] colonnes = [
row[1] for row in conn.execute(text("PRAGMA table_info(documents)"))
]
assert colonnes.count("extracted_at") == 1 assert colonnes.count("extracted_at") == 1
connection.reset_connection() connection.reset_connection()