From d8ec61802d69906b92eb48d0b8dd8f55649f3036 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 7 Jan 2026 20:01:55 +0300 Subject: [PATCH] feat(universign): add signed document storage and download functionality --- api.py | 20 +-- database/models/universign.py | 19 +- routes/universign.py | 296 +++++++++++++++++++++++++++++--- sage_client.py | 2 +- services/universign_document.py | 156 +++++++++++++++++ services/universign_sync.py | 55 ++++-- 6 files changed, 489 insertions(+), 59 deletions(-) create mode 100644 services/universign_document.py diff --git a/api.py b/api.py index a78fcbc..da967df 100644 --- a/api.py +++ b/api.py @@ -391,7 +391,7 @@ async def creer_devis(devis: DevisRequest): resultat = sage_client.creer_devis(devis_data) logger.info( - f"✅ Devis créé: {resultat.get('numero_devis')} " + f"Devis créé: {resultat.get('numero_devis')} " f"({resultat.get('total_ttc')}€ TTC)" ) @@ -405,7 +405,7 @@ async def creer_devis(devis: DevisRequest): ) except Exception as e: - logger.error(f"❌ Erreur création devis: {e}") + logger.error(f"Erreur création devis: {e}") raise HTTPException(500, str(e)) @@ -474,7 +474,7 @@ async def creer_commande( resultat = sage_client.creer_commande(commande_data) logger.info( - f"✅ Commande créée: {resultat.get('numero_commande')} " + f"Commande créée: {resultat.get('numero_commande')} " f"({resultat.get('total_ttc')}€ TTC)" ) @@ -496,7 +496,7 @@ async def creer_commande( except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur création commande: {e}") + logger.error(f"Erreur création commande: {e}") raise HTTPException(500, str(e)) @@ -1420,7 +1420,7 @@ async def creer_facture( resultat = sage_client.creer_facture(facture_data) logger.info( - f"✅ Facture créée: {resultat.get('numero_facture')} " + f"Facture créée: {resultat.get('numero_facture')} " f"({resultat.get('total_ttc')}€ TTC)" ) @@ -1442,7 +1442,7 @@ async def creer_facture( except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur création facture: {e}") + logger.error(f"Erreur création facture: {e}") raise HTTPException(500, str(e)) @@ -1943,7 +1943,7 @@ async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_se resultat = sage_client.creer_avoir(avoir_data) logger.info( - f"✅ Avoir créé: {resultat.get('numero_avoir')} " + f"Avoir créé: {resultat.get('numero_avoir')} " f"({resultat.get('total_ttc')}€ TTC)" ) @@ -1965,7 +1965,7 @@ async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_se except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur création avoir: {e}") + logger.error(f"Erreur création avoir: {e}") raise HTTPException(500, str(e)) @@ -2070,7 +2070,7 @@ async def creer_livraison( resultat = sage_client.creer_livraison(livraison_data) logger.info( - f"✅ Livraison créée: {resultat.get('numero_livraison')} " + f"Livraison créée: {resultat.get('numero_livraison')} " f"({resultat.get('total_ttc')}€ TTC)" ) @@ -2092,7 +2092,7 @@ async def creer_livraison( except HTTPException: raise except Exception as e: - logger.error(f"❌ Erreur création livraison: {e}") + logger.error(f"Erreur création livraison: {e}") raise HTTPException(500, str(e)) diff --git a/database/models/universign.py b/database/models/universign.py index 52ac092..e4ad3a3 100644 --- a/database/models/universign.py +++ b/database/models/universign.py @@ -106,6 +106,23 @@ class UniversignTransaction(Base): # === URLS ET MÉTADONNÉES UNIVERSIGN === signer_url = Column(Text, nullable=True, comment="URL de signature") document_url = Column(Text, nullable=True, comment="URL du document signé") + + signed_document_path = Column( + Text, nullable=True, comment="Chemin local du PDF signé" + ) + signed_document_downloaded_at = Column( + DateTime, nullable=True, comment="Date de téléchargement du document" + ) + signed_document_size_bytes = Column( + Integer, nullable=True, comment="Taille du fichier en octets" + ) + download_attempts = Column( + Integer, default=0, comment="Nombre de tentatives de téléchargement" + ) + download_error = Column( + Text, nullable=True, comment="Dernière erreur de téléchargement" + ) + certificate_url = Column(Text, nullable=True, comment="URL du certificat") # === SIGNATAIRES === @@ -268,7 +285,7 @@ class UniversignConfig(Base): ) api_url = Column(String(500), nullable=False) - api_key = Column(String(500), nullable=False, comment="⚠️ À chiffrer") + api_key = Column(String(500), nullable=False, comment="À chiffrer") # === OPTIONS === webhook_url = Column(String(500), nullable=True) diff --git a/routes/universign.py b/routes/universign.py index 49271be..431b816 100644 --- a/routes/universign.py +++ b/routes/universign.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, or_, and_ +from sqlalchemy import false, select, func, or_, and_, true from sqlalchemy.orm import selectinload from typing import List, Optional from datetime import datetime, timedelta @@ -16,7 +17,10 @@ from database import ( LocalDocumentStatus, SageDocumentType, ) +import os +from pathlib import Path import json +from services.universign_document import UniversignDocumentService from services.universign_sync import UniversignSyncService from config.config import settings from utils.generic_functions import normaliser_type_doc @@ -63,6 +67,10 @@ class TransactionResponse(BaseModel): needs_sync: bool signers: List[dict] + signed_document_available: bool = False + signed_document_downloaded_at: Optional[datetime] = None + signed_document_size_kb: Optional[float] = None + class SyncStatsResponse(BaseModel): """Statistiques de synchronisation""" @@ -96,7 +104,7 @@ async def create_signature( if all_existing: logger.warning( - f"⚠️ {len(all_existing)} transaction(s) existante(s) trouvée(s)" + f"{len(all_existing)} transaction(s) existante(s) trouvée(s)" ) # Filtrer les transactions non-finales @@ -115,7 +123,7 @@ async def create_signature( if active_txs: active_tx = active_txs[0] logger.error( - f"❌ Transaction active existante: {active_tx.transaction_id} " + f"Transaction active existante: {active_tx.transaction_id} " f"(statut: {active_tx.local_status.value})" ) raise HTTPException( @@ -126,7 +134,7 @@ async def create_signature( ) logger.info( - "✅ Toutes les transactions existantes sont finales, création autorisée" + "Toutes les transactions existantes sont finales, création autorisée" ) # Génération PDF @@ -138,7 +146,7 @@ async def create_signature( if not pdf_bytes: raise HTTPException(400, "Échec génération PDF") - logger.info(f"✅ PDF généré: {len(pdf_bytes)} octets") + logger.info(f"PDF généré: {len(pdf_bytes)} octets") # === CRÉATION TRANSACTION UNIVERSIGN === import requests @@ -160,11 +168,11 @@ async def create_signature( ) if resp.status_code != 200: - logger.error(f"❌ Erreur Universign (création): {resp.text}") + logger.error(f"Erreur Universign (création): {resp.text}") raise HTTPException(500, f"Erreur Universign: {resp.status_code}") universign_tx_id = resp.json().get("id") - logger.info(f"✅ Transaction Universign créée: {universign_tx_id}") + logger.info(f"Transaction Universign créée: {universign_tx_id}") # Upload PDF logger.info("📤 Upload PDF...") @@ -176,11 +184,11 @@ async def create_signature( ) if resp.status_code not in [200, 201]: - logger.error(f"❌ Erreur upload: {resp.text}") + logger.error(f"Erreur upload: {resp.text}") raise HTTPException(500, "Erreur upload PDF") file_id = resp.json().get("id") - logger.info(f"✅ PDF uploadé: {file_id}") + logger.info(f"PDF uploadé: {file_id}") # Attachement document logger.info("🔗 Attachement document...") @@ -246,14 +254,14 @@ async def create_signature( if not signer_url: raise HTTPException(500, "URL de signature non retournée") - logger.info("✅ URL de signature obtenue") + logger.info("URL de signature obtenue") # === ENREGISTREMENT LOCAL === local_id = str(uuid.uuid4()) transaction = UniversignTransaction( id=local_id, - transaction_id=universign_tx_id, # ⚠️ Utiliser l'ID Universign, ne jamais le changer + transaction_id=universign_tx_id, # Utiliser l'ID Universign, ne jamais le changer sage_document_id=request.sage_document_id, sage_document_type=request.sage_document_type, universign_status=UniversignTransactionStatus.STARTED, @@ -436,6 +444,16 @@ async def list_transactions( } for s in tx.signers ], + # ✅ NOUVEAUX CHAMPS + signed_document_available=bool( + tx.signed_document_path and Path(tx.signed_document_path).exists() + ), + signed_document_downloaded_at=tx.signed_document_downloaded_at, + signed_document_size_kb=( + tx.signed_document_size_bytes / 1024 + if tx.signed_document_size_bytes + else None + ), ) for tx in transactions ] @@ -482,6 +500,16 @@ async def get_transaction( } for s in tx.signers ], + # ✅ NOUVEAUX CHAMPS + signed_document_available=bool( + tx.signed_document_path and Path(tx.signed_document_path).exists() + ), + signed_document_downloaded_at=tx.signed_document_downloaded_at, + signed_document_size_kb=( + tx.signed_document_size_bytes / 1024 + if tx.signed_document_size_bytes + else None + ), ) @@ -544,7 +572,7 @@ async def webhook_universign( ) logger.debug(f"Payload complet: {json.dumps(payload, indent=2)}") - # ✅ EXTRACTION CORRECTE DU TRANSACTION_ID + # EXTRACTION CORRECTE DU TRANSACTION_ID transaction_id = None # 🔍 Structure 1 : Événements avec payload imbriqué (la plus courante) @@ -555,7 +583,7 @@ async def webhook_universign( if nested_object.get("object") == "transaction": transaction_id = nested_object.get("id") logger.info( - f"✅ Transaction ID extrait de payload.object.id: {transaction_id}" + f"Transaction ID extrait de payload.object.id: {transaction_id}" ) # 🔍 Structure 2 : Action événements (action.opened, action.completed) @@ -565,25 +593,23 @@ async def webhook_universign( payload.get("payload", {}).get("object", {}).get("transaction_id") ) logger.info( - f"✅ Transaction ID extrait de payload.object.transaction_id: {transaction_id}" + f"Transaction ID extrait de payload.object.transaction_id: {transaction_id}" ) # 🔍 Structure 3 : Transaction directe (fallback) elif payload.get("object") == "transaction": transaction_id = payload.get("id") - logger.info(f"✅ Transaction ID extrait direct: {transaction_id}") + logger.info(f"Transaction ID extrait direct: {transaction_id}") # 🔍 Structure 4 : Ancien format (pour rétro-compatibilité) elif "transaction" in payload: transaction_id = payload.get("transaction", {}).get("id") - logger.info( - f"✅ Transaction ID extrait de transaction.id: {transaction_id}" - ) + logger.info(f"Transaction ID extrait de transaction.id: {transaction_id}") - # ❌ Échec d'extraction + # Échec d'extraction if not transaction_id: logger.error( - f"❌ Transaction ID introuvable dans webhook\n" + f"Transaction ID introuvable dans webhook\n" f"Type d'événement: {payload.get('type', 'unknown')}\n" f"Clés racine: {list(payload.keys())}\n" f"Payload simplifié: {json.dumps({k: v if k != 'payload' else '...' for k, v in payload.items()})}" @@ -606,7 +632,7 @@ async def webhook_universign( if not tx: logger.warning( - f"⚠️ Transaction {transaction_id} inconnue en local\n" + f"Transaction {transaction_id} inconnue en local\n" f"Type d'événement: {payload.get('type')}\n" f"Elle sera synchronisée au prochain polling" ) @@ -623,16 +649,16 @@ async def webhook_universign( ) if not success: - logger.error(f"❌ Erreur traitement webhook: {error}") + logger.error(f"Erreur traitement webhook: {error}") return { "status": "error", "message": error, "transaction_id": transaction_id, }, 500 - # ✅ Succès + # Succès logger.info( - f"✅ Webhook traité avec succès\n" + f"Webhook traité avec succès\n" f"Transaction: {transaction_id}\n" f"Nouveau statut: {tx.local_status.value if tx else 'unknown'}\n" f"Type d'événement: {payload.get('type')}" @@ -967,7 +993,7 @@ async def diagnostic_complet(session: AsyncSession = Depends(get_session)): date_limite = datetime.now() - timedelta(hours=1) sans_sync_query = select(func.count(UniversignTransaction.id)).where( and_( - UniversignTransaction.needs_sync == True, + UniversignTransaction.needs_sync.is_(true()), or_( UniversignTransaction.last_synced_at < date_limite, UniversignTransaction.last_synced_at.is_(None), @@ -997,7 +1023,7 @@ async def diagnostic_complet(session: AsyncSession = Depends(get_session)): # Transactions sans webhook reçu sans_webhook_query = select(func.count(UniversignTransaction.id)).where( and_( - UniversignTransaction.webhook_received == False, + UniversignTransaction.webhook_received.is_(false()), UniversignTransaction.local_status != LocalDocumentStatus.PENDING, ) ) @@ -1029,13 +1055,13 @@ async def diagnostic_complet(session: AsyncSession = Depends(get_session)): if len(doublons) > 0: diagnostic["recommandations"].append( - f"⚠️ {len(doublons)} document(s) avec doublons. " + f"{len(doublons)} document(s) avec doublons. " f"Utilisez POST /universign/cleanup/all-duplicates" ) if erreurs > 0: diagnostic["recommandations"].append( - f"❌ {erreurs} transaction(s) en erreur. " + f"{erreurs} transaction(s) en erreur. " f"Vérifiez les logs avec GET /universign/transactions?status=ERREUR" ) @@ -1373,3 +1399,217 @@ async def voir_dernier_webhook( except Exception as e: logger.error(f"Erreur debug webhook: {e}") raise HTTPException(500, str(e)) + + +@router.get( + "/transactions/{transaction_id}/document/download", tags=["Documents Signés"] +) +async def telecharger_document_signe( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """ + Télécharge le document signé localement stocké + """ + try: + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + raise HTTPException(404, f"Transaction {transaction_id} introuvable") + + if not transaction.signed_document_path: + raise HTTPException( + 404, + "Document signé non disponible localement. " + "Utilisez POST /admin/download-missing-documents pour le récupérer.", + ) + + file_path = Path(transaction.signed_document_path) + + if not file_path.exists(): + # Document perdu, on peut tenter de le retélécharger + logger.warning(f"Fichier perdu : {file_path}") + raise HTTPException( + 404, + "Fichier introuvable sur le serveur. " + "Utilisez POST /admin/download-missing-documents pour le récupérer.", + ) + + # Génération du nom de fichier pour le téléchargement + download_name = ( + f"{transaction.sage_document_id}_" + f"{transaction.sage_document_type.name}_" + f"signe.pdf" + ) + + return FileResponse( + path=str(file_path), media_type="application/pdf", filename=download_name + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur téléchargement document : {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@router.get("/transactions/{transaction_id}/document/info", tags=["Documents Signés"]) +async def info_document_signe( + transaction_id: str, session: AsyncSession = Depends(get_session) +): + """ + Informations sur le document signé + """ + try: + query = select(UniversignTransaction).where( + UniversignTransaction.transaction_id == transaction_id + ) + result = await session.execute(query) + transaction = result.scalar_one_or_none() + + if not transaction: + raise HTTPException(404, f"Transaction {transaction_id} introuvable") + + file_exists = False + file_size_mb = None + + if transaction.signed_document_path: + file_path = Path(transaction.signed_document_path) + file_exists = file_path.exists() + + if file_exists: + file_size_mb = os.path.getsize(file_path) / (1024 * 1024) + + return { + "transaction_id": transaction_id, + "document_available_locally": file_exists, + "document_url_universign": transaction.document_url, + "downloaded_at": ( + transaction.signed_document_downloaded_at.isoformat() + if transaction.signed_document_downloaded_at + else None + ), + "file_size_mb": round(file_size_mb, 2) if file_size_mb else None, + "download_attempts": transaction.download_attempts, + "last_download_error": transaction.download_error, + "local_path": transaction.signed_document_path if file_exists else None, + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur info document : {e}") + raise HTTPException(500, str(e)) + + +@router.post("/admin/download-missing-documents", tags=["Admin"]) +async def telecharger_documents_manquants( + force_redownload: bool = Query( + False, description="Forcer le retéléchargement même si déjà présent" + ), + session: AsyncSession = Depends(get_session), +): + """ + Télécharge tous les documents signés manquants pour les transactions SIGNE + """ + try: + # Transactions signées sans document local + query = select(UniversignTransaction).where( + UniversignTransaction.local_status == LocalDocumentStatus.SIGNED, + or_( + UniversignTransaction.signed_document_path.is_(None), + force_redownload.is_(true()), + ), + ) + + result = await session.execute(query) + transactions = result.scalars().all() + + logger.info(f"📥 {len(transactions)} document(s) à télécharger") + + document_service = UniversignDocumentService( + api_key=settings.universign_api_key, timeout=60 + ) + + results = {"total": len(transactions), "success": 0, "failed": 0, "details": []} + + for transaction in transactions: + try: + ( + success, + error, + ) = await document_service.download_and_store_signed_document( + session=session, transaction=transaction, force=force_redownload + ) + + if success: + results["success"] += 1 + results["details"].append( + { + "transaction_id": transaction.transaction_id, + "sage_document_id": transaction.sage_document_id, + "status": "success", + } + ) + else: + results["failed"] += 1 + results["details"].append( + { + "transaction_id": transaction.transaction_id, + "sage_document_id": transaction.sage_document_id, + "status": "failed", + "error": error, + } + ) + + except Exception as e: + logger.error(f"Erreur téléchargement {transaction.transaction_id}: {e}") + results["failed"] += 1 + results["details"].append( + {"transaction_id": transaction.transaction_id, "error": str(e)} + ) + + await session.commit() + + logger.info( + f"Téléchargement terminé : {results['success']}/{results['total']} réussis" + ) + + return results + + except Exception as e: + logger.error(f"Erreur téléchargement batch : {e}", exc_info=True) + raise HTTPException(500, str(e)) + + +@router.post("/admin/cleanup-old-documents", tags=["Admin"]) +async def nettoyer_anciens_documents( + days_to_keep: int = Query( + 90, ge=7, le=365, description="Nombre de jours à conserver" + ), +): + """ + Supprime les documents signés de plus de X jours (par défaut 90) + """ + try: + document_service = UniversignDocumentService( + api_key=settings.universign_api_key + ) + + deleted, size_freed_mb = await document_service.cleanup_old_documents( + days_to_keep=days_to_keep + ) + + return { + "success": True, + "files_deleted": deleted, + "space_freed_mb": size_freed_mb, + "days_kept": days_to_keep, + } + + except Exception as e: + logger.error(f"Erreur nettoyage : {e}") + raise HTTPException(500, str(e)) diff --git a/sage_client.py b/sage_client.py index cec8042..8caef65 100644 --- a/sage_client.py +++ b/sage_client.py @@ -408,7 +408,7 @@ class SageGatewayClient: return self._post( "/sage/collaborateurs/list", { - "filtre": filtre or "", # ⚠️ Convertir None en "" + "filtre": filtre or "", # Convertir None en "" "actifs_seulement": actifs_seulement, }, ).get("data", []) diff --git a/services/universign_document.py b/services/universign_document.py new file mode 100644 index 0000000..98baf68 --- /dev/null +++ b/services/universign_document.py @@ -0,0 +1,156 @@ +import os +import logging +import requests +from pathlib import Path +from datetime import datetime +from typing import Optional, Tuple +from sqlalchemy.ext.asyncio import AsyncSession + +logger = logging.getLogger(__name__) + +SIGNED_DOCS_DIR = Path(os.getenv("SIGNED_DOCS_PATH", "/app/data/signed_documents")) +SIGNED_DOCS_DIR.mkdir(parents=True, exist_ok=True) + + +class UniversignDocumentService: + """Service de gestion des documents signés Universign""" + + def __init__(self, api_key: str, timeout: int = 60): + self.api_key = api_key + self.timeout = timeout + self.auth = (api_key, "") + + async def download_and_store_signed_document( + self, session: AsyncSession, transaction, force: bool = False + ) -> Tuple[bool, Optional[str]]: + if not force and transaction.signed_document_path: + if os.path.exists(transaction.signed_document_path): + logger.debug(f"Document déjà téléchargé : {transaction.transaction_id}") + return True, None + + if not transaction.document_url: + error = "Aucune URL de document disponible" + logger.warning(f"{error} pour {transaction.transaction_id}") + transaction.download_error = error + await session.commit() + return False, error + + try: + logger.info(f"Téléchargement document signé : {transaction.transaction_id}") + + transaction.download_attempts += 1 + + response = requests.get( + transaction.document_url, + auth=self.auth, + timeout=self.timeout, + stream=True, + ) + + response.raise_for_status() + + content_type = response.headers.get("Content-Type", "") + if "pdf" not in content_type.lower(): + error = f"Type de contenu invalide : {content_type}" + logger.error(error) + transaction.download_error = error + await session.commit() + return False, error + + filename = self._generate_filename(transaction) + file_path = SIGNED_DOCS_DIR / filename + + with open(file_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + file_size = os.path.getsize(file_path) + + if file_size < 1024: # Moins de 1 KB = suspect + error = f"Fichier trop petit : {file_size} octets" + logger.error(error) + os.remove(file_path) + transaction.download_error = error + await session.commit() + return False, error + + transaction.signed_document_path = str(file_path) + transaction.signed_document_downloaded_at = datetime.now() + transaction.signed_document_size_bytes = file_size + transaction.download_error = None + + await session.commit() + + logger.info(f"Document téléchargé : {filename} ({file_size / 1024:.1f} KB)") + + return True, None + + except requests.exceptions.RequestException as e: + error = f"Erreur HTTP : {str(e)}" + logger.error(f"{error} pour {transaction.transaction_id}") + transaction.download_error = error + await session.commit() + return False, error + + except OSError as e: + error = f"Erreur filesystem : {str(e)}" + logger.error(f"{error}") + transaction.download_error = error + await session.commit() + return False, error + + except Exception as e: + error = f"Erreur inattendue : {str(e)}" + logger.error(f"{error}", exc_info=True) + transaction.download_error = error + await session.commit() + return False, error + + def _generate_filename(self, transaction) -> str: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + tx_id = transaction.transaction_id.replace("tr_", "") + + filename = f"{transaction.sage_document_id}_{tx_id}_{timestamp}.pdf" + + return filename + + def get_document_path(self, transaction) -> Optional[Path]: + if not transaction.signed_document_path: + return None + + path = Path(transaction.signed_document_path) + if path.exists(): + return path + + return None + + async def cleanup_old_documents(self, days_to_keep: int = 90) -> Tuple[int, int]: + from datetime import timedelta + + cutoff_date = datetime.now() - timedelta(days=days_to_keep) + + deleted = 0 + size_freed = 0 + + for file_path in SIGNED_DOCS_DIR.glob("*.pdf"): + try: + file_time = datetime.fromtimestamp(os.path.getmtime(file_path)) + + if file_time < cutoff_date: + size_freed += os.path.getsize(file_path) + os.remove(file_path) + deleted += 1 + logger.info(f"🗑️ Supprimé : {file_path.name}") + + except Exception as e: + logger.error(f"Erreur suppression {file_path}: {e}") + + size_freed_mb = size_freed / (1024 * 1024) + + logger.info( + f"Nettoyage terminé : {deleted} fichiers supprimés " + f"({size_freed_mb:.2f} MB libérés)" + ) + + return deleted, int(size_freed_mb) diff --git a/services/universign_sync.py b/services/universign_sync.py index 11f30dd..bde966a 100644 --- a/services/universign_sync.py +++ b/services/universign_sync.py @@ -19,6 +19,7 @@ from database import ( StatutEmail, ) from data.data import templates_signature_email +from services.universign_document import UniversignDocumentService from utils.universign_status_mapping import ( map_universign_to_local, is_transition_allowed, @@ -39,6 +40,7 @@ class UniversignSyncService: self.sage_client = None self.email_queue = None self.settings = None + self.document_service = UniversignDocumentService(api_key=api_key, timeout=60) def configure(self, sage_client, email_queue, settings): self.sage_client = sage_client @@ -201,7 +203,7 @@ class UniversignSyncService: transaction = result.scalar_one_or_none() if not transaction: - logger.warning(f"⚠️ Transaction {transaction_id} inconnue localement") + logger.warning(f"Transaction {transaction_id} inconnue localement") return False, "Transaction inconnue" # Marquer comme webhook reçu @@ -218,7 +220,7 @@ class UniversignSyncService: # Log du changement de statut if success and transaction.local_status.value != old_status: logger.info( - f"✅ Webhook traité: {transaction_id} | " + f"Webhook traité: {transaction_id} | " f"{old_status} → {transaction.local_status.value}" ) @@ -233,7 +235,7 @@ class UniversignSyncService: new_status=transaction.local_status.value, changes=json.dumps( payload, default=str - ), # ✅ Ajout default=str pour éviter les erreurs JSON + ), # Ajout default=str pour éviter les erreurs JSON ) await session.commit() @@ -267,7 +269,7 @@ class UniversignSyncService: logger.warning(f"Signataire sans email à l'index {idx}, ignoré") continue - # ✅ PROTECTION : gérer les statuts inconnus + # PROTECTION : gérer les statuts inconnus raw_status = signer_data.get("status") or signer_data.get( "state", "waiting" ) @@ -298,7 +300,7 @@ class UniversignSyncService: if signer_data.get("name") and not signer.name: signer.name = signer_data.get("name") else: - # ✅ Nouveau signer avec gestion d'erreur intégrée + # Nouveau signer avec gestion d'erreur intégrée try: signer = UniversignSigner( id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}", @@ -345,9 +347,9 @@ class UniversignSyncService: if not result: error = "Échec récupération données Universign" - logger.error(f"❌ {error}: {transaction.transaction_id}") + logger.error(f"{error}: {transaction.transaction_id}") - # ✅ CORRECTION : Incrémenter les tentatives MÊME en cas d'échec + # CORRECTION : Incrémenter les tentatives MÊME en cas d'échec transaction.sync_attempts += 1 transaction.sync_error = error @@ -373,14 +375,12 @@ class UniversignSyncService: # Vérifier la transition if not is_transition_allowed(previous_local_status, new_local_status): logger.warning( - f"⚠️ Transition refusée: {previous_local_status} → {new_local_status}" + f"Transition refusée: {previous_local_status} → {new_local_status}" ) new_local_status = resolve_status_conflict( previous_local_status, new_local_status ) - logger.info( - f"✅ Résolution conflit: statut résolu = {new_local_status}" - ) + logger.info(f"Résolution conflit: statut résolu = {new_local_status}") status_changed = previous_local_status != new_local_status @@ -395,7 +395,7 @@ class UniversignSyncService: universign_status_raw ) except ValueError: - logger.warning(f"⚠️ Statut Universign inconnu: {universign_status_raw}") + logger.warning(f"Statut Universign inconnu: {universign_status_raw}") # Fallback intelligent if new_local_status == "SIGNE": transaction.universign_status = ( @@ -408,7 +408,7 @@ class UniversignSyncService: else: transaction.universign_status = UniversignTransactionStatus.STARTED - # ✅ Mise à jour du statut local + # Mise à jour du statut local transaction.local_status = LocalDocumentStatus(new_local_status) transaction.universign_status_updated_at = datetime.now() @@ -419,11 +419,11 @@ class UniversignSyncService: if new_local_status == "SIGNE" and not transaction.signed_at: transaction.signed_at = datetime.now() - logger.info("✅ Date de signature mise à jour") + logger.info("Date de signature mise à jour") if new_local_status == "REFUSE" and not transaction.refused_at: transaction.refused_at = datetime.now() - logger.info("❌ Date de refus mise à jour") + logger.info("Date de refus mise à jour") if new_local_status == "EXPIRE" and not transaction.expired_at: transaction.expired_at = datetime.now() @@ -438,6 +438,23 @@ class UniversignSyncService: if first_doc.get("url"): transaction.document_url = first_doc["url"] + # NOUVEAU : Téléchargement automatique du document signé + if new_local_status == "SIGNE" and transaction.document_url: + if not transaction.signed_document_path: + logger.info("Déclenchement téléchargement document signé") + + ( + download_success, + download_error, + ) = await self.document_service.download_and_store_signed_document( + session=session, transaction=transaction, force=False + ) + + if download_success: + logger.info("Document signé téléchargé avec succès") + else: + logger.warning(f"Échec téléchargement : {download_error}") + # Synchroniser les signataires await self._sync_signers(session, transaction, universign_data) @@ -445,7 +462,7 @@ class UniversignSyncService: transaction.last_synced_at = datetime.now() transaction.sync_attempts += 1 transaction.needs_sync = not is_final_status(new_local_status) - transaction.sync_error = None # ✅ Effacer l'erreur précédente + transaction.sync_error = None # Effacer l'erreur précédente # Log de la tentative await self._log_sync_attempt( @@ -462,7 +479,7 @@ class UniversignSyncService: "universign_raw": universign_status_raw, "response_time_ms": result.get("response_time_ms"), }, - default=str, # ✅ Éviter les erreurs de sérialisation + default=str, # Éviter les erreurs de sérialisation ), ) @@ -476,7 +493,7 @@ class UniversignSyncService: ) logger.info( - f"✅ Sync terminée: {transaction.transaction_id} | " + f"Sync terminée: {transaction.transaction_id} | " f"{previous_local_status} → {new_local_status}" ) @@ -484,7 +501,7 @@ class UniversignSyncService: except Exception as e: error_msg = f"Erreur lors de la synchronisation: {str(e)}" - logger.error(f"❌ {error_msg}", exc_info=True) + logger.error(f"{error_msg}", exc_info=True) transaction.sync_error = error_msg[:1000] # Tronquer si trop long transaction.sync_attempts += 1