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,
|
"siret": document.editeur_siret,
|
||||||
},
|
},
|
||||||
"json_data": json.loads(document.json_data) if document.json_data else None,
|
"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,
|
"created_at": document.created_at.isoformat() if document.created_at else None,
|
||||||
"has_pdf": document.pdf_path is not None,
|
"has_pdf": document.pdf_path is not None,
|
||||||
"has_json": document.json_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")
|
@router.post("/documents/{document_id}/re-extract")
|
||||||
async def re_extract_document(
|
def re_extract_document(
|
||||||
document_id: int,
|
document_id: int,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Re-extrait les donnees depuis le PDF stocke.
|
"""Re-extrait les donnees depuis le PDF stocke.
|
||||||
|
|
||||||
Utile pour corriger l'extraction apres amelioration des parsers.
|
Utile pour corriger l'extraction apres amelioration des parsers, document par
|
||||||
Ne modifie pas automatiquement la base - retourne les nouvelles donnees
|
document ou lors d'un balayage de toute la base. Ne modifie pas la base :
|
||||||
pour validation par l'utilisateur.
|
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)
|
db_service = DatabaseService(session)
|
||||||
document = db_service.get_document_by_id(document_id)
|
document = db_service.get_document_by_id(document_id)
|
||||||
@@ -405,7 +466,10 @@ async def re_extract_document(
|
|||||||
"document_id": document_id,
|
"document_id": document_id,
|
||||||
"reference": document.reference,
|
"reference": document.reference,
|
||||||
"date": str(document.date),
|
"date": str(document.date),
|
||||||
|
"source_file": document.source_file,
|
||||||
"original_json_path": document.json_path,
|
"original_json_path": document.json_path,
|
||||||
|
"previous_data": json.loads(document.json_data) if document.json_data else None,
|
||||||
"re_extracted_data": new_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.",
|
"message": "Donnees re-extraites. Utilisez PUT /api/documents/{id} pour mettre a jour.",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ class DatabaseService:
|
|||||||
pdf_content: bytes = None,
|
pdf_content: bytes = None,
|
||||||
depenses_tags: list[dict] = None,
|
depenses_tags: list[dict] = None,
|
||||||
overwrite: bool = False,
|
overwrite: bool = False,
|
||||||
|
replace_document_id: int = None,
|
||||||
) -> Document:
|
) -> Document:
|
||||||
"""Save extracted JSON data to database.
|
"""Save extracted JSON data to database.
|
||||||
|
|
||||||
@@ -137,12 +138,19 @@ class DatabaseService:
|
|||||||
pdf_content: Binary content of the PDF file (optional, for storage)
|
pdf_content: Binary content of the PDF file (optional, for storage)
|
||||||
depenses_tags: List of tags for expenses
|
depenses_tags: List of tags for expenses
|
||||||
overwrite: If True, delete existing document and recreate it
|
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:
|
Returns:
|
||||||
The created Document instance
|
The created Document instance
|
||||||
|
|
||||||
Raises:
|
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)
|
# Uniformiser les numéros de lot avant toute persistance (JSON + tables)
|
||||||
normalize_extraction_lots(data)
|
normalize_extraction_lots(data)
|
||||||
@@ -163,7 +171,18 @@ class DatabaseService:
|
|||||||
# Check for duplicates and preserve existing file paths if overwriting
|
# Check for duplicates and preserve existing file paths if overwriting
|
||||||
existing_pdf_path = None
|
existing_pdf_path = None
|
||||||
existing_json_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 existing:
|
||||||
if overwrite:
|
if overwrite:
|
||||||
# Preserve existing file paths for reuse
|
# Preserve existing file paths for reuse
|
||||||
@@ -341,6 +360,30 @@ class DatabaseService:
|
|||||||
"""Get a document by ID."""
|
"""Get a document by ID."""
|
||||||
return self.session.get(Document, doc_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(
|
def get_revenus_summary(
|
||||||
self, immeuble_id: int = None, year: int = None
|
self, immeuble_id: int = None, year: int = None
|
||||||
) -> list[dict]:
|
) -> 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