La liste des documents montrait la date d'import, figee au premier enregistrement : c'est la date de la derniere extraction ayant produit les donnees affichees qui renseigne, surtout apres un balayage de re-extraction. La colonne `extracted_at` la porte, mise a jour a chaque enregistrement, et la date d'import reste consultable en infobulle. Le projet n'ayant pas d'outil de migration (`create_all` laisse intactes les tables existantes), `init_db` rattrape les colonnes ajoutees apres coup : sans cela une base deja installee cesserait de fonctionner. Les documents deja presents recoivent leur date d'import, qui est bien celle de leur extraction. Une fois la selection appliquee sans echec, le balayage renvoie vers la liste des documents ; en cas d'echec on reste sur place, les lignes concernees portant leur message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
490 lines
17 KiB
Python
490 lines
17 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.canonical import canonical_copy
|
|
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,
|
|
extracted_at=doc.extracted_at.isoformat() if doc.extracted_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,
|
|
"extracted_at": document.extracted_at.isoformat()
|
|
if document.extracted_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.
|
|
|
|
`previous_canonical` donne les donnees actuelles telles qu'elles seraient
|
|
reecrites a l'enregistrement (cf. utils.canonical). L'appelant compare avec
|
|
`previous_data` pour savoir quels champs le serveur imposera de toute facon,
|
|
et le signaler avant que l'utilisateur ne se prononce — sans avoir a
|
|
connaitre les regles de reecriture.
|
|
|
|
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)}",
|
|
)
|
|
|
|
previous_data = json.loads(document.json_data) if document.json_data else None
|
|
|
|
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": previous_data,
|
|
"previous_canonical": canonical_copy(previous_data),
|
|
"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.",
|
|
}
|