POST /api/documents/{id}/re-extract retourne desormais, en plus des donnees
re-extraites, les donnees actuelles et les tags des depenses : un seul appel
suffit pour comparer avant/apres et reporter les tags. L'endpoint devient
synchrone pour que FastAPI l'execute dans un thread, l'extraction bloquant
plusieurs secondes par PDF.
PUT /api/documents/{id} enregistre la nouvelle extraction en visant le
document par son ID (save_document(replace_document_id=...)) : une extraction
qui corrige la reference ou la date met a jour le bon document au lieu d'en
creer un second, et refuse la collision avec un document voisin. Le PDF
stocke est conserve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
476 lines
16 KiB
Python
476 lines
16 KiB
Python
"""Documents routes - CRUD operations for documents."""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
|
from fastapi.responses import JSONResponse, Response
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ...database import DatabaseService, get_session, storage
|
|
from ...database.models import Depense, Document, Immeuble, Locataire, Lot, Revenu
|
|
from ...database.service import DuplicateDocumentError
|
|
from ...extractor import extract_compte_rendu
|
|
from ...utils.uploads import UploadTooLargeError, read_upload_limited
|
|
from ..schemas import DocumentSummary, SaveRequest, SaveResponse
|
|
|
|
router = APIRouter(prefix="/api", tags=["documents"])
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@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:
|
|
logger.exception("Erreur lors de la sauvegarde du document")
|
|
raise HTTPException(
|
|
status_code=500, detail="Erreur lors de la sauvegarde du document."
|
|
)
|
|
|
|
|
|
@router.post("/save-with-pdf", response_model=SaveResponse)
|
|
async def save_document_with_pdf(
|
|
pdf_file: UploadFile = File(..., description="Fichier PDF original"),
|
|
data: str = Form(..., description="Donnees JSON extraites"),
|
|
depenses_tags: str | None = Form(None, description="Tags JSON pour les depenses"),
|
|
overwrite: bool = Form(False, description="Ecraser si existant"),
|
|
session: Session = Depends(get_session),
|
|
) -> SaveResponse:
|
|
"""Sauvegarde les donnees extraites avec le fichier PDF original.
|
|
|
|
Cette version stocke le PDF et le JSON sur le disque pour tracabilite.
|
|
|
|
- **pdf_file**: Fichier PDF original (multipart)
|
|
- **data**: Donnees JSON extraites (string JSON)
|
|
- **depenses_tags**: Tags JSON pour les depenses (optionnel)
|
|
- **overwrite**: Si True, ecrase le document existant
|
|
"""
|
|
# Parse JSON data
|
|
try:
|
|
parsed_data = json.loads(data)
|
|
except json.JSONDecodeError as e:
|
|
raise HTTPException(status_code=400, detail=f"JSON invalide pour data: {e}")
|
|
|
|
# Parse depenses_tags if provided
|
|
parsed_tags = None
|
|
if depenses_tags:
|
|
try:
|
|
parsed_tags = json.loads(depenses_tags)
|
|
except json.JSONDecodeError as e:
|
|
raise HTTPException(
|
|
status_code=400, detail=f"JSON invalide pour depenses_tags: {e}"
|
|
)
|
|
|
|
# Read PDF content (taille bornée)
|
|
try:
|
|
pdf_content = await read_upload_limited(pdf_file)
|
|
except UploadTooLargeError as e:
|
|
raise HTTPException(status_code=413, detail=str(e))
|
|
except Exception as e:
|
|
raise HTTPException(status_code=400, detail=f"Erreur lecture du PDF: {str(e)}")
|
|
|
|
try:
|
|
db_service = DatabaseService(session)
|
|
document = db_service.save_document(
|
|
data=parsed_data,
|
|
source_file=pdf_file.filename,
|
|
pdf_content=pdf_content,
|
|
depenses_tags=parsed_tags,
|
|
overwrite=overwrite,
|
|
)
|
|
|
|
return SaveResponse(
|
|
success=True,
|
|
message="Document sauvegarde avec succes (PDF et JSON stockes)",
|
|
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:
|
|
logger.exception("Erreur lors de la sauvegarde du document avec PDF")
|
|
raise HTTPException(
|
|
status_code=500, detail="Erreur lors de la sauvegarde du document."
|
|
)
|
|
|
|
|
|
@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,
|
|
has_pdf=doc.pdf_path is not None,
|
|
has_json=doc.json_path is not 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,
|
|
"depenses_tags": db_service.get_depenses_tags(document_id),
|
|
"created_at": document.created_at.isoformat() if document.created_at else None,
|
|
"has_pdf": document.pdf_path is not None,
|
|
"has_json": document.json_path is not 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,
|
|
}
|
|
|
|
|
|
@router.get("/documents/{document_id}/pdf")
|
|
async def download_document_pdf(
|
|
document_id: int,
|
|
session: Session = Depends(get_session),
|
|
) -> Response:
|
|
"""Telecharge le fichier PDF original d'un document.
|
|
|
|
Retourne le fichier PDF si disponible, sinon erreur 404.
|
|
"""
|
|
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")
|
|
|
|
if not document.pdf_path:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Fichier PDF non disponible pour ce document",
|
|
)
|
|
|
|
try:
|
|
pdf_content = storage.read_pdf(document.pdf_path)
|
|
filename = document.source_file or f"{document.reference}.pdf"
|
|
return Response(
|
|
content=pdf_content,
|
|
media_type="application/pdf",
|
|
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
|
)
|
|
except FileNotFoundError:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Fichier PDF introuvable sur le disque",
|
|
)
|
|
|
|
|
|
@router.get("/documents/{document_id}/json")
|
|
async def download_document_json(
|
|
document_id: int,
|
|
session: Session = Depends(get_session),
|
|
) -> Response:
|
|
"""Telecharge le fichier JSON extrait d'un document.
|
|
|
|
Retourne le fichier JSON si disponible sur disque,
|
|
sinon retourne le json_data de la base de donnees.
|
|
"""
|
|
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")
|
|
|
|
filename = f"{document.reference}_{document.date}.json"
|
|
|
|
# Try to read from storage first
|
|
if document.json_path:
|
|
try:
|
|
json_data = storage.read_json(document.json_path)
|
|
return JSONResponse(
|
|
content=json_data,
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
except FileNotFoundError:
|
|
pass # Fall back to database
|
|
|
|
# Fall back to json_data in database
|
|
if document.json_data:
|
|
json_data = json.loads(document.json_data)
|
|
return JSONResponse(
|
|
content=json_data,
|
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
|
)
|
|
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Donnees JSON non disponibles pour ce document",
|
|
)
|
|
|
|
|
|
@router.put("/documents/{document_id}", response_model=SaveResponse)
|
|
async def update_document(
|
|
document_id: int,
|
|
request: SaveRequest,
|
|
session: Session = Depends(get_session),
|
|
) -> SaveResponse:
|
|
"""Remplace les donnees d'un document existant, identifie par son ID.
|
|
|
|
A la difference de `POST /api/save` avec `overwrite`, qui retrouve le
|
|
document par (reference, date), le document vise est ici designe par son ID :
|
|
une nouvelle extraction qui corrige la reference ou la date met a jour le bon
|
|
document au lieu d'en creer un second. Le PDF stocke est conserve.
|
|
|
|
- **data**: Nouvelles donnees extraites
|
|
- **depenses_tags**: Tags a appliquer aux depenses (par index d'operation)
|
|
- **source_file**: Nom du fichier source (optionnel, conserve si absent)
|
|
"""
|
|
db_service = DatabaseService(session)
|
|
existing = db_service.get_document_by_id(document_id)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="Document non trouve")
|
|
|
|
try:
|
|
document = db_service.save_document(
|
|
data=request.data,
|
|
source_file=request.source_file or existing.source_file,
|
|
depenses_tags=request.depenses_tags,
|
|
replace_document_id=document_id,
|
|
)
|
|
except DuplicateDocumentError as e:
|
|
return SaveResponse(
|
|
success=False,
|
|
message=(
|
|
f"Un autre document porte deja 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:
|
|
logger.exception("Erreur lors de la mise a jour du document %s", document_id)
|
|
raise HTTPException(
|
|
status_code=500, detail="Erreur lors de la mise a jour du document."
|
|
)
|
|
|
|
return SaveResponse(
|
|
success=True,
|
|
message="Document mis a jour avec succes",
|
|
document_id=document.id,
|
|
reference=document.reference,
|
|
date=str(document.date),
|
|
)
|
|
|
|
|
|
@router.post("/documents/{document_id}/re-extract")
|
|
def re_extract_document(
|
|
document_id: int,
|
|
session: Session = Depends(get_session),
|
|
) -> dict:
|
|
"""Re-extrait les donnees depuis le PDF stocke.
|
|
|
|
Utile pour corriger l'extraction apres amelioration des parsers, document par
|
|
document ou lors d'un balayage de toute la base. Ne modifie pas la base :
|
|
retourne cote a cote les donnees actuelles et les donnees re-extraites, pour
|
|
que l'appelant compare et decide s'il enregistre (PUT /api/documents/{id}).
|
|
|
|
`depenses_tags` porte les tags actuels du document, pour pouvoir etre
|
|
reportes sur la nouvelle extraction plutot que perdus au reenregistrement.
|
|
|
|
Endpoint synchrone (`def`) : l'extraction est bloquante et prend plusieurs
|
|
secondes, FastAPI l'execute donc dans un thread pour ne pas figer le serveur.
|
|
"""
|
|
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")
|
|
|
|
if not document.pdf_path:
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Fichier PDF non disponible pour ce document - re-extraction impossible",
|
|
)
|
|
|
|
# Get absolute path
|
|
pdf_full_path = storage.get_absolute_path(document.pdf_path)
|
|
|
|
if not pdf_full_path.exists():
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail="Fichier PDF introuvable sur le disque",
|
|
)
|
|
|
|
# Re-extract
|
|
try:
|
|
new_data = extract_compte_rendu(str(pdf_full_path))
|
|
except Exception as e:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"Erreur lors de la re-extraction: {str(e)}",
|
|
)
|
|
|
|
return {
|
|
"document_id": document_id,
|
|
"reference": document.reference,
|
|
"date": str(document.date),
|
|
"source_file": document.source_file,
|
|
"original_json_path": document.json_path,
|
|
"previous_data": json.loads(document.json_data) if document.json_data else None,
|
|
"re_extracted_data": new_data,
|
|
"depenses_tags": db_service.get_depenses_tags(document_id),
|
|
"message": "Donnees re-extraites. Utilisez PUT /api/documents/{id} pour mettre a jour.",
|
|
}
|