feat(universign): add transaction management and status synchronization

This commit is contained in:
Fanilo-Nantenaina 2026-01-06 19:15:35 +03:00
parent 92a2b95cbb
commit a3f02cbd91
5 changed files with 409 additions and 113 deletions

9
api.py
View file

@ -129,14 +129,19 @@ async def lifespan(app: FastAPI):
api_url=settings.universign_api_url, api_key=settings.universign_api_key api_url=settings.universign_api_url, api_key=settings.universign_api_key
) )
# Configuration du service avec les dépendances
sync_service.configure(
sage_client=sage_client, email_queue=email_queue, settings=settings
)
scheduler = UniversignSyncScheduler( scheduler = UniversignSyncScheduler(
sync_service=sync_service, sync_service=sync_service,
interval_minutes=5, # Synchronisation toutes les 5 minutes interval_minutes=5,
) )
sync_task = asyncio.create_task(scheduler.start(async_session_factory)) sync_task = asyncio.create_task(scheduler.start(async_session_factory))
logger.info("Synchronisation Universign démarrée (5min)") logger.info("Synchronisation Universign démarrée (5min)")
yield yield

View file

@ -1,12 +1,3 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Script de création du premier utilisateur administrateur
Usage:
python create_admin.py
"""
import asyncio import asyncio
import sys import sys
from pathlib import Path from pathlib import Path

View file

@ -80,6 +80,29 @@ async def create_signature(
request: CreateSignatureRequest, session: AsyncSession = Depends(get_session) request: CreateSignatureRequest, session: AsyncSession = Depends(get_session)
): ):
try: try:
# === VÉRIFICATION DOUBLON ===
existing_query = select(UniversignTransaction).where(
UniversignTransaction.sage_document_id == request.sage_document_id,
UniversignTransaction.sage_document_type == request.sage_document_type,
~UniversignTransaction.local_status.in_(
[
LocalDocumentStatus.SIGNED,
LocalDocumentStatus.REJECTED,
LocalDocumentStatus.EXPIRED,
LocalDocumentStatus.ERROR,
]
),
)
existing_result = await session.execute(existing_query)
existing_tx = existing_result.scalar_one_or_none()
if existing_tx:
raise HTTPException(
400,
f"Une demande de signature est déjà en cours pour {request.sage_document_id} "
f"(transaction: {existing_tx.transaction_id}, statut: {existing_tx.local_status.value})",
)
pdf_bytes = email_queue._generate_pdf( pdf_bytes = email_queue._generate_pdf(
request.sage_document_id, normaliser_type_doc(request.sage_document_type) request.sage_document_id, normaliser_type_doc(request.sage_document_type)
) )
@ -265,6 +288,21 @@ async def create_signature(
email_queue.enqueue(email_log.id) email_queue.enqueue(email_log.id)
# === MISE À JOUR STATUT SAGE (Confirmé = 1) ===
try:
from sage_client import sage_client
sage_client.changer_statut_document(
document_type_code=request.sage_document_type.value,
numero=request.sage_document_id,
nouveau_statut=1,
)
logger.info(
f"Statut Sage mis à jour: {request.sage_document_id} → Confirmé (1)"
)
except Exception as e:
logger.warning(f"Impossible de mettre à jour le statut Sage: {e}")
# === RÉPONSE === # === RÉPONSE ===
return TransactionResponse( return TransactionResponse(
id=transaction.id, id=transaction.id,
@ -558,3 +596,203 @@ async def get_transaction_logs(
for log in logs for log in logs
], ],
} }
# Ajouter ces routes dans universign.py
@router.get("/documents/{sage_document_id}/signatures")
async def get_signatures_for_document(
sage_document_id: str,
session: AsyncSession = Depends(get_session),
):
"""Liste toutes les transactions de signature pour un document Sage"""
query = (
select(UniversignTransaction)
.options(selectinload(UniversignTransaction.signers))
.where(UniversignTransaction.sage_document_id == sage_document_id)
.order_by(UniversignTransaction.created_at.desc())
)
result = await session.execute(query)
transactions = result.scalars().all()
return [
{
"id": tx.id,
"transaction_id": tx.transaction_id,
"local_status": tx.local_status.value,
"universign_status": tx.universign_status.value
if tx.universign_status
else None,
"created_at": tx.created_at.isoformat(),
"signed_at": tx.signed_at.isoformat() if tx.signed_at else None,
"signer_url": tx.signer_url,
"signers_count": len(tx.signers),
}
for tx in transactions
]
@router.delete("/documents/{sage_document_id}/duplicates")
async def cleanup_duplicate_signatures(
sage_document_id: str,
keep_latest: bool = Query(
True, description="Garder la plus récente (True) ou la plus ancienne (False)"
),
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)
.order_by(
UniversignTransaction.created_at.desc()
if keep_latest
else UniversignTransaction.created_at.asc()
)
)
result = await session.execute(query)
transactions = result.scalars().all()
if len(transactions) <= 1:
return {
"success": True,
"message": "Aucun doublon trouvé",
"kept": transactions[0].transaction_id if transactions else None,
"deleted_count": 0,
}
# Garder la première (selon l'ordre), supprimer les autres
to_keep = transactions[0]
to_delete = transactions[1:]
deleted_ids = []
for tx in to_delete:
deleted_ids.append(tx.transaction_id)
await session.delete(tx)
await session.commit()
logger.info(
f"Nettoyage doublons {sage_document_id}: gardé {to_keep.transaction_id}, supprimé {deleted_ids}"
)
return {
"success": True,
"document_id": sage_document_id,
"kept": {
"id": to_keep.id,
"transaction_id": to_keep.transaction_id,
"status": to_keep.local_status.value,
"created_at": to_keep.created_at.isoformat(),
},
"deleted_count": len(deleted_ids),
"deleted_transaction_ids": deleted_ids,
}
@router.delete("/transactions/{transaction_id}")
async def delete_transaction(
transaction_id: str,
session: AsyncSession = Depends(get_session),
):
"""Supprime une transaction spécifique par son ID Universign"""
query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id
)
result = await session.execute(query)
tx = result.scalar_one_or_none()
if not tx:
raise HTTPException(404, f"Transaction {transaction_id} introuvable")
await session.delete(tx)
await session.commit()
logger.info(f"Transaction {transaction_id} supprimée")
return {
"success": True,
"deleted_transaction_id": transaction_id,
"document_id": tx.sage_document_id,
}
@router.post("/cleanup/all-duplicates")
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,
func.count(UniversignTransaction.id).label("count"),
)
.group_by(UniversignTransaction.sage_document_id)
.having(func.count(UniversignTransaction.id) > 1)
).subquery()
duplicates_query = select(subquery.c.sage_document_id)
duplicates_result = await session.execute(duplicates_query)
duplicate_docs = [row[0] for row in duplicates_result.fetchall()]
total_deleted = 0
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)
.order_by(UniversignTransaction.created_at.desc())
)
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):
status_priority = priority.get(tx.local_status.value, 99)
return (status_priority, -tx.created_at.timestamp())
sorted_txs = sorted(transactions, key=sort_key)
to_keep = sorted_txs[0]
to_delete = sorted_txs[1:]
for tx in to_delete:
await session.delete(tx)
total_deleted += 1
cleanup_details.append(
{
"document_id": doc_id,
"kept": to_keep.transaction_id,
"kept_status": to_keep.local_status.value,
"deleted_count": len(to_delete),
}
)
await session.commit()
logger.info(
f"Nettoyage global: {total_deleted} doublons supprimés sur {len(duplicate_docs)} documents"
)
return {
"success": True,
"documents_processed": len(duplicate_docs),
"total_deleted": total_deleted,
"details": cleanup_details,
}

View file

@ -1,10 +1,13 @@
import requests import requests
import json import json
import logging import logging
import uuid
from typing import Dict, Optional, Tuple from typing import Dict, Optional, Tuple
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_ from sqlalchemy import select, and_, or_
from sqlalchemy.orm import selectinload
from database import ( from database import (
UniversignTransaction, UniversignTransaction,
@ -13,7 +16,10 @@ from database import (
UniversignTransactionStatus, UniversignTransactionStatus,
LocalDocumentStatus, LocalDocumentStatus,
UniversignSignerStatus, UniversignSignerStatus,
EmailLog,
StatutEmail,
) )
from data.data import templates_signature_email
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,
@ -31,6 +37,14 @@ class UniversignSyncService:
self.api_key = api_key self.api_key = api_key
self.timeout = timeout self.timeout = timeout
self.auth = (api_key, "") self.auth = (api_key, "")
self.sage_client = None
self.email_queue = None
self.settings = None
def configure(self, sage_client, email_queue, settings):
self.sage_client = sage_client
self.email_queue = email_queue
self.settings = settings
def fetch_transaction_status(self, transaction_id: str) -> Optional[Dict]: def fetch_transaction_status(self, transaction_id: str) -> Optional[Dict]:
start_time = datetime.now() start_time = datetime.now()
@ -48,9 +62,7 @@ class UniversignSyncService:
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
logger.info( logger.info(
f"✓ Fetch OK: {transaction_id} " f"Fetch OK: {transaction_id} status={data.get('state')} ({response_time_ms}ms)"
f"status={data.get('state')} "
f"({response_time_ms}ms)"
) )
return { return {
"transaction": data, "transaction": data,
@ -67,8 +79,7 @@ class UniversignSyncService:
else: else:
logger.error( logger.error(
f"Erreur HTTP {response.status_code} " f"Erreur HTTP {response.status_code} pour {transaction_id}: {response.text}"
f"pour {transaction_id}: {response.text}"
) )
return None return None
@ -88,15 +99,12 @@ class UniversignSyncService:
) -> Tuple[bool, Optional[str]]: ) -> Tuple[bool, Optional[str]]:
if is_final_status(transaction.local_status.value) and not force: if is_final_status(transaction.local_status.value) and not force:
logger.debug( logger.debug(
f"Skip {transaction.transaction_id}: " f"Skip {transaction.transaction_id}: statut final {transaction.local_status.value}"
f"statut final {transaction.local_status.value}"
) )
transaction.needs_sync = False transaction.needs_sync = False
await session.commit() await session.commit()
return True, None return True, None
# === FETCH UNIVERSIGN ===
result = self.fetch_transaction_status(transaction.transaction_id) result = self.fetch_transaction_status(transaction.transaction_id)
if not result: if not result:
@ -104,29 +112,20 @@ class UniversignSyncService:
await self._log_sync_attempt(session, transaction, "polling", False, error) await self._log_sync_attempt(session, transaction, "polling", False, error)
return False, error return False, error
# === EXTRACTION DONNÉES ===
universign_data = result["transaction"] universign_data = result["transaction"]
universign_status_raw = universign_data.get("state", "draft") universign_status_raw = universign_data.get("state", "draft")
# === MAPPING STATUT ===
new_local_status = map_universign_to_local(universign_status_raw) new_local_status = map_universign_to_local(universign_status_raw)
previous_local_status = transaction.local_status.value previous_local_status = transaction.local_status.value
# === VALIDATION 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}"
) )
# En cas de conflit, résoudre par priorité
new_local_status = resolve_status_conflict( new_local_status = resolve_status_conflict(
previous_local_status, new_local_status previous_local_status, new_local_status
) )
# === DÉTECTION CHANGEMENT ===
status_changed = previous_local_status != new_local_status status_changed = previous_local_status != new_local_status
if not status_changed and not force: if not status_changed and not force:
@ -136,16 +135,20 @@ class UniversignSyncService:
await session.commit() await session.commit()
return True, None return True, None
# === MISE À JOUR TRANSACTION === try:
transaction.universign_status = UniversignTransactionStatus(
universign_status_raw
)
except ValueError:
transaction.universign_status = (
UniversignTransactionStatus.COMPLETED
if new_local_status == "SIGNE"
else UniversignTransactionStatus.FAILED
)
transaction.universign_status = UniversignTransactionStatus(
universign_status_raw
)
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()
# === DATES SPÉCIFIQUES ===
if new_local_status == "EN_COURS" and not transaction.sent_at: if new_local_status == "EN_COURS" and not transaction.sent_at:
transaction.sent_at = datetime.now() transaction.sent_at = datetime.now()
@ -158,31 +161,18 @@ class UniversignSyncService:
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()
# === URLS === if universign_data.get("documents") and len(universign_data["documents"]) > 0:
if "signers" in universign_data and len(universign_data["signers"]) > 0:
first_signer = universign_data["signers"][0]
if "url" in first_signer:
transaction.signer_url = first_signer["url"]
if "documents" in universign_data and len(universign_data["documents"]) > 0:
first_doc = universign_data["documents"][0] first_doc = universign_data["documents"][0]
if "url" in first_doc: if first_doc.get("url"):
transaction.document_url = first_doc["url"] transaction.document_url = first_doc["url"]
# === SIGNATAIRES ===
await self._sync_signers(session, transaction, universign_data) await self._sync_signers(session, transaction, universign_data)
# === FLAGS ===
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 transaction.sync_error = None
# === LOG ===
await self._log_sync_attempt( await self._log_sync_attempt(
session=session, session=session,
transaction=transaction, transaction=transaction,
@ -202,14 +192,11 @@ class UniversignSyncService:
await session.commit() await session.commit()
# === ACTIONS MÉTIER ===
if status_changed: if status_changed:
await self._execute_status_actions(session, transaction, new_local_status) await self._execute_status_actions(session, transaction, new_local_status)
logger.info( logger.info(
f"✓ Sync OK: {transaction.transaction_id} " f"Sync OK: {transaction.transaction_id} {previous_local_status}{new_local_status}"
f"{previous_local_status}{new_local_status}"
) )
return True, None return True, None
@ -217,14 +204,9 @@ class UniversignSyncService:
async def sync_all_pending( async def sync_all_pending(
self, session: AsyncSession, max_transactions: int = 50 self, session: AsyncSession, max_transactions: int = 50
) -> Dict[str, int]: ) -> Dict[str, int]:
"""
Synchronise toutes les transactions en attente
"""
from sqlalchemy.orm import selectinload # Si pas déjà importé en haut
query = ( query = (
select(UniversignTransaction) select(UniversignTransaction)
.options(selectinload(UniversignTransaction.signers)) # AJOUTER CETTE LIGNE .options(selectinload(UniversignTransaction.signers))
.where( .where(
and_( and_(
UniversignTransaction.needs_sync, UniversignTransaction.needs_sync,
@ -267,7 +249,6 @@ class UniversignSyncService:
if success: if success:
stats["success"] += 1 stats["success"] += 1
if transaction.local_status.value != previous_status: if transaction.local_status.value != previous_status:
stats["status_changes"] += 1 stats["status_changes"] += 1
else: else:
@ -280,8 +261,7 @@ class UniversignSyncService:
stats["failed"] += 1 stats["failed"] += 1
logger.info( logger.info(
f"Polling terminé: {stats['success']}/{stats['total_found']} OK, " f"Polling terminé: {stats['success']}/{stats['total_found']} OK, {stats['status_changes']} changements détectés"
f"{stats['status_changes']} changements détectés"
) )
return stats return stats
@ -296,8 +276,10 @@ class UniversignSyncService:
if not transaction_id: if not transaction_id:
return False, "Pas de transaction_id dans le webhook" return False, "Pas de transaction_id dans le webhook"
query = select(UniversignTransaction).where( query = (
UniversignTransaction.transaction_id == transaction_id select(UniversignTransaction)
.options(selectinload(UniversignTransaction.signers))
.where(UniversignTransaction.transaction_id == transaction_id)
) )
result = await session.execute(query) result = await session.execute(query)
transaction = result.scalar_one_or_none() transaction = result.scalar_one_or_none()
@ -326,8 +308,7 @@ class UniversignSyncService:
await session.commit() await session.commit()
logger.info( logger.info(
f"✓ Webhook traité: {transaction_id} " f"Webhook traité: {transaction_id} event={event_type} success={success}"
f"event={event_type} success={success}"
) )
return success, error return success, error
@ -342,27 +323,47 @@ class UniversignSyncService:
transaction: UniversignTransaction, transaction: UniversignTransaction,
universign_data: Dict, universign_data: Dict,
): ):
"""Synchronise les signataires"""
signers_data = universign_data.get("signers", []) signers_data = universign_data.get("signers", [])
# Supprimer les anciens signataires # Ne pas toucher aux signers existants si Universign n'en retourne pas
for signer in transaction.signers: if not signers_data:
await session.delete(signer) return
# Mettre à jour les signers existants ou en créer de nouveaux
existing_signers = {s.email: s for s in transaction.signers}
# Créer les nouveaux
for idx, signer_data in enumerate(signers_data): for idx, signer_data in enumerate(signers_data):
signer = UniversignSigner( email = signer_data.get("email", "")
id=f"{transaction.id}_signer_{idx}",
transaction_id=transaction.id, if email in existing_signers:
email=signer_data.get("email", ""), # Mise à jour du signer existant
name=signer_data.get("name"), signer = existing_signers[email]
status=UniversignSignerStatus(signer_data.get("status", "waiting")), signer.status = UniversignSignerStatus(
order_index=idx, signer_data.get("status", "waiting")
viewed_at=self._parse_date(signer_data.get("viewed_at")), )
signed_at=self._parse_date(signer_data.get("signed_at")), signer.viewed_at = (
refused_at=self._parse_date(signer_data.get("refused_at")), self._parse_date(signer_data.get("viewed_at")) or signer.viewed_at
) )
session.add(signer) signer.signed_at = (
self._parse_date(signer_data.get("signed_at")) or signer.signed_at
)
signer.refused_at = (
self._parse_date(signer_data.get("refused_at")) or signer.refused_at
)
else:
# Nouveau signer
signer = UniversignSigner(
id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}",
transaction_id=transaction.id,
email=email,
name=signer_data.get("name"),
status=UniversignSignerStatus(signer_data.get("status", "waiting")),
order_index=idx,
viewed_at=self._parse_date(signer_data.get("viewed_at")),
signed_at=self._parse_date(signer_data.get("signed_at")),
refused_at=self._parse_date(signer_data.get("refused_at")),
)
session.add(signer)
async def _log_sync_attempt( async def _log_sync_attempt(
self, self,
@ -375,7 +376,6 @@ class UniversignSyncService:
new_status: Optional[str] = None, new_status: Optional[str] = None,
changes: Optional[str] = None, changes: Optional[str] = None,
): ):
"""Enregistre une tentative de sync dans les logs"""
log = UniversignSyncLog( log = UniversignSyncLog(
transaction_id=transaction.id, transaction_id=transaction.id,
sync_type=sync_type, sync_type=sync_type,
@ -391,48 +391,112 @@ class UniversignSyncService:
async def _execute_status_actions( async def _execute_status_actions(
self, session: AsyncSession, transaction: UniversignTransaction, new_status: str self, session: AsyncSession, transaction: UniversignTransaction, new_status: str
): ):
"""Exécute les actions métier associées au statut"""
actions = get_status_actions(new_status) actions = get_status_actions(new_status)
if not actions: if not actions:
return return
# Mise à jour Sage
if actions.get("update_sage_status"): if actions.get("update_sage_status"):
await self._update_sage_status(transaction, new_status) await self._update_sage_status(transaction, new_status)
# Déclencher workflow
if actions.get("trigger_workflow"):
await self._trigger_workflow(transaction)
# Notifications
if actions.get("send_notification"): if actions.get("send_notification"):
await self._send_notification(transaction, new_status) await self._send_notification(session, transaction, new_status)
# Archive async def _update_sage_status(
if actions.get("archive_document"): self, transaction: UniversignTransaction, status: str
await self._archive_signed_document(transaction) ):
if not self.sage_client:
logger.warning("sage_client non configuré pour mise à jour Sage")
return
async def _update_sage_status(self, transaction, status): try:
"""Met à jour le statut dans Sage""" type_doc = transaction.sage_document_type.value
# TODO: Appeler sage_client.mettre_a_jour_champ_libre() doc_id = transaction.sage_document_id
logger.info(f"TODO: Mettre à jour Sage pour {transaction.sage_document_id}")
async def _trigger_workflow(self, transaction): if status == "SIGNE":
"""Déclenche un workflow (ex: devis→commande)""" self.sage_client.changer_statut_document(
logger.info(f"TODO: Workflow pour {transaction.sage_document_id}") document_type_code=type_doc, numero=doc_id, nouveau_statut=2
)
logger.info(f"Statut Sage mis à jour: {doc_id} → Accepté (2)")
async def _send_notification(self, transaction, status): elif status == "EN_COURS":
"""Envoie une notification email""" self.sage_client.changer_statut_document(
logger.info(f"TODO: Notif pour {transaction.sage_document_id}") document_type_code=type_doc, numero=doc_id, nouveau_statut=1
)
logger.info(f"Statut Sage mis à jour: {doc_id} → Confirmé (1)")
async def _archive_signed_document(self, transaction): except Exception as e:
"""Archive le document signé""" logger.error(
logger.info(f"TODO: Archivage pour {transaction.sage_document_id}") f"Erreur mise à jour Sage pour {transaction.sage_document_id}: {e}"
)
async def _send_notification(
self, session: AsyncSession, transaction: UniversignTransaction, status: str
):
if not self.email_queue or not self.settings:
logger.warning("email_queue ou settings non configuré")
return
try:
if status == "SIGNE":
template = templates_signature_email["signature_confirmee"]
type_labels = {
0: "Devis",
10: "Commande",
30: "Bon de Livraison",
60: "Facture",
50: "Avoir",
}
variables = {
"NOM_SIGNATAIRE": transaction.requester_name or "Client",
"TYPE_DOC": type_labels.get(
transaction.sage_document_type.value, "Document"
),
"NUMERO": transaction.sage_document_id,
"DATE_SIGNATURE": transaction.signed_at.strftime("%d/%m/%Y à %H:%M")
if transaction.signed_at
else datetime.now().strftime("%d/%m/%Y à %H:%M"),
"TRANSACTION_ID": transaction.transaction_id,
"CONTACT_EMAIL": self.settings.smtp_from,
}
sujet = template["sujet"]
corps = template["corps_html"]
for var, valeur in variables.items():
sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur))
corps = corps.replace(f"{{{{{var}}}}}", str(valeur))
email_log = EmailLog(
id=str(uuid.uuid4()),
destinataire=transaction.requester_email,
sujet=sujet,
corps_html=corps,
document_ids=transaction.sage_document_id,
type_document=transaction.sage_document_type.value,
statut=StatutEmail.EN_ATTENTE,
date_creation=datetime.now(),
nb_tentatives=0,
)
session.add(email_log)
await session.flush()
self.email_queue.enqueue(email_log.id)
logger.info(
f"Email confirmation signature envoyé à {transaction.requester_email}"
)
except Exception as e:
logger.error(
f"Erreur envoi notification pour {transaction.transaction_id}: {e}"
)
@staticmethod @staticmethod
def _parse_date(date_str: Optional[str]) -> Optional[datetime]: def _parse_date(date_str: Optional[str]) -> Optional[datetime]:
"""Parse une date ISO 8601"""
if not date_str: if not date_str:
return None return None
try: try:
@ -448,7 +512,6 @@ class UniversignSyncScheduler:
self.is_running = False self.is_running = False
async def start(self, session_factory): async def start(self, session_factory):
"""Démarre le polling automatique"""
import asyncio import asyncio
self.is_running = True self.is_running = True
@ -470,10 +533,8 @@ class UniversignSyncScheduler:
except Exception as e: except Exception as e:
logger.error(f"Erreur polling: {e}", exc_info=True) logger.error(f"Erreur polling: {e}", exc_info=True)
# Attendre avant le prochain cycle
await asyncio.sleep(self.interval_minutes * 60) await asyncio.sleep(self.interval_minutes * 60)
def stop(self): def stop(self):
"""Arrête le polling"""
self.is_running = False self.is_running = False
logger.info("Arrêt polling Universign") logger.info("Arrêt polling Universign")

View file

@ -297,6 +297,7 @@ UNIVERSIGN_TO_LOCAL: Dict[str, str] = {
"started": "EN_COURS", "started": "EN_COURS",
# États finaux (succès) # États finaux (succès)
"completed": "SIGNE", "completed": "SIGNE",
"closed": "SIGNE",
# États finaux (échec) # États finaux (échec)
"refused": "REFUSE", "refused": "REFUSE",
"expired": "EXPIRE", "expired": "EXPIRE",