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

View file

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

View file

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

View file

@ -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", [])

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