From 5e06aafff70cd9f3084485f7c3a6055dfc053f2f Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Wed, 26 Nov 2025 11:37:05 +0300 Subject: [PATCH] Initial backend FastAPI VPS --- .env.example | 32 +++ .gitignore | 38 +++ api.py | 666 +++++++++++++++++++++++++++++++++++++++++++++++++ config.py | 43 ++++ sage_client.py | 99 ++++++++ 5 files changed, 878 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 api.py create mode 100644 config.py create mode 100644 sage_client.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0995196 --- /dev/null +++ b/.env.example @@ -0,0 +1,32 @@ +# ============================================ +# Configuration Linux VPS - API Principale +# ============================================ + +# === Sage Gateway Windows === +SAGE_GATEWAY_URL=http://192.168.1.50:8100 +SAGE_GATEWAY_TOKEN=4e8f9c2a7b1d5e3f9a0c8b7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f + +# === Base de données === +DATABASE_URL=sqlite+aiosqlite:///./sage_dataven.db + +# === SMTP === +SMTP_HOST=smtp.office365.com +SMTP_PORT=587 +SMTP_USER=commercial@monentreprise.fr +SMTP_PASSWORD=MonMotDePasseEmail123! +SMTP_FROM=commercial@monentreprise.fr + +# === Universign === +UNIVERSIGN_API_KEY=your_real_universign_key_here +UNIVERSIGN_API_URL=https://api.universign.com/v1 + +# === API === +API_HOST=0.0.0.0 +API_PORT=8002 +API_RELOAD=False + +# === Email Queue === +MAX_EMAIL_WORKERS=3 + +# === Logs === +LOG_LEVEL=INFO \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5407a98 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# ================================ +# Python / FastAPI +# ================================ + +# Environnements virtuels +venv/ +.env +*.env +*.local.env + +# caches +__pycache__/ +*.py[cod] +*.pyo + +# logs +*.log + +# Compilations +*.so +*.dll + +# Outils Python +.mypy_cache/ +.pytest_cache/ +.coverage +htmlcov/ + +# VSCode +.vscode/ + +# PyCharm +.idea/ + +# Docker +*~ +.build/ +dist/ diff --git a/api.py b/api.py new file mode 100644 index 0000000..c88a57d --- /dev/null +++ b/api.py @@ -0,0 +1,666 @@ +from fastapi import FastAPI, HTTPException, Query, Depends, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field, EmailStr +from typing import List, Optional, Dict +from datetime import date, datetime +from enum import Enum +import uvicorn +from contextlib import asynccontextmanager +import uuid +import csv +import io +import logging +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +# Configuration logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler("sage_api.log"), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +# Imports locaux +from config import settings +from database.config import init_db, async_session_factory, get_session +from database.models import ( + EmailLog as EmailLogModel, + StatutEmail as StatutEmailEnum, + WorkflowLog, + SignatureLog, + StatutSignature as StatutSignatureEnum +) +from email_queue import email_queue +from sage_client import sage_client + +# ===================================================== +# ENUMS +# ===================================================== +class TypeDocument(int, Enum): + DEVIS = 0 + BON_LIVRAISON = 1 + BON_RETOUR = 2 + COMMANDE = 3 + PREPARATION = 4 + FACTURE = 5 + +class StatutSignature(str, Enum): + EN_ATTENTE = "EN_ATTENTE" + ENVOYE = "ENVOYE" + SIGNE = "SIGNE" + REFUSE = "REFUSE" + EXPIRE = "EXPIRE" + +class StatutEmail(str, Enum): + EN_ATTENTE = "EN_ATTENTE" + EN_COURS = "EN_COURS" + ENVOYE = "ENVOYE" + OUVERT = "OUVERT" + ERREUR = "ERREUR" + BOUNCE = "BOUNCE" + +# ===================================================== +# MODÈLES PYDANTIC +# ===================================================== +class ClientResponse(BaseModel): + numero: str + intitule: str + adresse: Optional[str] = None + code_postal: Optional[str] = None + ville: Optional[str] = None + email: Optional[str] = None + telephone: Optional[str] = None + +class ArticleResponse(BaseModel): + reference: str + designation: str + prix_vente: float + stock_reel: float + +class LigneDevis(BaseModel): + article_code: str + quantite: float + prix_unitaire_ht: Optional[float] = None + remise_pourcentage: Optional[float] = 0.0 + +class DevisRequest(BaseModel): + client_id: str + date_devis: Optional[date] = None + lignes: List[LigneDevis] + +class DevisResponse(BaseModel): + id: str + client_id: str + date_devis: str + montant_total_ht: float + montant_total_ttc: float + nb_lignes: int + +class SignatureRequest(BaseModel): + doc_id: str + type_doc: TypeDocument + email_signataire: EmailStr + nom_signataire: str + +class EmailEnvoiRequest(BaseModel): + destinataire: EmailStr + cc: Optional[List[EmailStr]] = [] + cci: Optional[List[EmailStr]] = [] + sujet: str + corps_html: str + document_ids: Optional[List[str]] = None + type_document: Optional[TypeDocument] = None + +class RelanceDevisRequest(BaseModel): + doc_id: str + message_personnalise: Optional[str] = None + +# ===================================================== +# SERVICES EXTERNES (Universign) +# ===================================================== +async def universign_envoyer(doc_id: str, pdf_bytes: bytes, email: str, nom: str) -> Dict: + """Envoi signature via API Universign""" + 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"}, + timeout=30 + ) + response.raise_for_status() + transaction_id = response.json().get("id") + + # Étape 2: Upload PDF + files = {'file': (f'Devis_{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, + data={"document": file_id}, + timeout=30 + ) + 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, + data={"type": "signature"}, + timeout=30 + ) + 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, + data={"signer": email, "field": field_id}, + timeout=30 + ) + response.raise_for_status() + + # Étape 6: Démarrer transaction + response = requests.post( + 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 "" + + logger.info(f"✅ Signature Universign envoyée: {transaction_id}") + + return { + "transaction_id": transaction_id, + "signer_url": signer_url, + "statut": "ENVOYE" + } + + except Exception as e: + logger.error(f"❌ Erreur Universign: {e}") + return {"error": str(e), "statut": "ERREUR"} + +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)} + +# ===================================================== +# CYCLE DE VIE +# ===================================================== +@asynccontextmanager +async def lifespan(app: FastAPI): + # Init base de données + await init_db() + logger.info("✅ Base de données initialisée") + + # Injecter session_factory dans email_queue + email_queue.session_factory = async_session_factory + + # ⚠️ PAS de sage_connector ici (c'est sur Windows !) + # email_queue utilisera sage_client pour générer les PDFs via HTTP + + # Démarrer queue + email_queue.start(num_workers=settings.max_email_workers) + logger.info(f"✅ Email queue démarrée") + + yield + + # Cleanup + email_queue.stop() + logger.info("👋 Services arrêtés") + +# ===================================================== +# APPLICATION +# ===================================================== +app = FastAPI( + title="API Sage 100c Dataven", + version="2.0.0", + description="API de gestion commerciale - VPS Linux", + lifespan=lifespan +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["*"], + allow_credentials=True +) + +# ===================================================== +# ENDPOINTS - US-A1 (CRÉATION RAPIDE DEVIS) +# ===================================================== +@app.get("/clients", response_model=List[ClientResponse], tags=["US-A1"]) +async def rechercher_clients(query: Optional[str] = Query(None)): + """🔍 Recherche clients via gateway Windows""" + try: + clients = sage_client.lister_clients(filtre=query or "") + return [ClientResponse(**c) for c in clients] + except Exception as e: + logger.error(f"Erreur recherche clients: {e}") + raise HTTPException(500, str(e)) + +@app.get("/articles", response_model=List[ArticleResponse], tags=["US-A1"]) +async def rechercher_articles(query: Optional[str] = Query(None)): + """🔍 Recherche articles via gateway Windows""" + try: + articles = sage_client.lister_articles(filtre=query or "") + return [ArticleResponse(**a) for a in articles] + except Exception as e: + logger.error(f"Erreur recherche articles: {e}") + raise HTTPException(500, str(e)) + +@app.post("/devis", response_model=DevisResponse, status_code=201, tags=["US-A1"]) +async def creer_devis(devis: DevisRequest): + """📝 Création de devis via gateway Windows""" + try: + # Préparer les données pour la gateway + devis_data = { + "client_id": devis.client_id, + "date_devis": devis.date_devis.isoformat() if devis.date_devis else None, + "lignes": [ + { + "article_code": l.article_code, + "quantite": l.quantite, + "prix_unitaire_ht": l.prix_unitaire_ht, + "remise_pourcentage": l.remise_pourcentage + } + for l in devis.lignes + ] + } + + # Appel HTTP vers Windows + resultat = sage_client.creer_devis(devis_data) + + logger.info(f"✅ Devis créé: {resultat.get('numero_devis')}") + + return DevisResponse( + id=resultat["numero_devis"], + client_id=devis.client_id, + date_devis=resultat["date_devis"], + montant_total_ht=resultat["total_ht"], + montant_total_ttc=resultat["total_ttc"], + nb_lignes=resultat["nb_lignes"] + ) + + except Exception as e: + logger.error(f"Erreur création devis: {e}") + raise HTTPException(500, str(e)) + +@app.get("/devis/{id}", tags=["US-A1"]) +async def lire_devis(id: str): + """📄 Lecture d'un devis via gateway Windows""" + try: + devis = sage_client.lire_devis(id) + if not devis: + raise HTTPException(404, f"Devis {id} introuvable") + return devis + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur lecture devis: {e}") + raise HTTPException(500, str(e)) + +@app.get("/devis/{id}/pdf", tags=["US-A1"]) +async def telecharger_devis_pdf(id: str): + """📄 Téléchargement PDF (généré via email_queue)""" + try: + # Générer PDF en appelant la méthode de email_queue + # qui elle-même appellera sage_client pour récupérer les données + pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) + + return StreamingResponse( + iter([pdf_bytes]), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=Devis_{id}.pdf"} + ) + except Exception as e: + logger.error(f"Erreur génération PDF: {e}") + raise HTTPException(500, str(e)) + +@app.post("/devis/{id}/envoyer", tags=["US-A1"]) +async def envoyer_devis_email( + id: str, + request: EmailEnvoiRequest, + session: AsyncSession = Depends(get_session) +): + """📧 Envoi devis par email""" + try: + # Vérifier que le devis existe + devis = sage_client.lire_devis(id) + if not devis: + raise HTTPException(404, f"Devis {id} introuvable") + + # Créer logs email pour chaque destinataire + tous_destinataires = [request.destinataire] + request.cc + request.cci + email_logs = [] + + for dest in tous_destinataires: + email_log = EmailLogModel( + id=str(uuid.uuid4()), + destinataire=dest, + sujet=request.sujet, + corps_html=request.corps_html, + document_ids=id, + type_document=TypeDocument.DEVIS, + statut=StatutEmailEnum.EN_ATTENTE, + date_creation=datetime.now(), + nb_tentatives=0 + ) + + session.add(email_log) + await session.flush() + + email_queue.enqueue(email_log.id) + email_logs.append(email_log.id) + + await session.commit() + + logger.info(f"✅ Email devis {id} mis en file: {len(tous_destinataires)} destinataire(s)") + + return { + "success": True, + "email_log_ids": email_logs, + "devis_id": id, + "message": f"{len(tous_destinataires)} email(s) en file d'attente" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur envoi email: {e}") + raise HTTPException(500, str(e)) + +# ===================================================== +# ENDPOINTS - US-A2 (WORKFLOW SANS RESSAISIE) +# ===================================================== +@app.post("/workflow/devis/{id}/to-commande", tags=["US-A2"]) +async def devis_vers_commande( + id: str, + session: AsyncSession = Depends(get_session) +): + """🔧 Transformation Devis → Commande via gateway Windows""" + try: + # Appel HTTP vers Windows + resultat = sage_client.transformer_document( + numero_source=id, + type_source=TypeDocument.DEVIS, + type_cible=TypeDocument.COMMANDE + ) + + # Logger en DB + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.DEVIS, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.COMMANDE, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True + ) + + session.add(workflow_log) + await session.commit() + + logger.info(f"✅ Transformation: Devis {id} → Commande {resultat['document_cible']}") + + return { + "success": True, + "document_source": id, + "document_cible": resultat["document_cible"], + "nb_lignes": resultat["nb_lignes"] + } + + except Exception as e: + logger.error(f"Erreur transformation: {e}") + raise HTTPException(500, str(e)) + +@app.post("/workflow/commande/{id}/to-facture", tags=["US-A2"]) +async def commande_vers_facture( + id: str, + session: AsyncSession = Depends(get_session) +): + """🔧 Transformation Commande → Facture via gateway Windows""" + try: + resultat = sage_client.transformer_document( + numero_source=id, + type_source=TypeDocument.COMMANDE, + type_cible=TypeDocument.FACTURE + ) + + workflow_log = WorkflowLog( + id=str(uuid.uuid4()), + document_source=id, + type_source=TypeDocument.COMMANDE, + document_cible=resultat.get("document_cible", ""), + type_cible=TypeDocument.FACTURE, + nb_lignes=resultat.get("nb_lignes", 0), + date_transformation=datetime.now(), + succes=True + ) + + session.add(workflow_log) + await session.commit() + + return resultat + + except Exception as e: + logger.error(f"Erreur transformation: {e}") + raise HTTPException(500, str(e)) + +# ===================================================== +# ENDPOINTS - US-A3 (SIGNATURE ÉLECTRONIQUE) +# ===================================================== +@app.post("/signature/universign/send", tags=["US-A3"]) +async def envoyer_signature( + demande: SignatureRequest, + session: AsyncSession = Depends(get_session) +): + """✍️ Envoi document pour signature Universign""" + 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 "error" in resultat: + raise HTTPException(500, resultat["error"]) + + # Logger en DB + signature_log = SignatureLog( + id=str(uuid.uuid4()), + document_id=demande.doc_id, + type_document=demande.type_doc, + transaction_id=resultat["transaction_id"], + signer_url=resultat["signer_url"], + email_signataire=demande.email_signataire, + nom_signataire=demande.nom_signataire, + statut=StatutSignatureEnum.ENVOYE, + date_envoi=datetime.now() + ) + + session.add(signature_log) + await session.commit() + + # MAJ champ libre Sage via gateway Windows + sage_client.mettre_a_jour_champ_libre( + demande.doc_id, + demande.type_doc, + "UniversignID", + resultat["transaction_id"] + ) + + logger.info(f"✅ Signature envoyée: {demande.doc_id}") + + return { + "success": True, + "transaction_id": resultat["transaction_id"], + "signer_url": resultat["signer_url"] + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur signature: {e}") + raise HTTPException(500, str(e)) + +# ===================================================== +# ENDPOINTS - US-A6 (RELANCE DEVIS) +# ===================================================== +@app.post("/devis/{id}/relancer-signature", tags=["US-A6"]) +async def relancer_devis_signature( + id: str, + relance: RelanceDevisRequest, + session: AsyncSession = Depends(get_session) +): + """📧 Relance devis via Universign""" + try: + # Lire devis via gateway + devis = sage_client.lire_devis(id) + if not devis: + raise HTTPException(404, f"Devis {id} introuvable") + + # Récupérer contact via gateway + contact = sage_client.lire_contact_client(devis["client_code"]) + if not contact or not contact.get("email"): + raise HTTPException(400, "Aucun email trouvé pour ce client") + + # Générer PDF + pdf_bytes = email_queue._generate_pdf(id, TypeDocument.DEVIS) + + # Envoi Universign + resultat = await universign_envoyer( + id, + pdf_bytes, + contact["email"], + contact["nom"] or contact["client_intitule"] + ) + + if "error" in resultat: + raise HTTPException(500, resultat["error"]) + + # Logger en DB + signature_log = SignatureLog( + id=str(uuid.uuid4()), + document_id=id, + type_document=TypeDocument.DEVIS, + transaction_id=resultat["transaction_id"], + signer_url=resultat["signer_url"], + email_signataire=contact["email"], + nom_signataire=contact["nom"] or contact["client_intitule"], + statut=StatutSignatureEnum.ENVOYE, + date_envoi=datetime.now(), + est_relance=True, + nb_relances=1 + ) + + session.add(signature_log) + await session.commit() + + return { + "success": True, + "devis_id": id, + "transaction_id": resultat["transaction_id"], + "message": "Relance signature envoyée" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Erreur relance: {e}") + raise HTTPException(500, str(e)) + +# ===================================================== +# ENDPOINTS - HEALTH +# ===================================================== +@app.get("/health", tags=["System"]) +async def health_check(): + """🏥 Health check""" + gateway_health = sage_client.health() + + return { + "status": "healthy", + "sage_gateway": gateway_health, + "email_queue": { + "running": email_queue.running, + "workers": len(email_queue.workers), + "queue_size": email_queue.queue.qsize() + }, + "timestamp": datetime.now().isoformat() + } + +@app.get("/", tags=["System"]) +async def root(): + """🏠 Page d'accueil""" + return { + "api": "Sage 100c Dataven - VPS Linux", + "version": "2.0.0", + "documentation": "/docs", + "health": "/health" + } + +# ===================================================== +# LANCEMENT +# ===================================================== +if __name__ == "__main__": + uvicorn.run( + "api:app", + host=settings.api_host, + port=settings.api_port, + reload=settings.api_reload + ) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..111a969 --- /dev/null +++ b/config.py @@ -0,0 +1,43 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import List + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore" + ) + + # === Sage Gateway (Windows) === + sage_gateway_url: str + sage_gateway_token: str + + # === Base de données === + database_url: str = "sqlite+aiosqlite:///./sage_dataven.db" + + # === SMTP === + smtp_host: str + smtp_port: int = 587 + smtp_user: str + smtp_password: str + smtp_from: str + + # === Universign === + universign_api_key: str + universign_api_url: str = "https://api.universign.com/v1" + + # === API === + api_host: str = "0.0.0.0" + api_port: int = 8002 + api_reload: bool = False + + # === Email Queue === + max_email_workers: int = 3 + max_retry_attempts: int = 3 + retry_delay_seconds: int = 60 + + # === CORS === + cors_origins: List[str] = ["*"] + +settings = Settings() \ No newline at end of file diff --git a/sage_client.py b/sage_client.py new file mode 100644 index 0000000..a0b92fe --- /dev/null +++ b/sage_client.py @@ -0,0 +1,99 @@ +import requests +from typing import Dict, List, Optional +from config import settings +import logging + +logger = logging.getLogger(__name__) + +class SageGatewayClient: + """ + Client HTTP pour communiquer avec la gateway Sage Windows + """ + + def __init__(self): + self.url = settings.sage_gateway_url.rstrip("/") + self.headers = { + "X-Sage-Token": settings.sage_gateway_token, + "Content-Type": "application/json" + } + self.timeout = 30 + + def _post(self, endpoint: str, data: dict = None, retries: int = 3) -> dict: + """POST avec retry automatique""" + import time + + for attempt in range(retries): + try: + r = requests.post( + f"{self.url}{endpoint}", + json=data or {}, + headers=self.headers, + timeout=self.timeout + ) + r.raise_for_status() + return r.json() + except requests.exceptions.RequestException as e: + if attempt == retries - 1: + logger.error(f"❌ Échec après {retries} tentatives sur {endpoint}: {e}") + raise + time.sleep(2 ** attempt) # Backoff exponentiel + + # === CLIENTS === + def lister_clients(self, filtre: str = "") -> List[Dict]: + return self._post("/sage/clients/list", {"filtre": filtre}).get("data", []) + + def lire_client(self, code: str) -> Optional[Dict]: + return self._post("/sage/clients/get", {"code": code}).get("data") + + # === ARTICLES === + def lister_articles(self, filtre: str = "") -> List[Dict]: + return self._post("/sage/articles/list", {"filtre": filtre}).get("data", []) + + def lire_article(self, ref: str) -> Optional[Dict]: + return self._post("/sage/articles/get", {"code": ref}).get("data") + + # === DEVIS === + def creer_devis(self, devis_data: Dict) -> Dict: + return self._post("/sage/devis/create", devis_data).get("data", {}) + + def lire_devis(self, numero: str) -> Optional[Dict]: + return self._post("/sage/devis/get", {"code": numero}).get("data") + + # === DOCUMENTS === + def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]: + return self._post("/sage/documents/get", {"numero": numero, "type_doc": type_doc}).get("data") + + def transformer_document(self, numero_source: str, type_source: int, type_cible: int) -> Dict: + return self._post("/sage/documents/transform", { + "numero_source": numero_source, + "type_source": type_source, + "type_cible": type_cible + }).get("data", {}) + + def mettre_a_jour_champ_libre(self, doc_id: str, type_doc: int, nom_champ: str, valeur: str) -> bool: + resp = self._post("/sage/documents/champ-libre", { + "doc_id": doc_id, + "type_doc": type_doc, + "nom_champ": nom_champ, + "valeur": valeur + }) + return resp.get("success", False) + + # === CONTACTS === + def lire_contact_client(self, code_client: str) -> Optional[Dict]: + return self._post("/sage/contact/read", {"code": code_client}).get("data") + + # === CACHE === + def refresh_cache(self) -> Dict: + return self._post("/sage/cache/refresh") + + # === HEALTH === + def health(self) -> dict: + try: + r = requests.get(f"{self.url}/health", timeout=5) + return r.json() + except: + return {"status": "down"} + +# Instance globale +sage_client = SageGatewayClient() \ No newline at end of file