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
|
||||
|
||||
|
||||
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):
|
||||
"""Get or create SQLAlchemy engine (singleton pattern)."""
|
||||
global _engine
|
||||
|
||||
if _engine is None:
|
||||
if db_path is None:
|
||||
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
|
||||
)
|
||||
_engine = _build_engine(db_path or get_db_path())
|
||||
|
||||
return _engine
|
||||
|
||||
@@ -87,25 +91,13 @@ def init_db(db_path: Path | None = None) -> Path:
|
||||
if db_path is None:
|
||||
db_path = get_db_path()
|
||||
|
||||
# Reset globals to use new path
|
||||
global _engine, _SessionLocal
|
||||
_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}
|
||||
)
|
||||
# Reset globals so the engine/session factory rebuild against db_path
|
||||
reset_connection()
|
||||
|
||||
# Build the engine (reuses the shared configuration) and create tables
|
||||
engine = get_engine(db_path)
|
||||
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_tags_if_empty(engine)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from .models import Document, Immeuble, Lot, Locataire, Revenu, Depense, Tag
|
||||
from . import storage
|
||||
from ..utils.amounts import parse_amount
|
||||
|
||||
|
||||
class DuplicateDocumentError(Exception):
|
||||
@@ -95,6 +96,21 @@ class DatabaseService:
|
||||
except (ValueError, TypeError):
|
||||
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(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
@@ -186,7 +202,7 @@ class DatabaseService:
|
||||
json_data=json.dumps(data, ensure_ascii=False, default=str),
|
||||
editeur_nom=editeur_info.get("nom"),
|
||||
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_date_arrete=self._parse_date(solde_info.get("date_arrete")),
|
||||
pdf_path=pdf_path,
|
||||
|
||||
@@ -95,6 +95,29 @@ def test_save_document_assigns_tags(db_session, sample_data):
|
||||
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):
|
||||
service = DatabaseService(db_session)
|
||||
assert service.check_duplicate("REF001", date(2024, 1, 15)) is None
|
||||
|
||||
Reference in New Issue
Block a user