feat: expose la re-extraction verifiable d'un document
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>
This commit is contained in:
@@ -242,6 +242,7 @@ async def get_document(
|
||||
"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,
|
||||
@@ -358,18 +359,78 @@ async def download_document_json(
|
||||
)
|
||||
|
||||
|
||||
@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")
|
||||
async def re_extract_document(
|
||||
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.
|
||||
Ne modifie pas automatiquement la base - retourne les nouvelles donnees
|
||||
pour validation par l'utilisateur.
|
||||
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}).
|
||||
|
||||
Retourne les donnees re-extraites du PDF.
|
||||
`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)
|
||||
@@ -405,7 +466,10 @@ async def re_extract_document(
|
||||
"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.",
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ class DatabaseService:
|
||||
pdf_content: bytes = None,
|
||||
depenses_tags: list[dict] = None,
|
||||
overwrite: bool = False,
|
||||
replace_document_id: int = None,
|
||||
) -> Document:
|
||||
"""Save extracted JSON data to database.
|
||||
|
||||
@@ -137,12 +138,19 @@ class DatabaseService:
|
||||
pdf_content: Binary content of the PDF file (optional, for storage)
|
||||
depenses_tags: List of tags for expenses
|
||||
overwrite: If True, delete existing document and recreate it
|
||||
replace_document_id: ID du document a remplacer. Le document vise est
|
||||
identifie par son ID et non par (reference, date) : une nouvelle
|
||||
extraction qui corrige la reference ou la date met alors a jour le
|
||||
bon document au lieu d'en creer un second.
|
||||
|
||||
Returns:
|
||||
The created Document instance
|
||||
|
||||
Raises:
|
||||
DuplicateDocumentError: If document already exists and overwrite=False
|
||||
DuplicateDocumentError: If document already exists and overwrite=False,
|
||||
ou si un *autre* document porte deja la (reference, date) de
|
||||
`data` lors d'un remplacement par ID
|
||||
ValueError: Si `replace_document_id` ne designe aucun document
|
||||
"""
|
||||
# Uniformiser les numéros de lot avant toute persistance (JSON + tables)
|
||||
normalize_extraction_lots(data)
|
||||
@@ -163,7 +171,18 @@ class DatabaseService:
|
||||
# Check for duplicates and preserve existing file paths if overwriting
|
||||
existing_pdf_path = None
|
||||
existing_json_path = None
|
||||
existing = self.check_duplicate(reference, doc_date)
|
||||
if replace_document_id is not None:
|
||||
existing = self.session.get(Document, replace_document_id)
|
||||
if existing is None:
|
||||
raise ValueError(f"Document {replace_document_id} introuvable")
|
||||
# La nouvelle extraction ne doit pas entrer en collision avec un
|
||||
# autre document deja en base.
|
||||
collision = self.check_duplicate(reference, doc_date)
|
||||
if collision is not None and collision.id != existing.id:
|
||||
raise DuplicateDocumentError(reference, doc_date)
|
||||
overwrite = True
|
||||
else:
|
||||
existing = self.check_duplicate(reference, doc_date)
|
||||
if existing:
|
||||
if overwrite:
|
||||
# Preserve existing file paths for reuse
|
||||
@@ -341,6 +360,30 @@ class DatabaseService:
|
||||
"""Get a document by ID."""
|
||||
return self.session.get(Document, doc_id)
|
||||
|
||||
def get_depenses_tags(self, doc_id: int) -> list[dict]:
|
||||
"""Retourne les tags actuels des dépenses d'un document.
|
||||
|
||||
Les dépenses sont ordonnées par id, ce qui reproduit l'ordre de
|
||||
`recapitulatif_operations` au moment de l'enregistrement (cf.
|
||||
`save_document`). L'index retourné correspond donc à celui de
|
||||
l'opération dans le JSON, format attendu par `depenses_tags`.
|
||||
|
||||
Seules les dépenses effectivement taguées sont retournées.
|
||||
"""
|
||||
stmt = (
|
||||
select(Depense, Tag.nom)
|
||||
.outerjoin(Tag, Depense.tag_id == Tag.id)
|
||||
.where(Depense.document_id == doc_id)
|
||||
.order_by(Depense.id)
|
||||
)
|
||||
rows = self.session.execute(stmt).all()
|
||||
|
||||
return [
|
||||
{"index": idx, "tag_id": depense.tag_id, "tag_nom": tag_nom}
|
||||
for idx, (depense, tag_nom) in enumerate(rows)
|
||||
if depense.tag_id is not None
|
||||
]
|
||||
|
||||
def get_revenus_summary(
|
||||
self, immeuble_id: int = None, year: int = None
|
||||
) -> list[dict]:
|
||||
|
||||
Reference in New Issue
Block a user