feat(universign): add signed document storage and download functionality

This commit is contained in:
Fanilo-Nantenaina 2026-01-07 20:01:55 +03:00
parent 0be28f6744
commit d8ec61802d
6 changed files with 489 additions and 59 deletions

20
api.py
View file

@ -391,7 +391,7 @@ async def creer_devis(devis: DevisRequest):
resultat = sage_client.creer_devis(devis_data) resultat = sage_client.creer_devis(devis_data)
logger.info( logger.info(
f"Devis créé: {resultat.get('numero_devis')} " f"Devis créé: {resultat.get('numero_devis')} "
f"({resultat.get('total_ttc')}€ TTC)" f"({resultat.get('total_ttc')}€ TTC)"
) )
@ -405,7 +405,7 @@ async def creer_devis(devis: DevisRequest):
) )
except Exception as e: 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)) raise HTTPException(500, str(e))
@ -474,7 +474,7 @@ async def creer_commande(
resultat = sage_client.creer_commande(commande_data) resultat = sage_client.creer_commande(commande_data)
logger.info( logger.info(
f"Commande créée: {resultat.get('numero_commande')} " f"Commande créée: {resultat.get('numero_commande')} "
f"({resultat.get('total_ttc')}€ TTC)" f"({resultat.get('total_ttc')}€ TTC)"
) )
@ -496,7 +496,7 @@ async def creer_commande(
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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)) raise HTTPException(500, str(e))
@ -1420,7 +1420,7 @@ async def creer_facture(
resultat = sage_client.creer_facture(facture_data) resultat = sage_client.creer_facture(facture_data)
logger.info( logger.info(
f"Facture créée: {resultat.get('numero_facture')} " f"Facture créée: {resultat.get('numero_facture')} "
f"({resultat.get('total_ttc')}€ TTC)" f"({resultat.get('total_ttc')}€ TTC)"
) )
@ -1442,7 +1442,7 @@ async def creer_facture(
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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)) 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) resultat = sage_client.creer_avoir(avoir_data)
logger.info( logger.info(
f"Avoir créé: {resultat.get('numero_avoir')} " f"Avoir créé: {resultat.get('numero_avoir')} "
f"({resultat.get('total_ttc')}€ TTC)" f"({resultat.get('total_ttc')}€ TTC)"
) )
@ -1965,7 +1965,7 @@ async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_se
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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)) raise HTTPException(500, str(e))
@ -2070,7 +2070,7 @@ async def creer_livraison(
resultat = sage_client.creer_livraison(livraison_data) resultat = sage_client.creer_livraison(livraison_data)
logger.info( logger.info(
f"Livraison créée: {resultat.get('numero_livraison')} " f"Livraison créée: {resultat.get('numero_livraison')} "
f"({resultat.get('total_ttc')}€ TTC)" f"({resultat.get('total_ttc')}€ TTC)"
) )
@ -2092,7 +2092,7 @@ async def creer_livraison(
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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)) raise HTTPException(500, str(e))

View file

@ -106,6 +106,23 @@ class UniversignTransaction(Base):
# === URLS ET MÉTADONNÉES UNIVERSIGN === # === URLS ET MÉTADONNÉES UNIVERSIGN ===
signer_url = Column(Text, nullable=True, comment="URL de signature") signer_url = Column(Text, nullable=True, comment="URL de signature")
document_url = Column(Text, nullable=True, comment="URL du document signé") 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") certificate_url = Column(Text, nullable=True, comment="URL du certificat")
# === SIGNATAIRES === # === SIGNATAIRES ===
@ -268,7 +285,7 @@ class UniversignConfig(Base):
) )
api_url = Column(String(500), nullable=False) 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 === # === OPTIONS ===
webhook_url = Column(String(500), nullable=True) webhook_url = Column(String(500), nullable=True)

View file

