diff --git a/src/plesna_gerance/api/routes/documents.py b/src/plesna_gerance/api/routes/documents.py index 30ff4b8..1cbf608 100644 --- a/src/plesna_gerance/api/routes/documents.py +++ b/src/plesna_gerance/api/routes/documents.py @@ -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.", } diff --git a/src/plesna_gerance/database/service.py b/src/plesna_gerance/database/service.py index f2f6db7..501052a 100644 --- a/src/plesna_gerance/database/service.py +++ b/src/plesna_gerance/database/service.py @@ -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]: diff --git a/tests/test_reextraction.py b/tests/test_reextraction.py new file mode 100644 index 0000000..df7a64a --- /dev/null +++ b/tests/test_reextraction.py @@ -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