feat: add homepage

This commit is contained in:
2026-01-18 21:11:35 +01:00
parent ee1db93298
commit c5e51a7513
17 changed files with 1550 additions and 115 deletions

View File

@@ -0,0 +1,21 @@
"""Database module for plesna-gerance."""
from .connection import get_engine, get_session, get_session_factory, init_db
from .models import Base, Document, Immeuble, Lot, Locataire, Revenu, Depense
from .service import DatabaseService, DuplicateDocumentError
__all__ = [
"get_engine",
"get_session",
"get_session_factory",
"init_db",
"Base",
"Document",
"Immeuble",
"Lot",
"Locataire",
"Revenu",
"Depense",
"DatabaseService",
"DuplicateDocumentError",
]

View File

@@ -0,0 +1,118 @@
"""Database connection management for SQLite."""
import os
from pathlib import Path
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from .models import Base
# Default database path - relative to project root
def _get_project_root() -> Path:
"""Find project root by looking for pyproject.toml."""
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "pyproject.toml").exists():
return parent
# Fallback to home directory if not found
return Path.home() / ".plesna_gerance"
DEFAULT_DB_PATH = _get_project_root() / "data" / "database.sqlite"
# Global engine instance
_engine = None
_SessionLocal = None
def get_db_path() -> Path:
"""Get database path from environment or default."""
env_path = os.environ.get("PLESNA_DB_PATH")
if env_path:
return Path(env_path)
return DEFAULT_DB_PATH
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
)
return _engine
def get_session_factory(engine=None):
"""Get or create session factory."""
global _SessionLocal
if _SessionLocal is None:
if engine is None:
engine = get_engine()
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
return _SessionLocal
def get_session() -> Generator[Session, None, None]:
"""Dependency for FastAPI to get database session."""
SessionLocal = get_session_factory()
session = SessionLocal()
try:
yield session
finally:
session.close()
def init_db(db_path: Path | None = None) -> Path:
"""Initialize database: create all tables.
Returns the path to the database file.
"""
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}
)
Base.metadata.create_all(bind=engine)
# Update globals
_engine = engine
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
return db_path
def reset_connection():
"""Reset global connection (useful for testing)."""
global _engine, _SessionLocal
if _engine is not None:
_engine.dispose()
_engine = None
_SessionLocal = None

View File