@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession 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 sqlalchemy.orm import selectinload
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -16,7 +17,10 @@ from database import (
LocalDocumentStatus, LocalDocumentStatus,
SageDocumentType, SageDocumentType,
) )
import os
from pathlib import Path
import json import json
from services.universign_document import UniversignDocumentService
from services.universign_sync import UniversignSyncService from services.universign_sync import UniversignSyncService
from config.config import settings from config.config import settings
from utils.generic_functions import normaliser_type_doc from utils.generic_functions import normaliser_type_doc
@ -63,6 +67,10 @@ class TransactionResponse(BaseModel):
needs_sync: bool needs_sync: bool
signers: List[dict] 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): class SyncStatsResponse(BaseModel):
"""Statistiques de synchronisation""" """Statistiques de synchronisation"""
@ -96,7 +104,7 @@ async def create_signature(
if all_existing: if all_existing:
logger.warning( 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 # Filtrer les transactions non-finales
@ -115,7 +123,7 @@ async def create_signature(
if active_txs: if active_txs:
active_tx = active_txs[0] active_tx = active_txs[0]
logger.error( 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})" f"(statut: {active_tx.local_status.value})"
) )
raise HTTPException( raise HTTPException(
@ -126,7 +134,7 @@ async def create_signature(
) )
logger.info( 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 # Génération PDF
@ -138,7 +146,7 @@ async def create_signature(
if not pdf_bytes: if not pdf_bytes:
raise HTTPException(400, "Échec génération PDF") 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 === # === CRÉATION TRANSACTION UNIVERSIGN ===
import requests import requests
@ -160,11 +168,11 @@ async def create_signature(
) )
if resp.status_code != 200: 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}") raise HTTPException(500, f"Erreur Universign: {resp.status_code}")
universign_tx_id = resp.json().get("id") 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 # Upload PDF
logger.info("📤 Upload PDF...") logger.info("📤 Upload PDF...")
@ -176,11 +184,11 @@ async def create_signature(
) )
if resp.status_code not in [200, 201]: 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") raise HTTPException(500, "Erreur upload PDF")
file_id = resp.json().get("id") file_id = resp.json().get("id")
logger.info(f"PDF uploadé: {file_id}") logger.info(f"PDF uploadé: {file_id}")
# Attachement document # Attachement document
logger.info("🔗 Attachement document...") logger.info("🔗 Attachement document...")
@ -246,14 +254,14 @@ async def create_signature(
if not signer_url: if not signer_url:
raise HTTPException(500, "URL de signature non retournée") raise HTTPException(500, "URL de signature non retournée")
logger.info("URL de signature obtenue") logger.info("URL de signature obtenue")
# === ENREGISTREMENT LOCAL === # === ENREGISTREMENT LOCAL ===
local_id = str(uuid.uuid4()) local_id = str(uuid.uuid4())
transaction = UniversignTransaction( transaction = UniversignTransaction(
id=local_id, 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_id=request.sage_document_id,
sage_document_type=request.sage_document_type, sage_document_type=request.sage_document_type,
universign_status=UniversignTransactionStatus.STARTED, universign_status=UniversignTransactionStatus.STARTED,
@ -436,6 +444,16 @@ async def list_transactions(
} }
for s in tx.signers 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 for tx in transactions
] ]
@ -482,6 +500,16 @@ async def get_transaction(
} }
for s in tx.signers 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)}") logger.debug(f"Payload complet: {json.dumps(payload, indent=2)}")
# EXTRACTION CORRECTE DU TRANSACTION_ID # EXTRACTION CORRECTE DU TRANSACTION_ID
transaction_id = None transaction_id = None
# 🔍 Structure 1 : Événements avec payload imbriqué (la plus courante) # 🔍 Structure 1 : Événements avec payload imbriqué (la plus courante)
@ -555,7 +583,7 @@ async def webhook_universign(
if nested_object.get("object") == "transaction": if nested_object.get("object") == "transaction":
transaction_id = nested_object.get("id") transaction_id = nested_object.get("id")
logger.info( 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) # 🔍 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") payload.get("payload", {}).get("object", {}).get("transaction_id")
) )
logger.info( 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) # 🔍 Structure 3 : Transaction directe (fallback)
elif payload.get("object") == "transaction": elif payload.get("object") == "transaction":
transaction_id = payload.get("id") 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é) # 🔍 Structure 4 : Ancien format (pour rétro-compatibilité)
elif "transaction" in payload: elif "transaction" in payload:
transaction_id = payload.get("transaction", {}).get("id") transaction_id = payload.get("transaction", {}).get("id")
logger.info( logger.info(f"Transaction ID extrait de transaction.id: {transaction_id}")
f"✅ Transaction ID extrait de transaction.id: {transaction_id}"
)
# Échec d'extraction # Échec d'extraction
if not transaction_id: if not transaction_id:
logger.error( 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"Type d'événement: {payload.get('type', 'unknown')}\n"
f"Clés racine: {list(payload.keys())}\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()})}" 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: if not tx:
logger.warning( 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"Type d'événement: {payload.get('type')}\n"
f"Elle sera synchronisée au prochain polling" f"Elle sera synchronisée au prochain polling"
) )
@ -623,16 +649,16 @@ async def webhook_universign(
) )
if not success: if not success:
logger.error(f"Erreur traitement webhook: {error}") logger.error(f"Erreur traitement webhook: {error}")
return { return {
"status": "error", "status": "error",
"message": error, "message": error,
"transaction_id": transaction_id, "transaction_id": transaction_id,
}, 500 }, 500
# Succès # Succès
logger.info( logger.info(
f"Webhook traité avec succès\n" f"Webhook traité avec succès\n"
f"Transaction: {transaction_id}\n" f"Transaction: {transaction_id}\n"
f"Nouveau statut: {tx.local_status.value if tx else 'unknown'}\n" f"Nouveau statut: {tx.local_status.value if tx else 'unknown'}\n"
f"Type d'événement: {payload.get('type')}" 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) date_limite = datetime.now() - timedelta(hours=1)
sans_sync_query = select(func.count(UniversignTransaction.id)).where( sans_sync_query = select(func.count(UniversignTransaction.id)).where(
and_( and_(
UniversignTransaction.needs_sync == True, UniversignTransaction.needs_sync.is_(true()),
or_( or_(
UniversignTransaction.last_synced_at < date_limite, UniversignTransaction.last_synced_at < date_limite,
UniversignTransaction.last_synced_at.is_(None), UniversignTransaction.last_synced_at.is_(None),
@ -997,7 +1023,7 @@ async def diagnostic_complet(session: AsyncSession = Depends(get_session)):
# Transactions sans webhook reçu # Transactions sans webhook reçu
sans_webhook_query = select(func.count(UniversignTransaction.id)).where( sans_webhook_query = select(func.count(UniversignTransaction.id)).where(
and_( and_(
UniversignTransaction.webhook_received == False, UniversignTransaction.webhook_received.is_(false()),
UniversignTransaction.local_status != LocalDocumentStatus.PENDING, UniversignTransaction.local_status != LocalDocumentStatus.PENDING,
) )
) )
@ -1029,13 +1055,13 @@ async def diagnostic_complet(session: AsyncSession = Depends(get_session)):
if len(doublons) > 0: if len(doublons) > 0:
diagnostic["recommandations"].append( diagnostic["recommandations"].append(
f"⚠️ {len(doublons)} document(s) avec doublons. " f"{len(doublons)} document(s) avec doublons. "
f"Utilisez POST /universign/cleanup/all-duplicates" f"Utilisez POST /universign/cleanup/all-duplicates"
) )
if erreurs > 0: if erreurs > 0:
diagnostic["recommandations"].append( 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" f"Vérifiez les logs avec GET /universign/transactions?status=ERREUR"
) )
@ -1373,3 +1399,217 @@ async def voir_dernier_webhook(
except Exception as e: except Exception as e:
logger.error(f"Erreur debug webhook: {e}") logger.error(f"Erreur debug webhook: {e}")
raise HTTPException(500, str(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))

View file

@ -408,7 +408,7 @@ class SageGatewayClient:
return self._post( return self._post(
"/sage/collaborateurs/list", "/sage/collaborateurs/list",
{ {
"filtre": filtre or "", # ⚠️ Convertir None en "" "filtre": filtre or "", # Convertir None en ""
"actifs_seulement": actifs_seulement, "actifs_seulement": actifs_seulement,
}, },
).get("data", []) ).get("data", [])

View file

@ -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)

View file

@ -19,6 +19,7 @@ from database import (
StatutEmail, StatutEmail,
) )
from data.data import templates_signature_email from data.data import templates_signature_email
from services.universign_document import UniversignDocumentService
from utils.universign_status_mapping import ( from utils.universign_status_mapping import (
map_universign_to_local, map_universign_to_local,
is_transition_allowed, is_transition_allowed,
@ -39,6 +40,7 @@ class UniversignSyncService:
self.sage_client = None self.sage_client = None
self.email_queue = None self.email_queue = None
self.settings = None self.settings = None
self.document_service = UniversignDocumentService(api_key=api_key, timeout=60)
def configure(self, sage_client, email_queue, settings): def configure(self, sage_client, email_queue, settings):
self.sage_client = sage_client self.sage_client = sage_client
@ -201,7 +203,7 @@ class UniversignSyncService:
transaction = result.scalar_one_or_none() transaction = result.scalar_one_or_none()
if not transaction: if not transaction:
logger.warning(f"⚠️ Transaction {transaction_id} inconnue localement") logger.warning(f"Transaction {transaction_id} inconnue localement")
return False, "Transaction inconnue" return False, "Transaction inconnue"
# Marquer comme webhook reçu # Marquer comme webhook reçu
@ -218,7 +220,7 @@ class UniversignSyncService:
# Log du changement de statut # Log du changement de statut
if success and transaction.local_status.value != old_status: if success and transaction.local_status.value != old_status:
logger.info( logger.info(
f"Webhook traité: {transaction_id} | " f"Webhook traité: {transaction_id} | "
f"{old_status}{transaction.local_status.value}" f"{old_status}{transaction.local_status.value}"
) )
@ -233,7 +235,7 @@ class UniversignSyncService:
new_status=transaction.local_status.value, new_status=transaction.local_status.value,
changes=json.dumps( changes=json.dumps(
payload, default=str payload, default=str
), # Ajout default=str pour éviter les erreurs JSON ), # Ajout default=str pour éviter les erreurs JSON
) )
await session.commit() await session.commit()
@ -267,7 +269,7 @@ class UniversignSyncService:
logger.warning(f"Signataire sans email à l'index {idx}, ignoré") logger.warning(f"Signataire sans email à l'index {idx}, ignoré")
continue continue
# PROTECTION : gérer les statuts inconnus # PROTECTION : gérer les statuts inconnus
raw_status = signer_data.get("status") or signer_data.get( raw_status = signer_data.get("status") or signer_data.get(
"state", "waiting" "state", "waiting"
) )
@ -298,7 +300,7 @@ class UniversignSyncService:
if signer_data.get("name") and not signer.name: if signer_data.get("name") and not signer.name:
signer.name = signer_data.get("name") signer.name = signer_data.get("name")
else: else:
# Nouveau signer avec gestion d'erreur intégrée # Nouveau signer avec gestion d'erreur intégrée
try: try:
signer = UniversignSigner( signer = UniversignSigner(
id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}", id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}",
@ -345,9 +347,9 @@ class UniversignSyncService:
if not result: if not result:
error = "Échec récupération données Universign" 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_attempts += 1
transaction.sync_error = error transaction.sync_error = error
@ -373,14 +375,12 @@ class UniversignSyncService:
# Vérifier la transition # Vérifier la transition
if not is_transition_allowed(previous_local_status, new_local_status): if not is_transition_allowed(previous_local_status, new_local_status):
logger.warning( 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( new_local_status = resolve_status_conflict(
previous_local_status, new_local_status previous_local_status, new_local_status
) )
logger.info( logger.info(f"Résolution conflit: statut résolu = {new_local_status}")
f"✅ Résolution conflit: statut résolu = {new_local_status}"
)
status_changed = previous_local_status != new_local_status status_changed = previous_local_status != new_local_status
@ -395,7 +395,7 @@ class UniversignSyncService:
universign_status_raw universign_status_raw
) )
except ValueError: except ValueError:
logger.warning(f"⚠️ Statut Universign inconnu: {universign_status_raw}") logger.warning(f"Statut Universign inconnu: {universign_status_raw}")
# Fallback intelligent # Fallback intelligent
if new_local_status == "SIGNE": if new_local_status == "SIGNE":
transaction.universign_status = ( transaction.universign_status = (
@ -408,7 +408,7 @@ class UniversignSyncService:
else: else:
transaction.universign_status = UniversignTransactionStatus.STARTED transaction.universign_status = UniversignTransactionStatus.STARTED
# Mise à jour du statut local # Mise à jour du statut local
transaction.local_status = LocalDocumentStatus(new_local_status) transaction.local_status = LocalDocumentStatus(new_local_status)
transaction.universign_status_updated_at = datetime.now() transaction.universign_status_updated_at = datetime.now()
@ -419,11 +419,11 @@ class UniversignSyncService:
if new_local_status == "SIGNE" and not transaction.signed_at: if new_local_status == "SIGNE" and not transaction.signed_at:
transaction.signed_at = datetime.now() 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: if new_local_status == "REFUSE" and not transaction.refused_at:
transaction.refused_at = datetime.now() 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: if new_local_status == "EXPIRE" and not transaction.expired_at:
transaction.expired_at = datetime.now() transaction.expired_at = datetime.now()
@ -438,6 +438,23 @@ class UniversignSyncService:
if first_doc.get("url"): if first_doc.get("url"):
transaction.document_url = first_doc["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 # Synchroniser les signataires
await self._sync_signers(session, transaction, universign_data) await self._sync_signers(session, transaction, universign_data)
@ -445,7 +462,7 @@ class UniversignSyncService:
transaction.last_synced_at = datetime.now() transaction.last_synced_at = datetime.now()
transaction.sync_attempts += 1 transaction.sync_attempts += 1
transaction.needs_sync = not is_final_status(new_local_status) 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 # Log de la tentative
await self._log_sync_attempt( await self._log_sync_attempt(
@ -462,7 +479,7 @@ class UniversignSyncService:
"universign_raw": universign_status_raw, "universign_raw": universign_status_raw,
"response_time_ms": result.get("response_time_ms"), "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( logger.info(
f"Sync terminée: {transaction.transaction_id} | " f"Sync terminée: {transaction.transaction_id} | "
f"{previous_local_status}{new_local_status}" f"{previous_local_status}{new_local_status}"
) )
@ -484,7 +501,7 @@ class UniversignSyncService:
except Exception as e: except Exception as e:
error_msg = f"Erreur lors de la synchronisation: {str(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_error = error_msg[:1000] # Tronquer si trop long
transaction.sync_attempts += 1 transaction.sync_attempts += 1