from typing import Dict, List from config.config import settings import logging from datetime import datetime import uuid import requests from sqlalchemy.ext.asyncio import AsyncSession from data.data import templates_signature_email from database import EmailLog, StatutEmail as StatutEmailEnum logger = logging.getLogger(__name__) async def universign_envoyer( doc_id: str, pdf_bytes: bytes, email: str, nom: str, doc_data: Dict, session: AsyncSession, ) -> Dict: from email_queue import email_queue try: api_key = settings.universign_api_key api_url = settings.universign_api_url auth = (api_key, "") logger.info(f" Démarrage processus Universign pour {email}") logger.info(f"Document: {doc_id} ({doc_data.get('type_label')})") if not pdf_bytes or len(pdf_bytes) == 0: raise Exception("Le PDF généré est vide") logger.info(f"PDF valide : {len(pdf_bytes)} octets") logger.info("ÉTAPE 1/6 : Création transaction") response = requests.post( f"{api_url}/transactions", auth=auth, json={ "name": f"{doc_data.get('type_label', 'Document')} {doc_id}", "language": "fr", }, timeout=30, ) if response.status_code != 200: logger.error(f"Erreur création transaction: {response.text}") raise Exception(f"Erreur création transaction: {response.status_code}") transaction_id = response.json().get("id") logger.info(f"Transaction créée: {transaction_id}") logger.info("ÉTAPE 2/6 : Upload 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=60, ) if response.status_code not in [200, 201]: logger.error(f"Erreur upload: {response.text}") raise Exception(f"Erreur upload fichier: {response.status_code}") file_id = response.json().get("id") logger.info(f"Fichier uploadé: {file_id}") logger.info("ÉTAPE 3/6 : Ajout document à transaction") response = requests.post( f"{api_url}/transactions/{transaction_id}/documents", auth=auth, data={"document": file_id}, timeout=30, ) if response.status_code not in [200, 201]: logger.error(f"Erreur ajout document: {response.text}") raise Exception(f"Erreur ajout document: {response.status_code}") document_id = response.json().get("id") logger.info(f"Document ajouté: {document_id}") logger.info("ÉTAPE 4/6 : Création champ signature") response = requests.post( f"{api_url}/transactions/{transaction_id}/documents/{document_id}/fields", auth=auth, data={ "type": "signature", }, timeout=30, ) if response.status_code not in [200, 201]: logger.error(f"Erreur création champ: {response.text}") raise Exception(f"Erreur création champ: {response.status_code}") field_id = response.json().get("id") logger.info(f"Champ créé: {field_id}") logger.info(" ÉTAPE 5/6 : Liaison signataire au champ") response = requests.post( f"{api_url}/transactions/{transaction_id}/signatures", # /signatures pas /signers auth=auth, data={ "signer": email, "field": field_id, }, timeout=30, ) if response.status_code not in [200, 201]: logger.error(f"Erreur liaison signataire: {response.text}") raise Exception(f"Erreur liaison signataire: {response.status_code}") logger.info(f"Signataire lié: {email}") logger.info("ÉTAPE 6/6 : Démarrage transaction") response = requests.post( f"{api_url}/transactions/{transaction_id}/start", auth=auth, timeout=30 ) if response.status_code not in [200, 201]: logger.error(f"Erreur démarrage: {response.text}") raise Exception(f"Erreur démarrage: {response.status_code}") final_data = response.json() logger.info("Transaction démarrée") logger.info("Récupération URL de signature") signer_url = "" if final_data.get("actions"): for action in final_data["actions"]: if action.get("url"): signer_url = action["url"] break if not signer_url and final_data.get("signers"): for signer in final_data["signers"]: if signer.get("email") == email: signer_url = signer.get("url", "") break if not signer_url: logger.error(f"URL introuvable dans: {final_data}") raise ValueError("URL de signature non retournée par Universign") logger.info("URL récupérée") logger.info(" Préparation email") template = templates_signature_email["demande_signature"] 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, } sujet = template["sujet"] corps = template["corps_html"] for var, valeur in variables.items(): sujet = sujet.replace(f"{{{{{var}}}}}", str(valeur)) corps = corps.replace(f"{{{{{var}}}}}", str(valeur)) email_log = EmailLog( id=str(uuid.uuid4()), destinataire=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() email_queue.enqueue(email_log.id) logger.info(f"Email mis en file pour {email}") logger.info("🎉 Processus terminé avec succès") 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}", exc_info=True) return { "error": str(e), "statut": "ERREUR", "email_sent": False, } async def universign_statut(transaction_id: str) -> Dict: """Récupération statut signature""" import requests try: response = requests.get( f"{settings.universign_api_url}/transactions/{transaction_id}", auth=(settings.universign_api_key, ""), timeout=10, ) if response.status_code == 200: data = response.json() statut_map = { "draft": "EN_ATTENTE", "started": "EN_ATTENTE", "completed": "SIGNE", "refused": "REFUSE", "expired": "EXPIRE", "canceled": "REFUSE", } return { "statut": statut_map.get(data.get("state"), "EN_ATTENTE"), "date_signature": data.get("completed_at"), } else: return {"statut": "ERREUR"} except Exception as e: logger.error(f"Erreur statut Universign: {e}") return {"statut": "ERREUR", "error": str(e)} def normaliser_type_doc(type_doc: int) -> int: TYPES_AUTORISES = {0, 10, 30, 50, 60} 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 def _preparer_lignes_document(lignes: List) -> List[Dict]: return [ { "article_code": ligne.article_code, "quantite": ligne.quantite, "prix_unitaire_ht": ligne.prix_unitaire_ht, "remise_pourcentage": ligne.remise_pourcentage or 0.0, } for ligne in lignes ] UNIVERSIGN_TO_LOCAL: Dict[str, str] = { # États initiaux "draft": "EN_ATTENTE", "ready": "EN_ATTENTE", # En cours "started": "EN_COURS", # États finaux (succès) "completed": "SIGNE", "closed": "SIGNE", # États finaux (échec) "refused": "REFUSE", "expired": "EXPIRE", "canceled": "REFUSE", "failed": "ERREUR", } LOCAL_TO_SAGE_STATUS: Dict[str, int] = { "EN_ATTENTE": 0, "EN_COURS": 1, "SIGNE": 2, "REFUSE": 3, "EXPIRE": 4, "ERREUR": 5, } STATUS_ACTIONS: Dict[str, Dict[str, any]] = { """ Actions automatiques à déclencher selon le statut """ "SIGNE": { "update_sage_status": True, # Mettre à jour Sage "trigger_workflow": True, # Déclencher transformation (devis→commande) "send_notification": True, # Email de confirmation "archive_document": True, # Archiver le PDF signé "update_sage_field": "CB_DateSignature", # Champ libre Sage }, "REFUSE": { "update_sage_status": True, "trigger_workflow": False, "send_notification": True, "archive_document": False, "alert_sales": True, # Alerter commercial }, "EXPIRE": { "update_sage_status": True, "trigger_workflow": False, "send_notification": True, "archive_document": False, "schedule_reminder": True, # Programmer relance }, "ERREUR": { "update_sage_status": False, "trigger_workflow": False, "send_notification": False, "log_error": True, "retry_sync": True, }, } ALLOWED_TRANSITIONS: Dict[str, list] = { """ Transitions de statuts autorisées (validation) """ "EN_ATTENTE": ["EN_COURS", "ERREUR"], "EN_COURS": ["SIGNE", "REFUSE", "EXPIRE", "ERREUR"], "SIGNE": [], # État final, pas de retour "REFUSE": [], # État final "EXPIRE": [], # État final "ERREUR": ["EN_ATTENTE", "EN_COURS"], # Retry possible } def map_universign_to_local(universign_status: str) -> str: return UNIVERSIGN_TO_LOCAL.get( universign_status.lower(), "ERREUR", # Fallback si statut inconnu ) def get_sage_status_code(local_status: str) -> int: return LOCAL_TO_SAGE_STATUS.get(local_status, 5) def is_transition_allowed(from_status: str, to_status: str) -> bool: if from_status == to_status: return True # Même statut = OK (idempotence) allowed = ALLOWED_TRANSITIONS.get(from_status, []) return to_status in allowed def get_status_actions(local_status: str) -> Dict[str, any]: return STATUS_ACTIONS.get(local_status, {}) def is_final_status(local_status: str) -> bool: return local_status in ["SIGNE", "REFUSE", "EXPIRE"] STATUS_PRIORITY: Dict[str, int] = { "ERREUR": 0, "EN_ATTENTE": 1, "EN_COURS": 2, "EXPIRE": 3, "REFUSE": 4, "SIGNE": 5, } def resolve_status_conflict(status_a: str, status_b: str) -> str: priority_a = STATUS_PRIORITY.get(status_a, 0) priority_b = STATUS_PRIORITY.get(status_b, 0) return status_a if priority_a >= priority_b else status_b STATUS_MESSAGES: Dict[str, Dict[str, str]] = { "EN_ATTENTE": { "fr": "Document en attente d'envoi", "en": "Document pending", "icon": "⏳", "color": "gray", }, "EN_COURS": { "fr": "En attente de signature", "en": "Awaiting signature", "icon": "✍️", "color": "blue", }, "SIGNE": { "fr": "Signé avec succès", "en": "Successfully signed", "icon": "", "color": "green", }, "REFUSE": { "fr": "Signature refusée", "en": "Signature refused", "icon": "❌", "color": "red", }, "EXPIRE": { "fr": "Délai de signature expiré", "en": "Signature expired", "icon": "⏰", "color": "orange", }, "ERREUR": { "fr": "Erreur technique", "en": "Technical error", "icon": "", "color": "red", }, } def get_status_message(local_status: str, lang: str = "fr") -> str: """ Obtient le message utilisateur pour un statut Args: local_status: Statut local lang: Langue (fr, en) Returns: Message formaté """ status_info = STATUS_MESSAGES.get(local_status, {}) icon = status_info.get("icon", "") message = status_info.get(lang, local_status) return f"{icon} {message}" __all__ = ["_preparer_lignes_document", "normaliser_type_doc"]