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 : +

+ + + + + + +
+ + ✍️ Signer le document + +
+ + + + + + +
+

+ ⏰ 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 : +

+ + + + + + +
+ + ✍️ Signer 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)