refactor: factorise la création d'engine et normalise solde_montant
- connection: création de l'engine centralisée dans _build_engine (source unique de config) ; init_db réutilise reset_connection + get_engine au lieu de dupliquer create_engine - service: _normalize_amount garantit qu'un montant non numérique issu de l'extraction (string, dict…) n'entre jamais en base dans une colonne Float ; appliqué à solde_montant - tests: couverture de _normalize_amount et du solde sous forme de string Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -36,23 +36,27 @@ def get_db_path() -> Path:
|
|||||||
return DEFAULT_DB_PATH
|
return DEFAULT_DB_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def _build_engine(db_path: Path):
|
||||||
|
"""Create a SQLAlchemy engine for the given SQLite path.
|
||||||
|
|
||||||
|
Single source of truth for engine configuration.
|
||||||
|
"""
|
||||||
|
# Create parent directory if needed
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
return create_engine(
|
||||||
|
f"sqlite:///{db_path}",
|
||||||
|
echo=False, # Set to True for SQL debugging
|
||||||
|
connect_args={"check_same_thread": False}, # Required for FastAPI
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_engine(db_path: Path | None = None):
|
def get_engine(db_path: Path | None = None):
|
||||||
"""Get or create SQLAlchemy engine (singleton pattern)."""
|
"""Get or create SQLAlchemy engine (singleton pattern)."""
|
||||||
global _engine
|
global _engine
|
||||||
|
|
||||||
if _engine is None:
|
if _engine is None:
|
||||||
if db_path is None:
|
_engine = _build_engine(db_path or get_db_path())
|
||||||
db_path = get_db_path()
|
|
||||||
|
|
||||||
# Create parent directory if needed
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create engine with SQLite
|
|
||||||
_engine = create_engine(
|
|
||||||
f"sqlite:///{db_path}",
|
|
||||||
echo=False, # Set to True for SQL debugging
|
|
||||||
connect_args={"check_same_thread": False}, # Required for FastAPI
|
|
||||||
)
|
|
||||||
|
|
||||||
return _engine
|
return _engine
|
||||||
|
|
||||||
@@ -87,25 +91,13 @@ def init_db(db_path: Path | None = None) -> Path:
|
|||||||
if db_path is None:
|
if db_path is None:
|
||||||
db_path = get_db_path()
|
db_path = get_db_path()
|
||||||
|
|
||||||
# Reset globals to use new path
|
# Reset globals so the engine/session factory rebuild against db_path
|
||||||
global _engine, _SessionLocal
|
reset_connection()
|
||||||
_engine = None
|
|
||||||
_SessionLocal = None
|
|
||||||
|
|
||||||
# Create parent directory
|
|
||||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create engine and tables
|
|
||||||
engine = create_engine(
|
|
||||||
f"sqlite:///{db_path}", echo=False, connect_args={"check_same_thread": False}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# Build the engine (reuses the shared configuration) and create tables
|
||||||
|
engine = get_engine(db_path)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
# Update globals
|
|
||||||
_engine = engine
|
|
||||||
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
||||||
|
|
||||||
# Seed predefined tags if the table is empty
|
# Seed predefined tags if the table is empty
|
||||||
_seed_tags_if_empty(engine)
|
_seed_tags_if_empty(engine)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
|
|
||||||
from .models import Document, Immeuble, Lot, Locataire, Revenu, Depense, Tag
|
from .models import Document, Immeuble, Lot, Locataire, Revenu, Depense, Tag
|
||||||
from . import storage
|
from . import storage
|
||||||
|
from ..utils.amounts import parse_amount
|
||||||
|
|
||||||
|
|
||||||
class DuplicateDocumentError(Exception):
|
class DuplicateDocumentError(Exception):
|
||||||
@@ -95,6 +96,21 @@ class DatabaseService:
|
|||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_amount(value: Any) -> float | None:
|
||||||
|
"""Normalise un montant en float (ou None si non interprétable).
|
||||||
|
|
||||||
|
Garantit qu'un type non numérique issu de l'extraction (string, dict…)
|
||||||
|
n'entre jamais en base dans une colonne Float.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool): # bool est un int en Python, on l'exclut
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return parse_amount(value)
|
||||||
|
return None
|
||||||
|
|
||||||
def save_document(
|
def save_document(
|
||||||
self,
|
self,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
@@ -186,7 +202,7 @@ class DatabaseService:
|
|||||||
json_data=json.dumps(data, ensure_ascii=False, default=str),
|
json_data=json.dumps(data, ensure_ascii=False, default=str),
|
||||||
editeur_nom=editeur_info.get("nom"),
|
editeur_nom=editeur_info.get("nom"),
|
||||||
editeur_siret=editeur_info.get("siret"),
|
editeur_siret=editeur_info.get("siret"),
|
||||||
solde_montant=solde_info.get("montant"),
|
solde_montant=self._normalize_amount(solde_info.get("montant")),
|
||||||
solde_type=solde_info.get("type"),
|
solde_type=solde_info.get("type"),
|
||||||
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
|
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
|
||||||
pdf_path=pdf_path,
|
pdf_path=pdf_path,
|
||||||
|
|||||||
@@ -95,6 +95,29 @@ def test_save_document_assigns_tags(db_session, sample_data):
|
|||||||
assert depense.tag_id == tag.id
|
assert depense.tag_id == tag.id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,expected",
|
||||||
|
[
|
||||||
|
(100.0, 100.0),
|
||||||
|
(100, 100.0),
|
||||||
|
("1 234,56", 1234.56), # string française -> parsée
|
||||||
|
(None, None),
|
||||||
|
({}, None), # type inattendu -> None, jamais stocké tel quel
|
||||||
|
(True, None), # bool exclu
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_normalize_amount(raw, expected):
|
||||||
|
assert DatabaseService._normalize_amount(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_document_solde_string_is_normalized(db_session, sample_data):
|
||||||
|
sample_data["metadata"]["solde"]["montant"] = "1 234,56"
|
||||||
|
service = DatabaseService(db_session)
|
||||||
|
doc = service.save_document(data=sample_data)
|
||||||
|
assert isinstance(doc.solde_montant, float)
|
||||||
|
assert doc.solde_montant == pytest.approx(1234.56)
|
||||||
|
|
||||||
|
|
||||||
def test_check_duplicate(db_session, sample_data):
|
def test_check_duplicate(db_session, sample_data):
|
||||||
service = DatabaseService(db_session)
|
service = DatabaseService(db_session)
|
||||||
assert service.check_duplicate("REF001", date(2024, 1, 15)) is None
|
assert service.check_duplicate("REF001", date(2024, 1, 15)) is None
|
||||||
|
|||||||
Reference in New Issue
Block a user