refactor(universign): remove emojis from logs and clean up admin routes

This commit is contained in:
Fanilo-Nantenaina 2026-01-13 11:46:18 +03:00
parent d6ed8792cc
commit 18f9a45ef6
7 changed files with 94 additions and 469 deletions

2
api.py
View file

@ -2446,7 +2446,7 @@ async def creer_sortie_stock(sortie: SortieStock):
if sortie_data.get("date_sortie"):
sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat()
logger.info(f"📤 Création sortie stock: {len(sortie.lignes)} ligne(s)")
logger.info(f"Création sortie stock: {len(sortie.lignes)} ligne(s)")
resultat = sage_client.creer_sortie_stock(sortie_data)

View file

@ -50,7 +50,7 @@ class LocalDocumentStatus(str, Enum):
class SageDocumentType(int, Enum):
DEVIS = 0
BON_COMMANDE = 10
BON_COMMANDE = 10
PREPARATION = 20
BON_LIVRAISON = 30
BON_RETOUR = 40

View file

@ -1,11 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import false, select, func, or_, and_, true
from sqlalchemy import select, func, and_
from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import datetime, timedelta
from pydantic import BaseModel, EmailStr
import logging
from data.data import templates_signature_email
from email_queue import email_queue
@ -15,19 +14,21 @@ from database import (
UniversignSigner,
UniversignSyncLog,
LocalDocumentStatus,
SageDocumentType,
)
import os
from pathlib import Path
import json
from services.universign_document import UniversignDocumentService
from services.universign_sync import UniversignSyncService
from config.config import settings
from utils.generic_functions import normaliser_type_doc
from utils.universign_status_mapping import get_status_message, map_universign_to_local
from database.models.email import EmailLog
from database.enum.status import StatutEmail
from schemas import (
SyncStatsResponse,
CreateSignatureRequest,
TransactionResponse,
)
logger = logging.getLogger(__name__)
@ -38,60 +39,13 @@ sync_service = UniversignSyncService(
)
class CreateSignatureRequest(BaseModel):
"""Demande de création d'une signature"""
sage_document_id: str
sage_document_type: SageDocumentType
signer_email: EmailStr
signer_name: str
document_name: Optional[str] = None
class TransactionResponse(BaseModel):
"""Réponse détaillée d'une transaction"""
id: str
transaction_id: str
sage_document_id: str
sage_document_type: str
universign_status: str
local_status: str
local_status_label: str
signer_url: Optional[str]
document_url: Optional[str]
created_at: datetime
sent_at: Optional[datetime]
signed_at: Optional[datetime]
last_synced_at: Optional[datetime]
needs_sync: bool
signers: List[dict]
signed_document_available: bool = False
signed_document_downloaded_at: Optional[datetime] = None
signed_document_size_kb: Optional[float] = None
class SyncStatsResponse(BaseModel):
"""Statistiques de synchronisation"""
total_transactions: int
pending_sync: int
signed: int
in_progress: int
refused: int
expired: int
last_sync_at: Optional[datetime]
@router.post("/signatures/create", response_model=TransactionResponse)
async def create_signature(
request: CreateSignatureRequest, session: AsyncSession = Depends(get_session)
):
try:
# === VÉRIFICATION DOUBLON RENFORCÉE ===
logger.info(
f"🔍 Vérification doublon pour: {request.sage_document_id} "
f"Vérification doublon pour: {request.sage_document_id} "
f"(type: {request.sage_document_type.name})"
)
@ -107,7 +61,6 @@ async def create_signature(
f"{len(all_existing)} transaction(s) existante(s) trouvée(s)"
)
# Filtrer les transactions non-finales
active_txs = [
tx
for tx in all_existing
@ -137,8 +90,7 @@ async def create_signature(
"Toutes les transactions existantes sont finales, création autorisée"
)
# Génération PDF
logger.info(f"📄 Génération PDF: {request.sage_document_id}")
logger.info(f"Génération PDF: {request.sage_document_id}")
pdf_bytes = email_queue._generate_pdf(
request.sage_document_id, normaliser_type_doc(request.sage_document_type)
)
@ -148,13 +100,12 @@ async def create_signature(
logger.info(f"PDF généré: {len(pdf_bytes)} octets")
# === CRÉATION TRANSACTION UNIVERSIGN ===
import requests
import uuid
auth = (settings.universign_api_key, "")
logger.info("🔄 Création transaction Universign...")
logger.info("Création transaction Universign...")
resp = requests.post(
f"{settings.universign_api_url}/transactions",
@ -174,8 +125,7 @@ async def create_signature(
universign_tx_id = resp.json().get("id")
logger.info(f"Transaction Universign créée: {universign_tx_id}")
# Upload PDF
logger.info("📤 Upload PDF...")
logger.info("Upload PDF...")
files = {
"file": (f"{request.sage_document_id}.pdf", pdf_bytes, "application/pdf")
}
@ -190,8 +140,7 @@ async def create_signature(
file_id = resp.json().get("id")
logger.info(f"PDF uploadé: {file_id}")
# Attachement document
logger.info("🔗 Attachement document...")
logger.info("Attachement document...")
resp = requests.post(
f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents",
auth=auth,
@ -204,8 +153,7 @@ async def create_signature(
document_id = resp.json().get("id")
# Création champ signature
logger.info("✍️ Création champ signature...")
logger.info("Création champ signature...")
resp = requests.post(
f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents/{document_id}/fields",
auth=auth,
@ -218,8 +166,7 @@ async def create_signature(
field_id = resp.json().get("id")
# Liaison signataire
logger.info(f"👤 Liaison signataire: {request.signer_email}")
logger.info(f"Liaison signataire: {request.signer_email}")
resp = requests.post(
f"{settings.universign_api_url}/transactions/{universign_tx_id}/signatures",
auth=auth,
@ -230,8 +177,7 @@ async def create_signature(
if resp.status_code not in [200, 201]:
raise HTTPException(500, "Erreur liaison signataire")
# Démarrage transaction
logger.info("🚀 Démarrage transaction...")
logger.info("Démarrage transaction...")
resp = requests.post(
f"{settings.universign_api_url}/transactions/{universign_tx_id}/start",
auth=auth,
@ -243,7 +189,6 @@ async def create_signature(
final_data = resp.json()
# Extraction URL de signature
signer_url = ""
if final_data.get("actions"):
for action in final_data["actions"]:
@ -256,12 +201,11 @@ async def create_signature(
logger.info("URL de signature obtenue")
# === ENREGISTREMENT LOCAL ===
local_id = str(uuid.uuid4())
transaction = UniversignTransaction(
id=local_id,
transaction_id=universign_tx_id, # Utiliser l'ID Universign, ne jamais le changer
transaction_id=universign_tx_id,
sage_document_id=request.sage_document_id,
sage_document_type=request.sage_document_type,
universign_status=UniversignTransactionStatus.STARTED,
@ -291,10 +235,9 @@ async def create_signature(
await session.commit()
logger.info(
f"💾 Transaction sauvegardée: {local_id} (Universign: {universign_tx_id})"
f"Transaction sauvegardée: {local_id} (Universign: {universign_tx_id})"
)
# === ENVOI EMAIL AVEC TEMPLATE ===
template = templates_signature_email["demande_signature"]
type_labels = {
@ -349,7 +292,6 @@ async def create_signature(
email_queue.enqueue(email_log.id)
# === MISE À JOUR STATUT SAGE (Confirmé = 1) ===
try:
from sage_client import sage_client
@ -364,7 +306,6 @@ async def create_signature(
except Exception as e:
logger.warning(f"Impossible de mettre à jour le statut Sage: {e}")
# === RÉPONSE ===
return TransactionResponse(
id=transaction.id,
transaction_id=transaction.transaction_id,
@ -444,7 +385,6 @@ async def list_transactions(
}
for s in tx.signers
],
# ✅ NOUVEAUX CHAMPS
signed_document_available=bool(
tx.signed_document_path and Path(tx.signed_document_path).exists()
),
@ -500,7 +440,6 @@ async def get_transaction(
}
for s in tx.signers
],
# ✅ NOUVEAUX CHAMPS
signed_document_available=bool(
tx.signed_document_path and Path(tx.signed_document_path).exists()
),
@ -566,17 +505,11 @@ async def webhook_universign(
try:
payload = await request.json()
# 📋 LOG COMPLET du payload pour débogage
logger.info(
f"📥 Webhook Universign reçu - Type: {payload.get('type', 'unknown')}"
)
logger.info(f"Webhook Universign reçu - Type: {payload.get('type', 'unknown')}")
logger.debug(f"Payload complet: {json.dumps(payload, indent=2)}")
# EXTRACTION CORRECTE DU TRANSACTION_ID
transaction_id = None
# 🔍 Structure 1 : Événements avec payload imbriqué (la plus courante)
# Exemple : transaction.lifecycle.created, transaction.lifecycle.started, etc.
if payload.get("type", "").startswith("transaction.") and "payload" in payload:
# Le transaction_id est dans payload.object.id
nested_object = payload.get("payload", {}).get("object", {})
@ -586,9 +519,7 @@ async def webhook_universign(
f"Transaction ID extrait de payload.object.id: {transaction_id}"
)
# 🔍 Structure 2 : Action événements (action.opened, action.completed)
elif payload.get("type", "").startswith("action."):
# Le transaction_id est directement dans payload.object.transaction_id
transaction_id = (
payload.get("payload", {}).get("object", {}).get("transaction_id")
)
@ -596,17 +527,14 @@ async def webhook_universign(
f"Transaction ID extrait de payload.object.transaction_id: {transaction_id}"
)
# 🔍 Structure 3 : Transaction directe (fallback)
elif payload.get("object") == "transaction":
transaction_id = payload.get("id")
logger.info(f"Transaction ID extrait direct: {transaction_id}")
# 🔍 Structure 4 : Ancien format (pour rétro-compatibilité)
elif "transaction" in payload:
transaction_id = payload.get("transaction", {}).get("id")
logger.info(f"Transaction ID extrait de transaction.id: {transaction_id}")
# Échec d'extraction
if not transaction_id:
logger.error(
f"Transaction ID introuvable dans webhook\n"
@ -621,9 +549,8 @@ async def webhook_universign(
"event_id": payload.get("id"),
}, 400
logger.info(f"🎯 Transaction ID identifié: {transaction_id}")
logger.info(f"Transaction ID identifié: {transaction_id}")
# Vérifier si la transaction existe localement
query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id
)
@ -643,7 +570,6 @@ async def webhook_universign(
"event_type": payload.get("type"),
}
# Traiter le webhook
success, error = await sync_service.process_webhook(
session, payload, transaction_id
)
@ -656,7 +582,6 @@ async def webhook_universign(
"transaction_id": transaction_id,
}, 500
# Succès
logger.info(
f"Webhook traité avec succès\n"
f"Transaction: {transaction_id}\n"
@ -673,7 +598,7 @@ async def webhook_universign(
}
except Exception as e:
logger.error(f"💥 Erreur critique webhook: {e}", exc_info=True)
logger.error(f"Erreur critique webhook: {e}", exc_info=True)
return {"status": "error", "message": str(e)}, 500
@ -681,17 +606,14 @@ async def webhook_universign(
async def get_sync_stats(session: AsyncSession = Depends(get_session)):
"""Statistiques globales de synchronisation"""
# Total
total_query = select(func.count(UniversignTransaction.id))
total = (await session.execute(total_query)).scalar()
# En attente de sync
pending_query = select(func.count(UniversignTransaction.id)).where(
UniversignTransaction.needs_sync
)
pending = (await session.execute(pending_query)).scalar()
# Par statut
signed_query = select(func.count(UniversignTransaction.id)).where(
UniversignTransaction.local_status == LocalDocumentStatus.SIGNED
)
@ -712,7 +634,6 @@ async def get_sync_stats(session: AsyncSession = Depends(get_session)):
)
expired = (await session.execute(expired_query)).scalar()
# Dernière sync
last_sync_query = select(func.max(UniversignTransaction.last_synced_at))
last_sync = (await session.execute(last_sync_query)).scalar()
@ -733,7 +654,6 @@ async def get_transaction_logs(
limit: int = Query(50, le=500),
session: AsyncSession = Depends(get_session),
):
# Trouver la transaction
tx_query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id
)
@ -743,7 +663,6 @@ async def get_transaction_logs(
if not tx:
raise HTTPException(404, "Transaction introuvable")
# Logs
logs_query = (
select(UniversignSyncLog)
.where(UniversignSyncLog.transaction_id == tx.id)
@ -772,9 +691,6 @@ async def get_transaction_logs(
}
# Ajouter ces routes dans universign.py
@router.get("/documents/{sage_document_id}/signatures")
async def get_signatures_for_document(
sage_document_id: str,
@ -816,10 +732,6 @@ async def cleanup_duplicate_signatures(
),
session: AsyncSession = Depends(get_session),
):
"""
Supprime les doublons de signatures pour un document.
Garde une seule transaction (la plus récente ou ancienne selon le paramètre).
"""
query = (
select(UniversignTransaction)
.where(UniversignTransaction.sage_document_id == sage_document_id)
@ -841,7 +753,6 @@ async def cleanup_duplicate_signatures(
"deleted_count": 0,
}
# Garder la première (selon l'ordre), supprimer les autres
to_keep = transactions[0]
to_delete = transactions[1:]
@ -901,13 +812,8 @@ async def delete_transaction(
async def cleanup_all_duplicates(
session: AsyncSession = Depends(get_session),
):
"""
Nettoie tous les doublons dans la base.
Pour chaque document avec plusieurs transactions, garde la plus récente non-erreur ou la plus récente.
"""
from sqlalchemy import func
# Trouver les documents avec plusieurs transactions
subquery = (
select(
UniversignTransaction.sage_document_id,
@ -925,7 +831,6 @@ async def cleanup_all_duplicates(
cleanup_details = []
for doc_id in duplicate_docs:
# Récupérer toutes les transactions pour ce document
tx_query = (
select(UniversignTransaction)
.where(UniversignTransaction.sage_document_id == doc_id)
@ -934,7 +839,6 @@ async def cleanup_all_duplicates(
tx_result = await session.execute(tx_query)
transactions = tx_result.scalars().all()
# Priorité: SIGNE > EN_COURS > EN_ATTENTE > autres
priority = {"SIGNE": 0, "EN_COURS": 1, "EN_ATTENTE": 2}
def sort_key(tx):
@ -972,115 +876,11 @@ async def cleanup_all_duplicates(
}
@router.get("/admin/diagnostic", tags=["Admin"])
async def diagnostic_complet(session: AsyncSession = Depends(get_session)):
"""
Diagnostic complet de l'état des transactions Universign
"""
try:
# Statistiques générales
total_query = select(func.count(UniversignTransaction.id))
total = (await session.execute(total_query)).scalar()
# Par statut local
statuts_query = select(
UniversignTransaction.local_status, func.count(UniversignTransaction.id)
).group_by(UniversignTransaction.local_status)
statuts_result = await session.execute(statuts_query)
statuts = {status.value: count for status, count in statuts_result.all()}
# Transactions sans sync récente
date_limite = datetime.now() - timedelta(hours=1)
sans_sync_query = select(func.count(UniversignTransaction.id)).where(
and_(
UniversignTransaction.needs_sync,
or_(
UniversignTransaction.last_synced_at < date_limite,
UniversignTransaction.last_synced_at.is_(None),
),
)
)
sans_sync = (await session.execute(sans_sync_query)).scalar()
# Doublons potentiels
doublons_query = (
select(
UniversignTransaction.sage_document_id,
func.count(UniversignTransaction.id).label("count"),
)
.group_by(UniversignTransaction.sage_document_id)
.having(func.count(UniversignTransaction.id) > 1)
)
doublons_result = await session.execute(doublons_query)
doublons = doublons_result.fetchall()
# Transactions avec erreurs de sync
erreurs_query = select(func.count(UniversignTransaction.id)).where(
UniversignTransaction.sync_error.isnot(None)
)
erreurs = (await session.execute(erreurs_query)).scalar()
# Transactions sans webhook reçu
sans_webhook_query = select(func.count(UniversignTransaction.id)).where(
and_(
not UniversignTransaction.webhook_received,
UniversignTransaction.local_status != LocalDocumentStatus.PENDING,
)
)
sans_webhook = (await session.execute(sans_webhook_query)).scalar()
diagnostic = {
"timestamp": datetime.now().isoformat(),
"total_transactions": total,
"repartition_statuts": statuts,
"problemes_detectes": {
"sans_sync_recente": sans_sync,
"doublons_possibles": len(doublons),
"erreurs_sync": erreurs,
"sans_webhook": sans_webhook,
},
"documents_avec_doublons": [
{"document_id": doc_id, "nombre_transactions": count}
for doc_id, count in doublons
],
"recommandations": [],
}
# Recommandations
if sans_sync > 0:
diagnostic["recommandations"].append(
f"🔄 {sans_sync} transaction(s) à synchroniser. "
f"Utilisez POST /universign/sync/all"
)
if len(doublons) > 0:
diagnostic["recommandations"].append(
f"{len(doublons)} document(s) avec doublons. "
f"Utilisez POST /universign/cleanup/all-duplicates"
)
if erreurs > 0:
diagnostic["recommandations"].append(
f"{erreurs} transaction(s) en erreur. "
f"Vérifiez les logs avec GET /universign/transactions?status=ERREUR"
)
return diagnostic
except Exception as e:
logger.error(f"Erreur diagnostic: {e}")
raise HTTPException(500, str(e))
@router.post("/admin/force-sync-all", tags=["Admin"])
async def forcer_sync_toutes_transactions(
max_transactions: int = Query(200, le=500),
session: AsyncSession = Depends(get_session),
):
"""
Force la synchronisation de TOUTES les transactions (même finales)
À utiliser pour réparer les incohérences
"""
try:
query = (
select(UniversignTransaction)
@ -1105,7 +905,7 @@ async def forcer_sync_toutes_transactions(
previous_status = transaction.local_status.value
logger.info(
f"🔄 Force sync: {transaction.transaction_id} (statut: {previous_status})"
f"Force sync: {transaction.transaction_id} (statut: {previous_status})"
)
success, error = await sync_service.sync_transaction(
@ -1154,9 +954,6 @@ async def forcer_sync_toutes_transactions(
async def reparer_transaction(
transaction_id: str, session: AsyncSession = Depends(get_session)
):
"""
Répare une transaction spécifique en la re-synchronisant depuis Universign
"""
try:
query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id
@ -1174,7 +971,6 @@ async def reparer_transaction(
else None
)
# Force sync
success, error = await sync_service.sync_transaction(
session, transaction, force=True
)
@ -1211,11 +1007,7 @@ async def reparer_transaction(
async def trouver_transactions_inconsistantes(
session: AsyncSession = Depends(get_session),
):
"""
Trouve les transactions dont le statut local ne correspond pas au statut Universign
"""
try:
# Toutes les transactions non-finales
query = select(UniversignTransaction).where(
UniversignTransaction.local_status.in_(
[LocalDocumentStatus.PENDING, LocalDocumentStatus.IN_PROGRESS]
@ -1229,7 +1021,6 @@ async def trouver_transactions_inconsistantes(
for tx in transactions:
try:
# Récupérer le statut depuis Universign
universign_data = sync_service.fetch_transaction_status(
tx.transaction_id
)
@ -1298,9 +1089,6 @@ async def nettoyer_transactions_erreur(
),
session: AsyncSession = Depends(get_session),
):
"""
Nettoie les transactions en erreur anciennes
"""
try:
date_limite = datetime.now() - timedelta(days=age_jours)
@ -1344,9 +1132,6 @@ async def nettoyer_transactions_erreur(
async def voir_dernier_webhook(
transaction_id: str, session: AsyncSession = Depends(get_session)
):
"""
Affiche le dernier payload webhook reçu pour une transaction
"""
try:
query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id
@ -1357,7 +1142,6 @@ async def voir_dernier_webhook(
if not tx:
raise HTTPException(404, "Transaction introuvable")
# Récupérer le dernier log de type webhook
logs_query = (
select(UniversignSyncLog)
.where(
@ -1407,9 +1191,6 @@ async def voir_dernier_webhook(
async def telecharger_document_signe(
transaction_id: str, session: AsyncSession = Depends(get_session)
):
"""
Télécharge le document signé localement stocké
"""
try:
query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id
@ -1430,7 +1211,6 @@ async def telecharger_document_signe(
file_path = Path(transaction.signed_document_path)
if not file_path.exists():
# Document perdu, on peut tenter de le retélécharger
logger.warning(f"Fichier perdu : {file_path}")
raise HTTPException(
404,
@ -1438,7 +1218,6 @@ async def telecharger_document_signe(
"Utilisez POST /admin/download-missing-documents pour le récupérer.",
)
# Génération du nom de fichier pour le téléchargement
download_name = (
f"{transaction.sage_document_id}_"
f"{transaction.sage_document_type.name}_"
@ -1503,218 +1282,3 @@ async def info_document_signe(
except Exception as e:
logger.error(f"Erreur info document : {e}")
raise HTTPException(500, str(e))
@router.post("/admin/download-missing-documents", tags=["Admin"])
async def telecharger_documents_manquants(
force_redownload: bool = Query(
False, description="Forcer le retéléchargement même si déjà présent"
),
session: AsyncSession = Depends(get_session),
):
"""
Télécharge tous les documents signés manquants pour les transactions SIGNE
"""
try:
# Transactions signées sans document local
query = select(UniversignTransaction).where(
UniversignTransaction.local_status == LocalDocumentStatus.SIGNED,
or_(
UniversignTransaction.signed_document_path.is_(None),
force_redownload,
),
)
result = await session.execute(query)
transactions = result.scalars().all()
logger.info(f"📥 {len(transactions)} document(s) à télécharger")
document_service = UniversignDocumentService(
api_url=settings.universign_api_url,
api_key=settings.universign_api_key, timeout=60
)
results = {"total": len(transactions), "success": 0, "failed": 0, "details": []}
for transaction in transactions:
try:
(
success,
error,
) = await document_service.download_and_store_signed_document(
session=session, transaction=transaction, force=force_redownload
)
if success:
results["success"] += 1
results["details"].append(
{
"transaction_id": transaction.transaction_id,
"sage_document_id": transaction.sage_document_id,
"status": "success",
}
)
else:
results["failed"] += 1
results["details"].append(
{
"transaction_id": transaction.transaction_id,
"sage_document_id": transaction.sage_document_id,
"status": "failed",
"error": error,
}
)
except Exception as e:
logger.error(f"Erreur téléchargement {transaction.transaction_id}: {e}")
results["failed"] += 1
results["details"].append(
{"transaction_id": transaction.transaction_id, "error": str(e)}
)
await session.commit()
logger.info(
f"Téléchargement terminé : {results['success']}/{results['total']} réussis"
)
return results
except Exception as e:
logger.error(f"Erreur téléchargement batch : {e}", exc_info=True)
raise HTTPException(500, str(e))
@router.post("/admin/cleanup-old-documents", tags=["Admin"])
async def nettoyer_anciens_documents(
days_to_keep: int = Query(
90, ge=7, le=365, description="Nombre de jours à conserver"
),
):
"""
Supprime les documents signés de plus de X jours (par défaut 90)
"""
try:
document_service = UniversignDocumentService(
api_url=settings.universign_api_url,
api_key=settings.universign_api_key
)
deleted, size_freed_mb = await document_service.cleanup_old_documents(
days_to_keep=days_to_keep
)
return {
"success": True,
"files_deleted": deleted,
"space_freed_mb": size_freed_mb,
"days_kept": days_to_keep,
}
except Exception as e:
logger.error(f"Erreur nettoyage : {e}")
raise HTTPException(500, str(e))
@router.get("/transactions/{transaction_id}/diagnose", tags=["Debug"])
async def diagnose_transaction(
transaction_id: str, session: AsyncSession = Depends(get_session)
):
"""
Diagnostic complet d'une transaction Universign
Utile pour débugger les problèmes de récupération de documents
"""
from services.universign_document import UniversignDocumentService
try:
# Récupérer la transaction locale
query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id
)
result = await session.execute(query)
transaction = result.scalar_one_or_none()
local_info = None
if transaction:
local_info = {
"id": transaction.id,
"sage_document_id": transaction.sage_document_id,
"local_status": transaction.local_status.value,
"document_url": transaction.document_url,
"signed_document_path": transaction.signed_document_path,
"download_attempts": transaction.download_attempts,
"download_error": transaction.download_error,
}
# Diagnostic API Universign
document_service = UniversignDocumentService(
api_url=settings.universign_api_url,
api_key=settings.universign_api_key,
timeout=30,
)
api_diagnosis = document_service.diagnose_transaction(transaction_id)
return {
"transaction_id": transaction_id,
"local_data": local_info,
"api_diagnosis": api_diagnosis,
"recommendations": _generate_recommendations(local_info, api_diagnosis),
}
except Exception as e:
logger.error(f"Erreur diagnostic: {e}", exc_info=True)
raise HTTPException(500, str(e))
def _generate_recommendations(local_info, api_diagnosis):
"""Génère des recommandations basées sur le diagnostic"""
recommendations = []
if not local_info:
recommendations.append(
"Transaction introuvable localement. Vérifiez le transaction_id."
)
return recommendations
if not api_diagnosis.get("success"):
recommendations.append(
f"Erreur API Universign: {api_diagnosis.get('error')}. "
f"Vérifiez la connectivité et les credentials."
)
return recommendations
state = api_diagnosis.get("checks", {}).get("transaction_data", {}).get("state")
if state not in ["completed", "closed"]:
recommendations.append(
f"La transaction n'est pas encore signée (state={state}). "
f"Attendez que le signataire complète la signature."
)
docs = api_diagnosis.get("checks", {}).get("documents", [])
if not docs:
recommendations.append("Aucun document trouvé dans la transaction Universign.")
else:
for doc in docs:
dl_check = doc.get("download_check", {})
if not dl_check.get("accessible"):
recommendations.append(
f"Document {doc.get('id')} non accessible: "
f"status_code={dl_check.get('status_code')}. "
f"Vérifiez que la signature est complète."
)
if local_info.get("download_error"):
recommendations.append(
f"Dernière erreur de téléchargement: {local_info['download_error']}"
)
if not recommendations:
recommendations.append(
"Tout semble correct. Essayez POST /admin/download-missing-documents "
"avec force_redownload=true"
)
return recommendations

View file

@ -26,7 +26,13 @@ from schemas.documents.documents import TypeDocument, TypeDocumentSQL
from schemas.documents.email import StatutEmail, EmailEnvoi
from schemas.documents.factures import FactureCreate, FactureUpdate
from schemas.documents.livraisons import LivraisonCreate, LivraisonUpdate
from schemas.documents.universign import Signature, StatutSignature
from schemas.documents.universign import (
Signature,
StatutSignature,
SyncStatsResponse,
CreateSignatureRequest,
TransactionResponse,
)
from schemas.articles.articles import (
ArticleCreate,
Article,
@ -105,4 +111,7 @@ __all__ = [
"SageGatewayTest",
"SageGatewayStatsResponse",
"CurrentGatewayInfo",
"SyncStatsResponse",
"CreateSignatureRequest",
"TransactionResponse",
]

View file

@ -1,6 +1,12 @@
from pydantic import BaseModel, EmailStr
from enum import Enum
from schemas.documents.documents import TypeDocument
from database import (
SageDocumentType,
)
from typing import List, Optional
from datetime import datetime
class StatutSignature(str, Enum):
@ -16,3 +22,49 @@ class Signature(BaseModel):
type_doc: TypeDocument
email_signataire: EmailStr
nom_signataire: str
class CreateSignatureRequest(BaseModel):
"""Demande de création d'une signature"""
sage_document_id: str
sage_document_type: SageDocumentType
signer_email: EmailStr
signer_name: str
document_name: Optional[str] = None
class TransactionResponse(BaseModel):
"""Réponse détaillée d'une transaction"""
id: str
transaction_id: str
sage_document_id: str
sage_document_type: str
universign_status: str
local_status: str
local_status_label: str
signer_url: Optional[str]
document_url: Optional[str]
created_at: datetime
sent_at: Optional[datetime]
signed_at: Optional[datetime]
last_synced_at: Optional[datetime]
needs_sync: bool
signers: List[dict]
signed_document_available: bool = False
signed_document_downloaded_at: Optional[datetime] = None
signed_document_size_kb: Optional[float] = None
class SyncStatsResponse(BaseModel):
"""Statistiques de synchronisation"""
total_transactions: int
pending_sync: int
signed: int
in_progress: int
refused: int
expired: int
last_sync_at: Optional[datetime]

View file

@ -76,7 +76,7 @@ class UniversignDocumentService:
f"/documents/{document_id}/download"
)
logger.info(f"📥 Téléchargement depuis: {download_url}")
logger.info(f"Téléchargement depuis: {download_url}")
response = requests.get(
download_url,
@ -155,7 +155,7 @@ class UniversignDocumentService:
try:
# ÉTAPE 1: Récupérer les documents de la transaction
logger.info(
f"🔄 Récupération document signé pour: {transaction.transaction_id}"
f"Récupération document signé pour: {transaction.transaction_id}"
)
documents = self.fetch_transaction_documents(transaction.transaction_id)
@ -177,7 +177,7 @@ class UniversignDocumentService:
if doc_status in ["signed", "completed", "closed"]:
document_id = doc_id
logger.info(
f"📄 Document signé trouvé: {doc_id} (status: {doc_status})"
f"Document signé trouvé: {doc_id} (status: {doc_status})"
)
break
@ -309,7 +309,7 @@ class UniversignDocumentService:
try:
# Test 1: Récupération de la transaction
logger.info(f"🔍 Diagnostic transaction: {transaction_id}")
logger.info(f"Diagnostic transaction: {transaction_id}")
response = requests.get(
f"{self.api_url}/transactions/{transaction_id}",

View file

@ -341,7 +341,7 @@ class UniversignSyncService:
return True, None
# Récupération du statut distant
logger.info(f"🔄 Synchronisation: {transaction.transaction_id}")
logger.info(f"Synchronisation: {transaction.transaction_id}")
result = self.fetch_transaction_status(transaction.transaction_id)
@ -365,7 +365,7 @@ class UniversignSyncService:
previous_local_status = transaction.local_status.value
logger.info(
f"🔄 Mapping: {universign_status_raw} (Universign) → "
f"Mapping: {universign_status_raw} (Universign) → "
f"{new_local_status} (Local) | Actuel: {previous_local_status}"
)
@ -433,13 +433,13 @@ class UniversignSyncService:
if documents:
first_doc = documents[0]
logger.info(
f"📄 Document Universign trouvé: id={first_doc.get('id')}, "
f"Document Universign trouvé: id={first_doc.get('id')}, "
f"status={first_doc.get('status')}"
)
# Téléchargement automatique du document signé
if new_local_status == "SIGNE" and not transaction.signed_document_path:
logger.info("📥 Déclenchement téléchargement document signé...")
logger.info("Déclenchement téléchargement document signé...")
try:
(
@ -537,7 +537,7 @@ class UniversignSyncService:
transaction.universign_document_id = first_doc_id
logger.info(
f"📄 Document Universign: id={first_doc_id}, "
f"Document Universign: id={first_doc_id}, "
f"name={first_doc.get('name')}, status={first_doc.get('status')}"
)
else:
@ -546,7 +546,7 @@ class UniversignSyncService:
# Téléchargement automatique si signé
if new_local_status == "SIGNE":
if not transaction.signed_document_path:
logger.info("📥 Déclenchement téléchargement document signé...")
logger.info("Déclenchement téléchargement document signé...")
try:
(