464 lines
13 KiB
Python
464 lines
13 KiB
Python
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] = {
|
|
"draft": "EN_ATTENTE",
|
|
"ready": "EN_ATTENTE",
|
|
"started": "EN_COURS",
|
|
"completed": "SIGNE",
|
|
"closed": "SIGNE",
|
|
"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"]
|