refactor(universign): clean up code and update status constants

This commit is contained in:
Fanilo-Nantenaina 2026-01-06 11:29:36 +03:00
parent b40c998062
commit 410d4553d5
4 changed files with 24 additions and 214 deletions

3
api.py
View file

@ -1,4 +1,4 @@
from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body, Request
from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from fastapi.encoders import jsonable_encoder
@ -90,7 +90,6 @@ from core.sage_context import (
)
from utils.generic_functions import (
_preparer_lignes_document,
normaliser_type_doc,
universign_envoyer,
universign_statut,
)

View file

@ -481,22 +481,22 @@ async def get_sync_stats(session: AsyncSession = Depends(get_session)):
# Par statut
signed_query = select(func.count(UniversignTransaction.id)).where(
UniversignTransaction.local_status == LocalDocumentStatus.SIGNE
UniversignTransaction.local_status == LocalDocumentStatus.SIGNED
)
signed = (await session.execute(signed_query)).scalar()
in_progress_query = select(func.count(UniversignTransaction.id)).where(
UniversignTransaction.local_status == LocalDocumentStatus.EN_COURS
UniversignTransaction.local_status == LocalDocumentStatus.IN_PROGRESS
)
in_progress = (await session.execute(in_progress_query)).scalar()
refused_query = select(func.count(UniversignTransaction.id)).where(
UniversignTransaction.local_status == LocalDocumentStatus.REFUSE
UniversignTransaction.local_status == LocalDocumentStatus.REJECTED
)
refused = (await session.execute(refused_query)).scalar()
expired_query = select(func.count(UniversignTransaction.id)).where(
UniversignTransaction.local_status == LocalDocumentStatus.EXPIRE
UniversignTransaction.local_status == LocalDocumentStatus.EXPIRED
)
expired = (await session.execute(expired_query)).scalar()

View file

