feat: add homepage
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
21
src/plesna_gerance/database/__init__.py
Normal file
21
src/plesna_gerance/database/__init__.py
Normal 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",
|
||||
]
|
||||
118
src/plesna_gerance/database/connection.py
Normal file
118
src/plesna_gerance/database/connection.py
Normal 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
|
||||
232
src/plesna_gerance/database/models.py
Normal file
232
src/plesna_gerance/database/models.py
Normal 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})>"
|
||||
314
src/plesna_gerance/database/service.py
Normal file
314
src/plesna_gerance/database/service.py
Normal 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),
|
||||
}
|
||||
Reference in New Issue
Block a user