feat: init plesna-gerance - extracteur de comptes rendus de gerance

- Backend Python (uv + click + FastAPI)
  - CLI: plesna-gerance extract <pdf> pour extraire les donnees
  - CLI: plesna-gerance serve pour lancer le serveur API
  - API REST: POST /api/extract pour upload et extraction de PDF
  - Parsers modulaires: metadata, locataires, operations
  - Utilise pdftotext (poppler-utils) pour l'extraction de texte

- Frontend Vue.js + Tailwind CSS
  - Interface split-screen: PDF a gauche, donnees a droite
  - Preview PDF avec zoom et navigation pages (pdf.js)
  - Visualisation structuree des donnees extraites
  - Sections depliables: metadata, locataires, operations
  - Drag & drop pour upload de PDF
  - Extraction automatique a la selection du fichier
This commit is contained in:
2026-01-18 05:36:27 +01:00
commit 6bad5fbaf9
34 changed files with 5692 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""API FastAPI pour l'extraction de comptes rendus de gérance."""
from .app import app
__all__ = ["app"]

View File

@@ -0,0 +1,122 @@
"""Application FastAPI pour l'extraction de comptes rendus de gérance."""
import tempfile
from pathlib import Path
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from .. import __version__
from ..extractor import extract_compte_rendu
app = FastAPI(
title="Plesna Gérance API",
description="API pour extraire les informations structurées des PDFs de comptes rendus de gérance Oralia/ICS.",
version=__version__,
docs_url="/api/docs",
redoc_url="/api/redoc",
openapi_url="/api/openapi.json",
)
# Determine the frontend dist path
FRONTEND_DIST = Path(__file__).parent.parent.parent.parent / "frontend" / "dist"
@app.get("/api", tags=["health"])
async def api_root() -> dict:
"""Endpoint racine de l'API - informations."""
return {
"name": "Plesna Gérance API",
"version": __version__,
"docs": "/api/docs",
}
@app.get("/api/health", tags=["health"])
async def health() -> dict:
"""Vérification de l'état du serveur."""
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()
# Mount static files for production (if dist exists)
if FRONTEND_DIST.exists():
# Serve static assets
app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets")
# Catch-all route for SPA - must be last
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_spa(full_path: str):
"""Serve the SPA for all non-API routes."""
# If requesting a file that exists, serve it
file_path = FRONTEND_DIST / full_path
if file_path.is_file():
return FileResponse(file_path)
# Otherwise serve index.html for SPA routing
return FileResponse(FRONTEND_DIST / "index.html")