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:
2026-07-25 19:35:58 +02:00
parent 50508c98a8
commit 9cf0a89aab
3 changed files with 233 additions and 7 deletions

View File

@@ -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.",
}

View File

@@ -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]:

119
tests/test_reextraction.py Normal file
View File

@@ -0,0 +1,119 @@
"""Tests du remplacement d'un document par son ID (re-extraction validee)."""
import copy
from datetime import date
import pytest
from plesna_gerance.database import storage
from plesna_gerance.database.models import Depense, Document, Tag
from plesna_gerance.database.service import DatabaseService, DuplicateDocumentError
@pytest.fixture
def pdf_bytes():
"""Contenu binaire arbitraire : le stockage ne relit jamais le PDF ici."""
return b"%PDF-1.4 fake"
def test_replace_document_met_a_jour_meme_si_la_reference_change(
db_session, sample_data
):
"""Une re-extraction qui corrige la reference met a jour le meme document.
C'est tout l'interet du remplacement par ID : avec un enregistrement par
(reference, date), ce cas creerait un second document.
"""
service = DatabaseService(db_session)
original = service.save_document(data=sample_data)
original_id = original.id
new_data = copy.deepcopy(sample_data)
new_data["metadata"]["document"]["reference"] = "REF001-CORRIGEE"
updated = service.save_document(data=new_data, replace_document_id=original_id)
assert db_session.query(Document).count() == 1
assert updated.reference == "REF001-CORRIGEE"
assert updated.date == date(2024, 1, 15)
def test_replace_document_conserve_le_pdf_et_reecrit_le_json(
db_session, sample_data, pdf_bytes
):
service = DatabaseService(db_session)
original = service.save_document(
data=sample_data, source_file="cr.pdf", pdf_content=pdf_bytes
)
pdf_path, json_path = original.pdf_path, original.json_path
assert pdf_path and json_path
new_data = copy.deepcopy(sample_data)
new_data["situation_locataires"][0]["locataire"]["nom"] = "MARTIN"
# Re-enregistrement sans PDF : celui deja stocke doit etre conserve.
updated = service.save_document(
data=new_data, source_file="cr.pdf", replace_document_id=original.id
)
assert updated.pdf_path == pdf_path
assert updated.json_path == json_path
assert storage.read_pdf(pdf_path) == pdf_bytes
stored = storage.read_json(json_path)
assert stored["situation_locataires"][0]["locataire"]["nom"] == "MARTIN"
def test_replace_document_refuse_une_collision_avec_un_autre_document(
db_session, sample_data
):
"""La nouvelle extraction ne doit pas ecraser un document voisin."""
service = DatabaseService(db_session)
premier = service.save_document(data=sample_data)
autre_data = copy.deepcopy(sample_data)
autre_data["metadata"]["document"]["date"] = "2024-02-15"
service.save_document(data=autre_data)
# Le premier document re-extrait porterait la (reference, date) du second.
collision_data = copy.deepcopy(autre_data)
with pytest.raises(DuplicateDocumentError):
service.save_document(data=collision_data, replace_document_id=premier.id)
assert db_session.query(Document).count() == 2
def test_replace_document_inconnu_leve_value_error(db_session, sample_data):
service = DatabaseService(db_session)
with pytest.raises(ValueError):
service.save_document(data=sample_data, replace_document_id=4242)
def test_get_depenses_tags_puis_report_sur_la_nouvelle_extraction(
db_session, sample_data
):
"""Les tags actuels sont indexes comme les operations, et reportables."""
service = DatabaseService(db_session)
tag = Tag(nom="ENTRETIEN")
db_session.add(tag)
db_session.flush()
original = service.save_document(
data=sample_data, depenses_tags=[{"index": 0, "tag_id": tag.id}]
)
tags = service.get_depenses_tags(original.id)
assert tags == [{"index": 0, "tag_id": tag.id, "tag_nom": "ENTRETIEN"}]
# Report sur une nouvelle extraction du meme document.
new_data = copy.deepcopy(sample_data)
new_data["recapitulatif_operations"][0]["montants"]["debit"] = 75.0
updated = service.save_document(
data=new_data,
depenses_tags=[{"index": t["index"], "tag_id": t["tag_id"]} for t in tags],
replace_document_id=original.id,
)
depenses = db_session.query(Depense).filter_by(document_id=updated.id).all()
assert len(depenses) == 1
assert depenses[0].debit == 75.0
assert depenses[0].tag_id == tag.id