@ -1,16 +1,10 @@
"""
Service de synchronisation Universign
Architecture : polling + webhooks avec retry et gestion d'erreurs
"""
import requests
import json
import logging
from typing import Dict, List, Optional, Tuple
from typing import Dict, Optional, Tuple
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_
from sqlalchemy.orm import selectinload
from database import (
UniversignTransaction,
@ -22,7 +16,6 @@ from database import (
)
from utils.universign_status_mapping import (
map_universign_to_local,
get_sage_status_code,
is_transition_allowed,
get_status_actions,
is_final_status,
@ -33,30 +26,13 @@ logger = logging.getLogger(__name__)
class UniversignSyncService:
"""
Service centralisé de synchronisation Universign
"""
def __init__(self, api_url: str, api_key: str, timeout: int = 30):
self.api_url = api_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.auth = (api_key, "")
# ========================================
# 1. RÉCUPÉRATION DEPUIS UNIVERSIGN
# ========================================
def fetch_transaction_status(self, transaction_id: str) -> Optional[Dict]:
"""
Récupère le statut actuel d'une transaction depuis Universign
Args:
transaction_id: ID Universign (ex: tr_abc123)
Returns:
Dictionnaire avec les données Universign ou None
"""
start_time = datetime.now()
try:
@ -104,30 +80,12 @@ class UniversignSyncService:
logger.error(f"Erreur fetch {transaction_id}: {e}", exc_info=True)
return None
# ========================================
# 2. SYNCHRONISATION UNITAIRE
# ========================================
async def sync_transaction(
self,
session: AsyncSession,
transaction: UniversignTransaction,
force: bool = False,
) -> Tuple[bool, Optional[str]]:
"""
Synchronise UNE transaction avec Universign
Args:
session: Session BDD
transaction: Transaction à synchroniser
force: Forcer même si statut final
Returns:
(success: bool, error_message: Optional[str])
"""
# === VÉRIFICATIONS PRÉALABLES ===
# Si statut final et pas de force, skip
if is_final_status(transaction.local_status.value) and not force:
logger.debug(
f"Skip {transaction.transaction_id}: "
@ -263,18 +221,6 @@ class UniversignSyncService:
async def sync_all_pending(
self, session: AsyncSession, max_transactions: int = 50
) -> Dict[str, int]:
"""
Synchronise toutes les transactions en attente
Args:
session: Session BDD
max_transactions: Nombre max de transactions à traiter
Returns:
Statistiques de synchronisation
"""
# === SÉLECTION TRANSACTIONS À SYNCHRONISER ===
query = (
select(UniversignTransaction)
.where(
@ -304,8 +250,6 @@ class UniversignSyncService:
result = await session.execute(query)
transactions = result.scalars().all()
# === STATISTIQUES ===
stats = {
"total_found": len(transactions),
"success": 0,
@ -314,8 +258,6 @@ class UniversignSyncService:
"status_changes": 0,
}
# === TRAITEMENT ===
for transaction in transactions:
try:
previous_status = transaction.local_status.value
@ -345,34 +287,16 @@ class UniversignSyncService:
return stats
# ========================================
# 4. WEBHOOK HANDLER
# ========================================
async def process_webhook(
self, session: AsyncSession, payload: Dict
) -> Tuple[bool, Optional[str]]:
"""
Traite un webhook Universign
Args:
session: Session BDD
payload: Corps du webhook
Returns:
(success: bool, error_message: Optional[str])
"""
try:
# === EXTRACTION DONNÉES ===
event_type = payload.get("event")
transaction_id = payload.get("transaction_id") or payload.get("id")
if not transaction_id:
return False, "Pas de transaction_id dans le webhook"
# === RECHERCHE TRANSACTION ===
query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id
)
@ -385,18 +309,12 @@ class UniversignSyncService:
)
return False, "Transaction inconnue"
# === MARQUAGE WEBHOOK ===
transaction.webhook_received = True
# === SYNCHRONISATION ===
success, error = await self.sync_transaction(
session, transaction, force=True
)
# === LOG WEBHOOK ===
await self._log_sync_attempt(
session=session,
transaction=transaction,
@ -419,10 +337,6 @@ class UniversignSyncService:
logger.error(f"Erreur traitement webhook: {e}", exc_info=True)
return False, str(e)
# ========================================
# 5. HELPERS PRIVÉS
# ========================================
async def _sync_signers(
self,
session: AsyncSession,
@ -524,20 +438,11 @@ class UniversignSyncService:
return None
try:
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except:
except Exception:
return None
# ========================================
# 6. TÂCHE PLANIFIÉE (BACKGROUND)
# ========================================
class UniversignSyncScheduler:
"""
Planificateur de synchronisation automatique
"""
def __init__(self, sync_service: UniversignSyncService, interval_minutes: int = 5):
self.sync_service = sync_service
self.interval_minutes = interval_minutes

View file

@ -1,7 +1,6 @@
from typing import Dict, List, Optional
from typing import Dict, List
from config.config import settings
import logging
from enum import Enum
from datetime import datetime
import uuid
@ -290,47 +289,31 @@ def _preparer_lignes_document(lignes: List) -> List[Dict]:
]
# ========================================
# MAPPING UNIVERSIGN → LOCAL
# ========================================
UNIVERSIGN_TO_LOCAL: Dict[str, str] = {
# États initiaux
"draft": "EN_ATTENTE", # Transaction créée
"ready": "EN_ATTENTE", # Prête mais pas envoyée
"draft": "EN_ATTENTE",
"ready": "EN_ATTENTE",
# En cours
"started": "EN_COURS", # Envoyée, en attente de signature
"started": "EN_COURS",
# États finaux (succès)
"completed": "SIGNE", # ✓ Signé avec succès
"completed": "SIGNE",
# États finaux (échec)
"refused": "REFUSE", # ✗ Refusé par un signataire
"expired": "EXPIRE", # ⏰ Délai expiré
"canceled": "REFUSE", # ✗ Annulé manuellement
"failed": "ERREUR", # ⚠️ Erreur technique
"refused": "REFUSE",
"expired": "EXPIRE",
"canceled": "REFUSE",
"failed": "ERREUR",
}
# ========================================
# MAPPING LOCAL → SAGE (CHAMP LIBRE)
# ========================================
LOCAL_TO_SAGE_STATUS: Dict[str, int] = {
"""
À stocker dans un champ libre Sage (ex: CB_STATUT_SIGNATURE)
"""
"EN_ATTENTE": 0, # Non envoyé
"EN_COURS": 1, # Envoyé, en attente
"SIGNE": 2, # ✓ Signé (peut déclencher workflow)
"REFUSE": 3, # ✗ Refusé
"EXPIRE": 4, # ⏰ Expiré
"ERREUR": 5, # ⚠️ Erreur
"EN_ATTENTE": 0,
"EN_COURS": 1,
"SIGNE": 2,
"REFUSE": 3,
"EXPIRE": 4,
"ERREUR": 5,
}
# ========================================
# ACTIONS MÉTIER PAR STATUT
# ========================================
STATUS_ACTIONS: Dict[str, Dict[str, any]] = {
"""
Actions automatiques à déclencher selon le statut
@ -366,10 +349,6 @@ STATUS_ACTIONS: Dict[str, Dict[str, any]] = {
}
# ========================================
# RÈGLES DE TRANSITION
# ========================================
ALLOWED_TRANSITIONS: Dict[str, list] = {
"""
Transitions de statuts autorisées (validation)
@ -383,21 +362,7 @@ ALLOWED_TRANSITIONS: Dict[str, list] = {
}
# ========================================
# FONCTION DE MAPPING
# ========================================
def map_universign_to_local(universign_status: str) -> str:
"""
Convertit un statut Universign en statut local
Args:
universign_status: Statut brut Universign
Returns:
Statut local normalisé
"""
return UNIVERSIGN_TO_LOCAL.get(
universign_status.lower(),
"ERREUR", # Fallback si statut inconnu
@ -405,29 +370,10 @@ def map_universign_to_local(universign_status: str) -> str:
def get_sage_status_code(local_status: str) -> int:
"""
Obtient le code numérique pour Sage
Args:
local_status: Statut local (EN_ATTENTE, SIGNE, etc.)
Returns:
Code numérique pour champ libre Sage
"""
return LOCAL_TO_SAGE_STATUS.get(local_status, 5)
def is_transition_allowed(from_status: str, to_status: str) -> bool:
"""
Vérifie si une transition de statut est valide
Args:
from_status: Statut actuel
to_status: Nouveau statut
Returns:
True si la transition est autorisée
"""
if from_status == to_status:
return True # Même statut = OK (idempotence)
@ -436,70 +382,30 @@ def is_transition_allowed(from_status: str, to_status: str) -> bool:
def get_status_actions(local_status: str) -> Dict[str, any]:
"""
Obtient les actions à exécuter pour un statut
Args:
local_status: Statut local
Returns:
Dictionnaire des actions à exécuter
"""
return STATUS_ACTIONS.get(local_status, {})
def is_final_status(local_status: str) -> bool:
"""
Détermine si le statut est final (pas de synchronisation nécessaire)
Args:
local_status: Statut local
Returns:
True si statut final
"""
return local_status in ["SIGNE", "REFUSE", "EXPIRE"]
# ========================================
# PRIORITÉS DE STATUTS (POUR CONFLITS)
# ========================================
STATUS_PRIORITY: Dict[str, int] = {
"""
En cas de conflit de synchronisation, prendre le statut
avec la priorité la plus élevée
"""
"ERREUR": 0, # Priorité la plus basse
"ERREUR": 0,
"EN_ATTENTE": 1,
"EN_COURS": 2,
"EXPIRE": 3,
"REFUSE": 4,
"SIGNE": 5, # Priorité la plus élevée (état final souhaité)
"SIGNE": 5,
}
def resolve_status_conflict(status_a: str, status_b: str) -> str:
"""
Résout un conflit entre deux statuts (prend le plus prioritaire)
Args:
status_a: Premier statut
status_b: Second statut
Returns:
Statut prioritaire
"""
priority_a = STATUS_PRIORITY.get(status_a, 0)
priority_b = STATUS_PRIORITY.get(status_b, 0)
return status_a if priority_a >= priority_b else status_b
# ========================================
# MESSAGES UTILISATEUR
# ========================================
STATUS_MESSAGES: Dict[str, Dict[str, str]] = {
"EN_ATTENTE": {
"fr": "Document en attente d'envoi",