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,6 +171,17 @@ class DatabaseService:
|
||||
# Check for duplicates and preserve existing file paths if overwriting
|
||||
existing_pdf_path = None
|
||||
existing_json_path = None
|
||||
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:
|
||||
@@ -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
119
tests/test_reextraction.py
Normal 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
|
||||
Reference in New Issue
Block a user