@@ -0,0 +1,232 @@
"""SQLAlchemy models for plesna-gerance database."""
from datetime import date, datetime
from typing import Optional
from sqlalchemy import (
Column,
Integer,
String,
Float,
Date,
DateTime,
Text,
ForeignKey,
UniqueConstraint,
Index,
)
from sqlalchemy.orm import DeclarativeBase, relationship
class Base(DeclarativeBase):
"""Base class for all models."""
pass
class Immeuble(Base):
"""Table des immeubles gérés."""
__tablename__ = "immeubles"
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(20), unique=True, nullable=False, index=True)
adresse = Column(String(255), nullable=True)
ville = Column(String(100), nullable=True)
code_postal = Column(String(10), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Relations
lots = relationship("Lot", back_populates="immeuble", cascade="all, delete-orphan")
documents = relationship("Document", back_populates="immeuble")
depenses = relationship("Depense", back_populates="immeuble")
def __repr__(self) -> str:
return f"<Immeuble(code={self.code}, adresse={self.adresse})>"
class Lot(Base):
"""Table des lots (appartements, locaux commerciaux, etc.)."""
__tablename__ = "lots"
id = Column(Integer, primary_key=True, autoincrement=True)
immeuble_id = Column(Integer, ForeignKey("immeubles.id"), nullable=False)
numero = Column(String(10), nullable=False)
type = Column(String(50), nullable=True) # "Loc. Commercial", "Appartement", etc.
created_at = Column(DateTime, default=datetime.utcnow)
# Contrainte unique: un numéro de lot par immeuble
__table_args__ = (
UniqueConstraint("immeuble_id", "numero", name="uq_lot_immeuble_numero"),
Index("ix_lot_immeuble_numero", "immeuble_id", "numero"),
)
# Relations
immeuble = relationship("Immeuble", back_populates="lots")
locataires = relationship(
"Locataire", back_populates="lot", cascade="all, delete-orphan"
)
revenus = relationship("Revenu", back_populates="lot")
depenses = relationship("Depense", back_populates="lot")
def __repr__(self) -> str:
return f"<Lot(numero={self.numero}, type={self.type})>"
class Locataire(Base):
"""Table des locataires avec historique."""
__tablename__ = "locataires"
id = Column(Integer, primary_key=True, autoincrement=True)
lot_id = Column(Integer, ForeignKey("lots.id"), nullable=False)
nom = Column(String(255), nullable=False)
date_debut = Column(Date, nullable=True) # Date d'entrée dans le lot
date_fin = Column(Date, nullable=True) # Date de sortie (NULL si actif)
created_at = Column(DateTime, default=datetime.utcnow)
# Contrainte unique: un locataire par lot et période
__table_args__ = (
UniqueConstraint(
"lot_id", "nom", "date_debut", name="uq_locataire_lot_nom_debut"
),
Index("ix_locataire_nom", "nom"),
)
# Relations
lot = relationship("Lot", back_populates="locataires")
revenus = relationship("Revenu", back_populates="locataire")
def __repr__(self) -> str:
return f"<Locataire(nom={self.nom})>"
class Document(Base):
"""Table des documents PDF importés avec traçabilité."""
__tablename__ = "documents"
id = Column(Integer, primary_key=True, autoincrement=True)
reference = Column(String(50), nullable=False, index=True)
date = Column(Date, nullable=False, index=True)
type = Column(String(100), nullable=True) # "COMPTE RENDU DE GESTION"
source_file = Column(String(255), nullable=True) # Nom du fichier PDF original
immeuble_id = Column(Integer, ForeignKey("immeubles.id"), nullable=False)
# JSON brut pour traçabilité complète
json_data = Column(Text, nullable=False)
# Métadonnées éditeur
editeur_nom = Column(String(255), nullable=True)
editeur_siret = Column(String(20), nullable=True)
# Solde à la date du document
solde_montant = Column(Float, nullable=True)
solde_type = Column(String(20), nullable=True) # "crediteur" ou "debiteur"
solde_date_arrete = Column(Date, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
# Clé unique: référence + date (évite les doublons)
__table_args__ = (
UniqueConstraint("reference", "date", name="uq_document_reference_date"),
)
# Relations
immeuble = relationship("Immeuble", back_populates="documents")
revenus = relationship(
"Revenu", back_populates="document", cascade="all, delete-orphan"
)
depenses = relationship(
"Depense", back_populates="document", cascade="all, delete-orphan"
)
def __repr__(self) -> str:
return f"<Document(reference={self.reference}, date={self.date})>"
class Revenu(Base):
"""Table des revenus locatifs (lignes détaillées)."""
__tablename__ = "revenus"
id = Column(Integer, primary_key=True, autoincrement=True)
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
lot_id = Column(Integer, ForeignKey("lots.id"), nullable=False)
locataire_id = Column(Integer, ForeignKey("locataires.id"), nullable=False)
# Type de ligne: loyer, solde_anterieur, rappel_loyer, divers
type_ligne = Column(String(50), nullable=False)
# Période concernée
periode_debut = Column(Date, nullable=True)
periode_fin = Column(Date, nullable=True)
# Montants détaillés
loyers = Column(Float, default=0.0)
taxes = Column(Float, default=0.0)
provisions = Column(Float, default=0.0)
divers_montant = Column(Float, default=0.0)
divers_libelle = Column(String(255), nullable=True)
total = Column(Float, default=0.0)
regles = Column(Float, default=0.0) # Montant réglé
impayes = Column(Float, default=0.0)
created_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (
Index("ix_revenu_document", "document_id"),
Index("ix_revenu_lot", "lot_id"),
Index("ix_revenu_periode", "periode_debut", "periode_fin"),
)
# Relations
document = relationship("Document", back_populates="revenus")
lot = relationship("Lot", back_populates="revenus")
locataire = relationship("Locataire", back_populates="revenus")
def __repr__(self) -> str:
return f"<Revenu(type={self.type_ligne}, total={self.total})>"
class Depense(Base):
"""Table des dépenses/opérations."""
__tablename__ = "depenses"
id = Column(Integer, primary_key=True, autoincrement=True)
document_id = Column(Integer, ForeignKey("documents.id"), nullable=False)
immeuble_id = Column(Integer, ForeignKey("immeubles.id"), nullable=False)
lot_id = Column(
Integer, ForeignKey("lots.id"), nullable=True
) # NULL si dépense immeuble
# Catégorisation
categorie = Column(String(100), nullable=True) # DEPENSES_LOCATIVES, etc.
sous_categorie = Column(String(255), nullable=True) # Nettoyage immeuble, etc.
fournisseur = Column(String(255), nullable=True)
description = Column(String(500), nullable=True)
# Montants
debit = Column(Float, default=0.0)
credit = Column(Float, default=0.0)
tva = Column(Float, default=0.0)
locatif = Column(Float, default=0.0) # Part locative
deductible = Column(Float, default=0.0) # Part déductible
created_at = Column(DateTime, default=datetime.utcnow)
__table_args__ = (
Index("ix_depense_document", "document_id"),
Index("ix_depense_immeuble", "immeuble_id"),
Index("ix_depense_categorie", "categorie"),
)
# Relations
document = relationship("Document", back_populates="depenses")
immeuble = relationship("Immeuble", back_populates="depenses")
lot = relationship("Lot", back_populates="depenses")
def __repr__(self) -> str:
return f"<Depense(categorie={self.categorie}, debit={self.debit})>"

View File

@@ -0,0 +1,314 @@
"""Database service for saving and querying extracted data."""
import json
from datetime import date, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from sqlalchemy.exc import IntegrityError
from .models import Document, Immeuble, Lot, Locataire, Revenu, Depense
class DuplicateDocumentError(Exception):
"""Raised when attempting to save a document that already exists."""
def __init__(self, reference: str, doc_date: date):
self.reference = reference
self.date = doc_date
super().__init__(
f"Document avec reference={reference} et date={doc_date} existe deja"
)
class DatabaseService:
"""Service for database operations."""
def __init__(self, session: Session):
self.session = session
def check_duplicate(self, reference: str, doc_date: date) -> Document | None:
"""Check if a document with this reference and date already exists."""
stmt = select(Document).where(
Document.reference == reference, Document.date == doc_date
)
return self.session.execute(stmt).scalar_one_or_none()
def get_or_create_immeuble(
self, code: str, adresse: str = None, ville: str = None, code_postal: str = None
) -> Immeuble:
"""Get existing immeuble or create new one."""
stmt = select(Immeuble).where(Immeuble.code == code)
immeuble = self.session.execute(stmt).scalar_one_or_none()
if immeuble is None:
immeuble = Immeuble(
code=code, adresse=adresse, ville=ville, code_postal=code_postal
)
self.session.add(immeuble)
self.session.flush() # Get the ID
return immeuble
def get_or_create_lot(
self, immeuble_id: int, numero: str, lot_type: str = None
) -> Lot:
"""Get existing lot or create new one."""
stmt = select(Lot).where(Lot.immeuble_id == immeuble_id, Lot.numero == numero)
lot = self.session.execute(stmt).scalar_one_or_none()
if lot is None:
lot = Lot(immeuble_id=immeuble_id, numero=numero, type=lot_type)
self.session.add(lot)
self.session.flush()
return lot
def get_or_create_locataire(
self, lot_id: int, nom: str, date_debut: date = None
) -> Locataire:
"""Get existing locataire or create new one."""
stmt = select(Locataire).where(Locataire.lot_id == lot_id, Locataire.nom == nom)
# If date_debut is provided, include it in the search
if date_debut:
stmt = stmt.where(Locataire.date_debut == date_debut)
else:
stmt = stmt.where(Locataire.date_debut.is_(None))
locataire = self.session.execute(stmt).scalar_one_or_none()
if locataire is None:
locataire = Locataire(lot_id=lot_id, nom=nom, date_debut=date_debut)
self.session.add(locataire)
self.session.flush()
return locataire
def _parse_date(self, date_str: str | None) -> date | None:
"""Parse ISO date string to date object."""
if not date_str:
return None
try:
return datetime.strptime(date_str, "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
def save_document(self, data: dict[str, Any], source_file: str = None) -> Document:
"""Save extracted JSON data to database.
Args:
data: The 'data' portion of the extracted JSON (contains metadata,
situation_locataires, recapitulatif_operations)
source_file: Original PDF filename
Returns:
The created Document instance
Raises:
DuplicateDocumentError: If document already exists
"""
metadata = data.get("metadata", {})
doc_info = metadata.get("document", {})
immeuble_info = metadata.get("immeuble", {})
editeur_info = metadata.get("editeur", {})
solde_info = metadata.get("solde", {})
# Extract key fields
reference = doc_info.get("reference", "")
doc_date = self._parse_date(doc_info.get("date"))
if not reference or not doc_date:
raise ValueError("Document must have reference and date")
# Check for duplicates
existing = self.check_duplicate(reference, doc_date)
if existing:
raise DuplicateDocumentError(reference, doc_date)
# Get or create immeuble
immeuble = self.get_or_create_immeuble(
code=immeuble_info.get("code", "UNKNOWN"),
adresse=immeuble_info.get("adresse"),
ville=immeuble_info.get("ville"),
code_postal=immeuble_info.get("code_postal"),
)
# Create document
document = Document(
reference=reference,
date=doc_date,
type=doc_info.get("type"),
source_file=source_file,
immeuble_id=immeuble.id,
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_type=solde_info.get("type"),
solde_date_arrete=self._parse_date(solde_info.get("date_arrete")),
)
self.session.add(document)
self.session.flush()
# Process situation_locataires (revenus)
for situation in data.get("situation_locataires", []):
self._save_situation_locataire(document, immeuble, situation)
# Process recapitulatif_operations (depenses)
for operation in data.get("recapitulatif_operations", []):
self._save_operation(document, immeuble, operation)
self.session.commit()
return document
def _save_situation_locataire(
self, document: Document, immeuble: Immeuble, situation: dict
) -> None:
"""Save locataire situation (revenus lines)."""
lot_info = situation.get("lot", {})
locataire_info = situation.get("locataire", {})
# Get or create lot
lot = self.get_or_create_lot(
immeuble_id=immeuble.id,
numero=lot_info.get("numero", "0000"),
lot_type=lot_info.get("type"),
)
# Get or create locataire
locataire = self.get_or_create_locataire(
lot_id=lot.id, nom=locataire_info.get("nom", "INCONNU")
)
# Save each revenue line
for ligne in situation.get("lignes", []):
periode = ligne.get("periode", {})
divers = ligne.get("divers", {})
revenu = Revenu(
document_id=document.id,
lot_id=lot.id,
locataire_id=locataire.id,
type_ligne=ligne.get("type", "loyer"),
periode_debut=self._parse_date(periode.get("debut")),
periode_fin=self._parse_date(periode.get("fin")),
loyers=ligne.get("loyers", 0.0) or 0.0,
taxes=ligne.get("taxes", 0.0) or 0.0,
provisions=ligne.get("provisions", 0.0) or 0.0,
divers_montant=divers.get("montant", 0.0) or 0.0 if divers else 0.0,
divers_libelle=divers.get("libelle") if divers else None,
total=ligne.get("total", 0.0) or 0.0,
regles=ligne.get("regles", 0.0) or 0.0,
impayes=ligne.get("impayes", 0.0) or 0.0,
)
self.session.add(revenu)
def _save_operation(
self, document: Document, immeuble: Immeuble, operation: dict
) -> None:
"""Save operation (depense)."""
montants = operation.get("montants", {})
# Check if operation is linked to a specific lot
lot_id = None
lot_numero = operation.get("lot_numero")
if lot_numero:
lot = self.get_or_create_lot(immeuble_id=immeuble.id, numero=lot_numero)
lot_id = lot.id
depense = Depense(
document_id=document.id,
immeuble_id=immeuble.id,
lot_id=lot_id, # Can be NULL for immeuble-level expenses
categorie=operation.get("categorie"),
sous_categorie=operation.get("sous_categorie"),
fournisseur=operation.get("fournisseur"),
description=operation.get("description"),
debit=montants.get("debit", 0.0) or 0.0,
credit=montants.get("credit", 0.0) or 0.0,
tva=montants.get("tva", 0.0) or 0.0,
locatif=montants.get("locatif", 0.0) or 0.0,
deductible=montants.get("deductible", 0.0) or 0.0,
)
self.session.add(depense)
def list_documents(self, limit: int = 100, offset: int = 0) -> list[Document]:
"""List all imported documents."""
stmt = (
select(Document)
.order_by(Document.date.desc(), Document.created_at.desc())
.limit(limit)
.offset(offset)
)
result = self.session.execute(stmt)
return list(result.scalars().all())
def get_document_by_id(self, doc_id: int) -> Document | None:
"""Get a document by ID."""
return self.session.get(Document, doc_id)
def get_revenus_summary(
self, immeuble_id: int = None, year: int = None
) -> list[dict]:
"""Get revenue summary grouped by period."""
stmt = select(Revenu)
if immeuble_id:
stmt = stmt.join(Lot).where(Lot.immeuble_id == immeuble_id)
if year:
stmt = stmt.where(
Revenu.periode_debut >= date(year, 1, 1),
Revenu.periode_debut <= date(year, 12, 31),
)
result = self.session.execute(stmt)
revenus = result.scalars().all()
# Aggregate
total_loyers = sum(r.loyers for r in revenus)
total_regles = sum(r.regles for r in revenus)
total_impayes = sum(r.impayes for r in revenus)
return {
"total_loyers": total_loyers,
"total_regles": total_regles,
"total_impayes": total_impayes,
"count": len(revenus),
}
def get_depenses_summary(self, immeuble_id: int = None, year: int = None) -> dict:
"""Get expenses summary grouped by category."""
stmt = select(Depense)
if immeuble_id:
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
if year:
stmt = stmt.join(Document).where(
Document.date >= date(year, 1, 1), Document.date <= date(year, 12, 31)
)
result = self.session.execute(stmt)
depenses = result.scalars().all()
# Aggregate by category
by_category = {}
for d in depenses:
cat = d.categorie or "AUTRE"
if cat not in by_category:
by_category[cat] = {"debit": 0.0, "credit": 0.0, "count": 0}
by_category[cat]["debit"] += d.debit
by_category[cat]["credit"] += d.credit
by_category[cat]["count"] += 1
total_debit = sum(d.debit for d in depenses)
total_credit = sum(d.credit for d in depenses)
return {
"by_category": by_category,
"total_debit": total_debit,
"total_credit": total_credit,
"count": len(depenses),
}