feat(signature): add signed document storage and management

This commit is contained in:
Fanilo-Nantenaina 2026-01-06 18:38:50 +03:00
parent ba4a9d4773
commit 9bc2a3f7c9
5 changed files with 762 additions and 1010 deletions

11
api.py
View file

@ -80,7 +80,7 @@ from utils.normalization import normaliser_type_tiers
from routes.sage_gateway import router as sage_gateway_router from routes.sage_gateway import router as sage_gateway_router
from routes.universign import router as universign_router from routes.universign import router as universign_router
from services.universign_sync import UniversignSyncService, UniversignSyncScheduler from services.universign_sync import UniversignSyncService
from core.sage_context import ( from core.sage_context import (
get_sage_client_for_user, get_sage_client_for_user,
@ -134,19 +134,10 @@ async def lifespan(app: FastAPI):
sage_client=sage_client, email_queue=email_queue, settings=settings sage_client=sage_client, email_queue=email_queue, settings=settings
) )
scheduler = UniversignSyncScheduler(
sync_service=sync_service,
interval_minutes=5,
)
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
scheduler.stop()
sync_task.cancel()
email_queue.stop() email_queue.stop()
logger.info("Services arrêtés") logger.info("Services arrêtés")

View file

@ -145,6 +145,12 @@ class UniversignTransaction(Base):
) )
webhook_received = Column(Boolean, default=False, comment="Webhook Universign reçu") webhook_received = Column(Boolean, default=False, comment="Webhook Universign reçu")
signed_document_path = Column(
Text, nullable=True, comment="Chemin local du document signé téléchargé"
)
signed_document_downloaded_at = Column(
DateTime, nullable=True, comment="Date de téléchargement du document signé"
)
# === RELATION === # === RELATION ===
signers = relationship( signers = relationship(
"UniversignSigner", back_populates="transaction", cascade="all, delete-orphan" "UniversignSigner", back_populates="transaction", cascade="all, delete-orphan"

View file

@ -1,36 +1,48 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request """
Routes API Universign améliorées
Intègre la logique métier complète de gestion des signatures
"""
from fastapi import APIRouter, Depends, HTTPException, Path, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from typing import List, Optional from typing import Optional
from datetime import datetime from datetime import datetime
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
import logging import logging
from data.data import templates_signature_email import uuid
from email_queue import email_queue
from database import UniversignSignerStatus, UniversignTransactionStatus, get_session
from database import ( from database import (
UniversignTransaction, UniversignTransaction,
UniversignSigner, UniversignSigner,
UniversignSyncLog,
LocalDocumentStatus, LocalDocumentStatus,
SageDocumentType, SageDocumentType,
UniversignTransactionStatus,
UniversignSignerStatus,
get_session,
EmailLog,
StatutEmail,
) )
from services.universign_sync import UniversignSyncService from services.universign_sync import UniversignSyncService
from services.signed_documents import signed_documents
from config.config import settings from config.config import settings
from email_queue import email_queue
from sage_client import sage_client
from data.data import templates_signature_email
from utils.generic_functions import normaliser_type_doc from utils.generic_functions import normaliser_type_doc
from utils.universign_status_mapping import get_status_message
from database.models.email import EmailLog
from database.enum.status import StatutEmail
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/universign", tags=["Universign"]) router = APIRouter(prefix="/universign", tags=["Universign Enhanced"])
sync_service = UniversignSyncService( # Service de synchronisation amélioré
universign_sync = UniversignSyncService(
api_url=settings.universign_api_url, api_key=settings.universign_api_key api_url=settings.universign_api_url, api_key=settings.universign_api_key
) )
universign_sync.configure(
sage_client=sage_client, email_queue=email_queue, settings=settings
)
class CreateSignatureRequest(BaseModel): class CreateSignatureRequest(BaseModel):
@ -43,43 +55,29 @@ class CreateSignatureRequest(BaseModel):
document_name: Optional[str] = None document_name: Optional[str] = None
class TransactionResponse(BaseModel): @router.post("/signatures/create-enhanced")
"""Réponse détaillée d'une transaction""" async def create_signature_enhanced(
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]
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) request: CreateSignatureRequest, session: AsyncSession = Depends(get_session)
): ):
"""
Création de signature avec logique métier stricte:
- Vérifie le statut Sage actuel
- Ne met à jour à 1 QUE si statut = 0
- Crée la transaction Universign
- Envoie l'email de demande
"""
try: try:
# === VÉRIFICATION STATUT SAGE ACTUEL ===
doc = sage_client.lire_document(
request.sage_document_id, request.sage_document_type.value
)
if not doc:
raise HTTPException(404, f"Document {request.sage_document_id} introuvable")
statut_actuel = doc.get("statut", 0)
logger.info(f"📊 Statut Sage actuel: {statut_actuel}")
# === VÉRIFICATION DOUBLON === # === VÉRIFICATION DOUBLON ===
existing_query = select(UniversignTransaction).where( existing_query = select(UniversignTransaction).where(
UniversignTransaction.sage_document_id == request.sage_document_id, UniversignTransaction.sage_document_id == request.sage_document_id,
@ -99,10 +97,10 @@ async def create_signature(
if existing_tx: if existing_tx:
raise HTTPException( raise HTTPException(
400, 400,
f"Une demande de signature est déjà en cours pour {request.sage_document_id} " 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})",
) )
# === GÉNÉRATION PDF ===
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)
) )
@ -112,10 +110,10 @@ async def create_signature(
# === CRÉATION TRANSACTION UNIVERSIGN === # === CRÉATION TRANSACTION UNIVERSIGN ===
import requests import requests
import uuid
auth = (settings.universign_api_key, "") auth = (settings.universign_api_key, "")
# 1. Créer transaction
resp = requests.post( resp = requests.post(
f"{settings.universign_api_url}/transactions", f"{settings.universign_api_url}/transactions",
auth=auth, auth=auth,
@ -132,6 +130,7 @@ async def create_signature(
universign_tx_id = resp.json().get("id") universign_tx_id = resp.json().get("id")
# 2. Upload PDF
files = { files = {
"file": (f"{request.sage_document_id}.pdf", pdf_bytes, "application/pdf") "file": (f"{request.sage_document_id}.pdf", pdf_bytes, "application/pdf")
} }
@ -144,6 +143,7 @@ async def create_signature(
file_id = resp.json().get("id") file_id = resp.json().get("id")
# 3. Attacher document
resp = requests.post( resp = requests.post(
f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents", f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents",
auth=auth, auth=auth,
@ -156,6 +156,7 @@ async def create_signature(
document_id = resp.json().get("id") document_id = resp.json().get("id")
# 4. Créer champ signature
resp = requests.post( resp = requests.post(
f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents/{document_id}/fields", f"{settings.universign_api_url}/transactions/{universign_tx_id}/documents/{document_id}/fields",
auth=auth, auth=auth,
@ -168,6 +169,7 @@ async def create_signature(
field_id = resp.json().get("id") field_id = resp.json().get("id")
# 5. Lier signataire
resp = requests.post( resp = requests.post(
f"{settings.universign_api_url}/transactions/{universign_tx_id}/signatures", f"{settings.universign_api_url}/transactions/{universign_tx_id}/signatures",
auth=auth, auth=auth,
@ -178,6 +180,7 @@ async def create_signature(
if resp.status_code not in [200, 201]: if resp.status_code not in [200, 201]:
raise HTTPException(500, "Erreur liaison signataire") raise HTTPException(500, "Erreur liaison signataire")
# 6. Démarrer transaction
resp = requests.post( resp = requests.post(
f"{settings.universign_api_url}/transactions/{universign_tx_id}/start", f"{settings.universign_api_url}/transactions/{universign_tx_id}/start",
auth=auth, auth=auth,
@ -189,6 +192,7 @@ async def create_signature(
final_data = resp.json() final_data = resp.json()
# 7. Extraire URL de signature
signer_url = "" signer_url = ""
if final_data.get("actions"): if final_data.get("actions"):
for action in final_data["actions"]: for action in final_data["actions"]:
@ -233,7 +237,7 @@ async def create_signature(
session.add(signer) session.add(signer)
await session.commit() await session.commit()
# === ENVOI EMAIL AVEC TEMPLATE === # === ENVOI EMAIL ===
template = templates_signature_email["demande_signature"] template = templates_signature_email["demande_signature"]
type_labels = { type_labels = {
@ -244,7 +248,7 @@ async def create_signature(
50: "Avoir", 50: "Avoir",
} }
doc_info = email_queue.sage_client.lire_document( doc_info = sage_client.lire_document(
request.sage_document_id, request.sage_document_type.value request.sage_document_id, request.sage_document_type.value
) )
montant_ttc = f"{doc_info.get('total_ttc', 0):.2f}" if doc_info else "0.00" montant_ttc = f"{doc_info.get('total_ttc', 0):.2f}" if doc_info else "0.00"
@ -288,45 +292,37 @@ async def create_signature(
email_queue.enqueue(email_log.id) email_queue.enqueue(email_log.id)
# === MISE À JOUR STATUT SAGE (Confirmé = 1) === # === MISE À JOUR STATUT SAGE (LOGIQUE STRICTE) ===
try: statut_sage_updated = False
from sage_client import sage_client
sage_client.changer_statut_document( if statut_actuel == 0:
document_type_code=request.sage_document_type.value, try:
numero=request.sage_document_id, sage_client.changer_statut_document(
nouveau_statut=1, document_type_code=request.sage_document_type.value,
) numero=request.sage_document_id,
logger.info( nouveau_statut=1, # Confirmé
f"Statut Sage mis à jour: {request.sage_document_id} → Confirmé (1)" )
) logger.info(f"✅ Statut Sage mis à jour: 0 → 1")
except Exception as e: statut_sage_updated = True
logger.warning(f"Impossible de mettre à jour le statut Sage: {e}") except Exception as e:
logger.warning(f"⚠️ Impossible de mettre à jour le statut Sage: {e}")
else:
logger.info(f" Statut Sage non modifié (était {statut_actuel}, ≠ 0)")
# === RÉPONSE === # === RÉPONSE ===
return TransactionResponse( return {
id=transaction.id, "success": True,
transaction_id=transaction.transaction_id, "transaction_id": transaction.transaction_id,
sage_document_id=transaction.sage_document_id, "sage_document_id": transaction.sage_document_id,
sage_document_type=transaction.sage_document_type.name, "signer_url": transaction.signer_url,
universign_status=transaction.universign_status.value, "statut_sage_initial": statut_actuel,
local_status=transaction.local_status.value, "statut_sage_updated": statut_sage_updated,
local_status_label=get_status_message(transaction.local_status.value), "nouveau_statut_sage": 1 if statut_sage_updated else statut_actuel,
signer_url=transaction.signer_url, "message": (
document_url=None, f"Signature créée. Statut Sage: {statut_actuel}"
created_at=transaction.created_at, f"{1 if statut_sage_updated else statut_actuel}"
sent_at=transaction.sent_at, ),
signed_at=None, }
last_synced_at=None,
needs_sync=True,
signers=[
{
"email": signer.email,
"name": signer.name,
"status": signer.status.value,
}
],
)
except HTTPException: except HTTPException:
raise raise
@ -335,167 +331,108 @@ async def create_signature(
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@router.get("/transactions", response_model=List[TransactionResponse]) @router.post("/webhook-enhanced")
async def list_transactions( @router.post("/webhook-enhanced/")
status: Optional[LocalDocumentStatus] = None, async def webhook_universign_enhanced(
sage_document_id: Optional[str] = None,
limit: int = Query(100, le=1000),
session: AsyncSession = Depends(get_session),
):
"""Liste toutes les transactions"""
query = select(UniversignTransaction).options(
selectinload(UniversignTransaction.signers)
)
if status:
query = query.where(UniversignTransaction.local_status == status)
if sage_document_id:
query = query.where(UniversignTransaction.sage_document_id == sage_document_id)
query = query.order_by(UniversignTransaction.created_at.desc()).limit(limit)
result = await session.execute(query)
transactions = result.scalars().all()
return [
TransactionResponse(
id=tx.id,
transaction_id=tx.transaction_id,
sage_document_id=tx.sage_document_id,
sage_document_type=tx.sage_document_type.name,
universign_status=tx.universign_status.value,
local_status=tx.local_status.value,
local_status_label=get_status_message(tx.local_status.value),
signer_url=tx.signer_url,
document_url=tx.document_url,
created_at=tx.created_at,
sent_at=tx.sent_at,
signed_at=tx.signed_at,
last_synced_at=tx.last_synced_at,
needs_sync=tx.needs_sync,
signers=[
{
"email": s.email,
"name": s.name,
"status": s.status.value,
"signed_at": s.signed_at.isoformat() if s.signed_at else None,
}
for s in tx.signers
],
)
for tx in transactions
]
@router.get("/transactions/{transaction_id}", response_model=TransactionResponse)
async def get_transaction(
transaction_id: str, session: AsyncSession = Depends(get_session)
):
"""Récupère une transaction par son ID"""
query = (
select(UniversignTransaction)
.where(UniversignTransaction.transaction_id == transaction_id)
.options(selectinload(UniversignTransaction.signers))
)
result = await session.execute(query)
tx = result.scalar_one_or_none()
if not tx:
raise HTTPException(404, "Transaction introuvable")
return TransactionResponse(
id=tx.id,
transaction_id=tx.transaction_id,
sage_document_id=tx.sage_document_id,
sage_document_type=tx.sage_document_type.name,
universign_status=tx.universign_status.value,
local_status=tx.local_status.value,
local_status_label=get_status_message(tx.local_status.value),
signer_url=tx.signer_url,
document_url=tx.document_url,
created_at=tx.created_at,
sent_at=tx.sent_at,
signed_at=tx.signed_at,
last_synced_at=tx.last_synced_at,
needs_sync=tx.needs_sync,
signers=[
{
"email": s.email,
"name": s.name,
"status": s.status.value,
"signed_at": s.signed_at.isoformat() if s.signed_at else None,
}
for s in tx.signers
],
)
@router.post("/transactions/{transaction_id}/sync")
async def sync_single_transaction(
transaction_id: str,
force: bool = Query(False),
session: AsyncSession = Depends(get_session),
):
"""Force la synchronisation d'une transaction"""
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, "Transaction introuvable")
success, error = await sync_service.sync_transaction(
session, transaction, force=force
)
if not success:
raise HTTPException(500, error or "Échec synchronisation")
return {
"success": True,
"transaction_id": transaction_id,
"new_status": transaction.local_status.value,
"synced_at": transaction.last_synced_at.isoformat(),
}
@router.post("/sync/all")
async def sync_all_transactions(
max_transactions: int = Query(50, le=500),
session: AsyncSession = Depends(get_session),
):
"""Synchronise toutes les transactions en attente"""
stats = await sync_service.sync_all_pending(session, max_transactions)
return {"success": True, "stats": stats, "timestamp": datetime.now().isoformat()}
@router.post("/webhook")
@router.post("/webhook/")
async def webhook_universign(
request: Request, session: AsyncSession = Depends(get_session) request: Request, session: AsyncSession = Depends(get_session)
): ):
"""
Webhook Universign amélioré:
- Détecte l'événement 'closed' (signature complétée)
- Télécharge automatiquement le document signé
- Met à jour le statut Sage à 2
- Envoie la notification avec lien de téléchargement
"""
try: try:
payload = await request.json() payload = await request.json()
logger.info( event_type = payload.get("event")
f"Webhook reçu: {payload.get('event')} - {payload.get('transaction_id')}" transaction_id = payload.get("transaction_id") or payload.get("id")
logger.info(f"📨 Webhook reçu: event={event_type}, tx={transaction_id}")
if not transaction_id:
return {"status": "error", "message": "Pas de transaction_id"}, 400
# Récupérer la transaction locale
query = (
select(UniversignTransaction)
.options(selectinload(UniversignTransaction.signers))
.where(UniversignTransaction.transaction_id == transaction_id)
)
result = await session.execute(query)
transaction = result.scalar_one_or_none()
if not transaction:
logger.warning(f"Transaction {transaction_id} inconnue")
return {"status": "error", "message": "Transaction inconnue"}, 404
transaction.webhook_received = True
# Récupérer l'état complet depuis Universign
import requests
resp = requests.get(
f"{settings.universign_api_url}/transactions/{transaction_id}",
auth=(settings.universign_api_key, ""),
timeout=30,
) )
success, error = await sync_service.process_webhook(session, payload) if resp.status_code != 200:
logger.error(f"Erreur récupération transaction: {resp.status_code}")
return {"status": "error", "message": "Erreur API Universign"}, 500
if not success: universign_data = resp.json()
logger.error(f"Erreur traitement webhook: {error}") universign_status_raw = universign_data.get("state", "")
return {"status": "error", "message": error}, 500
previous_status = transaction.local_status.value
# Déterminer le nouveau statut
from utils.universign_status_mapping import map_universign_to_local
new_status = map_universign_to_local(universign_status_raw)
# Mettre à jour la transaction
transaction.universign_status = (
UniversignTransactionStatus(universign_status_raw)
if universign_status_raw in [s.value for s in UniversignTransactionStatus]
else transaction.universign_status
)
transaction.local_status = LocalDocumentStatus(new_status)
transaction.universign_status_updated_at = datetime.now()
transaction.last_synced_at = datetime.now()
if new_status == "SIGNE" and not transaction.signed_at:
transaction.signed_at = datetime.now()
await session.commit()
# Si statut = SIGNE (completed/closed), gérer la complétion
if new_status == "SIGNE" and previous_status != "SIGNE":
logger.info(f"🎯 Signature complétée détectée via webhook")
success, error = await universign_sync.handle_signature_completed(
session=session,
transaction=transaction,
universign_data=universign_data,
)
if not success:
logger.error(f"Erreur handle_signature_completed: {error}")
return {
"status": "partial_success",
"message": "Webhook traité mais erreur téléchargement",
"error": error,
}, 200
logger.info(f"✅ Webhook traité: {previous_status}{new_status}")
return { return {
"status": "processed", "status": "success",
"event": payload.get("event"), "event": event_type,
"transaction_id": payload.get("transaction_id"), "transaction_id": transaction_id,
"previous_status": previous_status,
"new_status": new_status,
} }
except Exception as e: except Exception as e:
@ -503,293 +440,122 @@ async def webhook_universign(
return {"status": "error", "message": str(e)}, 500 return {"status": "error", "message": str(e)}, 500
@router.get("/stats", response_model=SyncStatsResponse) @router.get("/documents/{transaction_local_id}/download")
async def get_sync_stats(session: AsyncSession = Depends(get_session)): async def download_signed_document(
"""Statistiques globales de synchronisation""" transaction_local_id: str = Path(..., description="ID local de la transaction"),
# 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
)
signed = (await session.execute(signed_query)).scalar()
in_progress_query = select(func.count(UniversignTransaction.id)).where(
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.REJECTED
)
refused = (await session.execute(refused_query)).scalar()
expired_query = select(func.count(UniversignTransaction.id)).where(
UniversignTransaction.local_status == LocalDocumentStatus.EXPIRED
)
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()
return SyncStatsResponse(
total_transactions=total,
pending_sync=pending,
signed=signed,
in_progress=in_progress,
refused=refused,
expired=expired,
last_sync_at=last_sync,
)
@router.get("/transactions/{transaction_id}/logs")
async def get_transaction_logs(
transaction_id: str,
limit: int = Query(50, le=500),
session: AsyncSession = Depends(get_session),
):
# Trouver la transaction
tx_query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id
)
tx_result = await session.execute(tx_query)
tx = tx_result.scalar_one_or_none()
if not tx:
raise HTTPException(404, "Transaction introuvable")
# Logs
logs_query = (
select(UniversignSyncLog)
.where(UniversignSyncLog.transaction_id == tx.id)
.order_by(UniversignSyncLog.sync_timestamp.desc())
.limit(limit)
)
logs_result = await session.execute(logs_query)
logs = logs_result.scalars().all()
return {
"transaction_id": transaction_id,
"total_syncs": len(logs),
"logs": [
{
"sync_type": log.sync_type,
"timestamp": log.sync_timestamp.isoformat(),
"success": log.success,
"previous_status": log.previous_status,
"new_status": log.new_status,
"error_message": log.error_message,
"response_time_ms": log.response_time_ms,
}
for log in logs
],
}
@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), session: AsyncSession = Depends(get_session),
): ):
""" """
Supprime les doublons de signatures pour un document. Téléchargement sécurisé du document signé
Garde une seule transaction (la plus récente ou ancienne selon le paramètre).
**Sécurité**:
- Vérifier que le document existe
- Vérifier l'intégrité du fichier
- Retourner 404 si non trouvé
""" """
query = ( try:
select(UniversignTransaction) # Récupérer la transaction
.where(UniversignTransaction.sage_document_id == sage_document_id) query = select(UniversignTransaction).where(
.order_by( UniversignTransaction.id == transaction_local_id
UniversignTransaction.created_at.desc()
if keep_latest
else UniversignTransaction.created_at.asc()
) )
) result = await session.execute(query)
transaction = result.scalar_one_or_none()
result = await session.execute(query) if not transaction:
transactions = result.scalars().all() raise HTTPException(404, "Transaction introuvable")
if transaction.local_status != LocalDocumentStatus.SIGNED:
raise HTTPException(
400, f"Document non signé (statut: {transaction.local_status.value})"
)
# Récupérer le chemin du document
file_path = signed_documents.get_document_path(transaction)
if not file_path:
raise HTTPException(404, "Document signé non disponible")
# Vérifier l'intégrité
if not signed_documents.verify_document_integrity(file_path):
logger.error(f"Document corrompu: {file_path}")
raise HTTPException(500, "Document signé corrompu")
# Nom du fichier à télécharger
filename = f"{transaction.sage_document_id}_signe.pdf"
logger.info(
f"📥 Téléchargement: {filename} par transaction {transaction_local_id}"
)
return FileResponse(
path=file_path,
media_type="application/pdf",
filename=filename,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"X-Transaction-ID": transaction.transaction_id,
"X-Signed-At": transaction.signed_at.isoformat()
if transaction.signed_at
else "",
},
)
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("/documents/{transaction_local_id}/info")
async def get_signed_document_info(
transaction_local_id: str, session: AsyncSession = Depends(get_session)
):
"""
Informations sur le document signé (sans le télécharger)
"""
try:
query = select(UniversignTransaction).where(
UniversignTransaction.id == transaction_local_id
)
result = await session.execute(query)
transaction = result.scalar_one_or_none()
if not transaction:
raise HTTPException(404, "Transaction introuvable")
file_path = signed_documents.get_document_path(transaction)
file_info = None
if file_path:
file_stat = file_path.stat()
file_info = {
"exists": True,
"size_bytes": file_stat.st_size,
"size_mb": round(file_stat.st_size / 1024 / 1024, 2),
"created_at": datetime.fromtimestamp(file_stat.st_ctime).isoformat(),
"integrity_ok": signed_documents.verify_document_integrity(file_path),
}
if len(transactions) <= 1:
return { return {
"success": True, "transaction_id": transaction.transaction_id,
"message": "Aucun doublon trouvé", "sage_document_id": transaction.sage_document_id,
"kept": transactions[0].transaction_id if transactions else None, "sage_document_type": transaction.sage_document_type.name,
"deleted_count": 0, "local_status": transaction.local_status.value,
"signed_at": transaction.signed_at.isoformat()
if transaction.signed_at
else None,
"downloaded_at": (
transaction.signed_document_downloaded_at.isoformat()
if transaction.signed_document_downloaded_at
else None
),
"file_info": file_info,
"download_url": f"/universign/documents/{transaction_local_id}/download",
} }
# Garder la première (selon l'ordre), supprimer les autres except HTTPException:
to_keep = transactions[0] raise
to_delete = transactions[1:] except Exception as e:
logger.error(f"Erreur récupération info document: {e}")
deleted_ids = [] raise HTTPException(500, str(e))
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

@ -0,0 +1,188 @@
import os
import requests
import hashlib
import logging
from pathlib import Path
from typing import Optional, Tuple
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from database import UniversignTransaction
logger = logging.getLogger(__name__)
class SignedDocuments:
"""Service de gestion des documents signés"""
def __init__(self, storage_path: str = "./data/signed_documents"):
self.storage_path = Path(storage_path)
self.storage_path.mkdir(parents=True, exist_ok=True)
# Créer des sous-répertoires par type de document
for doc_type in ["devis", "commandes", "factures", "livraisons", "avoirs"]:
(self.storage_path / doc_type).mkdir(exist_ok=True)
def _get_storage_subdir(self, sage_doc_type: int) -> str:
"""Retourne le sous-répertoire selon le type de document Sage"""
mapping = {
0: "devis",
10: "commandes",
30: "livraisons",
50: "avoirs",
60: "factures",
}
return mapping.get(sage_doc_type, "autres")
def _generate_filename(
self, transaction_id: str, sage_doc_id: str, sage_doc_type: int
) -> str:
"""Génère un nom de fichier unique et sécurisé"""
# Hash du transaction_id pour éviter les collisions
hash_suffix = hashlib.md5(transaction_id.encode()).hexdigest()[:8]
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{sage_doc_id}_{timestamp}_{hash_suffix}_signed.pdf"
async def download_and_store(
self,
session: AsyncSession,
transaction: UniversignTransaction,
document_url: str,
api_key: str,
) -> Tuple[bool, Optional[str], Optional[str]]:
"""
Télécharge et stocke le document signé
Returns:
(success, file_path, error_message)
"""
try:
# Télécharger le document depuis Universign
logger.info(f"Téléchargement document signé: {transaction.transaction_id}")
response = requests.get(
document_url, auth=(api_key, ""), timeout=60, stream=True
)
if response.status_code != 200:
error = f"Erreur HTTP {response.status_code} lors du téléchargement"
logger.error(error)
return False, None, error
# Vérifier que c'est bien un PDF
content_type = response.headers.get("Content-Type", "")
if "pdf" not in content_type.lower():
error = f"Type de contenu invalide: {content_type}"
logger.warning(error)
# Générer le nom de fichier et le chemin
subdir = self._get_storage_subdir(transaction.sage_document_type.value)
filename = self._generate_filename(
transaction.transaction_id,
transaction.sage_document_id,
transaction.sage_document_type.value,
)
file_path = self.storage_path / subdir / filename
# Écrire le fichier
with open(file_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
file_size = file_path.stat().st_size
logger.info(f"Document stocké: {file_path} ({file_size} octets)")
# Mettre à jour la transaction
transaction.signed_document_path = str(file_path)
transaction.signed_document_downloaded_at = datetime.now()
await session.commit()
return True, str(file_path), None
except requests.exceptions.RequestException as e:
error = f"Erreur réseau lors du téléchargement: {str(e)}"
logger.error(error, exc_info=True)
return False, None, error
except IOError as e:
error = f"Erreur d'écriture du fichier: {str(e)}"
logger.error(error, exc_info=True)
return False, None, error
except Exception as e:
error = f"Erreur inattendue: {str(e)}"
logger.error(error, exc_info=True)
return False, None, error
def get_document_path(self, transaction: UniversignTransaction) -> Optional[Path]:
"""Retourne le chemin du document signé s'il existe"""
if not transaction.signed_document_path:
return None
path = Path(transaction.signed_document_path)
if not path.exists():
logger.warning(f"Document signé introuvable: {path}")
return None
return path
def verify_document_integrity(self, file_path: Path) -> bool:
"""Vérifie l'intégrité basique du document (taille, extension)"""
try:
if not file_path.exists():
return False
# Vérifier que le fichier n'est pas vide
if file_path.stat().st_size == 0:
logger.error(f"Document vide: {file_path}")
return False
# Vérifier l'extension
if file_path.suffix.lower() != ".pdf":
logger.error(f"Extension invalide: {file_path}")
return False
# Vérifier les premiers octets (signature PDF)
with open(file_path, "rb") as f:
header = f.read(5)
if header != b"%PDF-":
logger.error(f"Signature PDF invalide: {file_path}")
return False
return True
except Exception as e:
logger.error(f"Erreur vérification intégrité: {e}")
return False
async def cleanup_old_documents(self, days_to_keep: int = 365):
"""Nettoie les documents signés de plus de X jours (archivage)"""
cutoff_date = datetime.now().timestamp() - (days_to_keep * 86400)
deleted_count = 0
try:
for subdir in self.storage_path.iterdir():
if not subdir.is_dir():
continue
for file_path in subdir.glob("*.pdf"):
if file_path.stat().st_mtime < cutoff_date:
logger.info(f"Suppression ancien document: {file_path}")
file_path.unlink()
deleted_count += 1
logger.info(f"Nettoyage terminé: {deleted_count} document(s) supprimé(s)")
return deleted_count
except Exception as e:
logger.error(f"Erreur nettoyage documents: {e}")
return 0
# Instance globale
signed_documents = SignedDocuments(
storage_path=os.getenv("SIGNED_DOCS_PATH", "./data/signed_documents")
)

View file

@ -1,539 +1,340 @@
import requests
import json
import logging
import uuid import uuid
import logging
from typing import Dict, Optional, Tuple from typing import Dict, Optional, Tuple
from datetime import datetime, timedelta from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_
from sqlalchemy.orm import selectinload
from database import ( from database import (
UniversignTransaction, UniversignTransaction,
UniversignSigner,
UniversignSyncLog,
UniversignTransactionStatus,
LocalDocumentStatus,
UniversignSignerStatus,
EmailLog, EmailLog,
StatutEmail, StatutEmail,
) )
from data.data import templates_signature_email from data.data import templates_signature_email
from utils.universign_status_mapping import ( from services.signed_documents import signed_documents
map_universign_to_local,
is_transition_allowed,
get_status_actions,
is_final_status,
resolve_status_conflict,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class UniversignSyncService: class UniversignSyncService:
def __init__(self, api_url: str, api_key: str, timeout: int = 30): """Service de synchronisation avec logique métier complète"""
def __init__(self, api_url: str, api_key: str):
self.api_url = api_url.rstrip("/") self.api_url = api_url.rstrip("/")
self.api_key = api_key self.api_key = api_key
self.timeout = timeout
self.auth = (api_key, "")
self.sage_client = None self.sage_client = None
self.email_queue = None self.email_queue = None
self.settings = None self.settings = None
def configure(self, sage_client, email_queue, settings): def configure(self, sage_client, email_queue, settings):
"""Configure les dépendances injectées"""
self.sage_client = sage_client self.sage_client = sage_client
self.email_queue = email_queue self.email_queue = email_queue
self.settings = settings self.settings = settings
def fetch_transaction_status(self, transaction_id: str) -> Optional[Dict]: async def handle_signature_completed(
start_time = datetime.now()
try:
response = requests.get(
f"{self.api_url}/transactions/{transaction_id}",
auth=self.auth,
timeout=self.timeout,
headers={"Accept": "application/json"},
)
response_time_ms = int((datetime.now() - start_time).total_seconds() * 1000)
if response.status_code == 200:
data = response.json()
logger.info(
f"Fetch OK: {transaction_id} status={data.get('state')} ({response_time_ms}ms)"
)
return {
"transaction": data,
"http_status": 200,
"response_time_ms": response_time_ms,
"fetched_at": datetime.now(),
}
elif response.status_code == 404:
logger.warning(
f"Transaction {transaction_id} introuvable sur Universign"
)
return None
else:
logger.error(
f"Erreur HTTP {response.status_code} pour {transaction_id}: {response.text}"
)
return None
except requests.exceptions.Timeout:
logger.error(f"Timeout récupération {transaction_id} (>{self.timeout}s)")
return None
except Exception as e:
logger.error(f"Erreur fetch {transaction_id}: {e}", exc_info=True)
return None
async def sync_transaction(
self,
session: AsyncSession,
transaction: UniversignTransaction,
force: bool = False,
) -> Tuple[bool, Optional[str]]:
if is_final_status(transaction.local_status.value) and not force:
logger.debug(
f"Skip {transaction.transaction_id}: statut final {transaction.local_status.value}"
)
transaction.needs_sync = False
await session.commit()
return True, None
result = self.fetch_transaction_status(transaction.transaction_id)
if not result:
error = "Échec récupération données Universign"
await self._log_sync_attempt(session, transaction, "polling", False, error)
return False, error
universign_data = result["transaction"]
universign_status_raw = universign_data.get("state", "draft")
new_local_status = map_universign_to_local(universign_status_raw)
previous_local_status = transaction.local_status.value
if not is_transition_allowed(previous_local_status, new_local_status):
logger.warning(
f"Transition refusée: {previous_local_status}{new_local_status}"
)
new_local_status = resolve_status_conflict(
previous_local_status, new_local_status
)
status_changed = previous_local_status != new_local_status
if not status_changed and not force:
logger.debug(f"Pas de changement pour {transaction.transaction_id}")
transaction.last_synced_at = datetime.now()
transaction.needs_sync = False
await session.commit()
return True, None
try:
transaction.universign_status = UniversignTransactionStatus(
universign_status_raw
)
except ValueError:
transaction.universign_status = (
UniversignTransactionStatus.COMPLETED
if new_local_status == "SIGNE"
else UniversignTransactionStatus.FAILED
)
transaction.local_status = LocalDocumentStatus(new_local_status)
transaction.universign_status_updated_at = datetime.now()
if new_local_status == "EN_COURS" and not transaction.sent_at:
transaction.sent_at = datetime.now()
if new_local_status == "SIGNE" and not transaction.signed_at:
transaction.signed_at = datetime.now()
if new_local_status == "REFUSE" and not transaction.refused_at:
transaction.refused_at = datetime.now()
if new_local_status == "EXPIRE" and not transaction.expired_at:
transaction.expired_at = datetime.now()
if universign_data.get("documents") and len(universign_data["documents"]) > 0:
first_doc = universign_data["documents"][0]
if first_doc.get("url"):
transaction.document_url = first_doc["url"]
await self._sync_signers(session, transaction, universign_data)
transaction.last_synced_at = datetime.now()
transaction.sync_attempts += 1
transaction.needs_sync = not is_final_status(new_local_status)
transaction.sync_error = None
await self._log_sync_attempt(
session=session,
transaction=transaction,
sync_type="polling",
success=True,
error_message=None,
previous_status=previous_local_status,
new_status=new_local_status,
changes=json.dumps(
{
"status_changed": status_changed,
"universign_raw": universign_status_raw,
"response_time_ms": result.get("response_time_ms"),
}
),
)
await session.commit()
if status_changed:
await self._execute_status_actions(session, transaction, new_local_status)
logger.info(
f"Sync OK: {transaction.transaction_id} {previous_local_status}{new_local_status}"
)
return True, None
async def sync_all_pending(
self, session: AsyncSession, max_transactions: int = 50
) -> Dict[str, int]:
query = (
select(UniversignTransaction)
.options(selectinload(UniversignTransaction.signers))
.where(
and_(
UniversignTransaction.needs_sync,
or_(
~UniversignTransaction.local_status.in_(
[
LocalDocumentStatus.SIGNED,
LocalDocumentStatus.REJECTED,
LocalDocumentStatus.EXPIRED,
]
),
UniversignTransaction.last_synced_at
< (datetime.now() - timedelta(hours=1)),
UniversignTransaction.last_synced_at.is_(None),
),
)
)
.order_by(UniversignTransaction.created_at.asc())
.limit(max_transactions)
)
result = await session.execute(query)
transactions = result.scalars().all()
stats = {
"total_found": len(transactions),
"success": 0,
"failed": 0,
"skipped": 0,
"status_changes": 0,
}
for transaction in transactions:
try:
previous_status = transaction.local_status.value
success, error = await self.sync_transaction(
session, transaction, force=False
)
if success:
stats["success"] += 1
if transaction.local_status.value != previous_status:
stats["status_changes"] += 1
else:
stats["failed"] += 1
except Exception as e:
logger.error(
f"Erreur sync {transaction.transaction_id}: {e}", exc_info=True
)
stats["failed"] += 1
logger.info(
f"Polling terminé: {stats['success']}/{stats['total_found']} OK, {stats['status_changes']} changements détectés"
)
return stats
async def process_webhook(
self, session: AsyncSession, payload: Dict
) -> Tuple[bool, Optional[str]]:
try:
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"
query = (
select(UniversignTransaction)
.options(selectinload(UniversignTransaction.signers))
.where(UniversignTransaction.transaction_id == transaction_id)
)
result = await session.execute(query)
transaction = result.scalar_one_or_none()
if not transaction:
logger.warning(
f"Webhook reçu pour transaction inconnue: {transaction_id}"
)
return False, "Transaction inconnue"
transaction.webhook_received = True
success, error = await self.sync_transaction(
session, transaction, force=True
)
await self._log_sync_attempt(
session=session,
transaction=transaction,
sync_type=f"webhook:{event_type}",
success=success,
error_message=error,
changes=json.dumps(payload),
)
await session.commit()
logger.info(
f"Webhook traité: {transaction_id} event={event_type} success={success}"
)
return success, error
except Exception as e:
logger.error(f"Erreur traitement webhook: {e}", exc_info=True)
return False, str(e)
async def _sync_signers(
self, self,
session: AsyncSession, session: AsyncSession,
transaction: UniversignTransaction, transaction: UniversignTransaction,
universign_data: Dict, universign_data: Dict,
): ) -> Tuple[bool, Optional[str]]:
signers_data = universign_data.get("signers", []) """
Gère la complétion d'une signature:
1. Télécharge et stocke le document signé
2. Met à jour le statut Sage à 2 (accepté)
3. Envoie la notification avec lien de téléchargement
"""
try:
logger.info(
f"🎯 Traitement signature complétée: {transaction.transaction_id}"
)
# Ne pas toucher aux signers existants si Universign n'en retourne pas # Étape 1: Télécharger le document signé
if not signers_data: document_url = self._extract_document_url(universign_data)
return
# Mettre à jour les signers existants ou en créer de nouveaux if not document_url:
existing_signers = {s.email: s for s in transaction.signers} error = "URL du document signé non trouvée dans la réponse Universign"
logger.error(error)
return False, error
for idx, signer_data in enumerate(signers_data): (
email = signer_data.get("email", "") success,
file_path,
error,
) = await signed_documents.download_and_store(
session=session,
transaction=transaction,
document_url=document_url,
api_key=self.api_key,
)
if email in existing_signers: if not success:
# Mise à jour du signer existant return False, f"Échec téléchargement document: {error}"
signer = existing_signers[email]
signer.status = UniversignSignerStatus( logger.info(f"✅ Document signé stocké: {file_path}")
signer_data.get("status", "waiting")
) # Étape 2: Mettre à jour le statut Sage UNIQUEMENT si ≠ 2
signer.viewed_at = ( current_sage_status = await self._get_current_sage_status(transaction)
self._parse_date(signer_data.get("viewed_at")) or signer.viewed_at
) if current_sage_status != 2:
signer.signed_at = ( success_sage = await self._update_sage_to_accepted(transaction)
self._parse_date(signer_data.get("signed_at")) or signer.signed_at
) if success_sage:
signer.refused_at = ( logger.info(f"✅ Statut Sage mis à jour: {current_sage_status} → 2")
self._parse_date(signer_data.get("refused_at")) or signer.refused_at else:
) logger.warning(
f"⚠️ Échec mise à jour statut Sage pour {transaction.sage_document_id}"
)
else: else:
# Nouveau signer logger.info(f" Statut Sage déjà à 2, pas de mise à jour")
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( # Étape 3: Envoyer notification avec lien de téléchargement
notification_sent = await self._send_signature_confirmation(
session=session,
transaction=transaction,
download_link=self._generate_download_link(transaction),
)
if not notification_sent:
logger.warning("⚠️ Notification non envoyée (mais document stocké)")
return True, None
except Exception as e:
error = f"Erreur handle_signature_completed: {str(e)}"
logger.error(error, exc_info=True)
return False, error
def _extract_document_url(self, universign_data: Dict) -> Optional[str]:
"""Extrait l'URL du document signé depuis la réponse Universign"""
try:
# Structure: data['documents'][0]['url']
documents = universign_data.get("documents", [])
if documents and len(documents) > 0:
return documents[0].get("url")
# Fallback: vérifier dans les actions
actions = universign_data.get("actions", [])
for action in actions:
if action.get("type") == "download" and action.get("url"):
return action["url"]
return None
except Exception as e:
logger.error(f"Erreur extraction URL document: {e}")
return None
async def _get_current_sage_status(self, transaction: UniversignTransaction) -> int:
"""Récupère le statut actuel du document dans Sage"""
try:
if not self.sage_client:
logger.warning("sage_client non configuré")
return 0
doc = self.sage_client.lire_document(
transaction.sage_document_id, transaction.sage_document_type.value
)
return doc.get("statut", 0) if doc else 0
except Exception as e:
logger.error(f"Erreur lecture statut Sage: {e}")
return 0
async def _update_sage_to_accepted(
self, transaction: UniversignTransaction
) -> bool:
"""Met à jour le statut Sage à 2 (accepté)"""
try:
if not self.sage_client:
logger.warning("sage_client non configuré")
return False
self.sage_client.changer_statut_document(
document_type_code=transaction.sage_document_type.value,
numero=transaction.sage_document_id,
nouveau_statut=2, # Accepté
)
return True
except Exception as e:
logger.error(f"Erreur mise à jour Sage: {e}")
return False
def _generate_download_link(self, transaction: UniversignTransaction) -> str:
"""Génère le lien de téléchargement sécurisé"""
base_url = (
self.settings.api_base_url if self.settings else "http://localhost:8000"
)
return f"{base_url}/universign/documents/{transaction.id}/download"
async def _send_signature_confirmation(
self, self,
session: AsyncSession, session: AsyncSession,
transaction: UniversignTransaction, transaction: UniversignTransaction,
sync_type: str, download_link: str,
success: bool, ) -> bool:
error_message: Optional[str] = None, """Envoie l'email de confirmation avec lien de téléchargement"""
previous_status: Optional[str] = None,
new_status: Optional[str] = None,
changes: Optional[str] = None,
):
log = UniversignSyncLog(
transaction_id=transaction.id,
sync_type=sync_type,
sync_timestamp=datetime.now(),
previous_status=previous_status,
new_status=new_status,
changes_detected=changes,
success=success,
error_message=error_message,
)
session.add(log)
async def _execute_status_actions(
self, session: AsyncSession, transaction: UniversignTransaction, new_status: str
):
actions = get_status_actions(new_status)
if not actions:
return
if actions.get("update_sage_status"):
await self._update_sage_status(transaction, new_status)
if actions.get("send_notification"):
await self._send_notification(session, transaction, new_status)
async def _update_sage_status(
self, transaction: UniversignTransaction, status: str
):
if not self.sage_client:
logger.warning("sage_client non configuré pour mise à jour Sage")
return
try: try:
type_doc = transaction.sage_document_type.value if not self.email_queue or not self.settings:
doc_id = transaction.sage_document_id logger.warning("email_queue ou settings non configuré")
return False
if status == "SIGNE": template = templates_signature_email["signature_confirmee"]
self.sage_client.changer_statut_document(
document_type_code=type_doc, numero=doc_id, nouveau_statut=2
)
logger.info(f"Statut Sage mis à jour: {doc_id} → Accepté (2)")
elif status == "EN_COURS": type_labels = {
self.sage_client.changer_statut_document( 0: "Devis",
document_type_code=type_doc, numero=doc_id, nouveau_statut=1 10: "Commande",
) 30: "Bon de Livraison",
logger.info(f"Statut Sage mis à jour: {doc_id} → Confirmé (1)") 60: "Facture",
50: "Avoir",
}
except Exception as e: variables = {
logger.error( "NOM_SIGNATAIRE": transaction.requester_name or "Client",
f"Erreur mise à jour Sage pour {transaction.sage_document_id}: {e}" "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,
"DOWNLOAD_LINK": download_link, # Nouvelle variable
}
sujet = template["sujet"]
# Corps modifié pour inclure le lien de téléchargement
corps = template["corps_html"].replace(
"</td>\n </tr>\n \n <!-- Footer -->",
f"""</td>
</tr>
<!-- Download Section -->
<tr>
<td style="padding: 20px 30px; background-color: #f0f9ff; border: 1px solid #90cdf4; border-radius: 4px; margin: 20px 0;">
<p style="color: #2c5282; font-size: 14px; line-height: 1.6; margin: 0 0 15px;">
📄 <strong>Télécharger le document signé :</strong>
</p>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<a href="{download_link}" style="display: inline-block; background: #48bb78; color: #ffffff; text-decoration: none; padding: 12px 30px; border-radius: 6px; font-size: 14px; font-weight: 600;">
Télécharger le PDF signé
</a>
</td>
</tr>
</table>
<p style="color: #718096; font-size: 12px; margin: 15px 0 0; text-align: center;">
Ce lien est valable pendant 1 an
</p>
</td>
</tr>
<!-- Footer -->""",
) )
async def _send_notification( for var, valeur in variables.items():
self, session: AsyncSession, transaction: UniversignTransaction, status: str sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur))
): corps = corps.replace(f"{{{{{var}}}}}", str(valeur))
if not self.email_queue or not self.settings:
logger.warning("email_queue ou settings non configuré")
return
try: email_log = EmailLog(
if status == "SIGNE": id=str(uuid.uuid4()),
template = templates_signature_email["signature_confirmee"] destinataire=transaction.requester_email,
sujet=sujet,
type_labels = { corps_html=corps,
0: "Devis", document_ids=transaction.sage_document_id,
10: "Commande", type_document=transaction.sage_document_type.value,
30: "Bon de Livraison", statut=StatutEmail.EN_ATTENTE,
60: "Facture", date_creation=datetime.now(),
50: "Avoir", nb_tentatives=0,
}
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 session.add(email_log)
def _parse_date(date_str: Optional[str]) -> Optional[datetime]: await session.flush()
if not date_str:
return None
try:
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
except Exception:
return None
self.email_queue.enqueue(email_log.id)
class UniversignSyncScheduler: logger.info(f"📧 Email confirmation envoyé à {transaction.requester_email}")
def __init__(self, sync_service: UniversignSyncService, interval_minutes: int = 5): return True
self.sync_service = sync_service
self.interval_minutes = interval_minutes
self.is_running = False
async def start(self, session_factory): except Exception as e:
import asyncio logger.error(f"Erreur envoi notification: {e}", exc_info=True)
return False
self.is_running = True
async def handle_status_transition(
self,
session: AsyncSession,
transaction: UniversignTransaction,
previous_status: str,
new_status: str,
universign_data: Dict,
) -> Tuple[bool, Optional[str]]:
"""
Gère les transitions de statut avec logique métier
"""
logger.info( logger.info(
f"Démarrage polling Universign (intervalle: {self.interval_minutes}min)" f"🔄 Transition: {transaction.transaction_id} "
f"{previous_status}{new_status}"
) )
while self.is_running: # Si passage à SIGNE (completed)
try: if new_status == "SIGNE" and previous_status != "SIGNE":
async with session_factory() as session: return await self.handle_signature_completed(
stats = await self.sync_service.sync_all_pending(session) session=session,
transaction=transaction,
universign_data=universign_data,
)
logger.info( # Si passage à REFUSE
f"Polling: {stats['success']} transactions synchronisées, " elif new_status == "REFUSE" and previous_status != "REFUSE":
f"{stats['status_changes']} changements" await self._update_sage_to_refused(transaction)
)
except Exception as e: # Si passage à EXPIRE
logger.error(f"Erreur polling: {e}", exc_info=True) elif new_status == "EXPIRE" and previous_status != "EXPIRE":
await self._update_sage_to_expired(transaction)
await asyncio.sleep(self.interval_minutes * 60) return True, None
def stop(self): async def _update_sage_to_refused(self, transaction: UniversignTransaction):
self.is_running = False """Met à jour Sage quand signature refusée"""
logger.info("Arrêt polling Universign") try:
if not self.sage_client:
return
# Statut 3 = Perdu/Refusé (selon config Sage)
self.sage_client.changer_statut_document(
document_type_code=transaction.sage_document_type.value,
numero=transaction.sage_document_id,
nouveau_statut=3,
)
logger.info(
f"📛 Statut Sage → 3 (Refusé) pour {transaction.sage_document_id}"
)
except Exception as e:
logger.error(f"Erreur mise à jour Sage (refusé): {e}")
async def _update_sage_to_expired(self, transaction: UniversignTransaction):
"""Met à jour Sage quand signature expirée"""
try:
if not self.sage_client:
return
# Statut 4 = Expiré/Archivé (selon config Sage)
self.sage_client.changer_statut_document(
document_type_code=transaction.sage_document_type.value,
numero=transaction.sage_document_id,
nouveau_statut=4,
)
logger.info(
f"⏰ Statut Sage → 4 (Expiré) pour {transaction.sage_document_id}"
)
except Exception as e:
logger.error(f"Erreur mise à jour Sage (expiré): {e}")