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

@@ -2,13 +2,18 @@
import tempfile
from pathlib import Path
from typing import Any
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi import FastAPI, File, HTTPException, UploadFile, Depends
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sqlalchemy.orm import Session
from .. import __version__
from ..extractor import extract_compte_rendu
from ..database import init_db, get_session, DatabaseService
from ..database.service import DuplicateDocumentError
app = FastAPI(
title="Plesna Gérance API",
@@ -20,6 +25,46 @@ app = FastAPI(
)
# Initialize database on startup
@app.on_event("startup")
async def startup_event():
"""Initialize database on application startup."""
init_db()
# Pydantic models for API
class SaveRequest(BaseModel):
"""Request body for saving extracted data."""
source_file: str | None = None
data: dict[str, Any]
class SaveResponse(BaseModel):
"""Response for save operation."""
success: bool
message: str
document_id: int | None = None
reference: str | None = None
date: str | None = None
class DocumentSummary(BaseModel):
"""Summary of a saved document."""
id: int
reference: str
date: str
type: str | None
source_file: str | None
immeuble_code: str | None
immeuble_adresse: str | None
solde_montant: float | None
solde_type: str | None
created_at: str
# Determine the frontend dist path
FRONTEND_DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist"
@@ -105,6 +150,184 @@ async def extract_pdf(
tmp_path.unlink()
@app.post("/api/save", tags=["database"], response_model=SaveResponse)
async def save_document(
request: SaveRequest,
session: Session = Depends(get_session),
) -> SaveResponse:
"""Sauvegarde les donnees extraites en base de donnees.
- **source_file**: Nom du fichier PDF original (optionnel)
- **data**: Donnees extraites (format identique a la reponse de /api/extract)
Retourne un message de succes avec l'ID du document cree,
ou une erreur si le document existe deja (doublon).
"""
try:
db_service = DatabaseService(session)
document = db_service.save_document(
data=request.data, source_file=request.source_file
)
return SaveResponse(
success=True,
message="Document sauvegarde avec succes",
document_id=document.id,
reference=document.reference,
date=str(document.date),
)
except DuplicateDocumentError as e:
return SaveResponse(
success=False,
message=f"Document deja existant: reference={e.reference}, date={e.date}",
reference=e.reference,
date=str(e.date),
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Erreur lors de la sauvegarde: {str(e)}"
)
@app.get("/api/stats", tags=["database"])
async def get_stats(
session: Session = Depends(get_session),
) -> dict:
"""Retourne les statistiques globales de la base de donnees.
Compteurs pour chaque table principale.
"""
from sqlalchemy import func, select
from ..database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
return {
"documents": session.execute(select(func.count(Document.id))).scalar() or 0,
"immeubles": session.execute(select(func.count(Immeuble.id))).scalar() or 0,
"lots": session.execute(select(func.count(Lot.id))).scalar() or 0,
"locataires": session.execute(select(func.count(Locataire.id))).scalar() or 0,
"revenus": session.execute(select(func.count(Revenu.id))).scalar() or 0,
"depenses": session.execute(select(func.count(Depense.id))).scalar() or 0,
}
@app.get("/api/documents", tags=["database"])
async def list_documents(
limit: int = 100,
offset: int = 0,
session: Session = Depends(get_session),
) -> list[DocumentSummary]:
"""Liste les documents importes en base.
- **limit**: Nombre maximum de documents a retourner (defaut: 100)
- **offset**: Decalage pour la pagination (defaut: 0)
Retourne une liste de documents avec leurs metadonnees principales.
"""
db_service = DatabaseService(session)
documents = db_service.list_documents(limit=limit, offset=offset)
return [
DocumentSummary(
id=doc.id,
reference=doc.reference,
date=str(doc.date),
type=doc.type,
source_file=doc.source_file,
immeuble_code=doc.immeuble.code if doc.immeuble else None,
immeuble_adresse=doc.immeuble.adresse if doc.immeuble else None,
solde_montant=doc.solde_montant,
solde_type=doc.solde_type,
created_at=doc.created_at.isoformat() if doc.created_at else None,
)
for doc in documents
]
@app.get("/api/documents/{document_id}", tags=["database"])
async def get_document(
document_id: int,
session: Session = Depends(get_session),
) -> dict:
"""Recupere un document par son ID avec toutes ses donnees.
Retourne le document complet incluant le JSON original.
"""
db_service = DatabaseService(session)
document = db_service.get_document_by_id(document_id)
if not document:
raise HTTPException(status_code=404, detail="Document non trouve")
import json
return {
"id": document.id,
"reference": document.reference,
"date": str(document.date),
"type": document.type,
"source_file": document.source_file,
"immeuble": {
"code": document.immeuble.code,
"adresse": document.immeuble.adresse,
"ville": document.immeuble.ville,
"code_postal": document.immeuble.code_postal,
}
if document.immeuble
else None,
"solde": {
"montant": document.solde_montant,
"type": document.solde_type,
"date_arrete": str(document.solde_date_arrete)
if document.solde_date_arrete
else None,
},
"editeur": {
"nom": document.editeur_nom,
"siret": document.editeur_siret,
},
"json_data": json.loads(document.json_data) if document.json_data else None,
"created_at": document.created_at.isoformat() if document.created_at else None,
}
@app.get("/api/check-duplicate", tags=["database"])
async def check_duplicate(
reference: str,
date: str,
session: Session = Depends(get_session),
) -> dict:
"""Verifie si un document existe deja en base.
- **reference**: Reference du document
- **date**: Date du document (format YYYY-MM-DD)
Retourne {exists: true/false, document_id: ...}
"""
from datetime import datetime
try:
doc_date = datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(
status_code=400, detail="Format de date invalide. Utiliser YYYY-MM-DD"
)
db_service = DatabaseService(session)
existing = db_service.check_duplicate(reference, doc_date)
return {
"exists": existing is not None,
"document_id": existing.id if existing else None,
"reference": reference,
"date": date,
}
# Mount static files for production (if dist exists)
if FRONTEND_DIST.exists():
# Serve static assets

View File

@@ -153,5 +153,68 @@ def serve(host: str, port: int, reload: bool) -> None:
)
@main.command()
@click.option(
"--db-path",
type=click.Path(dir_okay=False, path_type=Path),
help="Chemin de la base de donnees (defaut: ~/.plesna_gerance/database.sqlite)",
)
def init_db(db_path: Path | None) -> None:
"""Initialise la base de donnees SQLite.
Cree les tables necessaires pour stocker les documents,
immeubles, lots, locataires, revenus et depenses.
Exemples:
plesna-gerance init-db
plesna-gerance init-db --db-path ./data/gerance.db
"""
from .database import init_db as do_init_db
result_path = do_init_db(db_path)
click.echo(f"Base de donnees initialisee: {result_path}", err=True)
@main.command()
@click.option(
"--db-path",
type=click.Path(dir_okay=False, path_type=Path),
help="Chemin de la base de donnees",
)
def db_info(db_path: Path | None) -> None:
"""Affiche des informations sur la base de donnees.
Montre le nombre de documents, immeubles, lots, etc.
"""
from .database import init_db as do_init_db, get_session_factory
from .database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
from sqlalchemy import func, select
# Initialize/connect to database
result_path = do_init_db(db_path)
SessionLocal = get_session_factory()
with SessionLocal() as session:
# Count records in each table
doc_count = session.execute(select(func.count(Document.id))).scalar()
imm_count = session.execute(select(func.count(Immeuble.id))).scalar()
lot_count = session.execute(select(func.count(Lot.id))).scalar()
loc_count = session.execute(select(func.count(Locataire.id))).scalar()
rev_count = session.execute(select(func.count(Revenu.id))).scalar()
dep_count = session.execute(select(func.count(Depense.id))).scalar()
click.echo(f"Base de donnees: {result_path}")
click.echo(f"")
click.echo(f"Statistiques:")
click.echo(f" - Documents: {doc_count}")
click.echo(f" - Immeubles: {imm_count}")
click.echo(f" - Lots: {lot_count}")
click.echo(f" - Locataires: {loc_count}")
click.echo(f" - Revenus: {rev_count}")
click.echo(f" - Depenses: {dep_count}")
if __name__ == "__main__":
main()

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),
}