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) def _get(self, endpoint: str, params: dict = None, retries: int = 3) -> dict: """GET avec retry automatique""" import time for attempt in range(retries): try: r = requests.get( f"{self.url}{endpoint}", params=params 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 GET après {retries} tentatives sur {endpoint}: {e}" ) raise time.sleep(2**attempt) # ===================================================== # CLIENTS # ===================================================== def lister_clients(self, filtre: str = "") -> List[Dict]: """Liste tous les clients avec filtre optionnel""" return self._post("/sage/clients/list", {"filtre": filtre}).get("data", []) def lire_client(self, code: str) -> Optional[Dict]: """Lecture d'un client par code""" return self._post("/sage/clients/get", {"code": code}).get("data") # ===================================================== # ARTICLES # ===================================================== def lister_articles(self, filtre: str = "") -> List[Dict]: """Liste tous les articles avec filtre optionnel""" return self._post("/sage/articles/list", {"filtre": filtre}).get("data", []) def lire_article(self, ref: str) -> Optional[Dict]: """Lecture d'un article par référence""" return self._post("/sage/articles/get", {"code": ref}).get("data") # ===================================================== # DEVIS (US-A1) # ===================================================== def creer_devis(self, devis_data: Dict) -> Dict: """Création d'un devis""" return self._post("/sage/devis/create", devis_data).get("data", {}) def lire_devis(self, numero: str) -> Optional[Dict]: """Lecture d'un devis""" return self._post("/sage/devis/get", {"code": numero}).get("data") def lister_devis( self, limit: int = 100, statut: Optional[int] = None, inclure_lignes: bool = True, ) -> List[Dict]: """ ✅ Liste tous les devis avec filtres """ payload = {"limit": limit, "inclure_lignes": inclure_lignes} if statut is not None: payload["statut"] = statut return self._post("/sage/devis/list", payload).get("data", []) def changer_statut_devis(self, numero: str, nouveau_statut: int) -> Dict: """ ✅ CORRECTION: Utilise query params au lieu du body """ try: r = requests.post( f"{self.url}/sage/devis/statut", params={ "numero": numero, "nouveau_statut": nouveau_statut, }, headers=self.headers, timeout=self.timeout, ) r.raise_for_status() return r.json().get("data", {}) except requests.exceptions.RequestException as e: logger.error(f"❌ Erreur changement statut: {e}") raise # ===================================================== # DOCUMENTS GÉNÉRIQUES # ===================================================== def lire_document(self, numero: str, type_doc: int) -> Optional[Dict]: """Lecture d'un document générique""" 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: """ ✅ CORRECTION: Utilise query params pour la transformation """ try: r = requests.post( f"{self.url}/sage/documents/transform", params={ "numero_source": numero_source, "type_source": type_source, "type_cible": type_cible, }, headers=self.headers, timeout=60, ) r.raise_for_status() return r.json().get("data", {}) except requests.exceptions.RequestException as e: logger.error(f"❌ Erreur transformation: {e}") raise def mettre_a_jour_champ_libre( self, doc_id: str, type_doc: int, nom_champ: str, valeur: str ) -> bool: """Mise à jour d'un champ libre""" 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) # ===================================================== # COMMANDES (US-A2) # ===================================================== def lister_commandes( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: """ Utilise l'endpoint /sage/commandes/list qui filtre déjà sur type 10 """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut return self._post("/sage/commandes/list", payload).get("data", []) def lister_factures( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: """ ✅ Liste toutes les factures Utilise l'endpoint /sage/factures/list qui filtre déjà sur type 60 """ payload = {"limit": limit} if statut is not None: payload["statut"] = statut return self._post("/sage/factures/list", payload).get("data", []) def mettre_a_jour_derniere_relance(self, doc_id: str, type_doc: int) -> bool: """Met à jour le champ 'Dernière relance' d'une facture""" resp = self._post( "/sage/documents/derniere-relance", {"doc_id": doc_id, "type_doc": type_doc} ) return resp.get("success", False) # ===================================================== # CONTACTS (US-A6) # ===================================================== def lire_contact_client(self, code_client: str) -> Optional[Dict]: """Lecture du contact principal d'un client""" return self._post("/sage/contact/read", {"code": code_client}).get("data") # ===================================================== # REMISES (US-A5) # ===================================================== def lire_remise_max_client(self, code_client: str) -> float: """Récupère la remise max autorisée pour un client""" result = self._post("/sage/client/remise-max", {"code": code_client}) return result.get("data", {}).get("remise_max", 10.0) # ===================================================== # GÉNÉRATION PDF (pour email_queue) # ===================================================== def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: """Génère le PDF d'un document via la gateway Windows""" try: r = requests.post( f"{self.url}/sage/documents/generate-pdf", json={"doc_id": doc_id, "type_doc": type_doc}, headers=self.headers, timeout=60, ) r.raise_for_status() import base64 response_data = r.json() pdf_base64 = response_data.get("data", {}).get("pdf_base64", "") if not pdf_base64: raise ValueError("PDF vide retourné par la gateway") return base64.b64decode(pdf_base64) except Exception as e: logger.error(f"Erreur génération PDF: {e}") raise # ===================================================== # PROSPECTS # ===================================================== def lister_prospects(self, filtre: str = "") -> List[Dict]: """Liste tous les prospects avec filtre optionnel""" return self._post("/sage/prospects/list", {"filtre": filtre}).get("data", []) def lire_prospect(self, code: str) -> Optional[Dict]: """Lecture d'un prospect par code""" return self._post("/sage/prospects/get", {"code": code}).get("data") # ===================================================== # FOURNISSEURS # ===================================================== def lister_fournisseurs(self, filtre: str = "") -> List[Dict]: """Liste tous les fournisseurs avec filtre optionnel""" return self._post("/sage/fournisseurs/list", {"filtre": filtre}).get("data", []) def lire_fournisseur(self, code: str) -> Optional[Dict]: """Lecture d'un fournisseur par code""" return self._post("/sage/fournisseurs/get", {"code": code}).get("data") def creer_fournisseur(self, fournisseur_data: Dict) -> Dict: """ Envoie la requête de création de fournisseur à la gateway Windows. Args: fournisseur_data: Dict contenant intitule, compte_collectif, etc. Returns: Fournisseur créé avec son numéro définitif """ return self._post("/sage/fournisseurs/create", fournisseur_data).get("data", {}) def modifier_fournisseur(self, code: str, fournisseur_data: Dict) -> Dict: """ ✏️ Modification d'un fournisseur existant Args: code: Code du fournisseur à modifier fournisseur_data: Dictionnaire contenant les champs à modifier (seuls les champs présents seront mis à jour) Returns: Fournisseur modifié """ return self._post( "/sage/fournisseurs/update", {"code": code, "fournisseur_data": fournisseur_data}, ).get("data", {}) # ===================================================== # AVOIRS # ===================================================== def lister_avoirs( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: """Liste tous les avoirs""" payload = {"limit": limit} if statut is not None: payload["statut"] = statut return self._post("/sage/avoirs/list", payload).get("data", []) def lire_avoir(self, numero: str) -> Optional[Dict]: """Lecture d'un avoir avec ses lignes""" return self._post("/sage/avoirs/get", {"code": numero}).get("data") # ===================================================== # LIVRAISONS # ===================================================== def lister_livraisons( self, limit: int = 100, statut: Optional[int] = None ) -> List[Dict]: """Liste tous les bons de livraison""" payload = {"limit": limit} if statut is not None: payload["statut"] = statut return self._post("/sage/livraisons/list", payload).get("data", []) def lire_livraison(self, numero: str) -> Optional[Dict]: """Lecture d'une livraison avec ses lignes""" return self._post("/sage/livraisons/get", {"code": numero}).get("data") # ===================================================== # CACHE (ADMIN) # ===================================================== def refresh_cache(self) -> Dict: """Force le rafraîchissement du cache Windows""" return self._post("/sage/cache/refresh") def get_cache_info(self) -> Dict: """Récupère les infos du cache Windows""" return self._get("/sage/cache/info").get("data", {}) # ===================================================== # HEALTH # ===================================================== def health(self) -> dict: """Health check de la gateway Windows""" try: r = requests.get(f"{self.url}/health", timeout=5) return r.json() except: return {"status": "down"} def creer_client(self, client_data: Dict) -> Dict: """ Envoie la requête de création de client à la gateway Windows. :param client_data: Dict contenant intitule, compte_collectif, etc. """ # On appelle la route définie dans main.py return self._post("/sage/clients/create", client_data).get("data", {}) def modifier_client(self, code: str, client_data: Dict) -> Dict: """ ✏️ Modification d'un client existant Args: code: Code du client à modifier client_data: Dictionnaire contenant les champs à modifier (seuls les champs présents seront mis à jour) Returns: Client modifié """ return self._post( "/sage/clients/update", {"code": code, "client_data": client_data} ).get("data", {}) def modifier_devis(self, numero: str, devis_data: Dict) -> Dict: """ ✏️ Modification d'un devis existant Args: numero: Numéro du devis à modifier devis_data: Dictionnaire contenant les champs à modifier: - date_devis (str, optional): Nouvelle date - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut Returns: Devis modifié avec totaux recalculés """ return self._post( "/sage/devis/update", {"numero": numero, "devis_data": devis_data} ).get("data", {}) def creer_commande(self, commande_data: Dict) -> Dict: """ ➕ Création d'une nouvelle commande (Bon de commande) Args: commande_data: Dictionnaire contenant: - client_id (str): Code du client - date_commande (str, optional): Date au format ISO - reference (str, optional): Référence externe - lignes (List[Dict]): Liste des lignes avec: - article_code (str) - quantite (float) - prix_unitaire_ht (float, optional) - remise_pourcentage (float, optional) Returns: Commande créée avec son numéro et ses totaux """ return self._post("/sage/commandes/create", commande_data).get("data", {}) def modifier_commande(self, numero: str, commande_data: Dict) -> Dict: """ ✏️ Modification d'une commande existante Args: numero: Numéro de la commande à modifier commande_data: Dictionnaire contenant les champs à modifier: - date_commande (str, optional): Nouvelle date - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut - reference (str, optional): Nouvelle référence Returns: Commande modifiée avec totaux recalculés """ return self._post( "/sage/commandes/update", {"numero": numero, "commande_data": commande_data} ).get("data", {}) def creer_livraison(self, livraison_data: Dict) -> Dict: """ ➕ Création d'une nouvelle livraison (Bon de livraison) """ return self._post("/sage/livraisons/create", livraison_data).get("data", {}) def modifier_livraison(self, numero: str, livraison_data: Dict) -> Dict: """ ✏️ Modification d'une livraison existante """ return self._post( "/sage/livraisons/update", {"numero": numero, "livraison_data": livraison_data}, ).get("data", {}) def creer_avoir(self, avoir_data: Dict) -> Dict: """ ➕ Création d'un avoir (Bon d'avoir) Args: avoir_data: Dictionnaire contenant: - client_id (str): Code du client - date_avoir (str, optional): Date au format ISO - reference (str, optional): Référence externe - lignes (List[Dict]): Liste des lignes avec: - article_code (str) - quantite (float) - prix_unitaire_ht (float, optional) - remise_pourcentage (float, optional) Returns: Avoir créé avec son numéro et ses totaux """ return self._post("/sage/avoirs/create", avoir_data).get("data", {}) def modifier_avoir(self, numero: str, avoir_data: Dict) -> Dict: """ ✏️ Modification d'un avoir existant Args: numero: Numéro de l'avoir à modifier avoir_data: Dictionnaire contenant les champs à modifier: - date_avoir (str, optional): Nouvelle date - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut - reference (str, optional): Nouvelle référence Returns: Avoir modifié avec totaux recalculés """ return self._post( "/sage/avoirs/update", {"numero": numero, "avoir_data": avoir_data} ).get("data", {}) def creer_facture(self, facture_data: Dict) -> Dict: """ ➕ Création d'une facture Args: facture_data: Dictionnaire contenant: - client_id (str): Code du client - date_facture (str, optional): Date au format ISO - reference (str, optional): Référence externe - lignes (List[Dict]): Liste des lignes avec: - article_code (str) - quantite (float) - prix_unitaire_ht (float, optional) - remise_pourcentage (float, optional) Returns: Facture créée avec son numéro et ses totaux """ return self._post("/sage/factures/create", facture_data).get("data", {}) def modifier_facture(self, numero: str, facture_data: Dict) -> Dict: """ ✏️ Modification d'une facture existante Args: numero: Numéro de la facture à modifier facture_data: Dictionnaire contenant les champs à modifier: - date_facture (str, optional): Nouvelle date - lignes (List, optional): Nouvelles lignes - statut (int, optional): Nouveau statut - reference (str, optional): Nouvelle référence Returns: Facture modifiée avec totaux recalculés """ return self._post( "/sage/factures/update", {"numero": numero, "facture_data": facture_data} ).get("data", {}) def generer_pdf_document(self, doc_id: str, type_doc: int) -> bytes: """ 🆕 Génère le PDF d'un document via la gateway Windows (route généralisée) **Cette méthode remplace les appels spécifiques par type de document** Args: doc_id: Numéro du document (ex: "DE00001", "FA00001") type_doc: Type de document Sage: - 0: Devis - 10: Bon de commande - 30: Bon de livraison - 60: Facture - 50: Bon d'avoir Returns: bytes: Contenu du PDF (binaire) Raises: ValueError: Si le PDF retourné est vide RuntimeError: Si erreur de communication avec la gateway Example: >>> pdf_bytes = sage_client.generer_pdf_document("DE00001", 0) >>> with open("devis.pdf", "wb") as f: ... f.write(pdf_bytes) """ try: logger.info(f"📄 Demande génération PDF: doc_id={doc_id}, type={type_doc}") # Appel HTTP vers la gateway Windows r = requests.post( f"{self.url}/sage/documents/generate-pdf", json={"doc_id": doc_id, "type_doc": type_doc}, headers=self.headers, timeout=60, # Timeout élevé pour génération PDF ) r.raise_for_status() import base64 response_data = r.json() # Vérifier que la réponse contient bien le PDF if not response_data.get("success"): error_msg = response_data.get("error", "Erreur inconnue") raise RuntimeError(f"Gateway a retourné une erreur: {error_msg}") pdf_base64 = response_data.get("data", {}).get("pdf_base64", "") if not pdf_base64: raise ValueError( f"PDF vide retourné par la gateway pour {doc_id} (type {type_doc})" ) # Décoder le base64 pdf_bytes = base64.b64decode(pdf_base64) logger.info(f"✅ PDF décodé: {len(pdf_bytes)} octets") return pdf_bytes except requests.exceptions.Timeout: logger.error(f"⏱️ Timeout génération PDF pour {doc_id}") raise RuntimeError( f"Timeout lors de la génération du PDF (>60s). " f"Le document {doc_id} est peut-être trop volumineux." ) except requests.exceptions.RequestException as e: logger.error(f"❌ Erreur HTTP génération PDF: {e}") raise RuntimeError(f"Erreur de communication avec la gateway: {str(e)}") except Exception as e: logger.error(f"❌ Erreur génération PDF: {e}", exc_info=True) raise def creer_article(self, article_data: Dict) -> Dict: """ ➕ Création d'un article Args: article_data: Dictionnaire contenant: - reference (str, obligatoire): Référence article - designation (str, obligatoire): Désignation - prix_vente (float, optionnel): Prix vente HT - stock_reel (float, optionnel): Stock initial - ... (voir ArticleCreateRequest dans main.py) Returns: Article créé Example: >>> article = sage_client.creer_article({ ... "reference": "ART001", ... "designation": "Article test", ... "prix_vente": 10.0, ... "stock_reel": 100.0 ... }) """ return self._post("/sage/articles/create", article_data).get("data", {}) def modifier_article(self, reference: str, article_data: Dict) -> Dict: """ ✏️ Modification d'un article **Usage critique**: Augmenter le stock pour résoudre l'erreur 2881 Args: reference: Référence de l'article à modifier article_data: Dictionnaire contenant les champs à modifier: - stock_reel (float, optionnel): Nouveau stock - prix_vente (float, optionnel): Nouveau prix - ... (seuls les champs présents seront mis à jour) Returns: Article modifié Example - Résoudre erreur de stock: >>> # L'erreur 2881 indique un stock insuffisant >>> sage_client.modifier_article("ART001", { ... "stock_reel": 100.0 # Augmenter le stock ... }) """ return self._post( "/sage/articles/update", {"reference": reference, "article_data": article_data} ).get("data", {}) # Instance globale sage_client = SageGatewayClient()