From b619915ac12c9b55df9425ca6525511769a9b9c8 Mon Sep 17 00:00:00 2001 From: Fanilo-Nantenaina Date: Mon, 8 Dec 2025 18:08:01 +0300 Subject: [PATCH] feat: Add caching for sales quotes, orders, invoices, and suppliers with associated refresh logic and concurrency locks. --- sage_connector.py | 961 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 949 insertions(+), 12 deletions(-) diff --git a/sage_connector.py b/sage_connector.py index 022cf0f..f6fcf33 100644 --- a/sage_connector.py +++ b/sage_connector.py @@ -29,24 +29,49 @@ class SageConnector: self.mot_de_passe = mot_de_passe self.cial = None - # Caches existants + # ✅ Caches existants self._cache_clients: List[Dict] = [] self._cache_articles: List[Dict] = [] self._cache_clients_dict: Dict[str, Dict] = {} self._cache_articles_dict: Dict[str, Dict] = {} - # Métadonnées cache existantes + # ✅ NOUVEAUX CACHES + self._cache_devis: List[Dict] = [] + self._cache_commandes: List[Dict] = [] + self._cache_factures: List[Dict] = [] + self._cache_fournisseurs: List[Dict] = [] + + self._cache_devis_dict: Dict[str, Dict] = {} + self._cache_commandes_dict: Dict[str, Dict] = {} + self._cache_factures_dict: Dict[str, Dict] = {} + self._cache_fournisseurs_dict: Dict[str, Dict] = {} + + # ✅ Métadonnées cache existantes self._cache_clients_last_update: Optional[datetime] = None self._cache_articles_last_update: Optional[datetime] = None + + # ✅ NOUVELLES MÉTADONNÉES + self._cache_devis_last_update: Optional[datetime] = None + self._cache_commandes_last_update: Optional[datetime] = None + self._cache_factures_last_update: Optional[datetime] = None + self._cache_fournisseurs_last_update: Optional[datetime] = None + self._cache_ttl_minutes = 15 # Thread d'actualisation self._refresh_thread: Optional[threading.Thread] = None self._stop_refresh = threading.Event() - # Locks + # ✅ Locks existants self._lock_clients = threading.RLock() self._lock_articles = threading.RLock() + + # ✅ NOUVEAUX LOCKS + self._lock_devis = threading.RLock() + self._lock_commandes = threading.RLock() + self._lock_factures = threading.RLock() + self._lock_fournisseurs = threading.RLock() + self._lock_com = threading.RLock() # Thread-local storage pour COM @@ -112,10 +137,14 @@ class SageConnector: logger.info(f"✅ Connexion Sage réussie: {self.chemin_base}") - # Chargement initial du cache - logger.info("📦 Chargement initial du cache...") + # ✅ Chargement initial des caches + logger.info("📦 Chargement initial des caches...") self._refresh_cache_clients() self._refresh_cache_articles() + self._refresh_cache_devis() + self._refresh_cache_commandes() + self._refresh_cache_factures() + self._refresh_cache_fournisseurs() # Démarrage du thread d'actualisation self._start_refresh_thread() @@ -155,6 +184,7 @@ class SageConnector: while not self._stop_refresh.is_set(): time.sleep(60) + # ✅ Caches existants # Clients if self._cache_clients_last_update: age = datetime.now() - self._cache_clients_last_update @@ -167,6 +197,31 @@ class SageConnector: if age.total_seconds() > self._cache_ttl_minutes * 60: self._refresh_cache_articles() + # ✅ NOUVEAUX CACHES + # Devis + if self._cache_devis_last_update: + age = datetime.now() - self._cache_devis_last_update + if age.total_seconds() > self._cache_ttl_minutes * 60: + self._refresh_cache_devis() + + # Commandes + if self._cache_commandes_last_update: + age = datetime.now() - self._cache_commandes_last_update + if age.total_seconds() > self._cache_ttl_minutes * 60: + self._refresh_cache_commandes() + + # Factures + if self._cache_factures_last_update: + age = datetime.now() - self._cache_factures_last_update + if age.total_seconds() > self._cache_ttl_minutes * 60: + self._refresh_cache_factures() + + # Fournisseurs + if self._cache_fournisseurs_last_update: + age = datetime.now() - self._cache_fournisseurs_last_update + if age.total_seconds() > self._cache_ttl_minutes * 60: + self._refresh_cache_fournisseurs() + finally: pythoncom.CoUninitialize() @@ -293,6 +348,585 @@ class SageConnector: except Exception as e: logger.error(f" Erreur refresh articles: {e}", exc_info=True) + def _refresh_cache_devis(self): + """Actualise le cache des devis AVEC leurs lignes""" + if not self.cial: + return + + devis_list = [] + devis_dict = {} + + try: + with self._com_context(), self._lock_com: + factory = self.cial.FactoryDocumentVente + index = 1 + erreurs_consecutives = 0 + max_erreurs = 50 + + logger.info("🔄 Actualisation cache devis (avec lignes)...") + + while index < 10000 and erreurs_consecutives < max_erreurs: + try: + persist = factory.List(index) + if persist is None: + break + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + # Filtrer uniquement les devis (type 0) + if getattr(doc, "DO_Type", -1) != 0: + index += 1 + continue + + numero = getattr(doc, "DO_Piece", "") + if not numero: + index += 1 + continue + + # Charger client + client_code = "" + client_intitule = "" + try: + client_obj = getattr(doc, "Client", None) + if client_obj: + client_obj.Read() + client_code = getattr(client_obj, "CT_Num", "").strip() + client_intitule = getattr( + client_obj, "CT_Intitule", "" + ).strip() + except: + pass + + # ✅ CHARGER LES LIGNES + lignes = [] + try: + factory_lignes = getattr(doc, "FactoryDocumentLigne", None) + if not factory_lignes: + factory_lignes = getattr( + doc, "FactoryDocumentVenteLigne", None + ) + + if factory_lignes: + ligne_index = 1 + while ligne_index <= 100: + try: + ligne_persist = factory_lignes.List(ligne_index) + if ligne_persist is None: + break + + ligne = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) + ligne.Read() + + # Charger référence article + article_ref = "" + try: + article_ref = getattr( + ligne, "AR_Ref", "" + ).strip() + if not article_ref: + article_obj = getattr( + ligne, "Article", None + ) + if article_obj: + article_obj.Read() + article_ref = getattr( + article_obj, "AR_Ref", "" + ).strip() + except: + pass + + lignes.append( + { + "article": article_ref, + "designation": getattr( + ligne, "DL_Design", "" + ), + "quantite": float( + getattr(ligne, "DL_Qte", 0.0) + ), + "prix_unitaire": float( + getattr( + ligne, "DL_PrixUnitaire", 0.0 + ) + ), + "montant_ht": float( + getattr(ligne, "DL_MontantHT", 0.0) + ), + } + ) + + ligne_index += 1 + except: + break + except Exception as e: + logger.debug( + f"Erreur chargement lignes devis {numero}: {e}" + ) + + data = { + "numero": numero, + "date": str(getattr(doc, "DO_Date", "")), + "client_code": client_code, + "client_intitule": client_intitule, + "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), + "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), + "statut": getattr(doc, "DO_Statut", 0), + "lignes": lignes, # ✅ LIGNES INCLUSES + } + + devis_list.append(data) + devis_dict[numero] = data + erreurs_consecutives = 0 + index += 1 + + except: + erreurs_consecutives += 1 + index += 1 + if erreurs_consecutives >= max_erreurs: + break + + with self._lock_devis: + self._cache_devis = devis_list + self._cache_devis_dict = devis_dict + self._cache_devis_last_update = datetime.now() + + logger.info( + f"✅ Cache devis actualisé: {len(devis_list)} devis (avec lignes)" + ) + + except Exception as e: + logger.error(f"❌ Erreur refresh devis: {e}", exc_info=True) + + def _refresh_cache_commandes(self): + """Actualise le cache des commandes AVEC leurs lignes""" + if not self.cial: + return + + commandes_list = [] + commandes_dict = {} + + try: + with self._com_context(), self._lock_com: + factory = self.cial.FactoryDocumentVente + index = 1 + erreurs_consecutives = 0 + max_erreurs = 50 + + logger.info("🔄 Actualisation cache commandes (avec lignes)...") + + while index < 10000 and erreurs_consecutives < max_erreurs: + try: + persist = factory.List(index) + if persist is None: + break + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + # Filtrer uniquement les commandes (type 10) + if ( + getattr(doc, "DO_Type", -1) + != settings.SAGE_TYPE_BON_COMMANDE + ): + index += 1 + continue + + numero = getattr(doc, "DO_Piece", "") + if not numero: + index += 1 + continue + + # Charger client + client_code = "" + client_intitule = "" + try: + client_obj = getattr(doc, "Client", None) + if client_obj: + client_obj.Read() + client_code = getattr(client_obj, "CT_Num", "").strip() + client_intitule = getattr( + client_obj, "CT_Intitule", "" + ).strip() + except: + pass + + # ✅ CHARGER LES LIGNES + lignes = [] + try: + factory_lignes = getattr(doc, "FactoryDocumentLigne", None) + if not factory_lignes: + factory_lignes = getattr( + doc, "FactoryDocumentVenteLigne", None + ) + + if factory_lignes: + ligne_index = 1 + while ligne_index <= 100: + try: + ligne_persist = factory_lignes.List(ligne_index) + if ligne_persist is None: + break + + ligne = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) + ligne.Read() + + # Charger référence article + article_ref = "" + try: + article_ref = getattr( + ligne, "AR_Ref", "" + ).strip() + if not article_ref: + article_obj = getattr( + ligne, "Article", None + ) + if article_obj: + article_obj.Read() + article_ref = getattr( + article_obj, "AR_Ref", "" + ).strip() + except: + pass + + lignes.append( + { + "article": article_ref, + "designation": getattr( + ligne, "DL_Design", "" + ), + "quantite": float( + getattr(ligne, "DL_Qte", 0.0) + ), + "prix_unitaire": float( + getattr( + ligne, "DL_PrixUnitaire", 0.0 + ) + ), + "montant_ht": float( + getattr(ligne, "DL_MontantHT", 0.0) + ), + } + ) + + ligne_index += 1 + except: + break + except Exception as e: + logger.debug( + f"Erreur chargement lignes commande {numero}: {e}" + ) + + data = { + "numero": numero, + "reference": getattr(doc, "DO_Ref", ""), + "date": str(getattr(doc, "DO_Date", "")), + "client_code": client_code, + "client_intitule": client_intitule, + "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), + "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), + "statut": getattr(doc, "DO_Statut", 0), + "lignes": lignes, # ✅ LIGNES INCLUSES + } + + commandes_list.append(data) + commandes_dict[numero] = data + erreurs_consecutives = 0 + index += 1 + + except: + erreurs_consecutives += 1 + index += 1 + if erreurs_consecutives >= max_erreurs: + break + + with self._lock_commandes: + self._cache_commandes = commandes_list + self._cache_commandes_dict = commandes_dict + self._cache_commandes_last_update = datetime.now() + + logger.info( + f"✅ Cache commandes actualisé: {len(commandes_list)} commandes (avec lignes)" + ) + + except Exception as e: + logger.error(f"❌ Erreur refresh commandes: {e}", exc_info=True) + + def _refresh_cache_factures(self): + """Actualise le cache des factures AVEC leurs lignes""" + if not self.cial: + return + + factures_list = [] + factures_dict = {} + + try: + with self._com_context(), self._lock_com: + factory = self.cial.FactoryDocumentVente + index = 1 + erreurs_consecutives = 0 + max_erreurs = 50 + + logger.info("🔄 Actualisation cache factures (avec lignes)...") + + while index < 10000 and erreurs_consecutives < max_erreurs: + try: + persist = factory.List(index) + if persist is None: + break + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + # Filtrer uniquement les factures (type 60) + if getattr(doc, "DO_Type", -1) != settings.SAGE_TYPE_FACTURE: + index += 1 + continue + + numero = getattr(doc, "DO_Piece", "") + if not numero: + index += 1 + continue + + # Charger client + client_code = "" + client_intitule = "" + try: + client_obj = getattr(doc, "Client", None) + if client_obj: + client_obj.Read() + client_code = getattr(client_obj, "CT_Num", "").strip() + client_intitule = getattr( + client_obj, "CT_Intitule", "" + ).strip() + except: + pass + + # ✅ CHARGER LES LIGNES + lignes = [] + try: + factory_lignes = getattr(doc, "FactoryDocumentLigne", None) + if not factory_lignes: + factory_lignes = getattr( + doc, "FactoryDocumentVenteLigne", None + ) + + if factory_lignes: + ligne_index = 1 + while ligne_index <= 100: + try: + ligne_persist = factory_lignes.List(ligne_index) + if ligne_persist is None: + break + + ligne = win32com.client.CastTo( + ligne_persist, "IBODocumentLigne3" + ) + ligne.Read() + + # Charger référence article + article_ref = "" + try: + article_ref = getattr( + ligne, "AR_Ref", "" + ).strip() + if not article_ref: + article_obj = getattr( + ligne, "Article", None + ) + if article_obj: + article_obj.Read() + article_ref = getattr( + article_obj, "AR_Ref", "" + ).strip() + except: + pass + + lignes.append( + { + "article": article_ref, + "designation": getattr( + ligne, "DL_Design", "" + ), + "quantite": float( + getattr(ligne, "DL_Qte", 0.0) + ), + "prix_unitaire": float( + getattr( + ligne, "DL_PrixUnitaire", 0.0 + ) + ), + "montant_ht": float( + getattr(ligne, "DL_MontantHT", 0.0) + ), + } + ) + + ligne_index += 1 + except: + break + except Exception as e: + logger.debug( + f"Erreur chargement lignes facture {numero}: {e}" + ) + + data = { + "numero": numero, + "reference": getattr(doc, "DO_Ref", ""), + "date": str(getattr(doc, "DO_Date", "")), + "client_code": client_code, + "client_intitule": client_intitule, + "total_ht": float(getattr(doc, "DO_TotalHT", 0.0)), + "total_ttc": float(getattr(doc, "DO_TotalTTC", 0.0)), + "statut": getattr(doc, "DO_Statut", 0), + "lignes": lignes, # ✅ LIGNES INCLUSES + } + + factures_list.append(data) + factures_dict[numero] = data + erreurs_consecutives = 0 + index += 1 + + except: + erreurs_consecutives += 1 + index += 1 + if erreurs_consecutives >= max_erreurs: + break + + with self._lock_factures: + self._cache_factures = factures_list + self._cache_factures_dict = factures_dict + self._cache_factures_last_update = datetime.now() + + logger.info( + f"✅ Cache factures actualisé: {len(factures_list)} factures (avec lignes)" + ) + + except Exception as e: + logger.error(f"❌ Erreur refresh factures: {e}", exc_info=True) + + def _refresh_cache_fournisseurs(self): + """ + Actualise le cache des fournisseurs + ✅ CORRECTION : Utilise maintenant la logique complète de lecture + """ + if not self.cial: + return + + fournisseurs_list = [] + fournisseurs_dict = {} + + try: + with self._com_context(), self._lock_com: + logger.info("🔄 Actualisation cache fournisseurs...") + + factory = self.cial.CptaApplication.FactoryFournisseur + index = 1 + max_iterations = 10000 + erreurs_consecutives = 0 + max_erreurs = 50 + + while index < max_iterations and erreurs_consecutives < max_erreurs: + try: + persist = factory.List(index) + + if persist is None: + logger.debug(f"Fin de liste à l'index {index}") + break + + # Cast + fourn = self._cast_client(persist) + + if fourn: + # ✅ EXTRACTION DIRECTE (même logique que lister_tous_fournisseurs) + try: + numero = getattr(fourn, "CT_Num", "").strip() + if not numero: + logger.debug(f"Index {index}: CT_Num vide, skip") + erreurs_consecutives += 1 + index += 1 + continue + + intitule = getattr(fourn, "CT_Intitule", "").strip() + + # Construction objet minimal + data = { + "numero": numero, + "intitule": intitule, + "type": 1, # Fournisseur + "est_fournisseur": True, + } + + # Champs optionnels (avec gestion d'erreur) + try: + adresse_obj = getattr(fourn, "Adresse", None) + if adresse_obj: + data["adresse"] = getattr( + adresse_obj, "Adresse", "" + ).strip() + data["code_postal"] = getattr( + adresse_obj, "CodePostal", "" + ).strip() + data["ville"] = getattr( + adresse_obj, "Ville", "" + ).strip() + except: + data["adresse"] = "" + data["code_postal"] = "" + data["ville"] = "" + + try: + telecom_obj = getattr(fourn, "Telecom", None) + if telecom_obj: + data["telephone"] = getattr( + telecom_obj, "Telephone", "" + ).strip() + data["email"] = getattr( + telecom_obj, "EMail", "" + ).strip() + except: + data["telephone"] = "" + data["email"] = "" + + fournisseurs_list.append(data) + fournisseurs_dict[numero] = data + erreurs_consecutives = 0 + + except Exception as e: + logger.debug(f"⚠️ Erreur extraction index {index}: {e}") + erreurs_consecutives += 1 + else: + erreurs_consecutives += 1 + + index += 1 + + except Exception as e: + logger.debug(f"⚠️ Erreur index {index}: {e}") + erreurs_consecutives += 1 + index += 1 + + if erreurs_consecutives >= max_erreurs: + logger.warning( + f"⚠️ Arrêt après {max_erreurs} erreurs consécutives" + ) + break + + with self._lock_fournisseurs: + self._cache_fournisseurs = fournisseurs_list + self._cache_fournisseurs_dict = fournisseurs_dict + self._cache_fournisseurs_last_update = datetime.now() + + logger.info( + f"✅ Cache fournisseurs actualisé: {len(fournisseurs_list)} fournisseurs" + ) + + except Exception as e: + logger.error(f"❌ Erreur refresh fournisseurs: {e}", exc_info=True) + def lister_tous_fournisseurs(self, filtre=""): """ ✅ CORRECTION FINALE : Liste fournisseurs SANS passer par _extraire_client() @@ -1042,10 +1676,6 @@ class SageConnector: logger.error(f"❌ Erreur lecture fournisseur {code}: {e}") return None - # ========================================================================= - # API PUBLIQUE (ultra-rapide grâce au cache) - # ========================================================================= - def lister_tous_clients(self, filtre=""): """Retourne les clients depuis le cache (instantané)""" with self._lock_clients: @@ -1084,14 +1714,103 @@ class SageConnector: with self._lock_articles: return self._cache_articles_dict.get(reference) + # --- DEVIS (CACHE) --- + def lister_tous_devis_cache(self, filtre=""): + """Retourne les devis depuis le cache (instantané)""" + with self._lock_devis: + if not filtre: + return self._cache_devis.copy() + filtre_lower = filtre.lower() + return [ + d + for d in self._cache_devis + if filtre_lower in d["numero"].lower() + or filtre_lower in d.get("client_intitule", "").lower() + ] + + def lire_devis_cache(self, numero): + """Retourne un devis depuis le cache (instantané) - SANS lignes""" + with self._lock_devis: + return self._cache_devis_dict.get(numero) + + # --- COMMANDES (CACHE) --- + def lister_toutes_commandes_cache(self, filtre=""): + """Retourne les commandes depuis le cache (instantané)""" + with self._lock_commandes: + if not filtre: + return self._cache_commandes.copy() + filtre_lower = filtre.lower() + return [ + c + for c in self._cache_commandes + if filtre_lower in c["numero"].lower() + or filtre_lower in c.get("client_intitule", "").lower() + ] + + def lire_commande_cache(self, numero): + """Retourne une commande depuis le cache (instantané) - SANS lignes""" + with self._lock_commandes: + return self._cache_commandes_dict.get(numero) + + # --- FACTURES (CACHE) --- + def lister_toutes_factures_cache(self, filtre=""): + """Retourne les factures depuis le cache (instantané)""" + with self._lock_factures: + if not filtre: + return self._cache_factures.copy() + filtre_lower = filtre.lower() + return [ + f + for f in self._cache_factures + if filtre_lower in f["numero"].lower() + or filtre_lower in f.get("client_intitule", "").lower() + ] + + def lire_facture_cache(self, numero): + """Retourne une facture depuis le cache (instantané) - SANS lignes""" + with self._lock_factures: + return self._cache_factures_dict.get(numero) + + # --- FOURNISSEURS (CACHE) --- + def lister_tous_fournisseurs_cache(self, filtre=""): + """Retourne les fournisseurs depuis le cache (instantané)""" + with self._lock_fournisseurs: + if not filtre: + return self._cache_fournisseurs.copy() + filtre_lower = filtre.lower() + return [ + f + for f in self._cache_fournisseurs + if filtre_lower in f["numero"].lower() + or filtre_lower in f["intitule"].lower() + ] + + def lire_fournisseur_cache(self, code): + """Retourne un fournisseur depuis le cache (instantané)""" + with self._lock_fournisseurs: + return self._cache_fournisseurs_dict.get(code) + + # --- UTILITAIRES CACHE --- def forcer_actualisation_cache(self): """Force l'actualisation immédiate du cache (endpoint admin)""" - logger.info("🔄 Actualisation forcée du cache...") + # ... (existant) + + def get_cache_info(self): + """Retourne les infos du cache (endpoint monitoring)""" + # ... (existant, déjà mis à jour avec les nouveaux caches) + + def forcer_actualisation_cache(self): + """Force l'actualisation immédiate du cache (endpoint admin)""" + logger.info("🔄 Actualisation forcée de TOUS les caches...") + self._refresh_cache_clients() self._refresh_cache_articles() - logger.info("✅ Cache actualisé") + self._refresh_cache_devis() + self._refresh_cache_commandes() + self._refresh_cache_factures() + self._refresh_cache_fournisseurs() - logger.info("Cache actualisé") + logger.info("✅ Tous les caches actualisés") def get_cache_info(self): """Retourne les infos du cache (endpoint monitoring)""" @@ -1129,6 +1848,69 @@ class SageConnector: else None ), }, + # ✅ NOUVEAUX CACHES + "devis": { + "count": len(self._cache_devis), + "last_update": ( + self._cache_devis_last_update.isoformat() + if self._cache_devis_last_update + else None + ), + "age_minutes": ( + (datetime.now() - self._cache_devis_last_update).total_seconds() + / 60 + if self._cache_devis_last_update + else None + ), + }, + "commandes": { + "count": len(self._cache_commandes), + "last_update": ( + self._cache_commandes_last_update.isoformat() + if self._cache_commandes_last_update + else None + ), + "age_minutes": ( + ( + datetime.now() - self._cache_commandes_last_update + ).total_seconds() + / 60 + if self._cache_commandes_last_update + else None + ), + }, + "factures": { + "count": len(self._cache_factures), + "last_update": ( + self._cache_factures_last_update.isoformat() + if self._cache_factures_last_update + else None + ), + "age_minutes": ( + ( + datetime.now() - self._cache_factures_last_update + ).total_seconds() + / 60 + if self._cache_factures_last_update + else None + ), + }, + "fournisseurs": { + "count": len(self._cache_fournisseurs), + "last_update": ( + self._cache_fournisseurs_last_update.isoformat() + if self._cache_fournisseurs_last_update + else None + ), + "age_minutes": ( + ( + datetime.now() - self._cache_fournisseurs_last_update + ).total_seconds() + / 60 + if self._cache_fournisseurs_last_update + else None + ), + }, } info["ttl_minutes"] = self._cache_ttl_minutes @@ -6431,3 +7213,158 @@ class SageConnector: except Exception as e: logger.error(f"❌ Erreur technique: {e}", exc_info=True) raise RuntimeError(f"Erreur Sage: {str(e)}") + + def generer_pdf_document(self, numero: str, type_doc: int) -> bytes: + """ + 📄 Génère le PDF d'un document via les états Sage + + **Utilise les états Sage Crystal Reports pour générer les PDF** + + Args: + numero: Numéro du document (ex: "DE00001", "FA00001") + type_doc: Type de document Sage (0-60) + + Returns: + bytes: Contenu du PDF (binaire brut) + + Process: + 1. Charge le document depuis Sage + 2. Identifie l'état Crystal Reports approprié + 3. Génère le PDF via l'état + 4. Retourne le binaire + + Raises: + ValueError: Si le document n'existe pas + RuntimeError: Si erreur Sage lors de la génération + """ + if not self.cial: + raise RuntimeError("Connexion Sage non établie") + + try: + with self._com_context(), self._lock_com: + logger.info(f"📄 Génération PDF: {numero} (type={type_doc})") + + # ======================================== + # ÉTAPE 1 : CHARGER LE DOCUMENT + # ======================================== + factory = self.cial.FactoryDocumentVente + + # Essayer ReadPiece + persist = factory.ReadPiece(type_doc, numero) + + if not persist: + # Fallback: chercher dans List() + logger.debug(f"ReadPiece échoué, recherche dans List()...") + persist = self._find_document_in_list(numero, type_doc) + + if not persist: + raise ValueError(f"Document {numero} (type {type_doc}) introuvable") + + doc = win32com.client.CastTo(persist, "IBODocumentVente3") + doc.Read() + + logger.info(f"✅ Document chargé: {numero}") + + # ======================================== + # ÉTAPE 2 : IDENTIFIER L'ÉTAT CRYSTAL + # ======================================== + # Mapping des types vers les noms d'états Sage + etats_sage = { + 0: "VT_DEVIS.RPT", # Devis + 10: "VT_CMDE.RPT", # Bon de commande + 20: "VT_PREP.RPT", # Préparation + 30: "VT_BL.RPT", # Bon de livraison + 40: "VT_BR.RPT", # Bon de retour + 50: "VT_AVOIR.RPT", # Bon d'avoir + 60: "VT_FACT.RPT", # Facture + } + + etat_nom = etats_sage.get(type_doc) + + if not etat_nom: + raise ValueError(f"Type de document non supporté: {type_doc}") + + logger.info(f"📋 État Sage: {etat_nom}") + + # ======================================== + # ÉTAPE 3 : GÉNÉRER LE PDF VIA L'ÉTAT + # ======================================== + try: + # Accéder au gestionnaire d'états + factory_etat = self.cial.FactoryEtat + + # Charger l'état + etat = factory_etat.ReadNom(etat_nom) + + if not etat: + raise RuntimeError(f"État {etat_nom} non trouvé dans Sage") + + # Paramétrer l'état + etat.Destination = 6 # 6 = PDF + etat.Preview = False # Pas de prévisualisation + + # Définir le fichier de sortie temporaire + import tempfile + import os + + temp_dir = tempfile.gettempdir() + pdf_filename = f"sage_pdf_{numero}_{int(time.time())}.pdf" + pdf_path = os.path.join(temp_dir, pdf_filename) + + etat.FileName = pdf_path + + # Définir le filtre (seulement ce document) + etat.Selection = f"{{F_DOCENTETE.DO_Piece}} = '{numero}'" + + logger.info(f"📁 Fichier temporaire: {pdf_path}") + + # Exécuter l'état + logger.info("🔄 Exécution état Crystal...") + etat.Start() + + # Attendre que le fichier soit créé + max_wait = 30 # 30 secondes max + waited = 0 + + while not os.path.exists(pdf_path) and waited < max_wait: + time.sleep(0.5) + waited += 0.5 + + if not os.path.exists(pdf_path): + raise RuntimeError( + f"Le fichier PDF n'a pas été créé après {max_wait}s" + ) + + # Lire le fichier PDF + with open(pdf_path, "rb") as f: + pdf_bytes = f.read() + + logger.info(f"✅ PDF lu: {len(pdf_bytes)} octets") + + # Nettoyer le fichier temporaire + try: + os.remove(pdf_path) + logger.debug(f"🗑️ Fichier temporaire supprimé") + except: + pass + + if len(pdf_bytes) == 0: + raise RuntimeError("Le PDF généré est vide") + + logger.info( + f"✅✅✅ PDF GÉNÉRÉ: {numero} ({len(pdf_bytes)} octets) ✅✅✅" + ) + + return pdf_bytes + + except Exception as e: + logger.error(f"❌ Erreur génération état: {e}", exc_info=True) + raise RuntimeError(f"Erreur génération PDF: {str(e)}") + + except ValueError as e: + logger.error(f"❌ Erreur métier: {e}") + raise + + except Exception as e: + logger.error(f"❌ Erreur technique: {e}", exc_info=True) + raise RuntimeError(f"Erreur Sage: {str(e)}")