diff --git a/api.py b/api.py
index e87f713..b4ea354 100644
--- a/api.py
+++ b/api.py
@@ -1,4 +1,4 @@
-from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body
+from fastapi import FastAPI, HTTPException, Path, Query, Depends, status, Body, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field, EmailStr, validator, field_validator
@@ -1050,34 +1050,389 @@ class MouvementStockResponse(BaseModel):
nb_lignes: int = Field(..., description="Nombre de lignes")
-async def universign_envoyer(
- doc_id: str, pdf_bytes: bytes, email: str, nom: str
-) -> Dict:
- """Envoi signature via API Universign"""
- import requests
+
+templates_signature_email = {
+ "demande_signature": {
+ "id": "demande_signature",
+ "nom": "Demande de Signature Électronique",
+ "sujet": "📝 Signature requise - {{TYPE_DOC}} {{NUMERO}}",
+ "corps_html": """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📝 Signature Électronique Requise
+
+ |
+
+
+
+
+ |
+
+ Bonjour {{NOM_SIGNATAIRE}},
+
+
+
+ Nous vous invitons à signer électroniquement le document suivant :
+
+
+
+
+
+
+
+
+ | Type de document |
+ {{TYPE_DOC}} |
+
+
+ | Numéro |
+ {{NUMERO}} |
+
+
+ | Date |
+ {{DATE}} |
+
+
+ | Montant TTC |
+ {{MONTANT_TTC}} € |
+
+
+ |
+
+
+
+
+ Cliquez sur le bouton ci-dessous pour accéder au document et apposer votre signature électronique sécurisée :
+
+
+
+
+
+
+
+
+ |
+
+ ⏰ Important : Ce lien de signature est valable pendant 30 jours.
+ Nous vous recommandons de signer ce document dès que possible.
+
+ |
+
+
+
+
+ 🔒 Signature électronique sécurisée
+ Votre signature est protégée par notre partenaire de confiance Universign,
+ certifié eIDAS et conforme au RGPD. Votre identité sera vérifiée et le document sera
+ horodaté de manière infalsifiable.
+
+ |
+
+
+
+
+ |
+
+ Vous avez des questions ? Contactez-nous à {{CONTACT_EMAIL}}
+
+
+ Cet email a été envoyé automatiquement par le système Sage 100c Dataven.
+ Si vous avez reçu cet email par erreur, veuillez nous en informer.
+
+ |
+
+
+
+ |
+
+
+
+
+ """,
+ "variables_disponibles": [
+ "NOM_SIGNATAIRE",
+ "TYPE_DOC",
+ "NUMERO",
+ "DATE",
+ "MONTANT_TTC",
+ "SIGNER_URL",
+ "CONTACT_EMAIL"
+ ]
+ },
+
+ "signature_confirmee": {
+ "id": "signature_confirmee",
+ "nom": "Confirmation de Signature",
+ "sujet": "✅ Document signé - {{TYPE_DOC}} {{NUMERO}}",
+ "corps_html": """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ✅ Document Signé avec Succès
+
+ |
+
+
+
+
+ |
+
+ Bonjour {{NOM_SIGNATAIRE}},
+
+
+
+ Nous confirmons la signature électronique du document suivant :
+
+
+
+
+
+
+
+
+ | Document |
+ {{TYPE_DOC}} {{NUMERO}} |
+
+
+ | Signé le |
+ {{DATE_SIGNATURE}} |
+
+
+ | ID Transaction |
+ {{TRANSACTION_ID}} |
+
+
+ |
+
+
+
+
+ Le document signé a été automatiquement archivé et est disponible dans votre espace client.
+ Un certificat de signature électronique conforme eIDAS a été généré.
+
+
+
+
+ |
+
+ 🔐 Signature certifiée : Ce document a été signé avec une signature
+ électronique qualifiée, ayant la même valeur juridique qu'une signature manuscrite
+ conformément au règlement eIDAS.
+
+ |
+
+
+
+
+ Merci pour votre confiance. Notre équipe reste à votre disposition pour toute question.
+
+ |
+
+
+
+
+ |
+
+ Contact : {{CONTACT_EMAIL}}
+
+
+ Sage 100c Dataven - Système de signature électronique sécurisée
+
+ |
+
+
+
+ |
+
+
+
+
+ """,
+ "variables_disponibles": [
+ "NOM_SIGNATAIRE",
+ "TYPE_DOC",
+ "NUMERO",
+ "DATE_SIGNATURE",
+ "TRANSACTION_ID",
+ "CONTACT_EMAIL"
+ ]
+ },
+
+ "relance_signature": {
+ "id": "relance_signature",
+ "nom": "Relance Signature en Attente",
+ "sujet": "⏰ Rappel - Signature en attente {{TYPE_DOC}} {{NUMERO}}",
+ "corps_html": """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⏰ Signature en Attente
+
+ |
+
+
+
+
+ |
+
+ Bonjour {{NOM_SIGNATAIRE}},
+
+
+
+ Nous vous avons envoyé il y a {{NB_JOURS}} jours un document à signer électroniquement.
+ Nous constatons que celui-ci n'a pas encore été signé.
+
+
+
+
+
+ |
+
+ Document en attente : {{TYPE_DOC}} {{NUMERO}}
+
+
+ ⏳ Le lien de signature expirera dans {{JOURS_RESTANTS}} jours
+
+ |
+
+
+
+
+ Pour éviter tout retard dans le traitement de votre dossier, nous vous invitons à signer ce document dès maintenant :
+
+
+
+
+
+
+ Si vous rencontrez des difficultés ou avez des questions, n'hésitez pas à nous contacter.
+
+ |
+
+
+
+
+ |
+
+ Contact : {{CONTACT_EMAIL}}
+
+
+ Sage 100c Dataven - Relance automatique
+
+ |
+
+
+
+ |
+
+
+
+
+ """,
+ "variables_disponibles": [
+ "NOM_SIGNATAIRE",
+ "TYPE_DOC",
+ "NUMERO",
+ "NB_JOURS",
+ "JOURS_RESTANTS",
+ "SIGNER_URL",
+ "CONTACT_EMAIL"
+ ]
+ }
+}
+
+
+async def universign_envoyer_avec_email(
+ doc_id: str,
+ pdf_bytes: bytes,
+ email: str,
+ nom: str,
+ doc_data: Dict, # Données du document (type, montant, date, etc.)
+ session: AsyncSession
+) -> Dict:
+ import requests
+
try:
api_key = settings.universign_api_key
api_url = settings.universign_api_url
auth = (api_key, "")
- # Étape 1: Créer transaction
response = requests.post(
f"{api_url}/transactions",
auth=auth,
- json={"name": f"Devis {doc_id}", "language": "fr"},
+ json={
+ "name": f"{doc_data.get('type_label', 'Document')} {doc_id}",
+ "language": "fr"
+ },
timeout=30,
)
response.raise_for_status()
transaction_id = response.json().get("id")
+
+ logger.info(f"✅ Transaction Universign créée: {transaction_id}")
- # Étape 2: Upload PDF
- files = {"file": (f"Devis_{doc_id}.pdf", pdf_bytes, "application/pdf")}
+ files = {"file": (f"{doc_data.get('type_label', 'Document')}_{doc_id}.pdf", pdf_bytes, "application/pdf")}
response = requests.post(f"{api_url}/files", auth=auth, files=files, timeout=30)
response.raise_for_status()
file_id = response.json().get("id")
-
- # Étape 3: Ajouter document
+
response = requests.post(
f"{api_url}/transactions/{transaction_id}/documents",
auth=auth,
@@ -1087,7 +1442,6 @@ async def universign_envoyer(
response.raise_for_status()
document_id = response.json().get("id")
- # Étape 4: Créer champ signature
response = requests.post(
f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields",
auth=auth,
@@ -1096,8 +1450,7 @@ async def universign_envoyer(
)
response.raise_for_status()
field_id = response.json().get("id")
-
- # Étape 5: Assigner signataire
+
response = requests.post(
f"{api_url}/transactions/{transaction_id}/signatures",
auth=auth,
@@ -1106,31 +1459,90 @@ async def universign_envoyer(
)
response.raise_for_status()
- # Étape 6: Démarrer transaction
response = requests.post(
- f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30
+ f"{api_url}/transactions/{transaction_id}/start",
+ auth=auth,
+ timeout=30
)
response.raise_for_status()
-
+
final_data = response.json()
signer_url = (
final_data.get("actions", [{}])[0].get("url", "")
if final_data.get("actions")
else ""
)
+
+ if not signer_url:
+ raise ValueError("URL de signature non retournée par Universign")
+
+ logger.info(f"✅ Signature Universign démarrée: {transaction_id}")
- logger.info(f"✅ Signature Universign envoyée: {transaction_id}")
-
+ template = templates_signature_email["demande_signature"]
+
+ # Préparer les variables
+ type_labels = {
+ 0: "Devis",
+ 10: "Commande",
+ 30: "Bon de Livraison",
+ 60: "Facture",
+ 50: "Avoir"
+ }
+
+ variables = {
+ "NOM_SIGNATAIRE": nom,
+ "TYPE_DOC": type_labels.get(doc_data.get("type_doc", 0), "Document"),
+ "NUMERO": doc_id,
+ "DATE": doc_data.get("date", datetime.now().strftime("%d/%m/%Y")),
+ "MONTANT_TTC": f"{doc_data.get('montant_ttc', 0):.2f}",
+ "SIGNER_URL": signer_url,
+ "CONTACT_EMAIL": settings.smtp_from
+ }
+
+ # Remplacer les variables dans le template
+ 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))
+
+ # Créer log email
+ email_log = EmailLog(
+ id=str(uuid.uuid4()),
+ destinataire=email,
+ sujet=sujet,
+ corps_html=corps,
+ document_ids=doc_id,
+ type_document=doc_data.get("type_doc"),
+ statut=StatutEmailEnum.EN_ATTENTE,
+ date_creation=datetime.now(),
+ nb_tentatives=0,
+ )
+
+ session.add(email_log)
+ await session.flush()
+
+ # Enqueue l'email
+ email_queue.enqueue(email_log.id)
+
+ logger.info(f"📧 Email de signature envoyé en file: {email}")
+
return {
"transaction_id": transaction_id,
"signer_url": signer_url,
"statut": "ENVOYE",
+ "email_log_id": email_log.id,
+ "email_sent": True
}
-
+
except Exception as e:
- logger.error(f"❌ Erreur Universign: {e}")
- return {"error": str(e), "statut": "ERREUR"}
-
+ logger.error(f"❌ Erreur Universign+Email: {e}")
+ return {
+ "error": str(e),
+ "statut": "ERREUR",
+ "email_sent": False
+ }
async def universign_statut(transaction_id: str) -> Dict:
"""Récupération statut signature"""
@@ -1986,26 +2398,58 @@ async def commande_vers_facture(id: str, session: AsyncSession = Depends(get_ses
logger.error(f"Erreur transformation: {e}")
raise HTTPException(500, str(e))
+def normaliser_type_doc(type_doc: int) -> int:
+ TYPES_AUTORISES = {0, 10, 30, 50, 60}
-# =====================================================
-# ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE)
-# =====================================================
-@app.post("/signature/universign/send", tags=["Signatures"])
-async def envoyer_signature(
- demande: SignatureRequest, session: AsyncSession = Depends(get_session)
-):
- try:
- # Générer PDF
- pdf_bytes = email_queue._generate_pdf(demande.doc_id, demande.type_doc)
-
- # Envoi Universign
- resultat = await universign_envoyer(
- demande.doc_id, pdf_bytes, demande.email_signataire, demande.nom_signataire
+ if type_doc not in TYPES_AUTORISES:
+ raise ValueError(
+ f"type_doc invalide ({type_doc}). Valeurs autorisées : {sorted(TYPES_AUTORISES)}"
)
+ return type_doc if type_doc == 0 else type_doc // 10
+
+
+@app.post("/signature/universign/send", tags=["Signatures"])
+async def envoyer_signature_optimise(
+ demande: SignatureRequest,
+ session: AsyncSession = Depends(get_session)
+):
+ try:
+ # Récupérer le document depuis Sage
+ doc = sage_client.lire_document(demande.doc_id, normaliser_type_doc(demande.type_doc))
+ if not doc:
+ raise HTTPException(404, f"Document {demande.doc_id} introuvable")
+
+ # Générer PDF
+ pdf_bytes = email_queue._generate_pdf(demande.doc_id, normaliser_type_doc(demande.type_doc))
+
+ # Préparer les données du document pour l'email
+ doc_data = {
+ "type_doc": demande.type_doc,
+ "type_label": {
+ 0: "Devis",
+ 10: "Commande",
+ 30: "Bon de Livraison",
+ 60: "Facture",
+ 50: "Avoir"
+ }.get(demande.type_doc, "Document"),
+ "montant_ttc": doc.get("total_ttc", 0),
+ "date": doc.get("date", datetime.now().strftime("%d/%m/%Y"))
+ }
+
+ # Envoi Universign + Email automatique
+ resultat = await universign_envoyer_avec_email(
+ doc_id=demande.doc_id,
+ pdf_bytes=pdf_bytes,
+ email=demande.email_signataire,
+ nom=demande.nom_signataire,
+ doc_data=doc_data,
+ session=session
+ )
+
if "error" in resultat:
raise HTTPException(500, resultat["error"])
-
+
# Logger en DB
signature_log = SignatureLog(
id=str(uuid.uuid4()),
@@ -2018,29 +2462,238 @@ async def envoyer_signature(
statut=StatutSignatureEnum.ENVOYE,
date_envoi=datetime.now(),
)
-
+
session.add(signature_log)
await session.commit()
-
- # MAJ champ libre Sage via gateway Windows
+
+ # MAJ champ libre Sage
sage_client.mettre_a_jour_champ_libre(
- demande.doc_id, demande.type_doc, "UniversignID", resultat["transaction_id"]
+ demande.doc_id,
+ demande.type_doc,
+ "UniversignID",
+ resultat["transaction_id"]
)
-
- logger.info(f"✅ Signature envoyée: {demande.doc_id}")
-
+
+ logger.info(f"✅ Signature envoyée: {demande.doc_id} (Email: {resultat['email_sent']})")
+
return {
"success": True,
"transaction_id": resultat["transaction_id"],
"signer_url": resultat["signer_url"],
+ "email_sent": resultat["email_sent"],
+ "email_log_id": resultat.get("email_log_id"),
+ "message": f"Demande de signature envoyée à {demande.email_signataire}"
}
-
+
except HTTPException:
raise
except Exception as e:
- logger.error(f"Erreur signature: {e}")
+ logger.error(f"❌ Erreur signature: {e}")
raise HTTPException(500, str(e))
+
+
+@app.post("/webhooks/universign", tags=["Signatures"])
+async def webhook_universign(
+ request: Request,
+ session: AsyncSession = Depends(get_session)
+):
+ try:
+ payload = await request.json()
+
+ event_type = payload.get("event")
+ transaction_id = payload.get("transaction_id")
+
+ if not transaction_id:
+ logger.warning("⚠️ Webhook sans transaction_id")
+ return {"status": "ignored"}
+
+ # Chercher la signature dans la DB
+ query = select(SignatureLog).where(
+ SignatureLog.transaction_id == transaction_id
+ )
+ result = await session.execute(query)
+ signature_log = result.scalar_one_or_none()
+
+ if not signature_log:
+ logger.warning(f"⚠️ Transaction {transaction_id} introuvable en DB")
+ return {"status": "not_found"}
+
+ # =============================================
+ # TRAITER L'EVENT SELON LE TYPE
+ # =============================================
+
+ if event_type == "transaction.completed":
+ # ✅ SIGNATURE RÉUSSIE
+ signature_log.statut = StatutSignatureEnum.SIGNE
+ signature_log.date_signature = datetime.now()
+
+ logger.info(f"✅ Signature confirmée: {signature_log.document_id}")
+
+ # ENVOYER EMAIL DE CONFIRMATION
+ template = templates_signature_email["signature_confirmee"]
+
+ type_labels = {
+ 0: "Devis", 10: "Commande", 30: "Bon de Livraison",
+ 60: "Facture", 50: "Avoir"
+ }
+
+ variables = {
+ "NOM_SIGNATAIRE": signature_log.nom_signataire,
+ "TYPE_DOC": type_labels.get(signature_log.type_document, "Document"),
+ "NUMERO": signature_log.document_id,
+ "DATE_SIGNATURE": datetime.now().strftime("%d/%m/%Y à %H:%M"),
+ "TRANSACTION_ID": transaction_id,
+ "CONTACT_EMAIL": 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))
+
+ # Créer email de confirmation
+ email_log = EmailLog(
+ id=str(uuid.uuid4()),
+ destinataire=signature_log.email_signataire,
+ sujet=sujet,
+ corps_html=corps,
+ document_ids=signature_log.document_id,
+ type_document=signature_log.type_document,
+ statut=StatutEmailEnum.EN_ATTENTE,
+ date_creation=datetime.now(),
+ nb_tentatives=0,
+ )
+
+ session.add(email_log)
+ email_queue.enqueue(email_log.id)
+
+ logger.info(f"📧 Email de confirmation envoyé: {signature_log.email_signataire}")
+
+ elif event_type == "transaction.refused":
+ # ❌ SIGNATURE REFUSÉE
+ signature_log.statut = StatutSignatureEnum.REFUSE
+ logger.warning(f"❌ Signature refusée: {signature_log.document_id}")
+
+ elif event_type == "transaction.expired":
+ # ⏰ TRANSACTION EXPIRÉE
+ signature_log.statut = StatutSignatureEnum.EXPIRE
+ logger.warning(f"⏰ Transaction expirée: {signature_log.document_id}")
+
+ await session.commit()
+
+ return {
+ "status": "processed",
+ "event": event_type,
+ "transaction_id": transaction_id
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Erreur webhook Universign: {e}")
+ return {"status": "error", "message": str(e)}
+@app.get("/admin/signatures/relances-auto", tags=["Admin"])
+async def relancer_signatures_automatique(
+ session: AsyncSession = Depends(get_session)
+):
+ try:
+ from datetime import timedelta
+
+ # Chercher signatures en attente depuis > 7 jours
+ date_limite = datetime.now() - timedelta(days=7)
+
+ query = select(SignatureLog).where(
+ SignatureLog.statut.in_([
+ StatutSignatureEnum.EN_ATTENTE,
+ StatutSignatureEnum.ENVOYE
+ ]),
+ SignatureLog.date_envoi < date_limite,
+ SignatureLog.nb_relances < 3 # Max 3 relances
+ )
+
+ result = await session.execute(query)
+ signatures_a_relancer = result.scalars().all()
+
+ nb_relances = 0
+
+ for signature in signatures_a_relancer:
+ try:
+ # Calculer jours écoulés
+ nb_jours = (datetime.now() - signature.date_envoi).days
+ jours_restants = 30 - nb_jours # Lien expire après 30 jours
+
+ if jours_restants <= 0:
+ # Transaction expirée
+ signature.statut = StatutSignatureEnum.EXPIRE
+ continue
+
+ # Préparer email de relance
+ template = templates_signature_email["relance_signature"]
+
+ type_labels = {
+ 0: "Devis", 10: "Commande", 30: "Bon de Livraison",
+ 60: "Facture", 50: "Avoir"
+ }
+
+ variables = {
+ "NOM_SIGNATAIRE": signature.nom_signataire,
+ "TYPE_DOC": type_labels.get(signature.type_document, "Document"),
+ "NUMERO": signature.document_id,
+ "NB_JOURS": str(nb_jours),
+ "JOURS_RESTANTS": str(jours_restants),
+ "SIGNER_URL": signature.signer_url,
+ "CONTACT_EMAIL": 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))
+
+ # Créer email de relance
+ email_log = EmailLog(
+ id=str(uuid.uuid4()),
+ destinataire=signature.email_signataire,
+ sujet=sujet,
+ corps_html=corps,
+ document_ids=signature.document_id,
+ type_document=signature.type_document,
+ statut=StatutEmailEnum.EN_ATTENTE,
+ date_creation=datetime.now(),
+ nb_tentatives=0,
+ )
+
+ session.add(email_log)
+ email_queue.enqueue(email_log.id)
+
+ # Incrémenter compteur de relances
+ signature.est_relance = True
+ signature.nb_relances = (signature.nb_relances or 0) + 1
+
+ nb_relances += 1
+
+ logger.info(f"📧 Relance envoyée: {signature.document_id} ({signature.nb_relances}/3)")
+
+ except Exception as e:
+ logger.error(f"❌ Erreur relance signature {signature.id}: {e}")
+ continue
+
+ await session.commit()
+
+ return {
+ "success": True,
+ "signatures_verifiees": len(signatures_a_relancer),
+ "relances_envoyees": nb_relances,
+ "message": f"{nb_relances} email(s) de relance envoyé(s)"
+ }
+
+ except Exception as e:
+ logger.error(f"❌ Erreur relances automatiques: {e}")
+ raise HTTPException(500, str(e))
+
@app.get("/signature/universign/status", tags=["Signatures"])
async def statut_signature(docId: str = Query(...)):
diff --git a/database/models.py b/database/models.py
index ff7c224..da8c7b2 100644
--- a/database/models.py
+++ b/database/models.py
@@ -124,6 +124,7 @@ class SignatureLog(Base):
# Relances
est_relance = Column(Boolean, default=False)
nb_relances = Column(Integer, default=0)
+ derniere_relance = Column(DateTime, nullable=True)
# Métadonnées
raison_refus = Column(Text, nullable=True)