diff --git a/api.py b/api.py index d37e7f0..a7e2c9c 100644 --- a/api.py +++ b/api.py @@ -2446,7 +2446,7 @@ async def creer_sortie_stock(sortie: SortieStock): if sortie_data.get("date_sortie"): sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat() - logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)") + logger.info(f"Création sortie stock: {len(sortie.lignes)} ligne(s)") resultat = sage_client.creer_sortie_stock(sortie_data) diff --git a/database/models/universign.py b/database/models/universign.py index e4ad3a3..8b34ba3 100644 --- a/database/models/universign.py +++ b/database/models/universign.py @@ -50,7 +50,7 @@ class LocalDocumentStatus(str, Enum): class SageDocumentType(int, Enum): DEVIS = 0 - BON_COMMANDE = 10 + BON_COMMANDE = 10 PREPARATION = 20 BON_LIVRAISON = 30 BON_RETOUR = 40 diff --git a/routes/universign.py b/routes/universign.py index 949c41e..20d7960 100644 --- a/routes/universign.py +++ b/routes/universign.py @@ -1,11 +1,10 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import false, select, func, or_, and_, true +from sqlalchemy import select, func, and_ from sqlalchemy.orm import selectinload from typing import List, Optional from datetime import datetime, timedelta -from pydantic import BaseModel, EmailStr import logging from data.data import templates_signature_email from email_queue import email_queue @@ -15,19 +14,21 @@ from database import ( UniversignSigner, UniversignSyncLog, 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 from utils.universign_status_mapping import get_status_message, map_universign_to_local - from database.models.email import EmailLog from database.enum.status import StatutEmail +from schemas import ( + SyncStatsResponse, + CreateSignatureRequest, + TransactionResponse, +) logger = logging.getLogger(__name__) @@ -38,60 +39,13 @@ sync_service = UniversignSyncService( ) -class CreateSignatureRequest(BaseModel): - """Demande de création d'une signature""" - - sage_document_id: str - sage_document_type: SageDocumentType - signer_email: EmailStr - signer_name: str - document_name: Optional[str] = None - - -class TransactionResponse(BaseModel): - """Réponse détaillée d'une transaction""" - - id: str - transaction_id: str - sage_document_id: str - sage_document_type: str - universign_status: str - local_status: str - local_status_label: str - signer_url: Optional[str] - document_url: Optional[str] - created_at: datetime - sent_at: Optional[datetime] - signed_at: Optional[datetime] - last_synced_at: Optional[datetime] - 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""" - - total_transactions: int - pending_sync: int - signed: int - in_progress: int - refused: int - expired: int - last_sync_at: Optional[datetime] - - @router.post("/signatures/create", response_model=TransactionResponse) async def create_signature( request: CreateSignatureRequest, session: AsyncSession = Depends(get_session) ): try: - # === VÉRIFICATION DOUBLON RENFORCÉE === logger.info( - f"🔍 Vérification doublon pour: {request.sage_document_id} " + f"Vérification doublon pour: {request.sage_document_id} " f"(type: {request.sage_document_type.name})" ) @@ -107,7 +61,6 @@ async def create_signature( f"{len(all_existing)} transaction(s) existante(s) trouvée(s)" ) - # Filtrer les transactions non-finales active_txs = [ tx for tx in all_existing @@ -137,8 +90,7 @@ async def create_signature( "Toutes les transactions existantes sont finales, création autorisée" ) - # Génération PDF - logger.info(f"📄 Génération PDF: {request.sage_document_id}") + logger.info(f"Génération PDF: {request.sage_document_id}") pdf_bytes = email_queue._generate_pdf( request.sage_document_id, normaliser_type_doc(request.sage_document_type) ) @@ -148,13 +100,12 @@ async def create_signature( logger.info(f"PDF généré: {len(pdf_bytes)} octets") - # === CRÉATION TRANSACTION UNIVERSIGN === import requests import uuid auth = (settings.universign_api_key, "") - logger.info("🔄 Création transaction Universign...") + logger.info("Création transaction Universign...") resp = requests.post( f"{settings.universign_api_url}/transactions", @@ -174,8 +125,7 @@ async def create_signature( universign_tx_id = resp.json().get("id") logger.info(f"Transaction Universign créée: {universign_tx_id}") - # Upload PDF - logger.info("📤 Upload PDF...") + logger.info("Upload PDF...") files = { "file": (f"{request.sage_document_id}.pdf", pdf_bytes, "application/pdf") } @@ -190,8 +140,7 @@ async def create_signature( file_id = resp.json().get("id") logger.info(f"PDF uploadé: {file_id}") - # Attachement document - logger.info("🔗 Attachement document...") + logger.info("Attachement document...") resp = requests.post( f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents", auth=auth, @@ -204,8 +153,7 @@ async def create_signature( document_id = resp.json().get("id") - # Création champ signature - logger.info("✍️ Création champ signature...") + logger.info("Création champ signature...") resp = requests.post( f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents/{document_id}/fields", auth=auth, @@ -218,8 +166,7 @@ async def create_signature( field_id = resp.json().get("id") - # Liaison signataire - logger.info(f"👤 Liaison signataire: {request.signer_email}") + logger.info(f"Liaison signataire: {request.signer_email}") resp = requests.post( f"{settings.universign_api_url}/transactions/{universign_tx_id}/signatures", auth=auth, @@ -230,8 +177,7 @@ async def create_signature( if resp.status_code not in [200, 201]: raise HTTPException(500, "Erreur liaison signataire") - # Démarrage transaction - logger.info("🚀 Démarrage transaction...") + logger.info("Démarrage transaction...") resp = requests.post( f"{settings.universign_api_url}/transactions/{universign_tx_id}/start", auth=auth, @@ -243,7 +189,6 @@ async def create_signature( final_data = resp.json() - # Extraction URL de signature signer_url = "" if final_data.get("actions"): for action in final_data["actions"]: @@ -256,12 +201,11 @@ async def create_signature( 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, sage_document_id=request.sage_document_id, sage_document_type=request.sage_document_type, universign_status=UniversignTransactionStatus.STARTED, @@ -291,10 +235,9 @@ async def create_signature( await session.commit() logger.info( - f"💾 Transaction sauvegardée: {local_id} (Universign: {universign_tx_id})" + f"Transaction sauvegardée: {local_id} (Universign: {universign_tx_id})" ) - # === ENVOI EMAIL AVEC TEMPLATE === template = templates_signature_email["demande_signature"] type_labels = { @@ -349,7 +292,6 @@ async def create_signature( email_queue.enqueue(email_log.id) - # === MISE À JOUR STATUT SAGE (Confirmé = 1) === try: from sage_client import sage_client @@ -364,7 +306,6 @@ async def create_signature( except Exception as e: logger.warning(f"Impossible de mettre à jour le statut Sage: {e}") - # === RÉPONSE === return TransactionResponse( id=transaction.id, transaction_id=transaction.transaction_id, @@ -444,7 +385,6 @@ 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() ), @@ -500,7 +440,6 @@ 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() ), @@ -566,17 +505,11 @@ async def webhook_universign( try: payload = await request.json() - # 📋 LOG COMPLET du payload pour débogage - logger.info( - f"📥 Webhook Universign reçu - Type: {payload.get('type', 'unknown')}" - ) + logger.info(f"Webhook Universign reçu - Type: {payload.get('type', 'unknown')}") logger.debug(f"Payload complet: {json.dumps(payload, indent=2)}") - # EXTRACTION CORRECTE DU TRANSACTION_ID transaction_id = None - # 🔍 Structure 1 : Événements avec payload imbriqué (la plus courante) - # Exemple : transaction.lifecycle.created, transaction.lifecycle.started, etc. if payload.get("type", "").startswith("transaction.") and "payload" in payload: # Le transaction_id est dans payload.object.id nested_object = payload.get("payload", {}).get("object", {}) @@ -586,9 +519,7 @@ async def webhook_universign( f"Transaction ID extrait de payload.object.id: {transaction_id}" ) - # 🔍 Structure 2 : Action événements (action.opened, action.completed) elif payload.get("type", "").startswith("action."): - # Le transaction_id est directement dans payload.object.transaction_id transaction_id = ( payload.get("payload", {}).get("object", {}).get("transaction_id") ) @@ -596,17 +527,14 @@ async def webhook_universign( 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}") - # 🔍 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}") - # Échec d'extraction if not transaction_id: logger.error( f"Transaction ID introuvable dans webhook\n" @@ -621,9 +549,8 @@ async def webhook_universign( "event_id": payload.get("id"), }, 400 - logger.info(f"🎯 Transaction ID identifié: {transaction_id}") + logger.info(f"Transaction ID identifié: {transaction_id}") - # Vérifier si la transaction existe localement query = select(UniversignTransaction).where( UniversignTransaction.transaction_id == transaction_id ) @@ -643,7 +570,6 @@ async def webhook_universign( "event_type": payload.get("type"), } - # Traiter le webhook success, error = await sync_service.process_webhook( session, payload, transaction_id ) @@ -656,7 +582,6 @@ async def webhook_universign( "transaction_id": transaction_id, }, 500 - # Succès logger.info( f"Webhook traité avec succès\n" f"Transaction: {transaction_id}\n" @@ -673,7 +598,7 @@ async def webhook_universign( } except Exception as e: - logger.error(f"💥 Erreur critique webhook: {e}", exc_info=True) + logger.error(f"Erreur critique webhook: {e}", exc_info=True) return {"status": "error", "message": str(e)}, 500 @@ -681,17 +606,14 @@ async def webhook_universign( async def get_sync_stats(session: AsyncSession = Depends(get_session)): """Statistiques globales de synchronisation""" - # Total total_query = select(func.count(UniversignTransaction.id)) total = (await session.execute(total_query)).scalar() - # En attente de sync pending_query = select(func.count(UniversignTransaction.id)).where( UniversignTransaction.needs_sync ) pending = (await session.execute(pending_query)).scalar() - # Par statut signed_query = select(func.count(UniversignTransaction.id)).where( UniversignTransaction.local_status == LocalDocumentStatus.SIGNED ) @@ -712,7 +634,6 @@ async def get_sync_stats(session: AsyncSession = Depends(get_session)): ) expired = (await session.execute(expired_query)).scalar() - # Dernière sync last_sync_query = select(func.max(UniversignTransaction.last_synced_at)) last_sync = (await session.execute(last_sync_query)).scalar() @@ -733,7 +654,6 @@ async def get_transaction_logs( limit: int = Query(50, le=500), session: AsyncSession = Depends(get_session), ): - # Trouver la transaction tx_query = select(UniversignTransaction).where( UniversignTransaction.transaction_id == transaction_id ) @@ -743,7 +663,6 @@ async def get_transaction_logs( if not tx: raise HTTPException(404, "Transaction introuvable") - # Logs logs_query = ( select(UniversignSyncLog) .where(UniversignSyncLog.transaction_id == tx.id) @@ -772,9 +691,6 @@ async def get_transaction_logs( } -# Ajouter ces routes dans universign.py - - @router.get("/documents/{sage_document_id}/signatures") async def get_signatures_for_document( sage_document_id: str, @@ -816,10 +732,6 @@ async def cleanup_duplicate_signatures( ), session: AsyncSession = Depends(get_session), ): - """ - Supprime les doublons de signatures pour un document. - Garde une seule transaction (la plus récente ou ancienne selon le paramètre). - """ query = ( select(UniversignTransaction) .where(UniversignTransaction.sage_document_id == sage_document_id) @@ -841,7 +753,6 @@ async def cleanup_duplicate_signatures( "deleted_count": 0, } - # Garder la première (selon l'ordre), supprimer les autres to_keep = transactions[0] to_delete = transactions[1:] @@ -901,13 +812,8 @@ async def delete_transaction( async def cleanup_all_duplicates( session: AsyncSession = Depends(get_session), ): - """ - Nettoie tous les doublons dans la base. - Pour chaque document avec plusieurs transactions, garde la plus récente non-erreur ou la plus récente. - """ from sqlalchemy import func - # Trouver les documents avec plusieurs transactions subquery = ( select( UniversignTransaction.sage_document_id, @@ -925,7 +831,6 @@ async def cleanup_all_duplicates( cleanup_details = [] for doc_id in duplicate_docs: - # Récupérer toutes les transactions pour ce document tx_query = ( select(UniversignTransaction) .where(UniversignTransaction.sage_document_id == doc_id) @@ -934,7 +839,6 @@ async def cleanup_all_duplicates( tx_result = await session.execute(tx_query) transactions = tx_result.scalars().all() - # Priorité: SIGNE > EN_COURS > EN_ATTENTE > autres priority = {"SIGNE": 0, "EN_COURS": 1, "EN_ATTENTE": 2} def sort_key(tx): @@ -972,115 +876,11 @@ async def cleanup_all_duplicates( } -@router.get("/admin/diagnostic", tags=["Admin"]) -async def diagnostic_complet(session: AsyncSession = Depends(get_session)): - """ - Diagnostic complet de l'état des transactions Universign - """ - try: - # Statistiques générales - total_query = select(func.count(UniversignTransaction.id)) - total = (await session.execute(total_query)).scalar() - - # Par statut local - statuts_query = select( - UniversignTransaction.local_status, func.count(UniversignTransaction.id) - ).group_by(UniversignTransaction.local_status) - statuts_result = await session.execute(statuts_query) - statuts = {status.value: count for status, count in statuts_result.all()} - - # Transactions sans sync récente - date_limite = datetime.now() - timedelta(hours=1) - sans_sync_query = select(func.count(UniversignTransaction.id)).where( - and_( - UniversignTransaction.needs_sync, - or_( - UniversignTransaction.last_synced_at < date_limite, - UniversignTransaction.last_synced_at.is_(None), - ), - ) - ) - sans_sync = (await session.execute(sans_sync_query)).scalar() - - # Doublons potentiels - doublons_query = ( - select( - UniversignTransaction.sage_document_id, - func.count(UniversignTransaction.id).label("count"), - ) - .group_by(UniversignTransaction.sage_document_id) - .having(func.count(UniversignTransaction.id) > 1) - ) - doublons_result = await session.execute(doublons_query) - doublons = doublons_result.fetchall() - - # Transactions avec erreurs de sync - erreurs_query = select(func.count(UniversignTransaction.id)).where( - UniversignTransaction.sync_error.isnot(None) - ) - erreurs = (await session.execute(erreurs_query)).scalar() - - # Transactions sans webhook reçu - sans_webhook_query = select(func.count(UniversignTransaction.id)).where( - and_( - not UniversignTransaction.webhook_received, - UniversignTransaction.local_status != LocalDocumentStatus.PENDING, - ) - ) - sans_webhook = (await session.execute(sans_webhook_query)).scalar() - - diagnostic = { - "timestamp": datetime.now().isoformat(), - "total_transactions": total, - "repartition_statuts": statuts, - "problemes_detectes": { - "sans_sync_recente": sans_sync, - "doublons_possibles": len(doublons), - "erreurs_sync": erreurs, - "sans_webhook": sans_webhook, - }, - "documents_avec_doublons": [ - {"document_id": doc_id, "nombre_transactions": count} - for doc_id, count in doublons - ], - "recommandations": [], - } - - # Recommandations - if sans_sync > 0: - diagnostic["recommandations"].append( - f"🔄 {sans_sync} transaction(s) à synchroniser. " - f"Utilisez POST /universign/sync/all" - ) - - if len(doublons) > 0: - diagnostic["recommandations"].append( - 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"Vérifiez les logs avec GET /universign/transactions?status=ERREUR" - ) - - return diagnostic - - except Exception as e: - logger.error(f"Erreur diagnostic: {e}") - raise HTTPException(500, str(e)) - - @router.post("/admin/force-sync-all", tags=["Admin"]) async def forcer_sync_toutes_transactions( max_transactions: int = Query(200, le=500), session: AsyncSession = Depends(get_session), ): - """ - Force la synchronisation de TOUTES les transactions (même finales) - À utiliser pour réparer les incohérences - """ try: query = ( select(UniversignTransaction) @@ -1105,7 +905,7 @@ async def forcer_sync_toutes_transactions( previous_status = transaction.local_status.value logger.info( - f"🔄 Force sync: {transaction.transaction_id} (statut: {previous_status})" + f"Force sync: {transaction.transaction_id} (statut: {previous_status})" ) success, error = await sync_service.sync_transaction( @@ -1154,9 +954,6 @@ async def forcer_sync_toutes_transactions( async def reparer_transaction( transaction_id: str, session: AsyncSession = Depends(get_session) ): - """ - Répare une transaction spécifique en la re-synchronisant depuis Universign - """ try: query = select(UniversignTransaction).where( UniversignTransaction.transaction_id == transaction_id @@ -1174,7 +971,6 @@ async def reparer_transaction( else None ) - # Force sync success, error = await sync_service.sync_transaction( session, transaction, force=True ) @@ -1211,11 +1007,7 @@ async def reparer_transaction( async def trouver_transactions_inconsistantes( session: AsyncSession = Depends(get_session), ): - """ - Trouve les transactions dont le statut local ne correspond pas au statut Universign - """ try: - # Toutes les transactions non-finales query = select(UniversignTransaction).where( UniversignTransaction.local_status.in_( [LocalDocumentStatus.PENDING, LocalDocumentStatus.IN_PROGRESS] @@ -1229,7 +1021,6 @@ async def trouver_transactions_inconsistantes( for tx in transactions: try: - # Récupérer le statut depuis Universign universign_data = sync_service.fetch_transaction_status( tx.transaction_id ) @@ -1298,9 +1089,6 @@ async def nettoyer_transactions_erreur( ), session: AsyncSession = Depends(get_session), ): - """ - Nettoie les transactions en erreur anciennes - """ try: date_limite = datetime.now() - timedelta(days=age_jours) @@ -1344,9 +1132,6 @@ async def nettoyer_transactions_erreur( async def voir_dernier_webhook( transaction_id: str, session: AsyncSession = Depends(get_session) ): - """ - Affiche le dernier payload webhook reçu pour une transaction - """ try: query = select(UniversignTransaction).where( UniversignTransaction.transaction_id == transaction_id @@ -1357,7 +1142,6 @@ async def voir_dernier_webhook( if not tx: raise HTTPException(404, "Transaction introuvable") - # Récupérer le dernier log de type webhook logs_query = ( select(UniversignSyncLog) .where( @@ -1407,9 +1191,6 @@ async def voir_dernier_webhook( 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 @@ -1430,7 +1211,6 @@ async def telecharger_document_signe( 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, @@ -1438,7 +1218,6 @@ async def telecharger_document_signe( "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}_" @@ -1503,218 +1282,3 @@ async def info_document_signe( 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, - ), - ) - - result = await session.execute(query) - transactions = result.scalars().all() - - logger.info(f"📥 {len(transactions)} document(s) à télécharger") - - document_service = UniversignDocumentService( - api_url=settings.universign_api_url, - 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_url=settings.universign_api_url, - 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)) - - -@router.get("/transactions/{transaction_id}/diagnose", tags=["Debug"]) -async def diagnose_transaction( - transaction_id: str, session: AsyncSession = Depends(get_session) -): - """ - Diagnostic complet d'une transaction Universign - Utile pour débugger les problèmes de récupération de documents - """ - from services.universign_document import UniversignDocumentService - - try: - # Récupérer la transaction locale - query = select(UniversignTransaction).where( - UniversignTransaction.transaction_id == transaction_id - ) - result = await session.execute(query) - transaction = result.scalar_one_or_none() - - local_info = None - if transaction: - local_info = { - "id": transaction.id, - "sage_document_id": transaction.sage_document_id, - "local_status": transaction.local_status.value, - "document_url": transaction.document_url, - "signed_document_path": transaction.signed_document_path, - "download_attempts": transaction.download_attempts, - "download_error": transaction.download_error, - } - - # Diagnostic API Universign - document_service = UniversignDocumentService( - api_url=settings.universign_api_url, - api_key=settings.universign_api_key, - timeout=30, - ) - - api_diagnosis = document_service.diagnose_transaction(transaction_id) - - return { - "transaction_id": transaction_id, - "local_data": local_info, - "api_diagnosis": api_diagnosis, - "recommendations": _generate_recommendations(local_info, api_diagnosis), - } - - except Exception as e: - logger.error(f"Erreur diagnostic: {e}", exc_info=True) - raise HTTPException(500, str(e)) - - -def _generate_recommendations(local_info, api_diagnosis): - """Génère des recommandations basées sur le diagnostic""" - recommendations = [] - - if not local_info: - recommendations.append( - "Transaction introuvable localement. Vérifiez le transaction_id." - ) - return recommendations - - if not api_diagnosis.get("success"): - recommendations.append( - f"Erreur API Universign: {api_diagnosis.get('error')}. " - f"Vérifiez la connectivité et les credentials." - ) - return recommendations - - state = api_diagnosis.get("checks", {}).get("transaction_data", {}).get("state") - - if state not in ["completed", "closed"]: - recommendations.append( - f"La transaction n'est pas encore signée (state={state}). " - f"Attendez que le signataire complète la signature." - ) - - docs = api_diagnosis.get("checks", {}).get("documents", []) - if not docs: - recommendations.append("Aucun document trouvé dans la transaction Universign.") - else: - for doc in docs: - dl_check = doc.get("download_check", {}) - if not dl_check.get("accessible"): - recommendations.append( - f"Document {doc.get('id')} non accessible: " - f"status_code={dl_check.get('status_code')}. " - f"Vérifiez que la signature est complète." - ) - - if local_info.get("download_error"): - recommendations.append( - f"Dernière erreur de téléchargement: {local_info['download_error']}" - ) - - if not recommendations: - recommendations.append( - "Tout semble correct. Essayez POST /admin/download-missing-documents " - "avec force_redownload=true" - ) - - return recommendations diff --git a/schemas/__init__.py b/schemas/__init__.py index ececf1d..aa73aaa 100644 --- a/schemas/__init__.py +++ b/schemas/__init__.py @@ -26,7 +26,13 @@ from schemas.documents.documents import TypeDocument, TypeDocumentSQL from schemas.documents.email import StatutEmail, EmailEnvoi from schemas.documents.factures import FactureCreate, FactureUpdate from schemas.documents.livraisons import LivraisonCreate, LivraisonUpdate -from schemas.documents.universign import Signature, StatutSignature +from schemas.documents.universign import ( + Signature, + StatutSignature, + SyncStatsResponse, + CreateSignatureRequest, + TransactionResponse, +) from schemas.articles.articles import ( ArticleCreate, Article, @@ -105,4 +111,7 @@ __all__ = [ "SageGatewayTest", "SageGatewayStatsResponse", "CurrentGatewayInfo", + "SyncStatsResponse", + "CreateSignatureRequest", + "TransactionResponse", ] diff --git a/schemas/documents/universign.py b/schemas/documents/universign.py index ba866ac..206e7d9 100644 --- a/schemas/documents/universign.py +++ b/schemas/documents/universign.py @@ -1,6 +1,12 @@ from pydantic import BaseModel, EmailStr from enum import Enum from schemas.documents.documents import TypeDocument +from database import ( + SageDocumentType, +) + +from typing import List, Optional +from datetime import datetime class StatutSignature(str, Enum): @@ -16,3 +22,49 @@ class Signature(BaseModel): type_doc: TypeDocument email_signataire: EmailStr nom_signataire: str + + +class CreateSignatureRequest(BaseModel): + """Demande de création d'une signature""" + + sage_document_id: str + sage_document_type: SageDocumentType + signer_email: EmailStr + signer_name: str + document_name: Optional[str] = None + + +class TransactionResponse(BaseModel): + """Réponse détaillée d'une transaction""" + + id: str + transaction_id: str + sage_document_id: str + sage_document_type: str + universign_status: str + local_status: str + local_status_label: str + signer_url: Optional[str] + document_url: Optional[str] + created_at: datetime + sent_at: Optional[datetime] + signed_at: Optional[datetime] + last_synced_at: Optional[datetime] + 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""" + + total_transactions: int + pending_sync: int + signed: int + in_progress: int + refused: int + expired: int + last_sync_at: Optional[datetime] diff --git a/services/universign_document.py b/services/universign_document.py index b1d5052..60e46d3 100644 --- a/services/universign_document.py +++ b/services/universign_document.py @@ -76,7 +76,7 @@ class UniversignDocumentService: f"/documents/{document_id}/download" ) - logger.info(f"📥 Téléchargement depuis: {download_url}") + logger.info(f"Téléchargement depuis: {download_url}") response = requests.get( download_url, @@ -155,7 +155,7 @@ class UniversignDocumentService: try: # ÉTAPE 1: Récupérer les documents de la transaction logger.info( - f"🔄 Récupération document signé pour: {transaction.transaction_id}" + f"Récupération document signé pour: {transaction.transaction_id}" ) documents = self.fetch_transaction_documents(transaction.transaction_id) @@ -177,7 +177,7 @@ class UniversignDocumentService: if doc_status in ["signed", "completed", "closed"]: document_id = doc_id logger.info( - f"📄 Document signé trouvé: {doc_id} (status: {doc_status})" + f"Document signé trouvé: {doc_id} (status: {doc_status})" ) break @@ -309,7 +309,7 @@ class UniversignDocumentService: try: # Test 1: Récupération de la transaction - logger.info(f"🔍 Diagnostic transaction: {transaction_id}") + logger.info(f"Diagnostic transaction: {transaction_id}") response = requests.get( f"{self.api_url}/transactions/{transaction_id}", diff --git a/services/universign_sync.py b/services/universign_sync.py index 2a4a360..2390023 100644 --- a/services/universign_sync.py +++ b/services/universign_sync.py @@ -341,7 +341,7 @@ class UniversignSyncService: return True, None # Récupération du statut distant - logger.info(f"🔄 Synchronisation: {transaction.transaction_id}") + logger.info(f"Synchronisation: {transaction.transaction_id}") result = self.fetch_transaction_status(transaction.transaction_id) @@ -365,7 +365,7 @@ class UniversignSyncService: previous_local_status = transaction.local_status.value logger.info( - f"🔄 Mapping: {universign_status_raw} (Universign) → " + f"Mapping: {universign_status_raw} (Universign) → " f"{new_local_status} (Local) | Actuel: {previous_local_status}" ) @@ -433,13 +433,13 @@ class UniversignSyncService: if documents: first_doc = documents[0] logger.info( - f"📄 Document Universign trouvé: id={first_doc.get('id')}, " + f"Document Universign trouvé: id={first_doc.get('id')}, " f"status={first_doc.get('status')}" ) # Téléchargement automatique du document signé if new_local_status == "SIGNE" and not transaction.signed_document_path: - logger.info("📥 Déclenchement téléchargement document signé...") + logger.info("Déclenchement téléchargement document signé...") try: ( @@ -537,7 +537,7 @@ class UniversignSyncService: transaction.universign_document_id = first_doc_id logger.info( - f"📄 Document Universign: id={first_doc_id}, " + f"Document Universign: id={first_doc_id}, " f"name={first_doc.get('name')}, status={first_doc.get('status')}" ) else: @@ -546,7 +546,7 @@ class UniversignSyncService: # Téléchargement automatique si signé if new_local_status == "SIGNE": if not transaction.signed_document_path: - logger.info("📥 Déclenchement téléchargement document signé...") + logger.info("Déclenchement téléchargement document signé...") try: (