feat(universign): add signed document download and storage functionality

This commit is contained in:
Fanilo-Nantenaina 2026-01-13 11:50:32 +03:00
parent 74c0d73294
commit 983e960b9b
17 changed files with 903 additions and 348 deletions

View file

@ -1,23 +1,78 @@
# Backend Dockerfile # ================================
FROM python:3.12-slim # Base
# ================================
FROM python:3.12-slim AS base
WORKDIR /app WORKDIR /app
# Copier et installer les dépendances # Installer dépendances système si nécessaire
COPY requirements.txt . RUN apt-get update && apt-get install -y --no-install-recommends \
RUN pip install --no-cache-dir --upgrade pip \ curl \
&& pip install --no-cache-dir -r requirements.txt && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --upgrade pip
# ================================
# DEV
# ================================
FROM base AS dev
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
ENV=development
# Installer dépendances dev (si vous avez un requirements.dev.txt)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Créer dossiers
RUN mkdir -p /app/data /app/logs && chmod -R 777 /app/data /app/logs
# Copier le reste du projet
COPY . . COPY . .
# Créer dossier persistant pour SQLite avec bonnes permissions
RUN mkdir -p /app/data && chmod 777 /app/data
# Exposer le port
EXPOSE 8000 EXPOSE 8000
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
# Lancer l'API et initialiser la DB au démarrage # ================================
# CMD ["sh", "-c", "uvicorn api:app --host 0.0.0.0 --port 8000"] # STAGING
# ================================
FROM base AS staging
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
ENV=staging
CMD ["sh", "-c", "python init_db.py && uvicorn api:app --host 0.0.0.0 --port 8000"] RUN pip install --no-cache-dir -r requirements.txt
RUN mkdir -p /app/data /app/logs && chmod -R 755 /app/data /app/logs
COPY . .
# Initialiser la DB au build
RUN python init_db.py || true
EXPOSE 8002
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8002", "--log-level", "info"]
# ================================
# PROD
# ================================
FROM base AS prod
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
ENV=production
RUN pip install --no-cache-dir -r requirements.txt
# Créer utilisateur non-root pour la sécurité
RUN useradd -m -u 1000 appuser && \
mkdir -p /app/data /app/logs && \
chown -R appuser:appuser /app
COPY --chown=appuser:appuser . .
# Initialiser la DB au build
RUN python init_db.py || true
USER appuser
EXPOSE 8004
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8004", "--workers", "4"]

26
api.py
View file

