feat: add analytics
This commit is contained in:
@@ -1,20 +1,14 @@
|
||||
"""Application FastAPI pour l'extraction de comptes rendus de gérance."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, File, HTTPException, UploadFile, Depends
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import 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
|
||||
from ..services.tag_predictor import TagPredictor
|
||||
from ..database import init_db
|
||||
from .routes import extraction_router, documents_router, tags_router, analytics_router
|
||||
|
||||
app = FastAPI(
|
||||
title="Plesna Gérance API",
|
||||
@@ -33,51 +27,14 @@ async def startup_event():
|
||||
init_db()
|
||||
|
||||
|
||||
# Pydantic models for API
|
||||
class SaveRequest(BaseModel):
|
||||
"""Request body for saving extracted data."""
|
||||
|
||||
source_file: str | None = None
|
||||
data: dict[str, Any]
|
||||
depenses_tags: list[dict] | None = None # Liste des tags par index de dépense
|
||||
overwrite: bool = False # Si True, écrase le document existant
|
||||
|
||||
|
||||
class PredictTagsRequest(BaseModel):
|
||||
"""Request body for predicting tags."""
|
||||
|
||||
depenses: list[dict] # Liste des dépenses à prédire
|
||||
|
||||
|
||||
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"
|
||||
# Include routers
|
||||
app.include_router(extraction_router)
|
||||
app.include_router(documents_router)
|
||||
app.include_router(tags_router)
|
||||
app.include_router(analytics_router)
|
||||
|
||||
|
||||
# Health check endpoints (keep in main app)
|
||||
@app.get("/api", tags=["health"])
|
||||
async def api_root() -> dict:
|
||||
"""Endpoint racine de l'API - informations."""
|
||||
@@ -94,300 +51,8 @@ async def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.post("/api/extract", tags=["extraction"])
|
||||
async def extract_pdf(
|
||||
file: UploadFile = File(..., description="Fichier PDF de compte rendu de gérance"),
|
||||
) -> JSONResponse:
|
||||
"""Extrait les données d'un PDF de compte rendu de gérance.
|
||||
|
||||
Upload un fichier PDF et retourne les données structurées en JSON.
|
||||
|
||||
- **file**: Fichier PDF à analyser (Content-Type: multipart/form-data)
|
||||
|
||||
Retourne un objet JSON contenant:
|
||||
- **source_file**: Nom du fichier uploadé
|
||||
- **data**: Données extraites (metadata, situation_locataires, recapitulatif_operations)
|
||||
"""
|
||||
# Validation du type de fichier
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="Nom de fichier manquant")
|
||||
|
||||
if not file.filename.lower().endswith(".pdf"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Le fichier doit être un PDF. Reçu: {file.filename}",
|
||||
)
|
||||
|
||||
# Validation du content-type (si fourni)
|
||||
if file.content_type and file.content_type not in (
|
||||
"application/pdf",
|
||||
"application/octet-stream",
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Content-Type invalide. Attendu: application/pdf, reçu: {file.content_type}",
|
||||
)
|
||||
|
||||
tmp_path: Path | None = None
|
||||
|
||||
# Sauvegarde temporaire du fichier uploadé
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file:
|
||||
content = await file.read()
|
||||
tmp_file.write(content)
|
||||
tmp_path = Path(tmp_file.name)
|
||||
|
||||
# Extraction des données
|
||||
try:
|
||||
data = extract_compte_rendu(str(tmp_path))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Erreur lors de l'extraction du PDF: {str(e)}",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"source_file": file.filename,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
finally:
|
||||
# Nettoyage du fichier temporaire
|
||||
if tmp_path and tmp_path.exists():
|
||||
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)
|
||||
- **depenses_tags**: Liste des tags par index de depense (optionnel)
|
||||
- **overwrite**: Si True, ecrase le document existant (optionnel)
|
||||
|
||||
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,
|
||||
depenses_tags=request.depenses_tags,
|
||||
overwrite=request.overwrite,
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/tags", tags=["tags"])
|
||||
async def list_tags(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[dict]:
|
||||
"""Liste tous les tags disponibles pour le tagging des depenses.
|
||||
|
||||
Retourne une liste de tags avec leur id et nom.
|
||||
"""
|
||||
db_service = DatabaseService(session)
|
||||
tags = db_service.list_tags()
|
||||
|
||||
return [{"id": tag.id, "nom": tag.nom} for tag in tags]
|
||||
|
||||
|
||||
@app.post("/api/predict-tags", tags=["tags"])
|
||||
async def predict_tags(
|
||||
request: PredictTagsRequest,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Predit les tags pour une liste de depenses basee sur l'historique.
|
||||
|
||||
- **depenses**: Liste des depenses a predire (avec fournisseur, sous_categorie, etc.)
|
||||
|
||||
Retourne une liste de predictions avec tag suggere, confiance et raison.
|
||||
"""
|
||||
try:
|
||||
predictor = TagPredictor(session)
|
||||
predictions = predictor.predict_batch(request.depenses)
|
||||
|
||||
return {
|
||||
"predictions": [
|
||||
{
|
||||
"index": idx,
|
||||
"tag_id": pred.tag_id,
|
||||
"tag_name": pred.tag_name,
|
||||
"confidence": pred.confidence,
|
||||
"reason": pred.reason,
|
||||
}
|
||||
for idx, pred in enumerate(predictions)
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Erreur lors de la prediction: {str(e)}"
|
||||
)
|
||||
# Determine the frontend dist path
|
||||
FRONTEND_DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist"
|
||||
|
||||
|
||||
# Mount static files for production (if dist exists)
|
||||
|
||||
13
src/plesna_gerance/api/routes/__init__.py
Normal file
13
src/plesna_gerance/api/routes/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""API Routes package."""
|
||||
|
||||
from .extraction import router as extraction_router
|
||||
from .documents import router as documents_router
|
||||
from .tags import router as tags_router
|
||||
from .analytics import router as analytics_router
|
||||
|
||||
__all__ = [
|
||||
"extraction_router",
|
||||
"documents_router",
|
||||
"tags_router",
|
||||
"analytics_router",
|
||||
]
|
||||
438
src/plesna_gerance/api/routes/analytics.py
Normal file
438
src/plesna_gerance/api/routes/analytics.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""Analytics routes - Data analysis and reporting endpoints."""
|
||||
|
||||
from datetime import date
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select, func, distinct, extract
|
||||
|
||||
from ...database import get_session
|
||||
from ...database.models import Depense, Document, Immeuble, Lot, Tag
|
||||
from ..schemas.models import (
|
||||
ImmeubleResponse,
|
||||
LotResponse,
|
||||
DepenseDetail,
|
||||
DepensesSummary,
|
||||
CategorySummary,
|
||||
TagSummary,
|
||||
MonthlySummary,
|
||||
FournisseurSummary,
|
||||
FournisseurResponse,
|
||||
TagResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["analytics"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Reference data endpoints (for filters)
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/immeubles", response_model=list[ImmeubleResponse])
|
||||
async def list_immeubles(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[ImmeubleResponse]:
|
||||
"""Liste tous les immeubles avec statistiques.
|
||||
|
||||
Retourne la liste des immeubles avec le nombre de lots et de depenses.
|
||||
"""
|
||||
# Get immeubles with counts
|
||||
stmt = (
|
||||
select(
|
||||
Immeuble,
|
||||
func.count(distinct(Lot.id)).label("nb_lots"),
|
||||
func.count(distinct(Depense.id)).label("nb_depenses"),
|
||||
)
|
||||
.outerjoin(Lot, Lot.immeuble_id == Immeuble.id)
|
||||
.outerjoin(Depense, Depense.immeuble_id == Immeuble.id)
|
||||
.group_by(Immeuble.id)
|
||||
.order_by(Immeuble.code)
|
||||
)
|
||||
|
||||
result = session.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
ImmeubleResponse(
|
||||
id=row.Immeuble.id,
|
||||
code=row.Immeuble.code,
|
||||
adresse=row.Immeuble.adresse,
|
||||
ville=row.Immeuble.ville,
|
||||
code_postal=row.Immeuble.code_postal,
|
||||
nb_lots=row.nb_lots or 0,
|
||||
nb_depenses=row.nb_depenses or 0,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/lots", response_model=list[LotResponse])
|
||||
async def list_lots(
|
||||
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[LotResponse]:
|
||||
"""Liste tous les lots, optionnellement filtres par immeuble.
|
||||
|
||||
- **immeuble_id**: ID de l'immeuble pour filtrer (optionnel)
|
||||
"""
|
||||
stmt = (
|
||||
select(Lot, Immeuble.code.label("immeuble_code"))
|
||||
.join(Immeuble, Lot.immeuble_id == Immeuble.id)
|
||||
.order_by(Immeuble.code, Lot.numero)
|
||||
)
|
||||
|
||||
if immeuble_id is not None:
|
||||
stmt = stmt.where(Lot.immeuble_id == immeuble_id)
|
||||
|
||||
result = session.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
LotResponse(
|
||||
id=row.Lot.id,
|
||||
numero=row.Lot.numero,
|
||||
type=row.Lot.type,
|
||||
immeuble_id=row.Lot.immeuble_id,
|
||||
immeuble_code=row.immeuble_code,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/fournisseurs", response_model=list[FournisseurResponse])
|
||||
async def list_fournisseurs(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[FournisseurResponse]:
|
||||
"""Liste tous les fournisseurs distincts avec statistiques.
|
||||
|
||||
Retourne la liste des fournisseurs avec le nombre de depenses et total.
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
Depense.fournisseur,
|
||||
func.count(Depense.id).label("nb_depenses"),
|
||||
func.sum(Depense.debit).label("total_debit"),
|
||||
)
|
||||
.where(Depense.fournisseur.isnot(None))
|
||||
.where(Depense.fournisseur != "")
|
||||
.group_by(Depense.fournisseur)
|
||||
.order_by(func.sum(Depense.debit).desc())
|
||||
)
|
||||
|
||||
result = session.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
FournisseurResponse(
|
||||
nom=row.fournisseur,
|
||||
nb_depenses=row.nb_depenses or 0,
|
||||
total_debit=row.total_debit or 0.0,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/tags/stats", response_model=list[TagResponse])
|
||||
async def list_tags_with_stats(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[TagResponse]:
|
||||
"""Liste tous les tags avec statistiques.
|
||||
|
||||
Retourne la liste des tags avec le nombre de depenses associees.
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
Tag,
|
||||
func.count(Depense.id).label("nb_depenses"),
|
||||
)
|
||||
.outerjoin(Depense, Depense.tag_id == Tag.id)
|
||||
.group_by(Tag.id)
|
||||
.order_by(Tag.nom)
|
||||
)
|
||||
|
||||
result = session.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
TagResponse(
|
||||
id=row.Tag.id,
|
||||
nom=row.Tag.nom,
|
||||
nb_depenses=row.nb_depenses or 0,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Analytics endpoints
|
||||
# ============================================================
|
||||
|
||||
|
||||
@router.get("/analytics/depenses", response_model=list[DepenseDetail])
|
||||
async def get_depenses(
|
||||
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
||||
lot_id: int | None = Query(None, description="Filtrer par lot"),
|
||||
tag_id: int | None = Query(None, description="Filtrer par tag"),
|
||||
categorie: str | None = Query(None, description="Filtrer par categorie"),
|
||||
fournisseur: str | None = Query(None, description="Filtrer par fournisseur"),
|
||||
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
|
||||
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
|
||||
limit: int = Query(500, description="Nombre maximum de resultats"),
|
||||
offset: int = Query(0, description="Decalage pour la pagination"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[DepenseDetail]:
|
||||
"""Retourne la liste des depenses filtrees avec details.
|
||||
|
||||
Filtres disponibles:
|
||||
- **immeuble_id**: ID de l'immeuble
|
||||
- **lot_id**: ID du lot
|
||||
- **tag_id**: ID du tag
|
||||
- **categorie**: Categorie de depense
|
||||
- **fournisseur**: Nom du fournisseur
|
||||
- **date_debut**: Date de debut (incluse)
|
||||
- **date_fin**: Date de fin (incluse)
|
||||
- **limit**: Nombre max de resultats (defaut: 500)
|
||||
- **offset**: Decalage pour pagination
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
Depense,
|
||||
Document.date.label("document_date"),
|
||||
Document.reference.label("document_reference"),
|
||||
Immeuble.code.label("immeuble_code"),
|
||||
Immeuble.adresse.label("immeuble_adresse"),
|
||||
Lot.numero.label("lot_numero"),
|
||||
Tag.nom.label("tag_nom"),
|
||||
)
|
||||
.join(Document, Depense.document_id == Document.id)
|
||||
.join(Immeuble, Depense.immeuble_id == Immeuble.id)
|
||||
.outerjoin(Lot, Depense.lot_id == Lot.id)
|
||||
.outerjoin(Tag, Depense.tag_id == Tag.id)
|
||||
.order_by(Document.date.desc(), Depense.id.desc())
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if immeuble_id is not None:
|
||||
stmt = stmt.where(Depense.immeuble_id == immeuble_id)
|
||||
if lot_id is not None:
|
||||
stmt = stmt.where(Depense.lot_id == lot_id)
|
||||
if tag_id is not None:
|
||||
stmt = stmt.where(Depense.tag_id == tag_id)
|
||||
if categorie is not None:
|
||||
stmt = stmt.where(Depense.categorie == categorie)
|
||||
if fournisseur is not None:
|
||||
stmt = stmt.where(Depense.fournisseur.ilike(f"%{fournisseur}%"))
|
||||
if date_debut is not None:
|
||||
stmt = stmt.where(Document.date >= date_debut)
|
||||
if date_fin is not None:
|
||||
stmt = stmt.where(Document.date <= date_fin)
|
||||
|
||||
stmt = stmt.limit(limit).offset(offset)
|
||||
|
||||
result = session.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return [
|
||||
DepenseDetail(
|
||||
id=row.Depense.id,
|
||||
document_id=row.Depense.document_id,
|
||||
document_date=row.document_date,
|
||||
document_reference=row.document_reference,
|
||||
immeuble_id=row.Depense.immeuble_id,
|
||||
immeuble_code=row.immeuble_code,
|
||||
immeuble_adresse=row.immeuble_adresse,
|
||||
lot_id=row.Depense.lot_id,
|
||||
lot_numero=row.lot_numero,
|
||||
tag_id=row.Depense.tag_id,
|
||||
tag_nom=row.tag_nom,
|
||||
categorie=row.Depense.categorie,
|
||||
sous_categorie=row.Depense.sous_categorie,
|
||||
fournisseur=row.Depense.fournisseur,
|
||||
description=row.Depense.description,
|
||||
debit=row.Depense.debit or 0.0,
|
||||
credit=row.Depense.credit or 0.0,
|
||||
tva=row.Depense.tva or 0.0,
|
||||
locatif=row.Depense.locatif or 0.0,
|
||||
deductible=row.Depense.deductible or 0.0,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/analytics/depenses/summary", response_model=DepensesSummary)
|
||||
async def get_depenses_summary(
|
||||
immeuble_id: int | None = Query(None, description="Filtrer par immeuble"),
|
||||
lot_id: int | None = Query(None, description="Filtrer par lot"),
|
||||
tag_id: int | None = Query(None, description="Filtrer par tag"),
|
||||
categorie: str | None = Query(None, description="Filtrer par categorie"),
|
||||
fournisseur: str | None = Query(None, description="Filtrer par fournisseur"),
|
||||
date_debut: date | None = Query(None, description="Date de debut (YYYY-MM-DD)"),
|
||||
date_fin: date | None = Query(None, description="Date de fin (YYYY-MM-DD)"),
|
||||
session: Session = Depends(get_session),
|
||||
) -> DepensesSummary:
|
||||
"""Retourne un resume agrege des depenses pour les graphiques.
|
||||
|
||||
Memes filtres que /analytics/depenses.
|
||||
Retourne:
|
||||
- Totaux globaux
|
||||
- Repartition par categorie
|
||||
- Repartition par tag
|
||||
- Evolution mensuelle
|
||||
- Top fournisseurs
|
||||
"""
|
||||
# Base query with filters
|
||||
base_stmt = select(Depense).join(Document, Depense.document_id == Document.id)
|
||||
|
||||
if immeuble_id is not None:
|
||||
base_stmt = base_stmt.where(Depense.immeuble_id == immeuble_id)
|
||||
if lot_id is not None:
|
||||
base_stmt = base_stmt.where(Depense.lot_id == lot_id)
|
||||
if tag_id is not None:
|
||||
base_stmt = base_stmt.where(Depense.tag_id == tag_id)
|
||||
if categorie is not None:
|
||||
base_stmt = base_stmt.where(Depense.categorie == categorie)
|
||||
if fournisseur is not None:
|
||||
base_stmt = base_stmt.where(Depense.fournisseur.ilike(f"%{fournisseur}%"))
|
||||
if date_debut is not None:
|
||||
base_stmt = base_stmt.where(Document.date >= date_debut)
|
||||
if date_fin is not None:
|
||||
base_stmt = base_stmt.where(Document.date <= date_fin)
|
||||
|
||||
# Get all matching depenses
|
||||
result = session.execute(base_stmt)
|
||||
depenses = result.scalars().all()
|
||||
|
||||
# Calculate totals
|
||||
total_count = len(depenses)
|
||||
total_debit = sum(d.debit or 0 for d in depenses)
|
||||
total_credit = sum(d.credit or 0 for d in depenses)
|
||||
total_tva = sum(d.tva or 0 for d in depenses)
|
||||
total_locatif = sum(d.locatif or 0 for d in depenses)
|
||||
total_deductible = sum(d.deductible or 0 for d in depenses)
|
||||
|
||||
# Get document dates for monthly aggregation
|
||||
doc_dates = {}
|
||||
for d in depenses:
|
||||
if d.document_id not in doc_dates:
|
||||
doc = session.get(Document, d.document_id)
|
||||
doc_dates[d.document_id] = doc.date if doc else None
|
||||
|
||||
# Aggregate by category
|
||||
by_category_dict = defaultdict(
|
||||
lambda: {
|
||||
"count": 0,
|
||||
"debit": 0.0,
|
||||
"credit": 0.0,
|
||||
"tva": 0.0,
|
||||
"locatif": 0.0,
|
||||
"deductible": 0.0,
|
||||
}
|
||||
)
|
||||
for d in depenses:
|
||||
cat = d.categorie or "NON_CATEGORISE"
|
||||
by_category_dict[cat]["count"] += 1
|
||||
by_category_dict[cat]["debit"] += d.debit or 0
|
||||
by_category_dict[cat]["credit"] += d.credit or 0
|
||||
by_category_dict[cat]["tva"] += d.tva or 0
|
||||
by_category_dict[cat]["locatif"] += d.locatif or 0
|
||||
by_category_dict[cat]["deductible"] += d.deductible or 0
|
||||
|
||||
by_category = [
|
||||
CategorySummary(
|
||||
categorie=cat,
|
||||
count=data["count"],
|
||||
total_debit=data["debit"],
|
||||
total_credit=data["credit"],
|
||||
total_tva=data["tva"],
|
||||
total_locatif=data["locatif"],
|
||||
total_deductible=data["deductible"],
|
||||
)
|
||||
for cat, data in sorted(by_category_dict.items(), key=lambda x: -x[1]["debit"])
|
||||
]
|
||||
|
||||
# Aggregate by tag
|
||||
by_tag_dict = defaultdict(lambda: {"tag_nom": None, "count": 0, "debit": 0.0})
|
||||
for d in depenses:
|
||||
tag_key = d.tag_id or 0 # 0 for untagged
|
||||
by_tag_dict[tag_key]["count"] += 1
|
||||
by_tag_dict[tag_key]["debit"] += d.debit or 0
|
||||
if d.tag_id and d.tag:
|
||||
by_tag_dict[tag_key]["tag_nom"] = d.tag.nom
|
||||
|
||||
by_tag = [
|
||||
TagSummary(
|
||||
tag_id=tag_id if tag_id != 0 else None,
|
||||
tag_nom=data["tag_nom"] if tag_id != 0 else "Non taggue",
|
||||
count=data["count"],
|
||||
total_debit=data["debit"],
|
||||
)
|
||||
for tag_id, data in sorted(by_tag_dict.items(), key=lambda x: -x[1]["debit"])
|
||||
]
|
||||
|
||||
# Aggregate by month
|
||||
by_month_dict = defaultdict(lambda: {"count": 0, "debit": 0.0, "credit": 0.0})
|
||||
for d in depenses:
|
||||
doc_date = doc_dates.get(d.document_id)
|
||||
if doc_date:
|
||||
month_key = (doc_date.year, doc_date.month)
|
||||
by_month_dict[month_key]["count"] += 1
|
||||
by_month_dict[month_key]["debit"] += d.debit or 0
|
||||
by_month_dict[month_key]["credit"] += d.credit or 0
|
||||
|
||||
by_month = [
|
||||
MonthlySummary(
|
||||
year=year,
|
||||
month=month,
|
||||
count=data["count"],
|
||||
total_debit=data["debit"],
|
||||
total_credit=data["credit"],
|
||||
)
|
||||
for (year, month), data in sorted(by_month_dict.items())
|
||||
]
|
||||
|
||||
# Aggregate by fournisseur (top 20)
|
||||
by_fournisseur_dict = defaultdict(lambda: {"count": 0, "debit": 0.0})
|
||||
for d in depenses:
|
||||
fournisseur_key = d.fournisseur or "Non specifie"
|
||||
by_fournisseur_dict[fournisseur_key]["count"] += 1
|
||||
by_fournisseur_dict[fournisseur_key]["debit"] += d.debit or 0
|
||||
|
||||
by_fournisseur = [
|
||||
FournisseurSummary(
|
||||
fournisseur=fournisseur if fournisseur != "Non specifie" else None,
|
||||
count=data["count"],
|
||||
total_debit=data["debit"],
|
||||
)
|
||||
for fournisseur, data in sorted(
|
||||
by_fournisseur_dict.items(), key=lambda x: -x[1]["debit"]
|
||||
)[:20]
|
||||
]
|
||||
|
||||
return DepensesSummary(
|
||||
total_count=total_count,
|
||||
total_debit=total_debit,
|
||||
total_credit=total_credit,
|
||||
total_tva=total_tva,
|
||||
total_locatif=total_locatif,
|
||||
total_deductible=total_deductible,
|
||||
by_category=by_category,
|
||||
by_tag=by_tag,
|
||||
by_month=by_month,
|
||||
by_fournisseur=by_fournisseur,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics/categories")
|
||||
async def list_categories(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[str]:
|
||||
"""Liste toutes les categories de depenses distinctes."""
|
||||
stmt = (
|
||||
select(distinct(Depense.categorie))
|
||||
.where(Depense.categorie.isnot(None))
|
||||
.order_by(Depense.categorie)
|
||||
)
|
||||
result = session.execute(stmt)
|
||||
return [row[0] for row in result.all() if row[0]]
|
||||
191
src/plesna_gerance/api/routes/documents.py
Normal file
191
src/plesna_gerance/api/routes/documents.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Documents routes - CRUD operations for documents."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from ...database import get_session, DatabaseService
|
||||
from ...database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
|
||||
from ...database.service import DuplicateDocumentError
|
||||
from ..schemas import SaveRequest, SaveResponse, DocumentSummary
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["documents"])
|
||||
|
||||
|
||||
@router.post("/save", 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)
|
||||
- **depenses_tags**: Liste des tags par index de depense (optionnel)
|
||||
- **overwrite**: Si True, ecrase le document existant (optionnel)
|
||||
|
||||
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,
|
||||
depenses_tags=request.depenses_tags,
|
||||
overwrite=request.overwrite,
|
||||
)
|
||||
|
||||
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)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_stats(
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Retourne les statistiques globales de la base de donnees.
|
||||
|
||||
Compteurs pour chaque table principale.
|
||||
"""
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/documents", response_model=list[DocumentSummary])
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
@router.get("/documents/{document_id}")
|
||||
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")
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/check-duplicate")
|
||||
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: ...}
|
||||
"""
|
||||
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,
|
||||
}
|
||||
76
src/plesna_gerance/api/routes/extraction.py
Normal file
76
src/plesna_gerance/api/routes/extraction.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Extraction routes - PDF upload and data extraction."""
|
||||
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ...extractor import extract_compte_rendu
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["extraction"])
|
||||
|
||||
|
||||
@router.post("/extract")
|
||||
async def extract_pdf(
|
||||
file: UploadFile = File(..., description="Fichier PDF de compte rendu de gérance"),
|
||||
) -> JSONResponse:
|
||||
"""Extrait les données d'un PDF de compte rendu de gérance.
|
||||
|
||||
Upload un fichier PDF et retourne les données structurées en JSON.
|
||||
|
||||
- **file**: Fichier PDF à analyser (Content-Type: multipart/form-data)
|
||||
|
||||
Retourne un objet JSON contenant:
|
||||
- **source_file**: Nom du fichier uploadé
|
||||
- **data**: Données extraites (metadata, situation_locataires, recapitulatif_operations)
|
||||
"""
|
||||
# Validation du type de fichier
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="Nom de fichier manquant")
|
||||
|
||||
if not file.filename.lower().endswith(".pdf"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Le fichier doit être un PDF. Reçu: {file.filename}",
|
||||
)
|
||||
|
||||
# Validation du content-type (si fourni)
|
||||
if file.content_type and file.content_type not in (
|
||||
"application/pdf",
|
||||
"application/octet-stream",
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Content-Type invalide. Attendu: application/pdf, reçu: {file.content_type}",
|
||||
)
|
||||
|
||||
tmp_path: Path | None = None
|
||||
|
||||
# Sauvegarde temporaire du fichier uploadé
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file:
|
||||
content = await file.read()
|
||||
tmp_file.write(content)
|
||||
tmp_path = Path(tmp_file.name)
|
||||
|
||||
# Extraction des données
|
||||
try:
|
||||
data = extract_compte_rendu(str(tmp_path))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Erreur lors de l'extraction du PDF: {str(e)}",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"source_file": file.filename,
|
||||
"data": data,
|
||||
}
|
||||
)
|
||||
|
||||
finally:
|
||||
# Nettoyage du fichier temporaire
|
||||
if tmp_path and tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
58
src/plesna_gerance/api/routes/tags.py
Normal file
58
src/plesna_gerance/api/routes/tags.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Tags routes - Tag management and prediction."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ...database import get_session, DatabaseService
|
||||
from ...services.tag_predictor import TagPredictor
|
||||
from ..schemas import PredictTagsRequest
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
async def list_tags(
|
||||
session: Session = Depends(get_session),
|
||||
) -> list[dict]:
|
||||
"""Liste tous les tags disponibles pour le tagging des depenses.
|
||||
|
||||
Retourne une liste de tags avec leur id et nom.
|
||||
"""
|
||||
db_service = DatabaseService(session)
|
||||
tags = db_service.list_tags()
|
||||
|
||||
return [{"id": tag.id, "nom": tag.nom} for tag in tags]
|
||||
|
||||
|
||||
@router.post("/predict-tags")
|
||||
async def predict_tags(
|
||||
request: PredictTagsRequest,
|
||||
session: Session = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Predit les tags pour une liste de depenses basee sur l'historique.
|
||||
|
||||
- **depenses**: Liste des depenses a predire (avec fournisseur, sous_categorie, etc.)
|
||||
|
||||
Retourne une liste de predictions avec tag suggere, confiance et raison.
|
||||
"""
|
||||
try:
|
||||
predictor = TagPredictor(session)
|
||||
predictions = predictor.predict_batch(request.depenses)
|
||||
|
||||
return {
|
||||
"predictions": [
|
||||
{
|
||||
"index": idx,
|
||||
"tag_id": pred.tag_id,
|
||||
"tag_name": pred.tag_name,
|
||||
"confidence": pred.confidence,
|
||||
"reason": pred.reason,
|
||||
}
|
||||
for idx, pred in enumerate(predictions)
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Erreur lors de la prediction: {str(e)}"
|
||||
)
|
||||
23
src/plesna_gerance/api/schemas/__init__.py
Normal file
23
src/plesna_gerance/api/schemas/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Pydantic schemas for API request/response models."""
|
||||
|
||||
from .models import (
|
||||
SaveRequest,
|
||||
SaveResponse,
|
||||
PredictTagsRequest,
|
||||
DocumentSummary,
|
||||
DepenseDetail,
|
||||
DepensesSummary,
|
||||
ImmeubleResponse,
|
||||
LotResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SaveRequest",
|
||||
"SaveResponse",
|
||||
"PredictTagsRequest",
|
||||
"DocumentSummary",
|
||||
"DepenseDetail",
|
||||
"DepensesSummary",
|
||||
"ImmeubleResponse",
|
||||
"LotResponse",
|
||||
]
|
||||
178
src/plesna_gerance/api/schemas/models.py
Normal file
178
src/plesna_gerance/api/schemas/models.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Pydantic models for API requests and responses."""
|
||||
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Requests
|
||||
# ============================================================
|
||||
|
||||
|
||||
class SaveRequest(BaseModel):
|
||||
"""Request body for saving extracted data."""
|
||||
|
||||
source_file: str | None = None
|
||||
data: dict[str, Any]
|
||||
depenses_tags: list[dict] | None = None
|
||||
overwrite: bool = False
|
||||
|
||||
|
||||
class PredictTagsRequest(BaseModel):
|
||||
"""Request body for predicting tags."""
|
||||
|
||||
depenses: list[dict]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Responses - Documents
|
||||
# ============================================================
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Responses - Analytics
|
||||
# ============================================================
|
||||
|
||||
|
||||
class ImmeubleResponse(BaseModel):
|
||||
"""Response for immeuble listing."""
|
||||
|
||||
id: int
|
||||
code: str
|
||||
adresse: str | None
|
||||
ville: str | None
|
||||
code_postal: str | None
|
||||
nb_lots: int = 0
|
||||
nb_depenses: int = 0
|
||||
|
||||
|
||||
class LotResponse(BaseModel):
|
||||
"""Response for lot listing."""
|
||||
|
||||
id: int
|
||||
numero: str
|
||||
type: str | None
|
||||
immeuble_id: int
|
||||
immeuble_code: str | None
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
"""Response for tag listing."""
|
||||
|
||||
id: int
|
||||
nom: str
|
||||
nb_depenses: int = 0
|
||||
|
||||
|
||||
class FournisseurResponse(BaseModel):
|
||||
"""Response for fournisseur listing."""
|
||||
|
||||
nom: str
|
||||
nb_depenses: int
|
||||
total_debit: float
|
||||
|
||||
|
||||
class DepenseDetail(BaseModel):
|
||||
"""Detailed depense for analytics."""
|
||||
|
||||
id: int
|
||||
document_id: int
|
||||
document_date: date | None
|
||||
document_reference: str | None
|
||||
immeuble_id: int
|
||||
immeuble_code: str | None
|
||||
immeuble_adresse: str | None
|
||||
lot_id: int | None
|
||||
lot_numero: str | None
|
||||
tag_id: int | None
|
||||
tag_nom: str | None
|
||||
categorie: str | None
|
||||
sous_categorie: str | None
|
||||
fournisseur: str | None
|
||||
description: str | None
|
||||
debit: float
|
||||
credit: float
|
||||
tva: float
|
||||
locatif: float
|
||||
deductible: float
|
||||
|
||||
|
||||
class CategorySummary(BaseModel):
|
||||
"""Summary for a category."""
|
||||
|
||||
categorie: str
|
||||
count: int
|
||||
total_debit: float
|
||||
total_credit: float
|
||||
total_tva: float
|
||||
total_locatif: float
|
||||
total_deductible: float
|
||||
|
||||
|
||||
class TagSummary(BaseModel):
|
||||
"""Summary for a tag."""
|
||||
|
||||
tag_id: int | None
|
||||
tag_nom: str | None
|
||||
count: int
|
||||
total_debit: float
|
||||
|
||||
|
||||
class MonthlySummary(BaseModel):
|
||||
"""Summary for a month."""
|
||||
|
||||
year: int
|
||||
month: int
|
||||
count: int
|
||||
total_debit: float
|
||||
total_credit: float
|
||||
|
||||
|
||||
class FournisseurSummary(BaseModel):
|
||||
"""Summary for a fournisseur."""
|
||||
|
||||
fournisseur: str | None
|
||||
count: int
|
||||
total_debit: float
|
||||
|
||||
|
||||
class DepensesSummary(BaseModel):
|
||||
"""Aggregated summary of depenses for analytics."""
|
||||
|
||||
total_count: int
|
||||
total_debit: float
|
||||
total_credit: float
|
||||
total_tva: float
|
||||
total_locatif: float
|
||||
total_deductible: float
|
||||
by_category: list[CategorySummary]
|
||||
by_tag: list[TagSummary]
|
||||
by_month: list[MonthlySummary]
|
||||
by_fournisseur: list[FournisseurSummary]
|
||||
Reference in New Issue
Block a user