@ -176,10 +176,10 @@ app.include_router(universign_router)
@app.get("/clients", response_model=List[ClientDetails], tags=["Clients"]) @app.get("/clients", response_model=List[ClientDetails], tags=["Clients"])
async def obtenir_clients( async def obtenir_clients(
query: Optional[str] = Query(None), query: Optional[str] = Query(None),
sage: SageGatewayClient = Depends(get_sage_client_for_user), #sage: SageGatewayClient = Depends(get_sage_client_for_user),
): ):
try: try:
clients = sage.lister_clients(filtre=query or "") clients = sage_client.lister_clients(filtre=query or "")
return [ClientDetails(**c) for c in clients] return [ClientDetails(**c) for c in clients]
except Exception as e: except Exception as e:
logger.error(f"Erreur recherche clients: {e}") logger.error(f"Erreur recherche clients: {e}")
@ -391,7 +391,7 @@ async def creer_devis(devis: DevisRequest):
resultat = sage_client.creer_devis(devis_data) resultat = sage_client.creer_devis(devis_data)
logger.info( logger.info(
f"Devis créé: {resultat.get('numero_devis')} " f"Devis créé: {resultat.get('numero_devis')} "
f"({resultat.get('total_ttc')}€ TTC)" f"({resultat.get('total_ttc')}€ TTC)"
) )
@ -405,7 +405,7 @@ async def creer_devis(devis: DevisRequest):
) )
except Exception as e: except Exception as e:
logger.error(f"Erreur création devis: {e}") logger.error(f"Erreur création devis: {e}")
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@ -474,7 +474,7 @@ async def creer_commande(
resultat = sage_client.creer_commande(commande_data) resultat = sage_client.creer_commande(commande_data)
logger.info( logger.info(
f"Commande créée: {resultat.get('numero_commande')} " f"Commande créée: {resultat.get('numero_commande')} "
f"({resultat.get('total_ttc')}€ TTC)" f"({resultat.get('total_ttc')}€ TTC)"
) )
@ -496,7 +496,7 @@ async def creer_commande(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Erreur création commande: {e}") logger.error(f"Erreur création commande: {e}")
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@ -1420,7 +1420,7 @@ async def creer_facture(
resultat = sage_client.creer_facture(facture_data) resultat = sage_client.creer_facture(facture_data)
logger.info( logger.info(
f"Facture créée: {resultat.get('numero_facture')} " f"Facture créée: {resultat.get('numero_facture')} "
f"({resultat.get('total_ttc')}€ TTC)" f"({resultat.get('total_ttc')}€ TTC)"
) )
@ -1442,7 +1442,7 @@ async def creer_facture(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Erreur création facture: {e}") logger.error(f"Erreur création facture: {e}")
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@ -1943,7 +1943,7 @@ async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_se
resultat = sage_client.creer_avoir(avoir_data) resultat = sage_client.creer_avoir(avoir_data)
logger.info( logger.info(
f"Avoir créé: {resultat.get('numero_avoir')} " f"Avoir créé: {resultat.get('numero_avoir')} "
f"({resultat.get('total_ttc')}€ TTC)" f"({resultat.get('total_ttc')}€ TTC)"
) )
@ -1965,7 +1965,7 @@ async def creer_avoir(avoir: AvoirCreate, session: AsyncSession = Depends(get_se
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Erreur création avoir: {e}") logger.error(f"Erreur création avoir: {e}")
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@ -2070,7 +2070,7 @@ async def creer_livraison(
resultat = sage_client.creer_livraison(livraison_data) resultat = sage_client.creer_livraison(livraison_data)
logger.info( logger.info(
f"Livraison créée: {resultat.get('numero_livraison')} " f"Livraison créée: {resultat.get('numero_livraison')} "
f"({resultat.get('total_ttc')}€ TTC)" f"({resultat.get('total_ttc')}€ TTC)"
) )
@ -2092,7 +2092,7 @@ async def creer_livraison(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Erreur création livraison: {e}") logger.error(f"Erreur création livraison: {e}")
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@ -2446,7 +2446,7 @@ async def creer_sortie_stock(sortie: SortieStock):
if sortie_data.get("date_sortie"): if sortie_data.get("date_sortie"):
sortie_data["date_sortie"] = sortie_data["date_sortie"].isoformat() 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) resultat = sage_client.creer_sortie_stock(sortie_data)

18
data/status_latest.py Normal file
View file

@ -0,0 +1,18 @@
import enum
class StatutEmail(str, enum.Enum):
EN_ATTENTE = "EN_ATTENTE"
EN_COURS = "EN_COURS"
ENVOYE = "ENVOYE"
OUVERT = "OUVERT"
ERREUR = "ERREUR"
BOUNCE = "BOUNCE"
class StatutSignature(str, enum.Enum):
EN_ATTENTE = "EN_ATTENTE"
ENVOYE = "ENVOYE"
SIGNE = "SIGNE"
REFUSE = "REFUSE"
EXPIRE = "EXPIRE"

View file

@ -7,7 +7,7 @@ from database.models.generic_model import Base
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///./data/sage_dataven.db") DATABASE_URL = os.getenv("DATABASE_URL")
engine = create_async_engine( engine = create_async_engine(
DATABASE_URL, DATABASE_URL,

View file

@ -50,7 +50,7 @@ class LocalDocumentStatus(str, Enum):
class SageDocumentType(int, Enum): class SageDocumentType(int, Enum):
DEVIS = 0 DEVIS = 0
BON_COMMANDE = 10 BON_COMMANDE = 10
PREPARATION = 20 PREPARATION = 20
BON_LIVRAISON = 30 BON_LIVRAISON = 30
BON_RETOUR = 40 BON_RETOUR = 40
@ -106,6 +106,23 @@ class UniversignTransaction(Base):
# === URLS ET MÉTADONNÉES UNIVERSIGN === # === URLS ET MÉTADONNÉES UNIVERSIGN ===
signer_url = Column(Text, nullable=True, comment="URL de signature") signer_url = Column(Text, nullable=True, comment="URL de signature")
document_url = Column(Text, nullable=True, comment="URL du document signé") document_url = Column(Text, nullable=True, comment="URL du document signé")
signed_document_path = Column(
Text, nullable=True, comment="Chemin local du PDF signé"
)
signed_document_downloaded_at = Column(
DateTime, nullable=True, comment="Date de téléchargement du document"
)
signed_document_size_bytes = Column(
Integer, nullable=True, comment="Taille du fichier en octets"
)
download_attempts = Column(
Integer, default=0, comment="Nombre de tentatives de téléchargement"
)
download_error = Column(
Text, nullable=True, comment="Dernière erreur de téléchargement"
)
certificate_url = Column(Text, nullable=True, comment="URL du certificat") certificate_url = Column(Text, nullable=True, comment="URL du certificat")
# === SIGNATAIRES === # === SIGNATAIRES ===
@ -268,7 +285,7 @@ class UniversignConfig(Base):
) )
api_url = Column(String(500), nullable=False) api_url = Column(String(500), nullable=False)
api_key = Column(String(500), nullable=False, comment="⚠️ À chiffrer") api_key = Column(String(500), nullable=False, comment="À chiffrer")
# === OPTIONS === # === OPTIONS ===
webhook_url = Column(String(500), nullable=True) webhook_url = Column(String(500), nullable=True)

24
docker-compose.dev.yml Normal file
View file

@ -0,0 +1,24 @@
services:
backend:
container_name: dev-sage-api
build:
context: .
target: dev
env_file: .env
volumes:
- .:/app
- /app/__pycache__
- ./data:/app/data
- ./logs:/app/logs
ports:
- "8000:8000"
environment:
ENV: development
DEBUG: "true"
DATABASE_URL: "sqlite+aiosqlite:///./data/sage_dataven.db"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3

23
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,23 @@
services:
backend:
container_name: prod_sage_api
build:
context: .
target: prod
env_file: .env.production
volumes:
- ./data:/app/data
- ./logs:/app/logs
ports:
- "8004:8004"
environment:
ENV: production
DEBUG: "false"
DATABASE_URL: "sqlite+aiosqlite:///./data/sage_prod.db"
restart: always
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8004/health"]
interval: 30s
timeout: 10s
retries: 5
start_period: 40s

View file

@ -0,0 +1,22 @@
services:
backend:
container_name: staging_sage_api
build:
context: .
target: staging
env_file: .env.staging
volumes:
- ./data:/app/data
- ./logs:/app/logs
ports:
- "8002:8002"
environment:
ENV: staging
DEBUG: "false"
DATABASE_URL: "sqlite+aiosqlite:///./data/sage_staging.db"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8002/health"]
interval: 30s
timeout: 10s
retries: 3

View file

@ -1,11 +1,4 @@
services: services:
vps-sage-api: backend:
build: . build:
container_name: vps-sage-api context: .
env_file: .env
volumes:
- ./data:/app/data
- ./logs:/app/logs
ports:
- "8000:8000"
restart: unless-stopped

View file

@ -14,33 +14,14 @@ logger = logging.getLogger(__name__)
async def main(): async def main():
print("\n" + "=" * 60)
print("Initialisation de la base de données délocalisée")
print("=" * 60 + "\n")
try: try:
logger.info("Debut de l'initialisation") logger.info("Debut de l'initialisation")
await init_db() await init_db()
logger.info("Initialisation terminee") logger.info("Initialisation terminee")
print("\nInitialisation terminee")
print("\nBase de données créée avec succès !") print("\nBase de données créée avec succès !")
print("Fichier: sage_dataven.db")
print("\nTables créées:")
print(" |- email_logs (Journalisation emails)")
print(" |- signature_logs (Suivi signatures Universign)")
print(" |- workflow_logs (Transformations documents)")
print(" |- cache_metadata (Métadonnées cache)")
print(" |- audit_logs (Journal d'audit)")
print("\nProchaines étapes:")
print(" 1. Configurer le fichier .env avec les credentials")
print(" 2. Lancer la gateway Windows sur la machine Sage")
print(" 3. Lancer l'API VPS: uvicorn api:app --host 0.0.0.0 --port 8000")
print(" 4. Ou avec Docker : docker-compose up -d")
print(" 5. Tester: http://IP_DU_VPS:8000/docs")
print("\n" + "=" * 60 + "\n")
return True return True
except Exception as e: except Exception as e:

View file

@ -1,10 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_, and_ from sqlalchemy import select, func, and_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pydantic import BaseModel, EmailStr
import logging import logging
from data.data import templates_signature_email from data.data import templates_signature_email
from email_queue import email_queue from email_queue import email_queue
@ -14,16 +14,21 @@ from database import (
UniversignSigner, UniversignSigner,
UniversignSyncLog, UniversignSyncLog,
LocalDocumentStatus, LocalDocumentStatus,
SageDocumentType,
) )
import os
from pathlib import Path
import json import json
from services.universign_sync import UniversignSyncService from services.universign_sync import UniversignSyncService
from config.config import settings from config.config import settings
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, map_universign_to_local from utils.universign_status_mapping import get_status_message, map_universign_to_local
from database.models.email import EmailLog from database.models.email import EmailLog
from database.enum.status import StatutEmail from database.enum.status import StatutEmail
from schemas import (
SyncStatsResponse,
CreateSignatureRequest,
TransactionResponse,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -34,56 +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]
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) @router.post("/signatures/create", response_model=TransactionResponse)
async def create_signature( async def create_signature(
request: CreateSignatureRequest, session: AsyncSession = Depends(get_session) request: CreateSignatureRequest, session: AsyncSession = Depends(get_session)
): ):
try: try:
# === VÉRIFICATION DOUBLON RENFORCÉE ===
logger.info( 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})" f"(type: {request.sage_document_type.name})"
) )
@ -96,10 +58,9 @@ async def create_signature(
if all_existing: if all_existing:
logger.warning( logger.warning(
f"⚠️ {len(all_existing)} transaction(s) existante(s) trouvée(s)" f"{len(all_existing)} transaction(s) existante(s) trouvée(s)"
) )
# Filtrer les transactions non-finales
active_txs = [ active_txs = [
tx tx
for tx in all_existing for tx in all_existing
@ -115,7 +76,7 @@ async def create_signature(
if active_txs: if active_txs:
active_tx = active_txs[0] active_tx = active_txs[0]
logger.error( logger.error(
f"Transaction active existante: {active_tx.transaction_id} " f"Transaction active existante: {active_tx.transaction_id} "
f"(statut: {active_tx.local_status.value})" f"(statut: {active_tx.local_status.value})"
) )
raise HTTPException( raise HTTPException(
@ -126,11 +87,10 @@ async def create_signature(
) )
logger.info( logger.info(
"Toutes les transactions existantes sont finales, création autorisée" "Toutes les transactions existantes sont finales, création autorisée"
) )
# Génération PDF 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( 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)
) )
@ -138,15 +98,14 @@ async def create_signature(
if not pdf_bytes: if not pdf_bytes:
raise HTTPException(400, "Échec génération PDF") raise HTTPException(400, "Échec génération PDF")
logger.info(f"PDF généré: {len(pdf_bytes)} octets") logger.info(f"PDF généré: {len(pdf_bytes)} octets")
# === CRÉATION TRANSACTION UNIVERSIGN ===
import requests import requests
import uuid import uuid
auth = (settings.universign_api_key, "") auth = (settings.universign_api_key, "")
logger.info("🔄 Création transaction Universign...") logger.info("Création transaction Universign...")
resp = requests.post( resp = requests.post(
f"{settings.universign_api_url}/transactions", f"{settings.universign_api_url}/transactions",
@ -160,14 +119,13 @@ async def create_signature(
) )
if resp.status_code != 200: if resp.status_code != 200:
logger.error(f"Erreur Universign (création): {resp.text}") logger.error(f"Erreur Universign (création): {resp.text}")
raise HTTPException(500, f"Erreur Universign: {resp.status_code}") raise HTTPException(500, f"Erreur Universign: {resp.status_code}")
universign_tx_id = resp.json().get("id") universign_tx_id = resp.json().get("id")
logger.info(f"Transaction Universign créée: {universign_tx_id}") logger.info(f"Transaction Universign créée: {universign_tx_id}")
# Upload PDF logger.info("Upload PDF...")
logger.info("📤 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")
} }
@ -176,14 +134,13 @@ async def create_signature(
) )
if resp.status_code not in [200, 201]: if resp.status_code not in [200, 201]:
logger.error(f"Erreur upload: {resp.text}") logger.error(f"Erreur upload: {resp.text}")
raise HTTPException(500, "Erreur upload PDF") raise HTTPException(500, "Erreur upload PDF")
file_id = resp.json().get("id") file_id = resp.json().get("id")
logger.info(f"PDF uploadé: {file_id}") logger.info(f"PDF uploadé: {file_id}")
# Attachement document logger.info("Attachement document...")
logger.info("🔗 Attachement 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,
@ -196,8 +153,7 @@ async def create_signature(
document_id = resp.json().get("id") 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( 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,
@ -210,8 +166,7 @@ async def create_signature(
field_id = resp.json().get("id") 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( 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,
@ -222,8 +177,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")
# Démarrage transaction logger.info("Démarrage transaction...")
logger.info("🚀 Démarrage 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,
@ -235,7 +189,6 @@ async def create_signature(
final_data = resp.json() final_data = resp.json()
# Extraction 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"]:
@ -246,14 +199,13 @@ async def create_signature(
if not signer_url: if not signer_url:
raise HTTPException(500, "URL de signature non retournée") raise HTTPException(500, "URL de signature non retournée")
logger.info("URL de signature obtenue") logger.info("URL de signature obtenue")
# === ENREGISTREMENT LOCAL ===
local_id = str(uuid.uuid4()) local_id = str(uuid.uuid4())
transaction = UniversignTransaction( transaction = UniversignTransaction(
id=local_id, 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_id=request.sage_document_id,
sage_document_type=request.sage_document_type, sage_document_type=request.sage_document_type,
universign_status=UniversignTransactionStatus.STARTED, universign_status=UniversignTransactionStatus.STARTED,
@ -283,10 +235,9 @@ async def create_signature(
await session.commit() await session.commit()
logger.info( 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"] template = templates_signature_email["demande_signature"]
type_labels = { type_labels = {
@ -341,7 +292,6 @@ async def create_signature(
email_queue.enqueue(email_log.id) email_queue.enqueue(email_log.id)
# === MISE À JOUR STATUT SAGE (Confirmé = 1) ===
try: try:
from sage_client import sage_client from sage_client import sage_client
@ -356,7 +306,6 @@ async def create_signature(
except Exception as e: except Exception as e:
logger.warning(f"Impossible de mettre à jour le statut Sage: {e}") logger.warning(f"Impossible de mettre à jour le statut Sage: {e}")
# === RÉPONSE ===
return TransactionResponse( return TransactionResponse(
id=transaction.id, id=transaction.id,
transaction_id=transaction.transaction_id, transaction_id=transaction.transaction_id,
@ -436,6 +385,15 @@ async def list_transactions(
} }
for s in tx.signers for s in tx.signers
], ],
signed_document_available=bool(
tx.signed_document_path and Path(tx.signed_document_path).exists()
),
signed_document_downloaded_at=tx.signed_document_downloaded_at,
signed_document_size_kb=(
tx.signed_document_size_bytes / 1024
if tx.signed_document_size_bytes
else None
),
) )
for tx in transactions for tx in transactions
] ]
@ -482,6 +440,15 @@ async def get_transaction(
} }
for s in tx.signers for s in tx.signers
], ],
signed_document_available=bool(
tx.signed_document_path and Path(tx.signed_document_path).exists()
),
signed_document_downloaded_at=tx.signed_document_downloaded_at,
signed_document_size_kb=(
tx.signed_document_size_bytes / 1024
if tx.signed_document_size_bytes
else None
),
) )
@ -538,52 +505,39 @@ async def webhook_universign(
try: try:
payload = await request.json() 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)}") logger.debug(f"Payload complet: {json.dumps(payload, indent=2)}")
# ✅ EXTRACTION CORRECTE DU TRANSACTION_ID
transaction_id = None 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: if payload.get("type", "").startswith("transaction.") and "payload" in payload:
# Le transaction_id est dans payload.object.id # Le transaction_id est dans payload.object.id
nested_object = payload.get("payload", {}).get("object", {}) nested_object = payload.get("payload", {}).get("object", {})
if nested_object.get("object") == "transaction": if nested_object.get("object") == "transaction":
transaction_id = nested_object.get("id") transaction_id = nested_object.get("id")
logger.info( logger.info(
f"Transaction ID extrait de payload.object.id: {transaction_id}" f"Transaction ID extrait de payload.object.id: {transaction_id}"
) )
# 🔍 Structure 2 : Action événements (action.opened, action.completed)
elif payload.get("type", "").startswith("action."): elif payload.get("type", "").startswith("action."):
# Le transaction_id est directement dans payload.object.transaction_id
transaction_id = ( transaction_id = (
payload.get("payload", {}).get("object", {}).get("transaction_id") payload.get("payload", {}).get("object", {}).get("transaction_id")
) )
logger.info( logger.info(
f"Transaction ID extrait de payload.object.transaction_id: {transaction_id}" f"Transaction ID extrait de payload.object.transaction_id: {transaction_id}"
) )
# 🔍 Structure 3 : Transaction directe (fallback)
elif payload.get("object") == "transaction": elif payload.get("object") == "transaction":
transaction_id = payload.get("id") transaction_id = payload.get("id")
logger.info(f"Transaction ID extrait direct: {transaction_id}") logger.info(f"Transaction ID extrait direct: {transaction_id}")
# 🔍 Structure 4 : Ancien format (pour rétro-compatibilité)
elif "transaction" in payload: elif "transaction" in payload:
transaction_id = payload.get("transaction", {}).get("id") transaction_id = payload.get("transaction", {}).get("id")
logger.info( logger.info(f"Transaction ID extrait de transaction.id: {transaction_id}")
f"✅ Transaction ID extrait de transaction.id: {transaction_id}"
)
# ❌ Échec d'extraction
if not transaction_id: if not transaction_id:
logger.error( logger.error(
f"Transaction ID introuvable dans webhook\n" f"Transaction ID introuvable dans webhook\n"
f"Type d'événement: {payload.get('type', 'unknown')}\n" f"Type d'événement: {payload.get('type', 'unknown')}\n"
f"Clés racine: {list(payload.keys())}\n" f"Clés racine: {list(payload.keys())}\n"
f"Payload simplifié: {json.dumps({k: v if k != 'payload' else '...' for k, v in payload.items()})}" f"Payload simplifié: {json.dumps({k: v if k != 'payload' else '...' for k, v in payload.items()})}"
@ -595,9 +549,8 @@ async def webhook_universign(
"event_id": payload.get("id"), "event_id": payload.get("id"),
}, 400 }, 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( query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id UniversignTransaction.transaction_id == transaction_id
) )
@ -606,7 +559,7 @@ async def webhook_universign(
if not tx: if not tx:
logger.warning( logger.warning(
f"⚠️ Transaction {transaction_id} inconnue en local\n" f"Transaction {transaction_id} inconnue en local\n"
f"Type d'événement: {payload.get('type')}\n" f"Type d'événement: {payload.get('type')}\n"
f"Elle sera synchronisée au prochain polling" f"Elle sera synchronisée au prochain polling"
) )
@ -617,22 +570,20 @@ async def webhook_universign(
"event_type": payload.get("type"), "event_type": payload.get("type"),
} }
# Traiter le webhook
success, error = await sync_service.process_webhook( success, error = await sync_service.process_webhook(
session, payload, transaction_id session, payload, transaction_id
) )
if not success: if not success:
logger.error(f"Erreur traitement webhook: {error}") logger.error(f"Erreur traitement webhook: {error}")
return { return {
"status": "error", "status": "error",
"message": error, "message": error,
"transaction_id": transaction_id, "transaction_id": transaction_id,
}, 500 }, 500
# ✅ Succès
logger.info( logger.info(
f"Webhook traité avec succès\n" f"Webhook traité avec succès\n"
f"Transaction: {transaction_id}\n" f"Transaction: {transaction_id}\n"
f"Nouveau statut: {tx.local_status.value if tx else 'unknown'}\n" f"Nouveau statut: {tx.local_status.value if tx else 'unknown'}\n"
f"Type d'événement: {payload.get('type')}" f"Type d'événement: {payload.get('type')}"
@ -647,7 +598,7 @@ async def webhook_universign(
} }
except Exception as e: 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 return {"status": "error", "message": str(e)}, 500
@ -655,17 +606,14 @@ async def webhook_universign(
async def get_sync_stats(session: AsyncSession = Depends(get_session)): async def get_sync_stats(session: AsyncSession = Depends(get_session)):
"""Statistiques globales de synchronisation""" """Statistiques globales de synchronisation"""
# Total
total_query = select(func.count(UniversignTransaction.id)) total_query = select(func.count(UniversignTransaction.id))
total = (await session.execute(total_query)).scalar() total = (await session.execute(total_query)).scalar()
# En attente de sync
pending_query = select(func.count(UniversignTransaction.id)).where( pending_query = select(func.count(UniversignTransaction.id)).where(
UniversignTransaction.needs_sync UniversignTransaction.needs_sync
) )
pending = (await session.execute(pending_query)).scalar() pending = (await session.execute(pending_query)).scalar()
# Par statut
signed_query = select(func.count(UniversignTransaction.id)).where( signed_query = select(func.count(UniversignTransaction.id)).where(
UniversignTransaction.local_status == LocalDocumentStatus.SIGNED UniversignTransaction.local_status == LocalDocumentStatus.SIGNED
) )
@ -686,7 +634,6 @@ async def get_sync_stats(session: AsyncSession = Depends(get_session)):
) )
expired = (await session.execute(expired_query)).scalar() expired = (await session.execute(expired_query)).scalar()
# Dernière sync
last_sync_query = select(func.max(UniversignTransaction.last_synced_at)) last_sync_query = select(func.max(UniversignTransaction.last_synced_at))
last_sync = (await session.execute(last_sync_query)).scalar() last_sync = (await session.execute(last_sync_query)).scalar()
@ -707,7 +654,6 @@ async def get_transaction_logs(
limit: int = Query(50, le=500), limit: int = Query(50, le=500),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
# Trouver la transaction
tx_query = select(UniversignTransaction).where( tx_query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id UniversignTransaction.transaction_id == transaction_id
) )
@ -717,7 +663,6 @@ async def get_transaction_logs(
if not tx: if not tx:
raise HTTPException(404, "Transaction introuvable") raise HTTPException(404, "Transaction introuvable")
# Logs
logs_query = ( logs_query = (
select(UniversignSyncLog) select(UniversignSyncLog)
.where(UniversignSyncLog.transaction_id == tx.id) .where(UniversignSyncLog.transaction_id == tx.id)
@ -746,9 +691,6 @@ async def get_transaction_logs(
} }
# Ajouter ces routes dans universign.py
@router.get("/documents/{sage_document_id}/signatures") @router.get("/documents/{sage_document_id}/signatures")
async def get_signatures_for_document( async def get_signatures_for_document(
sage_document_id: str, sage_document_id: str,
@ -790,10 +732,6 @@ async def cleanup_duplicate_signatures(
), ),
session: AsyncSession = Depends(get_session), 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 = ( query = (
select(UniversignTransaction) select(UniversignTransaction)
.where(UniversignTransaction.sage_document_id == sage_document_id) .where(UniversignTransaction.sage_document_id == sage_document_id)
@ -815,7 +753,6 @@ async def cleanup_duplicate_signatures(
"deleted_count": 0, "deleted_count": 0,
} }
# Garder la première (selon l'ordre), supprimer les autres
to_keep = transactions[0] to_keep = transactions[0]
to_delete = transactions[1:] to_delete = transactions[1:]
@ -875,13 +812,8 @@ async def delete_transaction(
async def cleanup_all_duplicates( async def cleanup_all_duplicates(
session: AsyncSession = Depends(get_session), 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 from sqlalchemy import func
# Trouver les documents avec plusieurs transactions
subquery = ( subquery = (
select( select(
UniversignTransaction.sage_document_id, UniversignTransaction.sage_document_id,
@ -899,7 +831,6 @@ async def cleanup_all_duplicates(
cleanup_details = [] cleanup_details = []
for doc_id in duplicate_docs: for doc_id in duplicate_docs:
# Récupérer toutes les transactions pour ce document
tx_query = ( tx_query = (
select(UniversignTransaction) select(UniversignTransaction)
.where(UniversignTransaction.sage_document_id == doc_id) .where(UniversignTransaction.sage_document_id == doc_id)
@ -908,7 +839,6 @@ async def cleanup_all_duplicates(
tx_result = await session.execute(tx_query) tx_result = await session.execute(tx_query)
transactions = tx_result.scalars().all() transactions = tx_result.scalars().all()
# Priorité: SIGNE > EN_COURS > EN_ATTENTE > autres
priority = {"SIGNE": 0, "EN_COURS": 1, "EN_ATTENTE": 2} priority = {"SIGNE": 0, "EN_COURS": 1, "EN_ATTENTE": 2}
def sort_key(tx): def sort_key(tx):
@ -946,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 == True,
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_(
UniversignTransaction.webhook_received == False,
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"]) @router.post("/admin/force-sync-all", tags=["Admin"])
async def forcer_sync_toutes_transactions( async def forcer_sync_toutes_transactions(
max_transactions: int = Query(200, le=500), max_transactions: int = Query(200, le=500),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""
Force la synchronisation de TOUTES les transactions (même finales)
À utiliser pour réparer les incohérences
"""
try: try:
query = ( query = (
select(UniversignTransaction) select(UniversignTransaction)
@ -1079,7 +905,7 @@ async def forcer_sync_toutes_transactions(
previous_status = transaction.local_status.value previous_status = transaction.local_status.value
logger.info( 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( success, error = await sync_service.sync_transaction(
@ -1128,9 +954,6 @@ async def forcer_sync_toutes_transactions(
async def reparer_transaction( async def reparer_transaction(
transaction_id: str, session: AsyncSession = Depends(get_session) transaction_id: str, session: AsyncSession = Depends(get_session)
): ):
"""
Répare une transaction spécifique en la re-synchronisant depuis Universign
"""
try: try:
query = select(UniversignTransaction).where( query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id UniversignTransaction.transaction_id == transaction_id
@ -1148,7 +971,6 @@ async def reparer_transaction(
else None else None
) )
# Force sync
success, error = await sync_service.sync_transaction( success, error = await sync_service.sync_transaction(
session, transaction, force=True session, transaction, force=True
) )
@ -1185,11 +1007,7 @@ async def reparer_transaction(
async def trouver_transactions_inconsistantes( async def trouver_transactions_inconsistantes(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""
Trouve les transactions dont le statut local ne correspond pas au statut Universign
"""
try: try:
# Toutes les transactions non-finales
query = select(UniversignTransaction).where( query = select(UniversignTransaction).where(
UniversignTransaction.local_status.in_( UniversignTransaction.local_status.in_(
[LocalDocumentStatus.PENDING, LocalDocumentStatus.IN_PROGRESS] [LocalDocumentStatus.PENDING, LocalDocumentStatus.IN_PROGRESS]
@ -1203,7 +1021,6 @@ async def trouver_transactions_inconsistantes(
for tx in transactions: for tx in transactions:
try: try:
# Récupérer le statut depuis Universign
universign_data = sync_service.fetch_transaction_status( universign_data = sync_service.fetch_transaction_status(
tx.transaction_id tx.transaction_id
) )
@ -1272,9 +1089,6 @@ async def nettoyer_transactions_erreur(
), ),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
"""
Nettoie les transactions en erreur anciennes
"""
try: try:
date_limite = datetime.now() - timedelta(days=age_jours) date_limite = datetime.now() - timedelta(days=age_jours)
@ -1318,9 +1132,6 @@ async def nettoyer_transactions_erreur(
async def voir_dernier_webhook( async def voir_dernier_webhook(
transaction_id: str, session: AsyncSession = Depends(get_session) transaction_id: str, session: AsyncSession = Depends(get_session)
): ):
"""
Affiche le dernier payload webhook reçu pour une transaction
"""
try: try:
query = select(UniversignTransaction).where( query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id UniversignTransaction.transaction_id == transaction_id
@ -1331,7 +1142,6 @@ async def voir_dernier_webhook(
if not tx: if not tx:
raise HTTPException(404, "Transaction introuvable") raise HTTPException(404, "Transaction introuvable")
# Récupérer le dernier log de type webhook
logs_query = ( logs_query = (
select(UniversignSyncLog) select(UniversignSyncLog)
.where( .where(
@ -1373,3 +1183,102 @@ async def voir_dernier_webhook(
except Exception as e: except Exception as e:
logger.error(f"Erreur debug webhook: {e}") logger.error(f"Erreur debug webhook: {e}")
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@router.get(
"/transactions/{transaction_id}/document/download", tags=["Documents Signés"]
)
async def telecharger_document_signe(
transaction_id: str, session: AsyncSession = Depends(get_session)
):
try:
query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id
)
result = await session.execute(query)
transaction = result.scalar_one_or_none()
if not transaction:
raise HTTPException(404, f"Transaction {transaction_id} introuvable")
if not transaction.signed_document_path:
raise HTTPException(
404,
"Document signé non disponible localement. "
"Utilisez POST /admin/download-missing-documents pour le récupérer.",
)
file_path = Path(transaction.signed_document_path)
if not file_path.exists():
logger.warning(f"Fichier perdu : {file_path}")
raise HTTPException(
404,
"Fichier introuvable sur le serveur. "
"Utilisez POST /admin/download-missing-documents pour le récupérer.",
)
download_name = (
f"{transaction.sage_document_id}_"
f"{transaction.sage_document_type.name}_"
f"signe.pdf"
)
return FileResponse(
path=str(file_path), media_type="application/pdf", filename=download_name
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur téléchargement document : {e}", exc_info=True)
raise HTTPException(500, str(e))
@router.get("/transactions/{transaction_id}/document/info", tags=["Documents Signés"])
async def info_document_signe(
transaction_id: str, session: AsyncSession = Depends(get_session)
):
"""
Informations sur le document signé
"""
try:
query = select(UniversignTransaction).where(
UniversignTransaction.transaction_id == transaction_id
)
result = await session.execute(query)
transaction = result.scalar_one_or_none()
if not transaction:
raise HTTPException(404, f"Transaction {transaction_id} introuvable")
file_exists = False
file_size_mb = None
if transaction.signed_document_path:
file_path = Path(transaction.signed_document_path)
file_exists = file_path.exists()
if file_exists:
file_size_mb = os.path.getsize(file_path) / (1024 * 1024)
return {
"transaction_id": transaction_id,
"document_available_locally": file_exists,
"document_url_universign": transaction.document_url,
"downloaded_at": (
transaction.signed_document_downloaded_at.isoformat()
if transaction.signed_document_downloaded_at
else None
),
"file_size_mb": round(file_size_mb, 2) if file_size_mb else None,
"download_attempts": transaction.download_attempts,
"last_download_error": transaction.download_error,
"local_path": transaction.signed_document_path if file_exists else None,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Erreur info document : {e}")
raise HTTPException(500, str(e))

View file

@ -408,7 +408,7 @@ class SageGatewayClient:
return self._post( return self._post(
"/sage/collaborateurs/list", "/sage/collaborateurs/list",
{ {
"filtre": filtre or "", # ⚠️ Convertir None en "" "filtre": filtre or "", # Convertir None en ""
"actifs_seulement": actifs_seulement, "actifs_seulement": actifs_seulement,
}, },
).get("data", []) ).get("data", [])

View file

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

View file

@ -1,6 +1,12 @@
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from enum import Enum from enum import Enum
from schemas.documents.documents import TypeDocument from schemas.documents.documents import TypeDocument
from database import (
SageDocumentType,
)
from typing import List, Optional
from datetime import datetime
class StatutSignature(str, Enum): class StatutSignature(str, Enum):
@ -16,3 +22,49 @@ class Signature(BaseModel):
type_doc: TypeDocument type_doc: TypeDocument
email_signataire: EmailStr email_signataire: EmailStr
nom_signataire: str 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

@ -55,7 +55,7 @@ class SageGatewayService:
and_( and_(
SageGatewayConfig.id == gateway_id, SageGatewayConfig.id == gateway_id,
SageGatewayConfig.user_id == user_id, SageGatewayConfig.user_id == user_id,
# SageGatewayConfig.is_deleted.is_(false()), SageGatewayConfig.is_deleted == false(),
) )
) )
) )
@ -67,7 +67,7 @@ class SageGatewayService:
query = select(SageGatewayConfig).where(SageGatewayConfig.user_id == user_id) query = select(SageGatewayConfig).where(SageGatewayConfig.user_id == user_id)
if not include_deleted: if not include_deleted:
query = query.where(SageGatewayConfig.is_deleted.is_(false())) query = query.where(SageGatewayConfig.is_deleted == false())
query = query.order_by( query = query.order_by(
SageGatewayConfig.is_active.desc(), SageGatewayConfig.is_active.desc(),
@ -167,7 +167,7 @@ class SageGatewayService:
and_( and_(
SageGatewayConfig.user_id == user_id, SageGatewayConfig.user_id == user_id,
SageGatewayConfig.is_active, SageGatewayConfig.is_active,
SageGatewayConfig.is_deleted.is_(true()), SageGatewayConfig.is_deleted == false(),
) )
) )
) )

View file

@ -0,0 +1,378 @@
import os
import logging
import requests
from pathlib import Path
from datetime import datetime
from typing import Optional, Tuple, Dict, List
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
SIGNED_DOCS_DIR = Path(os.getenv("SIGNED_DOCS_PATH", "/app/data/signed_documents"))
SIGNED_DOCS_DIR.mkdir(parents=True, exist_ok=True)
class UniversignDocumentService:
"""Service de gestion des documents signés Universign - VERSION CORRIGÉE"""
def __init__(self, api_url: str, api_key: str, timeout: int = 60):
self.api_url = api_url.rstrip("/")
self.api_key = api_key
self.timeout = timeout
self.auth = (api_key, "")
def fetch_transaction_documents(self, transaction_id: str) -> Optional[List[Dict]]:
try:
logger.info(f"📋 Récupération documents pour transaction: {transaction_id}")
response = requests.get(
f"{self.api_url}/transactions/{transaction_id}",
auth=self.auth,
timeout=self.timeout,
headers={"Accept": "application/json"},
)
if response.status_code == 200:
data = response.json()
documents = data.get("documents", [])
logger.info(f"{len(documents)} document(s) trouvé(s)")
# Log détaillé de chaque document
for idx, doc in enumerate(documents):
logger.debug(
f" Document {idx}: id={doc.get('id')}, "
f"name={doc.get('name')}, status={doc.get('status')}"
)
return documents
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}: "
f"{response.text[:500]}"
)
return None
except requests.exceptions.Timeout:
logger.error(f"⏱️ Timeout récupération transaction {transaction_id}")
return None
except Exception as e:
logger.error(f"❌ Erreur fetch documents: {e}", exc_info=True)
return None
def download_signed_document(
self, transaction_id: str, document_id: str
) -> Optional[bytes]:
try:
download_url = (
f"{self.api_url}/transactions/{transaction_id}"
f"/documents/{document_id}/download"
)
logger.info(f"Téléchargement depuis: {download_url}")
response = requests.get(
download_url,
auth=self.auth,
timeout=self.timeout,
stream=True,
)
if response.status_code == 200:
content_type = response.headers.get("Content-Type", "")
content_length = response.headers.get("Content-Length", "unknown")
logger.info(
f"✅ Téléchargement réussi: "
f"Content-Type={content_type}, Size={content_length}"
)
# Vérification du type de contenu
if (
"pdf" not in content_type.lower()
and "octet-stream" not in content_type.lower()
):
logger.warning(
f"⚠️ Type de contenu inattendu: {content_type}. "
f"Tentative de lecture quand même..."
)
# Lecture du contenu
content = response.content
if len(content) < 1024:
logger.error(f"❌ Document trop petit: {len(content)} octets")
return None
return content
elif response.status_code == 404:
logger.error(
f"❌ Document {document_id} introuvable pour transaction {transaction_id}"
)
return None
elif response.status_code == 403:
logger.error(
f"❌ Accès refusé au document {document_id}. "
f"Vérifiez que la transaction est bien signée."
)
return None
else:
logger.error(
f"❌ Erreur HTTP {response.status_code}: {response.text[:500]}"
)
return None
except requests.exceptions.Timeout:
logger.error(f"⏱️ Timeout téléchargement document {document_id}")
return None
except Exception as e:
logger.error(f"❌ Erreur téléchargement: {e}", exc_info=True)
return None
async def download_and_store_signed_document(
self, session: AsyncSession, transaction, force: bool = False
) -> Tuple[bool, Optional[str]]:
# Vérification si déjà téléchargé
if not force and transaction.signed_document_path:
if os.path.exists(transaction.signed_document_path):
logger.debug(
f"✅ Document déjà téléchargé: {transaction.transaction_id}"
)
return True, None
transaction.download_attempts += 1
try:
# ÉTAPE 1: Récupérer les documents de la transaction
logger.info(
f"Récupération document signé pour: {transaction.transaction_id}"
)
documents = self.fetch_transaction_documents(transaction.transaction_id)
if not documents:
error = "Aucun document trouvé dans la transaction Universign"
logger.warning(f"⚠️ {error}")
transaction.download_error = error
await session.commit()
return False, error
# ÉTAPE 2: Récupérer le premier document (ou chercher celui qui est signé)
document_id = None
for doc in documents:
doc_id = doc.get("id")
doc_status = doc.get("status", "").lower()
# Priorité aux documents marqués comme signés/complétés
if doc_status in ["signed", "completed", "closed"]:
document_id = doc_id
logger.info(
f"Document signé trouvé: {doc_id} (status: {doc_status})"
)
break
# Fallback sur le premier document si aucun n'est explicitement signé
if document_id is None:
document_id = doc_id
if not document_id:
error = "Impossible de déterminer l'ID du document à télécharger"
logger.error(f"{error}")
transaction.download_error = error
await session.commit()
return False, error
# Stocker le document_id pour référence future
if hasattr(transaction, "universign_document_id"):
transaction.universign_document_id = document_id
# ÉTAPE 3: Télécharger le document signé
pdf_content = self.download_signed_document(
transaction_id=transaction.transaction_id, document_id=document_id
)
if not pdf_content:
error = f"Échec téléchargement document {document_id}"
logger.error(f"{error}")
transaction.download_error = error
await session.commit()
return False, error
# ÉTAPE 4: Stocker le fichier localement
filename = self._generate_filename(transaction)
file_path = SIGNED_DOCS_DIR / filename
with open(file_path, "wb") as f:
f.write(pdf_content)
file_size = os.path.getsize(file_path)
# Mise à jour de la transaction
transaction.signed_document_path = str(file_path)
transaction.signed_document_downloaded_at = datetime.now()
transaction.signed_document_size_bytes = file_size
transaction.download_error = None
# Stocker aussi l'URL de téléchargement pour référence
transaction.document_url = (
f"{self.api_url}/transactions/{transaction.transaction_id}"
f"/documents/{document_id}/download"
)
await session.commit()
logger.info(
f"✅ Document signé téléchargé: {filename} ({file_size / 1024:.1f} KB)"
)
return True, None
except OSError as e:
error = f"Erreur filesystem: {str(e)}"
logger.error(f"{error}")
transaction.download_error = error
await session.commit()
return False, error
except Exception as e:
error = f"Erreur inattendue: {str(e)}"
logger.error(f"{error}", exc_info=True)
transaction.download_error = error
await session.commit()
return False, error
def _generate_filename(self, transaction) -> str:
"""Génère un nom de fichier unique pour le document signé"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
tx_id = transaction.transaction_id.replace("tr_", "")
filename = f"{transaction.sage_document_id}_{tx_id}_{timestamp}_signed.pdf"
return filename
def get_document_path(self, transaction) -> 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 path.exists():
return path
return None
async def cleanup_old_documents(self, days_to_keep: int = 90) -> Tuple[int, int]:
"""Supprime les anciens documents signés"""
from datetime import timedelta
cutoff_date = datetime.now() - timedelta(days=days_to_keep)
deleted = 0
size_freed = 0
for file_path in SIGNED_DOCS_DIR.glob("*.pdf"):
try:
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
if file_time < cutoff_date:
size_freed += os.path.getsize(file_path)
os.remove(file_path)
deleted += 1
logger.info(f"🗑️ Supprimé: {file_path.name}")
except Exception as e:
logger.error(f"Erreur suppression {file_path}: {e}")
size_freed_mb = size_freed / (1024 * 1024)
logger.info(
f"Nettoyage terminé: {deleted} fichiers supprimés "
f"({size_freed_mb:.2f} MB libérés)"
)
return deleted, int(size_freed_mb)
# === MÉTHODES DE DIAGNOSTIC ===
def diagnose_transaction(self, transaction_id: str) -> Dict:
"""
Diagnostic complet d'une transaction pour debug
"""
result = {
"transaction_id": transaction_id,
"api_url": self.api_url,
"timestamp": datetime.now().isoformat(),
"checks": {},
}
try:
# Test 1: Récupération de la transaction
logger.info(f"Diagnostic transaction: {transaction_id}")
response = requests.get(
f"{self.api_url}/transactions/{transaction_id}",
auth=self.auth,
timeout=self.timeout,
)
result["checks"]["transaction_fetch"] = {
"status_code": response.status_code,
"success": response.status_code == 200,
}
if response.status_code != 200:
result["checks"]["transaction_fetch"]["error"] = response.text[:500]
return result
data = response.json()
result["checks"]["transaction_data"] = {
"state": data.get("state"),
"documents_count": len(data.get("documents", [])),
"participants_count": len(data.get("participants", [])),
}
# Test 2: Documents disponibles
documents = data.get("documents", [])
result["checks"]["documents"] = []
for doc in documents:
doc_info = {
"id": doc.get("id"),
"name": doc.get("name"),
"status": doc.get("status"),
}
# Test téléchargement
if doc.get("id"):
download_url = (
f"{self.api_url}/transactions/{transaction_id}"
f"/documents/{doc['id']}/download"
)
try:
dl_response = requests.head(
download_url,
auth=self.auth,
timeout=10,
)
doc_info["download_check"] = {
"url": download_url,
"status_code": dl_response.status_code,
"accessible": dl_response.status_code in [200, 302],
"content_type": dl_response.headers.get("Content-Type"),
}
except Exception as e:
doc_info["download_check"] = {"error": str(e)}
result["checks"]["documents"].append(doc_info)
result["success"] = True
except Exception as e:
result["success"] = False
result["error"] = str(e)
return result

View file

@ -19,6 +19,7 @@ from database import (
StatutEmail, StatutEmail,
) )
from data.data import templates_signature_email from data.data import templates_signature_email
from services.universign_document import UniversignDocumentService
from utils.universign_status_mapping import ( from utils.universign_status_mapping import (
map_universign_to_local, map_universign_to_local,
is_transition_allowed, is_transition_allowed,
@ -39,6 +40,9 @@ class UniversignSyncService:
self.sage_client = None self.sage_client = None
self.email_queue = None self.email_queue = None
self.settings = None self.settings = None
self.document_service = UniversignDocumentService(
api_url=api_url, api_key=api_key, timeout=60
)
def configure(self, sage_client, email_queue, settings): def configure(self, sage_client, email_queue, settings):
self.sage_client = sage_client self.sage_client = sage_client
@ -201,7 +205,7 @@ class UniversignSyncService:
transaction = result.scalar_one_or_none() transaction = result.scalar_one_or_none()
if not transaction: if not transaction:
logger.warning(f"⚠️ Transaction {transaction_id} inconnue localement") logger.warning(f"Transaction {transaction_id} inconnue localement")
return False, "Transaction inconnue" return False, "Transaction inconnue"
# Marquer comme webhook reçu # Marquer comme webhook reçu
@ -218,7 +222,7 @@ class UniversignSyncService:
# Log du changement de statut # Log du changement de statut
if success and transaction.local_status.value != old_status: if success and transaction.local_status.value != old_status:
logger.info( logger.info(
f"Webhook traité: {transaction_id} | " f"Webhook traité: {transaction_id} | "
f"{old_status}{transaction.local_status.value}" f"{old_status}{transaction.local_status.value}"
) )
@ -233,7 +237,7 @@ class UniversignSyncService:
new_status=transaction.local_status.value, new_status=transaction.local_status.value,
changes=json.dumps( changes=json.dumps(
payload, default=str payload, default=str
), # Ajout default=str pour éviter les erreurs JSON ), # Ajout default=str pour éviter les erreurs JSON
) )
await session.commit() await session.commit()
@ -267,7 +271,7 @@ class UniversignSyncService:
logger.warning(f"Signataire sans email à l'index {idx}, ignoré") logger.warning(f"Signataire sans email à l'index {idx}, ignoré")
continue continue
# PROTECTION : gérer les statuts inconnus # PROTECTION : gérer les statuts inconnus
raw_status = signer_data.get("status") or signer_data.get( raw_status = signer_data.get("status") or signer_data.get(
"state", "waiting" "state", "waiting"
) )
@ -298,7 +302,7 @@ class UniversignSyncService:
if signer_data.get("name") and not signer.name: if signer_data.get("name") and not signer.name:
signer.name = signer_data.get("name") signer.name = signer_data.get("name")
else: else:
# Nouveau signer avec gestion d'erreur intégrée # Nouveau signer avec gestion d'erreur intégrée
try: try:
signer = UniversignSigner( signer = UniversignSigner(
id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}", id=f"{transaction.id}_signer_{idx}_{int(datetime.now().timestamp())}",
@ -318,39 +322,34 @@ class UniversignSyncService:
except Exception as e: except Exception as e:
logger.error(f"Erreur création signer {email}: {e}") logger.error(f"Erreur création signer {email}: {e}")
# CORRECTION 3 : Amélioration du logging dans sync_transaction
async def sync_transaction( async def sync_transaction(
self, self,
session: AsyncSession, session,
transaction: UniversignTransaction, transaction,
force: bool = False, force: bool = False,
) -> Tuple[bool, Optional[str]]: ):
""" import json
CORRECTION : Meilleur logging et gestion d'erreurs
"""
# Si statut final et pas de force, skip # Si statut final et pas de force, skip
if is_final_status(transaction.local_status.value) and not force: if is_final_status(transaction.local_status.value) and not force:
logger.debug( logger.debug(
f"⏭️ Skip {transaction.transaction_id}: statut final {transaction.local_status.value}" f"⏭️ Skip {transaction.transaction_id}: statut final "
f"{transaction.local_status.value}"
) )
transaction.needs_sync = False transaction.needs_sync = False
await session.commit() await session.commit()
return True, None return True, None
# Récupération du statut distant # 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) result = self.fetch_transaction_status(transaction.transaction_id)
if not result: if not result:
error = "Échec récupération données Universign" error = "Échec récupération données Universign"
logger.error(f"{error}: {transaction.transaction_id}") logger.error(f"{error}: {transaction.transaction_id}")
# ✅ CORRECTION : Incrémenter les tentatives MÊME en cas d'échec
transaction.sync_attempts += 1 transaction.sync_attempts += 1
transaction.sync_error = error transaction.sync_error = error
await self._log_sync_attempt(session, transaction, "polling", False, error) await self._log_sync_attempt(session, transaction, "polling", False, error)
await session.commit() await session.commit()
return False, error return False, error
@ -366,7 +365,7 @@ class UniversignSyncService:
previous_local_status = transaction.local_status.value previous_local_status = transaction.local_status.value
logger.info( logger.info(
f"🔄 Mapping: {universign_status_raw} (Universign) → " f"Mapping: {universign_status_raw} (Universign) → "
f"{new_local_status} (Local) | Actuel: {previous_local_status}" f"{new_local_status} (Local) | Actuel: {previous_local_status}"
) )
@ -378,9 +377,7 @@ class UniversignSyncService:
new_local_status = resolve_status_conflict( new_local_status = resolve_status_conflict(
previous_local_status, new_local_status previous_local_status, new_local_status
) )
logger.info( logger.info(f"Résolution conflit: statut résolu = {new_local_status}")
f"✅ Résolution conflit: statut résolu = {new_local_status}"
)
status_changed = previous_local_status != new_local_status status_changed = previous_local_status != new_local_status
@ -396,7 +393,6 @@ class UniversignSyncService:
) )
except ValueError: except ValueError:
logger.warning(f"⚠️ Statut Universign inconnu: {universign_status_raw}") logger.warning(f"⚠️ Statut Universign inconnu: {universign_status_raw}")
# Fallback intelligent
if new_local_status == "SIGNE": if new_local_status == "SIGNE":
transaction.universign_status = ( transaction.universign_status = (
UniversignTransactionStatus.COMPLETED UniversignTransactionStatus.COMPLETED
@ -408,7 +404,7 @@ class UniversignSyncService:
else: else:
transaction.universign_status = UniversignTransactionStatus.STARTED transaction.universign_status = UniversignTransactionStatus.STARTED
# Mise à jour du statut local # Mise à jour du statut local
transaction.local_status = LocalDocumentStatus(new_local_status) transaction.local_status = LocalDocumentStatus(new_local_status)
transaction.universign_status_updated_at = datetime.now() transaction.universign_status_updated_at = datetime.now()
@ -429,14 +425,40 @@ class UniversignSyncService:
transaction.expired_at = datetime.now() transaction.expired_at = datetime.now()
logger.info("⏰ Date d'expiration mise à jour") logger.info("⏰ Date d'expiration mise à jour")
# Mise à jour des URLs # === SECTION CORRIGÉE: Gestion des documents ===
if ( # Ne plus chercher document_url dans la réponse (elle n'existe pas!)
universign_data.get("documents") # Le téléchargement se fait via le service document qui utilise le bon endpoint
and len(universign_data["documents"]) > 0
): documents = universign_data.get("documents", [])
first_doc = universign_data["documents"][0] if documents:
if first_doc.get("url"): first_doc = documents[0]
transaction.document_url = first_doc["url"] logger.info(
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é...")
try:
(
download_success,
download_error,
) = await self.document_service.download_and_store_signed_document(
session=session, transaction=transaction, force=False
)
if download_success:
logger.info("✅ Document signé téléchargé et stocké")
else:
logger.warning(f"⚠️ Échec téléchargement: {download_error}")
except Exception as e:
logger.error(
f"❌ Erreur téléchargement document: {e}", exc_info=True
)
# === FIN SECTION CORRIGÉE ===
# Synchroniser les signataires # Synchroniser les signataires
await self._sync_signers(session, transaction, universign_data) await self._sync_signers(session, transaction, universign_data)
@ -445,7 +467,7 @@ class UniversignSyncService:
transaction.last_synced_at = datetime.now() transaction.last_synced_at = datetime.now()
transaction.sync_attempts += 1 transaction.sync_attempts += 1
transaction.needs_sync = not is_final_status(new_local_status) transaction.needs_sync = not is_final_status(new_local_status)
transaction.sync_error = None # ✅ Effacer l'erreur précédente transaction.sync_error = None
# Log de la tentative # Log de la tentative
await self._log_sync_attempt( await self._log_sync_attempt(
@ -460,9 +482,10 @@ class UniversignSyncService:
{ {
"status_changed": status_changed, "status_changed": status_changed,
"universign_raw": universign_status_raw, "universign_raw": universign_status_raw,
"documents_count": len(documents),
"response_time_ms": result.get("response_time_ms"), "response_time_ms": result.get("response_time_ms"),
}, },
default=str, # ✅ Éviter les erreurs de sérialisation default=str,
), ),
) )
@ -486,7 +509,7 @@ class UniversignSyncService:
error_msg = f"Erreur lors de la synchronisation: {str(e)}" error_msg = f"Erreur lors de la synchronisation: {str(e)}"
logger.error(f"{error_msg}", exc_info=True) logger.error(f"{error_msg}", exc_info=True)
transaction.sync_error = error_msg[:1000] # Tronquer si trop long transaction.sync_error = error_msg[:1000]
transaction.sync_attempts += 1 transaction.sync_attempts += 1
await self._log_sync_attempt( await self._log_sync_attempt(
@ -496,6 +519,57 @@ class UniversignSyncService:
return False, error_msg return False, error_msg
# CORRECTION 3 : Amélioration du logging dans sync_transaction
async def _sync_transaction_documents_corrected(
self, session, transaction, universign_data: dict, new_local_status: str
):
# Récupérer et stocker les infos documents
documents = universign_data.get("documents", [])
if documents:
# Stocker le premier document_id pour référence
first_doc = documents[0]
first_doc_id = first_doc.get("id")
if first_doc_id:
# Stocker l'ID du document (si le champ existe dans le modèle)
if hasattr(transaction, "universign_document_id"):
transaction.universign_document_id = first_doc_id
logger.info(
f"Document Universign: id={first_doc_id}, "
f"name={first_doc.get('name')}, status={first_doc.get('status')}"
)
else:
logger.debug("Aucun document dans la réponse Universign")
# 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é...")
try:
(
download_success,
download_error,
) = await self.document_service.download_and_store_signed_document(
session=session, transaction=transaction, force=False
)
if download_success:
logger.info("✅ Document signé téléchargé avec succès")
else:
logger.warning(f"⚠️ Échec téléchargement: {download_error}")
except Exception as e:
logger.error(
f"❌ Erreur téléchargement document: {e}", exc_info=True
)
else:
logger.debug(
f"Document déjà téléchargé: {transaction.signed_document_path}"
)
async def _log_sync_attempt( async def _log_sync_attempt(
self, self,
session: AsyncSession, session: AsyncSession,